lecodes-cli 0.6.4 → 0.7.0

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.
@@ -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
  })
@@ -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 {
@@ -0,0 +1,349 @@
1
+ // No-code scenario aspects: ready-made behaviors a scene builder attaches in the editor without
2
+ // writing any code — move to a target, travel a waypoint path, spin, face something, start a GLB
3
+ // animation. They are ordinary aspects (work from hand-written code too); in the scene editor the
4
+ // movement ones double as `static editor` generators that draw their paths as editor-only lines
5
+ // (edges meshes under `this.generated` — play mode never calls rebuild(), so the lines cost
6
+ // nothing at runtime).
7
+ //
8
+ // Conventions shared by the family:
9
+ // • targets/waypoints are scene nodes — place an Empty, then pick it in the inspector (`ref()`);
10
+ // • times are seconds, angles degrees; all motion runs in `update(dt)` (play mode only);
11
+ // • orientation is quaternion-composed — never euler-incremented (see math/quat.ts).
12
+
13
+ import { Aspect } from "../core/Aspect"
14
+ import type { FieldMeta } from "../core/fields"
15
+ import type { InspectorUI } from "../core/InspectorUI"
16
+ import { Vec3 } from "../math/vec"
17
+ import { Quat } from "../math/quat"
18
+ import { Node } from "./Node"
19
+ import { Mesh } from "./Mesh"
20
+ import { Model } from "./Model"
21
+ import { Geometry } from "./Geometry"
22
+ import { Material } from "./Material"
23
+
24
+ const MOVE_LINE = "#5b8ef0"
25
+ const LOOK_LINE = "#d9a13f"
26
+ const DEG2RAD = Math.PI / 180
27
+
28
+ type LoopMode = "once" | "loop" | "pingpong"
29
+
30
+ /** Normalized phase for elapsed time `t` over one trip of `duration`: once clamps at 1, loop
31
+ * wraps, pingpong triangles 0→1→0. */
32
+ const phase = (t: number, duration: number, mode: LoopMode): number => {
33
+ if (duration <= 0) return 1
34
+ const u = t / duration
35
+ if (mode === "once") return Math.min(u, 1)
36
+ if (mode === "loop") return u - Math.floor(u)
37
+ const v = u % 2
38
+ return v <= 1 ? v : 2 - v
39
+ }
40
+
41
+ const ease = (k: number, easing: "smooth" | "linear"): number =>
42
+ easing === "smooth" ? k * k * (3 - 2 * k) : k
43
+
44
+ /** A world point in `node`'s PARENT space — what `node.position` is expressed in. */
45
+ const toParentLocal = (node: Node, world: Vec3): Vec3 => {
46
+ const parent = node.parent
47
+ return parent ? parent.worldMatrix.invert().transformPoint(world) : world
48
+ }
49
+
50
+ // ---- editor line drawing (edit mode only — see rebuild() on the aspects below) -------------------
51
+
52
+ /** @internal An "edges" line mesh from flat segment endpoints ([x,y,z, x,y,z] per segment) —
53
+ * editor-only visuals (path lines here, node markers in defineScene). */
54
+ export const edgesMesh = (segments: number[], color: string): Mesh => {
55
+ const vertices = new Float32Array(segments)
56
+ const count = vertices.length / 3
57
+ const normals = new Float32Array(count * 3)
58
+ for (let i = 0; i < count; i++) normals[i * 3 + 1] = 1
59
+ const indices = new Uint16Array(count)
60
+ for (let i = 0; i < count; i++) indices[i] = i
61
+ const geo = new Geometry(vertices, normals, indices, new Float32Array(count * 2))
62
+ geo.kind = "edges"
63
+ const mesh = Mesh.from(geo, { material: Material.unlit({ color }), name: "__editor_line" })
64
+ mesh.castShadows = false
65
+ mesh.receiveShadows = false
66
+ return mesh
67
+ }
68
+
69
+ const pushCross = (segments: number[], p: Vec3, s: number): void => {
70
+ segments.push(p.x - s, p.y, p.z, p.x + s, p.y, p.z)
71
+ segments.push(p.x, p.y - s, p.z, p.x, p.y + s, p.z)
72
+ segments.push(p.x, p.y, p.z - s, p.x, p.y, p.z + s)
73
+ }
74
+
75
+ /** `this.generated` hangs UNDER the host node, so line points (world space) must be expressed in
76
+ * the host's local frame; the host is an editor-run dep of its own aspects, so dragging it
77
+ * re-runs rebuild() and the conversion stays fresh. */
78
+ const worldSegmentsToLocal = (node: Node, segments: number[]): number[] => {
79
+ const inv = node.worldMatrix.invert()
80
+ const out: number[] = []
81
+ for (let i = 0; i < segments.length; i += 3) {
82
+ const p = inv.transformPoint([ segments[i]!, segments[i + 1]!, segments[i + 2]! ])
83
+ out.push(p.x, p.y, p.z)
84
+ }
85
+ return out
86
+ }
87
+
88
+ /** A path node's waypoints: its children in scene-FILE order (`_sceneChildOrder`, stamped by
89
+ * defineScene — the engine's live child order is insertion-based and may differ), editor helper
90
+ * nodes (`__`-prefixed) excluded. Runtime-added children append in engine order. */
91
+ const pathWaypoints = (path: Node): Node[] => {
92
+ const children = path.children.filter((c) => !(c.name ?? "").startsWith("__"))
93
+ const order = (path as { _sceneChildOrder?: string[] })._sceneChildOrder
94
+ if (!order) return children
95
+ const byName = new Map(children.map((c) => [ c.name, c ]))
96
+ const sorted: Node[] = []
97
+ for (const n of order) { const c = byName.get(n); if (c) sorted.push(c) }
98
+ const seen = new Set(sorted)
99
+ for (const c of children) if (!seen.has(c)) sorted.push(c)
100
+ return sorted
101
+ }
102
+
103
+ // ---- the aspects ---------------------------------------------------------------------------------
104
+
105
+ /** Travel from the node's starting position to a target node in `duration` seconds. */
106
+ export class MoveTo extends Aspect<"moveTo"> {
107
+ /** Where to travel — place an Empty node and pick it. */
108
+ target: Node | null = null
109
+
110
+ /** Seconds for the full trip. */
111
+ duration = 2
112
+
113
+ /** Seconds to wait before starting. */
114
+ delay = 0
115
+
116
+ /** once = stop at the target · loop = restart from the start point · pingpong = back and forth. */
117
+ mode: LoopMode = "once"
118
+
119
+ /** Motion curve. */
120
+ easing: "smooth" | "linear" = "smooth"
121
+
122
+ static readonly aspect = "moveTo"
123
+ static editor = { rebuild: true }
124
+ static fields: FieldMeta<MoveTo> = {
125
+ target: { editor: "node" },
126
+ duration: { min: 0.05, max: 120, step: 0.05 },
127
+ delay: { min: 0, max: 120, step: 0.05 },
128
+ mode: { options: [ "once", "loop", "pingpong" ] },
129
+ easing: { options: [ "smooth", "linear" ] },
130
+ }
131
+
132
+ private _t = 0
133
+ private _start: Vec3 | null = null
134
+
135
+ update(dt: number): void {
136
+ if (!this.target) return
137
+ this._start ??= this.node.position
138
+ this._t += dt
139
+ const k = ease(phase(Math.max(0, this._t - this.delay), this.duration, this.mode), this.easing)
140
+ const end = toParentLocal(this.node, this.target.worldPosition)
141
+ this.node.position = new Vec3(this._start).add(end.sub(this._start).scale(k))
142
+ }
143
+
144
+ /** Editor-only path line (edit mode; play mode never calls this). */
145
+ rebuild(): void {
146
+ this.generated.clear()
147
+ if (!this.target) return
148
+ const segments: number[] = []
149
+ const a = this.node.worldPosition
150
+ const b = this.target.worldPosition
151
+ segments.push(a.x, a.y, a.z, b.x, b.y, b.z)
152
+ pushCross(segments, b, 0.09)
153
+ this.generated.add(edgesMesh(worldSegmentsToLocal(this.node, segments), MOVE_LINE))
154
+ }
155
+ }
156
+
157
+ /** Travel through the children of a path node (add an Empty per waypoint) in `duration` seconds.
158
+ * `loop` runs the circuit closed (last → first); `pingpong` goes back and forth along the open
159
+ * path; `once` stops at the last waypoint. */
160
+ export class FollowPath extends Aspect<"followPath"> {
161
+ /** A node whose CHILDREN are the waypoints, in file order. */
162
+ path: Node | null = null
163
+
164
+ /** Seconds for one full pass of the path. */
165
+ duration = 6
166
+
167
+ /** Seconds to wait before starting. */
168
+ delay = 0
169
+
170
+ mode: LoopMode = "loop"
171
+
172
+ /** Turn to face the direction of travel. */
173
+ orient = true
174
+
175
+ /** Which local axis leads when orienting. */
176
+ forward: "-z" | "z" | "x" | "-x" = "-z"
177
+
178
+ static readonly aspect = "followPath"
179
+ static editor = { rebuild: true }
180
+ static fields: FieldMeta<FollowPath> = {
181
+ path: { editor: "node" },
182
+ duration: { min: 0.05, max: 300, step: 0.05 },
183
+ delay: { min: 0, max: 120, step: 0.05 },
184
+ mode: { options: [ "loop", "once", "pingpong" ] },
185
+ forward: { options: [ "-z", "z", "x", "-x" ] },
186
+ }
187
+
188
+ private _t = 0
189
+
190
+ private _points(): Vec3[] {
191
+ if (!this.path) return []
192
+ const points = pathWaypoints(this.path).map((c) => c.worldPosition)
193
+ if (this.mode === "loop" && points.length > 1) points.push(points[0]!.clone())
194
+ return points
195
+ }
196
+
197
+ update(dt: number): void {
198
+ const points = this._points()
199
+ if (points.length < 2) return
200
+ this._t += dt
201
+ const k = phase(Math.max(0, this._t - this.delay), this.duration, this.mode === "loop" ? "loop" : this.mode)
202
+
203
+ const lengths: number[] = []
204
+ let total = 0
205
+ for (let i = 0; i < points.length - 1; i++) {
206
+ const l = points[i + 1]!.sub(points[i]!).length()
207
+ lengths.push(l)
208
+ total += l
209
+ }
210
+ if (total <= 0) return
211
+
212
+ let dist = k * total
213
+ let i = 0
214
+ while (i < lengths.length - 1 && dist > lengths[i]!) { dist -= lengths[i]!; i++ }
215
+ const a = points[i]!
216
+ const b = points[i + 1]!
217
+ const seg = b.sub(a)
218
+ const u = lengths[i]! > 0 ? Math.min(dist / lengths[i]!, 1) : 0
219
+ const world = a.add(seg.scale(u))
220
+ this.node.position = toParentLocal(this.node, world)
221
+ if (this.orient && seg.lengthSq() > 1e-12) this.node.lookAt(world.add(seg), this.forward)
222
+ }
223
+
224
+ /** Editor-only waypoint polyline (edit mode; play mode never calls this). */
225
+ rebuild(): void {
226
+ this.generated.clear()
227
+ const points = this._points()
228
+ if (points.length < 2) return
229
+ const segments: number[] = []
230
+ for (let i = 0; i < points.length - 1; i++) {
231
+ const a = points[i]!, b = points[i + 1]!
232
+ segments.push(a.x, a.y, a.z, b.x, b.y, b.z)
233
+ }
234
+ for (const p of points) pushCross(segments, p, 0.07)
235
+ this.generated.add(edgesMesh(worldSegmentsToLocal(this.node, segments), MOVE_LINE))
236
+ }
237
+ }
238
+
239
+ /** Continuous rotation about one of the node's local axes, degrees per second. */
240
+ export class Spin extends Aspect<"spin"> {
241
+ /** Degrees per second. */
242
+ speed = 45
243
+
244
+ /** Local axis to spin around. */
245
+ axis: "x" | "y" | "z" = "y"
246
+
247
+ static readonly aspect = "spin"
248
+ static fields: FieldMeta<Spin> = {
249
+ speed: { min: -720, max: 720, step: 5 },
250
+ axis: { options: [ "x", "y", "z" ] },
251
+ }
252
+
253
+ update(dt: number): void {
254
+ const axis: [number, number, number] = this.axis === "x" ? [ 1, 0, 0 ] : this.axis === "y" ? [ 0, 1, 0 ] : [ 0, 0, 1 ]
255
+ // compose in quaternion space about the LOCAL axis (q ⊗ dq) — never euler-increment
256
+ this.node.quaternion = this.node.quaternion.mul(Quat.fromAxisAngle(axis, this.speed * dt * DEG2RAD))
257
+ }
258
+ }
259
+
260
+ /** Keep the node facing a target node (a turret tracking, a signpost, a camera aimed at a hero).
261
+ * Accessor `node.facing` — NOT `lookAt`, which would shadow the `Node.lookAt()` method. */
262
+ export class LookAt extends Aspect<"facing"> {
263
+ /** The node to face. */
264
+ target: Node | null = null
265
+
266
+ /** Which local axis points at the target. */
267
+ forward: "-z" | "z" | "x" | "-x" = "-z"
268
+
269
+ /** 0 = snap instantly; otherwise seconds of turn lag (bigger = slower, smoother). */
270
+ smoothing = 0
271
+
272
+ static readonly aspect = "facing"
273
+ static editor = { rebuild: true }
274
+ static fields: FieldMeta<LookAt> = {
275
+ target: { editor: "node" },
276
+ forward: { options: [ "-z", "z", "x", "-x" ] },
277
+ smoothing: { min: 0, max: 5, step: 0.05 },
278
+ }
279
+
280
+ update(dt: number): void {
281
+ if (!this.target) return
282
+ const point = this.target.worldPosition
283
+ if (this.smoothing <= 0) { this.node.lookAt(point, this.forward); return }
284
+ const q0 = this.node.quaternion
285
+ this.node.lookAt(point, this.forward)
286
+ const q1 = this.node.quaternion
287
+ this.node.quaternion = q0.slerp(q1, 1 - Math.exp(-dt / this.smoothing))
288
+ }
289
+
290
+ /** Editor-only sight line (edit mode; play mode never calls this). */
291
+ rebuild(): void {
292
+ this.generated.clear()
293
+ if (!this.target) return
294
+ const segments: number[] = []
295
+ const a = this.node.worldPosition
296
+ const b = this.target.worldPosition
297
+ segments.push(a.x, a.y, a.z, b.x, b.y, b.z)
298
+ pushCross(segments, b, 0.07)
299
+ this.generated.add(edgesMesh(worldSegmentsToLocal(this.node, segments), LOOK_LINE))
300
+ }
301
+ }
302
+
303
+ /** Start a GLB animation clip when the scene runs. Attach to a `model:` node; the inspector card
304
+ * lists the model's clips live and can preview them while editing. */
305
+ export class PlayAnimation extends Aspect<"playAnimation", Model> {
306
+ /** Clip name; empty = the model's first clip. */
307
+ clip = ""
308
+
309
+ loop = true
310
+
311
+ /** Playback rate (1 = authored speed). */
312
+ speed = 1
313
+
314
+ static readonly aspect = "playAnimation"
315
+ static fields: FieldMeta<PlayAnimation> = {
316
+ speed: { min: 0.05, max: 5, step: 0.05 },
317
+ }
318
+
319
+ onAttach(): void {
320
+ const anim = (this.node as Model | undefined)?.anim
321
+ if (!anim) return
322
+ anim.speed = this.speed
323
+ anim.play(this.clip === "" ? 0 : this.clip, { loop: this.loop })
324
+ }
325
+
326
+ static inspector(ui: InspectorUI, a: PlayAnimation): void {
327
+ const anim = (a.node as Model | undefined)?.anim
328
+ const clips = anim?.clips ?? []
329
+ if (clips.length === 0) {
330
+ ui.warn("No animation clips — attach this to a GLB model node")
331
+ return
332
+ }
333
+ ui.select("clip", clips.map((c) => c.name))
334
+ ui.auto("loop", "speed")
335
+ if (anim) {
336
+ if (ui.button(anim.playing ? "Restart preview" : "Preview")) {
337
+ anim.speed = a.speed
338
+ anim.play(a.clip === "" ? 0 : a.clip, { loop: a.loop })
339
+ }
340
+ if (anim.playing && ui.button("Stop")) {
341
+ anim.stop()
342
+ anim.time = 0
343
+ }
344
+ const clip = a.clip === "" ? clips[0]?.name : a.clip
345
+ const duration = clips.find((c) => c.name === clip)?.duration
346
+ ui.info(anim.playing ? "previewing…" : duration !== undefined ? `${duration.toFixed(2)}s` : "")
347
+ }
348
+ }
349
+ }
@@ -16,6 +16,7 @@ export { Color } from "./core/color"
16
16
 
17
17
  // ---- aspects (attachable node capabilities) ----
18
18
  export { Aspect, type With } from "./core/Aspect"
19
+ export { InspectorUI } from "./core/InspectorUI"
19
20
 
20
21
  // ---- reactivity (signals; UI text/style props also accept `() => value` bindings) ----
21
22
  export { signal, computed, effect, type Signal, type Computed } from "./core/signals"
@@ -60,6 +61,7 @@ export {
60
61
  UIVideo, type UIVideoStyle,
61
62
  UIInput, UITextArea, type UIInputStyle,
62
63
  UIScrollable, type UIScrollableStyle,
64
+ UIScreenHost, type UIScreenHostStyle,
63
65
  UIWidget, type UIWidgetStyle,
64
66
  UISpacer, type UISpacerStyle,
65
67
  UIVirtualizedList,
@@ -71,6 +73,9 @@ export {
71
73
  // UIColumn/UIRow/append/etc., so they must be global (type-only; erased at runtime).
72
74
  export type { UINode, UINodeChild } from "./ui/UINode"
73
75
 
76
+ // ---- UI kit (pure-TS components built on the core UI globals; see packages/sdk/src/kit/) ----
77
+ export { UITabs, type UITabItem } from "./kit/UITabs"
78
+
74
79
  // ---- animate ----
75
80
  export { animate, animateMat4, stopAnimation, pauseAnimation, resumeAnimation } from "./animate/animate"
76
81
  export { cubicBezier } from "./animate/bezier"
@@ -80,10 +85,17 @@ export { easeIn, easeOut, easeInOut } from "./animate/easings"
80
85
  export { QRScanner } from "./plugins/qr"
81
86
 
82
87
  // ---- scenes as data (.scene.ts files — see src/scene/defineScene.ts) ----
83
- export { defineScene, use, SceneHandle } from "./scene/defineScene"
84
- export type { SceneDef, SceneNodeDef, MeshDef, LightDef, MaterialDef, AspectEntry, LoadedScene } from "./scene/defineScene"
88
+ export { defineScene, use, ref, make, SceneHandle } from "./scene/defineScene"
89
+ export type { SceneDef, SceneNodeDef, MeshDef, LightDef, MaterialDef, CameraNodeDef, AspectEntry, MakeEntry, LoadedScene } from "./scene/defineScene"
85
90
  export type { FieldMeta } from "./core/fields"
86
91
 
92
+ // ---- no-code scenario aspects (attachable in the scene editor — see src/gl/scenarios.ts) ----
93
+ export { MoveTo, FollowPath, Spin, LookAt, PlayAnimation } from "./gl/scenarios"
94
+
95
+ // ---- editor plugins (called from `*.editor.ts` files — editor bundles only; DCE'd from apps) ----
96
+ export { registerEditorWindow, registerEditorTool, __editorPlugins } from "./scene/editorPlugins"
97
+ export type { EditorApi, EditorRayHit, EditorToolHooks, EditorWindowFn } from "./scene/editorPlugins"
98
+
87
99
  // ---- 3D engine (creator-gl) ----
88
100
  export { Scene, ARScene, VRScene } from "./gl/Scene"
89
101
  export { Node } from "./gl/Node"