# `Writer.setSceneModel`

> Contract 2.0.0 · package sha256 `a857eca95b35921109faf997094bb4192f12527d9d0fcd4be7e97f06425bad6e` · generated from `configurator-runtime.d.ts` by `services/onsite-scripter/scripts/build-contract-docs.mjs`; edit the source, not this file.

Method of [`Writer`](Writer.md) · inherited by [`Tx`](Tx.md), [`Session`](Session.md), [`testing.FakeSession`](testing.FakeSession.md) · stable · since 2.0.0 · called as `session.setSceneModel`

```ts
setSceneModel(model: DeepReadonly<SceneModel>, options?: RecordedOptions<C> & SceneModelOptions): Promise<Report>;
```

Replaces the scene model (recorded; replacing; in the write queue). Keeps every `nodeId`; a new
`glbUrl` under the same `nodeId` replaces the content and keeps the node. Loads run hidden; the
swap is one frame. Two pending calls coalesce only when adjacent in the queue: the older
resolves 'superseded' and only the newer one's options apply. An older call that carries
`undo: 'rebase'`, a `checkpoint` or actor 'shopper' is superseded only by one that carries the
same, and a call is superseded only by one made under the same history group, or under none
when it was (O14); otherwise both commit (spec section 6.1, O2c), so a load keeps its floor
and a gesture keeps its last write.
An equal model commits nothing (`changed: false`). It is compared as `assetBaseUrl` resolves
it, since the state keeps the resolved URLs ([`getSceneModel()`](Writer.getSceneModel.md) reads them): the same model
under a base that resolves every URL the same ('…/164' and '…/164/') is equal, and under a
base that resolves a URL elsewhere it is a change, so that node loads from there (spec section
6.1, O15). Equal or not, [`problems`](ReportFields.problems.md) lists one
`asset-failed`, with [`node`](Writer.node.md), for each node the model places (spec section 6.9, ID10) that this
write leaves without its asset: a fresh failure, a node an earlier write failed whose refetch
after the failure window fails again, or one still inside it (no request; spec sections 6.1,
O15, and 6.4). To make only what a write adds all or nothing, act on the problems whose [`node`](Writer.node.md)
the model before it ([`getSceneModel()`](Writer.getSceneModel.md)) didn't hold.
Connections (spec section 6.12, A8): `model.connections` given are restored without a re-snap
('connection-stale' drops one whose planes no longer meet; a [`connectionsBasis`](connectionsBasis.md) mismatch,
'connections-outdated', restores none); omitted keeps the current ones; `[]` clears them. What
to pass: [`SceneModel.connections`](SceneModel.connections.md) and [`connectionsBasis`](connectionsBasis.md). A model you got from
[`getSceneModel()`](Writer.getSceneModel.md) still decrypts: the runtime keeps key material in a private store filled
from your inputs.
It never moves the camera, the first call included: `model.defaultView` is stored data (degrees,
in other names): convert it with [`cameraViewFromStored`](cameraViewFromStored.md), then [`camera.set`](Camera.set.md), or call [`camera.fit`](Camera.fit.md).
Outcome: 'committed' at its commit point, after its loads; through a `tx`, 'staged' once
loaded; 'rolled-back' with reason 'internal-error' when applying it failed inside the runtime
(through a `tx`, it then rejects AbortError and the transaction resolves 'rolled-back'; spec
section 6.4).
Errors: rejects HoloInputError 'missing-node-id' or 'duplicate-node-id'. Asset failures:
[`problems`](ReportFields.problems.md).

## Options

Through [`Session`](Session.md):

| Option | Type | Default | What it does |
| --- | --- | --- | --- |
| `actor` | `Actor` | the `expectGroup`'s actor when one is given, else 'app' | Who made the write. |
| `assetBaseUrl` | `string` | the runtime's asset root | Absolute HTTP(S) directory for the model's GLB, try-on rig (`tryOnUrl`, `tryOnCandidateUrl`), texture and HDR URLs; any other value rejects… |
| `checkpoint` | `JsonValue` | unchanged | Page state stored atomically with this step; returned in [`state().checkpoint`](SessionState.checkpoint.md) and the feed. |
| `dryRun` | `boolean` | false | Runs and reports, then rolls back by design ('dry-run'). |
| `expectGroup` | `HistoryGroup` | undefined | A guard, checked at the call: this write belongs to the undo group you are running. |
| `expectRevision` | `number` | undefined (no check) | If-match on [`session.revision`](Session.revision.md). |
| `origin` | `string` | null | Your tag, echoed unchanged in [`Report.origin`](ReportFields.origin.md) and `cause.origin`. |
| `signal` | `AbortSignal` | undefined | Cancels this call; the member's JSDoc says what an abort does. |
| `supersede` | `boolean` | false (the write is queued behind the transaction and applies after it closes) | While a transaction is open: roll it back (it resolves 'superseded'); the writes queued behind it apply in call order, then this one, which supersedes an… |
| `undo` | `'rebase' \| (string & {})` | a recorded-state change gets one step labelled 'Change'; a look-only write gets none; the session's first model (cause 'load') gets none, whatever the label: it is the floor of history | An undo label makes this write one undo step. |

Through [`Tx`](Tx.md):

| Option | Type | Default | What it does |
| --- | --- | --- | --- |
| `assetBaseUrl` | `string` | the runtime's asset root | Absolute HTTP(S) directory for the model's GLB, try-on rig (`tryOnUrl`, `tryOnCandidateUrl`), texture and HDR URLs; any other value rejects… |
| `signal` | `AbortSignal` | undefined | Cancels this call; the member's JSDoc says what an abort does. |

Refused here (typed `never`, so passing one fails to compile): `actor`, `checkpoint`, `dryRun`, `expectGroup`, `expectRevision`, `origin`, `supersede`, `undo`.

## Abort (`signal`)

This call is a write (`SignalOption.signal`, [the kinds of call](../read-this-first.md#conventions)).

> A write: before the commit point it rolls back and rejects AbortError; after it, no effect, except that a running camera move stops where the last frame drew it (`CameraMoveOptions`).
>
> Through a `tx` the commit point is the transaction's: aborting a write still loading rejects AbortError and stages nothing (catch it: unobserved, it rolls the transaction back, spec section 6.1, O6); aborting one already staged unstages it, its paths keeping the latest other write to them as after a failed savepoint (O7), and its Promise stays resolved 'staged'.

## Examples

Imports: values (`has`, `validate`, the `Holo*Error` classes, the pure helpers) from `@holostep-configurators/runtime-client`, types from `@holostep-configurators/runtime-client/contract`, `/math` and `/testing` names from those entries; `boot` from the runtime module the root's `loadRuntimeAtFloor` resolves to. A type your page defines is your app's: declare it there, never import it from the package.

Compiled under the contract's strict settings (`examples/declaration/05-Writer.setSceneModel-1.ts`):

```ts
import type { IConfigurator, JsonPatchOp, JsonValue, Session } from '@holostep-configurators/runtime-client/contract';
declare const session: Session;
declare const signal: AbortSignal;
// A store sync, both ways. The store's document stays the source of truth.
declare const store: { replace(doc: IConfigurator): void };
declare function rootFor(doc: IConfigurator): string;
// applies a commit's scene-model ops to `doc` by nodeId: each node's position, rotation,
// scale and enabled and each group's defaultNodeId (a field op, or a node an undo adds back)
declare function mergeChanges(doc: IConfigurator, patch: readonly JsonPatchOp[]): IConfigurator;
// what the store owns beyond placement (an instance list, selections); at most 256 KB
declare function structureOf(doc: IConfigurator): JsonValue;
declare function withStructure(doc: IConfigurator, structure: JsonValue): IConfigurator;
declare function reportSyncError(error: unknown): void;
const SYNC = 'store-sync';
let current: IConfigurator | null = null;        // the store's latest document
let echoed: IConfigurator | null = null;         // the document this code pushed into the store
let edits = 0;                                   // store edits so far, echoes excluded
let editsAtUndo = 0;                             // `edits` when the shopper last pressed undo

// Runtime → store. Skip our own sync by `origin`, and merge only what each commit changed
// (its `patch`), never its whole model: a commit's payload can predate the store's latest
// document (a write called before that sync can commit after the sync's call, `Cause.seq`),
// and merging a whole snapshot would put that document's newer placement back (spec section
// 6.11, rule 6). The delta keeps both: a shopper's pick made while a sync was queued returns
// to the store, and the store's own newer edits stay (the runtime's model has no key
// material: never save it). An undo or redo can add or remove nodes, which placement can't
// carry: rebuild the store's structure from the checkpoint it restored first. A store edit
// made after undo was pressed owns the future: its queued sync commits after the undo and
// re-projects the store, so that undo is not adopted.
session.on('committed', ({ cause, patch, checkpoint }) => {
  if (cause.origin === SYNC || !current) return;
  if (!patch.some((op) => op.path.startsWith('/model/'))) return;   // a look-only commit
  const undone = cause.kind === 'undo' || cause.kind === 'redo';
  if (undone && edits !== editsAtUndo) return;
  const base = undone && checkpoint !== null ? withStructure(current, checkpoint) : current;
  store.replace((echoed = mergeChanges(base, patch)));
}, { signal });

// The toolbar's undo (a redo button does the same).
export function onUndoClick(): void {
  editsAtUndo = edits;
  session.undo({ actor: 'shopper', origin: 'toolbar-undo' }).catch(reportSyncError);
}

// Store → runtime. Newest wins; a labelled undo step. Never `undo: 'rebase'` here: after a
// shopper edit it would erase the shopper's undo steps.
export function onStoreChange(doc: IConfigurator): void {
  current = doc;
  // The reverse echo (the store repeating our own merge) goes back without `supersede`: equal,
  // it commits nothing and rolls back no open transaction; if a sync queued before the shopper's
  // change replaced it meanwhile, it puts that change back. Never skip it.
  const echo = doc === echoed;
  if (!echo) edits += 1;
  const options = { assetBaseUrl: rootFor(doc), origin: SYNC, supersede: !echo, undo: 'Configure' };
  // Rejects only on invalid input or abort. The checkpoint lets an undo restore the structure.
  const sync = () => session.setSceneModel(doc, { ...options, checkpoint: structureOf(doc) });
  // An internal failure resolves 'rolled-back' with the scene restored (spec section 6.4):
  // report it, and send this document once more unless a newer one has come.
  sync().then(async (r) => {
    if (r.outcome !== 'rolled-back') return;
    reportSyncError(r);
    const again = doc === current ? await sync() : null;
    if (again?.outcome === 'rolled-back') reportSyncError(again);
  }).catch(reportSyncError);
}
```

Compiled under the contract's strict settings (`examples/declaration/05-Writer.setSceneModel-2.ts`):

```ts
import type { DeepReadonly, IModelNode, SceneModel, Session } from '@holostep-configurators/runtime-client/contract';
declare const session: Session;
declare const signal: AbortSignal;
// Read, modify, write: add a shelf and turn a node off, in one recorded step.
// getSceneModel() is DeepReadonly<SceneModel>, which setSceneModel takes as it is: build the
// next model with spreads into a DeepReadonly<SceneModel>. Never annotate it `SceneModel`
// (the read is not assignable to it, TS2322) and never mutate the read.
declare const shelf: IModelNode;                 // a new node with its own nodeId
declare const lampId: string;                    // a root node's nodeId
const model = session.getSceneModel();
if (model) {
  const next: DeepReadonly<SceneModel> = {
    ...model,
    root: [...model.root.map((n) => (n.nodeId === lampId ? { ...n, enabled: false } : n)), shelf],
  };
  await session.setSceneModel(next, { undo: 'Add shelf' });
}
```

Compiled under the contract's strict settings (`examples/declaration/05-Writer.setSceneModel-3.ts`):

```ts
import type { SceneModel, Session } from '@holostep-configurators/runtime-client/contract';
declare const session: Session;
declare const signal: AbortSignal;
// Show one GLB you have, with no saved design (a modeller's check, a quick demo): the smallest
// scene model is one ModelNode with its required fields. Without `enabled: true` it validates
// and is never drawn.
declare const glbUrl: string;                     // absolute, or relative to assetBaseUrl
const model: SceneModel = {
  root: [{
    type: 'ModelNode', nodeId: 'preview', glbUrl, enabled: true,
    position: { x: 0, y: 0, z: 0 }, rotation: { x: 0, y: 0, z: 0 }, scale: { x: 1, y: 1, z: 1 },
  }],
};
const r = await session.setSceneModel(model);
for (const p of r.problems) console.warn(p.code, p.url ?? '', p.message); // 'asset-failed'
await session.camera.fit();                       // frame it
console.info(session.describe('preview')?.manifest ?? 'no stamp: manifestFromGlb reads the file');
```

## Generic-API test (spec 3.1)

| Rule | Result |
| --- | --- |
| 1. Neutral name | pass: `setSceneModel` names no product |
| 2. A precedent | pass: [Unity Undo.RecordObject](https://docs.unity3d.com/ScriptReference/Undo.RecordObject.html) (row `X6`); a correctness rule (rows `G33`) |
| 3. Two apps, or labs | pass: 22 apps (ledger: app-37356bf09f, app-4f16d2f6da, app-4fd23bc869, app-822bb9d6aa, app-8675b3225c, app-bcb7ef21da, app-c1384707fc, app-ddfb017681 and 10 more; fleet cross-check: app-04c2774145, app-0c773a4ee3, app-37356bf09f, app-4762caacdc, app-4fd23bc869, app-822bb9d6aa, app-8675b3225c, app-b362a81c85 and 12 more) |
| 4. The cheaper fix first | decided per need in Appendix B: rows `G33`, `X6` at tier stable (a member only where no rule, data, asset or app code serves the need) |
