# Migrating a configurator app from runtime 1.x to contract 2.0

> Contract 2.0.0 · package sha256 `a857eca95b35921109faf997094bb4192f12527d9d0fcd4be7e97f06425bad6e` · generated from `migration.json` and `conformance/port-usage.json` by `services/onsite-scripter/scripts/build-contract-docs.mjs`; edit the source, not this file.

Every 1.x member (contract 1.10), every internal pattern the apps reach and every version-1 viewer-document key, each with one disposition (`migration.json`, 258 rows: 55 kept, 32 removed, 138 replaced, 33 runtime-owned). Each replaced row carries a recipe that compiles against the 2.0 declaration under the apps' strict settings (139 recipes); a recipe takes the 1.x values as their 1.x types (`V1`, the v1 tenant declaration) and calls the 2.0 contract.

- **kept**: same name and meaning (shape changes noted in `as` and `recipe`).
- **replaced**: removed, with the 2.0 member (`replacement`) and a compiled recipe (`code`).
- **runtime-owned**: the runtime does it; the app deletes its code or setting.
- **removed**: no successor, with the reason (`recipe`).

Port each use through its row, then delete the hatch file, every engine import in `src/` and the dead code. A gap goes back into the rc, never around it: no casts, no element or shadow-root reads, no engine import, no hatch "for now".

"Used by" names the apps whose 1.x source or paper port uses the row (`conformance/port-usage.json`, 28 apps, configurators `be828d5d`).

## Contents

- [Not built in 2.0.0](#not-built)
- [C.1 Boot, contract and basic types](#c.1)
- [C.2 Stored-format types (vendored into `/contract`; replace `@holo/interfaces` imports)](#c.2)
- [C.3 Session members](#c.3)
- [C.4 Events (`TenantEventMap` → `Events`)](#c.4)
- [C.5 Internals of the native session (absent from 2.0, D5)](#c.5)
- [C.6 Hatch-level patterns (what apps reach through `runtime-escape-hatches.ts`)](#c.6)
- [C.7 Version-1 viewer-document keys (D50)](#c.7)
- [C.8 From a RegExp to globs (M16)](#c.8)
- [B.4 Runtime-owned guarantees](#b-4)
- [The fleet cross-check](#fleet-cross-check)

<a id="not-built"></a>
## Not built in 2.0.0

What 2.0.0 doesn't do yet. Every form the declaration names is built: the four stage forms it first shipped without (a panorama `background`, `environment: 'room'`, `ground.shadow.on` and a `stage.set` `fadeMs` above 0) draw on the runtime and the fake since F1a (DL64), and their `supports()` ids answer true.

2.0.0 doesn't have these rules yet, on the runtime and the fake alike: both reach the same outcome, so a port's unit test agrees with the browser. Write the app for the outcome below until a 2.x release builds the rule.

- a rule an `onFrame` hook writes shows one frame later, not in the frame that wrote it: hide a cut wall with `Frame.visible` in the same hook, which draws in that frame, and expect its ghost rules a frame after
- a rule staged in a `transaction` after a staged scene write that adds its part reports `matched: 0` and `pending: true`, and applies when the transaction commits
- a rule written for a resolved `Collection.load` item while a queued scene write still prepares counts that item at the call, so aborting the load afterwards records no `target-unmatched`; behind an open transaction it does
- only a stored `MaterialNode` that a scene write shows (`select`, `setSceneModel`, `applyPatch`) is ready at its colour maps, its detail maps (normal, roughness, metallic, occlusion, ORM) landing after the commit; a rule's material maps are waited for whole, so a rule with a slow normal map commits once that map has decoded
- a rule that wears a created `Material` waits for none of its maps, and a map of it that fails is in no write's `Report`
- a capture after a detail map failed is `completed` and settled with the neutral stand-in in that slot, and lists no `asset-failed` in its `problems`
- an `ambientOcclusion` or `bloom` object a layer writes over a merged `true` or `false` is drawn with the document's fields it omits, but `stage.get()` reads the layer's object alone: read the document's own values for those fields

<a id="c.1"></a>
## C.1 Boot, contract and basic types

<a id="c.1_boot"></a>
### C.1/boot: `boot(options)`

**kept** · written for 1.x 1.5

add `contract: '2.0'`; a renderer that can't start rejects `HoloBootError` (1.4 rethrew the raw error, 1.5 a `BootError`)

Used by: app-04c2774145, app-0c773a4ee3, app-37356bf09f, app-378d190f1b, app-4762caacdc, app-4f16d2f6da, app-4fd23bc869, app-510d750508, app-59c5b5751e, app-822bb9d6aa, app-8675b3225c, app-b362a81c85, app-b4ae2fce25, app-bcb7ef21da, app-c1384707fc, app-ceb4eccc72, app-ddfb017681, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f3598fad77, app-f9a32142f1, app-fb16dd1985, app-fc07850558, app-fc306ec3dc, app-fd09090420, configurator-template.

<a id="c.1_bootoptions.el"></a>
### C.1/BootOptions.el: `BootOptions.el`, `.accountId`, `.configuratorId`, `.document`, `.signal`

**kept** · written for 1.x 1.5

`.document` is the page layer: it merges over the platform defaults and under the account's defaults and the configurator's stored document, which win, as on 1.x since production `8211ec9a30` (before it, the page was the top layer)

Used by: app-04c2774145, app-0c773a4ee3, app-37356bf09f, app-378d190f1b, app-4762caacdc, app-4f16d2f6da, app-4fd23bc869, app-510d750508, app-59c5b5751e, app-822bb9d6aa, app-8675b3225c, app-b362a81c85, app-b4ae2fce25, app-bcb7ef21da, app-c1384707fc, app-ceb4eccc72, app-ddfb017681, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f3598fad77, app-f9a32142f1, app-fb16dd1985, app-fc07850558, app-fc306ec3dc, app-fd09090420, configurator-template.

<a id="c.1_booterror"></a>
### C.1/BootError: `BootError` (1.5)

**replaced** (replaced by `HoloBootError`) · 2.0: [`HoloBootError`](api/HoloBootError.md) · written for 1.x 1.5

`e.name === 'BootError'` → `e instanceof HoloBootError`; `reason` and `cause` keep their meaning, and `message` is now a plain sentence for `reason` (the underlying text stays in `cause`); `'error'` → `'other'`

Used by: no app in the cross-check.

```ts
/** 1.x `BootError`: inspect the typed boot rejection. */
export function bootErrorDetails(
  error: unknown,
): { reason: HoloBootError['reason']; message: string; cause: unknown } | null {
  if (!(error instanceof HoloBootError)) return null;
  return { reason: error.reason, message: error.message, cause: error.cause };
}
```

<a id="c.1_bootfailurereason"></a>
### C.1/BootFailureReason: `BootFailureReason` (1.5)

**replaced** · 2.0: [`HoloBootError`](api/HoloBootError.md) · written for 1.x 1.5

the closed output union `HoloBootError['reason']`: `'no-webgl2'`, `'webgl-context'` and `'disconnected'` kept; `'error'` and the open `(string & {})` tail → `'other'` (3.6). runtime-client's `bootFailureReason` drops its message-text fallback (`packages/runtime-client/src/boot-failure.ts:43-46` at `342b26ce`) and keeps its `!env.hasWebGl2` check (`:42`): a boot that ran rejects `HoloBootError`, but an entry that failed to import does not (5.1), so that check still sends a browser that can't parse the entry to `'no-webgl2'`'s advice

Used by: no app in the cross-check.

```ts
/** 1.x `BootFailureReason`: keep the browser check when the entry cannot import. */
export function bootFailureReason(
  error: unknown,
  env: { hasWebGl2: boolean },
): HoloBootError['reason'] {
  if (!env.hasWebGl2) return 'no-webgl2';
  return error instanceof HoloBootError ? error.reason : 'other';
}
```

<a id="c.1_bootoptions.viewerline"></a>
### C.1/BootOptions.viewerLine: `BootOptions.viewerLine`

**removed** · written for 1.x 1.5

`contract` declares compatibility; the viewer line is diagnostic only

Used by: app-04c2774145, app-0c773a4ee3, app-37356bf09f, app-378d190f1b, app-4762caacdc, app-4f16d2f6da, app-4fd23bc869, app-510d750508, app-59c5b5751e, app-822bb9d6aa, app-8675b3225c, app-b362a81c85, app-b4ae2fce25, app-bcb7ef21da, app-c1384707fc, app-ceb4eccc72, app-ddfb017681, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f3598fad77, app-f9a32142f1, app-fb16dd1985, app-fc07850558, app-fc306ec3dc, app-fd09090420, configurator-template.

<a id="c.1_runtime_contract"></a>
### C.1/RUNTIME_CONTRACT: `RUNTIME_CONTRACT`, `RUNTIME_VERSION`, `RuntimeContract`

**kept** · written for 1.x 1.5

`apiVersion` is `'2.0.x'`

Used by: no app in the cross-check.

<a id="c.1_point3"></a>
### C.1/Point3: `Point3`, `ReadonlyPoint3`, `ScreenPoint`, `ProjectedPoint`, `JsonValue`

**kept** · written for 1.x 1.5

—

Used by: app-37356bf09f, app-4f16d2f6da, app-4fd23bc869, app-822bb9d6aa, app-8675b3225c, app-b362a81c85, app-b4ae2fce25, app-bcb7ef21da, app-c1384707fc, app-ddfb017681, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f3598fad77, app-fb16dd1985, app-fc07850558, app-fc306ec3dc, app-fd09090420, configurator-template.

<a id="c.1_bounds"></a>
### C.1/Bounds: `Bounds`

**kept** · written for 1.x 1.5

—

Used by: no app in the cross-check.

<a id="c.1_boundsoptions"></a>
### C.1/BoundsOptions: `BoundsOptions`

**replaced** · 2.0: [`Session.find`](api/Writer.find.md), [`Parts.bounds`](api/PartActions.bounds.md) · written for 1.x 1.5

`camera.getBounds({ nodeIds })` → `session.find(nodeIds.map((node) => ({ node }))).bounds()`

Used by: no app in the cross-check.

```ts
/**
 * `camera.getBounds({ nodeIds })`: the bounds of those models' parts; without `nodeIds`, of every
 * scene-model part (role 'product'). Null when nothing matched.
 */
export function boundsOf(session: Session, options: V1.BoundsOptions = {}): Bounds | null {
  const query = options.nodeIds
    ? options.nodeIds.map((node) => ({ node }))
    : { role: 'product' as const };
  return session.find(query).bounds();
}
```

<a id="c.1_capability"></a>
### C.1/Capability: `Capability`

**kept** · written for 1.x 1.5

—

Used by: app-bcb7ef21da, app-e3b47169bc, app-e61a0b42ad, app-f07e9033d9, app-fb16dd1985, app-fd09090420, configurator-template.

<a id="c.1_knowncapability"></a>
### C.1/KnownCapability: `KnownCapability`

**replaced** · 2.0: [`KnownCapability`](api/KnownCapability.md), [`Session.supports`](api/Session.supports.md) · written for 1.x 1.5

the 2.0 union, id by id: `'history.transaction'` → `'history.group'`; `'interaction.pick'` → `'input.pick'`; `'interaction.setDraggable'` → `'input.drag'`; `'interaction.snapTo'` → `'assembly.attach'`; `'interaction.pickGround'` → none (`camera.ray` plus `/math`, always present); `'overlays.anchors'`, `'overlays.hotspots'` kept; `'overlays.project'` → none (`camera.project` is always present); `'idle'`, `'camera.getPose'`, `'camera.fitTo'`, `'camera.getBounds'` → none (`idle()`, `camera.state`, `camera.fit`, `find().bounds()` are always present: delete the guard); `'events.*'` → none (events are typed). A stale id still compiles, answers `false` and records `unknown-capability` (3.7)

Used by: no app in the cross-check.

```ts
/**
 * 1.x `KnownCapability`: rename guarded capabilities; ask `supports` for the ids 2.0 keeps under
 * their own name (a runtime may not build each, as 1.x's answered `tryOn.mode.handoff` false);
 * always-present members need no guard.
 */
export function capabilityAvailable(session: Session, id: V1.KnownCapability): boolean {
  switch (id) {
    case 'history.transaction':
      return session.supports('history.group');
    case 'interaction.pick':
      return session.supports('input.pick');
    case 'interaction.setDraggable':
      return session.supports('input.drag');
    case 'interaction.snapTo':
      return session.supports('assembly.attach');
    case 'overlays.anchors':
    case 'overlays.hotspots':
    case 'activateAR.overlayRoot':
    case 'activateAR.ui.headless':
    case 'canTryOn':
    case 'endAR':
    case 'tryOn':
    case 'tryOn.mode.handoff':
      return session.supports(id);
    default:
      return true;
  }
}
```

<a id="c.1_commandreport"></a>
### C.1/CommandReport: `CommandReport`

**replaced** (replaced by `Report`) · 2.0: [`Report`](api/Report.md) · written for 1.x 1.5

`ok` → `r.outcome === 'committed' && r.problems.length === 0`; `failed` → `r.problems`; `superseded` → `r.outcome === 'superseded'`

Used by: no app in the cross-check.

```ts
/** 1.x `CommandReport.ok`, `.failed`, `.superseded`: read the unified report. */
export function commandResult(r: Report): {
  ok: boolean;
  failed: readonly Problem[];
  superseded: boolean;
} {
  return {
    ok: r.outcome === 'committed' && r.problems.length === 0,
    failed: r.problems,
    superseded: r.outcome === 'superseded',
  };
}
```

<a id="c.1_assetfailure"></a>
### C.1/AssetFailure: `AssetFailure`

**replaced** · 2.0: [`Problem`](api/Problem.md), [`ReportFields.problems`](api/ReportFields.problems.md) · written for 1.x 1.5

a `Problem` with code `'asset-failed'`, `url`, `node` and `asset`, in `Report.problems`: `kind` → `asset` (`'material'` → `'texture'`); `nodeId` → `node`; `materialNodeId` → `node` (a MaterialNode is a scene-model node)

Used by: no app in the cross-check.

```ts
/** 1.x `AssetFailure`: asset failures now live in `Report.problems`. */
export function assetFailures(report: Report): readonly Problem[] {
  return report.problems.filter((problem) => problem.code === 'asset-failed');
}
```

<a id="c.1_problem"></a>
### C.1/Problem: `Problem`

**kept** (kept (changed)) · written for 1.x 1.5

`nodeId` → `node`; `pluginId` removed (no plugins); `docs`, `op`, `didYouMean` added

Used by: no app in the cross-check.

<a id="c.1_problemcode"></a>
### C.1/ProblemCode: `ProblemCode`

**replaced** · 2.0: [`ProblemCode`](api/ProblemCode.md), [`Problem`](api/Problem.md) · written for 1.x 1.5

`'three-duplicate'` → `'engine-conflict'`; `'viewer-line-mismatch'` removed with `BootOptions.viewerLine` (a bootstrap that refuses an entry of another major rejects `HoloBootError` `'other'`, 5.1); `'viewer-disconnected'` → `'host-disconnected'`; `'model-failed'`, `'material-failed'` → `'asset-failed'`; `` `apply:${string}` `` (1.x's prefix on each problem of the viewer's apply report, which carries the key's `path`: `rt/session.ts:216-222` at `eb5ce2afe`) → `'document-error'` with its JSON `path` and `layer`, while `'apply-failed'` (kept) is a failure inside the runtime, with `layer` and no `path`; `'plugin-missing'`, `'plugin-disabled'`, `'setter-failed'` removed (no plugins or setters); `'checkpoint-rejected'` removed (`history.checkpoint` is gone: an oversized `checkpoint` option rejects `HoloInputError('checkpoint-rejected')` and records `'invalid-input'` at `/checkpoint`); `'transaction-timeout'` now means a transaction rollback; the rest kept

1.x codes: `apply-failed` → `apply-failed`; `apply:${string}` → `document-error`; `checkpoint-rejected` → none; `defaults-unavailable` → `defaults-unavailable`; `deprecated` → `deprecated`; `document-error` → `document-error`; `handler-async` → `handler-async`; `handler-rejected` → `handler-rejected`; `handler-threw` → `handler-threw`; `hotspot-id-taken` → `hotspot-id-taken`; `invalid-input` → `invalid-input`; `material-failed` → `asset-failed`; `model-failed` → `asset-failed`; `plugin-disabled` → none; `plugin-missing` → none; `setter-failed` → none; `sync-failed` → `sync-failed`; `tenant-document-absent` → `tenant-document-absent`; `tenant-document-unavailable` → `tenant-document-unavailable`; `three-duplicate` → `engine-conflict`; `transaction-timeout` → `transaction-timeout`; `unknown-boot-option` → `unknown-boot-option`; `unknown-event` → `unknown-event`; `viewer-disconnected` → `host-disconnected`; `viewer-line-mismatch` → none; `xr-${string}` → none.

Used by: no app in the cross-check.

```ts
/** 1.x `ProblemCode`: translate codes and retain document paths and layers. */
export function migrateProblem(
  problem: V1.Problem,
  layer: string,
): Omit<Problem, 'at' | 'docs'> | null {
  const { code, message } = problem;
  if (code.startsWith('apply:'))
    return { code: 'document-error', message, path: problem.path, layer };
  switch (code) {
    case 'three-duplicate':
      return { code: 'engine-conflict', message };
    case 'viewer-disconnected':
      return { code: 'host-disconnected', message };
    case 'model-failed':
      return { code: 'asset-failed', asset: 'model', node: problem.nodeId, message };
    case 'material-failed':
      return { code: 'asset-failed', asset: 'texture', node: problem.nodeId, message };
    case 'viewer-line-mismatch':
    case 'plugin-missing':
    case 'plugin-disabled':
    case 'setter-failed':
    case 'checkpoint-rejected':
      return null;
    case 'document-error':
      return { code, message, path: problem.path, layer };
    case 'apply-failed':
      return { code, message, layer };
    default:
      return { code: code as ProblemCode, message };
  }
}
```

<a id="c.1_inforeport"></a>
### C.1/InfoReport: `InfoReport`

**kept** (kept (changed)) · written for 1.x 1.5

`three` removed → `renderer.engineConflict`; `viewer` removed (diagnostic of an internal); `renderer`, `device` and `ar` (the AR modes this device can open, before the tap) added

Used by: no app in the cross-check.

<a id="c.1_nodetarget"></a>
### C.1/NodeTarget: `NodeTarget`

**replaced** · 2.0: [`Query`](api/Query.md) · written for 1.x 1.5

`{ nodeId }` → the query `{ node: nodeId }`

Used by: app-04c2774145, app-37356bf09f, app-4762caacdc, app-ceb4eccc72, app-f9a32142f1.

```ts
/** 1.x `NodeTarget { nodeId }`: use a node query. */
export function nodeTarget(target: V1.NodeTarget): Query {
  return { node: target.nodeId };
}
```

<a id="c.1_nodereference"></a>
### C.1/NodeReference: `NodeReference`

**replaced** · 2.0: [`Hit.part`](api/Hit.part.md), [`Part.node`](api/Part.node.md), [`Part.name`](api/Part.name.md) · written for 1.x 1.5

`{ nodeId, name }` → `hit.part.node`, `hit.part.name`

Used by: no app in the cross-check.

```ts
/** 1.x `NodeReference { nodeId, name }`: read the hit part. */
export function nodeReference(hit: Hit): V1.NodeReference {
  return { nodeId: hit.part.node, name: hit.part.name };
}
```

<a id="c.1_nodepose"></a>
### C.1/NodePose: `NodePose`

**replaced** (replaced by `Pose`) · 2.0: [`Pose`](api/Pose.md), [`Part.worldPose`](api/Part.worldPose.md), `vec3`, `angle` · written for 1.x 1.5

`position` → `session.node(nodeId).worldPose.position`; the radians field `rotationY` → the yaw about +Y of `session.node(nodeId).worldPose.quaternion`: `angle.radToDeg(Math.atan2(f.x, f.z))` with `f = vec3.applyQuat({ x: 0, y: 0, z: 1 }, quaternion)`, which keeps a 180° yaw at 180°; `rotationDeg.y` and `quat.toEulerDeg` decompose X then Y then Z, so a 180° yaw reads (180, 0, 180) and its y reads 0 (app-37356bf09f's 1.4 test `src/__tests__/runtime/drag-commit.test.ts:72` at `be828d5d`)

Used by: no app in the cross-check.

```ts
/** 1.x `NodePose.position` and `rotationY`: read world position and the forward-vector yaw. */
export function nodeWorldPose(
  session: Session,
  nodeId: string,
): { position: ReadonlyPoint3; yawDeg: number } {
  const { position, quaternion } = session.node(nodeId).worldPose;
  const f = vec3.applyQuat({ x: 0, y: 0, z: 1 }, quaternion);
  return { position, yawDeg: angle.radToDeg(Math.atan2(f.x, f.z)) };
}
```

<a id="c.2"></a>
## C.2 Stored-format types (vendored into `/contract`; replace `@holo/interfaces` imports)

configurators' `tenant-api` keeps its own types-only copy of these stored-format types, taken from runtime-client's vendored contract so that it needs no workspace dependency (`apps/tenant-api/src/core/catalog/configurator.ts:1` at `c4c6ffd2`). It reads stored documents into catalogs and spreads them through, so the 2.0 fields lose no data there, but the copy is re-copied from the 2.0 `/contract` in the commit of the first port that stores `connections` and `connectionsBasis`, or its types drift. Its date pass maps `node.children` unguarded (`configurator-dates.ts:26-32`), so the re-copy also guards a node without `children`, which 2.0 makes optional.

<a id="c.2_iconfigurator"></a>
### C.2/IConfigurator: `IConfigurator`

**kept** · written for 1.x 1.5

gains top-level `connections` and `connectionsBasis`. `hdrUrl`, `hdrName` and `toneMapping` stay storage only: the runtime never reads them, as 1.4's scene model left `hdrUrl` out (`configurator-runtime/tenant-types.ts:454-464` at `eb5ce2afe`) and its viewer took the curve from the viewer document alone; an app that wants that HDR or curve writes `stage.set` or its page document. So app-e3b47169bc's `/test-viewer/minimal` page, whose stored studio HDR and `'AGX'` 1.4 never applied either (`apps/app-e3b47169bc/src/app/test-viewer/minimal/page.tsx:26-27` at `be828d5d`), renders as before. `backgroundColor` is read: a non-null value is the scene model's background layer, as 1.x's `setSceneModel` set it (`BackgroundValue`, 5.5)

Used by: no app in the cross-check.

<a id="c.2_iconfiguratornode"></a>
### C.2/IConfiguratorNode: `IConfiguratorNode`

**kept** (kept (changed)) · written for 1.x 1.5

`nodeId` becomes required (`normalizeNodeIds` repairs old saves, and takes a loose input type, so no cast). Also: `_id`, `label`, `order`, `expanded`, `createdAt`, `updatedAt`, `children` and `attributes` become optional, and `createdAt`/`updatedAt` become `Date | string`. Code that read them as always present gets a compile error to fix

Used by: no app in the cross-check.

<a id="c.2_imodelnode"></a>
### C.2/IModelNode: `IModelNode`

**kept** · written for 1.x 1.5

gains `space` (labs, runtime-derived models only); `dek`, `encryption`, `keyRef` leave the type and every output; `plainGlbUrl`, `plainOptGlbUrl` input-only

Note: An encrypted tenant's stored `keyRef` (a `.holo` node's `{ glbUrl, plainGlbUrl, encrypted: true, keyRef }`, as `platform-assets.json` manifests carry it) needs no 2.0 field: the node goes to the runtime as stored, and the `.holo` names its own key ref, which the runtime resolves as 1.x did (spec 8.5). Declare `keyRef?: number` in the app's own type, never `Pick<IModelNode, 'keyRef'>`, which fails TS2344; it passes through undeclared, as every undeclared node field does, and no output carries it, so keep the app's own copy for its saves (the backend also restores it by `glbUrl`). A `dek` is the only key input, passed the same way. No gap to file and no cast.

Used by: no app in the cross-check.

```ts
/**
 * 1.x `IModelNode` key material, as an encrypted tenant's manifest typed it (`Pick<IModelNode,
 * 'glbUrl' | 'plainGlbUrl' | 'encrypted' | 'keyRef'>`): 2.0 declares no `keyRef`, so the app's
 * own type carries it, never `Pick<IModelNode, 'keyRef'>` (TS2344). The node goes to the runtime
 * as stored: the `.holo` names its own key ref, which the runtime resolves as 1.x did, and no
 * output carries it.
 */
export type StoredAsset = Pick<IModelNode, 'glbUrl' | 'plainGlbUrl' | 'encrypted'> & {
  readonly keyRef?: number;
};

export function encryptedModelNode(
  node: IModelNode,
  asset: Pick<V1.IModelNode, 'glbUrl' | 'plainGlbUrl' | 'encrypted' | 'keyRef'>,
): IModelNode {
  const stored: StoredAsset = asset;
  return { ...node, ...stored };
}
```

<a id="c.2_imaterialnode"></a>
### C.2/IMaterialNode: `IMaterialNode`

**kept** · written for 1.x 1.5

format unchanged; convert with `materialSpecFromNode` / `materialNodeFromSpec`

Used by: no app in the cross-check.

<a id="c.2_igroupnode"></a>
### C.2/IGroupNode: `IGroupNode`, `IChildDefaults`, `IGlobalMaterialListEntry`, `IMagneticPlaneConfig`, `IMagneticPlaneRule`, `IPreset`, `IPresetNode`, `IPrice`, `IGradientBackground`, `IGradientColorStop`, `GradientDirection`, `BackgroundValue`, `ConfiguratorNode`

**kept** · written for 1.x 1.5

`IMagneticPlaneConfig` maps to `Connector` as 6.12 says (`connector` is the side, read through `meta.connector`); a GroupNode's `childDefaults` resolve under each direct child at every scene-model write, as in 1.4, and the model reads back as passed (6.12)

Used by: no app in the cross-check.

<a id="c.2_idefaultview"></a>
### C.2/IDefaultView: `IDefaultView`

**kept** · written for 1.x 1.5

the runtime never reads it, as in 1.4, where only the debug tools copy one: a scene write never moves the camera, and the app applies its stored view with `camera.set`. The template's `cameraViewFor` (8 apps) lets a stored `position` win: port it as `camera.set` with the `CameraView` that helper computes (azimuth and polar by `atan2`, distance by `hypot`, `focalOffset` kept), or the app's first `camera.set` changes; a `LookAtView` zeroes `focalOffset`, so `{ position, target }` only for a stored view without one (`apps/configurator-template/src/components/viewer/use-viewer.ts:36-47` at `c4c6ffd2`)

Used by: no app in the cross-check.

<a id="c.2_viewerdocumentjson"></a>
### C.2/ViewerDocumentJson: `ViewerDocumentJson`

**replaced** (replaced by version 2) · 2.0: [`ViewerDocumentJson`](api/ViewerDocumentJson.md) · written for 1.x 1.5

no `plugins`, no engine keys; each version-1 key per C.7; the page document must be version 2, while published layers may stay version 1 (8.3)

Used by: app-04c2774145, app-37356bf09f, app-4762caacdc, app-4f16d2f6da, app-4fd23bc869, app-822bb9d6aa, app-8675b3225c, app-b362a81c85, app-bcb7ef21da, app-c1384707fc, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f3598fad77, app-f9a32142f1, app-fb16dd1985, app-fc306ec3dc, app-fd09090420, configurator-template.

```ts
/** 1.x `ViewerDocumentJson`: assemble the page from the C.7 fragments, preserving notes. */
export function pageDocument(
  document: V1.ViewerDocumentJson,
  migrated: Pick<ViewerDocumentJson, 'stage' | 'camera' | 'materials'>,
): ViewerDocumentJson {
  return { schema: 'holo.viewer-document', version: 2, meta: document.meta, ...migrated };
}
```

<a id="c.2_scenemodel"></a>
### C.2/SceneModel: `SceneModel`

**kept** (kept (redeclared)) · written for 1.x 1.5

gains `connections`; `IConfigurator` stays assignable

Used by: app-04c2774145, app-0c773a4ee3, app-37356bf09f, app-4762caacdc, app-4fd23bc869, app-822bb9d6aa, app-8675b3225c, app-b362a81c85, app-bcb7ef21da, app-c1384707fc, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f3598fad77, app-f9a32142f1, app-fb16dd1985, app-fc306ec3dc, app-fd09090420, configurator-template.

<a id="c.2_scenemodelnode"></a>
### C.2/SceneModelNode: `SceneModelNode`, `SceneMaterialNode`, `SceneGroupNode`, `SceneNode`

**replaced** · 2.0: [`ConfiguratorNode`](api/ConfiguratorNode.md), [`IModelNode`](api/IModelNode.md), [`IMaterialNode`](api/IMaterialNode.md), [`IGroupNode`](api/IGroupNode.md), [`normalizeNodeIds`](api/normalizeNodeIds.md) · written for 1.x 1.5

the stored node types (`IModelNode`, `IMaterialNode`, `IGroupNode`, `ConfiguratorNode`), with `nodeId` required

Used by: no app in the cross-check.

```ts
/** 1.x `SceneModelNode`, `SceneMaterialNode`, `SceneGroupNode`, `SceneNode`: repair required node ids. */
export function storedNodes(root: V1.SceneNode[]): ConfiguratorNode[] {
  return normalizeNodeIds({ root }).doc.root;
}
```

<a id="c.2_scenemodeloptions"></a>
### C.2/SceneModelOptions: `SceneModelOptions`

**kept** · written for 1.x 1.5

member options only (`assetBaseUrl`, `signal`); the commit options come from `WriteOptions` on the `session` overload and are rejected through a `tx` (B1)

Used by: app-04c2774145, app-0c773a4ee3, app-37356bf09f, app-4762caacdc, app-4fd23bc869, app-822bb9d6aa, app-8675b3225c, app-b362a81c85, app-bcb7ef21da, app-c1384707fc, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f3598fad77, app-f9a32142f1, app-fb16dd1985, app-fc306ec3dc, app-fd09090420, configurator-template.

<a id="c.2_texturechannel"></a>
### C.2/TextureChannel: `TextureChannel`

**kept** · written for 1.x 1.5

—

Used by: no app in the cross-check.

<a id="c.2_texturefields"></a>
### C.2/TextureFields: `TextureFields`, `SurfaceFields`, `SceneFields`, `SceneNodeOptional`, `SceneNodeRequired`

**removed** · written for 1.x 1.5

internal helper types, folded into the declarations

Used by: no app in the cross-check.

<a id="c.2_stripnodedeks"></a>
### C.2/stripNodeDeks: `stripNodeDeks` (a value, not a type: app-f9a32142f1's cart `postMessage`, `apps/app-f9a32142f1/src/lib/post-message.ts:1, 52-57` at `c4c6ffd2`)

**removed** · written for 1.x 1.5

no 2.0 export: the app's own walk over `root` and every `children`, deleting `dek` on a copy, as 1.x's `stripNodeDeks` does (`libraries/interfaces/src/node-dek.ts:9-17` at `eb5ce2afe`; 8.5); no backend sends a `dek` now, so the walk only guards older inputs. A helper joins the contract package when a second app forwards documents (3.1 rule 3)

Used by: app-f9a32142f1.

<a id="c.3"></a>
## C.3 Session members

<a id="c.3_session"></a>
### C.3/Session: `TenantSession` (exported as `Session`)

**kept** · written for 1.x 1.5

the 1.x entry exported `TenantSession` as `Session`, the name 2.0 keeps; each member has its own row below

Used by: no app in the cross-check.

<a id="c.3_applydocument"></a>
### C.3/applyDocument: `applyDocument(document, options)`, `ApplyDocumentOptions`, `ApplyReport`

**kept** (kept (changed)) · written for 1.x 1.5

`ApplyDocumentOptions` → `SignalOption`, which keeps its `signal`; `ApplyReport.problems` holds `Problem`s; an apply aborted in time rejects AbortError (1.x resolved `superseded: true`); a failure inside the runtime while applying resolves `ok: false` with one `'apply-failed'` problem (1.x rethrew it, `rt/session.ts:186-189` at `eb5ce2afe`)

Used by: app-04c2774145, app-37356bf09f, app-4762caacdc, app-4f16d2f6da, app-4fd23bc869, app-822bb9d6aa, app-8675b3225c, app-b362a81c85, app-bcb7ef21da, app-c1384707fc, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f3598fad77, app-f9a32142f1, app-fb16dd1985, app-fc306ec3dc, app-fd09090420, configurator-template.

<a id="c.3_documentproblem"></a>
### C.3/DocumentProblem: `DocumentProblem`

**replaced** · 2.0: [`Problem`](api/Problem.md), [`ApplyReport.problems`](api/ApplyReport.problems.md) · written for 1.x 1.5

`Problem` with code `'document-error'`, its `path` (always set), `layer` and, for a version-1 key, `didYouMean`; `pluginId` removed

Used by: no app in the cross-check.

```ts
/** 1.x `DocumentProblem`: read document errors, including their path, layer and suggestions. */
export function documentProblems(report: Report): readonly Problem[] {
  return report.problems.filter((problem) => problem.code === 'document-error');
}
```

<a id="c.3_document"></a>
### C.3/document: `document`

**kept** · written for 1.x 1.5

—

Used by: app-4f16d2f6da, app-b362a81c85, app-fc07850558, app-fc306ec3dc.

<a id="c.3_setscenemodel"></a>
### C.3/setSceneModel: `setSceneModel(model, options)`

**kept** (kept (changed)) · written for 1.x 1.5

resolves a `Report` at the commit point; `nodeId` required; connections restored (omitted: kept where their nodes remain); in the write queue; `checkpoint` may ride along; app-fc306ec3dc passes `undo: 'rebase'` (revision 3 called that value `undo: false`)

Used by: app-04c2774145, app-0c773a4ee3, app-37356bf09f, app-4762caacdc, app-4fd23bc869, app-822bb9d6aa, app-8675b3225c, app-b362a81c85, app-bcb7ef21da, app-c1384707fc, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f3598fad77, app-f9a32142f1, app-fb16dd1985, app-fc306ec3dc, app-fd09090420, configurator-template.

<a id="c.3_select"></a>
### C.3/select: `select(globalMaterialNodeId, materialNodeId)`

**kept** · written for 1.x 1.5

resolves a `Report`

Used by: app-04c2774145, app-0c773a4ee3, app-37356bf09f, app-378d190f1b, app-4762caacdc, app-4f16d2f6da, app-4fd23bc869, app-510d750508, app-822bb9d6aa, app-8675b3225c, app-b362a81c85, app-b4ae2fce25, app-bcb7ef21da, app-c1384707fc, app-ddfb017681, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f3598fad77, app-f9a32142f1, app-fb16dd1985, app-fc07850558, app-fc306ec3dc, app-fd09090420, configurator-template.

<a id="c.3_selectreport"></a>
### C.3/SelectReport: `SelectReport`

**replaced** · 2.0: [`Session.select`](api/Writer.select.md), [`Session.getSceneModel`](api/Writer.getSceneModel.md) · written for 1.x 1.5

`r.model` → `session.getSceneModel()` after the await

Used by: no app in the cross-check.

```ts
/** 1.x `SelectReport.model`: read the scene model after awaiting selection. */
export async function selectModel(
  session: Session,
  group: string,
  material: string,
): Promise<DeepReadonly<SceneModel> | null> {
  await session.select(group, material);
  return session.getSceneModel();
}
```

<a id="c.3_setcameraview"></a>
### C.3/setCameraView: `setCameraView(view, { transition })`

**replaced** · 2.0: [`Camera.set`](api/Camera.set.md), [`CameraView`](api/CameraView.md), [`CameraMoveOptions.durationMs`](api/CameraMoveOptions.durationMs.md), [`CameraMoveOptions.easing`](api/CameraMoveOptions.easing.md) · written for 1.x 1.5

`session.camera.set({ target, azimuthDeg: azimuth, polarDeg: polar, distance: radius, focalOffset }, { durationMs: transition ? GLIDE_MS : 0, easing: 'damped' })`. 1.4's `transition: true` was a damped glide with no duration; it moved only the target: azimuth, polar and distance jumped at the call (`viewer/controllers/camera.controller.ts:362-378` at `eb5ce2afe`), so a preset button whose views share a target (app-04c2774145's and app-4762caacdc's `src/lib/camera/camera-manager.ts:102-113` at `c4c6ffd2`) cut in 1.4 and orbits in 2.0, with the same end frame, a visual change on the port's sign-off (D44); a port that keeps the cut passes `durationMs: 0` when the target is unchanged. `GLIDE_MS` is `round(1000π × smoothTime)` from the version-1 `controls.smoothTime` of the page document the move runs under, set in `migration.json` per page document (1,571 ms at the default 0.5; section 12, Q20), and `'damped'` moves the target as 1.4 did, within 1.38 % of the travel (spike G1). A glide retargeted mid-way stops at its new target, where 1.4's target passed a target just ahead by 8–10 cm and came back, and one called while the camera drifts after a shopper's gesture starts from rest: D44 items for a port that does either (Q20)

Visual changes to sign off (D44): A preset button whose views share a target cut in 1.4 (only the target moved) and orbits in 2.0, with the same end frame; a port that keeps the cut passes `durationMs: 0` when the target is unchanged.; A glide retargeted mid-way stops at its new target, where 1.4 passed a target just ahead by 8–10 cm and came back.; A glide called while the camera drifts after a shopper's gesture starts from rest..

Used by: app-04c2774145, app-0c773a4ee3, app-37356bf09f, app-378d190f1b, app-4762caacdc, app-4fd23bc869, app-59c5b5751e, app-822bb9d6aa, app-8675b3225c, app-b362a81c85, app-bcb7ef21da, app-c1384707fc, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f3598fad77, app-f9a32142f1, app-fb16dd1985, app-fc07850558, app-fc306ec3dc, app-fd09090420, configurator-template.

```ts
/** `session.setCameraView(view, { transition })`; `glideMs` is the app's `GLIDE_MS`. */
export function setCameraView(
  session: Session,
  view: V1.CameraView,
  transition: boolean,
  glideMs: number,
): Promise<Report> {
  return session.camera.set(
    {
      target: view.target,
      azimuthDeg: view.azimuth,
      polarDeg: view.polar,
      distance: view.radius,
      focalOffset: view.focalOffset,
    },
    { durationMs: transition ? glideMs : 0, easing: 'damped' },
  );
}
```

<a id="c.3_fittoscene"></a>
### C.3/fitToScene: `fitToScene(options)`

**replaced** · 2.0: [`Camera.fit`](api/Camera.fit.md), [`CameraMoveOptions.durationMs`](api/CameraMoveOptions.durationMs.md), [`CameraMoveOptions.easing`](api/CameraMoveOptions.easing.md) · written for 1.x 1.5

`session.camera.fit(undefined, { durationMs: GLIDE_MS, easing: 'damped' })` (Q20); without `durationMs` the fit becomes a jump. 1.4 also turned the camera to the nearest axis view: camera-controls' `fitToBox` rounds azimuth and polar to multiples of 90° (`camera-controls.module.js:1627-1629`, 3.1.2, reached through `viewer/controllers/camera.controller.ts:328-331` at `eb5ce2afe`), while 2.0's `fit` keeps the angles (Q33). So every caller's framed view changes (the template's fit button and its first view without a `defaultView`, `apps/configurator-template/src/components/viewer/use-viewer-actions.ts:87` and `use-viewer.ts:149`, and the apps copied from it; app-04c2774145's and app-4762caacdc's `use-viewer.ts:714`, at `5cdd2780`): a visible change for the port's D44 diff. A port that keeps 1.4's framing passes the current azimuth and polar rounded to the nearest 90° as `fit`'s `view`, one move as in 1.4

Visual changes to sign off (D44): 1.4's fit turned the camera to the nearest axis view; 2.0's `fit` keeps the angles, so every caller's framed view changes unless the port passes the angles rounded to 90° as `fit`'s `view`..

Used by: app-04c2774145, app-4762caacdc, app-4fd23bc869, app-510d750508, app-822bb9d6aa, app-8675b3225c, app-b362a81c85, app-bcb7ef21da, app-c1384707fc, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f3598fad77, app-f9a32142f1, app-fb16dd1985, app-fc306ec3dc, app-fd09090420, configurator-template.

```ts
/**
 * `session.fitToScene()`: one damped fit over the scene. 1.x also turned to the nearest axis
 * view; `keepAxisView` keeps that framing by fitting from the angles rounded to 90°.
 */
export function fitToScene(
  session: Session,
  glideMs: number,
  keepAxisView = false,
): Promise<Report> {
  const { azimuthDeg, polarDeg } = session.camera.state;
  const round90 = (deg: number) => Math.round(deg / 90) * 90;
  return session.camera.fit(undefined, {
    durationMs: glideMs,
    easing: 'damped',
    ...(keepAxisView
      ? { view: { azimuthDeg: round90(azimuthDeg), polarDeg: round90(polarDeg) } }
      : {}),
  });
}
```

<a id="c.3_cameraview"></a>
### C.3/CameraView: `CameraView`

**replaced** (replaced by 2.0 `CameraView`) · 2.0: [`CameraView`](api/CameraView.md) · written for 1.x 1.5

rename `azimuth` → `azimuthDeg`, `polar` → `polarDeg`, `radius` → `distance` (degrees both sides); a stored `IDefaultView` maps the same way

Used by: app-04c2774145, app-0c773a4ee3, app-37356bf09f, app-378d190f1b, app-4762caacdc, app-4fd23bc869, app-59c5b5751e, app-822bb9d6aa, app-8675b3225c, app-b362a81c85, app-bcb7ef21da, app-c1384707fc, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f3598fad77, app-f9a32142f1, app-fb16dd1985, app-fc07850558, app-fc306ec3dc, app-fd09090420, configurator-template.

```ts
/** 1.x `CameraView` or stored `IDefaultView`: keep angles in degrees and rename fields. */
export function cameraView(view: V1.CameraView | V1.IDefaultView): CameraView {
  return {
    target: view.target,
    azimuthDeg: view.azimuth,
    polarDeg: view.polar,
    distance: view.radius,
    focalOffset: view.focalOffset,
  };
}
```

<a id="c.3_cameracommandoptions"></a>
### C.3/CameraCommandOptions: `CameraCommandOptions`

**replaced** (replaced by `CameraMoveOptions`) · 2.0: [`CameraMoveOptions`](api/CameraMoveOptions.md) · written for 1.x 1.5

`transition: true` → `{ durationMs: GLIDE_MS, easing: 'damped' }`, `false` → `{ durationMs: 0 }`: 1.4's transition was its damped glide (Q20)

Used by: app-04c2774145, app-0c773a4ee3, app-37356bf09f, app-378d190f1b, app-4762caacdc, app-4fd23bc869, app-510d750508, app-59c5b5751e, app-822bb9d6aa, app-8675b3225c, app-b362a81c85, app-bcb7ef21da, app-c1384707fc, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f3598fad77, app-f9a32142f1, app-fb16dd1985, app-fc07850558, app-fc306ec3dc, app-fd09090420, configurator-template.

```ts
/** 1.x `CameraCommandOptions.transition`: give the damped move an explicit duration. */
export function cameraCommandOptions(
  options: V1.CameraCommandOptions,
  glideMs: number,
): CameraMoveOptions {
  return { durationMs: options.transition ? glideMs : 0, easing: 'damped' };
}
```

<a id="c.3_animations"></a>
### C.3/animations: `animations`

**kept** (kept (namespace)) · written for 1.x 1.5

members below

Used by: app-04c2774145, app-0c773a4ee3, app-37356bf09f, app-4762caacdc, app-4fd23bc869, app-822bb9d6aa, app-8675b3225c, app-b362a81c85, app-bcb7ef21da, app-c1384707fc, app-ceb4eccc72, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f3598fad77, app-f9a32142f1, app-fb16dd1985, app-fc306ec3dc, app-fd09090420, configurator-template.

<a id="c.3_sessionanimations.list"></a>
### C.3/SessionAnimations.list: `SessionAnimations.list()`, `AnimationInfo`

**replaced** · 2.0: [`Animations.list`](api/Animations.list.md), [`ClipInfo`](api/ClipInfo.md) · written for 1.x 1.5

`animations.list()` → `ClipInfo { node, item, clip, durationMs, playing }` (seconds become ms; `node` is null only for a collection item's clip)

Used by: app-04c2774145, app-0c773a4ee3, app-37356bf09f, app-4762caacdc, app-4fd23bc869, app-822bb9d6aa, app-8675b3225c, app-b362a81c85, app-bcb7ef21da, app-c1384707fc, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f3598fad77, app-f9a32142f1, app-fb16dd1985, app-fc07850558, app-fc306ec3dc, app-fd09090420, configurator-template.

```ts
/** 1.x `SessionAnimations.list()` and `AnimationInfo`: clips carry milliseconds and their address. */
export function animationClips(session: Session): ClipInfo[] {
  return session.animations.list();
}
```

<a id="c.3_sessionanimations.playall"></a>
### C.3/SessionAnimations.playAll: `SessionAnimations.playAll()`

**replaced** · 2.0: [`Animations.list`](api/Animations.list.md), [`Animations.play`](api/Animations.play.md) · written for 1.x 1.5

each model's first clip, as 1.x's `playAll()` kept one clip of clips sharing a node (`viewer/controllers/animation.controller.ts:323-339` at `eb5ce2afe`; 5.10): `const seen = new Set<string>(); for (const c of session.animations.list()) if (c.node && !seen.has(c.node)) { seen.add(c.node); void session.animations.play(c.node, c.clip, { toggle: true }); }`

Used by: app-37356bf09f, app-4fd23bc869, app-822bb9d6aa, app-8675b3225c, app-b362a81c85, app-c1384707fc, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f3598fad77, app-fb16dd1985, app-fc306ec3dc, app-fd09090420, configurator-template.

```ts
/** 1.x `SessionAnimations.playAll()`: toggle each model's first clip. */
export function playAll(session: Session): void {
  const seen = new Set<string>();
  for (const c of session.animations.list()) {
    if (c.node && !seen.has(c.node)) {
      seen.add(c.node);
      void session.animations.play(c.node, c.clip, { toggle: true });
    }
  }
}
```

<a id="c.3_camera"></a>
### C.3/camera: `camera`

**kept** (kept (namespace)) · written for 1.x 1.5

members below

Used by: app-04c2774145, app-0c773a4ee3, app-37356bf09f, app-378d190f1b, app-4762caacdc, app-4f16d2f6da, app-510d750508, app-59c5b5751e, app-b362a81c85, app-b4ae2fce25, app-bcb7ef21da, app-ceb4eccc72, app-ddfb017681, app-e3b47169bc, app-e61a0b42ad, app-f3598fad77, app-f9a32142f1, app-fb16dd1985, app-fc07850558, app-fc306ec3dc, app-fd09090420.

<a id="c.3_sessioncamera.getpose"></a>
### C.3/SessionCamera.getPose: `SessionCamera.getPose()`, `CameraPose`

**replaced** · 2.0: [`Camera.state`](api/Camera.state.md), [`CameraState`](api/CameraState.md) · written for 1.x 1.5

`session.camera.state` (`azimuthDeg`, `polarDeg`, `distance`, `target`, `focalOffset`)

Used by: app-04c2774145, app-37356bf09f, app-378d190f1b, app-4762caacdc, app-b362a81c85, app-bcb7ef21da, app-e3b47169bc, app-e61a0b42ad, app-f3598fad77, app-f9a32142f1, app-fb16dd1985, app-fc07850558, app-fd09090420.

```ts
/** 1.x `SessionCamera.getPose()`: read camera state. */
export function cameraPose(session: Session): CameraState {
  return session.camera.state;
}
```

<a id="c.3_sessioncamera.getbounds"></a>
### C.3/SessionCamera.getBounds: `SessionCamera.getBounds(options)`

**replaced** · 2.0: [`Session.find`](api/Writer.find.md), [`Parts.bounds`](api/PartActions.bounds.md) · written for 1.x 1.5

`session.find(query).bounds()`

Used by: no app in the cross-check.

```ts
/** 1.x `SessionCamera.getBounds(options)`: measure matching product parts. */
export function cameraBounds(session: Session, options: V1.BoundsOptions = {}): Bounds | null {
  const query: Query = options.nodeIds
    ? options.nodeIds.map((node) => ({ node }))
    : { role: 'product' };
  return session.find(query).bounds();
}
```

<a id="c.3_sessioncamera.fitto"></a>
### C.3/SessionCamera.fitTo: `SessionCamera.fitTo(options)`, `FitOptions`

**replaced** · 2.0: [`Camera.fit`](api/Camera.fit.md), [`CameraMoveOptions.durationMs`](api/CameraMoveOptions.durationMs.md), [`CameraMoveOptions.easing`](api/CameraMoveOptions.easing.md) · written for 1.x 1.5

`session.camera.fit(nodeIds.map((node) => ({ node })), { paddingM, durationMs: transition ? GLIDE_MS : 0, easing: 'damped' })` (Q20). 1.4 turned to the nearest axis view here too (`viewer/configurator-viewer.ts:1600` at `eb5ce2afe`); see the `fitToScene` row, and its margin note: an omitted `paddingM` keeps a 4% margin (5.6)

Used by: no app in the cross-check.

```ts
/** 1.x `SessionCamera.fitTo(options)`: fit the models, optionally retaining the nearest axis view. */
export function fitModels(
  session: Session,
  options: V1.FitOptions,
  glideMs: number,
  keepAxisView = false,
): Promise<Report> {
  const { azimuthDeg, polarDeg } = session.camera.state;
  const round90 = (deg: number): number => Math.round(deg / 90) * 90;
  return session.camera.fit(
    options.nodeIds?.map((node) => ({ node })),
    {
      paddingM: options.padding,
      durationMs: options.transition ? glideMs : 0,
      easing: 'damped',
      ...(keepAxisView
        ? { view: { azimuthDeg: round90(azimuthDeg), polarDeg: round90(polarDeg) } }
        : {}),
    },
  );
}
```

<a id="c.3_history"></a>
### C.3/history: `history`

**kept** (kept (namespace)) · written for 1.x 1.5

members below

Used by: app-04c2774145, app-37356bf09f, app-4762caacdc, app-4f16d2f6da, app-510d750508, app-59c5b5751e, app-b4ae2fce25, app-bcb7ef21da, app-ceb4eccc72, app-ddfb017681, app-e3b47169bc, app-e61a0b42ad, app-f07e9033d9, app-f3598fad77, app-f9a32142f1, app-fc07850558, app-fc306ec3dc, app-fd09090420, configurator-template.

<a id="c.3_sessionhistory.transaction"></a>
### C.3/SessionHistory.transaction: `SessionHistory.transaction(options)`, `HistoryTransaction`, `TransactionOptions`

**replaced** · 2.0: [`History.group`](api/History.group.md), [`HistoryGroup`](api/HistoryGroup.md), [`Session.transaction`](api/Session.transaction.md) · written for 1.x 1.5

used to group undo steps (a drag, a turn; app-37356bf09f's drag gesture, `src/runtime/drag-commit.ts:73` at `c4c6ffd2`): `history.transaction({ label })` → `session.history.group({ label })`; `commit()` and `open` unchanged; no 10 s auto-commit: the group default is 60 s. Used to keep work out of history, as app-04c2774145 does with its assembly snaps: do that work inside the sync's `session.transaction(work, { undo })`, so the snaps are part of that one step, never a step of their own

Used by: app-04c2774145, app-37356bf09f, app-4762caacdc, app-510d750508, app-bcb7ef21da, app-f3598fad77.

```ts
/** 1.x `SessionHistory.transaction({ label })`: use an explicit undo group and commit its handle. */
export function historyGroup(session: Session, options: V1.TransactionOptions = {}): HistoryGroup {
  return session.history.group({ label: options.label });
}
/** Keep a sync and its assembly snaps in one step instead of suppressing snap history. */
export function syncWithSnaps(
  session: Session,
  model: SceneModel,
  nodeIds: readonly string[],
  undo: string,
): Promise<TransactionReport<void>> {
  return session.transaction(
    async (tx) => {
      await tx.setSceneModel(model);
      for (const node of nodeIds) await tx.assembly.attach(node);
    },
    { undo },
  );
}
```

<a id="c.3_screenshot"></a>
### C.3/screenshot: `screenshot(options)`, `CaptureOptions`

**replaced** · 2.0: [`Session.capture`](api/Session.capture.md), [`CaptureResult`](api/CaptureResult.md), [`Session.idle`](api/Session.idle.md) · written for 1.x 1.5

`(await session.capture({ size: { width, height }, format, quality })).image`. 1.x waited, at most 3 s, for the material applies in flight, a fresh pick included; for the configuration just made, first `await session.idle({ signal: AbortSignal.timeout(5000) })`, its `AbortError` caught (5.11, 6.8 rule 2)

Used by: app-04c2774145, app-4762caacdc, app-4f16d2f6da, app-4fd23bc869, app-510d750508, app-822bb9d6aa, app-8675b3225c, app-b362a81c85, app-b4ae2fce25, app-bcb7ef21da, app-c1384707fc, app-ceb4eccc72, app-ddfb017681, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f3598fad77, app-f9a32142f1, app-fb16dd1985, app-fc07850558, app-fc306ec3dc, app-fd09090420, configurator-template.

```ts
/** 1.x `screenshot(options)`: wait for the current configuration, then return the capture image. */
export async function screenshot(session: Session, options: V1.CaptureOptions = {}): Promise<Blob> {
  try {
    await session.idle({ signal: AbortSignal.timeout(5000) });
  } catch (error) {
    if (!(error instanceof DOMException && error.name === 'AbortError')) throw error;
  }
  const { width, height, format, quality } = options;
  return (
    await session.capture({
      ...(width !== undefined && height !== undefined ? { size: { width, height } } : {}),
      format,
      quality,
    })
  ).image;
}
```

<a id="c.3_activatear"></a>
### C.3/activateAR: `activateAR(options)`, `ActivateAROptions`, `ActivateARReport`

**kept** (kept (changed)) · written for 1.x 1.5

unions gain `'other'`; `usdzUrl` (the file Quick Look opens; a `glbUrl` never reaches it) and `title` added; `scale` and `occlusion` added as experimental; a WebXR session draws its own product-only scene instead of the live one (5.11); `reason` gains `'cancelled'`, and the call routes by `@holo/xr`'s route order (5.11, D64); `plainGlbUrl` removed: `glbUrl` is never a `.holo`, so the plain twin goes in `glbUrl`, and no app passes `plainGlbUrl` at `c4c6ffd2`; on Android an account that opts in (`ar.place.android: 'holostep'`) opens Holostep's in-page WebXR presenter over a copy of the committed product, and `'webxr'` joins `info().ar.modes` once the route can open it (5.11, D64)

Used by: app-04c2774145, app-37356bf09f, app-4762caacdc, app-4fd23bc869, app-822bb9d6aa, app-8675b3225c, app-b362a81c85, app-bcb7ef21da, app-c1384707fc, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f3598fad77, app-f9a32142f1, app-fb16dd1985, app-fc306ec3dc, app-fd09090420, configurator-template.

<a id="c.3_undo"></a>
### C.3/undo: `undo()`, `UndoReport`

**kept** (kept (changed)) · written for 1.x 1.5

resolves a `Report`; `restored` removed: `restored: false` → `outcome: 'refused', reason: 'empty'`

Used by: app-04c2774145, app-37356bf09f, app-4762caacdc, app-4fd23bc869, app-822bb9d6aa, app-8675b3225c, app-b362a81c85, app-bcb7ef21da, app-c1384707fc, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f3598fad77, app-f9a32142f1, app-fb16dd1985, app-fc07850558, app-fc306ec3dc, app-fd09090420, configurator-template.

<a id="c.3_checkpoint"></a>
### C.3/checkpoint: `checkpoint(state)`

**replaced** · 2.0: [`CheckpointOption.checkpoint`](api/CheckpointOption.checkpoint.md), [`SessionState.checkpoint`](api/SessionState.checkpoint.md) · written for 1.x 1.5

pass the page state with the change it belongs to: `session.setSceneModel(model, { checkpoint: state })` (any recorded write takes it), or `session.transaction(work, { checkpoint: state })`; read it back from `session.state().checkpoint` or the `'committed'` feed

Used by: app-37356bf09f, app-4fd23bc869, app-822bb9d6aa, app-8675b3225c, app-bcb7ef21da, app-c1384707fc, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f3598fad77, app-fb16dd1985, app-fd09090420, configurator-template.

```ts
/** 1.x `checkpoint(state)`: record page state alongside the scene change and read it back. */
export async function checkpoint(
  session: Session,
  model: SceneModel,
  state: V1.JsonValue,
): Promise<JsonValue | null> {
  await session.setSceneModel(model, { checkpoint: state });
  return session.state().checkpoint;
}
```

<a id="c.3_onevent"></a>
### C.3/onEvent: `onEvent(event, handler)`

**replaced** · 2.0: [`Session.on`](api/Session.on.md) · written for 1.x 1.5

`session.on(event, handler, { signal })`

Used by: app-04c2774145, app-0c773a4ee3, app-37356bf09f, app-378d190f1b, app-4762caacdc, app-4fd23bc869, app-822bb9d6aa, app-8675b3225c, app-b362a81c85, app-bcb7ef21da, app-c1384707fc, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f3598fad77, app-f9a32142f1, app-fb16dd1985, app-fc306ec3dc, app-fd09090420, configurator-template.

```ts
/** 1.x `onEvent(event, handler)`: subscribe to the typed event map with an abort signal. */
export function onEvent<K extends keyof Events>(
  session: Session,
  event: K,
  handler: (payload: Events[K]) => void,
  signal: AbortSignal,
): () => void {
  return session.on(event, handler, { signal });
}
```

<a id="c.3_info"></a>
### C.3/info: `info()`

**kept** (kept (changed)) · written for 1.x 1.5

see `InfoReport`

Used by: app-b362a81c85, app-f9a32142f1, app-fc07850558, app-fd09090420.

<a id="c.3_supports"></a>
### C.3/supports: `supports(capability)`

**kept** · written for 1.x 1.5

—

Used by: app-bcb7ef21da, app-e3b47169bc, app-e61a0b42ad, app-f07e9033d9, app-fb16dd1985, app-fd09090420, configurator-template.

<a id="c.3_interaction"></a>
### C.3/interaction: `interaction`

**replaced** · 2.0: [`Session.input`](api/Session.input.md), [`Input`](api/Input.md) · written for 1.x 1.5

`session.input`

Used by: app-04c2774145, app-37356bf09f, app-4762caacdc, app-e61a0b42ad, app-fc306ec3dc.

```ts
/** 1.x `interaction`: use the input member. */
export function interaction(session: Session): Input {
  return session.input;
}
```

<a id="c.3_sessioninteraction.pick"></a>
### C.3/SessionInteraction.pick: `SessionInteraction.pick(at, options)`, `PickOptions`

**replaced** · 2.0: [`Input.pick`](api/Input.pick.md), [`Hit`](api/Hit.md) · written for 1.x 1.5

`session.input.pick(at, { query: nodeIds.map((node) => ({ node })) })` returns a `Hit`

Used by: app-b4ae2fce25, app-ddfb017681, app-e61a0b42ad, app-fc07850558, app-fc306ec3dc.

```ts
/** 1.x `SessionInteraction.pick(at, options)`: pick a hit within the queried models. */
export function pick(
  session: Session,
  at: V1.ScreenPoint,
  nodeIds: readonly string[],
): Promise<Hit | null> {
  return session.input.pick(at, { query: nodeIds.map((node) => ({ node })) });
}
```

<a id="c.3_sessioninteraction.pickground"></a>
### C.3/SessionInteraction.pickGround: `SessionInteraction.pickGround(at, { planeY })`, `PickGroundOptions`

**replaced** · 2.0: [`Camera.ray`](api/Camera.ray.md), `ray` · written for 1.x 1.5

`ray.intersectPlane(session.camera.ray(at), { normal: { x: 0, y: 1, z: 0 }, constant: -planeY })` from `/math` (synchronous); app-37356bf09f's inventory drop (`src/runtime/panel-drop.ts:11` at `c4c6ffd2`)

Used by: app-37356bf09f.

```ts
/** 1.x `SessionInteraction.pickGround(at, { planeY })`: intersect the camera ray synchronously. */
export function pickGround(
  session: Session,
  at: V1.ScreenPoint,
  options: V1.PickGroundOptions = {},
): Point3 | null {
  const planeY = options.planeY ?? 0;
  return ray.intersectPlane(session.camera.ray(at), {
    normal: { x: 0, y: 1, z: 0 },
    constant: -planeY,
  });
}
```

<a id="c.3_sessioninteraction.snapto"></a>
### C.3/SessionInteraction.snapTo: `SessionInteraction.snapTo(target, { near })`, `SnapOptions`, `SnapReport`

**replaced** · 2.0: [`Assembly.attach`](api/Assembly.attach.md), [`AttachReport`](api/AttachReport.md) · written for 1.x 1.5

`session.assembly.attach(target.nodeId, { near })` (6.12); `snapped` → `attached`; `pose` → `session.node(target.nodeId).worldPose`; `'no-planes'` → `'no-candidate'`; `'rejected'` → `'rejected'`; `'plugin-missing'` → none (always present). An app that only reads the snapped pose for its own placement math (app-37356bf09f) takes it from `attach(…, { dryRun: true })`'s `patch`, so no connection is recorded (K63)

Used by: app-04c2774145, app-37356bf09f, app-4762caacdc, app-ceb4eccc72, app-f9a32142f1.

```ts
/** 1.x `SessionInteraction.snapTo(target, { near })`: read attachment status and the world pose. */
export async function snapTo(
  session: Session,
  target: V1.NodeTarget,
  options: V1.SnapOptions = {},
): Promise<{ report: AttachReport; pose: Pose }> {
  const report = await session.assembly.attach(target.nodeId, { near: options.near });
  return { report, pose: session.node(target.nodeId).worldPose };
}
/** Placement-only math reads a dry run's patch without recording a connection. */
export async function snapPatch(
  session: Session,
  target: V1.NodeTarget,
  options: V1.SnapOptions = {},
): Promise<readonly JsonPatchOp[]> {
  const report = await session.assembly.attach(target.nodeId, { near: options.near, dryRun: true });
  return report.outcome === 'dry-run' ? report.patch : [];
}
```

<a id="c.3_sessioninteraction.setdraggable"></a>
### C.3/SessionInteraction.setDraggable: `SessionInteraction.setDraggable(nodeIds, options)`, `DraggableOptions`

**replaced** · 2.0: [`Input.drag`](api/Input.drag.md) · written for 1.x 1.5

`const stop = session.input.drag(nodeIds.map((node) => ({ node })), { on: 'floor' })`; `setDraggable([])` → `stop()`

Used by: app-04c2774145, app-37356bf09f, app-4762caacdc, app-fc306ec3dc.

```ts
/** 1.x `SessionInteraction.setDraggable(nodeIds)` and `setDraggable([])`: retain and call the disposer. */
export function setDraggable(session: Session, nodeIds: readonly string[]): () => void {
  const stop = session.input.drag(
    nodeIds.map((node) => ({ node })),
    { on: 'floor' },
  );
  return () => stop();
}
```

<a id="c.3_overlays"></a>
### C.3/overlays: `overlays`

**kept** (kept (namespace)) · written for 1.x 1.5

members below

Used by: app-37356bf09f, app-fd09090420.

<a id="c.3_sessionoverlays.anchors.set-clear"></a>
### C.3/SessionOverlays.anchors.set-clear: `SessionOverlays.anchors.set/clear`, `AnchorOptions`, `AnchorPosition`

**kept** (kept (changed)) · written for 1.x 1.5

`AnchorOptions.margin` → `marginPx`, CSS px as before (the unit in the name, 3.3)

Used by: app-04c2774145, app-37356bf09f, app-378d190f1b, app-4762caacdc, app-f9a32142f1.

<a id="c.3_anchorspec"></a>
### C.3/AnchorSpec: `AnchorSpec`

**replaced** · 2.0: [`AnchorSpec`](api/AnchorSpec.md) · written for 1.x 1.5

`{ id, position, nodeId }` → `{ id, at: position, on: { node: nodeId } }`

Used by: app-04c2774145, app-37356bf09f, app-378d190f1b, app-4762caacdc, app-f9a32142f1.

```ts
/** 1.x `AnchorSpec { id, position, nodeId }`: rename the point and optional node query. */
export function anchorSpec(anchor: V1.AnchorSpec): AnchorSpec {
  return {
    id: anchor.id,
    at: anchor.position,
    ...(anchor.nodeId !== undefined ? { on: { node: anchor.nodeId } } : {}),
  };
}
```

<a id="c.3_sessionoverlays.project"></a>
### C.3/SessionOverlays.project: `SessionOverlays.project(points)`

**replaced** · 2.0: [`Camera.project`](api/Camera.project.md) · written for 1.x 1.5

`session.camera.project(points)` (synchronous)

Note: For a capture with its own `view` or `size` (a quote still taken off-display, app-fd09090420's corner check), `camera.project` can't check the framing before the shot: it projects only through `camera.state`. `/math` `projection.project({ view, size }, points)` projects through that view at that size with the lens the capture draws with (the default lens when the page set none, else pass its `fovDeg` or `horizontalFovDeg`), so a loop that backs off until every corner is inside the frame runs before `capture({ view, size })` and never moves the live camera.

Used by: app-37356bf09f, app-4f16d2f6da, app-b362a81c85, app-b4ae2fce25, app-bcb7ef21da, app-ddfb017681, app-fc07850558, app-fc306ec3dc, app-fd09090420.

```ts
/** 1.x `SessionOverlays.project(points)`: project synchronously through the camera. */
export function project(
  session: Session,
  points: readonly V1.Point3[],
): Array<ProjectedPoint | null> {
  return session.camera.project(points);
}
```

<a id="c.3_sessionoverlays.hotspots.set"></a>
### C.3/SessionOverlays.hotspots.set: `SessionOverlays.hotspots.set(badges, options)`, `HotspotOptions`

**kept** (kept (changed)) · written for 1.x 1.5

`set(badges)`: `HotspotOptions.alwaysVisible` is removed; no app passed it, and an always-shown marker is the app's own DOM on `overlays.anchors`, as app-4762caacdc and app-04c2774145 draw theirs

Used by: app-37356bf09f, app-f3598fad77, app-fc07850558, app-fd09090420.

<a id="c.3_hotspotbadge"></a>
### C.3/HotspotBadge: `HotspotBadge`

**replaced** · 2.0: [`HotspotBadge`](api/HotspotBadge.md) · written for 1.x 1.5

`{ id, position, nodeId, markerClass, group }` → `{ id, at: position, label, stylePart: markerClass }`; `nodeId` is dropped, since no app passes it (app-fd09090420 and app-f3598fad77 pass `{ id, position, markerClass }`); `group` is dropped, since badges never cluster in 2.0 and no app passes it at `c4c6ffd2`; `label` is required; `markerClass` was a CSS class, and `stylePart` is a `part` name styled with `::part(<stylePart>)`, so the rename makes a kept class name fail to compile

Used by: app-37356bf09f, app-f3598fad77, app-fc07850558, app-fd09090420.

```ts
/** 1.x `HotspotBadge`: provide an accessible label and style its part with `::part(...)`. */
export function hotspotBadge(badge: V1.HotspotBadge, label: string): HotspotBadge {
  return { id: badge.id, at: badge.position, label, stylePart: badge.markerClass };
}
```

<a id="c.3_hotspotreference"></a>
### C.3/HotspotReference: `HotspotReference`

**replaced** · 2.0: [`HotspotReference`](api/HotspotReference.md) · written for 1.x 1.5

`nodeId` is dropped: a badge sits at a world point

Used by: no app in the cross-check.

```ts
/** 1.x `HotspotReference`: identify the world-space badge by id alone. */
export function hotspotReference(hotspot: V1.HotspotReference): HotspotReference {
  return { id: hotspot.id };
}
```

<a id="c.3_idle"></a>
### C.3/idle: `idle()`

**kept** · written for 1.x 1.5

throws `HoloScopeError` inside `work`

Used by: app-bcb7ef21da, app-e61a0b42ad, app-f3598fad77, app-fb16dd1985, app-fd09090420, configurator-template.

<a id="c.3_destroy"></a>
### C.3/destroy: `destroy()`

**kept** · written for 1.x 1.5

rolls back an open transaction in place

Used by: app-04c2774145, app-0c773a4ee3, app-37356bf09f, app-378d190f1b, app-4762caacdc, app-4f16d2f6da, app-4fd23bc869, app-510d750508, app-59c5b5751e, app-822bb9d6aa, app-8675b3225c, app-b362a81c85, app-b4ae2fce25, app-bcb7ef21da, app-c1384707fc, app-ceb4eccc72, app-ddfb017681, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f3598fad77, app-f9a32142f1, app-fb16dd1985, app-fc07850558, app-fc306ec3dc, app-fd09090420, configurator-template.

<a id="c.3_cantryon"></a>
### C.3/canTryOn: `canTryOn()`, `TryOnAvailability`, `TryOnKind`, `TryOnUnavailable` (1.6)

**kept** (kept (experimental)) · written for 1.x 1.6

a 2.0 experimental member (`@stability labs @experimental`), reached through `has(session, 'canTryOn')`, which answers true on a 2.0 runtime; `available`, `kind` and `reason` keep their 1.7 meaning (`kind` includes `'finger'`, which answers `reason: 'unsupported'` until its tracker ships, and `'not-enabled'` also covers an account that has not set its consent mode, which every kind needs; WD35, `ae5c5d0ede`), `modes` adds `'handoff'` where the hand-off can open (F1b: `['here', 'handoff']` when available, `['handoff']` with `reason: 'unsupported'` where only this device can't; 1.7 lists `['here']` or nothing), and `reason: 'not-enabled'` is the enablement (no `permits('tryOn')`); re-read it on `'committed'`, where 1.6 re-reads it on `'scene:settled'` (delivery-run 2026-10-20; DECIDED: XR alignment reply §3, Erik)

Used by: no app in the cross-check.

<a id="c.3_tryon"></a>
### C.3/tryOn: `tryOn(options)`, `TryOnOptions`, `TryOnReport`, `TryOnMode` (1.6)

**kept** (kept (experimental)) · written for 1.x 1.6

a 2.0 experimental member, reached through `has(session, 'tryOn')`, true on a 2.0 runtime; it resolves `{ launched, mode, reason?, durationMs, handoffUrl?, problems }` (`launched`, not `opened`), an aborted `signal` rejects `AbortError`, and `mode: 'handoff'` is used only after `supports('tryOn.mode.handoff')` (true on a 2.0 runtime since F1b: Holostep's QR hand-off screen, `launched: true` once it showed; 1.x answers false and resolves 'unsupported'); its option `root` is `overlayRoot`, as `activateAR`'s (delivery-run 2026-10-20; DECIDED: XR alignment reply §3, Erik; R3-API-9)

Used by: no app in the cross-check.

<a id="c.3_endar"></a>
### C.3/endAR: `endAR()` (1.6)

**kept** (kept (experimental)) · written for 1.x 1.6

a 2.0 experimental member, reached through `has(session, 'endAR')`, true on a 2.0 runtime; it closes Holostep's in-page AR or try-on from page code, as in 1.6 (delivery-run 2026-10-20; DECIDED: XR alignment reply §3, Erik)

Used by: no app in the cross-check.

<a id="c.3_activatearoptions.overlayroot"></a>
### C.3/ActivateAROptions.overlayRoot: `ActivateAROptions.overlayRoot`, `.ui` (1.6)

**kept** (kept (experimental)) · written for 1.x 1.6

`activateAR`'s experimental options: `overlayRoot` used only after `supports('activateAR.overlayRoot')`, `ui: 'headless'` only after `supports('activateAR.ui.headless')`. `overlayRoot` answers true since 2.0 takes production's in-page AR route (DL28, cycle 15): the tenant's UI over the camera on both in-page routes, with the inset properties `--holo-ar-inset-top` and `--holo-ar-inset-bottom` as production's AR v3 sets them; `ui.headless` answers true since production's AR v3 (!1099, DL51): the in-page camera route runs without Holostep's screens and reports through `'ar:status'`'s `place`, the other routes open the native viewer (delivery-run 2026-10-20; DECIDED: XR alignment reply §3, Erik; cycle 19, DL51)

Used by: no app in the cross-check.

<a id="c.4"></a>
## C.4 Events (`TenantEventMap` → `Events`)

<a id="c.4_tenanteventmap"></a>
### C.4/TenantEventMap: `TenantEventMap`

**replaced** (replaced by `Events`) · 2.0: [`Events`](api/Events.md), [`Session.on`](api/Session.on.md) · written for 1.x 1.5

`TenantEventMap` → `Events`; each event has its own row below

Used by: app-04c2774145, app-0c773a4ee3, app-37356bf09f, app-378d190f1b, app-4762caacdc, app-4fd23bc869, app-822bb9d6aa, app-8675b3225c, app-b362a81c85, app-bcb7ef21da, app-c1384707fc, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f3598fad77, app-f9a32142f1, app-fb16dd1985, app-fc306ec3dc, app-fd09090420, configurator-template.

```ts
/** 1.x `TenantEventMap` → 2.0 `Events`: the same subscription, typed by the 2.0 map. */
export function subscribe<K extends keyof Events>(
  session: Session,
  event: K,
  handler: (payload: Events[K]) => void,
  signal: AbortSignal,
): () => void {
  return session.on(event, handler, { signal });
}
```

<a id="c.4_model_load-start"></a>
### C.4/model:load-start: `'model:load-start'`, `'model:loaded'`, `'model:updated'`, `'model:removed'`

**replaced** · 2.0: [`ReportFields.problems`](api/ReportFields.problems.md), [`Problem`](api/Problem.md), [`Session.getSceneModel`](api/Writer.getSceneModel.md), [`Session.node`](api/Writer.node.md), [`Part.exists`](api/Part.exists.md) · written for 1.x 1.5

await the write's `Report`; per-load passes become rules or `beforeCommit`; a 'models missing' notice reads the `asset-failed` problems of the latest setSceneModel and of every `select`, `undo` and `redo` after it (a sync names every node, and an equal sync still lists the missing ones, O15) and keeps the nodes the scene model still holds that are not loaded (`session.node(id).exists` false), since an undo or redo that drops a failed node reports nothing about it and a later write may load one, or checks `session.node(id).exists` for the scene model's ModelNodes after each `'committed'`, instead of pairing `'model:failed'` with a late `'model:loaded'` (app-f9a32142f1 `use-viewer.ts:271-296` at `342b26ce`)

Used by: app-fc306ec3dc.

```ts
/**
 * `'model:load-start'`, `'model:loaded'`, `'model:updated'`, `'model:removed'`: await the write's
 * Report. A "models missing" notice reads the 'asset-failed' problems of the latest
 * `setSceneModel` and of every `select`, `undo` and `redo` after it, and keeps the ModelNodes the
 * scene model still holds that are not loaded: an undo that drops a failed node reports nothing
 * about it, and a later write may load one.
 */
export function missingModels(
  session: Session,
  latestSync: Report,
  since: readonly Report[],
): string[] {
  const failed = new Set<string>();
  for (const report of [latestSync, ...since]) {
    for (const problem of report.problems) {
      if (problem.code === 'asset-failed' && problem.asset === 'model' && problem.node) {
        failed.add(problem.node);
      }
    }
  }
  const held = new Set<string>();
  const walk = (nodes: readonly DeepReadonly<ConfiguratorNode>[] | undefined): void => {
    for (const node of nodes ?? []) {
      if (node.type === 'ModelNode') held.add(node.nodeId);
      walk(node.children);
    }
  };
  walk(session.getSceneModel()?.root);
  return [...failed].filter((node) => held.has(node) && !session.node(node).exists);
}
```

<a id="c.4_model_failed"></a>
### C.4/model:failed: `'model:failed'`, `'material:failed'`

**replaced** · 2.0: [`ReportFields.problems`](api/ReportFields.problems.md), [`Events.problem`](api/Events.problem.md), [`Problem`](api/Problem.md) · written for 1.x 1.5

`Report.problems` and `'problem'` with `'asset-failed'`

Used by: app-f9a32142f1, app-fc306ec3dc.

```ts
/** `'model:failed'`, `'material:failed'`: `Report.problems`, and `'problem'` with 'asset-failed'. */
export function onAssetFailed(
  session: Session,
  handler: (problem: Problem) => void,
  signal: AbortSignal,
): () => void {
  return session.on(
    'problem',
    ({ problem }) => {
      if (problem.code === 'asset-failed') handler(problem);
    },
    { signal },
  );
}
```

<a id="c.4_selection_changed"></a>
### C.4/selection:changed: `'selection:changed'`

**removed** · written for 1.x 1.5

the viewer's object selection is not part of 2.0; the app owns it, with 1.x's rules (`viewer/controllers/interaction.controller.ts` at `eb5ce2afe`): on a `'click'` whose `hit` is non-null, select `hit.part.node`; a click on empty space keeps the selection (`:528`), so a port that clears it there records that as its own UX change; Ctrl toggles membership (`:514-522`), read from the app's own pointer listener on `el`, since `'click'` carries no modifier keys. No `'click'` follows a drag that moved (5.7), yet 1.x selected the dragged model at a mouse or pen release (`:491-501`; the tenant `'click'` is travel-filtered at `rt/session.ts:446`): select the dragged unit's `part.node` on `'drag:start'`, as app-37356bf09f's paper port does, or on a `'drag:end'` with `moved` and reason `'released'`. On `'scene-model:changed'`, drop a selected node the model no longer holds, as `forgetSubtree` did (`:262-293`). app-37356bf09f (`apps/app-37356bf09f/src/hooks/use-app-37356bf09f-assembly.ts:23-29`) and app-f9a32142f1 (`apps/app-f9a32142f1/src/components/viewer/use-viewer.ts:787-789`) port this way

Used by: app-37356bf09f, app-f9a32142f1.

<a id="c.4_hover_changed"></a>
### C.4/hover:changed: `'hover:changed'`

**replaced** · 2.0: [`Events.hover`](api/Events.hover.md), [`Hit`](api/Hit.md) · written for 1.x 1.5

`'hover'` `{ at, hit }`; mouse and pen only, and the runtime picks for it only while a listener exists

Used by: app-fd09090420.

```ts
/** `'hover:changed'` `{ object }` → `'hover'` `{ at, hit }` (mouse and pen only). */
export function onHover(
  session: Session,
  handler: (object: V1.NodeReference | null) => void,
  signal: AbortSignal,
): () => void {
  return session.on(
    'hover',
    ({ hit }) =>
      handler(hit && hit.part.node ? { nodeId: hit.part.node, name: hit.part.name } : null),
    { signal },
  );
}
```

<a id="c.4_drag_start"></a>
### C.4/drag:start: `'drag:start'`

**kept** (kept (payload changed)) · written for 1.x 1.5

`{ node, position }` → `{ part, pose, pointerId }`

Used by: app-37356bf09f, app-fc306ec3dc.

<a id="c.4_drag_end"></a>
### C.4/drag:end: `'drag:end'`

**kept** (kept (payload changed)) · written for 1.x 1.5

`{ node, position, rotationY }` → `{ part, pose, moved, reason, cause, op }`; snapping moves into `constrain` or an `attach` after the event; a refused drop (app-04c2774145's pouf) is `commit: 'app'` plus `input.revert`, as the `Input.drag` example shows

Used by: app-04c2774145, app-37356bf09f, app-4762caacdc, app-f9a32142f1, app-fc306ec3dc.

<a id="c.4_anchors_moved"></a>
### C.4/anchors:moved: `'anchors:moved'`

**kept** · written for 1.x 1.5

—

Used by: app-04c2774145, app-37356bf09f, app-378d190f1b, app-4762caacdc.

<a id="c.4_hotspot_click"></a>
### C.4/hotspot:click: `'hotspot:click'`, `'hotspot:hover'`

**kept** · written for 1.x 1.5

`hotspot.nodeId` is dropped; `'hotspot:hover'` also reports the badge with keyboard focus when the pointer is on none, where 1.4 fired only on `mouseenter` and `mouseleave` (`viewer/plugins/hotspot/hotspot-plugin.ts:621-627` at `eb5ce2afe`; WCAG 1.4.13)

Used by: app-04c2774145, app-0c773a4ee3, app-4762caacdc, app-4f16d2f6da, app-4fd23bc869, app-510d750508, app-822bb9d6aa, app-8675b3225c, app-b362a81c85, app-b4ae2fce25, app-bcb7ef21da, app-c1384707fc, app-ceb4eccc72, app-ddfb017681, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f3598fad77, app-f9a32142f1, app-fb16dd1985, app-fc07850558, app-fc306ec3dc, app-fd09090420, configurator-template.

<a id="c.4_scene_settled"></a>
### C.4/scene:settled: `'scene:settled'`

**replaced** · 2.0: [`Session.idle`](api/Session.idle.md), [`Events.committed`](api/Events.committed.md) · written for 1.x 1.5

`await session.idle()`, or `'committed'`

Used by: app-e61a0b42ad, app-f3598fad77, app-fb16dd1985.

```ts
/** `'scene:settled'` → `await session.idle()` (or the `'committed'` feed). */
export async function settled(session: Session, signal: AbortSignal): Promise<void> {
  await session.idle({ signal });
}
```

<a id="c.4_click"></a>
### C.4/click: `'click'`

**kept** (kept (payload changed)) · written for 1.x 1.5

`{ object }` → `{ at, hit }`; `object.nodeId` → `hit?.part.node`; one per touch tap

Used by: app-04c2774145, app-0c773a4ee3, app-4762caacdc, app-4f16d2f6da, app-4fd23bc869, app-510d750508, app-822bb9d6aa, app-8675b3225c, app-b362a81c85, app-b4ae2fce25, app-bcb7ef21da, app-c1384707fc, app-ceb4eccc72, app-ddfb017681, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f3598fad77, app-f9a32142f1, app-fb16dd1985, app-fc07850558, app-fc306ec3dc, app-fd09090420, configurator-template.

<a id="c.4_camera_control-start"></a>
### C.4/camera:control-start: `'camera:control-start'`, `'camera:control-end'`

**kept** · written for 1.x 1.5

pointer and touch gestures only, as 1.x: a wheel or touchpad zoom fires neither

Used by: no app in the cross-check.

<a id="c.4_camera_rest"></a>
### C.4/camera:rest: `'camera:rest'`

**kept** (kept (payload added)) · written for 1.x 1.5

`{ state }`

Used by: app-b362a81c85, app-fb16dd1985, app-fc306ec3dc.

<a id="c.4_camera_changed"></a>
### C.4/camera:changed: `'camera:changed'`

**kept** (kept (payload changed)) · written for 1.x 1.5

`{ pose }` → `{ state }`

Used by: app-e61a0b42ad.

<a id="c.4_resize"></a>
### C.4/resize: `'resize'`

**kept** · written for 1.x 1.5

—

Used by: no app in the cross-check.

<a id="c.4_animation_started"></a>
### C.4/animation:started: `'animation:started'`, `'animation:stopped'`, `'animation:finished'`

**kept** (kept (payload changed)) · written for 1.x 1.5

`{ name, nodeId }` → `{ clip, node, item }` (`item` for a collection item's clip, experimental)

Used by: app-04c2774145, app-37356bf09f, app-4762caacdc, app-4fd23bc869, app-822bb9d6aa, app-8675b3225c, app-b362a81c85, app-bcb7ef21da, app-c1384707fc, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f3598fad77, app-f9a32142f1, app-fb16dd1985, app-fc306ec3dc, app-fd09090420, configurator-template.

<a id="c.4_ar_status"></a>
### C.4/ar:status: `'ar:status'`

**kept** · written for 1.x 1.5

`status` gains a closed union plus `'other'`

Used by: app-fc07850558.

<a id="c.4_material_changed"></a>
### C.4/material:changed: `'material:changed'`

**replaced** · 2.0: [`Events.material:selected`](api/Events.material_selected.md), [`Cause`](api/Cause.md), [`Session.select`](api/Writer.select.md), [`CommitOptions.origin`](api/CommitOptions.origin.md) · written for 1.x 1.5

`'material:selected'` `{ cause, group, material }`; unlike 1.4's event, which fired for `select` only, it also fires when undo, redo or a sync changes the selection. `cause.kind` tells undo and redo; a sync and an autocommit `select` are both `'write'`, so an app that needs 1.4's event passes its own `origin` on `select` and reads `cause.origin`. `cause.actor` says who, not which call: an app's `select` on a shopper's click is `'app'` unless the app passes `actor`

Used by: app-b362a81c85, app-f3598fad77, app-fb16dd1985.

```ts
/**
 * `'material:changed'` → `'material:selected'` `{ cause, group, material }`. 1.4 fired it for a
 * `select` only; 2.0 also fires it when undo, redo or a sync changes the selection. The handler
 * gets the `cause`: `cause.kind` tells undo and redo, and `cause.origin` the app's own selects,
 * which `selectMaterial` tags. A sync is a `'write'` too, and `cause.actor` says who, not which
 * call.
 */
export const SELECT_ORIGIN = 'material-select';

export function onMaterialSelected(
  session: Session,
  handler: (group: string, material: string, cause: Cause) => void,
  signal: AbortSignal,
): () => void {
  return session.on(
    'material:selected',
    ({ cause, group, material }) => handler(group, material, cause),
    { signal },
  );
}

/** 1.4's `select(group, material)`, tagged so the handler tells it by `cause.origin`. */
export function selectMaterial(session: Session, group: string, material: string): Promise<Report> {
  return session.select(group, material, { origin: SELECT_ORIGIN });
}
```

<a id="c.4_history_changed"></a>
### C.4/history:changed: `'history:changed'`

**kept** (kept (payload added)) · written for 1.x 1.5

adds `cause`, `undoLabel`, `redoLabel`

Used by: app-04c2774145, app-37356bf09f, app-4762caacdc, app-4fd23bc869, app-822bb9d6aa, app-8675b3225c, app-b362a81c85, app-bcb7ef21da, app-c1384707fc, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f3598fad77, app-f9a32142f1, app-fb16dd1985, app-fc306ec3dc, app-fd09090420, configurator-template.

<a id="c.4_history_restored"></a>
### C.4/history:restored: `'history:restored'`

**replaced** · 2.0: [`Events.committed`](api/Events.committed.md) · written for 1.x 1.5

`'committed'` where `cause.kind` is `'undo'` or `'redo'`; its `checkpoint` is the restored page state

Used by: app-04c2774145, app-37356bf09f, app-4762caacdc, app-4fd23bc869, app-822bb9d6aa, app-8675b3225c, app-b362a81c85, app-bcb7ef21da, app-c1384707fc, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f3598fad77, app-f9a32142f1, app-fb16dd1985, app-fc306ec3dc, app-fd09090420, configurator-template.

```ts
/** `'history:restored'` → `'committed'` where `cause.kind` is 'undo' or 'redo'. */
export function onRestored(
  session: Session,
  restore: (checkpoint: JsonValue | null) => void,
  signal: AbortSignal,
): () => void {
  return session.on(
    'committed',
    ({ cause, checkpoint }) => {
      if (cause.kind === 'undo' || cause.kind === 'redo') restore(checkpoint);
    },
    { signal },
  );
}
```

<a id="c.4_scene-model_changed"></a>
### C.4/scene-model:changed: `'scene-model:changed'`

**kept** (kept (payload added)) · written for 1.x 1.5

adds `cause`; `model` is never null

Used by: app-4fd23bc869, app-822bb9d6aa, app-8675b3225c, app-b362a81c85, app-bcb7ef21da, app-c1384707fc, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f3598fad77, app-fb16dd1985, app-fd09090420, configurator-template.

<a id="c.4_scene_synced"></a>
### C.4/scene:synced: `'scene:synced'`

**replaced** · 2.0: [`Events.scene-model:changed`](api/Events.scene-model_changed.md) · written for 1.x 1.5

`'scene-model:changed'`, which fires for each commit that changed the scene model, with `cause` and the model. 1.4 fired `'scene:synced'` after every scene sync (`viewer/configurator-viewer.ts:2174` at `eb5ce2afe`); an equal sync now fires nothing (O15). `'committed'` filtered on `recorded` is the wider choice, since it also counts commits that change only the checkpoint or the connections; unfiltered, it fires for look-only commits too, a rule or a camera move

Used by: app-04c2774145, app-0c773a4ee3, app-37356bf09f, app-4762caacdc, app-4fd23bc869, app-822bb9d6aa, app-8675b3225c, app-b362a81c85, app-bcb7ef21da, app-c1384707fc, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f3598fad77, app-f9a32142f1, app-fb16dd1985, app-fc306ec3dc, app-fd09090420, configurator-template.

```ts
/**
 * `'scene:synced'` → `'scene-model:changed'`, once per commit that changed the scene model; an
 * equal sync fires nothing. `'committed'` filtered on `recorded` is the wider choice.
 */
export function onSceneModelChanged(
  session: Session,
  handler: (model: DeepReadonly<SceneModel>) => void,
  signal: AbortSignal,
): () => void {
  return session.on('scene-model:changed', ({ model }) => handler(model), { signal });
}
```

<a id="c.4_document_applied"></a>
### C.4/document:applied: `'document:applied'`, `'problem'`

**kept** · written for 1.x 1.5

—

Used by: app-e61a0b42ad, app-fc07850558.

<a id="c.4_xr_start"></a>
### C.4/xr:start: `'xr:start'`, `'xr:end'`, `'xr:cta'` (1.6)

**kept** (kept (experimental)) · written for 1.x 1.6

2.0's experimental XR events, typed in `Events`: `'xr:start'` `{ presenter, experience }`, `'xr:end'` `{ reason, durationMs }` and `'xr:cta'` `{ action, configuration }` (`'xr:state'` and `'xr:variant'` are not in 2.0.0: they wait for XR's P3); v1's unversioned `'xr:session-start'` and `'xr:session-end'` (`@holo/xr`'s) map to `'xr:start'` and `'xr:end'`, so one handler fits both lines, except that 2.0 names `presenter` as `ActivateARReport.mode` does: 1.x's `'holostep-webxr'`, and `'other'` with `experience: 'place'` (the iOS camera route), are `'webxr'`, and `'appclip'` and `'none'` are `'other'` (delivery-run 2026-10-20; DECIDED: XR alignment reply §3, Erik; R3-API-9)

Used by: no app in the cross-check.

<a id="c.5"></a>
## C.5 Internals of the native session (absent from 2.0, D5)

<a id="c.5_on"></a>
### C.5/on: `on(event, handler)` (the unversioned stream with engine objects)

**replaced** · 2.0: [`Session.on`](api/Session.on.md) · written for 1.x 1.5

the typed `on`

Used by: no app in the cross-check.

```ts
/** The unversioned native `on(event, handler)` → the typed `session.on`. */
export function onClick(
  session: Session,
  handler: (nodeId: string | null) => void,
  signal: AbortSignal,
): () => void {
  return session.on('click', ({ hit }) => handler(hit?.part.node ?? null), { signal });
}
```

<a id="c.5_requestframe"></a>
### C.5/requestFrame: `requestFrame()`

**removed** · written for 1.x 1.5

writes autocommit and draw; `onFrame(cb, { continuous: true })` keeps drawing

Used by: app-04c2774145, app-0c773a4ee3, app-4762caacdc, app-b362a81c85, app-bcb7ef21da, app-c1384707fc, app-e61a0b42ad, app-f07e9033d9, app-f3598fad77, app-fb16dd1985, app-fd09090420.

<a id="c.5_scene"></a>
### C.5/scene: `scene`

**removed** · written for 1.x 1.5

`find()`, `Part`, rules and collections

Used by: app-04c2774145, app-0c773a4ee3, app-37356bf09f, app-378d190f1b, app-4762caacdc, app-4f16d2f6da, app-4fd23bc869, app-510d750508, app-59c5b5751e, app-822bb9d6aa, app-8675b3225c, app-b362a81c85, app-b4ae2fce25, app-bcb7ef21da, app-c1384707fc, app-ceb4eccc72, app-ddfb017681, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f3598fad77, app-f9a32142f1, app-fb16dd1985, app-fc07850558, app-fc306ec3dc, app-fd09090420, configurator-template.

<a id="c.5_scenemodel"></a>
### C.5/sceneModel: `sceneModel`

**replaced** · 2.0: [`Session.getSceneModel`](api/Writer.getSceneModel.md) · written for 1.x 1.5

`session.getSceneModel()`

Used by: app-04c2774145, app-4762caacdc, app-f9a32142f1.

```ts
/** `session.sceneModel` → `session.getSceneModel()`. */
export function sceneModel(session: Session): DeepReadonly<SceneModel> | null {
  return session.getSceneModel();
}
```

<a id="c.5_nodes.find"></a>
### C.5/nodes.find: `nodes.find(nodeId)`

**replaced** · 2.0: [`Session.getSceneModel`](api/Writer.getSceneModel.md) · written for 1.x 1.5

walk `session.getSceneModel()`

Used by: no app in the cross-check.

```ts
/** `nodes.find(nodeId)` → a walk over `session.getSceneModel()`. */
export function findNode(session: Session, nodeId: string): DeepReadonly<ConfiguratorNode> | null {
  const walk = (
    nodes: readonly DeepReadonly<ConfiguratorNode>[] | undefined,
  ): DeepReadonly<ConfiguratorNode> | null => {
    for (const node of nodes ?? []) {
      if (node.nodeId === nodeId) return node;
      const found = walk(node.children);
      if (found) return found;
    }
    return null;
  };
  return walk(session.getSceneModel()?.root);
}
```

<a id="c.5_nodes.object"></a>
### C.5/nodes.object: `nodes.object(nodeId)`

**replaced** · 2.0: [`Session.node`](api/Writer.node.md) · written for 1.x 1.5

`session.node(nodeId)`, the model's root part (`find({ node })` is every part of it)

Used by: app-4f16d2f6da, app-b362a81c85, app-b4ae2fce25, app-bcb7ef21da, app-ddfb017681, app-fc07850558, app-fc306ec3dc.

```ts
/** `nodes.object(nodeId)` → `session.node(nodeId)`, the model's root part. */
export function modelRoot(session: Session, nodeId: string): Part<CommitOptions> {
  return session.node(nodeId);
}
```

<a id="c.5_nodes.ownerof"></a>
### C.5/nodes.ownerOf: `nodes.ownerOf(object)`

**replaced** · 2.0: [`Hit.part`](api/Hit.part.md), [`Part.node`](api/Part.node.md) · written for 1.x 1.5

`hit.part.node`

Used by: app-0c773a4ee3.

```ts
/** `nodes.ownerOf(object)` → `hit.part.node`. */
export const ownerOf = (hit: Hit): string | null => hit.part.node;
```

<a id="c.5_nodes.setvisible"></a>
### C.5/nodes.setVisible: `nodes.setVisible(nodeId, visible)`

**replaced** · 2.0: [`Part.set`](api/PartActions.set.md), [`PartState.visible`](api/PartState.visible.md) · written for 1.x 1.5

a look-only hide: `session.node(nodeId).set({ visible })`, reset with `{ visible: null }`. To take the node out of the configuration (priced, saved), set `enabled: false` in the scene model instead

Used by: app-4f16d2f6da, app-b362a81c85.

```ts
/**
 * `nodes.setVisible(nodeId, visible)` → a look-only hide, reset with `{ visible: null }`. To take
 * the node out of the configuration (priced, saved), set `enabled: false` in the scene model.
 */
export function setVisible(session: Session, nodeId: string, visible: boolean): Promise<Report> {
  return session.node(nodeId).set({ visible });
}

/** Drops the hide: the part shows what is underneath again. */
export function resetVisible(session: Session, nodeId: string): Promise<Report> {
  return session.node(nodeId).set({ visible: null });
}
```

<a id="c.5_plugin"></a>
### C.5/plugin: `plugin(id)`

**replaced** · 2.0: [`Input.drag`](api/Input.drag.md), [`Session.assembly`](api/Writer.assembly.md), [`Overlays.dimensions`](api/Overlays.dimensions.md), [`Session.select`](api/Writer.select.md), [`Session.capture`](api/Session.capture.md), [`Session.history`](api/Session.history.md), [`Session.camera`](api/Writer.camera.md), [`Session.stage`](api/Writer.stage.md) · written for 1.x 1.5

the member that replaces that plugin: drag → `input.drag`; snap connectors → `assembly`; dimensions → `overlays.dimensions`; material swap and global materials → rules and `select`; screenshot → `capture`; scene history → `history`; camera controls → `camera`; post-process → `stage.ambientOcclusion` and `stage.bloom` through `stage.set` (C.6's `setAO` row)

Used by: app-04c2774145, app-4762caacdc, app-510d750508, app-b362a81c85, app-bcb7ef21da, app-c1384707fc, app-ceb4eccc72, app-e61a0b42ad, app-f07e9033d9, app-f9a32142f1, app-fb16dd1985, app-fc07850558, app-fc306ec3dc.

```ts
/** `plugin(id)` → the member that replaces that plugin. */
export function pluginMembers(session: Session) {
  return {
    drag: session.input.drag,
    snapConnectors: session.assembly,
    dimensions: session.overlays.dimensions,
    materialSwap: session.select,
    screenshot: session.capture,
    sceneHistory: session.history,
    cameraControls: session.camera,
    postProcess: session.stage,
  };
}
```

<a id="c.5_register"></a>
### C.5/register: `register(plugin)`, `unregister(name)`

**replaced** · 2.0: [`Session.onFrame`](api/Session.onFrame.md), [`Session.collection`](api/Writer.collection.md), [`Session.beforeCommit`](api/Session.beforeCommit.md) · written for 1.x 1.5

collections, `onFrame`, rules and `beforeCommit`

Used by: app-0c773a4ee3, app-378d190f1b, app-4f16d2f6da, app-510d750508, app-59c5b5751e, app-b362a81c85, app-b4ae2fce25, app-ceb4eccc72, app-ddfb017681, app-f9a32142f1, app-fc07850558, app-fc306ec3dc.

```ts
/** `register(plugin)`, `unregister(name)` → `onFrame` (and collections, rules, `beforeCommit`). */
export function registerFrameLogic(
  session: Session,
  everyFrame: (frame: Frame) => void,
  signal: AbortSignal,
): () => void {
  return session.onFrame(everyFrame, { signal });
}
```

<a id="c.5_viewer"></a>
### C.5/viewer: `viewer`

**removed** · written for 1.x 1.5

the host element is the app's own `el`; style through `::part()`; size from `'resize'`

Used by: app-04c2774145, app-0c773a4ee3, app-37356bf09f, app-4762caacdc, app-4f16d2f6da, app-4fd23bc869, app-510d750508, app-59c5b5751e, app-822bb9d6aa, app-8675b3225c, app-b362a81c85, app-bcb7ef21da, app-c1384707fc, app-ceb4eccc72, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f3598fad77, app-f9a32142f1, app-fb16dd1985, app-fc07850558, app-fc306ec3dc, app-fd09090420, configurator-template.

<a id="c.5_engine-type-profiles"></a>
### C.5/engine-type-profiles: engine type profiles

**removed** · written for 1.x 1.5

the engine-free `/contract` profile is the only one

Used by: no app in the cross-check.

<a id="c.5_window.__holosession"></a>
### C.5/window.__holoSession: app-side `window.__holoSession`, `window.__snapPlugin`

**replaced** · 2.0: [`Assembly.connections`](api/Assembly.connections.md) · written for 1.x 1.5

dev and E2E builds only; `assembly.connections()` for connector reads

Used by: app-04c2774145, app-4762caacdc.

```ts
/** `window.__holoSession`, `window.__snapPlugin` (dev and E2E builds only) → `assembly.connections()`. */
export function connectionsForE2E(session: Session): readonly Connection[] {
  return session.assembly.connections();
}
```

<a id="c.6"></a>
## C.6 Hatch-level patterns (what apps reach through `runtime-escape-hatches.ts`)

Revision 3 mapped every public 1.4 member but only the coarse internals. Round 6 found about 33 patterns that ports meet inside the hatch files and plugin calls (fleet report, F7; pilot report, F18). Each row becomes a `migration.json` entry with a compiled recipe.

<a id="c.6_playanimation"></a>
### C.6/playAnimation: viewer element `playAnimation(name)` (app-04c2774145, app-fd09090420, app-fb16dd1985; app-b362a81c85's and app-0c773a4ee3's doors, K64)

**replaced** · 2.0: [`Session.animations`](api/Writer.animations.md), [`Animations.play`](api/Animations.play.md), [`PlayOptions.toggle`](api/PlayOptions.toggle.md) · written for 1.x 1.5

`session.animations.play(node, clip, { toggle: true })`

Used by: app-04c2774145, app-0c773a4ee3, app-4762caacdc, app-b362a81c85, app-bcb7ef21da, app-f9a32142f1, app-fb16dd1985, app-fd09090420.

```ts
/** Viewer element `playAnimation(name)`. */
export function playAnimation(session: Session, node: string, clip: string): Promise<Report> {
  return session.animations.play(node, clip, { toggle: true });
}
```

<a id="c.6_resetanimations"></a>
### C.6/resetAnimations: viewer element `resetAnimations()` (app-04c2774145, app-f9a32142f1, app-b362a81c85, app-0c773a4ee3)

**replaced** · 2.0: [`Session.animations`](api/Writer.animations.md), [`Animations.reset`](api/Animations.reset.md) · written for 1.x 1.5

`session.animations.reset(node?)`; inside a transaction or `beforeCommit` it is staged, so reads see frame 0 at once. It also clears each clip's last direction, so the next `toggle` plays forward, as 1.x set every clip forward (`viewer/controllers/animation.controller.ts:366-379` at `eb5ce2afe`). Unlike 1.x's silent reset (`:362-366`), it fires `'animation:stopped'` for each playing clip, so delete the hand counter reset (app-04c2774145 `use-viewer.ts:876-883` at `c4c6ffd2`)

Used by: app-04c2774145, app-0c773a4ee3, app-4762caacdc, app-b362a81c85, app-bcb7ef21da, app-f9a32142f1, app-fd09090420.

```ts
/** Viewer element `resetAnimations()`, without a hand-maintained animation counter reset. */
export function resetAnimations(session: Session, node?: string): Promise<Report> {
  return session.animations.reset(node);
}
```

<a id="c.6_stopallanimations"></a>
### C.6/stopAllAnimations: viewer element `stopAllAnimations()`

**replaced** · 2.0: [`Session.animations`](api/Writer.animations.md), [`Animations.list`](api/Animations.list.md), [`Animations.pause`](api/Animations.pause.md), [`ClipRef.node`](api/ClipRef.node.md), [`ClipRef.clip`](api/ClipRef.clip.md) · written for 1.x 1.5

`for (const c of session.animations.list()) if (c.node) void session.animations.pause(c.node, c.clip)`

Used by: app-0c773a4ee3, app-b362a81c85, app-bcb7ef21da, app-fd09090420.

```ts
/** Viewer element `stopAllAnimations()`. */
export function stopAllAnimations(session: Session): void {
  for (const c of session.animations.list()) {
    if (c.node) void session.animations.pause(c.node, c.clip);
  }
}
```

<a id="c.6_setmodelprs"></a>
### C.6/setModelPRS: viewer element `setModelPRS(nodeId, prs, { animate: false })` to place a node (app-04c2774145 `position-sync.ts`)

**replaced** · 2.0: [`Session.applyPatch`](api/Writer.applyPatch.md), [`JsonPatchOp`](api/JsonPatchOp.md) · written for 1.x 1.5

a recorded move: `session.applyPatch([{ op: 'replace', path: '/model/node/' + id + '/position', value }])` (and `/rotation`). Not a pose rule: that is look-only and never saved

Used by: app-04c2774145, app-4762caacdc, app-c1384707fc, app-f3598fad77, app-f9a32142f1.

```ts
/** Viewer element `setModelPRS(nodeId, prs, { animate: false })`, recorded in the model. */
export function setModelPrs(
  session: Session,
  id: string,
  position: V1.IModelNode['position'],
  rotation: V1.IModelNode['rotation'],
): Promise<Report> {
  const path = '/model/node/' + id.replace(/~/g, '~0').replace(/\//g, '~1');
  return session.applyPatch([
    { op: 'replace', path: path + '/position', value: { ...position } },
    { op: 'replace', path: path + '/rotation', value: { ...rotation } },
  ]);
}
```

<a id="c.6_setmodelprs_2"></a>
### C.6/setModelPRS~2: viewer element `setModelPRS(…, { animate: true })`, a tween (app-f3598fad77)

**replaced** · 2.0: [`Session.node`](api/Writer.node.md), [`Part.pose`](api/Part.pose.md), [`Part.set`](api/PartActions.set.md), [`PartState.pose`](api/PartState.pose.md), [`PoseInput`](api/PoseInput.md), [`PartWriteOptions.durationMs`](api/PartWriteOptions.durationMs.md), [`Report`](api/Report.md) · written for 1.x 1.5

a slide tied to a scene write (app-f3598fad77's module swap, `apps/app-f3598fad77/src/components/viewer/animate-scene-swap.ts:293-320` at `c4c6ffd2`) takes pose rules on the model roots: before the sync, a 0 ms `node(id).set({ pose })` holds each moving survivor where it is drawn; right after the sync is called, another starts each newcomer at 1 mm, which waits for the sync's node (`Report.pending`) instead of recording 'target-unmatched' (`Writer.node`, Q4); when the sync's Report resolves `'committed'`, `set({ pose }, { durationMs })` tweens them to their places, from the pose drawn last, a newcomer to its destination's `scale` (1 when left out: a pose write merges field by field, so the 1 mm would stay, R2-DOC-3); release the rules with `null` when the slide is off, since a pose rule beats the scene model's pose (R7; the app-f3598fad77 paper port). A tween with no scene write is an `onFrame` hook writing `frame.pose(session.node(id), …)`, then the recorded patch above

Used by: app-04c2774145, app-4762caacdc, app-c1384707fc, app-f3598fad77, app-f9a32142f1.

```ts
/**
 * Viewer element `setModelPRS(..., { animate: true })` around a module-swap sync. `destination`
 * is the node's pose in the synced model; a newcomer grows from 1 mm to its `scale` (1 when
 * left out), because a later pose write merges into the starting one field by field.
 */
export async function slideSceneSwap(
  session: Session,
  sync: () => Promise<Report>,
  moving: readonly { id: string; newcomer: boolean; destination: PoseInput }[],
  durationMs: number,
  slide: boolean,
): Promise<Report> {
  if (!slide) {
    for (const move of moving) await session.node(move.id).set({ pose: null });
    return sync();
  }
  // Placed modules stay where they are drawn until the sync has moved them.
  for (const move of moving.filter((m) => !m.newcomer)) {
    const { position, quaternion, scale } = session.node(move.id).pose;
    await session.node(move.id).set({ pose: { position, quaternion, scale } }, { durationMs: 0 });
  }
  // A newcomer's node exists once the sync commits: its starting rule, written after the sync
  // was called, waits for that node instead of recording 'target-unmatched'.
  const syncing = sync();
  const starts = moving
    .filter((m) => m.newcomer)
    .map((m) =>
      session.node(m.id).set({ pose: { ...m.destination, scale: 0.001 } }, { durationMs: 0 }),
    );
  const report = await syncing;
  await Promise.all(starts);
  if (report.outcome === 'committed') {
    await Promise.all(
      moving.map(({ id, newcomer, destination }) => {
        // a placed module keeps its held scale (a mirror's −1 among them) unless one is given
        const pose = newcomer ? { ...destination, scale: destination.scale ?? 1 } : destination;
        return session.node(id).set({ pose }, { durationMs });
      }),
    );
  }
  return report;
}
```

<a id="c.6_updatecontrols"></a>
### C.6/updateControls: viewer element `updateControls(options)` (app-04c2774145; app-4f16d2f6da also passes `minPolarAngle` and `truckAxes`, `pergola-viewer.tsx:124-135` at `342b26ce`)

**replaced** · 2.0: [`Camera.limits`](api/Camera.limits.md), [`CameraLimits.panAxes`](api/CameraLimits.panAxes.md), [`CameraLimits.minPolarDeg`](api/CameraLimits.minPolarDeg.md), [`Camera.mode`](api/Camera.mode.md) · written for 1.x 1.5

`session.camera.limits({ … })` (a merge patch, so distance and collision can be set by separate calls) and `session.camera.mode(…)`; `truckAxes: 'none'` is not needed once the target is bounded by `limits({ target })` (app-4f16d2f6da set it only when it could not bound the target); `truckAxes: 'horizontal'` is `limits({ panAxes: 'horizontal' })`

Used by: app-04c2774145, app-4762caacdc, app-4f16d2f6da, app-ceb4eccc72, app-f9a32142f1, app-fc306ec3dc.

```ts
/**
 * Viewer element `updateControls(options)`: `camera.limits` (a merge patch; radians become
 * degrees) and `camera.mode`. `truckAxes: 'horizontal'` is `panAxes: 'horizontal'`; `'none'` is
 * not needed once the target is bounded by `limits({ target })`.
 */
export async function updateControls(
  session: Session,
  controls: {
    minDistance?: number;
    maxDistance?: number;
    minPolarAngle?: number;
    maxPolarAngle?: number;
    truckAxes?: 'horizontal' | 'none';
  },
  mode?: 'orbit' | 'pan' | 'fixed',
): Promise<void> {
  const limits: CameraLimits = {};
  if (controls.minDistance !== undefined) limits.minDistance = controls.minDistance;
  if (controls.maxDistance !== undefined) limits.maxDistance = controls.maxDistance;
  if (controls.minPolarAngle !== undefined) {
    limits.minPolarDeg = angle.radToDeg(controls.minPolarAngle);
  }
  if (controls.maxPolarAngle !== undefined) {
    limits.maxPolarDeg = angle.radToDeg(controls.maxPolarAngle);
  }
  if (controls.truckAxes) limits.panAxes = controls.truckAxes;
  await session.camera.limits(limits);
  if (mode) await session.camera.mode(mode);
}
```

<a id="c.6_plugincontext.getcameracontrols"></a>
### C.6/PluginContext.getCameraControls: camera controls through `PluginContext.getCameraControls()`: `minDistance`, `maxDistance`, `minPolarAngle`, `maxPolarAngle` fields, `setBoundary(box)`, `getFocalOffset`, `setFocalOffset(x, y, z, true)` (app-fc07850558 `runtime-escape-hatches.ts:119-172` at `5cdd2780`, app-4f16d2f6da `:105-119, 353-384` at `342b26ce`; app-fc306ec3dc)

**replaced** · 2.0: [`Session.camera`](api/Writer.camera.md), [`Camera.limits`](api/Camera.limits.md), [`CameraLimits.minDistance`](api/CameraLimits.minDistance.md), [`CameraLimits.maxDistance`](api/CameraLimits.maxDistance.md), [`CameraLimits.minPolarDeg`](api/CameraLimits.minPolarDeg.md), [`CameraLimits.maxPolarDeg`](api/CameraLimits.maxPolarDeg.md), [`CameraLimits.target`](api/CameraLimits.target.md), [`Camera.state`](api/Camera.state.md), [`CameraState.focalOffset`](api/CameraState.focalOffset.md), [`Camera.set`](api/Camera.set.md), [`CameraView.focalOffset`](api/CameraView.focalOffset.md), [`CameraMoveOptions.durationMs`](api/CameraMoveOptions.durationMs.md), `angle` · written for 1.x 1.5

`session.camera.limits({ minDistance, maxDistance, minPolarDeg, maxPolarDeg, target: { min, max } })` (radians become degrees); a focal-offset read is `camera.state.focalOffset`; an eased offset is `camera.set({...view, focalOffset }, { durationMs })` from `camera.state`, or `camera.lens({ insets })` when it makes room for a sheet (5.6)

Used by: app-4f16d2f6da, app-510d750508, app-b362a81c85, app-b4ae2fce25, app-ceb4eccc72, app-ddfb017681, app-fc07850558, app-fc306ec3dc.

```ts
/** `PluginContext.getCameraControls()` limits, boundary and focal-offset access. */
export async function cameraControls(
  session: Session,
  minDistance: number,
  maxDistance: number,
  minPolarAngle: number,
  maxPolarAngle: number,
  min: V1.Point3,
  max: V1.Point3,
  focalOffset: V1.Point3,
  durationMs: number,
): Promise<V1.Point3> {
  await session.camera.limits({
    minDistance,
    maxDistance,
    minPolarDeg: angle.radToDeg(minPolarAngle),
    maxPolarDeg: angle.radToDeg(maxPolarAngle),
    target: { min, max },
  });
  const { target, azimuthDeg, polarDeg, distance, focalOffset: previous } = session.camera.state;
  const view = { target, azimuthDeg, polarDeg, distance };
  // Use an orbit view: a LookAtView would clear the focal offset.
  await session.camera.set({ ...view, focalOffset }, { durationMs });
  return previous;
}
```

<a id="c.6_cameracontrols.enabled-false"></a>
### C.6/CameraControls.enabled-false: `CameraControls.enabled = false` while the app sets the view every frame, with `minDistance` and `maxDistance` pinned to the eye and all three put back on dispose (app-ddfb017681's panorama and collider-overlay plugins, `apps/app-ddfb017681/src/viewer-plugins/panorama-plugin.ts:498-510, 970-978`, `collider-overlay-plugin.ts:526-529`; estate's copies, `apps/app-b4ae2fce25/src/viewer-plugins/panorama-plugin.ts:507`, `collider-overlay-plugin.ts:479`, at `be828d5d`)

**replaced** · 2.0: [`Session.camera`](api/Writer.camera.md), [`Camera.mode`](api/Camera.mode.md), [`Session.onFrame`](api/Session.onFrame.md), [`Frame`](api/Frame.md), [`Frame.camera`](api/Frame.camera.md), [`Frame.timeMs`](api/Frame.timeMs.md), [`FrameOptions.continuous`](api/FrameOptions.continuous.md), [`FrameOptions.signal`](api/FrameOptions.signal.md), [`CameraView`](api/CameraView.md) · written for 1.x 1.5

`session.camera.mode('fixed')`: the shopper's orbit, pan and dolly are off and the wheel is left to the page; the view of each frame is `frame.camera(view)` in an `onFrame(…, { continuous: true, signal })` hook (`Frame.camera`, 5.12, K41), which commits nothing. Leaving is `camera.mode('orbit')` and aborting the hook, after which the committed view draws again. The distance pins go: `Frame.camera` holds the view

Used by: app-b4ae2fce25, app-ddfb017681.

```ts
/**
 * app-ddfb017681's and estate's `controls.enabled = false` with the view set every frame:
 * `camera.mode('fixed')` and `Frame.camera` in a continuous hook. The returned call leaves: the
 * hook ends, so the committed view draws again, and the shopper orbits again.
 */
export async function driveCamera(
  session: Session,
  viewAt: (timeMs: number) => CameraView,
): Promise<() => Promise<Report>> {
  await session.camera.mode('fixed');
  const hook = new AbortController();
  session.onFrame((frame) => frame.camera(viewAt(frame.timeMs)), {
    continuous: true,
    signal: hook.signal,
  });
  return () => {
    hook.abort();
    return session.camera.mode('orbit');
  };
}
```

<a id="c.6_controlend"></a>
### C.6/controlend: app-4f16d2f6da's always-on camera guard: the polar and azimuth clamps written again on every `'controlend'` and before a frame, and the orbit pivot lowered to the floor after each fit, vertical pan left free (`apps/app-4f16d2f6da/src/lib/pergola-plugins/pergola-camera-guard-plugin.ts:10-55, 264-325` at `c4c6ffd2`)

**replaced** · 2.0: [`Session.camera`](api/Writer.camera.md), [`Camera.limits`](api/Camera.limits.md), [`CameraLimits.maxPolarDeg`](api/CameraLimits.maxPolarDeg.md), [`CameraLimits.minAzimuthDeg`](api/CameraLimits.minAzimuthDeg.md), [`CameraLimits.maxAzimuthDeg`](api/CameraLimits.maxAzimuthDeg.md), [`Session.find`](api/Writer.find.md), [`Parts.bounds`](api/PartActions.bounds.md), [`Bounds`](api/Bounds.md), [`Camera.state`](api/Camera.state.md), [`CameraState.fovDeg`](api/CameraState.fovDeg.md), [`CameraState.aspect`](api/CameraState.aspect.md), [`CameraState.position`](api/CameraState.position.md), [`CameraState.target`](api/CameraState.target.md), [`Camera.set`](api/Camera.set.md), [`LookAtView`](api/LookAtView.md), [`CameraMoveOptions.durationMs`](api/CameraMoveOptions.durationMs.md), `angle`, `vec3` · written for 1.x 1.5

the clamps become persistent `camera.limits({ maxPolarDeg: 90, minAzimuthDeg: 9.8, maxAzimuthDeg: 170.2 })` (E0; a baked scene takes its manifest's angle), and app limits are sticky, so the `'controlend'` re-clamp and the before-frame restore go; the ground-pinned framing is app math on `/math`, as app-fc306ec3dc's (G43): `bounds()` plus the padding, `camera.state.fovDeg` and `aspect` give the eye point, written as one `camera.set({ position, target: { x, y: 0, z } }, { durationMs })` (a baked scene: the box's lowest y). `camera.fit` keeps the box-centre pivot, and a `camera.set` after it would play a second move; no `target` box, whose faces would stop the vertical pan app-4f16d2f6da keeps

Used by: app-4f16d2f6da, app-ceb4eccc72.

```ts
/** app-4f16d2f6da's `controlend` re-clamps and ground-pinned framing. */
export async function frameFromGround(
  session: Session,
  query: Query,
  paddingM: number,
  durationMs: number,
): Promise<Report | null> {
  await session.camera.limits({ maxPolarDeg: 90, minAzimuthDeg: 9.8, maxAzimuthDeg: 170.2 });
  const bounds = session.find(query).bounds();
  if (!bounds) return null;
  const { x, z } = bounds.center;
  const target = { x, y: 0, z };
  const state = session.camera.state;
  const direction = vec3.normalize(vec3.sub(state.position, state.target));
  const right = vec3.normalize(vec3.cross({ x: 0, y: 1, z: 0 }, direction));
  const up = vec3.cross(direction, right);
  const tanY = Math.tan(angle.degToRad(state.fovDeg) / 2);
  const tanX = tanY * state.aspect;
  let distance = paddingM;
  for (const bx of [bounds.min.x - paddingM, bounds.max.x + paddingM]) {
    for (const by of [bounds.min.y - paddingM, bounds.max.y + paddingM]) {
      for (const bz of [bounds.min.z - paddingM, bounds.max.z + paddingM]) {
        const corner = vec3.sub({ x: bx, y: by, z: bz }, target);
        distance = Math.max(
          distance,
          vec3.dot(corner, direction) + Math.abs(vec3.dot(corner, right)) / tanX,
          vec3.dot(corner, direction) + Math.abs(vec3.dot(corner, up)) / tanY,
        );
      }
    }
  }
  const position = vec3.add(target, vec3.scale(direction, distance));
  return session.camera.set({ position, target: { x, y: 0, z } }, { durationMs });
}
```

<a id="c.6_camera.fov"></a>
### C.6/camera.fov: viewer element camera lens: `camera.fov`, `setViewOffset`, `setHorizontalFov` (app-04c2774145, app-fd09090420, app-fc306ec3dc; app-bcb7ef21da's phone stage inset, `framed-view.ts:50-53` at `342b26ce`; chair and app-378d190f1b, which hold a vertical field of view every frame, and app-e3b47169bc's preview)

**replaced** · 2.0: [`Session.camera`](api/Writer.camera.md), [`Camera.lens`](api/Camera.lens.md), [`CameraLens.fovDeg`](api/CameraLens.fovDeg.md), [`CameraLens.horizontalFovDeg`](api/CameraLens.horizontalFovDeg.md), [`CameraLens.insets`](api/CameraLens.insets.md), [`Insets`](api/Insets.md), [`CameraMoveOptions.durationMs`](api/CameraMoveOptions.durationMs.md), [`Camera.fit`](api/Camera.fit.md) · written for 1.x 1.5

`session.camera.lens({ fovDeg | horizontalFovDeg, insets }, { durationMs })`; chair, app-378d190f1b and app-e3b47169bc write `camera.lens({ fovDeg: 40 })`, `42` and `45` once (C.7's `viewer.camera.fov` row); app-bcb7ef21da's inset frustum math (`stage-inset.ts`) is then `camera.lens({ insets: { top: 10, right: 0, bottom: toolbarBandPx, left: 0 } })` plus `camera.fit()` (`Insets` has four required sides)

Note: A transient screen-space shift is not an inset-plus-fit: app-fd09090420's mobile sheet (`startViewOffsetGlide`, `panel-glide.ts`) holds the picture where it was when the stage's height changes by `delta`, then eases the offset to zero over 260 ms. In 2.0 that is `holdThroughResize` above: in the same task as the resize (the layout effect), `camera.lens({ insets: held }, { durationMs: 0 })`, then `camera.lens({ insets: zero }, { durationMs: 260, easing: 'easeOut' })`, and no `camera.fit()`. `Insets` are 0 or more, so the held inset is `bottom: delta` for a growing stage and `top: -delta` for a shrinking one. `CameraMoveOptions.durationMs` says the eased write starts from the 0 ms one (it jumps, then glides), and `Camera.lens` says the lens is drawn in the frame that shows the new aspect, so the picture never jumps; reduced motion lands at zero at once, as the 1.x hatch did.

Used by: app-bcb7ef21da, app-fd09090420.

```ts
/** Camera `fov`, `setHorizontalFov` and the phone stage's `setViewOffset`. */
export async function setCameraLens(
  session: Session,
  lens: { fovDeg: number } | { horizontalFovDeg: number },
  toolbarBandPx: number,
  durationMs: number,
): Promise<Report> {
  await session.camera.lens(
    { ...lens, insets: { top: 10, right: 0, bottom: toolbarBandPx, left: 0 } },
    { durationMs },
  );
  return session.camera.fit();
}

/**
 * A transient `setViewOffset` glide (app-fd09090420's mobile sheet): when the stage's height changes
 * by `deltaPx` (new minus old, CSS px), hold the picture where it was in the same task as the
 * resize (a layout effect), then ease the offset back to zero. No fit, no view write. `Insets`
 * are 0 or more, so a growing stage holds `bottom: delta` and a shrinking one `top: -delta`.
 */
export function holdThroughResize(
  session: Session,
  deltaPx: number,
  durationMs = 260,
): Promise<Report> {
  const held: Insets =
    deltaPx >= 0
      ? { top: 0, right: 0, bottom: deltaPx, left: 0 }
      : { top: -deltaPx, right: 0, bottom: 0, left: 0 };
  // drawn in the frame that shows the new aspect; the eased write starts from it
  void session.camera.lens({ insets: held }, { durationMs: 0 });
  return session.camera.lens(
    { insets: { top: 0, right: 0, bottom: 0, left: 0 } },
    { durationMs, easing: 'easeOut' },
  );
}
```

<a id="c.6_getcameraview"></a>
### C.6/getCameraView: viewer element `getCameraView()` read through the DOM (app-822bb9d6aa's showroom camera: a `querySelector('holo-configurator-viewer')` read of the fitted view, then the showroom view written twice, `apps/app-822bb9d6aa/src/components/viewer/initial-camera.ts:44-71` at `be828d5d`)

**replaced** · 2.0: [`Session.camera`](api/Writer.camera.md), [`Camera.fit`](api/Camera.fit.md), [`CameraMoveOptions.durationMs`](api/CameraMoveOptions.durationMs.md), [`Camera.state`](api/Camera.state.md), [`CameraState`](api/CameraState.md), [`Camera.set`](api/Camera.set.md), [`CameraView`](api/CameraView.md) · written for 1.x 1.5

`session.camera.state` after an awaited `session.camera.fit(undefined, { durationMs: 0 })`: a `durationMs: 0` write reads back from when it starts, so read after the await (5.6); then one `session.camera.set(view, { durationMs: 0 })`, which lands on the view itself, so the second write goes

Used by: app-822bb9d6aa, app-e61a0b42ad, app-f07e9033d9, app-fc306ec3dc.

```ts
/** DOM `getCameraView()` after fitting, followed by one showroom-view write. */
export async function fittedCameraView(
  session: Session,
  view: V1.CameraView,
): Promise<CameraState> {
  await session.camera.fit(undefined, { durationMs: 0 });
  const fitted = session.camera.state;
  await session.camera.set(
    {
      target: view.target,
      azimuthDeg: view.azimuth,
      polarDeg: view.polar,
      distance: view.radius,
      focalOffset: view.focalOffset,
    },
    { durationMs: 0 },
  );
  return fitted;
}
```

<a id="c.6_renderer.setsize"></a>
### C.6/renderer.setSize: viewer element `renderer.setSize`, `resizeViewerCanvas` (app-fd09090420)

**runtime-owned** · written for 1.x 1.5

the aspect follows a resize at once (K37)

Used by: app-4f16d2f6da, app-510d750508, app-ceb4eccc72, app-e3b47169bc, app-fc07850558, app-fd09090420.

<a id="c.6_shadowmap.needsupdate"></a>
### C.6/shadowMap.needsUpdate: viewer element `shadowMap.needsUpdate`, `shadowMap` settings (app-04c2774145, pergola; app-fc07850558 and app-fc306ec3dc reach it through `session.viewer.renderer`, app-fc07850558 `runtime-escape-hatches.ts:209-224`, app-fc306ec3dc `shadow-refresh.ts:10-21`, at `5cdd2780`)

**runtime-owned** · written for 1.x 1.5

shadows refresh as B.4 X11 says; the map per tier is the runtime's (`ShadowSpec`)

Used by: app-fc306ec3dc.

<a id="c.6_renderer.transmissionresolutionscale-0.5"></a>
### C.6/renderer.transmissionResolutionScale-0.5: `renderer.transmissionResolutionScale = 0.5` (pergola `src/lib/pergola-plugins/pergola-lighting-plugin.ts:98`, app-4f16d2f6da `:109` at `be828d5d`)

**runtime-owned** · written for 1.x 1.5

the transmission pass's resolution is the runtime's (B.4 X17); the port drops the setting and its unit test's assertion, which 10.3's glass run replaces

Used by: app-4f16d2f6da, app-ceb4eccc72.

<a id="c.6_shadow-light.ts"></a>
### C.6/shadow-light.ts: app-04c2774145 `shadow-light.ts` (aiming the key light at the product)

**runtime-owned** · written for 1.x 1.5

`ShadowSpec.covers` (default: role `'product'`)

Used by: app-04c2774145, app-4762caacdc, app-59c5b5751e, app-e61a0b42ad.

<a id="c.6_shadow.radius"></a>
### C.6/shadow.radius: the key light's `shadow.radius`, `.bias`, `.normalBias` set by the app (app-fc306ec3dc room rig, `use-planner-viewer.ts:1780-1789` at `342b26ce`: radius 14 → 3, normalBias 0.035)

**replaced** · 2.0: [`Session.find`](api/Writer.find.md), [`Query`](api/Query.md), [`Parts.set`](api/PartActions.set.md), [`PartState.shadows`](api/PartState.shadows.md), [`Session.collection`](api/Writer.collection.md), [`CollectionOptions.role`](api/CollectionOptions.role.md), [`Collection.set`](api/Collection.set.md), [`CollectionSpec.stage`](api/CollectionSpec.stage.md), [`StageSettings.lights`](api/StageSettings.lights.md), [`ItemSpec`](api/ItemSpec.md), [`LightSpec`](api/LightSpec.md), [`ShadowSpec`](api/ShadowSpec.md) · written for 1.x 1.5

a collection with `stage: { lights: 'none' }` and `light` items (`{ key, kind: 'light', light: { kind: 'sun', direction, intensity, shadow: { softness } } }`); the filter follows the receivers and bias is runtime-owned (K27). A 1.x radius r carries over as `softness` 0.3 × r / base, base 3 while a product part receives the shadow and 14 while only the ground and scenery do (`ShadowSpec`): app-fc306ec3dc's and chair's 3 with their product receiving (`chairPlugin.ts:41`, `settings.ts:367` at `be828d5d`; app-fc306ec3dc's scene-model cabinets through a `receive: true` rule, `PartState.shadows`) is 0.3, and app-510d750508's 1.5 (`crib-lighting-plugin.ts:77` at `be828d5d`) is 0.15 while its crib receives, the default, or about 0.032 with `receive: false` on the crib, as 1.x; the rig itself is a signed-off visual change (D44)

Visual changes to sign off (D44): The room rig (collection with `light` items) is a signed-off visual change..

Used by: no app in the cross-check.

```ts
/** Key-light `shadow.radius`, `bias` and `normalBias` writes. */
export async function setShadowSoftness(
  session: Session,
  radius: number,
  productReceives: boolean,
  direction: V1.Point3,
  intensity: number,
): Promise<CollectionReport> {
  await session.find({ role: 'product' }).set({ shadows: { receive: productReceives } });
  return session.collection('shadow-rig', { role: 'scenery' }).set({
    stage: { lights: 'none' },
    items: [
      {
        key: 'key',
        kind: 'light',
        light: {
          kind: 'sun',
          direction,
          intensity,
          shadow: { softness: (0.3 * radius) / (productReceives ? 3 : 14) },
        },
      },
    ],
  });
}
```

<a id="c.6_shadowground"></a>
### C.6/ShadowGround: the runtime's seeded lights changed in place: the key moved and fitted to the room, bias and normalBias set, fills and ambient retuned, `ShadowGround` hidden, the rig re-applied after a re-seed (app-fc07850558 `shadow-rig.ts:63-102` at `5cdd2780`, since `d1debb8a9`)

**replaced** · 2.0: [`Session.collection`](api/Writer.collection.md), [`CollectionOptions.role`](api/CollectionOptions.role.md), [`Collection.set`](api/Collection.set.md), [`CollectionSpec.stage`](api/CollectionSpec.stage.md), [`StageSettings.lights`](api/StageSettings.lights.md), [`StageSettings.ground`](api/StageSettings.ground.md), [`ItemSpec`](api/ItemSpec.md), [`LightSpec`](api/LightSpec.md), [`ShadowSpec`](api/ShadowSpec.md), [`HexColor`](api/HexColor.md) · written for 1.x 1.5

one `'scenery'` collection with `stage: { lights: 'none', ground: 'none' }` and `light` items: a `sun` with `shadow: { covers: [{ collection: 'room' }, { collection: 'kitchen' }] }` for the key, two `sun` fills without a shadow, a `sky` with equal colours for the ambient (role `'scenery'`, not `'helper'`, so captures keep the rig). Bias and the shadow box are runtime-owned (K27). The items are desired state, so no re-apply check is needed (6.5). The rig is a signed-off visual change (D44)

Visual changes to sign off (D44): app-fc07850558's 'scenery' rig is a signed-off visual change..

Used by: app-fc07850558.

```ts
/** Re-seeded key/fill/ambient rig mutations and hiding `ShadowGround`. */
export function setRoomRig(
  session: Session,
  direction: V1.Point3,
  keyIntensity: number,
  fillIntensity: number,
  ambientColor: HexColor,
): Promise<CollectionReport> {
  return session.collection('room-rig', { role: 'scenery' }).set({
    stage: { lights: 'none', ground: 'none' },
    items: [
      {
        key: 'key',
        kind: 'light',
        light: {
          kind: 'sun',
          direction,
          intensity: keyIntensity,
          shadow: { covers: [{ collection: 'room' }, { collection: 'kitchen' }] },
        },
      },
      {
        key: 'fill-left',
        kind: 'light',
        light: { kind: 'sun', direction: { x: 1, y: -1, z: 0 }, intensity: fillIntensity },
      },
      {
        key: 'fill-right',
        kind: 'light',
        light: { kind: 'sun', direction: { x: -1, y: -1, z: 0 }, intensity: fillIntensity },
      },
      {
        key: 'ambient',
        kind: 'light',
        light: { kind: 'sky', sky: ambientColor, ground: ambientColor },
      },
    ],
  });
}
```

<a id="c.6_snapto"></a>
### C.6/snapTo: snap plugin `snapTo(source, target)`

**replaced** · 2.0: [`Session.assembly`](api/Writer.assembly.md), [`Assembly.attach`](api/Assembly.attach.md), [`AttachOptions.from`](api/AttachOptions.from.md), [`AttachOptions.to`](api/AttachOptions.to.md), [`ConnectorRef.node`](api/ConnectorRef.node.md), [`ConnectorRef.connector`](api/ConnectorRef.connector.md) · written for 1.x 1.5

`session.assembly.attach(source.nodeId, { from: <source mesh name>, to: { node: target.nodeId, connector: <target mesh name> } })` (6.12)

Used by: app-04c2774145, app-37356bf09f, app-4762caacdc, app-ceb4eccc72, app-f9a32142f1.

```ts
/** Snap plugin `snapTo(source, target)`. */
export function snapTo(
  session: Session,
  source: V1.NodeTarget,
  target: V1.NodeTarget,
  sourceMeshName: string,
  targetMeshName: string,
): Promise<AttachReport> {
  return session.assembly.attach(source.nodeId, {
    from: sourceMeshName,
    to: { node: target.nodeId, connector: targetMeshName },
  });
}
```

<a id="c.6_getplanesfornode"></a>
### C.6/getPlanesForNode: snap plugin `getPlanesForNode(nodeId)`, `getPlane(id)`, `getConnections()`

**replaced** · 2.0: [`Session.assembly`](api/Writer.assembly.md), [`Assembly.connectors`](api/Assembly.connectors.md), [`Assembly.connections`](api/Assembly.connections.md), [`Connector`](api/Connector.md), [`Connector.ref`](api/Connector.ref.md), [`ConnectorRef.connector`](api/ConnectorRef.connector.md), [`Connection`](api/Connection.md) · written for 1.x 1.5

`session.assembly.connectors(nodeId)`; `session.assembly.connections()`

Used by: app-04c2774145, app-4762caacdc, app-c1384707fc, app-f9a32142f1.

```ts
/** Snap plugin `getPlanesForNode(nodeId)`, `getPlane(id)` and `getConnections()`. */
export function getPlanesForNode(
  session: Session,
  nodeId: string,
  meshName: string,
): { connectors: Connector[]; connector: Connector | null; connections: Connection[] } {
  const connectors = session.assembly.connectors(nodeId);
  return {
    connectors,
    connector: connectors.find((c) => c.ref.connector === meshName) ?? null,
    connections: session.assembly.connections(),
  };
}
```

<a id="c.6_disconnect"></a>
### C.6/disconnect: snap plugin `disconnect(planeId)`

**replaced** · 2.0: [`Session.assembly`](api/Writer.assembly.md), [`Assembly.detach`](api/Assembly.detach.md), [`ConnectorRef.node`](api/ConnectorRef.node.md), [`ConnectorRef.connector`](api/ConnectorRef.connector.md) · written for 1.x 1.5

`session.assembly.detach({ node, connector })`

Used by: no app in the cross-check.

```ts
/** Snap plugin `disconnect(planeId)`. */
export function disconnect(session: Session, node: string, connector: string): Promise<Report> {
  return session.assembly.detach({ node, connector });
}
```

<a id="c.6_cansnap"></a>
### C.6/canSnap: snap plugin `canSnap(a, b)`

**replaced** · 2.0: [`Session.assembly`](api/Writer.assembly.md), [`Assembly.candidates`](api/Assembly.candidates.md), [`ConnectorRef.node`](api/ConnectorRef.node.md) · written for 1.x 1.5

`session.assembly.candidates(node, { from })`, or `attach(…, { dryRun: true })`

Used by: no app in the cross-check.

```ts
/** Snap plugin `canSnap(a, b)`. */
export function canSnap(
  session: Session,
  source: V1.NodeTarget,
  target: V1.NodeTarget,
  from: string,
): boolean {
  return (
    session.assembly.candidates(source.nodeId, { from, to: { node: target.nodeId } }).length > 0
  );
}
```

<a id="c.6_snapplane"></a>
### C.6/SnapPlane: `SnapPlane` fields: `id`, `nodeId`, `type`, `enabled`, `priority`, `connectedTo`, `userData`, world `normal`, `center`, `polygon`

**replaced** · 2.0: [`Session.assembly`](api/Writer.assembly.md), [`Assembly.connectors`](api/Assembly.connectors.md), [`Connector`](api/Connector.md), [`Connector.ref`](api/Connector.ref.md), [`Connector.type`](api/Connector.type.md), [`Connector.enabled`](api/Connector.enabled.md), [`Connector.priority`](api/Connector.priority.md), [`Connector.peer`](api/Connector.peer.md), [`Connector.free`](api/Connector.free.md), [`Connector.meta`](api/Connector.meta.md), [`Connector.accepts`](api/Connector.accepts.md), [`Connector.normal`](api/Connector.normal.md), [`Connector.position`](api/Connector.position.md), [`Connector.polygon`](api/Connector.polygon.md), [`ConnectorRef.connector`](api/ConnectorRef.connector.md), [`JsonValue`](api/JsonValue.md) · written for 1.x 1.5

`Connector`: `id` (`nodeId:meshName`) → `ref` (`{ node, connector: meshName }`); `type`, `enabled`, `priority` kept; `connectedTo` → `peer` and `free`; `userData.connector` (the side) → `meta.connector`; `userData.allowedTypes` → `accepts`; other `userData` keys → `meta`; `normal`, `center` → `normal`, `position`; `polygon` kept

Used by: no app in the cross-check.

```ts
/** `SnapPlane` identity, connection, metadata and world-geometry fields. */
export function readSnapPlane(
  session: Session,
  nodeId: string,
  meshName: string,
): {
  ref: Connector['ref'];
  type: string;
  enabled: boolean;
  priority: number;
  peer: Connector['peer'];
  free: boolean;
  side: JsonValue | undefined;
  accepts: readonly string[];
  meta: Connector['meta'];
  normal: Connector['normal'];
  position: Connector['position'];
  polygon: Connector['polygon'];
} | null {
  const c = session.assembly.connectors(nodeId).find((c) => c.ref.connector === meshName);
  if (!c) return null;
  return {
    ref: c.ref,
    type: c.type,
    enabled: c.enabled,
    priority: c.priority,
    peer: c.peer,
    free: c.free,
    side: c.meta.connector,
    accepts: c.accepts,
    meta: c.meta,
    normal: c.normal,
    position: c.position,
    polygon: c.polygon,
  };
}
```

<a id="c.6_history.transaction"></a>
### C.6/history.transaction: `history.transaction` around assembly snaps, to keep them out of history (app-04c2774145)

**replaced** · 2.0: [`Session.transaction`](api/Session.transaction.md), [`Tx`](api/Tx.md), [`Tx.assembly`](api/Writer.assembly.md), [`Assembly.attach`](api/Assembly.attach.md), [`AttachOptions.from`](api/AttachOptions.from.md), [`AttachOptions.to`](api/AttachOptions.to.md), [`ConnectorRef`](api/ConnectorRef.md), [`TransactionOptions.undo`](api/CommitOptions.undo.md), [`TransactionReport`](api/TransactionReport.md), [`AttachReport`](api/AttachReport.md) · written for 1.x 1.5

do the snaps inside the sync's `session.transaction(work, { undo })`: they are part of that one step

Used by: app-04c2774145, app-37356bf09f, app-4762caacdc, app-510d750508, app-bcb7ef21da, app-f3598fad77.

```ts
/** `history.transaction` around the sync's assembly snaps. */
export function syncAndSnap(
  session: Session,
  sync: (tx: Tx) => Promise<Report>,
  source: V1.NodeTarget,
  target: V1.NodeTarget,
  from: string,
  connector: string,
  undo: string,
): Promise<TransactionReport<AttachReport>> {
  return session.transaction(
    async (tx) => {
      await sync(tx);
      return tx.assembly.attach(source.nodeId, { from, to: { node: target.nodeId, connector } });
    },
    { undo },
  );
}
```

<a id="c.6_findmodelroot"></a>
### C.6/findModelRoot: `findModelRoot(scene, nodeId)`, root traversal by `userData.nodeId` (app-04c2774145, app-fc306ec3dc, app-c1384707fc)

**replaced** · 2.0: [`Session.node`](api/Writer.node.md), [`Part`](api/Part.md), [`CommitOptions`](api/CommitOptions.md) · written for 1.x 1.5

`session.node(nodeId)`

Used by: app-04c2774145, app-4762caacdc.

```ts
/** `findModelRoot(scene, nodeId)` and traversal by `userData.nodeId`. */
export function findModelRoot(session: Session, nodeId: string): Part<CommitOptions> {
  return session.node(nodeId);
}
```

<a id="c.6_box3.setfromobject"></a>
### C.6/Box3.setFromObject: `Box3.setFromObject(root)`, `expandByObject` metrics (app-04c2774145, app-f9a32142f1)

**replaced** · 2.0: [`Session.node`](api/Writer.node.md), [`Part.visible`](api/Part.visible.md), [`Part.bounds`](api/PartActions.bounds.md), [`Bounds`](api/Bounds.md), `box3` · written for 1.x 1.5

`session.node(nodeId).bounds({ includeHidden: true })` (hidden descendants count, as they did); a scene metric is the union over the model nodes whose root `visible` is true, because `includeHidden: true` also counts a hidden root and app-04c2774145's metric skips one (`compute-scene-center.ts:36-43` at `c4c6ffd2`)

Used by: app-04c2774145, app-0c773a4ee3, app-4762caacdc, app-4f16d2f6da, app-510d750508, app-b362a81c85, app-bcb7ef21da, app-c1384707fc, app-ceb4eccc72, app-e3b47169bc, app-f9a32142f1, app-fc07850558, app-fc306ec3dc.

```ts
/** `Box3.setFromObject(root)` and `expandByObject` metrics, including hidden descendants. */
export function sceneBounds(session: Session, nodeIds: readonly string[]): Bounds | null {
  let result: Bounds | null = null;
  for (const nodeId of nodeIds) {
    const root = session.node(nodeId);
    if (!root.visible) continue;
    const bounds = root.bounds({ includeHidden: true });
    if (bounds) result = result ? box3.union(result, bounds) : bounds;
  }
  return result;
}
```

<a id="c.6_material-swap.applypbr"></a>
### C.6/material-swap.applyPBR: `material-swap.applyPBR(pattern, props)`, `applyPBRTo(root, pattern, props)` (app-f07e9033d9, app-fc306ec3dc; app-0c773a4ee3 calls neither: its finishes go through `select`, 8.2, `apps/app-0c773a4ee3/src/lib/configurator-3d/material-sync.ts:51-57` at `c4c6ffd2`)

**replaced** · 2.0: [`Session.find`](api/Writer.find.md), [`Query`](api/Query.md), [`Parts.set`](api/PartActions.set.md), [`HexColor`](api/HexColor.md), [`PartState.material`](api/PartState.material.md), [`MaterialSpec.baseColor`](api/MaterialSpec.baseColor.md), [`MaterialSpec.metallic`](api/MaterialSpec.metallic.md), [`MaterialSpec.roughness`](api/MaterialSpec.roughness.md), [`MaterialSpec.side`](api/MaterialSpec.side.md), [`MaterialSpec.maps`](api/MaterialSpec.maps.md), [`TextureSpec.url`](api/TextureSpec.url.md), [`TextureSpec.scale`](api/TextureSpec.scale.md) · written for 1.x 1.5

one persistent rule per region: `session.find({ name: <glob>, kind: 'mesh' }).set({ material: { … } })`; `kind: 'mesh'` because 1.4 matched meshes only, and a glob that also names a group would cascade to every mesh below (R8, C.8). Props: `color` → `baseColor` (a `HexColor`), `metalness` → `metallic`, `roughness` kept, `map`, `normalMap`, `roughnessMap` → `maps.baseColor`, `maps.normal`, `maps.roughness`, `repeat` → `scale`, `side` kept. Delete the "re-apply after every sync" effects. With `material-swap.resetUnspecifiedProps: true` (app-fc306ec3dc, `apps/app-fc306ec3dc/src/viewer/page-document.json:34` at `5cdd2780`), 1.4 also cleared the `map`, `normalMap` and `aoMap` a call didn't name (`viewer/plugins/material-swap-plugin.ts:695-698` at `eb5ce2afe`), so Tölgy → Fehér left no grain. A rule merges (R10), so a literal port keeps the old maps: name those slots in every finish of such a family, `'none'` for an absent map (`maps: { baseColor, normal, occlusion }`), or switch named materials with `{ ref }`, which replaces the rule's material (R10) and so also drops authored maps 1.4 kept (a visual change, D44). `applyPBR` also installed one clone of the first matched mesh's material on every match (`viewer/plugins/material-swap-plugin.ts:664-686` at `eb5ce2afe`), while a rule merges over each part's own material (R10): a pattern over meshes with different authored materials, such as app-f07e9033d9's `engine-casing|engine-plastic` (`apps/app-f07e9033d9/src/lib/scene/boat-materials.ts:188`, whose `pillow_logo` gets its own call for this reason, `:276-279` at `c4c6ffd2`), shows each mesh's own maps in 2.0, a D44 item. Its `normalMap` went through the flipping loader (5.4), so app-f07e9033d9's Linetex and Silvertex weave and deck-grain normal maps (`src/lib/scene/fabric-ranges.ts:31-44`, `boat-materials.ts:150`) render unflipped in 2.0, a second D44 item

Note: `maps.*` URLs (1.x `map`, `normalMap`, `roughnessMap`) resolve against the page's base URI, as `fetch()` resolves them, never against the scene model's `assetBaseUrl`: make a stored relative path absolute with `new URL(path, assetBaseUrl).href` first. On an app-proxy page (app-fc306ec3dc, app-fc07850558) the page's origin is the shop's, so build texture URLs on the app's deployed origin (`NEXT_PUBLIC_APP_ORIGIN`), never `window.location.origin` (MIGRATION-PROMPT.md, "URLs on an app-proxy page").

Visual changes to sign off (D44): `{ ref }` replaces the rule's material and so drops authored maps 1.4 kept.; A pattern over meshes with different authored materials shows each mesh's own maps in 2.0 (1.4 installed one clone of the first match's material on every match).; Normal maps 1.4 loaded through the flipping loader render unflipped in 2.0 (app-f07e9033d9's weave and deck-grain maps)..

Used by: app-0c773a4ee3, app-37356bf09f, app-822bb9d6aa, app-8675b3225c, app-bcb7ef21da, app-c1384707fc, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-fc306ec3dc, configurator-template.

```ts
/** `material-swap.applyPBR(pattern, props)` and `applyPBRTo(root, pattern, props)`. */
export function applyPbr(
  session: Session,
  glob: string,
  props: {
    color: HexColor;
    metalness: number;
    roughness: number;
    map?: string;
    normalMap?: string;
    roughnessMap?: string;
    aoMap?: string;
    repeat: number;
    side: 'front' | 'back' | 'double';
  },
): Promise<Report> {
  const texture = (url: string | undefined): { url: string; scale: number } | 'none' =>
    url ? { url, scale: props.repeat } : 'none';
  // Explicit absent slots preserve resetUnspecifiedProps; maps remain per-part authored merges.
  return session.find({ name: glob, kind: 'mesh' }).set({
    material: {
      baseColor: props.color,
      metallic: props.metalness,
      roughness: props.roughness,
      side: props.side,
      maps: {
        baseColor: texture(props.map),
        normal: texture(props.normalMap),
        roughness: texture(props.roughnessMap),
        occlusion: texture(props.aoMap),
      },
    },
  });
}
```

<a id="c.6_camera-presets.applypreset"></a>
### C.6/camera-presets.applyPreset: `camera-presets.applyPreset(pose)`, `.apply(name)`, `.save(name)` (app-fb16dd1985 `runtime-escape-hatches.ts:154-196`, app-510d750508 `:131-139`, pergola `:161-169` at `c4c6ffd2`; app-378d190f1b turns the plugin off, `page-document.json:23`)

**replaced** · 2.0: [`Session.camera`](api/Writer.camera.md), [`Camera.state`](api/Camera.state.md), [`CameraState`](api/CameraState.md), [`CameraView`](api/CameraView.md), [`Camera.set`](api/Camera.set.md), [`Camera.views`](api/Camera.views.md), [`CameraMoveOptions.durationMs`](api/CameraMoveOptions.durationMs.md), [`Camera.limits`](api/Camera.limits.md), [`CameraLimits.maxPolarDeg`](api/CameraLimits.maxPolarDeg.md), [`Session.on`](api/Session.on.md), [`Events.camera:control-end`](api/Events.camera_control-end.md), [`CameraMoveOptions.easing`](api/CameraMoveOptions.easing.md) · written for 1.x 1.5

`session.camera.views.go(name, { durationMs: GLIDE_MS, easing: 'damped' })` (views live in the version-2 document's `camera.views`); `session.camera.set(view, { durationMs: GLIDE_MS, easing: 'damped' })`; save = read `session.camera.state` and store the view. 1.4's plugin moved with camera-controls' transition while `smoothTransitions` is on, its default (`setLookAt(…, smooth)` and `fitToBox(box, smooth)`, `viewer/plugins/camera-presets-plugin.ts:29, 76-80, 104` at `eb5ce2afe`), so a preset glides as C.3's view row does (Q20), and with it off the move is `durationMs: 0`. pergola's toolbar (`src/components/viewer/pergola-viewer.tsx:214, 450-494`): `apply('top')` is `camera.views.go('top', { durationMs: GLIDE_MS, easing: 'damped' })` and `apply('default')` is `camera.set(saved, { durationMs: GLIDE_MS, easing: 'damped' })`; its underside view looks up past the garden's 90° polar clamp, so it is `camera.limits({ maxPolarDeg: 180 })` then `camera.set(undersideCameraPose(config), { durationMs: 0 })`, and the clamp returns with `camera.limits({ maxPolarDeg: 90 })` when the view is left and on `'camera:control-end'`, as `src/lib/pergola-plugins/pergola-camera-guard-plugin.ts:68-78, 122-127` does; limits are sticky, so nothing re-clamps per frame

Used by: app-378d190f1b, app-4f16d2f6da, app-510d750508, app-ceb4eccc72, app-fb16dd1985.

```ts
/**
 * `camera-presets.applyPreset(pose)`, `apply(name)` and `save(name)`. 1.4's plugin moved with
 * camera-controls' transition while `smoothTransitions` is on: the damped `GLIDE_MS` (`glideMs`).
 */
export function cameraPresets(
  session: Session,
  saved: Map<string, CameraView>,
  glideMs: number,
  smoothTransitions = true,
): {
  save: (name: string) => void;
  apply: (name: string) => Promise<Report>;
  applyPreset: (view: V1.CameraView) => Promise<Report>;
  underside: (position: V1.Point3, target: V1.Point3) => Promise<Report>;
  leaveUnderside: () => Promise<Report>;
  dispose: () => void;
} {
  const move: CameraMoveOptions = {
    durationMs: smoothTransitions ? glideMs : 0,
    easing: 'damped',
  };
  const leaveUnderside = (): Promise<Report> => session.camera.limits({ maxPolarDeg: 90 });
  const dispose = session.on('camera:control-end', () => {
    void leaveUnderside();
  });
  return {
    save: (name) => {
      const { target, azimuthDeg, polarDeg, distance, focalOffset } = session.camera.state;
      saved.set(name, { target, azimuthDeg, polarDeg, distance, focalOffset });
    },
    apply: async (name) => {
      await leaveUnderside();
      const view = saved.get(name);
      return view ? session.camera.set(view, move) : session.camera.views.go(name, move);
    },
    applyPreset: (view) =>
      session.camera.set(
        {
          target: view.target,
          azimuthDeg: view.azimuth,
          polarDeg: view.polar,
          distance: view.radius,
          focalOffset: view.focalOffset,
        },
        move,
      ),
    underside: async (position, target) => {
      await session.camera.limits({ maxPolarDeg: 180 });
      return session.camera.set({ position, target }, { durationMs: 0 });
    },
    leaveUnderside,
    dispose,
  };
}
```

<a id="c.6_dimension.formatvalue"></a>
### C.6/dimension.formatValue: `dimension.formatValue`, `patchDimensionLabels`, the dimension plugin's options (app-fb16dd1985, app-fc306ec3dc, app-fd09090420)

**replaced** · 2.0: [`Session.overlays`](api/Session.overlays.md), [`Overlays.dimensions`](api/Overlays.dimensions.md), [`Query`](api/Query.md), [`DimensionOptions.per`](api/DimensionOptions.per.md), [`DimensionOptions.axes`](api/DimensionOptions.axes.md), [`DimensionOptions.format`](api/DimensionOptions.format.md) · written for 1.x 1.5

`session.overlays.dimensions(query, { per, axes, format })`; a datasheet snap is `format: (metres, axis) => …`. app-fd09090420's ruler always prints the client's size table for width and depth and measures only height (its 'always override' tolerance, `apps/app-fd09090420/src/viewer/page-document.ts:38, 51-76`, `src/lib/lms/spec-dimensions.ts:1-22` at `c4c6ffd2`), so it is `format: (m, axis) => axis === 'height' ? fmt(m) : fmt(table[axis])`, called again on each layout change, where 1.4 re-applied its `withRuler` document (`src/components/viewer/use-viewer-actions.ts:148-158, 179-184`)

Used by: app-37356bf09f, app-4fd23bc869, app-822bb9d6aa, app-8675b3225c, app-bcb7ef21da, app-c1384707fc, app-ceb4eccc72, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f3598fad77, app-fb16dd1985, app-fd09090420, configurator-template.

```ts
/** `dimension.formatValue`, `patchDimensionLabels` and the ruler options. */
export function dimensionLabels(
  session: Session,
  query: Query,
  table: { width: number; depth: number },
  fmt: (metres: number) => string,
): void {
  // Call again when the layout or the client's size table changes.
  session.overlays.dimensions(query, {
    per: 'all',
    axes: ['width', 'height', 'depth'],
    format: (m, axis) => (axis === 'height' ? fmt(m) : fmt(table[axis])),
  });
}
```

<a id="c.6_adaptive-zoom.fittoscene"></a>
### C.6/adaptive-zoom.fitToScene: `adaptive-zoom.fitToScene()`, `fitToBox(box)`

**replaced** · 2.0: [`Session.camera`](api/Writer.camera.md), [`Camera.lens`](api/Camera.lens.md), [`CameraLens.insets`](api/CameraLens.insets.md), [`Insets`](api/Insets.md), [`Camera.fit`](api/Camera.fit.md), [`CameraMoveOptions.durationMs`](api/CameraMoveOptions.durationMs.md), [`Query`](api/Query.md), [`CameraMoveOptions.easing`](api/CameraMoveOptions.easing.md) · written for 1.x 1.5

`session.camera.fit(query, { paddingM, durationMs: fitTransition ? GLIDE_MS : 0, easing: 'damped' })`: 1.4's `fitToBox(box, fitTransition)` moved with camera-controls' transition, `fitTransition` on by default (`viewer/plugins/adaptive-zoom-plugin.ts:187` at `eb5ce2afe`), so a fit glides as C.3's fit row does (Q20). Per-side `fitToBox` paddings have no 2.0 form: app-510d750508 frames its crib with 0.3 × its largest size above, left and right and 0.15 × below on every size change (`apps/app-510d750508/src/lib/crib-plugins/crib-geometry-plugin.ts:113-124` at `c4c6ffd2`). The closest is `session.camera.lens({ insets })` (CSS px, kept across resizes), which `fit` frames into, plus `fit`'s one symmetric padding: a framing change for D44. A `fit` without `paddingM` keeps a 4% margin each side (5.6, DL36); a port matching 1.4's framing by eye passes its `paddingM`, `0` for the box exactly

Visual changes to sign off (D44): Per-side `fitToBox` paddings (app-510d750508's crib) become `camera.lens({ insets })` plus one symmetric padding: a framing change..

Used by: app-04c2774145, app-378d190f1b, app-4762caacdc, app-4fd23bc869, app-510d750508, app-822bb9d6aa, app-8675b3225c, app-b362a81c85, app-bcb7ef21da, app-c1384707fc, app-ceb4eccc72, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f3598fad77, app-f9a32142f1, app-fb16dd1985, app-fc306ec3dc, app-fd09090420, configurator-template.

```ts
/**
 * `adaptive-zoom.fitToScene()` and `fitToBox(box)` with asymmetric padding. 1.4 fitted with
 * camera-controls' transition while `fitTransition` is on: the damped `GLIDE_MS` (`glideMs`).
 */
export async function adaptiveZoom(
  session: Session,
  query: Query,
  paddingM: number,
  insets: Insets,
  glideMs: number,
  fitTransition = true,
): Promise<Report> {
  // Insets are CSS pixels; this is a framing change from per-side metre paddings.
  await session.camera.lens({ insets });
  return session.camera.fit(query, {
    paddingM,
    durationMs: fitTransition ? glideMs : 0,
    easing: 'damped',
  });
}
```

<a id="c.6_postprocessplugin.setao"></a>
### C.6/PostProcessPlugin.setAO: `PostProcessPlugin.setAO({ aoRadius })` through `session.plugin('post-process')` (app-b362a81c85 measures the radius once after load and pins it, so later swaps don't measure it again: `apps/app-b362a81c85/src/components/viewer/runtime-escape-hatches.ts:130-150`, `use-viewer-plugins.ts:113-122` at `be828d5d`)

**replaced** · 2.0: [`Session.collection`](api/Writer.collection.md), [`CollectionOptions.role`](api/CollectionOptions.role.md), [`Session.stage`](api/Writer.stage.md), [`Stage.set`](api/Stage.set.md), [`StageSettings`](api/StageSettings.md), [`StageWriteOptions.owner`](api/StageWriteOptions.owner.md) · written for 1.x 1.5

`session.stage.set({ ambientOcclusion: { radius } }, { owner })` with the measured metres, in a stage layer the app owns (K32); or no measurement, keeping the document's `ambientOcclusion: { radius: 'auto' }` (role `'product'` bounds, `StageSettings.ambientOcclusion`), as app-b362a81c85's paper port does

Used by: app-b362a81c85.

```ts
/**
 * app-b362a81c85's `PostProcessPlugin.setAO({ aoRadius })`: the radius it measured, pinned in a stage
 * layer the app owns. Without the measurement, the document's `radius: 'auto'` stays.
 */
export function pinAoRadius(session: Session, aoRadius: number): Promise<Report> {
  const owner = session.collection('ao-radius', { role: 'scenery' });
  return session.stage.set({ ambientOcclusion: { radius: aoRadius } }, { owner });
}
```

<a id="c.6_plugincontext"></a>
### C.6/PluginContext: `PluginContext` `render:before`

**replaced** · 2.0: [`Session.onFrame`](api/Session.onFrame.md), [`Frame`](api/Frame.md), [`FrameOptions.phase`](api/FrameOptions.phase.md) · written for 1.x 1.5

`session.onFrame(cb)` (`phase: 'late'` when it reads animated poses)

Used by: app-4f16d2f6da, app-510d750508, app-59c5b5751e, app-b362a81c85, app-ceb4eccc72, app-f9a32142f1, app-fc306ec3dc.

```ts
/** `PluginContext`'s `render:before` reading animated poses. */
export function beforeRender(session: Session, cb: (frame: Frame) => void): () => void {
  return session.onFrame(
    (frame) => {
      cb(frame);
    },
    { phase: 'late' },
  );
}
```

<a id="c.6_plugincontext_2"></a>
### C.6/PluginContext~2: `PluginContext` `render:after`

**replaced** · 2.0: [`Session.on`](api/Session.on.md), [`Events.committed`](api/Events.committed.md), [`Events.camera:changed`](api/Events.camera_changed.md) · written for 1.x 1.5

`onFrame` in the next frame, or the `'committed'` and `'camera:changed'` events

Used by: app-4f16d2f6da, app-510d750508, app-59c5b5751e, app-b362a81c85, app-ceb4eccc72.

```ts
/** `PluginContext`'s `render:after` updates after commits or camera changes. */
export function afterRender(session: Session, cb: () => void): () => void {
  const offCommit = session.on('committed', () => {
    cb();
  });
  const offCamera = session.on('camera:changed', () => {
    cb();
  });
  return () => {
    offCommit();
    offCamera();
  };
}
```

<a id="c.6_queuerender"></a>
### C.6/queueRender: `queueRender()`, `requestFrame()`

**removed** · written for 1.x 1.5

writes draw by themselves; `onFrame(cb, { continuous: true })` keeps drawing

Used by: app-04c2774145, app-0c773a4ee3, app-378d190f1b, app-4762caacdc, app-4f16d2f6da, app-510d750508, app-59c5b5751e, app-b362a81c85, app-b4ae2fce25, app-bcb7ef21da, app-c1384707fc, app-ceb4eccc72, app-ddfb017681, app-f9a32142f1, app-fc07850558, app-fc306ec3dc.

<a id="c.6_register"></a>
### C.6/register: `register(plugin)` host plugins (pergola, app-0c773a4ee3, chair, app-510d750508, app-378d190f1b, app-b362a81c85)

**replaced** · 2.0: [`Session.collection`](api/Writer.collection.md), [`CollectionOptions.role`](api/CollectionOptions.role.md), [`Collection.set`](api/Collection.set.md), [`CollectionSpec`](api/CollectionSpec.md), [`Session.find`](api/Writer.find.md), [`Parts.set`](api/PartActions.set.md), [`PartState.material`](api/PartState.material.md), [`MaterialSpec`](api/MaterialSpec.md), [`Session.stage`](api/Writer.stage.md), [`Stage.set`](api/Stage.set.md), [`StageSettings`](api/StageSettings.md), [`StageWriteOptions.owner`](api/StageWriteOptions.owner.md), [`Session.onFrame`](api/Session.onFrame.md), [`Frame`](api/Frame.md), [`FrameOptions.signal`](api/FrameOptions.signal.md), [`Collection.signal`](api/Collection.signal.md), [`Collection.dispose`](api/Collection.dispose.md) · written for 1.x 1.5

collections for content, rules for looks, `onFrame` for per-frame logic, `stage.set` for lights and environment

Used by: app-0c773a4ee3, app-378d190f1b, app-4f16d2f6da, app-510d750508, app-59c5b5751e, app-b362a81c85, app-b4ae2fce25, app-ceb4eccc72, app-ddfb017681, app-f9a32142f1, app-fc07850558, app-fc306ec3dc.

```ts
/** Host `register(plugin)` for content, looks, per-frame logic and stage settings. */
export async function registerContent(
  session: Session,
  content: CollectionSpec,
  query: Query,
  material: MaterialSpec,
  stage: StageSettings,
  cb: (frame: Frame) => void,
): Promise<() => void> {
  const owner = session.collection('app-content', { role: 'scenery' });
  await owner.set(content);
  await session.find(query).set({ material });
  await session.stage.set(stage, { owner });
  const off = session.onFrame(
    (frame) => {
      cb(frame);
    },
    { signal: owner.signal },
  );
  return () => {
    off();
    owner.dispose();
  };
}
```

<a id="c.6_default-visibility.ts"></a>
### C.6/default-visibility.ts: RegExp visibility and material patterns (app-f07e9033d9 `default-visibility.ts`, app-fb16dd1985 `tint-body-colour.ts`)

**replaced** · 2.0: [`Query`](api/Query.md) · written for 1.x 1.5

`Query.name` globs over the whole name (C.8)

Used by: app-59c5b5751e, app-e61a0b42ad, app-f07e9033d9, app-fb16dd1985.

```ts
/** RegExp visibility/material patterns such as `/body/` and `/^mp-[lr]/`. */
export const defaultVisibilityQuery: Query = [
  { name: '*body*', kind: 'mesh' },
  { name: 'mp-l*', kind: 'mesh' },
  { name: 'mp-r*', kind: 'mesh' },
];
```

<a id="c.6_an-emissive-pulse-every-frame"></a>
### C.6/an-emissive-pulse-every-frame: an emissive pulse every frame (app-e61a0b42ad)

**replaced** · 2.0: [`Session.onFrame`](api/Session.onFrame.md), [`Frame.timeMs`](api/Frame.timeMs.md), [`Session.find`](api/Writer.find.md), [`Query`](api/Query.md), [`Parts.list`](api/Parts.list.md), [`Part.material`](api/Part.material.md), [`Frame.material`](api/Frame.material.md), [`FrameOptions.continuous`](api/FrameOptions.continuous.md) · written for 1.x 1.5

a continuous hook that writes the colour and the strength each frame: `const off = session.onFrame((f) => { t0 ??= f.timeMs; for (const m of new Set(session.find(partMeshes(part).map((name) => ({ name, kind: 'mesh' as const }))).list().map((p) => p.material))) if (m) f.material(m, { emissive: '#8a6a3a', emissiveStrength: pulseIntensity(spec, f.timeMs - t0) }); }, { continuous: true })`, then `off()` at `durationMs`, which releases both values (5.12). The GLB's authored emissive is black, so a strength alone draws nothing; app-e61a0b42ad writes both and restores both (`apps/app-e61a0b42ad/src/components/viewer/runtime-escape-hatches.ts:425-459`, `src/lib/shoe-scene/highlight.ts:17` at `c4c6ffd2`). The Materials are read every frame, since a commit may give a part a different one (`Part.material`), and the reduced-motion flash (300 ms, peak 0.3) stays the app's (Q6). The material is shared by every part that wears it, so those parts pulse with it, as 1.4's writes to the shared instance did

Used by: no app in the cross-check.

```ts
/** An emissive pulse every frame, restoring both authored colour and strength on removal. */
export function emissivePulse(
  session: Session,
  meshNames: readonly string[],
  durationMs: number,
  pulseIntensity: (elapsedMs: number) => number,
): () => void {
  let t0: number | undefined;
  const off = session.onFrame(
    (f) => {
      t0 ??= f.timeMs;
      for (const m of new Set(
        session
          .find(meshNames.map((name) => ({ name, kind: 'mesh' as const })))
          .list()
          .map((p) => p.material),
      )) {
        if (m)
          f.material(m, {
            emissive: '#8a6a3a',
            emissiveStrength: pulseIntensity(f.timeMs - t0),
          });
      }
      if (f.timeMs - t0 >= durationMs) off();
    },
    { continuous: true },
  );
  return off;
}
```

<a id="c.6_skeleton-or-bone-polling-every-frame"></a>
### C.6/skeleton-or-bone-polling-every-frame: skeleton or bone polling every frame (app-f9a32142f1)

**replaced** · 2.0: [`Session.node`](api/Writer.node.md), [`Part.follow`](api/PartActions.follow.md), [`Query`](api/Query.md), [`FollowOptions.offset`](api/FollowOptions.offset.md) · written for 1.x 1.5

`follow(target, { offset: 'rest' })`, or an `onFrame(cb, { phase: 'late' })` hook reading `worldPose`

Used by: no app in the cross-check.

```ts
/** Skeleton or bone polling every frame to keep an attachment at its rest offset. */
export function followSkeleton(
  session: Session,
  attachmentNodeId: string,
  target: Query,
): () => void {
  return session.node(attachmentNodeId).follow(target, { offset: 'rest' });
}
```

<a id="c.6_gltfexporter"></a>
### C.6/GLTFExporter: live-scene exporters: `GLTFExporter` on the scene (`lib/ar/export.ts`, app-f07e9033d9 `export-scene.ts`, app-510d750508 crib export (dormant: no mounted control calls it at `c4c6ffd2`, so its port deletes it, or ports it to `export` if the control returns), app-4f16d2f6da `pergola-ar/export.ts`, app-fc07850558 `lib/ar/export.ts:34-78`, re-read at `5cdd2780`: live since `096bdd556`, the kitchen units and generated parts on a skeleton-safe clone)

**replaced** · 2.0: [`Session.permits`](api/Session.permits.md), [`Session.export`](api/Session.export.md), [`ExportOptions.format`](api/ExportOptions.format.md), [`ExportOptions.query`](api/ExportOptions.query.md), [`ExportOptions.prepare`](api/ExportOptions.prepare.md), [`ExportOptions.arQuickLook`](api/ExportOptions.arQuickLook.md), [`ExportResult.blob`](api/ExportResult.blob.md), [`Session.activateAR`](api/Session.activateAR.md), [`ActivateAROptions.glbUrl`](api/ActivateAROptions.glbUrl.md), [`ActivateAROptions.usdzUrl`](api/ActivateAROptions.usdzUrl.md), [`ActivateAROptions.title`](api/ActivateAROptions.title.md), [`ActivateAROptions.scale`](api/ActivateAROptions.scale.md), [`ActivateAROptions.occlusion`](api/ActivateAROptions.occlusion.md), [`Session.supports`](api/Session.supports.md), [`ActivateARReport`](api/ActivateARReport.md) · written for 1.x 1.5

`session.export({ format, query, prepare, arQuickLook })` behind `permits('export')`; indexing every primitive (app-4f16d2f6da's `indexGeometries`) and un-mirroring mirrored parts are now the runtime's job; an uploaded GLB (`glbUrl`) and, in Safari, the in-page USDZ export's `blob:` URL (`usdzUrl`, made with the app's `prepare`) launch through `activateAR({ glbUrl, usdzUrl, title })` instead of an app-built intent and Quick Look link; app-4f16d2f6da adds the experimental `scale: 'fixed'` and `occlusion: false` (9.5)

Used by: app-4f16d2f6da, app-510d750508, app-ceb4eccc72, app-f07e9033d9, app-fb16dd1985, app-fc07850558.

```ts
/** Live-scene `GLTFExporter`, app preparation and uploaded GLB plus Safari USDZ AR launch. */
export async function exportForAr(
  session: Session,
  query: Query,
  prepare: NonNullable<ExportOptions['prepare']>,
  upload: (blob: Blob) => Promise<string>,
  title: string,
): Promise<{ launch: ActivateARReport; release: () => void } | null> {
  if (!session.permits('export')) return null;
  const glb = await session.export({ format: 'glb', query, prepare, arQuickLook: false });
  const glbUrl = await upload(glb.blob);
  const usdz = await session.export({ format: 'usdz', query, prepare, arQuickLook: true });
  const usdzUrl = URL.createObjectURL(usdz.blob);
  try {
    const launch = await session.activateAR({
      glbUrl,
      usdzUrl,
      title,
      ...(session.supports('activateAR.scale.fixed') ? { scale: 'fixed' as const } : {}),
      ...(session.supports('activateAR.occlusion') ? { occlusion: false } : {}),
    });
    return { launch, release: () => URL.revokeObjectURL(usdzUrl) };
  } catch (error) {
    URL.revokeObjectURL(usdzUrl);
    throw error;
  }
}
```

<a id="c.6_usdz-from-glb.ts"></a>
### C.6/usdz-from-glb.ts: app-fb16dd1985's client GLB → USDZ conversion (`usdz-from-glb.ts:23-27`)

**replaced** · 2.0: [`Session.permits`](api/Session.permits.md), [`Session.export`](api/Session.export.md), [`ExportOptions.format`](api/ExportOptions.format.md), [`ExportResult.blob`](api/ExportResult.blob.md), [`Session.activateAR`](api/Session.activateAR.md), [`ActivateAROptions.usdzUrl`](api/ActivateAROptions.usdzUrl.md), [`ActivateARReport`](api/ActivateARReport.md) · written for 1.x 1.5

`session.export({ format: 'usdz' })`, whose `blob:` URL `activateAR({ usdzUrl })` takes in Safari, or a prebuilt USDZ asset passed as `activateAR({ usdzUrl })`; with neither, `activateAR()` exports the committed product for Quick Look itself

Used by: app-59c5b5751e, app-e61a0b42ad, app-fb16dd1985.

```ts
/** app-fb16dd1985's client GLB-to-USDZ conversion for Safari Quick Look. */
export async function usdzFromProduct(
  session: Session,
): Promise<{ launch: ActivateARReport; release: () => void } | null> {
  if (!session.permits('export')) return null;
  const { blob } = await session.export({ format: 'usdz' });
  const usdzUrl = URL.createObjectURL(blob);
  try {
    const launch = await session.activateAR({ usdzUrl });
    return { launch, release: () => URL.revokeObjectURL(usdzUrl) };
  } catch (error) {
    URL.revokeObjectURL(usdzUrl);
    throw error;
  }
}
```

<a id="c.6_triggeriosquicklook"></a>
### C.6/triggerIOSQuickLook: app AR launchers: app-fb16dd1985's `lib/ar/quick-look.ts` and `lib/ar/platform.ts`, `triggerIOSQuickLook` and the Scene Viewer intent builders (app-510d750508 `crib-ar/export.ts`, dormant, pergola and app-4f16d2f6da `pergola-ar/export.ts`, app-fc07850558 `lib/ar/export.ts`), app-fd09090420 `lib/lms/ar.ts`, the device checks in the template's `viewer-toolbar.tsx` copies (at `c4c6ffd2`)

**replaced** · 2.0: [`Session.info`](api/Session.info.md), [`InfoReport.ar`](api/InfoReport.ar.md), [`Session.activateAR`](api/Session.activateAR.md), [`ActivateAROptions.glbUrl`](api/ActivateAROptions.glbUrl.md), [`ActivateAROptions.usdzUrl`](api/ActivateAROptions.usdzUrl.md), [`ActivateAROptions.title`](api/ActivateAROptions.title.md), [`ActivateARReport`](api/ActivateARReport.md) · written for 1.x 1.5

`activateAR({ glbUrl, usdzUrl, title })`, which picks Quick Look, Scene Viewer or WebXR per device; `info().ar.modes` decides whether the AR control shows; the template's `components/ar/ar-button.tsx` copies are unimported dead code (11.1)

Used by: app-04c2774145, app-37356bf09f, app-4762caacdc, app-4f16d2f6da, app-4fd23bc869, app-510d750508, app-822bb9d6aa, app-8675b3225c, app-b362a81c85, app-bcb7ef21da, app-c1384707fc, app-ceb4eccc72, app-e3b47169bc, app-e9a33606d2, app-f07e9033d9, app-f3598fad77, app-f9a32142f1, app-fb16dd1985, app-fc07850558, app-fc306ec3dc, app-fd09090420, configurator-template.

```ts
/** `triggerIOSQuickLook`, Scene Viewer intents and device checks for the AR control. */
export function triggerQuickLook(
  session: Session,
  glbUrl: string,
  usdzUrl: string,
  title: string,
): Promise<ActivateARReport> | null {
  if (session.info().ar.modes.length === 0) return null;
  return session.activateAR({ glbUrl, usdzUrl, title });
}
```

<a id="c.6_shadowroot"></a>
### C.6/shadowRoot: the template's dead `lib/watermark/screenshot.ts` canvas reads through `shadowRoot` (9 apps)

**removed** · written for 1.x 1.5

dead code; `session.capture()` where a screenshot is needed

Used by: no app in the cross-check.

<a id="c.6_captureblob"></a>
### C.6/captureBlob: the 1.x screenshot plugin's path-traced hero shot, `captureBlob({ pathTracing })` and `preparePathTracing()` (no app)

**removed** · written for 1.x 1.5

no user: no app passes `pathTracing` or calls `preparePathTracing` at `c4c6ffd2` (3.1 rule 3), so the hero-shot chunk stays out of the 2.0 entry; `session.capture()` is the raster path (`viewer/plugins/screenshot-plugin.ts:46-53, 238` at `eb5ce2afe`)

Used by: no app in the cross-check.

<a id="c.6_saturate"></a>
### C.6/saturate: CSS `saturate()` on the canvas plus a capture re-filter (`capture-saturation.ts`, 19 copies)

**replaced** · 2.0: [`ViewerDocumentJson`](api/ViewerDocumentJson.md), [`ViewerDocumentJson.schema`](api/ViewerDocumentJson.schema.md), [`ViewerDocumentJson.version`](api/ViewerDocumentJson.version.md), [`ViewerDocumentJson.stage`](api/ViewerDocumentJson.stage.md), [`StageSettings.grade`](api/StageSettings.grade.md) · written for 1.x 1.5

the page document's `stage.grade.saturation` (`BootOptions.document`), as the ports set it (app-fc306ec3dc `src/viewer/page-document.ts:30-34`); drop the CSS filter from captured images too, or they saturate twice. The grade is the same `saturate()` matrix on the encoded output, so the value moves unchanged (`StageSettings.grade`)

Used by: app-04c2774145, app-0c773a4ee3, app-4762caacdc, app-4f16d2f6da, app-4fd23bc869, app-510d750508, app-59c5b5751e, app-822bb9d6aa, app-8675b3225c, app-b362a81c85, app-bcb7ef21da, app-c1384707fc, app-ceb4eccc72, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f9a32142f1, app-fb16dd1985, app-fc306ec3dc, app-fd09090420, configurator-template.

```ts
/** CSS `saturate()` and the duplicate capture filter move into the boot page document. */
export function saturatedPageDocument(saturation: number): ViewerDocumentJson {
  return {
    schema: 'holo.viewer-document',
    version: 2,
    stage: { grade: { saturation } },
  };
}
```

<a id="c.6_isinteracting"></a>
### C.6/isInteracting: app-fc306ec3dc `isInteracting()` (new at `aa80b3e3`)

**replaced** · 2.0: [`Session.on`](api/Session.on.md), [`Events.camera:control-start`](api/Events.camera_control-start.md), [`Events.camera:control-end`](api/Events.camera_control-end.md) · written for 1.x 1.5

a flag the app keeps from `'camera:control-start'` and `'camera:control-end'`

Used by: app-fc306ec3dc.

```ts
/** app-fc306ec3dc `isInteracting()` from the camera's control-start/control-end events. */
export function interactionFlag(session: Session): {
  isInteracting: () => boolean;
  dispose: () => void;
} {
  let interacting = false;
  const start = session.on('camera:control-start', () => {
    interacting = true;
  });
  const end = session.on('camera:control-end', () => {
    interacting = false;
  });
  return {
    isInteracting: () => interacting,
    dispose: () => {
      start();
      end();
    },
  };
}
```

<a id="c.6_lookat"></a>
### C.6/lookAt: app-fc306ec3dc `lookAt(position, target, transition)` (new at `aa80b3e3`)

**replaced** · 2.0: [`Session.camera`](api/Writer.camera.md), [`Camera.set`](api/Camera.set.md), [`LookAtView.position`](api/LookAtView.position.md), [`LookAtView.target`](api/LookAtView.target.md), [`CameraMoveOptions.durationMs`](api/CameraMoveOptions.durationMs.md), [`CameraMoveOptions.easing`](api/CameraMoveOptions.easing.md) · written for 1.x 1.5

`session.camera.set({ position, target }, { durationMs: transition ? GLIDE_MS : 0, easing: 'damped' })` (Q20); a `LookAtView` clears the focal offset in the same write

Used by: app-04c2774145, app-4762caacdc, app-4f16d2f6da, app-510d750508, app-b4ae2fce25, app-bcb7ef21da, app-ceb4eccc72, app-ddfb017681, app-fc07850558, app-fc306ec3dc.

```ts
/** app-fc306ec3dc `lookAt(position, target, transition)` with the app's `GLIDE_MS`. */
export function lookAt(
  session: Session,
  position: V1.Point3,
  target: V1.Point3,
  transition: boolean,
  glideMs: number,
): Promise<Report> {
  return session.camera.set(
    { position, target },
    { durationMs: transition ? glideMs : 0, easing: 'damped' },
  );
}
```

<a id="c.6_apply-fabric-uv-repeat.ts"></a>
### C.6/apply-fabric-uv-repeat.ts: texture repeat, wrap, colour space, orientation or anisotropy set on authored maps (app-f3598fad77 `apply-fabric-uv-repeat.ts`, app-0c773a4ee3 `anisotropy.ts`, app-b362a81c85 `anisotropy.ts` (C.7's `textureOptions` row), app-e61a0b42ad `repairTextureSampling`)

**replaced** · 2.0: [`Session.find`](api/Writer.find.md), [`Query`](api/Query.md), [`Parts.set`](api/PartActions.set.md), [`PartState.material`](api/PartState.material.md), [`MaterialSpec.maps`](api/MaterialSpec.maps.md), [`TextureSpec.scale`](api/TextureSpec.scale.md), [`TextureSpec.url`](api/TextureSpec.url.md) · written for 1.x 1.5

a URL-less `TextureSpec`: `find(q).set({ material: { maps: { baseColor: { scale } } } })` (R9), over the GLB's or a `select()`'s maps (app-f3598fad77, app-0c773a4ee3, app-b362a81c85). app-e61a0b42ad's maps are its leather rule's own `url`s (`apps/app-e61a0b42ad/src/lib/shoe-scene/materials.ts:49-62` at `c4c6ffd2`), which R9 places by their own fields, so its repair ports to `scale: 1.13` on each `TextureSpec` that rule brings, never a URL-less rule: its `repeat` wrap, sRGB albedo and `flipY: false` are the 2.0 defaults (`wrap` 'repeat', `colorSpace` 'srgb' for colour slots, the glTF orientation of 5.4)

Used by: app-59c5b5751e, app-e61a0b42ad, app-f3598fad77.

```ts
/** `apply-fabric-uv-repeat.ts` and sampling repairs on authored or rule-owned maps. */
export function applyFabricUvRepeat(
  session: Session,
  query: Query,
  scale: number,
  leatherUrls?: { baseColor: string; normal: string; roughness: string },
): Promise<Report> {
  if (leatherUrls) {
    return session.find(query).set({
      material: {
        maps: {
          baseColor: { url: leatherUrls.baseColor, scale: 1.13 },
          normal: { url: leatherUrls.normal, scale: 1.13 },
          roughness: { url: leatherUrls.roughness, scale: 1.13 },
        },
      },
    });
  }
  return session.find(query).set({ material: { maps: { baseColor: { scale } } } });
}
```

<a id="c.6_aomap-null"></a>
### C.6/aoMap-null: `aoMap = null` on baked AO (app-e61a0b42ad `dropBakedAO`)

**replaced** · 2.0: [`Session.find`](api/Writer.find.md), [`Query`](api/Query.md), [`Parts.set`](api/PartActions.set.md), [`PartState.material`](api/PartState.material.md), [`MaterialSpec.maps`](api/MaterialSpec.maps.md), [`TextureSlot`](api/TextureSlot.md) · written for 1.x 1.5

`maps: { occlusion: 'none' }`

Used by: app-e61a0b42ad.

```ts
/** `aoMap = null` to drop baked ambient occlusion. */
export function dropBakedAo(session: Session, query: Query): Promise<Report> {
  return session.find(query).set({ material: { maps: { occlusion: 'none' } } });
}
```

<a id="c.6_depthwrite_-false"></a>
### C.6/depthWrite:-false: `depthWrite: false` with `transparent: true` and a `renderOrder` (app-fc07850558's selection brackets and cut-away ghost colour pass, app-fc306ec3dc's selection brackets, app-4f16d2f6da's opacity glass)

**replaced** · 2.0: [`Session.find`](api/Writer.find.md), [`Query`](api/Query.md), [`Parts.set`](api/PartActions.set.md), [`PartState.material`](api/PartState.material.md), [`MaterialSpec.alphaMode`](api/MaterialSpec.alphaMode.md), [`MaterialSpec.alpha`](api/MaterialSpec.alpha.md), [`MaterialSpec.labs`](api/MaterialSpec.labs.md) · written for 1.x 1.5

`alphaMode: 'blend'` (also at `alpha` 1), which writes no depth, with `labs.drawOrder` for the order (5.4, K13)

Used by: no app in the cross-check.

```ts
/** `depthWrite: false`, `transparent: true` and `renderOrder`. */
export function transparentPass(
  session: Session,
  query: Query,
  drawOrder: number,
): Promise<Report> {
  return session.find(query).set({
    material: {
      alphaMode: 'blend',
      alpha: 1,
      labs: { drawOrder },
    },
  });
}
```

<a id="c.6_polygonoffset"></a>
### C.6/polygonOffset: `polygonOffset` on a fixture coplanar with the wall's faces (app-fc07850558's door and window casings, `room-fixtures.ts:66-87` at `c4c6ffd2`)

**replaced** · 2.0: [`Session.collection`](api/Writer.collection.md), [`CollectionOptions.role`](api/CollectionOptions.role.md), [`Collection.set`](api/Collection.set.md), [`CollectionSpec.items`](api/CollectionSpec.items.md), [`ItemSpec`](api/ItemSpec.md), [`ItemBase.at`](api/ItemBase.at.md), [`PoseInput`](api/PoseInput.md), [`Shape`](api/Shape.md), `vec3` · written for 1.x 1.5

the app builds the casing a millimetre or two off the wall's jamb and header planes; `MaterialSpec` has no polygon offset (one app, 3.1 rule 3; K13)

Used by: app-fc07850558.

```ts
/** Casing `polygonOffset` becomes geometry placed two millimetres off each wall plane. */
export function offsetCasing(
  session: Session,
  faces: readonly { key: string; position: V1.Point3; normal: V1.Point3; size: V1.Point3 }[],
): Promise<CollectionReport> {
  return session.collection('casings', { role: 'scenery' }).set({
    items: faces.map((face) => ({
      key: face.key,
      kind: 'mesh',
      shape: { kind: 'box', size: face.size },
      at: { position: vec3.add(face.position, vec3.scale(vec3.normalize(face.normal), 0.002)) },
    })),
  });
}
```

<a id="c.6_scene.scale.x-1"></a>
### C.6/scene.scale.x-1: `scene.scale.x = -1` (app-b362a81c85, app-0c773a4ee3)

**replaced** · 2.0: [`Session.labs`](api/Writer.labs.md), [`Labs.mirror`](api/Labs.mirror.md), [`Collection`](api/Collection.md) · written for 1.x 1.5

`session.labs.mirror('x', { owner })`. The `material.side` flip that went with it (`DoubleSide` mirrored, `FrontSide` otherwise, on every material: `apps/app-b362a81c85/src/lib/hooks/use-orientation-mirror.ts:14-15`, `apps/app-0c773a4ee3/src/lib/configurator-3d/orientation-mirror.ts:21` at `be828d5d`) was a winding workaround and goes, so authored sides stay; both orientations' backface views are one item of the port's G5 diff (D44)

Visual changes to sign off (D44): The `material.side` flip goes, so authored sides stay; both orientations' backface views are one item of the port's G5 diff..

Used by: no app in the cross-check.

```ts
/** `scene.scale.x = -1`, keeping the authored material sides. */
export function mirrorScene(session: Session, owner: Collection): Promise<Report> {
  return session.labs.mirror('x', { owner });
}
```

<a id="c.6_model_loaded"></a>
### C.6/model:loaded: a geometry trim on `model:loaded` and `scene:synced` (app-b362a81c85 `use-fireplace-trim.ts`, new at `342b26ce`)

**removed** · written for 1.x 1.5

the fixed chimney GLBs (G54); the event re-run and the `userData.nodeId` walk go with it

Used by: app-04c2774145, app-0c773a4ee3, app-37356bf09f, app-4762caacdc, app-4fd23bc869, app-822bb9d6aa, app-8675b3225c, app-b362a81c85, app-bcb7ef21da, app-c1384707fc, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f3598fad77, app-f9a32142f1, app-fb16dd1985, app-fc306ec3dc, app-fd09090420, configurator-template.

<a id="c.6_session.viewer.renderer.localclippingenabled"></a>
### C.6/session.viewer.renderer.localClippingEnabled: `session.viewer.renderer.localClippingEnabled` and material `clippingPlanes` (app-fc07850558 `runtime-escape-hatches.ts:230-240`, `scene-content.ts:1071-1105` at `5cdd2780`)

**removed** · written for 1.x 1.5

1.x already turns local clipping on (`viewer/controllers/renderer.controller.ts:670` at `eb5ce2afe`); in 2.0 the split is app geometry (B.5 G57)

Used by: app-fc07850558.

<a id="c.6_bind-pose-raycast.ts"></a>
### C.6/bind-pose-raycast.ts: bind-pose raycast patch (app-bcb7ef21da `bind-pose-raycast.ts`)

**removed** · written for 1.x 1.5

a runtime fix (K45); delete the patch and its `three-mesh-bvh` dependency

Used by: app-59c5b5751e, app-bcb7ef21da, app-e61a0b42ad.

<a id="c.6_hidemounthelpers"></a>
### C.6/hideMountHelpers: app-fd09090420's connector-plane hiding: `hideMountHelpers(root)` and `hideSceneMountHelpers(session)` on every `'scene:synced'` (`src/lib/lms/mount-helpers.ts:25-44`, `src/components/viewer/runtime-escape-hatches.ts:129-137, 152` at `c4c6ffd2`)

**runtime-owned** · written for 1.x 1.5

the runtime hides every connector plane itself (6.12); delete the traversal, `mount-helpers.ts` and their hatch calls (K58)

Used by: app-fd09090420.

<a id="c.6_pick-proxy.ts"></a>
### C.6/pick-proxy.ts: invisible pick proxies (app-fd09090420 `pick-proxy.ts`)

**removed** · written for 1.x 1.5

the hover cost they worked around (a 151,712-triangle skinned upholstery mesh, `apps/app-fd09090420/src/lib/lms/pick-proxy.ts:1-41` at `c4c6ffd2`) is the runtime fix of K45: every stock skinned mesh takes the baked-pose pick (`viewer/core/raycast-utils.ts:92-105` at `eb5ce2afe`). Delete `pick-proxy.ts` and `refreshScenePickProxies`, pick the real meshes and map `hit.part.node` to the module's slot (a collection item's `node` is null, so a transparent item can't). Only if app-fd09090420's hover bar (G25) fails: a transparent pickable item carrying the slot id in `meta`, read from `hit.part.meta`

Used by: app-59c5b5751e, app-e61a0b42ad, app-fd09090420.

<a id="c.6_accessory-grab.ts"></a>
### C.6/accessory-grab.ts: a pick stand-in: a clone of the pressed mesh added under another model for one press, so the drag plugin's pick lands there (app-fc306ec3dc `accessory-grab.ts`, new at `342b26ce`)

**removed** · written for 1.x 1.5

build the accessories as `space: 'parent'` children of their vanity in the app's scene model and register `input.drag([{ node: vanityId }], …)`: the drag unit (5.7) is the vanity; map `hit.part.node` to its owner for tap and hover

Used by: app-59c5b5751e, app-e61a0b42ad, app-fc306ec3dc.

<a id="c.6_getallanimations"></a>
### C.6/getAllAnimations: viewer element `getAllAnimations()`, `getNodeAnimations(nodeId)` and `playAllAnimations()` (app-4762caacdc, app-04c2774145, app-b362a81c85 and app-0c773a4ee3 list clips; app-b362a81c85 plays them all)

**replaced** · 2.0: [`Session.animations`](api/Writer.animations.md), [`Animations.list`](api/Animations.list.md), [`ClipInfo.durationMs`](api/ClipInfo.durationMs.md), [`ClipInfo.playing`](api/ClipInfo.playing.md), [`ClipRef.node`](api/ClipRef.node.md), [`ClipRef.clip`](api/ClipRef.clip.md), [`Animations.play`](api/Animations.play.md), [`PlayOptions.toggle`](api/PlayOptions.toggle.md) · written for 1.x 1.5

`session.animations.list(target?)`: a `ClipInfo` per clip, 1.x's `nodeId`, `name`, `duration` (seconds) and `isPlaying` read as `node`, `clip`, `durationMs` and `playing`; play all is `session.animations.play(c.node, c.clip, { toggle: true })` for each listed clip with a `node`, as 1.x's `playAll({ toggle: true })` (`viewer/configurator-viewer.ts:1658-1688` at `eb5ce2afe`)

Used by: app-04c2774145, app-0c773a4ee3, app-4762caacdc, app-b362a81c85.

```ts
/**
 * Viewer element `getAllAnimations()` and `getNodeAnimations(nodeId)` in 1.x's shape (`duration`
 * in seconds), and `playAllAnimations()`: each clip toggles, as 1.x's `playAll({ toggle: true })`.
 */
export function listAnimations(
  session: Session,
  nodeId?: string,
): Array<{ nodeId: string | null; name: string; duration: number; isPlaying: boolean }> {
  return session.animations.list(nodeId).map((c) => ({
    nodeId: c.node,
    name: c.clip,
    duration: c.durationMs / 1000,
    isPlaying: c.playing,
  }));
}

export function playAllAnimations(session: Session): Promise<Report[]> {
  const plays: Promise<Report>[] = [];
  for (const c of session.animations.list()) {
    if (c.node) plays.push(session.animations.play(c.node, c.clip, { toggle: true }));
  }
  return Promise.all(plays);
}
```

<a id="c.6_getmodelprs"></a>
### C.6/getModelPRS: viewer element `getModelPRS(nodeId)` (app-c1384707fc, app-f3598fad77, app-f9a32142f1)

**replaced** · 2.0: [`Session.node`](api/Writer.node.md), [`Part.exists`](api/Part.exists.md), [`Part.pose`](api/Part.pose.md), [`Pose.rotationDeg`](api/Pose.rotationDeg.md), `angle` · written for 1.x 1.5

`session.node(nodeId).pose`: `position` and `scale` as they were, `rotationDeg` in degrees where 1.x gave Euler radians in the same intrinsic X, Y, Z order (`angle.degToRad`); 1.x threw for a node not loaded, where `node(id).exists` is false

Used by: app-c1384707fc, app-f3598fad77, app-f9a32142f1.

```ts
/** Viewer element `getModelPRS(nodeId)`: 1.x's Euler radians (XYZ) from the pose's degrees. */
export function getModelPrs(
  session: Session,
  nodeId: string,
): { position: V1.Point3; rotation: V1.Point3; scale: V1.Point3 } | null {
  const root = session.node(nodeId);
  if (!root.exists) return null; // 1.x threw "Model not found"
  const { position, rotationDeg, scale } = root.pose;
  return {
    position: { x: position.x, y: position.y, z: position.z },
    rotation: {
      x: angle.degToRad(rotationDeg.x),
      y: angle.degToRad(rotationDeg.y),
      z: angle.degToRad(rotationDeg.z),
    },
    scale: { x: scale.x, y: scale.y, z: scale.z },
  };
}
```

<a id="c.6_setorbittarget"></a>
### C.6/setOrbitTarget: app-fc306ec3dc `setOrbitTarget(target, enableTransition)` (`use-planner-viewer.ts:1295` at `be828d5d`)

**replaced** · 2.0: [`Session.camera`](api/Writer.camera.md), [`Camera.state`](api/Camera.state.md), [`CameraState.position`](api/CameraState.position.md), [`Camera.set`](api/Camera.set.md), [`LookAtView.position`](api/LookAtView.position.md), [`LookAtView.target`](api/LookAtView.target.md), [`CameraMoveOptions.durationMs`](api/CameraMoveOptions.durationMs.md), [`CameraMoveOptions.easing`](api/CameraMoveOptions.easing.md) · written for 1.x 1.5

`session.camera.set({ position, target }, { durationMs: enableTransition ? GLIDE_MS : 0, easing: 'damped' })` with `position` from `camera.state`: 1.x moved only the pivot and kept the camera where it was (`viewer/controllers/camera.controller.ts:218-225` at `eb5ce2afe`)

Used by: app-fc306ec3dc.

```ts
/** app-fc306ec3dc `setOrbitTarget(target, enableTransition)`: the camera stays where it is, the pivot moves. */
export function setOrbitTarget(
  session: Session,
  target: V1.Point3,
  enableTransition: boolean,
  glideMs: number,
): Promise<Report> {
  const { x, y, z } = session.camera.state.position;
  return session.camera.set(
    { position: { x, y, z }, target },
    { durationMs: enableTransition ? glideMs : 0, easing: 'damped' },
  );
}
```

<a id="c.6_adaptive-zoom.recalculatezoomlimits"></a>
### C.6/adaptive-zoom.recalculateZoomLimits: `adaptive-zoom.recalculateZoomLimits()` (app-fc306ec3dc `runtime-escape-hatches.ts:363` at `be828d5d`)

**runtime-owned** · written for 1.x 1.5

while no document layer and no `limits` call sets `minDistance` or `maxDistance`, the runtime refits both to the role `'product'` bounds at every commit that changes them, 1.x's formula (5.6), and a distance set by a layer or a `limits` call stops that refit (limits are sticky). A call that recomputes them after a layout change: delete it. app-fc306ec3dc never recomputes: it replaces it with a no-op (`pinAdaptiveZoomLimits`) so the plugin never rewrites its room camera's distances. In 2.0 the room camera's own `camera.limits({ minDistance, maxDistance })` already stops the refit, so delete the pin and add no product-based `limits` call: one would override the room camera's distances. The formula is for only a page that wants 1.x's adaptive limits and also sets a distance itself, after each layout change: `session.camera.limits({ minDistance: Math.max(0.1, 0.7 * d), maxDistance: 10 * d })`, d the largest side of `session.find({ role: 'product' }).bounds()`

Used by: app-378d190f1b, app-510d750508, app-822bb9d6aa, app-c1384707fc, app-ceb4eccc72, app-e3b47169bc, app-fc306ec3dc.

<a id="c.6_loadmodel"></a>
### C.6/loadModel: viewer element `loadModel(url, nodeId)` and `removeModel(nodeId)` for a catalogue preview (app-fc306ec3dc `components/planner/catalogue-preview.ts:157-185` at `be828d5d`)

**replaced** · 2.0: [`Session.collection`](api/Writer.collection.md), [`CollectionOptions.role`](api/CollectionOptions.role.md), [`CollectionOptions.signal`](api/CollectionOptions.signal.md), [`Collection.load`](api/Collection.load.md), [`Collection.dispose`](api/Collection.dispose.md), [`Session.find`](api/Writer.find.md), [`Parts.set`](api/PartActions.set.md), [`PartState.material`](api/PartState.material.md) · written for 1.x 1.5

a `'helper'` collection (spec 8.5): `collection('catalogue-preview', { role: 'helper', signal }).load(url, { key, signal })`, dressed by a rule on `find({ collection, kind: 'mesh' })`, removed with the collection's `dispose()`; no owned copy and no scene graph walk

Used by: app-fc306ec3dc.

```ts
/**
 * Viewer element `loadModel(url, nodeId)` then `removeModel(nodeId)` for a catalogue preview
 * (app-fc306ec3dc): a helper collection's item, dressed by a rule, removed with the collection.
 */
export async function catalogPreview(
  session: Session,
  url: string,
  finish: MaterialSpec,
  signal: AbortSignal,
): Promise<{ part: Part; dispose: () => void }> {
  // the preview's lifecycle: an abort disposes the collection, as `dispose()` does
  const preview = session.collection('catalogue-preview', { role: 'helper', signal });
  const part = await preview.load(url, { key: 'preview', signal });
  // the room's finish on the preview: a rule on the collection's meshes
  await session.find({ collection: preview.name, kind: 'mesh' }).set({ material: finish });
  return { part, dispose: () => preview.dispose() };
}
```

<a id="c.6_drag_move"></a>
### C.6/drag:move: app-fc306ec3dc's drag plugin: `drag:move`, `setMode('ground')` and `setMode('plane')` with `setPlaneFromMesh(mesh)`, `isDragging` (`use-planner-drag.ts:629-630, 1348`, `runtime-escape-hatches.ts:255-277` at `be828d5d`; pergola's and app-bcb7ef21da's own `setMode` are their own objects)

**replaced** · 2.0: [`Session.input`](api/Session.input.md), [`Input.drag`](api/Input.drag.md), [`DragOptions.on`](api/DragOptions.on.md), [`DragOptions.constrain`](api/DragOptions.constrain.md), [`DragMove`](api/DragMove.md), [`Session.on`](api/Session.on.md), [`Events.drag:start`](api/Events.drag_start.md), [`Events.drag:end`](api/Events.drag_end.md) · written for 1.x 1.5

`session.input.drag(units, { on: 'floor' })` for `'ground'` and `{ on: { name: wallMesh, kind: 'mesh' } }` for a plane from a mesh, registered again when the mode changes (the newest registration that names a unit applies); each move reaches `constrain` as a `DragMove`; `isDragging` is a flag the app keeps from `'drag:start'` and `'drag:end'`

Used by: app-fc306ec3dc.

```ts
/**
 * app-fc306ec3dc's drag plugin: `setMode('ground')` drags on the floor, `setMode('plane')` with
 * `setPlaneFromMesh(wall)` on a wall mesh, `'drag:move'` per move, `isDragging` from the drag
 * events. Register again with the other `on` when the mode changes (the newest registration
 * that names a unit applies).
 */
export function plannerDrag(
  session: Session,
  units: Query,
  wallMesh: string | null,
  onMove: (move: DragMove) => void,
  signal: AbortSignal,
): { readonly isDragging: boolean } {
  let dragging = false;
  session.input.drag(units, {
    on: wallMesh ? { name: wallMesh, kind: 'mesh' } : 'floor',
    constrain: (move) => {
      onMove(move);
      return null; // the move as the runtime computed it
    },
    signal,
  });
  session.on('drag:start', () => (dragging = true), { signal });
  session.on('drag:end', () => (dragging = false), { signal });
  return {
    get isDragging() {
      return dragging;
    },
  };
}
```

<a id="c.6_material-swap.restoreoriginals"></a>
### C.6/material-swap.restoreOriginals: `material-swap.restoreOriginals()` (app-bcb7ef21da `runtime-escape-hatches.ts:37`, app-fc306ec3dc)

**replaced** · 2.0: [`Session.find`](api/Writer.find.md), [`Parts.set`](api/PartActions.set.md), [`PartState.material`](api/PartState.material.md) · written for 1.x 1.5

`session.find(q).set({ material: null })` on each region's rule: the rule's material goes and the part wears its authored one again (R10; C.7's `plugins.global-material` row)

Used by: app-37356bf09f, app-822bb9d6aa, app-8675b3225c, app-bcb7ef21da, app-c1384707fc, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-fc306ec3dc, configurator-template.

```ts
/** Material-swap `restoreOriginals()`: each region's rule drops its material (R10). */
export function restoreOriginals(session: Session, regions: readonly Query[]): Promise<Report[]> {
  return Promise.all(regions.map((q) => session.find(q).set({ material: null })));
}
```

<a id="c.6_material-swap.getgroups"></a>
### C.6/material-swap.getGroups: `material-swap.getGroups()`, `registerGroup(group)` and `selectMaterial(groupId, materialId)` (app-c1384707fc `runtime-escape-hatches.ts:184-188` at `be828d5d`; in the other apps `selectMaterial` is their own sync hook's field)

**replaced** · 2.0: [`Session.find`](api/Writer.find.md), [`Query`](api/Query.md), [`Parts.set`](api/PartActions.set.md), [`PartState.material`](api/PartState.material.md), [`MaterialSpec`](api/MaterialSpec.md) · written for 1.x 1.5

the groups are the app's own list; a pick is one persistent rule on the group's meshes, `session.find({ name: <glob>, kind: 'mesh' }).set({ material })` (R10), painted on every match now and after reloads, undo and redo, as app-c1384707fc's port does (`src/lib/door/door-materials.ts`); a stored MaterialNode pick is `session.select(group, material)`

Used by: app-37356bf09f, app-822bb9d6aa, app-8675b3225c, app-bcb7ef21da, app-c1384707fc, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, configurator-template.

```ts
/**
 * app-c1384707fc's material-swap groups (`getGroups`, `registerGroup`, `selectMaterial`): the groups are
 * the app's own list, and a pick is one persistent rule on the group's meshes (R10), painted
 * on every match now and after reloads, undo and redo.
 */
export function materialGroups(session: Session): {
  registerGroup: (group: { id: string; meshGlob: string }) => void;
  getGroups: () => ReadonlyArray<{ id: string; meshGlob: string }>;
  selectMaterial: (groupId: string, material: MaterialSpec) => Promise<Report> | null;
} {
  const groups = new Map<string, { id: string; meshGlob: string }>();
  return {
    registerGroup: (group) => void groups.set(group.id, group),
    getGroups: () => [...groups.values()],
    selectMaterial: (groupId, material) => {
      const group = groups.get(groupId);
      return group ? session.find({ name: group.meshGlob, kind: 'mesh' }).set({ material }) : null;
    },
  };
}
```

<a id="c.6_global-material.applyall"></a>
### C.6/global-material.applyAll: `global-material.applyAll()` after each `select` (app-f9a32142f1 `use-viewer.ts:1109-1115` at `be828d5d`)

**runtime-owned** · written for 1.x 1.5

a `select` applies the group's material and its visibility zone in its own commit (8.2 rule 7, C.7's `plugins.global-material` row); delete the re-apply and the history snapshot after it

Used by: app-04c2774145, app-37356bf09f, app-4762caacdc, app-4fd23bc869, app-822bb9d6aa, app-8675b3225c, app-b362a81c85, app-bcb7ef21da, app-c1384707fc, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f3598fad77, app-f9a32142f1, app-fb16dd1985, app-fc306ec3dc, app-fd09090420, configurator-template.

<a id="c.6_scene-history.savesnapshot"></a>
### C.6/scene-history.saveSnapshot: `scene-history.saveSnapshot()` and `clear()` after a first load that leaked undo steps (app-4762caacdc, app-04c2774145 `runtime-escape-hatches.ts:277`, app-f9a32142f1)

**removed** · written for 1.x 1.5

the session's first model is the floor of history (11.2): nothing to clear; a later load that must start clean passes `undo: 'rebase'` (C.3's history rows)

Used by: app-04c2774145, app-4762caacdc, app-f9a32142f1.

<a id="c.6_plugincontext.sceneindex.getmodelobjects"></a>
### C.6/PluginContext.sceneIndex.getModelObjects: `PluginContext.sceneIndex.getModelObjects(nodeId)` (app-f9a32142f1 `runtime-escape-hatches.ts:176-177` at `be828d5d`)

**replaced** · 2.0: [`Session.find`](api/Writer.find.md), [`Parts.list`](api/Parts.list.md), [`Session.node`](api/Writer.node.md) · written for 1.x 1.5

`session.find({ node: nodeId }).list()` for the parts, `session.node(nodeId)` for the model root

Used by: app-f9a32142f1.

```ts
/** `PluginContext.sceneIndex.getModelObjects(nodeId)`: the model root and its parts. */
export function modelObjects(session: Session, nodeId: string): Part[] {
  return session.find({ node: nodeId }).list();
}
```

<a id="c.6_webglrenderer.capabilities.maxtexturesize"></a>
### C.6/WebGLRenderer.capabilities.maxTextureSize: `WebGLRenderer.capabilities.maxTextureSize` to size frame-sequence decodes (app-b4ae2fce25, app-ddfb017681 `runtime-escape-hatches.ts:168-169` at `be828d5d`)

**replaced** · 2.0: [`Session.info`](api/Session.info.md), [`InfoReport.device`](api/InfoReport.device.md) · written for 1.x 1.5

`session.info().device.maxTextureSize`

Used by: no app in the cross-check.

```ts
/** `renderer.capabilities.maxTextureSize`, to size frame-sequence decodes (estate, app-ddfb017681). */
export function maxTextureSize(session: Session): number {
  return session.info().device.maxTextureSize;
}
```

<a id="c.6_quality_tier-changed"></a>
### C.6/quality:tier-changed: `quality:tier-changed` and viewer element `getPerfStats().qualityTier` to retune shadows per tier (app-ceb4eccc72, app-4f16d2f6da `runtime-escape-hatches.ts:300-310` at `be828d5d`)

**runtime-owned** · written for 1.x 1.5

the runtime sets the shadow map and filter per tier (`ShadowSpec`, B.4 X11); `info().renderer.tier` reads the tier where a page shows it; delete the retune

Used by: app-4f16d2f6da, app-ceb4eccc72.

<a id="c.6_plugincontext.events.emit"></a>
### C.6/PluginContext.events.emit: `PluginContext.events.emit('model:loaded')` so the ambient occlusion and adaptive zoom re-measure a procedural model (app-ceb4eccc72, app-4f16d2f6da `emitModelLoaded`, `runtime-escape-hatches.ts:303-313` at `be828d5d`)

**runtime-owned** · written for 1.x 1.5

a collection write is measured like a model: the `'auto'` occlusion radius and `camera.fit` read the committed scene; delete the emit (C.6's `model:loaded` row covers listening)

Used by: app-4f16d2f6da, app-510d750508, app-59c5b5751e, app-ceb4eccc72.

<a id="c.7"></a>
## C.7 Version-1 viewer-document keys (D50)

Every key the 17 apps' page documents use at `aa80b3e3`, re-checked at `342b26ce` (with app-fd09090420's hero document), plus `dimension` (set at run time through `applyDocument` by the template's `use-viewer-actions.ts`) and `camera-collision` (app-04c2774145 and app-4762caacdc). It also maps the version-1 keys the viewer added after `aa80b3e3` (at `26d38f8b6`), and every other key the viewer's schema and plugin manifests declare (`viewer/document/viewer-schema.ts`, `viewer/plugins/manifests/*.ts`), because D50 maps every version-1 key and published layers may carry them; the migration test reads the schema from the viewer's source and fails on a key with no row. It has a row for each of the 21 plugin ids in the viewer's manifest list (`viewer/plugins/manifests/index.ts:31-53` at `eb5ce2afe`), because the admin's viewer-defaults form is built from those manifests (`services/admin/app/components/viewer-defaults/document-form-model.ts:1-20`), and for each option key the page documents set at `c4c6ffd2`. The host-only `recovery` settings are not document keys and need no row, nor are `viewer.loader.dracoDecoderPath` and `.ktx2TranscoderPath`, which the schema marks `'x-holo-host-only'` (`viewer/document/viewer-schema.ts:374-383` at `eb5ce2afe`); 7.5's accepted glTF says where 2.0's decoders come from. "Runtime-owned" means the key may still appear in the layers Holostep publishes, where the runtime reads it; a 2.0 page document that sets it records `document-error`. The runtime reads version-1 published layers through this same table (8.3).

<a id="c.7_meta"></a>
### C.7/meta: `meta` (top level; app-ddfb017681's and estate's documents keep a `note` in it, `apps/app-ddfb017681/src/viewer/page-document.json:4-6` at `c4c6ffd2`)

**kept** · written for 1.x 1.5

`meta` in the version-2 document, one to one; the runtime ignores it

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `meta` | kept | `meta` | — |

Used by: no app in the cross-check.

<a id="c.7_plugins.adaptive-zoom"></a>
### C.7/plugins.adaptive-zoom: `plugins.adaptive-zoom` (`fitOnModelLoad`, `fitPadding`, `adaptOnResize`, `fitOnSelection`, `fitOnSelectionClear`, `fitTransition`, `selectionPadding`, `persistState`, `persistKey`)

**removed** · written for 1.x 1.5

removed: call `camera.fit(query, { paddingM })` after the first scene write; the view adapting to a resize is runtime-owned and refits nothing (5.6), so `adaptOnResize: false` (app-f07e9033d9) maps to nothing: 1.x's key only re-derived size-based limits a resize leaves alone. A fit on a pick, or back to the product on a miss, is `camera.fit` in the app's `'click'` handler; app-e3b47169bc sets both `fitOnSelection` keys `false`, 1.x's default (`apps/app-e3b47169bc/src/viewer/page-document.json:30-31` at `c4c6ffd2`), so its port writes nothing. `fitTransition` is the `durationMs` of those calls: on, 1.x's default, a fit moved with camera-controls' transition, so `{ durationMs: GLIDE_MS, easing: 'damped' }` as C.6's `adaptive-zoom` row, and off `durationMs: 0`; `selectionPadding` (how many times the distance that just fits the selection's bounding sphere, 2.5 by default, `viewer/plugins/manifests/adaptive-zoom.ts:24-28` at `eb5ce2afe`) is that `fit`'s `paddingM`, matched by eye; `persistState` and `persistKey` (the camera saved to `localStorage` at rest and restored at boot, `viewer/plugins/adaptive-zoom-plugin.ts:107, 304, 361`) are app code: keep the `state` of `'camera:rest'` and write it back with `camera.set(view, { durationMs: 0 })` after the first scene write. No page document sets those four at `be828d5d`

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `plugins.adaptive-zoom.options.fitOnModelLoad` | removed | — | camera.fit(query, { paddingM }) after the first scene write |
| `plugins.adaptive-zoom.options.fitPadding` | removed | — | camera.fit paddingM |
| `plugins.adaptive-zoom.options.adaptOnResize` | removed | — | nothing: the resize view is runtime-owned |
| `plugins.adaptive-zoom.options.fitOnSelection` | removed | — | camera.fit in the app's 'click' handler |
| `plugins.adaptive-zoom.options.fitOnSelectionClear` | removed | — | camera.fit in the app's 'click' handler |
| `plugins.adaptive-zoom.options.fitTransition` | removed | — | the durationMs of the app's camera.fit: GLIDE_MS with easing 'damped' when on, 0 when off |
| `plugins.adaptive-zoom.options.selectionPadding` | removed | — | camera.fit paddingM in the app's 'click' handler |
| `plugins.adaptive-zoom.options.persistState` | removed | — | app code: store the state of 'camera:rest', restore with camera.set |
| `plugins.adaptive-zoom.options.persistKey` | removed | — | app code: the app's own storage key |

Used by: app-378d190f1b, app-4f16d2f6da, app-4fd23bc869, app-510d750508, app-822bb9d6aa, app-8675b3225c, app-b362a81c85, app-bcb7ef21da, app-c1384707fc, app-ceb4eccc72, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-fb16dd1985, app-fc306ec3dc, app-fd09090420.

<a id="c.7_plugins.adaptive-zoom_2"></a>
### C.7/plugins.adaptive-zoom~2: `plugins.adaptive-zoom` `mobileZoomScale`, `mobileTruckScale` and `enabled`

**replaced** · written for 1.x 1.5

`camera.limits.coarsePointerDollyScale` and `.coarsePointerPanScale`, default 1.5 and 0.8 as 1.x, which multiplies the dolly and pan speed by them on a coarse pointer wherever the plugin is on (`viewer/plugins/adaptive-zoom-plugin.ts:22-25, 72-75, 112, 319-328` at `eb5ce2afe`). The fleet layer turns the plugin on for every configurator (`viewer/document/default-document.ts:19-28`) and no page document sets either key, so with no value phones keep 1.x's feel (app-fb16dd1985 pinches at 1.35 and pans at 1.6). A layer that turns it off (`enabled: false`: app-4762caacdc, app-378d190f1b, estate, app-04c2774145 and app-ddfb017681, `page-document.json` at `c4c6ffd2`) maps to 1 and 1, so app-04c2774145's and app-4762caacdc's own 1.6 touch pan (G62) is not scaled again

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `plugins.adaptive-zoom.options.mobileZoomScale` | replaced | `camera.limits.coarsePointerDollyScale` | — |
| `plugins.adaptive-zoom.options.mobileTruckScale` | replaced | `camera.limits.coarsePointerPanScale` | — |
| `plugins.adaptive-zoom.enabled` | replaced | `camera.limits.coarsePointerDollyScale` | — |

Used by: app-04c2774145, app-378d190f1b, app-4762caacdc, app-4fd23bc869, app-510d750508, app-59c5b5751e, app-822bb9d6aa, app-8675b3225c, app-b362a81c85, app-b4ae2fce25, app-bcb7ef21da, app-c1384707fc, app-ceb4eccc72, app-ddfb017681, app-e3b47169bc, app-e9a33606d2, app-f07e9033d9, app-fb16dd1985, app-fc07850558, app-fd09090420.

```ts
/**
 * `plugins.adaptive-zoom` `mobileZoomScale`, `mobileTruckScale`, `enabled` → the coarse-pointer
 * scales, 1.x's 1.5 and 0.8 by default, and 1 and 1 where the plugin is off.
 */
export function adaptiveZoomScales(
  enabled: boolean,
  mobileZoomScale = 1.5,
  mobileTruckScale = 0.8,
): ViewerDocumentJson {
  return {
    schema: 'holo.viewer-document',
    version: 2,
    camera: {
      limits: {
        coarsePointerDollyScale: enabled ? mobileZoomScale : 1,
        coarsePointerPanScale: enabled ? mobileTruckScale : 1,
      },
    },
  };
}
```

<a id="c.7_plugins.camera-collision.enabled"></a>
### C.7/plugins.camera-collision.enabled: `plugins.camera-collision.enabled` and the plugin's own option `enabled` (app-04c2774145 and app-4762caacdc set the option `false`, `apps/app-04c2774145/src/viewer/page-document.json:36`, the same line in app-4762caacdc, at `be828d5d`)

**replaced** · written for 1.x 1.5

`camera.limits.collision` (`CameraLimits`): `true` only where the plugin is on and its option is not `false`, as 1.x ran it, so app-04c2774145 and app-4762caacdc keep 2.0's default `false`; the plugin's other keys (`bufferDistance`, `rayCount`, `coneAngle`, `slideEnabled`, `useBVH`, `viewer/plugins/manifests/camera-collision.ts` at `eb5ce2afe`) are runtime-owned: the clearance stays 1.x's default 0.08 m with sliding on, and no page document sets them at `be828d5d`

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `plugins.camera-collision.enabled` | replaced | `camera.limits.collision` | — |
| `plugins.camera-collision.options.enabled` | replaced | `camera.limits.collision` | — |
| `plugins.camera-collision.options.bufferDistance` | runtime-owned | — | — |
| `plugins.camera-collision.options.rayCount` | runtime-owned | — | — |
| `plugins.camera-collision.options.coneAngle` | runtime-owned | — | — |
| `plugins.camera-collision.options.slideEnabled` | runtime-owned | — | — |
| `plugins.camera-collision.options.useBVH` | runtime-owned | — | — |

Used by: app-04c2774145, app-4762caacdc, app-f9a32142f1.

```ts
/**
 * `plugins.camera-collision.enabled` (the plugin's switch) and its option `enabled` →
 * `camera.limits.collision`, on only where both are, as 1.x ran it; its other keys are
 * runtime-owned.
 */
export function cameraCollision(pluginOn: boolean, optionOn = true): ViewerDocumentJson {
  return {
    schema: 'holo.viewer-document',
    version: 2,
    camera: { limits: { collision: pluginOn && optionOn } },
  };
}
```

<a id="c.7_plugins.camera-presets.presets"></a>
### C.7/plugins.camera-presets.presets: `plugins.camera-presets.presets`

**replaced** · written for 1.x 1.5

`camera.views` in the version-2 document, each preset's `position` and `target` as a `LookAtView`; `smoothTransitions` → `{ durationMs: GLIDE_MS, easing: 'damped' }` on `views.go`, `durationMs: 0` when it is off, as C.6's `camera-presets` row

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `plugins.camera-presets.options.presets` | replaced | `camera.views` | — |
| `plugins.camera-presets.options.smoothTransitions` | replaced | `camera.views.go durationMs` | — |

Used by: app-04c2774145, app-378d190f1b, app-4762caacdc, app-510d750508, app-bcb7ef21da, app-ceb4eccc72, app-e3b47169bc, app-fb16dd1985.

```ts
/**
 * `plugins.camera-presets.options.presets`, 1.x's array of `{ name, position, target }` →
 * `camera.views`, each preset under its name as a `LookAtView`.
 */
export function cameraViews(
  presets: readonly { name: string; position: Point3; target: Point3 }[],
): ViewerDocumentJson {
  const views: Record<string, LookAtView> = {};
  for (const { name, position, target } of presets) views[name] = { position, target };
  return { schema: 'holo.viewer-document', version: 2, camera: { views } };
}

/** `smoothTransitions` → the damped `GLIDE_MS` (`glideMs`) on `views.go`, or no move time. */
export function goToView(
  session: Session,
  name: string,
  smoothTransitions: boolean,
  glideMs: number,
): Promise<Report> {
  return session.camera.views.go(name, {
    durationMs: smoothTransitions ? glideMs : 0,
    easing: 'damped',
  });
}
```

<a id="c.7_plugins.controls-legend"></a>
### C.7/plugins.controls-legend: `plugins.controls-legend` (`labels`, `position`, `mode`, `autoHide`, `hideAfterMs`)

**removed** · written for 1.x 1.5

removed: no page document sets it at `c4c6ffd2`; a page that shows gesture hints draws them in its own DOM

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `plugins.controls-legend` | removed | — | the page's own DOM |
| `plugins.controls-legend.options.labels` | removed | — | — |
| `plugins.controls-legend.options.position` | removed | — | — |
| `plugins.controls-legend.options.mode` | removed | — | — |
| `plugins.controls-legend.options.autoHide` | removed | — | — |
| `plugins.controls-legend.options.hideAfterMs` | removed | — | — |

Used by: no app in the cross-check.

<a id="c.7_plugins.decal"></a>
### C.7/plugins.decal: `plugins.decal`

**removed** · written for 1.x 1.5

removed: no page document sets it at `c4c6ffd2` (app-bcb7ef21da projects its logos itself, `use-logo-layer.ts`); a decal is `labs.decal` (5.15)

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `plugins.decal` | removed | — | labs.decal |

Used by: no app in the cross-check.

<a id="c.7_plugins.dimension"></a>
### C.7/plugins.dimension: `plugins.dimension` (`unit`, `mode`, `axes`, `selectedOnly`, `lineColor`, `lineWidth`, `labelBackground`, `labelColor`, `overrides`: every key of its manifest, `viewer/plugins/manifests/dimension.ts:39-78` at `eb5ce2afe`)

**removed** · written for 1.x 1.5

removed: `overlays.dimensions(query, { per, axes, format })`; `unit` → `format.unit`; the label ends in the unit, as 1.x (`viewer/plugins/dimension-plugin.ts:697-701` at `eb5ce2afe`), but 1.x printed centimetres to one decimal, a whole value without '.0' ('212.5 cm'), and `format`'s default `decimals: 0` prints '213 cm', a D44 item; `overrides` (a datasheet snap: a measured length within `tolerance` of `target` prints the target, `viewer/plugins/dimension-plugin.ts:686-693` at `eb5ce2afe`; app-fb16dd1985 `apps/app-fb16dd1985/src/data/app-fb16dd1985-dimensions.ts:89-105`, app-fd09090420 `apps/app-fd09090420/src/viewer/page-document.ts:38, 51-76` at `c4c6ffd2`) → the `format` function, as C.6's `patchDimensionLabels` row shows; `selectedOnly` → the query the app passes; `mode` → `per`: `'combined'`, one box over every tracked model (`viewer/plugins/dimension-plugin.ts:28-33` at `eb5ce2afe`), is the default `per: 'all'`, which the template's measure toggle and its copies send through `applyDocument` (`apps/configurator-template/src/components/viewer/use-viewer-actions.ts:102` at `c4c6ffd2`), and `'individual'`, one box per object passed to `showFor`, is `per: 'node'` (`'part'` where `supports` says no), as app-fc306ec3dc's plan rulers (`apps/app-fc306ec3dc/src/viewer/page-document.json:44` at `c4c6ffd2`, 5.9); the line and label styling (pergola, `apps/app-ceb4eccc72/src/viewer/page-document.json:50-61` at `c4c6ffd2`) → CSS on `::part(ruler)` and `::part(ruler-label)`

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `plugins.dimension` | removed | — | overlays.dimensions(query, { per, axes, format }) |
| `plugins.dimension.options.unit` | removed | — | DimensionOptions.format unit |
| `plugins.dimension.options.mode` | removed | — | DimensionOptions.per |
| `plugins.dimension.options.axes` | removed | — | DimensionOptions.axes |
| `plugins.dimension.options.selectedOnly` | removed | — | the query the app passes |
| `plugins.dimension.options.lineColor` | removed | — | CSS on ::part(ruler) |
| `plugins.dimension.options.lineWidth` | removed | — | CSS on ::part(ruler) |
| `plugins.dimension.options.labelBackground` | removed | — | CSS on ::part(ruler-label) |
| `plugins.dimension.options.labelColor` | removed | — | CSS on ::part(ruler-label) |
| `plugins.dimension.options.overrides` | removed | — | the format function (C.6/dimension.formatValue) |

Visual changes to sign off (D44): 1.x printed centimetres to one decimal ('212.5 cm'); `format`'s default `decimals: 0` prints '213 cm'..

Used by: app-37356bf09f, app-4f16d2f6da, app-4fd23bc869, app-822bb9d6aa, app-8675b3225c, app-bcb7ef21da, app-c1384707fc, app-ceb4eccc72, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f3598fad77, app-fb16dd1985, app-fc306ec3dc, app-fd09090420, configurator-template.

<a id="c.7_plugins.drag"></a>
### C.7/plugins.drag: `plugins.drag` (`draggable`, `mode`, `groundY`, `collisionCheck`)

**removed** · written for 1.x 1.5

removed: `input.drag(query, { on, constrain })`; `groundY` → `on: 'floor'`; collision is app code in `constrain`

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `plugins.drag` | removed | — | input.drag(query, { on, constrain }) |
| `plugins.drag.options.draggable` | removed | — | the query input.drag takes |
| `plugins.drag.options.mode` | removed | — | input.drag |
| `plugins.drag.options.groundY` | removed | — | input.drag on: 'floor' |
| `plugins.drag.options.collisionCheck` | removed | — | app code in constrain |

Used by: app-04c2774145, app-37356bf09f, app-4762caacdc, app-fc306ec3dc.

<a id="c.7_plugins.environment.intensity"></a>
### C.7/plugins.environment.intensity: `plugins.environment.intensity`

**replaced** · written for 1.x 1.5

`stage.environment.intensity` (`StageSettings`); without a `url` it retunes the lower layers' environment, as app-04c2774145's page document sets only the intensity (`apps/app-04c2774145/src/viewer/page-document.json:20` at `5cdd2780`)

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `plugins.environment.options.intensity` | replaced | `stage.environment.intensity` | — |

Used by: app-04c2774145, app-0c773a4ee3, app-37356bf09f, app-4762caacdc, app-4fd23bc869, app-822bb9d6aa, app-8675b3225c, app-b362a81c85, app-bcb7ef21da, app-c1384707fc, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f3598fad77, app-f9a32142f1, app-fb16dd1985, app-fc306ec3dc, app-fd09090420, configurator-template.

```ts
/** `plugins.environment.intensity` → `stage.environment.intensity`, without a `url`. */
export function environmentIntensity(intensity: number): ViewerDocumentJson {
  return { schema: 'holo.viewer-document', version: 2, stage: { environment: { intensity } } };
}
```

<a id="c.7_plugins.environment.rotation"></a>
### C.7/plugins.environment.rotation: `plugins.environment.rotation`

**replaced** · written for 1.x 1.5

`stage.environment.rotationDeg` (`StageSettings`), one to one: 1.x turned the environment by `scene.environmentRotation.y` in degrees (`viewer/plugins/environment-plugin.ts:254, 296` at `eb5ce2afe`), whose lookup three 0.186.1 turns by the inverse rotation (`renderers/webgl/WebGLMaterials.js:245`), so it samples the panorama at u = atan2(d.z, d.x) / 2π + 0.5 + yaw / 2π, the 'standard' convention's yaw (`BackgroundSpec`); without a `url` it turns the lower layers' environment, as `intensity` retunes it. No page document sets it at `be828d5d`

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `plugins.environment.options.rotation` | replaced | `stage.environment.rotationDeg` | — |

Used by: app-0c773a4ee3, app-4fd23bc869.

```ts
/** `plugins.environment.rotation` (degrees) → `stage.environment.rotationDeg`, without a `url`. */
export function environmentRotation(rotation: number): ViewerDocumentJson {
  return {
    schema: 'holo.viewer-document',
    version: 2,
    stage: { environment: { rotationDeg: rotation } },
  };
}
```

<a id="c.7_plugins.environment.backgroundintensity"></a>
### C.7/plugins.environment.backgroundIntensity: `plugins.environment.backgroundIntensity`

**removed** · written for 1.x 1.5

removed, never `stage.background: { environment }`: 1.4's environment plugin wrote only `scene.backgroundIntensity` and never drew the environment behind the product (`viewer/plugins/environment-plugin.ts:256, 298, 370` at `eb5ce2afe`). The backdrop was the stored `backgroundColor`, a gradient, a colour or none over the page (`viewer/controllers/scene.controller.ts:174-186`), whose typed form is `BackgroundSpec`, so a page without one keeps `'transparent'`: app-f3598fad77's sofa sits on the page's white (`apps/app-f3598fad77/src/viewer/page-document.json:10` at `c4c6ffd2`). three scales a texture background by that value (`WebGLBackground.js`, read in three 0.183; unverified at the viewer's 0.186.1), so on a page whose stored background is a gradient 1.4 drew it at this brightness (default 0.5); its port folds the factor into the stops or records the change under D44

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `plugins.environment.options.backgroundIntensity` | removed | — | — |

Visual changes to sign off (D44): On a page whose stored background is a gradient, 1.4 drew it at this brightness (default 0.5); the port folds the factor into the stops or records the change..

Used by: app-04c2774145, app-0c773a4ee3, app-37356bf09f, app-4762caacdc, app-4fd23bc869, app-822bb9d6aa, app-8675b3225c, app-b362a81c85, app-bcb7ef21da, app-c1384707fc, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f3598fad77, app-f9a32142f1, app-fb16dd1985, app-fc306ec3dc, app-fd09090420, configurator-template.

<a id="c.7_plugins.environment.enabled"></a>
### C.7/plugins.environment.enabled: `plugins.environment.enabled` (app-4f16d2f6da, pergola, app-378d190f1b, app-ddfb017681, estate and app-510d750508 set `false`; app-fc07850558 turned it back on)

**replaced** · written for 1.x 1.5

`false` → `stage.environment: 'none'`; `true` → the document's environment, `'studio'` unless a layer sets one

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `plugins.environment.enabled` | replaced | `stage.environment` | — |

Used by: app-0c773a4ee3, app-378d190f1b, app-4f16d2f6da, app-4fd23bc869, app-510d750508, app-59c5b5751e, app-b4ae2fce25, app-ceb4eccc72, app-ddfb017681, app-fc07850558.

```ts
/** `plugins.environment.enabled: false` → `stage.environment: 'none'`; `true` writes nothing. */
export function environmentEnabled(enabled: boolean): ViewerDocumentJson {
  return enabled
    ? { schema: 'holo.viewer-document', version: 2 }
    : { schema: 'holo.viewer-document', version: 2, stage: { environment: 'none' } };
}
```

<a id="c.7_plugins.environment.mode"></a>
### C.7/plugins.environment.mode: `plugins.environment.mode`, `plugins.environment.hdrUrl`

**removed** · written for 1.x 1.5

removed: 1.x stored them and never read them (`viewer/plugins/manifests/environment.ts:76` at `eb5ce2afe`), so every tenant got the neutral studio, which is 2.0's `'studio'`, the runtime's `'default'` layer's environment. An HDR an app loaded at run time through the element's `loadEnvironment(url)` (app-c1384707fc's studio HDR, `apps/app-c1384707fc/src/components/viewer/runtime-escape-hatches.ts:198-204` at `c4c6ffd2`) is `stage.environment: { url }` in its page document

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `plugins.environment.options.mode` | removed | — | — |
| `plugins.environment.options.hdrUrl` | removed | — | stage.environment: { url } in the page document |

Used by: app-04c2774145, app-0c773a4ee3, app-4762caacdc, app-4f16d2f6da, app-4fd23bc869, app-c1384707fc, app-ceb4eccc72.

<a id="c.7_plugins.exploded-view"></a>
### C.7/plugins.exploded-view: `plugins.exploded-view`

**removed** · written for 1.x 1.5

removed: no page document sets it at `c4c6ffd2`; an explode is the app's pose writes with `durationMs` (`PartWriteOptions`)

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `plugins.exploded-view` | removed | — | pose writes with durationMs |

Used by: no app in the cross-check.

<a id="c.7_plugins.global-material"></a>
### C.7/plugins.global-material: `plugins.global-material`, `plugins.material-swap`, `plugins.material.retainOriginalMaterials`

**runtime-owned** · written for 1.x 1.5

runtime-owned: `select` and rules always work, a selected MaterialNode's visibility zone included (8.2 rule 7); `material-swap.resetUnspecifiedProps` has no 2.0 key (C.6's `applyPBR` row). `material.retainOriginalMaterials` (1.x kept a mesh's GLB material for global-material's `clearMaterialWhenEmpty` to put back, `viewer/plugins/material-plugin.ts:349` at `eb5ce2afe`) needs no switch: the 3D file's material is always the bottom layer, so a removed rule or a `null` shows it again (R1, R5). No page document sets it at `be828d5d`

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `plugins.global-material` | runtime-owned | `select` | — |
| `plugins.material-swap` | runtime-owned | `select and rules` | — |
| `plugins.material-swap.options.resetUnspecifiedProps` | removed | — | C.6/material-swap.applyPBR |
| `plugins.material.options.retainOriginalMaterials` | runtime-owned | — | — |

Used by: app-04c2774145, app-37356bf09f, app-4762caacdc, app-4fd23bc869, app-59c5b5751e, app-822bb9d6aa, app-8675b3225c, app-bcb7ef21da, app-c1384707fc, app-ceb4eccc72, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f3598fad77, app-f9a32142f1, app-fb16dd1985, app-fc306ec3dc, app-fd09090420, configurator-template.

<a id="c.7_plugins.material-swap.textureoptions"></a>
### C.7/plugins.material-swap.textureOptions: `plugins.material-swap.textureOptions` (`anisotropy`, `encoding`; app-b362a81c85's Livorno and Loua documents set `anisotropy: 8`, `page-document.livorno.json:42`, `page-document.loua.json:36` at `c4c6ffd2`)

**removed** · written for 1.x 1.5

removed: 1.4 applied it only to textures the material-swap plugin loads itself, for its groups and `applyPBR` (`viewer/plugins/material-swap-plugin.ts:727-743` at `eb5ce2afe`), and app-b362a81c85's picks load through the material plugin, so the key changes nothing there. app-b362a81c85's sharpening is its own sweep (`components/viewer/anisotropy.ts`: level 8 on every map slot, capped by the device), which ports to C.6's sampling row: a URL-less rule (R9) with `sampling: { anisotropy: 8 }` on each slot it sweeps

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `plugins.material-swap.options.textureOptions` | removed | — | — |
| `plugins.material-swap.options.textureOptions.anisotropy` | removed | — | TextureSpec sampling.anisotropy (C.6/apply-fabric-uv-repeat.ts) |
| `plugins.material-swap.options.textureOptions.encoding` | removed | — | — |

Used by: app-04c2774145, app-37356bf09f, app-4762caacdc, app-4fd23bc869, app-822bb9d6aa, app-8675b3225c, app-bcb7ef21da, app-c1384707fc, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f3598fad77, app-fb16dd1985, app-fd09090420, configurator-template.

<a id="c.7_plugins.highlight"></a>
### C.7/plugins.highlight: `plugins.highlight` (`color`, `edgeThickness`, `edgeOpacity`, `surfaceOpacity`, `hoverEnabled`, `selectionEnabled`)

**replaced** · written for 1.x 1.5

`PartState.outline`, written from the app's `'hover'` handler on the hovered product's model root (or collection root), as 1.x outlined the model root, and on the selected product (section 12, Q17); `color` → `outline.color`; `hoverEnabled` and `selectionEnabled` → which of the two the app writes; `edgeThickness` → `outline.widthPx`, matched by eye; `edgeOpacity` and `surfaceOpacity` have no field: the parts keep their colour, so 1.x's 12% fill in `#e8f4fd` (the defaults, `viewer/plugins/manifests/highlight.ts:10-34` at `eb5ce2afe`) goes, a visual change each of the four ports signs off in its diff (D44, the 9.5 outline row)

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `plugins.highlight` | replaced | `PartState.outline` | — |
| `plugins.highlight.options.color` | replaced | `PartState.outline.color` | — |
| `plugins.highlight.options.edgeThickness` | replaced | `PartState.outline.widthPx` | — |
| `plugins.highlight.options.edgeOpacity` | removed | — | — |
| `plugins.highlight.options.surfaceOpacity` | removed | — | — |
| `plugins.highlight.options.hoverEnabled` | removed | — | the app writes the outline from its 'hover' handler |
| `plugins.highlight.options.selectionEnabled` | removed | — | the app writes the outline on the selected product |

Visual changes to sign off (D44): 1.x's 12% fill in `#e8f4fd` goes (no `edgeOpacity` or `surfaceOpacity` field); each of the four ports signs it off in its diff..

Used by: app-4f16d2f6da, app-510d750508, app-822bb9d6aa, app-ceb4eccc72.

```ts
/**
 * `plugins.highlight` → `PartState.outline` on the hovered product's model root, written from
 * the app's `'hover'` handler: `color` → `outline.color`, `edgeThickness` → `outline.widthPx`
 * (by eye). `edgeOpacity` and `surfaceOpacity` have no field.
 */
export function outlineOnHover(
  session: Session,
  color: ColorValue,
  edgeThickness: number,
  signal: AbortSignal,
): () => void {
  let outlined: string | null = null;
  return session.on(
    'hover',
    ({ hit }) => {
      const node = hit?.part.node ?? null;
      if (node === outlined) return;
      if (outlined) void session.node(outlined).set({ outline: null });
      if (node) void session.node(node).set({ outline: { color, widthPx: edgeThickness } });
      outlined = node;
    },
    { signal },
  );
}
```

<a id="c.7_plugins.hotspot"></a>
### C.7/plugins.hotspot: `plugins.hotspot`

**runtime-owned** · written for 1.x 1.5

runtime-owned: `overlays.hotspots`; `clustering` has no 2.0 home (of the 27 page documents at `c4c6ffd2`, 15 set `"hotspot": {}` and 12 have none)

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `plugins.hotspot` | runtime-owned | `overlays.hotspots` | — |
| `plugins.hotspot.options.clustering` | removed | — | — |

Used by: app-37356bf09f, app-4fd23bc869, app-822bb9d6aa, app-8675b3225c, app-bcb7ef21da, app-c1384707fc, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f3598fad77, app-f9a32142f1, app-fb16dd1985, app-fd09090420, configurator-template.

<a id="c.7_plugins.material.quality"></a>
### C.7/plugins.material.quality: `plugins.material.quality`, `plugins.material.maxVramBytes`

**runtime-owned** · written for 1.x 1.5

runtime-owned: the quality tier and its texture budget (B.4 X10). The runtime prefers the optimized twin on every tier, so the originals-first `'high'` has no 2.0 home (app-f9a32142f1 moved to `'auto'` and a 64 MiB budget at `342b26ce`, `page-document.ts:20-32`). The pilot pins `'high'` (`apps/app-04c2774145/src/viewer/page-document.json:39`, the same line in app-4762caacdc, at `c4c6ffd2`), so its fabrics show their optimized twins in 2.0: a look change its G5 states include as a fabric close-up at both sizes. The same close-up shows stored metalness, roughness and ORM maps magnified smooth, where 1.x drew them nearest-neighbour (8.2 rule 1)

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `plugins.material.options.quality` | runtime-owned | `the quality tier` | — |
| `plugins.material.options.maxVramBytes` | runtime-owned | `the quality tier's texture budget` | — |

Visual changes to sign off (D44): The pilot's pinned `'high'` shows the optimized twins: a fabric close-up at both sizes in its G5 states, and stored metalness, roughness and ORM maps magnified smooth..

Used by: app-04c2774145, app-4762caacdc, app-59c5b5751e, app-ceb4eccc72, app-f9a32142f1.

<a id="c.7_plugins.material.progressive"></a>
### C.7/plugins.material.progressive: `plugins.material.progressive`, `plugins.material.adaptiveTextureBudget` (no app sets them; 1.4's defaults are `true`, `viewer/plugins/material-plugin.ts:343-344` at `eb5ce2afe`)

**runtime-owned** · written for 1.x 1.5

runtime-owned: colour maps first, detail maps as late swaps (6.6 rule 7, section 12, Q36); the texture budget follows the quality tier (B.4 X10)

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `plugins.material.options.progressive` | runtime-owned | — | — |
| `plugins.material.options.adaptiveTextureBudget` | runtime-owned | — | — |

Used by: app-04c2774145, app-4762caacdc, app-59c5b5751e, app-ceb4eccc72.

<a id="c.7_plugins.post-process.ao"></a>
### C.7/plugins.post-process.ao: `plugins.post-process.ao`, `.bloom`

**replaced** · written for 1.x 1.5

`stage.ambientOcclusion`, `stage.bloom`. `ao.aoRadius` → `radius` (metres or `'auto'`); `ao.intensity` → `strength = min(1, intensity / 3)` (21 of 27 page documents set 3, so they map to 1; the 1.x default 1.6 maps to 0.53; section 12, Q35); `ao.distanceFalloff`, `.denoiseSamples`, `.denoiseRadius`, `.denoiseIterations`, `.transparencyAware` → runtime-owned per tier (B.4 X14)

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `plugins.post-process.options.ao` | replaced | `stage.ambientOcclusion` | — |
| `plugins.post-process.options.ao.aoRadius` | replaced | `stage.ambientOcclusion.radius` | — |
| `plugins.post-process.options.ao.intensity` | replaced | `stage.ambientOcclusion.strength` | — |
| `plugins.post-process.options.ao.distanceFalloff` | runtime-owned | — | — |
| `plugins.post-process.options.ao.denoiseSamples` | runtime-owned | — | — |
| `plugins.post-process.options.ao.denoiseRadius` | runtime-owned | — | — |
| `plugins.post-process.options.ao.denoiseIterations` | runtime-owned | — | — |
| `plugins.post-process.options.ao.transparencyAware` | runtime-owned | — | — |
| `plugins.post-process.options.bloom` | replaced | `stage.bloom` | — |

Used by: app-04c2774145, app-37356bf09f, app-4762caacdc, app-4f16d2f6da, app-4fd23bc869, app-510d750508, app-59c5b5751e, app-822bb9d6aa, app-8675b3225c, app-b362a81c85, app-bcb7ef21da, app-c1384707fc, app-ceb4eccc72, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f9a32142f1, app-fb16dd1985, app-fc07850558, app-fc306ec3dc, app-fd09090420, configurator-template.

```ts
/**
 * `plugins.post-process.ao`, `.bloom` → `stage.ambientOcclusion`, `stage.bloom`: `aoRadius` →
 * `radius`; `intensity` → `strength = min(1, intensity / 3)` (1.x's default 1.6 → 0.53); the
 * denoise keys are runtime-owned.
 */
export function ambientOcclusion(
  ao: false | { aoRadius?: number; intensity?: number },
  bloom: boolean,
): ViewerDocumentJson {
  return {
    schema: 'holo.viewer-document',
    version: 2,
    stage: {
      ambientOcclusion:
        ao === false
          ? false
          : { radius: ao.aoRadius ?? 'auto', strength: Math.min(1, (ao.intensity ?? 1.6) / 3) },
      bloom,
    },
  };
}
```

<a id="c.7_plugins.post-process.enabled"></a>
### C.7/plugins.post-process.enabled: `plugins.post-process.enabled` and the plugin's own option `enabled` (app-fc07850558 sets the switch, `"post-process": { "enabled": true, … }`, `apps/app-fc07850558/src/components/viewer/page-document.json:12-13` at `be828d5d`)

**replaced** · written for 1.x 1.5

`false` (either one) → `stage.ambientOcclusion: false` and `stage.bloom: false`: 1.x then drew each frame directly, past its effect chain (`viewer/plugins/post-process-plugin.ts:766-769` at `eb5ce2afe`), so no AO and no bloom, while anti-aliasing stays the tier's (B.4 X9); `true` writes nothing, and the `ao` and `bloom` row above maps the options

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `plugins.post-process.enabled` | replaced | `stage.ambientOcclusion` | — |
| `plugins.post-process.options.enabled` | replaced | `stage.ambientOcclusion` | — |

Used by: app-59c5b5751e, app-b362a81c85, app-ceb4eccc72, app-fc07850558.

```ts
/**
 * `plugins.post-process.enabled`, or its option `enabled`, `false` → no AO and no bloom: 1.x drew
 * past its effect chain. `true` writes nothing; the `ao` and `bloom` row maps the options.
 */
export function postProcessEnabled(enabled: boolean): ViewerDocumentJson {
  return enabled
    ? { schema: 'holo.viewer-document', version: 2 }
    : {
        schema: 'holo.viewer-document',
        version: 2,
        stage: { ambientOcclusion: false, bloom: false },
      };
}
```

<a id="c.7_plugins.post-process.antialias"></a>
### C.7/plugins.post-process.antialias: `plugins.post-process.antialias`, `.multisampling`, `.lowTierBypass`, `.beautyFrameEffects`, `.maxAoPixels`

**runtime-owned** · written for 1.x 1.5

runtime-owned: the quality tier; the medium and high tiers anti-alias, and the low tier draws one sample with no anti-aliasing for an `antialias: 'none'` chain, as production since `090cd0ee3` (B.4 X9, DL49). app-b362a81c85 (Livorno, Loua) and app-f9a32142f1 set `lowTierBypass: false` so the low tier keeps AO (a D44 item, DL22) and, before `090cd0ee3`, antialiasing; 2.0's low tier keeps a half-float frame (Q3). Whether the effect chain runs on the idle still (`beautyFrameEffects`) and the drawing-buffer size above which the AO pass drops to half resolution (`maxAoPixels`, `viewer/plugins/post-process-plugin.ts:279-280, 1102` at `eb5ce2afe`) follow the tier too (B.4 X14). No page document sets those two at `be828d5d`

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `plugins.post-process.options.antialias` | runtime-owned | — | — |
| `plugins.post-process.options.multisampling` | runtime-owned | — | — |
| `plugins.post-process.options.lowTierBypass` | runtime-owned | — | — |
| `plugins.post-process.options.beautyFrameEffects` | runtime-owned | — | — |
| `plugins.post-process.options.maxAoPixels` | runtime-owned | — | — |

Used by: app-04c2774145, app-37356bf09f, app-4762caacdc, app-4f16d2f6da, app-4fd23bc869, app-510d750508, app-59c5b5751e, app-822bb9d6aa, app-8675b3225c, app-b362a81c85, app-bcb7ef21da, app-c1384707fc, app-ceb4eccc72, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f9a32142f1, app-fb16dd1985, app-fc07850558, app-fc306ec3dc, app-fd09090420, configurator-template.

<a id="c.7_plugins.post-process.owntonemapping"></a>
### C.7/plugins.post-process.ownToneMapping: `plugins.post-process.ownToneMapping`

**runtime-owned** · written for 1.x 1.5

runtime-owned: the colour pipeline (one tone curve, in every path)

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `plugins.post-process.options.ownToneMapping` | runtime-owned | — | — |

Used by: app-04c2774145, app-37356bf09f, app-4762caacdc, app-4f16d2f6da, app-4fd23bc869, app-510d750508, app-59c5b5751e, app-822bb9d6aa, app-8675b3225c, app-b362a81c85, app-bcb7ef21da, app-c1384707fc, app-ceb4eccc72, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f9a32142f1, app-fb16dd1985, app-fc07850558, app-fc306ec3dc, app-fd09090420, configurator-template.

<a id="c.7_plugins.preload"></a>
### C.7/plugins.preload: `plugins.preload` (`prefetchTextures`, `prefetchVariants`, `preloadActiveGlbs`, `concurrency`, `constrainedMaxAssets`, `quality`)

**runtime-owned** · written for 1.x 1.5

runtime-owned: readiness. The runtime keeps the 1.4 viewer's prefetch rule (Erik, 2026-10-14: required models first): it starts after the first complete frame, with at most 2 requests in flight, and none while a model the shopper asked for or a picked material's maps load. Phones, tablets and slow links get only the enabled siblings of an active choice, at most 8; Save-Data gets nothing; WebKit uses low-priority `fetch()` (`viewer/plugins/preload-plugin.ts:13-43` at `26d38f8b6`). Past those first 8 siblings, the rest of the list stops at 64 MiB per visit on every device (DECIDED, R1-GAP-1: the 1.4 rule took app-04c2774145's and app-4762caacdc's whole 494 MB catalogue on a desktop, which their page documents switch off), so app-f9a32142f1's switch-off (`prefetchVariants: false`, set against the earlier blanket prefetch) and app-4762caacdc's and app-04c2774145's need no 2.0 key. To fetch one option ahead of the pick, `labs.prefetch` (G56) replaces its hover and focus preload (configurators `971f858a5`)

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `plugins.preload` | runtime-owned | `readiness` | — |
| `plugins.preload.options.prefetchTextures` | runtime-owned | — | — |
| `plugins.preload.options.prefetchVariants` | runtime-owned | — | — |
| `plugins.preload.options.preloadActiveGlbs` | runtime-owned | — | — |
| `plugins.preload.options.concurrency` | runtime-owned | — | — |
| `plugins.preload.options.constrainedMaxAssets` | runtime-owned | — | — |
| `plugins.preload.options.quality` | runtime-owned | — | — |

Used by: app-04c2774145, app-37356bf09f, app-378d190f1b, app-4762caacdc, app-4f16d2f6da, app-510d750508, app-59c5b5751e, app-b4ae2fce25, app-ceb4eccc72, app-ddfb017681, app-f9a32142f1, app-fc07850558.

<a id="c.7_plugins.material.enabled"></a>
### C.7/plugins.material.enabled: `plugins.material.enabled`, `plugins.preload.enabled`, `plugins.camera-presets.enabled` (estate and app-ddfb017681 set all three `false`, `apps/app-b4ae2fce25/src/viewer/page-document.json:10-13` at `c4c6ffd2`; seven more apps set one or two, app-4762caacdc and app-04c2774145 `preload`, `page-document.json:47`)

**removed** · written for 1.x 1.5

removed, with nothing to turn off: the scene model's materials always apply (R1) and the prefetch rule is runtime-owned (the row above), so an app with no scene model loads and prefetches nothing and one with a model gets the prefetch rule; `camera.views` holds only the views the document gives

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `plugins.material.enabled` | removed | — | — |
| `plugins.preload.enabled` | removed | — | — |
| `plugins.camera-presets.enabled` | removed | — | — |

Used by: app-04c2774145, app-378d190f1b, app-4762caacdc, app-4f16d2f6da, app-510d750508, app-59c5b5751e, app-b4ae2fce25, app-bcb7ef21da, app-ceb4eccc72, app-ddfb017681, app-fb16dd1985, app-fc07850558.

<a id="c.7_plugins.review-annotation"></a>
### C.7/plugins.review-annotation: `plugins.review-annotation`

**removed** · written for 1.x 1.5

removed: a review tool of the configurator service's playground (`services/configurator/app/routes/playground.tsx`); no page document sets it at `c4c6ffd2`

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `plugins.review-annotation` | removed | — | — |

Used by: no app in the cross-check.

<a id="c.7_plugins.runtime-lod"></a>
### C.7/plugins.runtime-lod: `plugins.runtime-lod`

**runtime-owned** · written for 1.x 1.5

runtime-owned: geometry detail follows the quality tier, as the texture budget does (B.4 X10), and LOD swaps stop during a hold (6.6 rule 3); no page document enables it at `c4c6ffd2`. It changes only the drawn detail: reads, exports and captures use each part's full geometry (B.4 X18)

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `plugins.runtime-lod` | runtime-owned | `the quality tier` | — |

Used by: no app in the cross-check.

<a id="c.7_plugins.scene-history.maxhistory"></a>
### C.7/plugins.scene-history.maxHistory: `plugins.scene-history.maxHistory`, `.autoCaptureEvents` and `.enabled` (app-04c2774145 and app-4762caacdc name the plugin with `"scene-history": {}`, which turns it on, `apps/app-04c2774145/src/viewer/page-document.json:40`, the same line in app-4762caacdc, at `be828d5d`)

**runtime-owned** · written for 1.x 1.5

runtime-owned: 50 steps on every tier, the value 15 apps set (section 12, Q19; B.4 X15); history is always on, as the 1.x runtime kept the plugin on whatever a document said (`rt/floor.ts:5, 17-26` at `eb5ce2afe`), so `.enabled` has nothing to write. `autoCaptureEvents` (viewer events that also took a debounced undo snapshot, none by default, `viewer/plugins/scene-history-plugin.ts:102` at `eb5ce2afe`) is removed: a 2.0 step is a recorded write with its `undo` label, never a viewer event. No page document sets it at `be828d5d`

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `plugins.scene-history.options.maxHistory` | runtime-owned | — | — |
| `plugins.scene-history.enabled` | runtime-owned | — | — |
| `plugins.scene-history.options.autoCaptureEvents` | removed | — | nothing: a step is a recorded write and its undo label |

Used by: app-04c2774145, app-37356bf09f, app-4762caacdc, app-4fd23bc869, app-822bb9d6aa, app-8675b3225c, app-bcb7ef21da, app-c1384707fc, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f3598fad77, app-f9a32142f1, app-fb16dd1985, app-fc306ec3dc, app-fd09090420, configurator-template.

<a id="c.7_plugins.scene-history.restorescene"></a>
### C.7/plugins.scene-history.restoreScene: `plugins.scene-history.restoreScene` (app-fc306ec3dc: `false`, with `maxHistory: 50`)

**removed** · written for 1.x 1.5

removed: an app that keeps its own undo passes `undo: 'rebase'` on its scene writes (D49, 11.2)

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `plugins.scene-history.options.restoreScene` | removed | — | undo: 'rebase' on the app's scene writes |

Used by: app-04c2774145, app-4762caacdc, app-fc306ec3dc.

<a id="c.7_plugins.screenshot"></a>
### C.7/plugins.screenshot: `plugins.screenshot`

**runtime-owned** · written for 1.x 1.5

runtime-owned: `capture`

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `plugins.screenshot` | runtime-owned | `capture` | — |

Used by: app-04c2774145, app-4762caacdc, app-4f16d2f6da, app-4fd23bc869, app-510d750508, app-822bb9d6aa, app-8675b3225c, app-b362a81c85, app-b4ae2fce25, app-bcb7ef21da, app-c1384707fc, app-ceb4eccc72, app-ddfb017681, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f3598fad77, app-f9a32142f1, app-fb16dd1985, app-fc07850558, app-fc306ec3dc, app-fd09090420, configurator-template.

<a id="c.7_plugins.snap-connector"></a>
### C.7/plugins.snap-connector: `plugins.snap-connector` (`debugVisualize`)

**runtime-owned** · written for 1.x 1.5

runtime-owned: `assembly`; `debugVisualize` is a HoloDebug concern

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `plugins.snap-connector` | runtime-owned | `assembly` | — |
| `plugins.snap-connector.options.debugVisualize` | runtime-owned | — | — |

Used by: app-04c2774145, app-37356bf09f, app-4762caacdc, app-c1384707fc, app-f9a32142f1.

<a id="c.7_viewer.ar.enabled"></a>
### C.7/viewer.ar.enabled: `viewer.ar.enabled`, `.preferredModes`, `.allowUnfetchableSceneViewerUrl`

**runtime-owned** · written for 1.x 1.5

runtime-owned: the account's published layers, read back as `info().ar` (`enabled`, and `modes` in that order, narrowed to this device); `activateAR()` reports `'disabled'`. A page document that sets `.preferredModes` moves it into its account's published layer at the port (app-fd09090420: `webxr` first, `page-document.json:12` at `c4c6ffd2`). A version-1 order with `'webxr'` first (app-fd09090420) becomes `ar.place.android: 'holostep'` in that layer, because 2.0 has no second WebXR path (5.11, D64). `allowUnfetchableSceneViewerUrl` (Scene Viewer launched on a page-scoped `blob:` URL it cannot fetch, `viewer/document/viewer-schema.ts:428` at `eb5ce2afe`) is removed: `activateAR` never picks Scene Viewer without an http(s) `glbUrl` (5.11). No page document sets it at `be828d5d`

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `viewer.ar.enabled` | runtime-owned | `info().ar.enabled` | — |
| `viewer.ar.preferredModes` | runtime-owned | `info().ar.modes` | — |
| `viewer.ar.allowUnfetchableSceneViewerUrl` | removed | — | nothing: activateAR picks Scene Viewer only with an http(s) glbUrl (5.11) |

Used by: app-fd09090420.

<a id="c.7_ar.place"></a>
### C.7/ar.place: `ar.place.*` (`android`, `ios`, `autoPlace`, `androidShare`) and `ar.native.*` (`banner`, `title`, `cta`, `cartUrlTemplate`), next to `viewer.ar.enabled`

**runtime-owned** · written for 1.x 1.5

runtime-owned: `activateAR`'s route reads them from the account's published layer (5.11); a page document that sets one records `document-error` (D64, XR §6.3)

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `ar.place.android` | runtime-owned | — | — |
| `ar.place.ios` | runtime-owned | — | — |
| `ar.place.autoPlace` | runtime-owned | — | — |
| `ar.place.androidShare` | runtime-owned | — | — |
| `ar.native.banner` | runtime-owned | — | — |
| `ar.native.title` | runtime-owned | — | — |
| `ar.native.cta` | runtime-owned | — | — |
| `ar.native.cartUrlTemplate` | runtime-owned | — | — |

Used by: app-fc07850558.

<a id="c.7_xr.tryon"></a>
### C.7/xr.tryOn: `xr.tryOn.*` (`enabled`, `consent`) and `xr.ui.*` (`theme`, `copy`, `controls`)

**runtime-owned** · written for 1.x 1.5

runtime-owned, read by `@holo/xr`; `xr.tryOn.consent` is read from the account layer only (D64, XR §6.3)

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `xr.tryOn.enabled` | runtime-owned | — | — |
| `xr.tryOn.consent` | runtime-owned | — | — |
| `xr.ui.theme` | runtime-owned | — | — |
| `xr.ui.copy` | runtime-owned | — | — |
| `xr.ui.controls` | runtime-owned | — | — |

Used by: no app in the cross-check.

<a id="c.7_viewer.camera.fov"></a>
### C.7/viewer.camera.fov: `viewer.camera.fov`

**removed** · written for 1.x 1.5

removed: 1.x used it only until the viewer first sized, then derived the vertical field of view from `horizontalFov` on every resize (`viewer/types.ts:139-151`, `viewer/controllers/camera.controller.ts:511-545` at `eb5ce2afe`), so the document key maps to nothing. An app that holds a vertical field of view through a hatch on every frame writes `camera.lens({ fovDeg })` once, which 2.0 holds across resizes: chair 40 (`holdVerticalFov`, `apps/app-59c5b5751e/src/scene/chairPlugin.ts:148, 158`), app-378d190f1b 42 (`setHorizontalFov(horizontalFovFor(aspect))`, `apps/app-378d190f1b/src/three/pergola-scene-plugin.ts:47-48, 76-83`, `src/three/camera.ts:32`) and app-e3b47169bc 45 once its own-renderer preview moves to a session (`apps/app-e3b47169bc/src/components/viewer/glb-preview.tsx:236`, all at `c4c6ffd2`)

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `viewer.camera.fov` | removed | — | camera.lens({ fovDeg }) once, for an app that held a vertical field of view |

Used by: app-59c5b5751e.

<a id="c.7_viewer.camera.horizontalfov"></a>
### C.7/viewer.camera.horizontalFov: `viewer.camera.horizontalFov`

**replaced** · written for 1.x 1.5

`camera.lens.horizontalFovDeg` in the version-2 document; no page document sets it at `c4c6ffd2`, and the default lens holds 1.x's 60° horizontal, except that a `fit` on a portrait element frames at 45° vertical at most (`CameraLens`; 5.6, DL36, DL61's 2026-10-26 amendment); a port whose phone framing must stay 1.x's may write `horizontalFovDeg: 60`, which is never capped

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `viewer.camera.horizontalFov` | replaced | `camera.lens.horizontalFovDeg` | — |

Used by: no app in the cross-check.

```ts
/** `viewer.camera.horizontalFov` → `camera.lens.horizontalFovDeg` (the default lens holds 60°). */
export function horizontalFov(degrees: number): ViewerDocumentJson {
  return {
    schema: 'holo.viewer-document',
    version: 2,
    camera: { lens: { horizontalFovDeg: degrees } },
  };
}
```

<a id="c.7_viewer.camera.near"></a>
### C.7/viewer.camera.near: `viewer.camera.near`, `.far`

**runtime-owned** · written for 1.x 1.5

runtime-owned (K40, B.4 X12)

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `viewer.camera.near` | runtime-owned | — | — |
| `viewer.camera.far` | runtime-owned | — | — |

Used by: app-378d190f1b, app-59c5b5751e.

<a id="c.7_viewer.camera.adaptivezoomlimits"></a>
### C.7/viewer.camera.adaptiveZoomLimits: `viewer.camera.adaptiveZoomLimits`

**replaced** · written for 1.x 1.5

`true` (1.x's default; 18 of 25 page documents at `c4c6ffd2`): no distance in the version-2 document, so the runtime fits them (`CameraLimits.minDistance`, 5.6); `false` (app-4762caacdc, chair, app-37356bf09f, app-378d190f1b, app-04c2774145, app-f9a32142f1, app-fc306ec3dc): the layer's `controls.minDistance` and `.maxDistance`, else 1.x's 0.5 and 50 m, as `camera.limits.minDistance` and `.maxDistance` (`viewer/controllers/camera.controller.ts:285-324` at `eb5ce2afe`)

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `viewer.camera.adaptiveZoomLimits` | replaced | `camera.limits.minDistance` | — |

Used by: app-04c2774145, app-37356bf09f, app-378d190f1b, app-4762caacdc, app-59c5b5751e, app-f9a32142f1, app-fc306ec3dc.

```ts
/**
 * `viewer.camera.adaptiveZoomLimits`: `true` writes no distance (the runtime fits them); `false`
 * takes the layer's `controls.minDistance` and `.maxDistance`, else 1.x's 0.5 and 50 m.
 */
export function adaptiveZoomLimits(
  adaptive: boolean,
  minDistance = 0.5,
  maxDistance = 50,
): ViewerDocumentJson {
  return adaptive
    ? { schema: 'holo.viewer-document', version: 2 }
    : {
        schema: 'holo.viewer-document',
        version: 2,
        camera: { limits: { minDistance, maxDistance } },
      };
}
```

<a id="c.7_viewer.controls.mindistance"></a>
### C.7/viewer.controls.minDistance: `viewer.controls.minDistance`, `.maxDistance`

**replaced** · written for 1.x 1.5

`camera.limits.minDistance`, `.maxDistance` (`CameraLimits`) when the layer sets `adaptiveZoomLimits: false` or `maxDistanceFitFactor`; otherwise removed, since 1.x's adaptive limits ignored them

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `viewer.controls.minDistance` | replaced | `camera.limits.minDistance` | — |
| `viewer.controls.maxDistance` | replaced | `camera.limits.maxDistance` | — |

Used by: app-04c2774145, app-37356bf09f, app-378d190f1b, app-4762caacdc, app-59c5b5751e, app-b4ae2fce25, app-ddfb017681, app-e61a0b42ad, app-f07e9033d9, app-f9a32142f1, configurator-template.

```ts
/**
 * `viewer.controls.minDistance`, `.maxDistance` → `camera.limits` when the layer sets
 * `adaptiveZoomLimits: false` or `maxDistanceFitFactor`; otherwise 1.x ignored them.
 */
export function distances(minDistance: number, maxDistance: number): ViewerDocumentJson {
  return {
    schema: 'holo.viewer-document',
    version: 2,
    camera: { limits: { minDistance, maxDistance } },
  };
}
```

<a id="c.7_viewer.controls.maxdistancefitfactor"></a>
### C.7/viewer.controls.maxDistanceFitFactor: `viewer.controls.maxDistanceFitFactor`

**replaced** · written for 1.x 1.5

`camera.limits.maxDistanceFit` (`CameraLimits`), with the layer's distances (else 1.x's 0.5 and 50 m), at least the factor that keeps the app's start view unclamped: 1.x clamped only a dolly gesture to the cap, 2.0 clamps every view write (5.6), so take the larger of the 1.x factor and the stored start distance (with any pull-back the app adds) ÷ the sphere-fit distance r / sin(h) at the narrowest aspect the page shows, and pin 1.x's lens (`horizontalFovDeg`) if the app frames from that distance. 1.x values at `c4c6ffd2`: the template (2, 0.3–14 m; its port needs 6 with `horizontalFovDeg: 60`), app-b362a81c85's Livorno and Loua documents (2: check their start views), app-f07e9033d9 (100, 3.57–25 m), app-e61a0b42ad (100, 0.2–1.5 m). A raised factor also lets the shopper dolly further out than 1.x (the template: 2.24 m → 5.9–6.7 m): a visual change on the port's own D44 sign-off

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `viewer.controls.maxDistanceFitFactor` | replaced | `camera.limits.maxDistanceFit` | — |

Used by: app-e61a0b42ad, app-f07e9033d9, configurator-template.

```ts
/**
 * `viewer.controls.maxDistanceFitFactor` → `camera.limits.maxDistanceFit`, with the distances.
 * 2.0 clamps every view write to the cap where 1.x clamped only a dolly gesture, so the factor is
 * at least `startFactor`: the app's start distance (with its pull-back) ÷ the sphere-fit distance
 * at the narrowest aspect the page shows, rounded up (the template: 2 → 6).
 */
export function maxDistanceFit(
  factor: number,
  minDistance = 0.5,
  maxDistance = 50,
  startFactor = 0,
): ViewerDocumentJson {
  return {
    schema: 'holo.viewer-document',
    version: 2,
    camera: {
      limits: {
        maxDistanceFit: Math.max(factor, Math.ceil(startFactor)),
        minDistance,
        maxDistance,
      },
    },
  };
}
```

<a id="c.7_viewer.controls.minpolarangle"></a>
### C.7/viewer.controls.minPolarAngle: `viewer.controls.minPolarAngle`, `.maxPolarAngle` (radians)

**replaced** · written for 1.x 1.5

`camera.limits.minPolarDeg`, `.maxPolarDeg` (degrees)

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `viewer.controls.minPolarAngle` | replaced | `camera.limits.minPolarDeg` | — |
| `viewer.controls.maxPolarAngle` | replaced | `camera.limits.maxPolarDeg` | — |

Used by: app-04c2774145, app-37356bf09f, app-378d190f1b, app-4762caacdc, app-59c5b5751e, app-f3598fad77, app-f9a32142f1, app-fd09090420, configurator-template.

```ts
/** `viewer.controls.minPolarAngle`, `.maxPolarAngle` (radians) → `minPolarDeg`, `maxPolarDeg`. */
export function polarLimits(minPolarAngle: number, maxPolarAngle: number): ViewerDocumentJson {
  const degrees = (radians: number) => (radians * 180) / Math.PI;
  return {
    schema: 'holo.viewer-document',
    version: 2,
    camera: {
      limits: { minPolarDeg: degrees(minPolarAngle), maxPolarDeg: degrees(maxPolarAngle) },
    },
  };
}
```

<a id="c.7_viewer.controls.touchpaddollyscale"></a>
### C.7/viewer.controls.touchpadDollyScale: `viewer.controls.touchpadDollyScale`

**replaced** · written for 1.x 1.5

`camera.limits.touchpadDollyScale`, default 2.5 as 1.x (`viewer/types.ts:780` at `eb5ce2afe`): app-4762caacdc and app-04c2774145 set 5, app-37356bf09f 10, app-f9a32142f1 12 (its document says 10 under the 1.x schema cap, and `apps/app-f9a32142f1/src/components/viewer/use-viewer.ts:63, 193` raises it to 12 after boot), chair 1, and the other 20 page documents rely on 2.5 (`page-document.json` at `c4c6ffd2`)

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `viewer.controls.touchpadDollyScale` | replaced | `camera.limits.touchpadDollyScale` | — |

Used by: app-04c2774145, app-37356bf09f, app-4762caacdc, app-59c5b5751e, app-f9a32142f1.

```ts
/** `viewer.controls.touchpadDollyScale` → `camera.limits.touchpadDollyScale` (default 2.5). */
export function touchpadDollyScale(scale = 2.5): ViewerDocumentJson {
  return {
    schema: 'holo.viewer-document',
    version: 2,
    camera: { limits: { touchpadDollyScale: scale } },
  };
}
```

<a id="c.7_viewer.controls.truckaxes"></a>
### C.7/viewer.controls.truckAxes: `viewer.controls.truckAxes`, `.enableZoom`

**replaced** · written for 1.x 1.5

`truckAxes` is `camera.limits.panAxes` (`CameraLimits`): `'horizontal'` (app-37356bf09f's and app-f9a32142f1's page documents) and `'none'` map one to one, and `'none'` can also be a `target` box around the target; pan only is `camera.mode('pan')`. `enableZoom` is removed: no zoom (`enableZoom: false`: the template's `src/viewer/hero-document.json` and six copies, app-4fd23bc869, app-f07e9033d9, app-fd09090420, app-e9a33606d2, app-8675b3225c and app-f3598fad77, at `c4c6ffd2`) is `dollySpeed: 0` in the version-2 document's `camera.limits` (or `camera.limits({ dollySpeed: 0 })`), so a hero framed by `camera.fit` keeps its fitted distance, the wheel then scrolls the page and a two-finger touch orbits, as in 1.x (5.6); `camera.limits({ minDistance: d, maxDistance: d })` only for a known fixed distance

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `viewer.controls.truckAxes` | replaced | `camera.limits.panAxes` | — |
| `viewer.controls.enableZoom` | removed | — | camera.limits.dollySpeed: 0 |

Used by: app-04c2774145, app-37356bf09f, app-4762caacdc, app-4fd23bc869, app-8675b3225c, app-e9a33606d2, app-f07e9033d9, app-f3598fad77, app-f9a32142f1, app-fd09090420, configurator-template.

```ts
/**
 * `viewer.controls.truckAxes` → `camera.limits.panAxes`, one to one; `enableZoom: false` is
 * `dollySpeed: 0`, which leaves the wheel to the page and lets a two-finger touch orbit.
 */
export function panAndZoom(
  truckAxes: 'horizontal' | 'none',
  enableZoom: boolean,
): ViewerDocumentJson {
  const limits: CameraLimits = { panAxes: truckAxes, ...(enableZoom ? {} : { dollySpeed: 0 }) };
  return { schema: 'holo.viewer-document', version: 2, camera: { limits } };
}
```

<a id="c.7_viewer.controls.rotatespeed"></a>
### C.7/viewer.controls.rotateSpeed: `viewer.controls.rotateSpeed`, `.dollySpeed`, `.truckSpeed`, `.dollyToCursor`

**replaced** · written for 1.x 1.5

`camera.limits.rotateSpeed`, `.dollySpeed`, `.panSpeed`, `.dollyToCursor` (`CameraLimits`), one to one, with 1.x's defaults (0.8, 0.9, 2, false; `viewer/types.ts:762-774` at `eb5ce2afe`). Pages tune them: `dollySpeed` 0.6 (app-04c2774145, app-4762caacdc) or 1.2 (app-37356bf09f, app-f9a32142f1), `dollyToCursor` true (the template, app-b362a81c85), `rotateSpeed` 1 (chair, app-378d190f1b) (`page-document.json` at `c4c6ffd2`). app-04c2774145 and app-4762caacdc slow the two-finger pan to 1.6 on a coarse pointer (`components/viewer/page-device.ts:14-25`, `viewer-types.ts:30-36`); the port sets that with `camera.limits({ panSpeed: 1.6 })` (G62)

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `viewer.controls.rotateSpeed` | replaced | `camera.limits.rotateSpeed` | — |
| `viewer.controls.dollySpeed` | replaced | `camera.limits.dollySpeed` | — |
| `viewer.controls.truckSpeed` | replaced | `camera.limits.panSpeed` | — |
| `viewer.controls.dollyToCursor` | replaced | `camera.limits.dollyToCursor` | — |

Used by: app-04c2774145, app-37356bf09f, app-378d190f1b, app-4762caacdc, app-59c5b5751e, app-f9a32142f1, configurator-template.

```ts
/**
 * `viewer.controls.rotateSpeed`, `.dollySpeed`, `.truckSpeed`, `.dollyToCursor` → `camera.limits`
 * one to one (`truckSpeed` → `panSpeed`), with 1.x's defaults 0.8, 0.9, 2 and false.
 */
export function controlSpeeds(controls: {
  rotateSpeed?: number;
  dollySpeed?: number;
  truckSpeed?: number;
  dollyToCursor?: boolean;
}): ViewerDocumentJson {
  return {
    schema: 'holo.viewer-document',
    version: 2,
    camera: {
      limits: {
        rotateSpeed: controls.rotateSpeed ?? 0.8,
        dollySpeed: controls.dollySpeed ?? 0.9,
        panSpeed: controls.truckSpeed ?? 2,
        dollyToCursor: controls.dollyToCursor ?? false,
      },
    },
  };
}
```

<a id="c.7_viewer.controls"></a>
### C.7/viewer.controls: `viewer.controls` feel: `smoothTime`, `draggingSmoothTime`, `infinityDolly`, `boundaryFriction`, `restThreshold`, `dollyDragInverted`

**runtime-owned** · written for 1.x 1.5

runtime-owned: `restThreshold` (the movement below which 1.x's controls fired `'rest'`, `viewer/document/viewer-schema.ts:252` at `eb5ce2afe`) is when the runtime fires `'camera:rest'`, and the drag dolly keeps 1.x's default direction (`dollyDragInverted: false`, `:313`). No page document sets those two at `be828d5d`

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `viewer.controls.smoothTime` | runtime-owned | — | — |
| `viewer.controls.draggingSmoothTime` | runtime-owned | — | — |
| `viewer.controls.infinityDolly` | runtime-owned | — | — |
| `viewer.controls.boundaryFriction` | runtime-owned | — | — |
| `viewer.controls.restThreshold` | runtime-owned | — | — |
| `viewer.controls.dollyDragInverted` | runtime-owned | — | — |

Used by: app-378d190f1b, app-4fd23bc869, app-59c5b5751e, app-822bb9d6aa, app-8675b3225c, app-e9a33606d2, app-fd09090420.

<a id="c.7_viewer.controls.panboundary"></a>
### C.7/viewer.controls.panBoundary: `viewer.controls.panBoundary` (`'none'` / `'model'`)

**runtime-owned** · written for 1.x 1.5

runtime-owned in published layers: `'model'` keeps the orbit target inside the role `'product'` bounds and refreshes the box when they change, as 1.4 does (`viewer/controllers/camera.controller.ts:244-262` at `26d38f8b6`). In a page, `camera.limits({ target })` with the box the app wants. No app sets it at `342b26ce`

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `viewer.controls.panBoundary` | runtime-owned | — | — |

Used by: no app in the cross-check.

<a id="c.7_viewer.loader.cache"></a>
### C.7/viewer.loader.cache: `viewer.loader.cache`, `.modelQuality`, `.emitRemovedOnReplace`, `.nativeBvh`, `.skinnedPickAcceleration`, `.dracoWorkerLimit`, `.retainGltfParser`

**runtime-owned** · written for 1.x 1.5

runtime-owned; a replaced model shows in the commit feed (5.14). `cacheEncrypted: true` (app-fc306ec3dc) is the only behaviour: the byte cache keeps `.holo` ciphertext, never decrypted bytes (6.5). How a model decodes (the Draco workers), builds its pick and collision BVHs and keeps its parser's buffers (`viewer/document/viewer-schema.ts:394-410` at `eb5ce2afe`) is the runtime's too: no host code reaches the parser (C.5's `scene` row). No page document sets those four at `be828d5d`

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `viewer.loader.cache` | runtime-owned | — | — |
| `viewer.loader.modelQuality` | runtime-owned | — | — |
| `viewer.loader.emitRemovedOnReplace` | runtime-owned | — | — |
| `viewer.loader.nativeBvh` | runtime-owned | — | — |
| `viewer.loader.skinnedPickAcceleration` | runtime-owned | — | — |
| `viewer.loader.dracoWorkerLimit` | runtime-owned | — | — |
| `viewer.loader.retainGltfParser` | runtime-owned | — | — |

Used by: app-04c2774145, app-37356bf09f, app-4762caacdc, app-4fd23bc869, app-822bb9d6aa, app-8675b3225c, app-bcb7ef21da, app-c1384707fc, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f3598fad77, app-fb16dd1985, app-fc306ec3dc, configurator-template.

<a id="c.7_viewer.renderer.tonemapping"></a>
### C.7/viewer.renderer.toneMapping: `viewer.renderer.toneMapping`

**replaced** · written for 1.x 1.5

`stage.toneMapping`: today's ACES → `'aces'`; `'neutral'`, 1.x's default (`viewer/document/viewer-schema.ts:79-80` at `eb5ce2afe`), → `'product'`; `'agx'` → `'mood'`; `'none'` → `'none'`. `'reinhard'` is removed: no page document or preset sets it at `be828d5d`, so it records `document-error` with `didYouMean` `'product'` and the layer below decides

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `viewer.renderer.toneMapping` | replaced | `stage.toneMapping` | — |

Used by: app-04c2774145, app-0c773a4ee3, app-37356bf09f, app-378d190f1b, app-4762caacdc, app-4f16d2f6da, app-4fd23bc869, app-510d750508, app-59c5b5751e, app-822bb9d6aa, app-8675b3225c, app-bcb7ef21da, app-c1384707fc, app-ceb4eccc72, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f9a32142f1, app-fb16dd1985, app-fc07850558, app-fc306ec3dc, app-fd09090420, configurator-template.

```ts
/**
 * `viewer.renderer.toneMapping` → `stage.toneMapping`: ACES → 'aces', 'neutral' → 'product',
 * 'agx' → 'mood', 'none' → 'none'; 'reinhard' is removed (the layer below decides).
 */
export function toneMapping(
  curve: 'aces' | 'neutral' | 'agx' | 'none' | 'reinhard',
): ViewerDocumentJson {
  const map = { aces: 'aces', neutral: 'product', agx: 'mood', none: 'none' } as const;
  return curve === 'reinhard'
    ? { schema: 'holo.viewer-document', version: 2 }
    : { schema: 'holo.viewer-document', version: 2, stage: { toneMapping: map[curve] } };
}
```

<a id="c.7_viewer.renderer.tonemappingexposure"></a>
### C.7/viewer.renderer.toneMappingExposure: `viewer.renderer.toneMappingExposure`

**replaced** · written for 1.x 1.5

`stage.exposureEv`, from the curve the version-1 layers merge to: with `'aces'`, `log2(value / 0.83)` (the preset already includes the fleet's 0.83, so 21 apps' 0.83 becomes 0); with `'agx'`, `log2(value) + 0.6`, since `'mood'` already includes −0.6 EV (1.x's mood preset is AgX at 0.65, `viewer/document/tone-mapping-presets.ts:41-45`, so it becomes −0.02); with another curve, `log2(value)`

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `viewer.renderer.toneMappingExposure` | replaced | `stage.exposureEv` | — |

Used by: app-04c2774145, app-0c773a4ee3, app-37356bf09f, app-378d190f1b, app-4762caacdc, app-4f16d2f6da, app-4fd23bc869, app-510d750508, app-59c5b5751e, app-822bb9d6aa, app-8675b3225c, app-bcb7ef21da, app-c1384707fc, app-ceb4eccc72, app-e3b47169bc, app-e61a0b42ad, app-e9a33606d2, app-f07e9033d9, app-f9a32142f1, app-fb16dd1985, app-fc306ec3dc, app-fd09090420, configurator-template.

```ts
/**
 * `viewer.renderer.toneMappingExposure` → `stage.exposureEv`, from the curve the version-1 layers
 * merge to: ACES `log2(value / 0.83)`, AgX `log2(value) + 0.6`, another `log2(value)`.
 */
export function exposure(value: number, curve: string): ViewerDocumentJson {
  const exposureEv =
    curve === 'aces'
      ? Math.log2(value / 0.83)
      : curve === 'agx'
      ? Math.log2(value) + 0.6
      : Math.log2(value);
  return { schema: 'holo.viewer-document', version: 2, stage: { exposureEv } };
}
```

<a id="c.7_viewer.renderer.antialias"></a>
### C.7/viewer.renderer.antialias: `viewer.renderer.antialias`, `.beautyFrame`, `.beautyFrameMaxPixelRatio`, `.pixelRatioLimit`, `.adaptiveQuality`, `.qualityTier`, `.temporalAccumulation`, `.beautyFrameMultiplier`, `.beautyFrameDelayMs`, `.temporalAccumulationFrames`, `.fpsTarget`

**runtime-owned** · written for 1.x 1.5

runtime-owned: the quality tier; an app no longer pins a tier (app-b362a81c85 pins `'high'` today, so its fire and glass checks in 9.5 run on the adaptive tier; app-fc07850558 pins `'high'` with `adaptiveQuality: false` since configurators `d1debb8a9` because the ladder switched tier mid-orbit, `page-document.json:5` at `5cdd2780`, which B.4 X13 makes runtime-owned); forcing a tier for a measurement is a HoloDebug concern. The idle still's size, delay and frame count and the frame rate the ladder holds (`fpsTarget`, 55 in 1.x; B.4 X13) are the tier's too (`viewer/document/viewer-schema.ts:118, 148-165` at `eb5ce2afe`). No page document sets those four at `be828d5d`

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `viewer.renderer.antialias` | runtime-owned | — | — |
| `viewer.renderer.beautyFrame` | runtime-owned | — | — |
| `viewer.renderer.beautyFrameMaxPixelRatio` | runtime-owned | — | — |
| `viewer.renderer.pixelRatioLimit` | runtime-owned | — | — |
| `viewer.renderer.adaptiveQuality` | runtime-owned | — | — |
| `viewer.renderer.qualityTier` | runtime-owned | — | — |
| `viewer.renderer.temporalAccumulation` | runtime-owned | — | — |
| `viewer.renderer.beautyFrameMultiplier` | runtime-owned | — | — |
| `viewer.renderer.beautyFrameDelayMs` | runtime-owned | — | — |
| `viewer.renderer.temporalAccumulationFrames` | runtime-owned | — | — |
| `viewer.renderer.fpsTarget` | runtime-owned | — | — |

Used by: app-37356bf09f, app-378d190f1b, app-59c5b5751e, app-f9a32142f1, app-fc07850558, app-fc306ec3dc.

<a id="c.7_viewer.renderer.powerpreference"></a>
### C.7/viewer.renderer.powerPreference: `viewer.renderer.powerPreference`, `.precompile`, `.shaderWarmup` (`viewer/document/viewer-schema.ts:124-137` at `eb5ce2afe`)

**runtime-owned** · written for 1.x 1.5

runtime-owned: the GPU the context asks for and when shaders compile; a new look shows once its shaders are ready, so no frame waits for a shader link (10.3's frame-time gate). No page document sets them at `be828d5d`

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `viewer.renderer.powerPreference` | runtime-owned | — | — |
| `viewer.renderer.precompile` | runtime-owned | — | — |
| `viewer.renderer.shaderWarmup` | runtime-owned | — | — |

Used by: no app in the cross-check.

<a id="c.7_viewer.renderer.brightness"></a>
### C.7/viewer.renderer.brightness: `viewer.renderer.brightness`, `.contrast` (display-space, after the tone curve and the output encoding, `viewer/document/viewer-schema.ts:91-104` at `eb5ce2afe`)

**removed** · written for 1.x 1.5

removed: `stage.grade` takes only `saturation`, and `exposureEv` acts before the tone curve, so neither matches; a port that needs one asks for a `grade` field under 3.1's generic-API test. No page document sets them at `be828d5d`

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `viewer.renderer.brightness` | removed | — | — |
| `viewer.renderer.contrast` | removed | — | — |

Used by: no app in the cross-check.

<a id="c.7_viewer.perf"></a>
### C.7/viewer.perf: `viewer.perf` (`monitor`, `sampleIntervalMs`, `dropFpsThreshold`, `dropSustainMs`, `spikeMs`, `slowSpanMs`: the viewer's own `perf:*` telemetry, `viewer/document/viewer-schema.ts:452-480` at `eb5ce2afe`)

**runtime-owned** · written for 1.x 1.5

runtime-owned: performance telemetry is the runtime's beacons and a HoloDebug concern, as forcing a tier is; the v1 tenant declaration carries no `perf:*` event, so no app listens to one. No page document sets them at `be828d5d`

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `viewer.perf.monitor` | runtime-owned | — | — |
| `viewer.perf.sampleIntervalMs` | runtime-owned | — | — |
| `viewer.perf.dropFpsThreshold` | runtime-owned | — | — |
| `viewer.perf.dropSustainMs` | runtime-owned | — | — |
| `viewer.perf.spikeMs` | runtime-owned | — | — |
| `viewer.perf.slowSpanMs` | runtime-owned | — | — |

Used by: no app in the cross-check.

<a id="c.7_viewer.renderer.preservedrawingbuffer"></a>
### C.7/viewer.renderer.preserveDrawingBuffer: `viewer.renderer.preserveDrawingBuffer` (app-fc306ec3dc, pergola, app-4f16d2f6da)

**removed** · written for 1.x 1.5

removed: `capture()` reads the frame (`source: 'displayed'` for the displayed one, G19; K38); app-fc306ec3dc's thumbnails (`capture-thumbnail.ts`) port to `capture({ size })`, which keeps the lens's 60° horizontal field of view where 1.x's `captureBlob` kept the live vertical one, so they frame as 1.x's only at a 4:3 stage (9.8)

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `viewer.renderer.preserveDrawingBuffer` | removed | — | capture() |

Used by: app-4f16d2f6da, app-ceb4eccc72, app-fc306ec3dc.

<a id="c.7_viewer.shadows.enabled_-false"></a>
### C.7/viewer.shadows.enabled:-false: `viewer.shadows.enabled: false`

**replaced** · written for 1.x 1.5

`stage.ground: 'none'` and lights without `shadow` (no app sets it at `c4c6ffd2`)

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `viewer.shadows.enabled` | replaced | `stage.ground` | — |

Used by: app-59c5b5751e.

```ts
/** `viewer.shadows.enabled: false` → `stage.ground: 'none'`, and lights without `shadow`. */
export const shadowsOff: ViewerDocumentJson = {
  schema: 'holo.viewer-document',
  version: 2,
  stage: { ground: 'none' },
};
```

<a id="c.7_viewer.shadows.intensity"></a>
### C.7/viewer.shadows.intensity: `viewer.shadows.intensity`

**replaced** · written for 1.x 1.5

`stage.ground: { shadow: { opacity } }`. chair sets 0 and removes the runtime's ground itself, with shadows on because its key spot shadows the chair (`apps/app-59c5b5751e/src/runtime/page-document.json:12`, `page-document.ts:13-15`, `src/scene/chairPlugin.ts:94-101` at `c4c6ffd2`): `ground: 'none'`, and its key `spot` keeps `shadow: { strength: 0.3 }`, 1.x's shadow intensity, with `softness` tuned for its radius 3 against the 9.6 chair golden (`src/scene/settings.ts:340-369`)

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `viewer.shadows.intensity` | replaced | `stage.ground.shadow.opacity` | — |

Used by: app-59c5b5751e.

```ts
/** `viewer.shadows.intensity` → `stage.ground: { shadow: { opacity } }`. */
export function shadowOpacity(intensity: number): ViewerDocumentJson {
  return {
    schema: 'holo.viewer-document',
    version: 2,
    stage: { ground: { shadow: { opacity: intensity } } },
  };
}
```

<a id="c.7_the-shadow-grounds-height-and-size"></a>
### C.7/the-shadow-grounds-height-and-size: the shadow ground's height and size (no version-1 key: 1.x draws a 50 m ground at opacity 0.2, 1 cm under the content's lowest point, `viewer/controllers/scene.controller.ts:122-127, 310`, `viewer/types.ts:724-731` at `eb5ce2afe`)

**kept** · written for 1.x 1.5

nothing to write: `stage.ground.shadow` `y`, `sizeM` and `opacity` default to the same (`StageSettings.ground`)

Used by: no app in the cross-check.

<a id="c.7_viewer.shadows.radius"></a>
### C.7/viewer.shadows.radius: `viewer.shadows.radius`, `.mapSize`, `.soft`, `.softness`

**runtime-owned** · written for 1.x 1.5

runtime-owned: the shadow filter per tier and per receiver (`ShadowSpec`); an app tunes it with `ShadowSpec.softness`, carrying a 1.x radius r over as 0.3 × r / base (base 3 while a product part receives, else 14; C.6's key-light row). The map size follows the tier as well (1.x's tier preset replaced `mapSize` before the first frame, `viewer/document/viewer-schema.ts:36-40` at `eb5ce2afe`), and the idle soft ground shadow (`soft`, `softness`) is the runtime's idle refinement. No page document sets those three at `be828d5d`

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `viewer.shadows.radius` | runtime-owned | — | — |
| `viewer.shadows.mapSize` | runtime-owned | — | — |
| `viewer.shadows.soft` | runtime-owned | — | — |
| `viewer.shadows.softness` | runtime-owned | — | — |

Used by: no app in the cross-check.

<a id="c.7_viewer.transition.enabled"></a>
### C.7/viewer.transition.enabled: `viewer.transition.enabled` (only app-fc07850558, app-fc306ec3dc and app-f3598fad77 set it, to `false`: `apps/app-fc07850558/src/components/viewer/page-document.json:6`, `apps/app-fc306ec3dc/src/viewer/page-document.json:12`, `apps/app-f3598fad77/src/viewer/page-document.json:7` at `c4c6ffd2`)

**replaced** · written for 1.x 1.5

`false` → `materials.fadeMs: 0` in the version-2 document; `true` → the default selection crossfade, 420 ms. The key never touched the camera: 1.x crossfades every appearance-only material change, a `select` included (`viewer/types.ts:394-420, 830-833`, `viewer/plugins/material-plugin.ts:1415-1441` at `eb5ce2afe`)

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `viewer.transition.enabled` | replaced | `materials.fadeMs` | — |

Used by: app-f3598fad77, app-fc07850558, app-fc306ec3dc.

```ts
/** `viewer.transition.enabled: false` → `materials.fadeMs: 0`; `true` keeps the 420 ms fade. */
export function transitionEnabled(enabled: boolean): ViewerDocumentJson {
  return enabled
    ? { schema: 'holo.viewer-document', version: 2 }
    : { schema: 'holo.viewer-document', version: 2, materials: { fadeMs: 0 } };
}
```

<a id="c.7_viewer.transition.durationms"></a>
### C.7/viewer.transition.durationMs: `viewer.transition.durationMs`

**replaced** · written for 1.x 1.5

`materials.fadeMs` in the version-2 document

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `viewer.transition.durationMs` | replaced | `materials.fadeMs` | — |

Used by: no app in the cross-check.

```ts
/** `viewer.transition.durationMs` → `materials.fadeMs`. */
export function transitionDuration(durationMs: number): ViewerDocumentJson {
  return { schema: 'holo.viewer-document', version: 2, materials: { fadeMs: durationMs } };
}
```

<a id="c.7_viewer.ar.place"></a>
### C.7/viewer.ar.place: `viewer.ar.place.*` (`android`, `ios`, `autoPlace`, `androidShare`, `inpage`, `inpageSheet`, `shadow`, `occlusion`, `calibrateLength`, `ui.*`), `viewer.ar.placement`, `viewer.ar.scale` and `viewer.ar.native.*` (`banner`, `title`, `cta`, `cartUrlTemplate`) (1.6: the viewer schema's paths of the `ar.place.*` and `ar.native.*` row; `inpage`, `inpageSheet` and `shadow` are production's in-page AR keys, `viewer/document/viewer-schema.ts` since `feat/xr-inpage-ar` and, for `shadow`, `feat/xr-inpage-quicklook`; `occlusion`, `calibrateLength`, `ui.*`, `placement` and `scale` are AR v3's, !1099, contract 1.8)

**runtime-owned** · written for 1.x 1.6

runtime-owned, as that row: `activateAR`'s route reads them from the account's published layer (5.11), and the platform file's top-level `xr` key (`place`, `deny`) switches in-page AR off for v1 and v2 alike (5.1, D64); a page document that sets one records `document-error` (delivery-run 2026-10-20; DECIDED: XR alignment reply §3, Erik; the in-page keys as their siblings, delivery-run cycle 15)

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `viewer.ar.place.android` | runtime-owned | — | — |
| `viewer.ar.place.ios` | runtime-owned | — | — |
| `viewer.ar.place.autoPlace` | runtime-owned | — | — |
| `viewer.ar.place.androidShare` | runtime-owned | — | — |
| `viewer.ar.place.inpage` | runtime-owned | — | — |
| `viewer.ar.place.inpageSheet` | runtime-owned | — | — |
| `viewer.ar.place.shadow` | runtime-owned | — | — |
| `viewer.ar.place.occlusion` | runtime-owned | — | — |
| `viewer.ar.place.calibrateLength` | runtime-owned | — | — |
| `viewer.ar.place.ui` | runtime-owned | — | — |
| `viewer.ar.placement` | runtime-owned | — | — |
| `viewer.ar.scale` | runtime-owned | — | — |
| `viewer.ar.native.banner` | runtime-owned | — | — |
| `viewer.ar.native.title` | runtime-owned | — | — |
| `viewer.ar.native.cta` | runtime-owned | — | — |
| `viewer.ar.native.cartUrlTemplate` | runtime-owned | — | — |

Used by: app-fc07850558.

<a id="c.7_viewer.xr.tryon"></a>
### C.7/viewer.xr.tryOn: `viewer.xr.tryOn.*` (`enabled`, `consent`) and `viewer.xr.ui.*` (`theme`, `copy`, `controls`) (1.6: the viewer schema's paths of the `xr.tryOn.*` and `xr.ui.*` row)

**runtime-owned** · written for 1.x 1.6

runtime-owned, as that row: read by `@holo/xr`, `consent` from the account layer only (D64, XR §6.3), and the platform `xr` key's `tryOn` switches try-on off for v1 and v2 alike (5.1) (delivery-run 2026-10-20; DECIDED: XR alignment reply §3, Erik)

| Version-1 key | Disposition | 2.0 home | Via |
| --- | --- | --- | --- |
| `viewer.xr.tryOn.enabled` | runtime-owned | — | — |
| `viewer.xr.tryOn.consent` | runtime-owned | — | — |
| `viewer.xr.ui.theme` | runtime-owned | — | — |
| `viewer.xr.ui.copy` | runtime-owned | — | — |
| `viewer.xr.ui.controls` | runtime-owned | — | — |

Used by: no app in the cross-check.

<a id="c.8"></a>
## C.8 From a RegExp to globs (M16)

`Query.name` is a glob over the **whole** name (6.10, R6). The regular expressions apps use today become globs like this:

<a id="c.8_hull-outside"></a>
### C.8/hull-outside: `/hull.*outside/` (unanchored)

**replaced** · 2.0: [`Query`](api/Query.md) · written for 1.x 1.5

`{ name: '*hull*outside*' }`

Note: an unanchored RegExp matches anywhere, so wrap it in `*`

Used by: no app in the cross-check.

```ts
/** `/hull.*outside/` matches anywhere, so the glob is wrapped in `*`. */
export const hullOutside: Query = { name: '*hull*outside*' };
```

<a id="c.8_bimini-mechanism"></a>
### C.8/bimini-mechanism: `/^bimini-mechanism$/` (anchored)

**replaced** · 2.0: [`Query`](api/Query.md) · written for 1.x 1.5

`{ name: 'bimini-mechanism' }`

Note: a glob is anchored already; it no longer matches `bimini-mechanism-inox`

Used by: no app in the cross-check.

```ts
/** `/^bimini-mechanism$/`: a glob is anchored already (no longer matches `bimini-mechanism-inox`). */
export const biminiMechanism: Query = { name: 'bimini-mechanism' };
```

<a id="c.8_mp"></a>
### C.8/mp: `/^mp[-_]/`

**replaced** · 2.0: [`Query`](api/Query.md) · written for 1.x 1.5

`[{ name: 'mp-*' }, { name: 'mp_*' }]`

Note: a character class becomes an array (any-of)

Used by: no app in the cross-check.

```ts
/** `/^mp[-_]/`: a character class becomes an array (any of). */
export const connectorPlanes: Query = [{ name: 'mp-*' }, { name: 'mp_*' }];
```

<a id="c.8_a-b"></a>
### C.8/a-b: `/a|b/`

**replaced** · 2.0: [`Query`](api/Query.md) · written for 1.x 1.5

`[{ name: '*a*' }, { name: '*b*' }]`

Note: an alternation becomes an array

Used by: no app in the cross-check.

```ts
/** `/a|b/`: an alternation becomes an array. */
export const eitherName: Query = [{ name: '*a*' }, { name: '*b*' }];
```

<a id="c.8_text-boat-side-i"></a>
### C.8/text-boat-side-i: `/text-boat-side/i` (case-insensitive)

**replaced** · 2.0: [`Query`](api/Query.md) · written for 1.x 1.5

the spellings the GLB has, as an array

Note: `Query.name` is case-sensitive; `didYouMean` on `target-unmatched` names the authored spelling

Used by: no app in the cross-check.

```ts
/** `/text-boat-side/i`: the spellings the GLB has, as an array (`Query.name` is case-sensitive). */
export const boatSideText = (spellings: readonly string[]): Query =>
  spellings.map((spelling) => ({ name: `*${spelling}*` }));
```

<a id="c.8_name-d"></a>
### C.8/Name-d: `/^Name_\d+$/` (loader-numbered primitives)

**replaced** · 2.0: [`Query`](api/Query.md) · written for 1.x 1.5

`{ name: 'Name' }` plus `slot`

Note: a multi-primitive mesh is one part per primitive; match its parts by `slot`

Used by: no app in the cross-check.

```ts
/** `/^Name_\d+$/` (loader-numbered primitives): the mesh's name, its primitives by `slot`. */
export const primitiveOf = (slot: string): Query => ({ name: 'Name', slot });
```

<a id="c.8_a-regexp-over-material-names"></a>
### C.8/a-RegExp-over-material-names: a RegExp over material names

**replaced** · 2.0: [`Query`](api/Query.md) · written for 1.x 1.5

`{ slot: '<glob>' }`, its spellings as an array

Note: `slot` is the same glob as `name`, over the authored material's slot (R6)

Used by: no app in the cross-check.

```ts
/** A RegExp over material names → `{ slot: <glob> }`, its spellings as an array. */
export const bySlots = (slots: readonly string[]): Query => slots.map((slot) => ({ slot }));
```

<a id="c.8_transparent"></a>
### C.8/transparent: a test of the loaded material's state (pergola's foliage fix: `transparent` with a `map`)

**replaced** · 2.0: [`Query`](api/Query.md) · written for 1.x 1.5

the slots that hold that state in the shipped GLB, listed by name after reading its materials

Note: no `Query` field reads a material's state, and a word glob reaches slots the test skips: in pergola's `garden.glb` only `grass1` and `grass2` are `BLEND` with a map, while `*tree*` also matches `Christmas_tree_Billboard_Mat`, a `MASK` at cutoff 0.218 (`apps/app-ceb4eccc72/src/lib/pergola-plugins/pergola-environment-plugin.ts:444-447`, `public/env/garden.glb` at `c4c6ffd2`)

Used by: no app in the cross-check.

```ts
/**
 * A test of the loaded material's state (pergola's foliage: `transparent` with a `map`) → the
 * slots that hold that state in the shipped GLB, by name: in `garden.glb`, `grass1` and `grass2`.
 */
export const blendedFoliage: Query = [{ slot: 'grass1' }, { slot: 'grass2' }];
```

<a id="c.8_in-a-sanitized-name"></a>
### C.8/in-a-sanitized-name: `/\./` in a sanitized name

**replaced** · 2.0: [`Query`](api/Query.md) · written for 1.x 1.5

`{ name: 'main.l' }` or `{ name: 'mainl' }`

Note: both the authored and the sanitized form match

Used by: no app in the cross-check.

```ts
/** `/\./` in a sanitized name: the authored ('main.l') and the sanitized ('mainl') form match. */
export const mainLeft: Query = { name: 'main.l' };
```

<a id="c.8_applypbr"></a>
### C.8/applyPBR: a RegExp 1.4 tested over meshes only (`applyPBR`, `getAllMeshes`)

**replaced** · 2.0: [`Query`](api/Query.md) · written for 1.x 1.5

add `kind: 'mesh'`: `{ name: 'Corpus*', kind: 'mesh' }`; a primitive the RegExp named by its mesh name becomes an `id`: app-fc306ec3dc's finish is `[{ name: 'Corpus*', kind: 'mesh' }, { id: '400x400x450-2F-DO033' }, { id: '600x400x450-2F_-DO001' }]`

Note: a glob also matches a group named like its meshes, and a rule on the group cascades to every mesh below (R8); app-fc306ec3dc's `^Corpus` would reach the `Corpus012` group of its two 2F bodies and paint their `matte black` accent (`apps/app-fc306ec3dc/src/components/planner/finish-materials.ts:100-150` at `c4c6ffd2`). Those bodies are primitive parts named after the glTF mesh (`Query`'s `slot`), so `Corpus*` alone misses them, as 1.4's `^Corpus` did; app-fc306ec3dc's RegExp lists them `$`-anchored, and their `matte black` siblings are `…_1`

Used by: app-e61a0b42ad, app-f07e9033d9, app-fc306ec3dc.

```ts
/**
 * A RegExp 1.4 tested over meshes only (`applyPBR`, `getAllMeshes`): add `kind: 'mesh'`, and a
 * primitive the RegExp named by its mesh name becomes an `id` (app-fc306ec3dc's finish).
 */
export const app_fc306ec3dcCorpusFinish: Query = [
  { name: 'Corpus*', kind: 'mesh' },
  { id: '400x400x450-2F-DO033' },
  { id: '600x400x450-2F_-DO001' },
];
```

<a id="b-4"></a>
## B.4 Runtime-owned guarantees that let ports delete code

The runtime does these; a port deletes the code that did them.

| Id | Need | Today's route | 2.0 | Apps |
| --- | --- | --- | --- | --- |
| B.4/X1 | Serialised scene sync, newest wins | `scene-sync-scheduler.ts` (15 copies: the template's, 12 identical to it, and app-b362a81c85's and app-fc306ec3dc's own, at `c4c6ffd2`) | the write queue (O2): call order, adjacent scene writes coalesce, a `select` between them is kept; `supersede: true` only for a sync that re-derives everything | 14 apps plus the template |
| B.4/X2 | Echo suppression | refs in `use-viewer-sync.ts`; `skipNextSyncRef`, 16 ms debounce | `origin`, `cause.origin` for runtime → store; the equal-write rule (O15) for store → runtime | the template's copies; app-f9a32142f1 |
| B.4/X3 | Node id repair and duplication | `model-node-id-normalizer.ts` (app-37356bf09f at projection time: `apps/app-37356bf09f/src/lib/app-37356bf09f/project.ts:226-243`, `src/lib/configurator/model-node-id-normalizer.ts:158-182` at `be828d5d`) | `normalizeNodeIds`, `duplicateNodes`; app-37356bf09f's projection-time use stays app code: it re-ids each placed module's cloned template children on every store change with deterministic ids (the base id plus an occurrence suffix), so an equal re-projection commits nothing (O15), where `duplicateNodes` mints new ids per call | app-04c2774145, app-4762caacdc, app-f9a32142f1, app-37356bf09f |
| B.4/X4 | Freezing the frame during a sync | `scene-freeze.ts` (8 s cap) | `transaction` with the capped hold | app-04c2774145, app-4762caacdc |
| B.4/X5 | Re-snapping on load and undo | `sofa-assembly.ts` | stored connections | app-04c2774145, app-4762caacdc, app-f9a32142f1; app-37356bf09f stores no connection (K63) |
| B.4/X6 | Page state paired with undo | the 1.4 page-state call | `setSceneModel(model, { checkpoint })` or any recorded write's `checkpoint`; `transaction({ checkpoint })`; `state().checkpoint` | 14 apps (grep) |
| B.4/X7 | Undo grouping over a gesture | the 1.4 history grouping member | `history.group` | app-4762caacdc, app-37356bf09f, app-04c2774145, app-f3598fad77, app-bcb7ef21da (grep) |
| B.4/X8 | Stored ModelNodes without a transform load | `withIdentityTransforms` before every `setSceneModel` (`scene-model.ts:48-98` at `342b26ce`) | runtime-owned: missing or partial transforms read as identity and outputs carry them, as 1.x since holostep `160b346ba` (`viewer/utils/model-utils.ts:150-178`) | app-f9a32142f1 (Elisa, Henri Fotel and Henri Kanapé were seeded without one) |
| B.4/X9 | Anti-aliasing on every quality tier, done once | `renderer.antialias: false` with the composer's 4× MSAA and `lowTierBypass: false` (app-f9a32142f1 `page-document.json:9, 39` at `342b26ce`) | runtime-owned: the medium and high tiers anti-alias with the chain's MSAA, and the canvas is never multisampled under a multisampled composer. The low tier draws the composer's scene buffer with one sample, as production's viewer does since `090cd0ee3` (0.93 ms a frame against 1.03 ms with 4× MSAA at the low tier on an iPhone 13 Pro), so a chain with `antialias: 'none'`, 2.0's own and every composing production app's, has no anti-aliasing there, and `'auto'` keeps FXAA; the switch changes only the targets' sample count and links no program (DL49) | app-f9a32142f1, app-b362a81c85 |
| B.4/X10 | A texture budget that holds on desktop and on phones | `material.maxVramBytes: 67108864` on every tier (`page-document.json:42`; 381 → 224 MB steady-state WebGL texture memory on RIO, `page-document.ts:26-32`); app-04c2774145 and app-4762caacdc raise it to 128 MB on narrow screens and 256 MB elsewhere, because one Wind fabric's resident set is about 82 MB and the 64 MB mobile default evicted on every texture load (`apps/app-04c2774145/src/viewer/page-document.ts:55-57`, `apps/app-4762caacdc/src/viewer/page-document.ts:55-57` at `5cdd2780`) | runtime-owned: the tier's texture budget; the port measures against the 64 MiB pin; on a phone, the pilot's parity run swaps fabrics with the GOLF Wind set resident and sees no re-upload of a texture the current commit draws; eviction takes only textures it doesn't draw, and on the 'high' and 'med' tiers keeps the look the last commit replaced until the next pick that loads (Erik's decision 1); the 'low' tier keeps none, and a tier change down to 'low' or a lost context drops a kept look (the council's `33725fae8`) | app-f9a32142f1; app-04c2774145 and app-4762caacdc |
| B.4/X11 | Shadows follow what moves, and an orbit costs no shadow pass | `renderer.shadowMap.needsUpdate` plus each casting light's `shadow.needsUpdate` through `session.viewer.renderer` when a casting signature changes (app-fc07850558 `runtime-backend.ts:126-156`, `shadow-rig.ts:207-217`; app-fc306ec3dc `shadow-refresh.ts:10-21`, at `5cdd2780`); 1.x renders shadows on demand (`viewer/controllers/renderer.controller.ts:674-677` at `eb5ce2afe`) | runtime-owned: the shadow map re-renders at most once a frame when what the shadow pass draws changes: a casting part's drawn pose, visibility, geometry, cast flag (`shadows`) or cut-out (`alphaMode`, `alphaCutoff`, an alpha map; through a rule or `Frame.material`), or a casting light's position, direction, target, on/off or `ShadowSpec`, or the shadow camera's fit (`covers`); by a commit, a drag move, `Frame.pose`, `Frame.visible`, a clip step or the release of a frame write; never for a camera move alone. pergola's sun slider (`pergola-lighting-plugin.ts:520-531`), app-fc07850558's rig refit and cast signature (`runtime-backend.ts:131-158`) and 1.x's refresh on `'material:changed'` (`viewer/configurator-viewer.ts:693-709` at `eb5ce2afe`) are covered | app-fc07850558, app-fc306ec3dc, app-04c2774145, pergola |
| B.4/X12 | Depth precision where parts meet (no joint flicker) | near 0.1 m and far 100 m written to the engine camera in every `render:before` (app-fc07850558 `runtime-backend.ts:108-114`, `room-camera.ts:13-21` at `5cdd2780`) against the 1.x default 0.01–1000 m (`viewer/types.ts:755-756`, re-applied by `viewer/controllers/camera.controller.ts:529-533` at `eb5ce2afe`); app-4f16d2f6da `pergola-camera-guard-plugin.ts:210-228` | runtime-owned: near and far are fitted to `camera.limits` and the bounds of every drawn part, whatever its role (helper included), seen from the drawn camera, a `Frame.camera` view included (app-ddfb017681's helper colliders span about 6,000 units, and 1.4 sets 0.3–6000 by hand, `apps/app-ddfb017681/src/viewer-plugins/collider-overlay-plugin.ts:47-48, 588-596` at `c4c6ffd2`; estate the same, `apps/app-b4ae2fce25/src/viewer-plugins/collider-overlay-plugin.ts:47-48, 541-545` at `c4c6ffd2`), never the fixed 0.01–1000 m, and no resize or document layer resets them; two faces 2.5 mm apart (a door on its carcass) never swap at the orbit's farthest distance, nor two faces 1 mm apart (app-fc07850558's casing lip on its reveal, `room-fixtures.ts:220-227` at `be828d5d`) within the room's `limits.maxDistance` | app-fc07850558, app-4f16d2f6da (environment lab) |
| B.4/X13 | One look during a gesture: no tier switch mid-orbit, mid-transition or mid-drag | `adaptiveQuality: false`, `qualityTier: 'high'` in the page document (app-fc07850558 `page-document.json:5`, reason in `page-document.test.ts:11-14` at `5cdd2780`; the 1.x ladder: 55 fps target and 1 s step-down, `viewer/core/quality-manager.ts:47-48, 163` at `eb5ce2afe`) | runtime-owned: the ladder keeps measuring during a camera gesture, a view move or a drag, and applies a tier change measured during one when it ends ('camera:control-end', the move landing, 'drag:end'), whatever else then moves the camera (`autoRotate`, an `onFrame` hook's `Frame.camera`); a change measured outside them applies at once, as 1.x's ladder samples and steps on every rendered frame (`viewer/controllers/renderer.controller.ts:1027-1034` at `eb5ce2afe`); no app tier key (C.7) | app-fc07850558, app-b362a81c85 |
| B.4/X14 | AO that holds still on thin, deep strips while the camera moves (sills, radiator fins, skirting) | denoise 16 samples / 12 px / 4 iterations in the page document (`page-document.json:12-29`; measured in `page-document.test.ts:1-17` at `5cdd2780`) | runtime-owned: the AO denoise per tier. The default is the fleet's: 8 samples / 12 px / 2 iterations, transparency not detected per frame, as 21 of the 22 production apps that compose ship it (the ladder caps the samples at 2 below the high tier; DL43, from the delivery run's composed-parity measurement: 16 / 12 px / 4 as a default would move those apps' frames by p99 12 code values on high). app-fc07850558 keeps its measured 16 / 12 px / 4 through its own stored version-1 layer, which the runtime reads (C.7), so its sills keep their steadiness and the contact shade at the foot of a unit | app-fc07850558 |
| B.4/X15 | Undo depth parity: 50 steps, phones included | `plugins.scene-history.maxHistory` (e.g. `apps/configurator-template/src/viewer/page-document.json:32` at `c4c6ffd2`) | runtime-owned: 50 undo steps on every tier (section 12, Q19) | the 15 apps whose page documents set `maxHistory: 50` (app-f3598fad77's undo store mirrors it); app-37356bf09f, which sets 20 (`apps/app-37356bf09f/src/runtime/page-document.json:35` at `c4c6ffd2`); app-04c2774145, app-4762caacdc and app-f9a32142f1, which set 20, or 10 on phones |
| B.4/X16 | A sharp view after a `devicePixelRatio` change: a window moved to another screen, a browser zoom | none: its e2e check is a `test.fixme`, since 1.x recomputes the interactive ratio only on a resize or a tier change, so a view dragged from a 1x to a 2x screen orbits at 1x (`apps/app-378d190f1b/e2e/render-sharpness.spec.ts:168-181` at `be828d5d`), and the canvas behind `el` is runtime-owned in 2.0 | runtime-owned: within one frame of a `devicePixelRatio` change, at rest and during a gesture, the drawing buffer is the element's CSS size × the live view's pixel ratio (the device's, capped by the quality tier, C.7) | app-378d190f1b |
| B.4/X17 | Glass costs no full-resolution transmission pass | `renderer.transmissionResolutionScale = 0.5` through the hatch, each asserted by its unit test (pergola `src/lib/pergola-plugins/pergola-lighting-plugin.ts:93-98`, app-4f16d2f6da `:104-109` at `be828d5d`): three otherwise redraws the opaque scene into a full-resolution multisampled target with mipmaps every frame a transmissive part shows (pergola's sliding glass closure; app-4f16d2f6da's glass and all five roof coverings, `src/lib/pergola-engine/materials/roof.ts:22-57`). 1.x never sets it (`viewer/core/transmission-targets.ts` only tracks the targets) | runtime-owned: what a transmissive part shows through it is drawn at half the drawing buffer's resolution or less on every tier, the engine's choice and no public setting. Every tenant whose GLBs carry `KHR_materials_transmission` gets it too, so its G5 diff lists it as a D44 item. app-4f16d2f6da's clear roofs keep `shadows: { cast: false }` (`roof.ts:71-75`), since a transmissive part casts a solid shadow | pergola, app-4f16d2f6da |
| B.4/X18 | Detail levels change only the live view | none: 1.x's `runtime-lod` swaps `mesh.geometry` in place and invalidates the node's bounds (`viewer/plugins/runtime-lod-plugin.ts:723, 731-734` at `eb5ce2afe`), and its in-page AR export serialises the live scene (`viewer/controllers/ar.controller.ts:338-350, 360-370`), so with it on, bounds, picks and the AR copy see the simplified level | runtime-owned: a level of detail changes only what the live view draws. Reads and outputs use each part's full geometry, the asset the runtime selected (its optimized twin included, X10) with its committed `labs.geometry.write`, `labs.skin` and `labs.mirror` writes, never an original fetched instead: `worldPose`, `bounds()`, `connectors()`, dimension rulers, `pick` and `raycast` `Hit`s, camera collision, `export`, `activateAR`'s own GLB and USDZ copy and a `source: 'render'` capture. A `'displayed'` capture copies what is shown | every app on a tier that draws a simplified level; none today, since no page document or app enables `runtime-lod` at `be828d5d` |
| B.4/X19 | Repeated assets load once (council I7) | none: each 2.0 model node and collection `model` item fetched and parsed its own copy (the council's app-fc07850558-like scene downloaded 180.5 MB; a app-fc306ec3dc-like row of 12 cabinets made 12 requests, `be45f3218`) | runtime-owned: Repeated assets: within a session, every model node and collection model item whose resolved URL, keyRef and loader options match shares one fetch and one parse. Each item draws its own clone, with its own nodes, skeleton, materials and clips, so a look, pose, transform or clip on one item never reaches another. The parse is freed with the last item that shows it. A KHR_materials_variants model is parsed per item. (Measured at `be45f3218`: 180.5 → 99.7 MB downloaded, first presented frame 3,927 → 2,922 ms, GPU memory 1,744 → 1,148 MB on the app-fc07850558-like scene; 12 → 1 request on the app-fc306ec3dc-like row; 0 eager viewer bytes) | app-fc07850558 (41 items over 31 URLs), app-fc306ec3dc (a row of identical cabinets); any page that shows one GLB more than once |

<a id="fleet-cross-check"></a>
## The fleet cross-check

Each app's 1.x patterns, read from its paper port's notes and its 1.x source at configurators `be828d5d` (`conformance/port-usage.mjs`), matched to the rows above. A pattern is a 1.x name the 1.x viewer, the v1 surface or Appendix C.5 declares, a plugin id, an event or a version-1 document key; a row covers it when its 1.x cell, members, keys or recipe name it. Engine classes are listed apart: a port replaces them by need, through Appendix B.

| App | Rows used | Patterns with no row | Engine classes it imports |
| --- | --- | --- | --- |
| app-04c2774145 | 94 | none | 7 |
| app-0c773a4ee3 | 41 | none | 16 |
| app-37356bf09f | 79 | none | 0 |
| app-378d190f1b | 37 | none | 20 |
| app-4762caacdc | 94 | none | 7 |
| app-4f16d2f6da | 51 | none | 95 |
| app-4fd23bc869 | 60 | none | 0 |
| app-510d750508 | 46 | none | 35 |
| app-59c5b5751e | 49 | none | 27 |
| app-822bb9d6aa | 62 | none | 0 |
| app-8675b3225c | 60 | none | 0 |
| app-b362a81c85 | 69 | none | 28 |
| app-b4ae2fce25 | 27 | none | 25 |
| app-bcb7ef21da | 79 | none | 29 |
| app-c1384707fc | 69 | none | 8 |
| app-ceb4eccc72 | 57 | none | 46 |
| app-ddfb017681 | 27 | none | 25 |
| app-e3b47169bc | 67 | none | 19 |
| app-e61a0b42ad | 81 | none | 1 |
| app-e9a33606d2 | 60 | none | 0 |
| app-f07e9033d9 | 70 | none | 1 |
| app-f3598fad77 | 65 | none | 3 |
| app-f9a32142f1 | 85 | none | 20 |
| app-fb16dd1985 | 72 | none | 1 |
| app-fc07850558 | 55 | none | 33 |
| app-fc306ec3dc | 88 | none | 43 |
| app-fd09090420 | 78 | none | 6 |
| configurator-template | 65 | none | 0 |

### Patterns a port uses that no row covers

Each needs a row of its own (spec 9.1 item 4: every internal pattern the apps use). The 2.0 home each note names is a reading of the declaration, to be confirmed in that row.

None.

### Named by no row, inside a row's scope

- `CameraControls.getPosition` (camera controls; app-ceb4eccc72): [C.6/PluginContext.getCameraControls](#c.6_plugincontext.getcameracontrols). pergola reads and writes the orbit through the camera controls (`setLookAt`, `setTarget`, `getTarget`, `getPosition`); the row covers that route, its 2.0 home is `camera.set` and `camera.state`
- `CameraControls.getTarget` (camera controls; app-ceb4eccc72): [C.6/PluginContext.getCameraControls](#c.6_plugincontext.getcameracontrols). as `CameraControls.getPosition`
- `CameraControls.setTarget` (camera controls; app-ceb4eccc72): [C.6/PluginContext.getCameraControls](#c.6_plugincontext.getcameracontrols). as `CameraControls.getPosition`
- `controls:changed` (event; app-b362a81c85): [C.6/register](#c.6_register). app-b362a81c85's camera-stage host plugin re-applies its limits on each controls change; the plugin is C.6/register's, its clamp is `camera.limits` (`target`)
- `dimension.markDirty` (plugin; app-ceb4eccc72): [C.6/dimension.formatValue](#c.6_dimension.formatvalue). the dimension plugin's members (`markDirty`, `setOverrides`, `showFor`, `getUnit`, `setUnit`), which the row covers as the plugin's options; 2.0's rulers update themselves and `format` takes the configured size
- `dimension.setOverrides` (plugin; app-ceb4eccc72): [C.6/dimension.formatValue](#c.6_dimension.formatvalue). as `dimension.markDirty`
- `findSnapCandidateNear` (plugin; app-f9a32142f1): [C.6/snapTo](#c.6_snapto). app-f9a32142f1's snap-connector lookup of the pair nearest a position (runtime-escape-hatches.ts:231-242); the snap rows cover the plugin, and 2.0's `interaction.snapTo(target, { near })` picks that pair
- `getUnit` (plugin; app-fb16dd1985): [C.6/dimension.formatValue](#c.6_dimension.formatvalue). app-fb16dd1985's dimension plugin read (its hatch); as `dimension.markDirty`
- `markDirty` (plugin; app-fb16dd1985, app-ceb4eccc72, app-4f16d2f6da, app-f9a32142f1): [C.6/dimension.formatValue](#c.6_dimension.formatvalue). app-fb16dd1985's and pergola's dimension plugin call (app-4f16d2f6da's own occluder object has a method of that name too); as `dimension.markDirty`
- `moveTo` (plugin; app-510d750508, app-b362a81c85, app-fc07850558, app-ceb4eccc72, app-4f16d2f6da): [C.6/register](#c.6_register). app-b362a81c85's camera-stage host plugin moves the orbit target (`CameraControls.moveTo`); in app-510d750508, pergola and app-4f16d2f6da it is a shape path's `moveTo` (engine geometry, ported to shapes) and app-fc07850558's own
- `onModelLoaded` (plugin; app-0c773a4ee3, app-b362a81c85, app-f9a32142f1): [C.6/register](#c.6_register). model-load hooks inside host plugins (app-0c773a4ee3 and app-b362a81c85 anisotropy; app-f9a32142f1's mattress-texture, agyracs-mesh-suffix, mattress-lid-follower and pocket-bone-follower); the row's app list does not name app-f9a32142f1, estate, app-ddfb017681, app-fc07850558, app-4f16d2f6da or app-fc306ec3dc, which register host plugins too
- `onModelRemoved` (plugin; app-f9a32142f1): [C.6/register](#c.6_register). as `onModelLoaded` (app-f9a32142f1's followers)
- `setEnabled` (plugin; app-fc306ec3dc): [C.6/CameraControls.enabled-false](#c.6_cameracontrols.enabled-false). app-fc306ec3dc's camera-lock host plugin turns the controls off; the row covers that pattern in app-ddfb017681
- `setOverrides` (plugin; app-ceb4eccc72): [C.6/dimension.formatValue](#c.6_dimension.formatvalue). app-ceb4eccc72's `callPluginMethod(session, 'dimension', 'setOverrides', …)`; as `dimension.setOverrides`
- `setUnit` (plugin; app-fc306ec3dc): [C.6/dimension.formatValue](#c.6_dimension.formatvalue). app-fc306ec3dc's dimension plugin option; as `dimension.markDirty`
- `PluginContext.canvas` (plugin context; app-b4ae2fce25, app-ddfb017681): [C.6/register](#c.6_register). host plugins draw DOM over the canvas (estate's and app-ddfb017681's collider overlay, app-b362a81c85's dimension drawing, app-fc306ec3dc); 2.0: anchors and overlays
- `PluginContext.getRenderer` (plugin context; app-510d750508, app-59c5b5751e, app-0c773a4ee3, app-b4ae2fce25, app-b362a81c85, app-ceb4eccc72, app-ddfb017681): [C.6/shadowMap.needsUpdate](#c.6_shadowmap.needsupdate). the renderer reached through the plugin context; each use is a renderer row (shadow map refresh, transmission scale, clipping)
- `canvas` (plugin context; app-510d750508, app-f07e9033d9, app-b4ae2fce25, app-b362a81c85, app-fc07850558, app-ceb4eccc72, app-4f16d2f6da, app-ddfb017681, app-fc306ec3dc, app-e3b47169bc, app-bcb7ef21da): [C.6/register](#c.6_register). as `PluginContext.canvas`
- `getRenderer` (plugin context; app-510d750508, app-59c5b5751e, app-0c773a4ee3, app-378d190f1b, app-b4ae2fce25, app-b362a81c85, app-ceb4eccc72, app-4f16d2f6da, app-ddfb017681): [C.6/shadowMap.needsUpdate](#c.6_shadowmap.needsupdate). as `PluginContext.getRenderer`
- `renderer.Exposure` (renderer; app-378d190f1b): [C.7/viewer.renderer.toneMappingExposure](#c.7_viewer.renderer.tonemappingexposure). app-378d190f1b's tone mapping and exposure (`renderer.toneMapping/Exposure` in its notes): C.7's `viewer.renderer.toneMapping` and `viewer.renderer.toneMappingExposure` rows
- `PluginContext.events.emit('model:loaded')` (event; app-ceb4eccc72, app-4f16d2f6da): [C.6/PluginContext.events.emit](#c.6_plugincontext.events.emit). pergola's and app-4f16d2f6da's plugins emit 'model:loaded' into the viewer's bus so its post-process AO and adaptive zoom re-measure the procedural model (`emitModelLoaded`, `runtime-escape-hatches.ts:303-313`); C.6's `PluginContext.events.emit` row: runtime-owned, a collection write is measured like a model

17 more candidates were ruled out on review (the app's own names; `reviewed` in `conformance/port-usage.json` says why). Rows no app in the cross-check uses: 74 of 258.
