lecodes-cli 0.6.3 → 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
+ }
@@ -0,0 +1,120 @@
1
+ // Field introspection for the scene-editor inspector: what's editable on an aspect, and with which
2
+ // editor widget. Engine-agnostic (works for 2D and 3D aspects alike).
3
+ //
4
+ // The editable surface of an aspect is its public class fields with defaults — the same convention
5
+ // aspects already follow for `node.aspect(Ctor, opts)` configuration. Editors are layered:
6
+ // 1. inference from the default value's shape (number → number field, '#rrggbb' → color, …), free
7
+ // for every user aspect with zero ceremony;
8
+ // 2. optional `static fields: FieldMeta<T>` on the class for ranges/labels/options/overrides
9
+ // (mirrors how `static aspect` declares the accessor name).
10
+
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). */
16
+ export type FieldEditor =
17
+ | "number" | "text" | "color" | "switch" | "select"
18
+ | "vec2" | "vec3" | "vec4" | "node" | "asset"
19
+
20
+ export type FieldMetaEntry = {
21
+ /** Inspector label (default: the field name). */
22
+ label?: string
23
+ min?: number
24
+ max?: number
25
+ step?: number
26
+ /** Allowed values — renders a dropdown (editor "select"). */
27
+ options?: readonly (string | number)[]
28
+ /** Override the editor inferred from the default value. */
29
+ editor?: FieldEditor
30
+ /** Exclude the field from the inspector. */
31
+ hidden?: boolean
32
+ }
33
+
34
+ /** Declared on an aspect class as `static fields: FieldMeta<MyAspect> = { … }`. All optional. */
35
+ export type FieldMeta<T = unknown> = { [K in Extract<keyof T, string>]?: FieldMetaEntry }
36
+
37
+ /** One editable field: key + default value + resolved editor + merged metadata. */
38
+ export type FieldDescriptor = {
39
+ key: string
40
+ label: string
41
+ /** The default value (from a freshly constructed instance; undefined if only declared in meta). */
42
+ value: unknown
43
+ /** Resolved editor kind; undefined = no editor known (inspector shows it read-only). */
44
+ editor: FieldEditor | undefined
45
+ min?: number
46
+ max?: number
47
+ step?: number
48
+ options?: readonly (string | number)[]
49
+ }
50
+
51
+ export type AspectClassInfo = {
52
+ /** The accessor name (`static aspect`, injected by chisel for user aspects). */
53
+ name: string
54
+ className: string
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
61
+ }
62
+
63
+ // Instance fields every aspect inherits from the Aspect base — configuration plumbing, not content.
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" ])
66
+
67
+ /** Infer the editor widget from a default value's shape. */
68
+ export const inferFieldEditor = (value: unknown): FieldEditor | undefined => {
69
+ if (typeof value === "boolean") return "switch"
70
+ if (typeof value === "number") return "number"
71
+ if (typeof value === "string") return value.startsWith("#") ? "color" : "text"
72
+ if (Array.isArray(value) && value.length >= 2 && value.length <= 4 && value.every((n) => typeof n === "number")) {
73
+ return value.length === 2 ? "vec2" : value.length === 3 ? "vec3" : "vec4"
74
+ }
75
+ return undefined
76
+ }
77
+
78
+ const cloneValue = (v: unknown): unknown => (Array.isArray(v) ? [ ...v ] : v)
79
+
80
+ /**
81
+ * Enumerate the editable fields of a class: construct a default instance, take its own enumerable
82
+ * non-underscore fields (minus the Aspect base plumbing), infer editors from the default values, and
83
+ * merge the class's optional `static fields` metadata (which can also add keys that have no runtime
84
+ * default, e.g. fields declared without an initializer).
85
+ */
86
+ export const describeFields = (ctor: abstract new () => unknown): FieldDescriptor[] => {
87
+ let defaults: Record<string, unknown> = {}
88
+ // Aspects have no-arg constructors by convention (the engine creates them via node.aspect()); a
89
+ // class that throws here just loses default-value inference, it doesn't break the inspector.
90
+ try { defaults = new (ctor as new () => Record<string, unknown>)() } catch { /* meta-only */ }
91
+
92
+ const meta = ((ctor as { fields?: FieldMeta }).fields ?? {}) as Record<string, FieldMetaEntry>
93
+
94
+ const keys = new Set<string>()
95
+ for (const k of Object.keys(defaults)) {
96
+ if (BASE_FIELDS.has(k) || k.startsWith("_")) continue
97
+ if (typeof defaults[k] === "function") continue
98
+ keys.add(k)
99
+ }
100
+ for (const k of Object.keys(meta)) keys.add(k)
101
+
102
+ const out: FieldDescriptor[] = []
103
+ for (const key of keys) {
104
+ const m = meta[key] ?? {}
105
+ if (m.hidden) continue
106
+ const value = defaults[key]
107
+ const editor = m.editor ?? (m.options ? "select" : inferFieldEditor(value))
108
+ out.push({ key, label: m.label ?? key, value: cloneValue(value), editor, min: m.min, max: m.max, step: m.step, options: m.options })
109
+ }
110
+ return out
111
+ }
112
+
113
+ /** Describe an aspect class for the inspector: accessor name + class name + editable fields. */
114
+ export const describeAspect = (ctor: abstract new () => unknown): AspectClassInfo => ({
115
+ name: (ctor as { aspect?: string }).aspect ?? "",
116
+ className: (ctor as { name?: string }).name ?? "",
117
+ fields: describeFields(ctor),
118
+ editor: (ctor as { editor?: unknown }).editor ? true : undefined,
119
+ inspector: typeof (ctor as { inspector?: unknown }).inspector === "function" ? true : undefined,
120
+ })
@@ -36,6 +36,14 @@ const applyUniform = (id: number, key: string, value: UniformValue): void => {
36
36
 
37
37
  export type MaterialColorOptions = { color?: ColorInput, map?: Texture | Canvas | null }
38
38
 
39
+ /** `Material.lit` options — PBR scalars on top of the color/map pair. */
40
+ export type LitMaterialOptions = MaterialColorOptions & {
41
+ /** Perceptual roughness, 0 (mirror) … 1 (matte). Unset = the shader's default. */
42
+ roughness?: number
43
+ /** Metallic factor, 0 (dielectric) … 1 (metal). Unset = the shader's default. */
44
+ metallic?: number
45
+ }
46
+
39
47
  export class Material {
40
48
  /** @internal native material-instance handle. */
41
49
  _id: number
@@ -84,12 +92,14 @@ export class Material {
84
92
  // --- factories ---
85
93
 
86
94
  /** PBR lit material. */
87
- static lit(options: MaterialColorOptions = {}): Material {
95
+ static lit(options: LitMaterialOptions = {}): Material {
88
96
  // The fetchLocal literal must be inline so the compiler's preload header captures the shader name.
89
97
  const m = new Material({ _id: _creatorUtils.fetchLocal("lit.filamat") } as any)
90
98
  m._colorKey = "baseColor"
91
99
  if (options.color !== undefined) m.color = options.color
92
100
  if (options.map) m.map = options.map
101
+ if (options.roughness !== undefined) m.uniforms.roughness = options.roughness
102
+ if (options.metallic !== undefined) m.uniforms.metallic = options.metallic
93
103
  return m
94
104
  }
95
105
 
@@ -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 {
@@ -66,11 +66,16 @@ export class Scene {
66
66
  if (parent) {
67
67
  this.camera = parent.camera
68
68
  this._id = _creator.createOverlayScene(parent._id)
69
+ // No camera re-attach: the native createOverlayScene wires the parent's camera into the
70
+ // overlay view. Re-attaching would (a) point camera._sceneId — the source for getRay /
71
+ // getViewDirection / fov — at the overlay's view, whose viewport/projection aren't
72
+ // maintained like the render view's, and (b) create a second native camera component on
73
+ // the shared entity. (Found via the scene editor: gizmo pick rays came out distorted.)
69
74
  } else {
70
75
  this.camera = new Camera()
71
76
  this._id = _creator.createScene()
77
+ this.camera._attach(this._id)
72
78
  }
73
- this.camera._attach(this._id)
74
79
 
75
80
  if (options.ibl !== false) _creator.setDefaultIbl(this._id, options.environmentIntensity ?? 20000)
76
81
  if (options.bloom) _creator.setBloomOptions(this._id, true, options.bloomIntensity ?? 0.2, 1)