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.
- package/README.md +1 -0
- package/dist/index.js +920 -636
- package/package.json +9 -4
- package/runtime/scene-harness.json +1 -0
- package/runtime/sdk/compile/assetMacro.ts +4 -3
- package/runtime/sdk/compile/bundler.ts +35 -3
- package/runtime/sdk/compile/compileProject.ts +14 -2
- package/runtime/sdk/compile/libraryImports.ts +47 -0
- package/runtime/sdk/compile/sceneEditor.ts +16 -4
- package/runtime/sdk/core/Aspect.ts +37 -0
- package/runtime/sdk/core/InspectorUI.ts +212 -0
- package/runtime/sdk/core/fields.ts +15 -3
- package/runtime/sdk/g2/Scene2D.ts +51 -5
- package/runtime/sdk/gl/Model.ts +9 -0
- package/runtime/sdk/gl/Node.ts +4 -1
- package/runtime/sdk/gl/Scene.ts +76 -11
- package/runtime/sdk/gl/scenarios.ts +349 -0
- package/runtime/sdk/inject.ts +28 -8
- package/runtime/sdk/kit/UITabs.ts +105 -0
- package/runtime/sdk/plugins/camera.ts +81 -0
- package/runtime/sdk/plugins/geolocation.ts +123 -0
- package/runtime/sdk/plugins/permission.ts +7 -0
- package/runtime/sdk/plugins/qr.ts +61 -24
- package/runtime/sdk/runtime/app.ts +37 -0
- package/runtime/sdk/runtime/appEvents.ts +29 -0
- package/runtime/sdk/runtime/channel.ts +50 -0
- package/runtime/sdk/runtime/clipboard.ts +20 -0
- package/runtime/sdk/runtime/datetime.ts +2 -4
- package/runtime/sdk/runtime/device.ts +137 -4
- package/runtime/sdk/runtime/misc.ts +4 -0
- package/runtime/sdk/runtime/service.ts +82 -0
- package/runtime/sdk/runtime/touch.ts +26 -0
- package/runtime/sdk/scene/defineScene.ts +651 -17
- package/runtime/sdk/scene/editorPlugins.ts +86 -0
- package/runtime/sdk/ui/NativeView.ts +144 -0
- package/runtime/sdk/ui/UI.ts +6 -1
- package/runtime/sdk/ui/UIButton.ts +26 -3
- package/runtime/sdk/ui/UIInput.ts +2 -2
- package/runtime/sdk/ui/UINode.ts +24 -4
- package/runtime/sdk/ui/UIScreen.ts +86 -29
- package/runtime/sdk/ui/UIScreenHost.ts +213 -0
- package/runtime/sdk/ui/UIText.ts +1 -1
- package/runtime/sdk/ui/UIVideo.ts +48 -2
- package/runtime/sdk/ui/UIWidget.ts +34 -1
- package/runtime/sdk/ui/presentable.ts +116 -0
- package/runtime/sdk/ui/router.ts +73 -29
- package/runtime/sdk-types.json +1 -1
- 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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
package/runtime/sdk/gl/Model.ts
CHANGED
|
@@ -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,
|
package/runtime/sdk/gl/Node.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
package/runtime/sdk/gl/Scene.ts
CHANGED
|
@@ -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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
|
193
|
-
|
|
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
|
-
|
|
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 {
|