lecodes-cli 0.6.4 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +1 -0
  2. package/dist/index.js +920 -636
  3. package/package.json +9 -4
  4. package/runtime/scene-harness.json +1 -0
  5. package/runtime/sdk/compile/assetMacro.ts +4 -3
  6. package/runtime/sdk/compile/bundler.ts +35 -3
  7. package/runtime/sdk/compile/compileProject.ts +14 -2
  8. package/runtime/sdk/compile/libraryImports.ts +47 -0
  9. package/runtime/sdk/compile/sceneEditor.ts +16 -4
  10. package/runtime/sdk/core/Aspect.ts +37 -0
  11. package/runtime/sdk/core/InspectorUI.ts +212 -0
  12. package/runtime/sdk/core/fields.ts +15 -3
  13. package/runtime/sdk/g2/Scene2D.ts +51 -5
  14. package/runtime/sdk/gl/Model.ts +9 -0
  15. package/runtime/sdk/gl/Node.ts +4 -1
  16. package/runtime/sdk/gl/Scene.ts +76 -11
  17. package/runtime/sdk/gl/scenarios.ts +349 -0
  18. package/runtime/sdk/inject.ts +28 -8
  19. package/runtime/sdk/kit/UITabs.ts +105 -0
  20. package/runtime/sdk/plugins/camera.ts +81 -0
  21. package/runtime/sdk/plugins/geolocation.ts +123 -0
  22. package/runtime/sdk/plugins/permission.ts +7 -0
  23. package/runtime/sdk/plugins/qr.ts +61 -24
  24. package/runtime/sdk/runtime/app.ts +37 -0
  25. package/runtime/sdk/runtime/appEvents.ts +29 -0
  26. package/runtime/sdk/runtime/channel.ts +50 -0
  27. package/runtime/sdk/runtime/clipboard.ts +20 -0
  28. package/runtime/sdk/runtime/datetime.ts +2 -4
  29. package/runtime/sdk/runtime/device.ts +137 -4
  30. package/runtime/sdk/runtime/misc.ts +4 -0
  31. package/runtime/sdk/runtime/service.ts +82 -0
  32. package/runtime/sdk/runtime/touch.ts +26 -0
  33. package/runtime/sdk/scene/defineScene.ts +651 -17
  34. package/runtime/sdk/scene/editorPlugins.ts +86 -0
  35. package/runtime/sdk/ui/NativeView.ts +144 -0
  36. package/runtime/sdk/ui/UI.ts +6 -1
  37. package/runtime/sdk/ui/UIButton.ts +26 -3
  38. package/runtime/sdk/ui/UIInput.ts +2 -2
  39. package/runtime/sdk/ui/UINode.ts +24 -4
  40. package/runtime/sdk/ui/UIScreen.ts +86 -29
  41. package/runtime/sdk/ui/UIScreenHost.ts +213 -0
  42. package/runtime/sdk/ui/UIText.ts +1 -1
  43. package/runtime/sdk/ui/UIVideo.ts +48 -2
  44. package/runtime/sdk/ui/UIWidget.ts +34 -1
  45. package/runtime/sdk/ui/presentable.ts +116 -0
  46. package/runtime/sdk/ui/router.ts +73 -29
  47. package/runtime/sdk-types.json +1 -1
  48. package/runtime/sdk/runtime/camera.ts +0 -31
@@ -0,0 +1,212 @@
1
+ // The scene editor's immediate-mode inspector protocol (docs/scene-editor-plan.md, phase 9).
2
+ //
3
+ // Custom editor UI never renders into the editor panel directly — the live data (aspect instances,
4
+ // nodes, `model.anim.clips`, generated meshes) lives in the SCENE WORLD (the compiled bundle), on
5
+ // the other side of the controller. So a custom view is an imgui-style function running in the
6
+ // world: it re-runs on every change/event and emits a WIDGET LIST (plain data); the editor panel is
7
+ // a dumb renderer of that list; user events `{ id, value }` come back and trigger the next run —
8
+ // `ui.button()` returns `true` on the run that consumes the click.
9
+ //
10
+ // class Road extends Aspect<'road'> {
11
+ // from: Node | null = null
12
+ // to: Node | null = null
13
+ // width = 2
14
+ // static editor = { rebuild: true }
15
+ // static inspector(ui: InspectorUI, road: Road) {
16
+ // ui.auto() // the inferred fields, as usual
17
+ // if (road.from && road.to) ui.info(`${road.generated.children.length} pieces`)
18
+ // else ui.warn('Assign both endpoints')
19
+ // if (ui.button('Shuffle')) road.rebuild()
20
+ // }
21
+ // }
22
+ //
23
+ // BINDING RULE: a field whose key is a declared aspect field (a public class field) is DOC-BOUND —
24
+ // the editor persists edits to the scene file through its normal commit/undo path. Any other key is
25
+ // EDITOR STATE — transient, kept on this InspectorUI instance across runs, never written to a file
26
+ // (the animation card's clip choice, a tool's brush size). One vocabulary, two lifetimes.
27
+
28
+ import type { FieldDescriptor, FieldEditor } from "./fields"
29
+
30
+ /** One user interaction coming back from the editor panel: the widget's id (+ value for fields). */
31
+ export type InspectorEvent = { id: string, value?: unknown }
32
+
33
+ export type InspectorFieldWidget = {
34
+ kind: "field"
35
+ id: string
36
+ /** The bound key — an aspect field (doc-bound) or an editor-state key. */
37
+ key: string
38
+ label: string
39
+ editor: FieldEditor | undefined
40
+ value: unknown
41
+ /** True = persisted to the scene file by the editor; false = transient editor state. */
42
+ doc: boolean
43
+ /** True = emitted by `ui.auto()` — the editor may overlay its syntactic hints (options/node). */
44
+ auto?: boolean
45
+ min?: number
46
+ max?: number
47
+ step?: number
48
+ options?: readonly (string | number)[]
49
+ }
50
+
51
+ export type InspectorWidget =
52
+ | InspectorFieldWidget
53
+ | { kind: "button", id: string, label: string }
54
+ | { kind: "toolButton", id: string, label: string, tool: string }
55
+ | { kind: "header", id: string, label: string }
56
+ | { kind: "info" | "warn", id: string, text: string }
57
+
58
+ type FieldOpts = {
59
+ label?: string
60
+ min?: number
61
+ max?: number
62
+ step?: number
63
+ /** Default for EDITOR-STATE keys (doc keys default from the class field). */
64
+ value?: unknown
65
+ }
66
+
67
+ /**
68
+ * The widget builder handed to `static inspector(ui, aspect)` (and to editor windows/tools later).
69
+ * One instance lives per card and persists across runs — it carries the card's editor state.
70
+ */
71
+ export class InspectorUI {
72
+ /** @internal This run's emitted widgets. */
73
+ _widgets: InspectorWidget[] = []
74
+ /** @internal Transient editor state (non-doc field keys), kept across runs. */
75
+ _state: Record<string, unknown> = {}
76
+ /** @internal The aspect entry's doc props (live reference — the runner keeps it current). */
77
+ _props: Record<string, unknown> = {}
78
+ /** @internal Keys that are declared aspect fields → doc-bound. Empty for node/window cards. */
79
+ _docKeys = new Set<string>()
80
+ /** @internal Field descriptors for auto() + doc-key defaults (describeFields output). */
81
+ _fields: FieldDescriptor[] = []
82
+ /** @internal The unconsumed button event of the current run. */
83
+ _event: InspectorEvent | null = null
84
+
85
+ // ---- fields (each emits one widget AND returns the current value) ---------------------------
86
+
87
+ /** Numeric field (min/max/step render as a slider-style scrub range). */
88
+ number(key: string, opts?: FieldOpts): number {
89
+ const v = this._field("number", key, opts)
90
+ return typeof v === "number" ? v : 0
91
+ }
92
+ /** Alias of `number` — give it min/max/step and the editor renders a scrubable range. */
93
+ slider(key: string, opts?: FieldOpts): number { return this.number(key, opts) }
94
+
95
+ text(key: string, opts?: FieldOpts): string {
96
+ const v = this._field("text", key, opts)
97
+ return typeof v === "string" ? v : ""
98
+ }
99
+
100
+ color(key: string, opts?: FieldOpts): string {
101
+ const v = this._field("color", key, opts)
102
+ return typeof v === "string" ? v : "#ffffff"
103
+ }
104
+
105
+ switch(key: string, opts?: FieldOpts): boolean {
106
+ return this._field("switch", key, opts) === true
107
+ }
108
+
109
+ /** Dropdown — `options` may be computed fresh every run (dynamic lists come free). */
110
+ select(key: string, options: readonly (string | number)[], opts?: FieldOpts): string | number {
111
+ const v = this._field("select", key, { ...opts, value: opts?.value ?? options[0] }, options)
112
+ return (typeof v === "string" || typeof v === "number") && options.includes(v) ? v : options[0]
113
+ }
114
+
115
+ vec2(key: string, opts?: FieldOpts): [number, number] { return this._vec("vec2", key, 2, opts) as [number, number] }
116
+ vec3(key: string, opts?: FieldOpts): [number, number, number] { return this._vec("vec3", key, 3, opts) as [number, number, number] }
117
+ vec4(key: string, opts?: FieldOpts): [number, number, number, number] { return this._vec("vec4", key, 4, opts) as [number, number, number, number] }
118
+
119
+ /** Scene-node reference (`ref()` fields): name dropdown + pick-in-viewport. Returns the RAW doc
120
+ * value (`{ $ref }` marker or null) — read the resolved live node off the aspect instance. */
121
+ node(key: string, opts?: FieldOpts): unknown {
122
+ return this._field("node", key, opts)
123
+ }
124
+
125
+ /** Project-asset path (the editor renders a dropdown of the project's GLBs). "" = none picked. */
126
+ asset(key: string, opts?: FieldOpts): string {
127
+ const v = this._field("asset", key, { ...opts, value: opts?.value ?? "" })
128
+ return typeof v === "string" ? v : ""
129
+ }
130
+
131
+ // ---- everything else --------------------------------------------------------------------------
132
+
133
+ /** True on the run that consumes this button's click — do the action right there. */
134
+ button(label: string, opts?: { id?: string }): boolean {
135
+ const id = opts?.id ?? `b:${label}`
136
+ this._widgets.push({ kind: "button", id, label })
137
+ if (this._event?.id === id) {
138
+ this._event = null
139
+ return true
140
+ }
141
+ return false
142
+ }
143
+
144
+ /** A toggle that activates/deactivates the named viewport tool (`registerEditorTool`). The
145
+ * editor owns the active-tool state — the click never round-trips into the scene world. */
146
+ toolButton(label: string, tool: string): void {
147
+ this._widgets.push({ kind: "toolButton", id: `t:${tool}`, label, tool })
148
+ }
149
+
150
+ header(label: string): void { this._widgets.push({ kind: "header", id: `h:${label}`, label }) }
151
+ info(text: string): void { this._widgets.push({ kind: "info", id: `i:${this._widgets.length}`, text }) }
152
+ warn(text: string): void { this._widgets.push({ kind: "warn", id: `w:${this._widgets.length}`, text }) }
153
+
154
+ /** Emit the inferred field widgets (all declared aspect fields, or just the named ones) — the
155
+ * zero-ceremony baseline; append custom widgets around it. */
156
+ auto(...keys: string[]): void {
157
+ for (const f of this._fields) {
158
+ if (keys.length > 0 && !keys.includes(f.key)) continue
159
+ const value = this._props[f.key] !== undefined ? this._props[f.key] : f.value
160
+ this._widgets.push({
161
+ kind: "field", id: `f:${f.key}`, key: f.key, label: f.label, editor: f.editor,
162
+ value, doc: true, auto: true, min: f.min, max: f.max, step: f.step, options: f.options,
163
+ })
164
+ }
165
+ }
166
+
167
+ // ---- internals ----------------------------------------------------------------------------
168
+
169
+ private _vec(editor: FieldEditor, key: string, size: number, opts?: FieldOpts): number[] {
170
+ const v = this._field(editor, key, opts)
171
+ const arr = Array.isArray(v) ? v : []
172
+ return Array.from({ length: size }, (_, i) => (typeof arr[i] === "number" ? arr[i] as number : 0))
173
+ }
174
+
175
+ private _field(
176
+ editor: FieldEditor, key: string, opts?: FieldOpts, options?: readonly (string | number)[],
177
+ ): unknown {
178
+ const doc = this._docKeys.has(key)
179
+ const fallback = doc ? this._fields.find((f) => f.key === key)?.value : opts?.value
180
+ const stored = doc ? this._props[key] : this._state[key]
181
+ const value = stored !== undefined ? stored : fallback
182
+ this._widgets.push({
183
+ kind: "field", id: `f:${key}`, key, label: opts?.label ?? key, editor, value, doc,
184
+ min: opts?.min, max: opts?.max, step: opts?.step, options,
185
+ })
186
+ return value
187
+ }
188
+
189
+ /**
190
+ * @internal One immediate-mode pass: deliver `event`, run `fn`, return the widget list.
191
+ * Editor-state field events are applied here; DOC-key field events must be applied by the
192
+ * caller beforehand (they go through the scene loader's prop machinery — rebuilds, ref
193
+ * resolution); button events are consumed by the matching `button()` call during the run.
194
+ * A throwing inspector logs and shows the error as a warn line — it can't take the editor down.
195
+ */
196
+ _run(fn: (ui: this) => void, event?: InspectorEvent): InspectorWidget[] {
197
+ if (event && event.id.startsWith("f:") && !this._docKeys.has(event.id.slice(2))) {
198
+ this._state[event.id.slice(2)] = event.value
199
+ event = undefined
200
+ }
201
+ this._event = event ?? null
202
+ this._widgets = []
203
+ try {
204
+ fn(this)
205
+ } catch (e) {
206
+ console.error("[scene] inspector render failed:", e)
207
+ this.warn(String((e as Error)?.message ?? e))
208
+ }
209
+ this._event = null
210
+ return this._widgets
211
+ }
212
+ }
@@ -8,10 +8,14 @@
8
8
  // 2. optional `static fields: FieldMeta<T>` on the class for ranges/labels/options/overrides
9
9
  // (mirrors how `static aspect` declares the accessor name).
10
10
 
11
- /** Editor widget kinds the inspector knows how to render. */
11
+ /** Editor widget kinds the inspector knows how to render. `"node"` = a scene-node reference
12
+ * (`ref('name')` in the file, name dropdown + pick-in-viewport in the inspector) — declare it via
13
+ * `static fields` on fields typed `Node | null`; a `Node | null` type annotation is also picked
14
+ * up syntactically by the editor's source scan. `"asset"` = a project-asset path (the editor
15
+ * fills the dropdown with the project's GLBs — `ui.asset()` in windows/tools/cards). */
12
16
  export type FieldEditor =
13
17
  | "number" | "text" | "color" | "switch" | "select"
14
- | "vec2" | "vec3" | "vec4"
18
+ | "vec2" | "vec3" | "vec4" | "node" | "asset"
15
19
 
16
20
  export type FieldMetaEntry = {
17
21
  /** Inspector label (default: the field name). */
@@ -49,10 +53,16 @@ export type AspectClassInfo = {
49
53
  name: string
50
54
  className: string
51
55
  fields: FieldDescriptor[]
56
+ /** True for editor-run classes (`static editor` — generators). The editor routes their
57
+ * structural changes through a re-run instead of a live patch. */
58
+ editor?: boolean
59
+ /** True when the class declares a custom `static inspector(ui, aspect)` card. */
60
+ inspector?: boolean
52
61
  }
53
62
 
54
63
  // Instance fields every aspect inherits from the Aspect base — configuration plumbing, not content.
55
- const BASE_FIELDS = new Set([ "node", "updateBeforePhysics", "order", "updateWhenVisible" ])
64
+ // `generated` is declare-only in TS, but the bundler lowers it to a real (undefined) class field.
65
+ const BASE_FIELDS = new Set([ "node", "generated", "updateBeforePhysics", "order", "updateWhenVisible" ])
56
66
 
57
67
  /** Infer the editor widget from a default value's shape. */
58
68
  export const inferFieldEditor = (value: unknown): FieldEditor | undefined => {
@@ -105,4 +115,6 @@ export const describeAspect = (ctor: abstract new () => unknown): AspectClassInf
105
115
  name: (ctor as { aspect?: string }).aspect ?? "",
106
116
  className: (ctor as { name?: string }).name ?? "",
107
117
  fields: describeFields(ctor),
118
+ editor: (ctor as { editor?: unknown }).editor ? true : undefined,
119
+ inspector: typeof (ctor as { inspector?: unknown }).inspector === "function" ? true : undefined,
108
120
  })
@@ -3,6 +3,7 @@
3
3
 
4
4
  import { Color, type ColorInput } from "../core/color"
5
5
  import { _installAspectFrames } from "../core/Aspect"
6
+ import { _bumpNavEpoch, _navSupported, _setCurrent, Presentable, type PresentOptions } from "../ui/presentable"
6
7
  import { cx, cy, type Vec2Like } from "../math/vec"
7
8
  import type { ClickEvent, TouchStartEvent } from "../runtime/touch"
8
9
  import { Camera2D } from "./Camera2D"
@@ -48,7 +49,7 @@ class LayerHandle {
48
49
  }
49
50
  }
50
51
 
51
- export class Scene2D {
52
+ export class Scene2D implements Presentable {
52
53
  /** Native scene handle. */
53
54
  readonly id: number
54
55
  readonly camera: Camera2D
@@ -111,17 +112,62 @@ export class Scene2D {
111
112
  return this
112
113
  }
113
114
 
114
- /** Make this the active scene (brings up the 2D engine on the canvas; closes any other). */
115
- open(): this {
115
+ // ---- Presentable (docs/navigation-presentable-plan.md) ----
116
+
117
+ /** @internal presentation lifecycle — fired by the host on present/dismiss (new navigation),
118
+ * or synchronously by open()/close() on legacy hosts. */
119
+ readonly ol: (() => void)[] = []
120
+ readonly cl: (() => void)[] = []
121
+ _backButtonCallback?: () => void
122
+ private _vd?: object
123
+
124
+ /** Fires when the scene becomes the visible destination (incl. a pop revealing it). */
125
+ onOpen(callback: () => void): this {
126
+ this.ol.push(callback)
127
+ return this
128
+ }
129
+ /** Fires when the scene stops being visible (closed, replaced, or covered by a push). */
130
+ onClose(callback: () => void): this {
131
+ this.cl.push(callback)
132
+ return this
133
+ }
134
+ onBackPressed(callback: () => void): this {
135
+ this._backButtonCallback = callback
136
+ return this
137
+ }
138
+
139
+ /** @internal stable wire descriptor; hosts hand it back via router onChange (`_p` = this). */
140
+ _viewDesc(): object {
141
+ return this._vd ??= { type: "scene2d", sceneId: this.id, _p: this }
142
+ }
143
+
144
+ /** Make this the active scene — shows it as the current destination (replaces a screen /
145
+ * another scene; only the active scene renders). */
146
+ open(options?: PresentOptions): this {
147
+ _bumpNavEpoch()
116
148
  // Reassert this scene's texture filter (the sampler is global — see Scene2DOptions.filter).
117
149
  if (this._filter !== undefined) _creator2d.setDefaultFilter(this._filter === 'linear')
118
- _creator2d.openScene(this.id)
150
+ if (_navSupported()) {
151
+ // The host activates the engine (openScene) as part of presenting the destination.
152
+ _creatorUI.openView!(this._viewDesc(), options?.transition ?? "none")
153
+ } else {
154
+ _creator2d.openScene(this.id)
155
+ for (const cb of this.ol) cb()
156
+ }
119
157
  Scene2D._active = this
158
+ _setCurrent(this)
120
159
  return this
121
160
  }
122
161
  close(): this {
123
- _creator2d.closeScene()
162
+ _bumpNavEpoch()
163
+ if (_navSupported()) {
164
+ _creatorUI.closeView!()
165
+ } else {
166
+ _creator2d.closeScene()
167
+ for (const cb of this.cl) cb()
168
+ }
124
169
  if (Scene2D._active === this) Scene2D._active = null
170
+ if (Presentable.current === this) _setCurrent(null)
125
171
  return this
126
172
  }
127
173
  destroy(): void {
@@ -17,6 +17,15 @@ export class Model extends Node {
17
17
  this.aspect(ModelAnimation)
18
18
  }
19
19
 
20
+ /** Duplicate this model — a deep copy of the GLB (meshes, skeleton, animation clips), attached to
21
+ * the same parent and scene and sharing this model's current transform. The clone has its own
22
+ * independent animation state (reach it via clone.anim). Mirrors Model.load's culling default. */
23
+ clone(): Model {
24
+ const id = _creator.cloneEntity(this.id)
25
+ _creator.setGlbCulling(id, false)
26
+ return new Model(id)
27
+ }
28
+
20
29
  /** Load a GLB model. Returns its root as a Model; play its baked clips via model.anim. */
21
30
  static load(
22
31
  source: string | FetchResponse,
@@ -130,7 +130,10 @@ export class Node extends AspectHost<NodeEvents> {
130
130
  set quaternion(v: QuatLike) { this._lastSync = 0; _creator.setQuaternion(this.id, cx(v), cy(v), cz(v), cw(v)) }
131
131
 
132
132
  get eulerAngles(): Vec3 { return new Mat4(this._sync()).eulerAngles }
133
- set eulerAngles(v: Vec3Like) { this._lastSync = 0; _creator.setEulerAngles(this.id, cx(v), cy(v), cz(v), 0) }
133
+ // order 1 = YXZ — the SDK's euler convention (math/quat.ts); the getter also extracts YXZ, so
134
+ // the pair round-trips. (Historically this passed 0/XYZ AND the engine stored the euler matrix
135
+ // transposed — the setter applied the INVERSE rotation. Both fixed 2026-07-10.)
136
+ set eulerAngles(v: Vec3Like) { this._lastSync = 0; _creator.setEulerAngles(this.id, cx(v), cy(v), cz(v), 1) }
134
137
 
135
138
  // --- world-space reads ---
136
139
  get forward(): Vec3 {
@@ -7,8 +7,10 @@
7
7
 
8
8
  import { Color, type ColorInput } from "../core/color"
9
9
  import { _installAspectFrames } from "../core/Aspect"
10
+ import { _bumpNavEpoch, _navEpoch, _navSupported, _setCurrent, Presentable, type PresentOptions } from "../ui/presentable"
10
11
  import type { ClickEvent, TouchStartEvent } from "../runtime/touch"
11
12
  import type { FetchResponse } from "../runtime/fetch"
13
+ import { _requestCameraPermission } from "../plugins/permission"
12
14
  import { Camera } from "./Camera"
13
15
  import { attachControls, type ControlsHandle, type ControlsOptions } from "./controls"
14
16
  import { Material } from "./Material"
@@ -46,7 +48,7 @@ const installRenderSyncedFrames = (): void => {
46
48
  })
47
49
  }
48
50
 
49
- export class Scene {
51
+ export class Scene implements Presentable {
50
52
  /** @internal native scene handle. */
51
53
  readonly _id: number
52
54
  readonly camera: Camera
@@ -115,16 +117,64 @@ export class Scene {
115
117
  return new Promise<void>((res) => _creator.warmRender(this._id, res))
116
118
  }
117
119
 
118
- /** Make this the active scene (brings up the 3D engine; closes any other). */
119
- open(): void {
120
- _creator.openScene(this._id)
120
+ // ---- Presentable (docs/navigation-presentable-plan.md) ----
121
+ // A scene is a destination like a screen: opening it replaces whatever is visible (suspending
122
+ // an active Router until Router.restore()), and it can be pushed onto the Router stack. The
123
+ // scene graph survives while covered — only the view detaches and rendering pauses.
124
+
125
+ /** @internal presentation lifecycle — fired by the host on present/dismiss (new navigation),
126
+ * or synchronously by open()/close() on legacy hosts. */
127
+ readonly ol: (() => void)[] = []
128
+ readonly cl: (() => void)[] = []
129
+ _backButtonCallback?: () => void
130
+ private _vd?: object
131
+
132
+ /** Fires when the scene becomes the visible destination (incl. a pop revealing it). */
133
+ onOpen(callback: () => void): this {
134
+ this.ol.push(callback)
135
+ return this
136
+ }
137
+ /** Fires when the scene stops being visible (closed, replaced, or covered by a push). */
138
+ onClose(callback: () => void): this {
139
+ this.cl.push(callback)
140
+ return this
141
+ }
142
+ onBackPressed(callback: () => void): this {
143
+ this._backButtonCallback = callback
144
+ return this
145
+ }
146
+
147
+ /** @internal stable wire descriptor; hosts hand it back via router onChange (`_p` = this). */
148
+ _viewDesc(): object {
149
+ return this._vd ??= { type: "scene3d", sceneId: this._id, _p: this }
150
+ }
151
+
152
+ /** Make this the active scene — shows it as the current destination (replaces a screen /
153
+ * another scene; only the active scene renders). */
154
+ open(options?: PresentOptions): void | Promise<void> {
155
+ _bumpNavEpoch()
156
+ if (_navSupported()) {
157
+ // The host activates the engine (openScene) as part of presenting the destination.
158
+ _creatorUI.openView!(this._viewDesc(), options?.transition ?? "none")
159
+ } else {
160
+ _creator.openScene(this._id)
161
+ for (const cb of this.ol) cb()
162
+ }
121
163
  Scene._active = this
122
164
  glState.activeScene = this
165
+ _setCurrent(this)
123
166
  }
124
167
  close(): void {
125
- _creator.closeScene()
168
+ _bumpNavEpoch()
169
+ if (_navSupported()) {
170
+ _creatorUI.closeView!()
171
+ } else {
172
+ _creator.closeScene()
173
+ for (const cb of this.cl) cb()
174
+ }
126
175
  if (Scene._active === this) Scene._active = null
127
176
  if (glState.activeScene === this) glState.activeScene = null
177
+ if (Presentable.current === this) _setCurrent(null)
128
178
  }
129
179
 
130
180
  addEventListener(channel: "click", callback: (ev: ClickEvent<Node | null>) => void): void
@@ -188,11 +238,26 @@ export class ARScene<T extends ARMode = "default"> extends Scene {
188
238
  return attachControls(this, target, options)
189
239
  }
190
240
 
191
- override async open(): Promise<void> {
192
- await new Promise<void>((res, rej) => _creator.requestCamera(res, rej))
193
- if (this.useWarmRender) { _creator.createGLView(); await this.warmRender() }
241
+ override async open(options?: PresentOptions): Promise<void> {
242
+ await this._prepare()
243
+ super.open(options)
244
+ }
245
+
246
+ /** @internal Presentable prepare hook: camera permission + warm render + AR session, all while
247
+ * the previous destination (typically a loading screen) stays visible — the swap to this scene
248
+ * happens only after it resolves. Warm render is headless on every platform (offscreen
249
+ * swapchain), so no GL view is created here — the host mounts the scene view at present time.
250
+ * Rejects on camera denial / AR launch failure, or if another navigation happened while
251
+ * preparing (superseded) — in every rejection case the current destination stays visible. */
252
+ async _prepare(): Promise<void> {
253
+ const epoch = _navEpoch()
254
+ await _requestCameraPermission()
255
+ if (this.useWarmRender) await this.warmRender()
194
256
  await new Promise<void>((res, rej) => _creator.launchAR(this._id, res, rej))
195
- super.open()
257
+ if (_navEpoch() !== epoch) {
258
+ _creator.stopAR()
259
+ throw new Error("ARScene.open() superseded: another destination was opened while preparing")
260
+ }
196
261
  }
197
262
 
198
263
  override close(): void {
@@ -212,9 +277,9 @@ export class ARScene<T extends ARMode = "default"> extends Scene {
212
277
  * `open()` rejects on hosts without a VR runtime ("VR is not supported on this device").
213
278
  */
214
279
  export class VRScene extends Scene {
215
- override async open(): Promise<void> {
280
+ override async open(options?: PresentOptions): Promise<void> {
216
281
  await new Promise<void>((res, rej) => _creator.launchVR(this._id, res, rej))
217
- super.open()
282
+ super.open(options)
218
283
  }
219
284
 
220
285
  override close(): void {