<!-- 04_VISUAL/xwui/xwui-lib/xwui-lib-super/src/layout/XWUISceneStage/XWUISceneStage.md -->
# XWUISceneStage

The substrate of every plnx 3D scene (plnx REF_06 C12, REF_11 WP3-A). A perspective world with a camera the reader moves by
pointer tilt, drag orbit, an idle sway or the keyboard; named depth layers; billboards that face the viewer so text stays upright;
pins that hold 2D overlay elements (name tags, HUD anchors) on world points; camera presets and easing; quality levels;
scale-to-fit; the dark `scene` surface in every theme; and a `3d | 2d` face whose 2D side is a flat twin the page fills.

**The world is drawn by xw3d.** The stage owns the camera, the policy, the toolbar, the twin and the overlay; the world is an
`XWUISceneCanvas` (basic tier) on one of two xw3d backends, chosen by `engine`. The stage writes no CSS 3D of its own: every
perspective, transform and layer depth is written by `@exonware/xw3d/css3d` on its own elements, or is the webgl engine's. The
maths (camera, projection, policy) is `@exonware/xw3d/css3d`, which XWUIPlaneStage (C13), XWUILayerStage (C14), XWUIOrbitStage
(C15) and XWUIFlowLanes (C18) import. It composes the basic tier's `_shared/playback` (page signals, sway clock), `hostSlots`
(page-filled parts) and the style tier's viz tokens (`.xwui-night`, `--xwui-scene-*`, `--xwui-glass-*`).

## Basic Usage

```example
file: examples/BasicUsage.ts
html: examples/BasicUsage.html
title: Basic Usage
description: A card that leans toward the pointer, with a 2D switch.
```

```example
file: examples/PlanSpace.ts
html: examples/PlanSpace.html
title: Plan Space
description: A drag-orbit floor of plan towers with pinned name tags, presets and a table twin.
```

## Data

| Field | Shape | Meaning |
| --- | --- | --- |
| `camera` | `{ yaw, pitch, zoom, roll?, focusX?, focusY? }` | Camera the host asks for (authored left to right); a change eases there. |
| `layers` | `{ id, z, billboard? }[]` | Depth layers, back to front (engine css3d). Each has a host element (`[data-layer-id]`) that xw3d places at depth `z`; `billboard` turns it, and every item set on the layer, to face the viewer. |
| `parts` | `XWUIScenePart[]` | Engine webgl only: the primitives of the world (box, sphere, cylinder, torus, plane, grid, line, instanced; colour by token). Diffed by id. |
| `pins` | `{ id, x, y, z?, label? }[]` | World points; each gets a 2D overlay element placed on its projected screen spot every frame. `label` is the default tag. |
| `view` | `'3d' \| '2d' \| null` | Face shown; unset lets the policy decide. |
| `summary` | string | One sentence that says what the scene shows: read with the 3D face, shown as the twin when no twin is filled. |
| `status`, `statusText` | `'ready' \| 'loading' \| 'empty' \| 'error'`, string | Content state; error offers Try again (`retry` event). |
| `worldSlot`, `layerSlot`, `overlaySlot`, `pinSlot`, `controlsSlot`, `twinSlot` | `(host, ctx) => cleanup?` | Page fills (runtime only). Each runs once per host element and survives re-renders; cleanup runs when the host leaves or on destroy. |

Composer components fill the same parts through the view context instead (`world`, `layers[id]`, `overlay`, `pins[id]`,
`controls`, `twin` children in `projectSceneStageView(snapshot, context)`), so their scene is one view tree with targeted updates.
That is composition mode: it draws no 3D (see "The recipe for a composer" below).

## Options

| Option | Default | What it does |
| --- | --- | --- |
| `engine` | `'auto'` | The xw3d backend that draws the world: `'css3d'` (live DOM in xw3d's CSS 3D stage; no GPU context), `'webgl'` (`data.parts` on a leased context, at most 2 live on a phone and 4 on desktop) or `'auto'` (css3d for a standalone stage: its content is cards and boards, few elements). |
| `mode` | `'pointer-tilt'` | `'pointer-tilt'`, `'drag-orbit'`, `'sway'` or `'fixed'` (no camera input). |
| `plane` | `'wall'` | `'wall'`: yaw turns a standing card. `'floor'`: yaw spins the ground. |
| `rest` | flat | Home pose; `reset`, `0` and `Home` go here. |
| `range` | yaw +/-6 and pitch +/-4 around `rest`, zoom 0.5 to 2 | Allowed span per axis; `yaw: null` is a free orbit. |
| `perspective` | `1500` | The eye sits this many px in front of the perspective origin (number or `'<n>px'`). |
| `origin`, `anchor` | `50, 50` | Perspective origin and world origin in percent of the stage, authored left to right. |
| `easing` | `0.12` | Share of the remaining distance per 60 fps frame; `0` snaps. Frame-rate independent. |
| `keyboardStep` | `12, 5, 0.15` | One key or toolbar step: yaw and pitch degrees, zoom fraction. |
| `presets` | none | Named poses for `flyTo(name)`. |
| `sway` | `6, 2, 9000` | Idle sway amplitude (degrees) and period (ms) in mode `sway`. |
| `surface` | `'scene'` | `'scene'`: the dark stage in every theme (root carries `.xwui-night`). `'inherit'`: transparent. |
| `quality` | `'full'` | `'low'` drops sway, blur and glows; `data-quality` on the root for composers. |
| `scaleToFit` | off | `{ width, height }` the world is authored at: the stage keeps that ratio and the world scales to it. |
| `fit` | `'design'` | How the stage takes its block size: `'design'` (the width over the `scaleToFit` ratio) or `'fill'` (the host's box). See "Sizing: design and fill". |
| `minBlockSize` | none | Floor of the block size in both sizings: a px number or a CSS length (`'18rem'`; px, rem, em, vh, svh, dvh). Anything else is ignored. |
| `touch` | `'pan-y'` | `'pan-y'`: a finger scrolls the page. `'orbit'`: a Rotate switch (coarse pointers, drag-orbit) lets a finger orbit. |
| `mirrorInRtl` | `'yaw'` | Mirror yaw, roll, origin and anchor under a right-to-left parent; `'none'` keeps the authored picture. |
| `twinDefault` | `'narrow'` | Twin is the default face under reduced motion (`'reduced-motion'`), also below `narrowBelow` (`'narrow'`), or `'never'`. |
| `narrowBelow` | `560` | Stage width in px that counts as narrow. |
| `toolbar` | `'auto'` | `'full'` (orbit, tilt, zoom, reset, 2D), `'compact'` (zoom, reset, 2D), `'view'` (2D only), `'none'`. Auto: full for drag-orbit, view otherwise. |
| `worldInteractive` | `false` | The world holds focusable content (C13 markers): a labelled group instead of `aria-hidden`. |
| `ariaLabel`, `hint`, `twinLabel` | locale pack | Accessible name, keyboard instructions, twin region name. |
| `locale`, `numberingSystem` | page language, `'latn'` | Zoom readout digits. |

## Events and methods

Events (`on<Event>(handler)` on the instance, or `logic.on(name)`): `cameraChange { camera, source }` (every drawn frame),
`cameraSettle { camera }`, `orbitStart`, `orbitEnd`, `viewChange { view, reason: 'user' | 'host' | 'auto' }`, `reset`, `retry`,
`unsupported { reason }` (the webgl surface cannot run: `'no-webgl2'`, `'low-power'`, `'cap'`, `'boot-failed'`, `'context-lost'`).

Methods: `setCamera(camera, { animate? })`, `getCamera()`, `flyTo(name)`, `command('orbit-left' | 'orbit-right' | 'tilt-up' |
'tilt-down' | 'zoom-in' | 'zoom-out' | 'reset')`, `reset()`, `setView(view)`, `toggleView()`, `getView()`, `project(point)`
(px from the viewport's visual top-left), `pinAt(clientX, clientY, radius?)`, `handleCameraKey(event)` (forward keys from a
widget inside the world), `getSlot(part, key?)`, `getViewport()`, `getElement()`, `updateData()`, `updateConfig()`, `destroy()`.
New with the xw3d seam: `getCanvas()` (the XWUISceneCanvas, for `pick`, `capture`, `getStage()`), `getLayerHost(id)` (the element a
page fills for a layer), `setLayerItems(layerId, items)` (live elements, each on its own world point: `{ id, element, point,
billboard?, align? }`; xw3d moves them with the camera and a billboard layer turns them to face the viewer), `onUnsupported(handler)`.

CSS hook: `--xwui-scene-stage-block-size` (3D face height without `scaleToFit`, default 24rem). The stage writes
`--xwui-scene-stage-aspect`, `--xwui-scene-stage-ratio` (`fill` only) and `--xwui-scene-stage-min-block-size` (when `minBlockSize` is set) on
its root; they are its own output, not page knobs, and a page rule on `.xwui-scene-stage-*` is refused by the override gate: use the options.

**Ended (REF_11 O-7): the per-frame custom-property channel.** The root no longer carries `--xwui-scene-stage-yaw`, `-pitch`,
`-roll`, `-zoom`, `-fit`, `-focus-x`, `-focus-y`, `-perspective`, `-origin-*`, `-anchor-*`, and a pin no longer carries `-pin-x`,
`-pin-y`, `-pin-layer`, `-pin-shown`; `.xwui-scene-stage-billboard` is gone too. xw3d writes plain properties (it measured 500 ms a
frame at 1,904 elements for the custom-property channel, 0.3 ms for a plain transform). Read the pose from `cameraChange`, or
the drawn spot of a world point from `project(point)`. The old rules are in `XWUISceneStage.css.set-aside-20261023`.

## Sizing: design and fill

The 3D face is as tall as the design box says by default: with `scaleToFit` the viewport is `aspect-ratio: width / height` of that box,
so a stage in a card taller than that is a strip with empty card below it. A page cannot change that with CSS (the override gate
refuses `.xwui-scene-stage-*` rules, correctly), so sizing is an option.

| `fit` | The viewport is | Use it |
| --- | --- | --- |
| `'design'` (default) | the stage width divided by the design ratio (`24rem` without a design box); nothing changes if you never set `fit` | a stage that stands alone and is as tall as its picture |
| `'fill'` | the host's whole box: inline AND block size | a stage in a card, a grid cell or a panel that is taller than the picture |

`fit: 'fill'` rules:

- **The host must give the stage a box**, by any one of: a block host with a height (fixed, or a grid or flex cell that is stretched);
  a grid or row-flex host (the stage stretches); a column-flex host (the stage grows). The stage's natural height stays the design
  ratio (or the `24rem` knob), so a host with no height never collapses it. A host shorter than that natural height clips the stage
  (the root is `overflow: hidden`): give the host room, or lower the design box.
- **The ratio is kept by the camera, never by stretching.** xw3d scales the world by `min(width / design.width, height / design.height)`
  (contain) and the world stays centred on the `anchor`; the spare box is empty stage surface (the dark `scene` surface in both themes).
  Pins, hit tests, the minimap and the toolbar read the same box.
- **Re-measured on resize.** The stage's `ResizeObserver` already watches the viewport: a host that grows, shrinks or narrows below
  `narrowBelow` moves the scale in the same frame, with no extra timer. Nothing runs while the scene is idle.
- **The flat twin sizes itself.** On the 2D face (reduced motion, narrow, the switch) the stage is content, so a list or table is
  never cut by the host's height.
- **`minBlockSize`** is a floor in both sizings (`'18rem'`, `320`). In `fill` it raises the natural height; in `design` it raises the
  ratio height (the world stays contain-fitted inside).
- RTL and LTR: logical properties only, the mirror of the camera is unchanged. Tokens only: no colour or size literal is added.

The composers take the same options through their `camera` option, so no composer gets a second knob:

```ts
new XWUIPlaneStage(host, data, { arrangement: 'site', camera: { fit: 'fill' } });
new XWUIOrbitStage(host, data, { shape: 'dial', camera: { fit: 'fill', minBlockSize: '20rem' } });
new XWUILayerStage(host, data, { camera: { fit: 'fill' } });
new XWUISceneStage(host, data, { scaleToFit: { width: 1100, height: 620 }, fit: 'fill' });
```

The maths is pure and tested without layout: `sceneStageSettings` (`sizing`, `minBlock`), `sceneStageMinBlock(value)`,
`sceneStageBlockSize(sizing, host, design, floorPx?, fallback?)` (the block size the stylesheet resolves to) and `sceneStageGeometry`
(the contain scale). The tester card "Fit in a tall card" shows design beside fill and a Resize card button.

## Engines

| Engine | World | Content | When |
| --- | --- | --- | --- |
| `css3d` (auto) | xw3d css3d stage | `layers` hosts (`layerSlot`, `worldSlot`), `setLayerItems`, pins | Live DOM: objective cards, boards, device previews; tens of elements |
| `webgl` | XWUISceneCanvas on a leased context | `data.parts`, pins | Many objects: towers, markers, roads. Layers have no webgl equivalent |

Every backend falls back to the flat twin when it cannot run: no WebGL2, the context cap is full, the engine fails to boot, a lost
context does not come back (all final: the 2D switch goes away and `unsupported` fires), or the device is low power (not final:
the twin comes first and a tap on the 3D switch asks for 3D). The twin is the page's `twinSlot` or the written summary: never a
hand-written CSS 3D pose and never pseudo-3D SVG. A css3d surface needs no GPU context and keeps running. Reduced motion changes
motion, never the backend: commands snap, nothing sways, the twin is the default face and the 3D face is a static pose.

## The recipe for a composer (C13, C14, C15, C18 and the pages)

1. **The view emits one host-owned element.** Project with `projectSceneStageView(snapshot, { ..., surfaceRef })`. That is surface
   mode: no `world`, `layers` or `pins` children, no 3D rule. (Without `surfaceRef` the view is composition mode: your children
   render as flat boxes, and your world is flat until you give it a surface.)
2. **The adapter builds the surface.** `new SceneStageSurface(hostEl, logic, hooks)` creates the XWUISceneCanvas, forwards every
   `cameraChange`, sends the stage config only when it changed and keeps the pins and layer hosts. `hooks` = `{ hasFill, fill }`
   (your `XWUIHostSlots`). Choose the backend with `config.engine`; webgl objects go in `data.parts`.
3. **Put your objects in the surface.** webgl: build parts from your data (`setParts` through `data.parts`). css3d: render your
   DOM into elements you own and give xw3d the points with `surface.setLayerItems(layerId, items)`; never write `transform`,
   `perspective` or `preserve-3d` yourself.
4. **Let the logic decide the face.** The surface reports `unsupported` to the logic (`setSurfaceReason`); the logic's `view()`
   turns it into the flat twin. Feed the page-wide narrow signal with `sceneEnvironment(root)` and `logic.setNarrow`.
5. **Destroy in order.** `surface.destroy()` first (it releases hosts and the canvas), then your slots, then the view mount.
6. **Tests.** Assert xw3d's writes through `inst.getCanvas().getStage()` (camera, `world.style.transform`); assert fallbacks with
   `setSceneCanvasHost({ webgl2, engine3d })`; keep engine parity by excluding what the surface builds from the "owned by the
   renderer" scan (`.xwui-scene-stage-surface *`). Re-capture `native-baseline.json` once the view is final.

## Accessibility and RTL

- The 3D face is a focusable `group` with `aria-roledescription` "3D scene", its name, and a described-by hint (keys by mode)
  plus the written summary. The world surface is `aria-hidden` (or a labelled group with `worldInteractive`). The flat twin is a
  labelled region and the accessible representation of the drawing; it is the default face under reduced motion and below
  `narrowBelow` (policy `twinDefault`). The visible 2D switch is a toggle button (`aria-pressed`) that keeps focus.
- Keys on the focused viewport: ArrowLeft and ArrowRight orbit, ArrowUp and ArrowDown tilt, `+`/`=` and `-`/`_` zoom, `0` and
  Home reset, Escape cancels a drag or leaves rotate mode. Keys from widgets inside the world are not taken (forward them with
  `handleCameraKey`). Every pointer-only action has a key and a toolbar button.
- Touch: hover tilt is off on coarse pointers; the viewport is `touch-action: pan-y` so the page scrolls; a finger orbits only in
  the explicit Rotate mode (`touch: 'orbit'`).
- Reduced motion, in JS and CSS: no tilt, drag orbit, sway or easing (commands snap), twin by default; the loading spinner stops.
- RTL: direction comes from the parent `dir`. With `mirrorInRtl: 'yaw'` the camera turns the other way (yaw and roll negated,
  origin and anchor mirrored) while the camera in data, events, presets and links stays the authored one; keys, drag and pointer
  follow what the reader sees. The world's geometry itself is not mirrored (a map or a floor plan must not flip): xw3d mirrors the camera, and the surface is
  `direction: ltr` (stage pixels are physical); text inside a layer host sets `dir="auto"`. Logical properties only; Western digits unless `numberingSystem` says otherwise.

## The camera maths (contract for C13, C14, C15, C18)

`import { ... } from '@exonware/xw3d/css3d'` (pure, no DOM; stable names: add, never change a meaning). The maths MOVED here from
`xwui-lib-super/src/_shared/scene`, which is set aside (`*.set-aside-20261023`, imported by nothing); the 21 substrate tests run in
xw3d. Source of truth: `04_VISUAL/xw3d/xw3d/docs/CSS3D.md`.

Coordinate model: world px, x right, y DOWN, z toward the viewer. World transform =
`scale(zoom * fit) rotateZ(roll) rotateX(pitch) Ryaw translate3d(-focusX, -focusY, 0)` with `Ryaw = rotateY` on a `'wall'` and
`rotateZ` on a `'floor'`. The world origin sits at the anchor; the eye sits `perspective` px in front of the perspective origin.

| Export | Signature | Use |
| --- | --- | --- |
| `SceneCamera`, `SceneCameraFull`, `SceneRange`, `ScenePlane`, `ScenePoint`, `SceneProjected`, `SceneMode`, `SceneTouch`, `SceneQuality`, `SceneTwinDefault`, `SceneView`, `SceneEnvironment`, `SceneMotionPolicy`, `SceneStep`, `SceneSway`, `SceneHitTarget`, `SceneCameraCommand` | types | The shared vocabulary; `XWUISceneCamera` is `SceneCamera`. |
| `fullCamera(camera, fallback?)` | camera -> full | Resolve optional and non-finite fields. |
| `clampCamera(camera, range)` | -> full | Keep in range; no yaw span = free orbit. |
| `mirrorCamera(camera, mirrored)` | -> full | The drawn pose on a right-to-left page (yaw, roll negated). |
| `easeFactor(easing, dtMs)`, `easeCamera(current, target, easing, dtMs?)` | -> `{ camera, settled }` | Frame-rate independent easing that lands exactly. |
| `interpolateCamera(a, b, t)`, `camerasEqual(a, b)` | | Tweens and the settle test. |
| `stepCamera(camera, command, step, rest, yawSign?)` | -> full | One key or toolbar command by visual direction. |
| `tiltFromPointer(nx, ny, range, yawSign?)` | -> `{ yaw?, pitch }` | Pointer at 0..1 sweeps the whole range. |
| `orbitFromDrag(start, dx, dy, yawSign?, sensitivity?)` | -> full | 0.35 and 0.25 degrees per px (prototype). |
| `swayPose(rest, sway, tMs)` | -> full | Idle sway, starts on rest. |
| `project(point, camera, { perspective, plane, fit?, offset? })` | -> `{ x, y, depth, scale, visible }` | Screen px relative to the perspective origin; pinned to the prototype's own projection by test. |
| `unprojectToGround(screen, camera, options)` | -> point or null | Screen to the z = 0 plane (floor picks, minimap, drop targets). |
| `worldTransform(camera, plane, fit?)`, `billboardTransform(camera, plane)` | -> CSS string | xw3d writes these; a page never does. |
| `sceneCameraVars(camera, prefix?)` | -> custom properties | The old per-frame channel under any prefix (ended for C12; composers still read it until they move to a surface). |
| `engineCamera(camera, options, viewport)` | -> `{ eye, target, up, fovDeg, worldChain, canvas, ... }` | The webgl bridge: every point lands within 0.5 px of `project()`. |
| `sceneEnvironment(el, { narrowBelow })` | -> live `{ reducedMotion, coarsePointer, narrow, onChange }` | Page signals that keep reading while the flat face hides the viewport. |
| `hitTest(targets, x, y)`, `depthOrder(items)` | | Front-most hit; back-to-front paint order. |
| `fitScale(container, design)`, `perspectivePx(value, fallback?)` | | Scale to fit; a CSS perspective as px. |
| `motionPolicy({ mode, env, quality?, touch?, rotateMode? })` | -> policy | What may move: tilt, drag, touchDrag, sway, ease, commands, touchAction. |
| `resolveView({ requested, twinDefault, env })` | -> `'3d' \| '2d'` | The face rule. |

How a Wave 2 stage composes C12: see "The recipe for a composer". Set `worldInteractive: true` when objects are focusable, and
forward their arrow keys to `handleCameraKey` when they do not use them. Composition mode (`world`, `layers[id]`, `pins[id]`
children) still exists for the composers not yet moved; it renders flat.

## Provenance and limits

New component (rung 6, REF_06 C12; reuse ladder in `docs/changes/CHANGE_*_SCENE_STAGE.md`). Prototype sources: PX-Space
(camera, projection, keys, drag, presets), PX-Strategy, PX-Forms, Web-ForTeams, Web-Home (pointer tilt ranges), Web-Spatial
(quality, reduced-motion still, auto orbit), PX-Simulation and Web-ForOps (fixed floors). The world moved to xw3d in REF_11
WP3-A (`docs/changes/CHANGE_20261023_*_SCENE_STAGE_XW3D.md`): css3d and webgl backends through XWUISceneCanvas; the data contract
here does not depend on CSS.

```api
```
