Plugin API

Plugins interact with Play via the Host object exposed at runtime:

window.__PLAY_HOST__

This page enumerates the callable surface. For semantics, constraints, and examples, see Plugin contract.

Registration

A plugin module is loaded via dynamic import() and must export one of:

  • registerPlayPlugin(host): named export

  • a default export function (host) => disposer

The register function may return:

  • a disposer function () => void, or

  • an object with dispose(): void

Host object v1

The Host API is a plain JavaScript object. This TypeScript-like shape is meant as a developer reference:

Notes:

  • The Host object is frozen (Object.freeze) to discourage plugins from mutating the contract surface.

  • Plugins that need to stash state should use host.extensions.

type PanelId = "left" | "right";
type UiMount =
  | "leftPanel" | "rightPanel" | "overlayRoot"
  | "leftPanelAfterFilePlugin" | "leftPanelPlugin" | "rightPanelPlugin";

interface ViewerControlsApi {
  // Debug/inspection helpers. Not part of the stable plugin contract.
  getBinding(id: string): unknown | null;

  // Convenience helpers around the built-in UI spec.
  listIds(prefix?: string): string[];
  toggleControl(id: string, value?: unknown): Promise<void> | void;
  getControl(id: string): UiControl | null;
  loadXmlTextAsModel(xmlText: string, label?: string): Promise<void> | void;
}

interface ViewerRendererApi {
  getStats(): Record<string, unknown>;
  getContext(): unknown | null;
  ensureLoop(): void;
  renderScene(snapshot: unknown, state: unknown): void;
  getOverlay3D(): unknown | null;
  overlay3d: {
    get(): unknown | null;
    createScope(scopeId: string, options?: unknown): unknown | null;
    getScope(scopeId: string): unknown | null;
  };
}

interface ViewerStore<State = unknown> {
  get(): State;
  replace(next: State): void;
  update(mutator: (state: State) => void): void; // mutates in-place
  subscribe(fn: (state: State) => void): () => void;
}

interface UiPanelApi {
  root: HTMLElement | null;
  collapseAll(): { changed: number; collapsed: boolean };
  expandAll(): { changed: number; collapsed: boolean };
  toggleAll(): { changed: number; collapsed: boolean | null };
}

interface UiSectionHandle {
  panel: PanelId;
  sectionId: string;
  sectionEl: HTMLElement;
  body: HTMLElement;
  setCollapsed(collapsed: boolean): void;
  collapse(): void;
  expand(): void;
  toggle(): void;
  dispose(): void;
}

interface UiSectionSpec {
  panel?: PanelId;
  sectionId: string;   // must start with "plugin:"
  title?: string;
  defaultOpen?: boolean;
  after?: string;
  before?: string;
  mount?: UiMount;
  render(body: HTMLElement, ctx: { panel: PanelId; sectionId: string; sectionEl: HTMLElement; body: HTMLElement; host: PlayHostV1 }): (void | (() => void) | { dispose(): void });
}

interface UiSectionsApi {
  register(spec: UiSectionSpec): UiSectionHandle;
  unregister(sectionId: string): boolean;
  get(sectionId: string): UiSectionHandle | null;
  list(): string[];
}

interface UiKit {
  namedRow(labelText: string, options?: { full?: boolean; half?: boolean }): { row: HTMLElement; label: HTMLLabelElement; field: HTMLElement };
  fullRow(options?: { half?: boolean }): { row: HTMLElement; field: HTMLElement };
  button(options: { label: string; variant?: "primary" | "secondary" | "pill"; testId?: string | null; onClick?: (ev: MouseEvent) => void }): HTMLButtonElement;
  textbox(options?: { value?: string; placeholder?: string; testId?: string | null; onInput?: (ev: Event, value: string) => void; onChange?: (ev: Event, value: string) => void }): HTMLInputElement;
  textarea(options?: { value?: string; placeholder?: string; rows?: number; variant?: "default" | "code"; testId?: string | null; onInput?: (ev: Event, value: string) => void; onChange?: (ev: Event, value: string) => void }): HTMLTextAreaElement;
  select(options?: { value?: string; options?: string[]; testId?: string | null; onChange?: (ev: Event, value: string) => void }): HTMLSelectElement;
  number(options?: { value?: number; min?: number; max?: number; step?: number; testId?: string | null; onInput?: (ev: Event, value: number) => void; onChange?: (ev: Event, value: number) => void }): HTMLInputElement;
  range(options?: { value?: number; min?: number; max?: number; step?: number; testId?: string | null; onInput?: (ev: Event, value: number) => void; onChange?: (ev: Event, value: number) => void }): HTMLInputElement;
  segmented(options: { options: Array<{ value: string; label: string }>; value: string; testId?: string | null; onChange?: (ev: Event, value: string) => void }): { root: HTMLElement; inputs: HTMLInputElement[]; value(): string; setValue(v: string): void };
  codebox(options?: { value?: string; testId?: string | null }): HTMLElement;
  boolButton(options: { label: string; value: boolean; disabled?: boolean; testId?: string | null; onChange?: (ev: Event, value: boolean) => void }): { root: HTMLElement; input: HTMLInputElement; text: HTMLElement };
}

interface UiApi {
  panel(panel: PanelId): UiPanelApi;
  sections: UiSectionsApi;
  kit: UiKit;
}

interface PlayClockApi<Snapshot = unknown, State = unknown> {
  onUiTick(fn: (ctx: { snapshot: Snapshot | null; state: State; nowMs: number }) => void): () => void;
  onUiMainTick(fn: (ctx: { snapshot: Snapshot | null; state: State; nowMs: number }) => void): () => void; // alias of onUiTick
  onUiControlsTick(fn: (ctx: { snapshot: Snapshot | null; state: State; nowMs: number }) => void): () => void;
  onUiSlowTick(fn: (ctx: { snapshot: Snapshot | null; state: State; nowMs: number }) => void): () => void;
  onSnapshot(fn: (ctx: { snapshot: Snapshot; state: State; nowMs: number }) => void): () => void;
  onFrame(fn: (ctx: unknown) => void): () => void;
}

interface PlayHostV1 {
  apiVersion: 1;
  contract: { apiVersion: 1 };
  capabilities: {
    mounts: boolean;
    ui: boolean;
    store: boolean;
    backend: boolean;
    controls: boolean;
    renderer: boolean;
    clock: boolean;
    overlay3d: boolean;
  };
  getCapability(name: string): boolean;
  extensions: Record<string, unknown>;
  mounts: {
    leftPanel: HTMLElement | null;
    rightPanel: HTMLElement | null;
    overlayRoot: HTMLElement | null;
    leftPanelAfterFilePlugin: HTMLElement | null;
    leftPanelPlugin: HTMLElement | null;
    rightPanelPlugin: HTMLElement | null;
  };
  ui: UiApi;
  store: ViewerStore;
  backend: unknown;  // see :doc:`/api_reference/js_api`
  controls: ViewerControlsApi;
  renderer: ViewerRendererApi;
  getSnapshot(): unknown | null;
  clock: PlayClockApi;
  logStatus(message: string, extra?: unknown): void;
  logWarn(message: string, extra?: unknown): void;
  logError(message: string, extra?: unknown): void;
  strictCatch(err: unknown, context: string, options?: { allow?: boolean }): unknown;
}

Viewer state types

The viewer store state is a large object. A TypeScript definition is committed for tooling and serves as the most complete “field list” reference:

// Auto-generated by tools/generate_ui_artifacts.mjs. Do not edit by hand.

// Type definitions for the runtime state helpers located in viewer_state.mjs.
// This file lets TypeScript-aware tooling reason about the viewer store while
// keeping the browser-consumable implementation in plain JS.

export interface OverlayState {
  help: boolean;
  info: boolean;
  profiler: boolean;
  sensor: boolean;
  fullscreen: boolean;
  vsync: boolean;
  busywait: boolean;
  pauseUpdate: boolean;
}

export interface SimulationState {
  run: boolean;
  scrubIndex: number;
  keyIndex: number;
  realTimeIndex: number;
}

export interface SelectionState {
  geom: number;
  body: number;
  joint: number;
  name: string;
  kind: string;
  point: [number, number, number];
  localPoint: [number, number, number];
  normal: [number, number, number];
  seq: number;
  timestamp: number;
}

export interface PerturbState {
  mode: string;
  active: boolean;
}

export interface RuntimeState {
  cameraIndex: number;
  cameraLabel: string;
  trackingGeom: number;
  lastAction: string;
  gesture: GestureState;
  drag: DragState;
  selection: SelectionState;
  perturb: PerturbState;
  lastAlign: AlignRuntimeState;
  lastCopy: CopyRuntimeState;
}

export interface PanelState {
  left: boolean;
  right: boolean;
}

export interface PhysicsState {
  disableFlags: Record<string, boolean>;
  enableFlags: Record<string, boolean>;
  actuatorGroups: Record<string, boolean>;
}

export interface ThemeState {
  color: number;
  spacing: number;
  font: number;
}

export interface ViewerGroupState {
  geom: boolean[];
  site: boolean[];
  joint: boolean[];
  tendon: boolean[];
  actuator: boolean[];
  flex: boolean[];
  skin: boolean[];
}

export interface RenderingOptionsState {
  materials: { forceBasic: boolean };
  instancing: { enabled: boolean };
  transparency: { bins: number; sortMode: string };
}

export interface RenderingAppearanceState {
  [key: string]: unknown;
}

export interface RenderingState {
  voptFlags: boolean[];
  sceneFlags: boolean[];
  labelMode: number;
  frameMode: number;
  flexLayer: number;
  bvhDepth: number;
  assets: unknown | null;
  groups: ViewerGroupState;
  hideAllGeometry: boolean;
  appearance: RenderingAppearanceState;
  options: RenderingOptionsState;
}

export interface HudState {
  time: number;
  frames: number;
  fps: number;
  rate: number;
  measuredSlowdown: number;
  ngeom: number;
  contacts: number;
  pausedSource: string;
  rateSource: string;
  modelLabel: string;
  info: Record<string, unknown> | null;
}

export interface HistoryState {
  captureHz: number;
  capacity: number;
  count: number;
  horizon: number;
  scrubIndex: number;
  live: boolean;
}

export interface WatchState {
  field: string;
  index: number;
  value: number | null;
  min: number | null;
  max: number | null;
  samples: number;
  status: string;
  valid: boolean;
  summary: string;
  sources: Record<string, { length?: number; label?: string }>;
}

export interface KeyframeState {
  capacity: number;
  count: number;
  labels: string[];
  slots: Array<{ index: number; label: string; kind: string; available: boolean }>;
  lastSaved: number;
  lastLoaded: number;
}

export interface ModelState {
  opt: Record<string, unknown>;
  vis: Record<string, unknown>;
  stat: Record<string, unknown>;
  visDefaults: Record<string, unknown>;
  cameras: Array<Record<string, unknown>>;
  geoms: Array<Record<string, unknown>>;
  ctrl: number[];
  optSupport: { supported: boolean; pointers: string[] };
  [key: string]: unknown;
}

export interface ToastState {
  message: string;
  ts: number;
}

export interface DragState {
  dx: number;
  dy: number;
}

export interface GesturePointer {
  x: number;
  y: number;
  dx: number;
  dy: number;
  buttons: number;
  pressure: number;
}

export interface GestureState {
  mode: string;
  phase: string;
  pointer?: GesturePointer | null;
}

export interface AlignRuntimeState {
  seq: number;
  center: [number, number, number];
  radius: number;
  timestamp: number;
  source: string;
}

export interface CopyRuntimeState {
  seq: number;
  precision: string;
  nq: number;
  nv: number;
  timestamp: number;
  qposPreview: number[];
  qvelPreview: number[];
  complete: boolean;
}

export interface VisualBackupsState {
  model: Record<string, unknown> | null;
  presetSun: Record<string, unknown> | null;
  presetMoon: Record<string, unknown> | null;
  sceneFlagsModel: boolean[] | null;
  sceneFlagsPresetSun: boolean[] | null;
  sceneFlagsPresetMoon: boolean[] | null;
  lightActiveModel: number[] | null;
  lightActivePresetSun: number[] | null;
  lightActivePresetMoon: number[] | null;
  appearanceModel: Record<string, unknown> | null;
  appearancePresetSun: Record<string, unknown> | null;
  appearancePresetMoon: Record<string, unknown> | null;
}

export interface VisualBaselinesState {
  model: Record<string, unknown> | null;
  sceneFlagsModel: boolean[] | null;
  presetSun: Record<string, unknown> | null;
  presetMoon: Record<string, unknown> | null;
  sceneFlagsPresetSun: boolean[] | null;
  sceneFlagsPresetMoon: boolean[] | null;
  lightActiveModel: number[] | null;
  lightActivePresetSun: number[] | null;
  lightActivePresetMoon: number[] | null;
  appearanceModel: Record<string, unknown> | null;
  appearancePresetSun: Record<string, unknown> | null;
  appearancePresetMoon: Record<string, unknown> | null;
}

export interface ViewerState {
  overlays: OverlayState;
  simulation: SimulationState;
  runtime: RuntimeState;
  model: ModelState;
  theme: ThemeState;
  panels: PanelState;
  physics: PhysicsState;
  rendering: RenderingState;
  hud: HudState;
  toast: ToastState | null;
  history: HistoryState;
  watch: WatchState;
  keyframes: KeyframeState;
  visualSourceMode: 'model' | 'preset-sun' | 'preset-moon';
  visualBackups: VisualBackupsState;
  visualBaselines: VisualBaselinesState;
}

export interface UiControl {
  item_id: string;
  type: string;
  label?: string;
  name?: string;
  binding?: string;
  options?: string[] | string;
  default?: unknown;
  shortcut?: string[] | null;
}

export interface ViewerStore {
  get(): ViewerState;
  replace(state: Partial<ViewerState> | ViewerState): void;
  update(mutator: (draft: ViewerState) => void): void;
  subscribe(listener: (state: ViewerState) => void): () => void;
}

export interface BackendUiApplyPayload {
  kind: 'ui';
  id: string;
  value: unknown;
  control: UiControl;
}

export interface GestureApplyPayload {
  kind: 'gesture';
  mode: string;
  phase?: string;
  pointer?: Partial<GesturePointer>;
  drag?: Partial<DragState>;
}

export type BackendApplyPayload = BackendUiApplyPayload | GestureApplyPayload;

export interface BackendSnapshot {
  t: number;
  rate: number;
  measuredSlowdown?: number;
  paused: boolean;
  ngeom: number;
  nq?: number;
  nv?: number;
  pausedSource?: string;
  rateSource?: string;
  gesture?: GestureState;
  drag?: DragState;
  voptFlags?: number[];
  sceneFlags?: number[];
  labelMode?: number;
  frameMode?: number;
  cameraMode?: number;
  frameId?: number | null;
  visual?: Record<string, unknown> | null;
  visualDefaults?: Record<string, unknown> | null;
  statistic?: Record<string, unknown> | null;
  visualVersion?: number;
  visualDefaultsVersion?: number;
  statisticVersion?: number;
  optionSupport?: { supported: boolean; pointers: string[] } | null;
  renderAssets?: unknown | null;
  groups?: ViewerGroupState | null;
  cameras?: Array<Record<string, unknown>> | null;
  geoms?: Array<Record<string, unknown>> | null;
  geom_bodyid?: Int32Array | number[] | null;
  body_parentid?: Int32Array | number[] | null;
  body_jntadr?: Int32Array | number[] | null;
  body_jntnum?: Int32Array | number[] | null;
  jtype?: Int32Array | number[] | null;
  nbody?: number;
  njnt?: number;
  scn_ngeom?: number;
  nisland?: number;
  info?: Record<string, unknown> | null;
  contacts?: { n?: number; [key: string]: unknown } | null;
  align?: AlignRuntimeState | null;
  copyState?: CopyRuntimeState | null;
  history?: {
    captureHz?: number;
    capacity?: number;
    count?: number;
    horizon?: number;
    scrubIndex?: number;
    live?: boolean;
  } | null;
  keyframes?: {
    capacity?: number;
    count?: number;
    labels?: string[];
    slots?: Array<{ index?: number; label?: string; kind?: string; available?: boolean }>;
    lastSaved?: number;
    lastLoaded?: number;
  } | null;
  watch?: {
    field?: string;
    index?: number;
    value?: number | null;
    min?: number | null;
    max?: number | null;
    samples?: number;
    status?: string;
    valid?: boolean;
    summary?: string;
  } | null;
  watchSources?: Record<string, { length?: number; label?: string }>;
  keyIndex?: number;
}

export interface ViewerBackend {
  kind: string;
  apply(payload: BackendApplyPayload): Promise<BackendSnapshot | undefined> | BackendSnapshot | undefined;
  snapshot(): Promise<BackendSnapshot> | BackendSnapshot;
  subscribe(listener: (snapshot: BackendSnapshot) => void): () => void;
  step?(direction?: number): Promise<BackendSnapshot | undefined> | BackendSnapshot | undefined;
  setCameraIndex?(index: number): Promise<BackendSnapshot | undefined> | BackendSnapshot | undefined;
  setRunState?(run: boolean, source?: string): Promise<BackendSnapshot | undefined> | BackendSnapshot | undefined;
  setRate?(rate: number, source?: string): Promise<BackendSnapshot | undefined> | BackendSnapshot | undefined;
  setVisualState?(payload: { visual?: Record<string, unknown> | null; sceneFlags?: boolean[] | null }): Promise<BackendSnapshot | undefined> | BackendSnapshot | undefined;
  dispose?(): void;
}

export {
  DEFAULT_VIEWER_STATE,
  createViewerStore,
  applySpecAction,
  applyGesture,
  createBackend,
  readControlValue,
  cameraLabelFromIndex,
  mergeBackendSnapshot,
  switchVisualSourceMode,
} from './state.mjs';