lecodes-cli 0.18.0 → 0.18.2

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 (62) hide show
  1. package/README.md +1 -1
  2. package/dist/index.js +2013 -564
  3. package/package.json +4 -4
  4. package/runtime/scene-harness.json +1 -1
  5. package/runtime/sdk/core/Aspect.ts +512 -255
  6. package/runtime/sdk/core/compWrite.ts +42 -0
  7. package/runtime/sdk/core/fields.ts +1 -1
  8. package/runtime/sdk/core/time.ts +81 -0
  9. package/runtime/sdk/g2/Camera2D.ts +8 -1
  10. package/runtime/sdk/g2/CharacterController2D.ts +253 -53
  11. package/runtime/sdk/g2/Node2D.ts +80 -10
  12. package/runtime/sdk/g2/OneWay2D.ts +66 -0
  13. package/runtime/sdk/g2/Physics2D.ts +240 -30
  14. package/runtime/sdk/g2/Scene2D.ts +33 -1
  15. package/runtime/sdk/g2/Shape2D.ts +218 -22
  16. package/runtime/sdk/g2/Trigger2D.ts +42 -12
  17. package/runtime/sdk/g2/groups2d.ts +106 -0
  18. package/runtime/sdk/g2/loop.ts +15 -4
  19. package/runtime/sdk/gl/Camera.ts +40 -1
  20. package/runtime/sdk/gl/CameraPlace.ts +52 -51
  21. package/runtime/sdk/gl/CharacterController.ts +184 -56
  22. package/runtime/sdk/gl/Gearbox.ts +212 -0
  23. package/runtime/sdk/gl/Geometry.ts +70 -9
  24. package/runtime/sdk/gl/Light.ts +64 -2
  25. package/runtime/sdk/gl/Lightmap.ts +179 -0
  26. package/runtime/sdk/gl/Material.ts +25 -0
  27. package/runtime/sdk/gl/Mesh.ts +6 -23
  28. package/runtime/sdk/gl/Model.ts +16 -2
  29. package/runtime/sdk/gl/Node.ts +119 -39
  30. package/runtime/sdk/gl/Physics.ts +75 -24
  31. package/runtime/sdk/gl/Scene.ts +161 -5
  32. package/runtime/sdk/gl/Shape.ts +42 -3
  33. package/runtime/sdk/gl/Trigger.ts +45 -50
  34. package/runtime/sdk/gl/Vehicle.ts +276 -322
  35. package/runtime/sdk/gl/Wheel.ts +240 -0
  36. package/runtime/sdk/gl/scenarios.ts +4 -30
  37. package/runtime/sdk/gl/state.ts +6 -6
  38. package/runtime/sdk/inject.ts +186 -171
  39. package/runtime/sdk/runtime/device.ts +102 -1
  40. package/runtime/sdk/scene/defineScene.ts +108 -53
  41. package/runtime/sdk/scene/gizmos.ts +31 -11
  42. package/runtime/sdk/scene/material.ts +188 -0
  43. package/runtime/sdk-types.json +1 -1
  44. package/runtime/sdk/compile/aspectMacro.ts +0 -42
  45. package/runtime/sdk/compile/assetIconMacro.ts +0 -384
  46. package/runtime/sdk/compile/assetMacro.ts +0 -45
  47. package/runtime/sdk/compile/assetName.ts +0 -50
  48. package/runtime/sdk/compile/bundler.ts +0 -252
  49. package/runtime/sdk/compile/compileProject.ts +0 -129
  50. package/runtime/sdk/compile/detectEntry.ts +0 -128
  51. package/runtime/sdk/compile/fontMacro.ts +0 -459
  52. package/runtime/sdk/compile/fontRegistry.ts +0 -78
  53. package/runtime/sdk/compile/header.ts +0 -67
  54. package/runtime/sdk/compile/index.ts +0 -85
  55. package/runtime/sdk/compile/libraryImports.ts +0 -52
  56. package/runtime/sdk/compile/liteMaterial.ts +0 -247
  57. package/runtime/sdk/compile/sceneEditor.ts +0 -78
  58. package/runtime/sdk/compile/serverSplit.ts +0 -233
  59. package/runtime/sdk/compile/serverTypes.ts +0 -227
  60. package/runtime/sdk/compile/sfnt.ts +0 -98
  61. package/runtime/sdk/compile/shaderTargets.ts +0 -42
  62. package/runtime/sdk/compile/sourcemap.ts +0 -25
@@ -39,7 +39,6 @@ import {
39
39
  type AspectEntry, type MakeEntry as SharedMakeEntry,
40
40
  } from "./grammar"
41
41
  import { InspectorUI, type InspectorEvent, type InspectorWidget } from "../core/InspectorUI"
42
- import type { ColorInput } from "../core/color"
43
42
  import type { Vec3Like } from "../math/vec"
44
43
  import { Scene, type SceneOptions } from "../gl/Scene"
45
44
  import { CAMERA_DEFAULTS } from "../gl/Camera"
@@ -47,11 +46,12 @@ import { CameraPlace } from "../gl/CameraPlace"
47
46
  import { Node } from "../gl/Node"
48
47
  import { Mesh } from "../gl/Mesh"
49
48
  import { Model } from "../gl/Model"
49
+ import { Physics } from "../gl/Physics"
50
+ import { Lightmap } from "../gl/Lightmap"
50
51
  import { Light, type SunOptions } from "../gl/Light"
51
- import { Material, type LitMaterialOptions, type MaterialColorOptions } from "../gl/Material"
52
+ import { assignMaterialDef, MaterialHandle, resolveMaterialDef, type MaterialDef } from "./material"
52
53
  import type { CylinderOptions, PlaneOptions, SphereOptions } from "../gl/Geometry"
53
- import { edgesMesh } from "../gl/scenarios"
54
- import { GizmoBuffer, withGizmoScope, type GizmoBatch } from "./gizmos"
54
+ import { GizmoBuffer, Gizmos, withGizmoScope, type GizmoBatch } from "./gizmos"
55
55
 
56
56
  // ---- the literal grammar (what the visual editor reads and writes) -----------
57
57
 
@@ -63,12 +63,9 @@ export type MeshDef =
63
63
 
64
64
  export type LightDef = { kind: "sun" } & SunOptions
65
65
 
66
- export type MaterialDef =
67
- | { lit: LitMaterialOptions }
68
- | { unlit: MaterialColorOptions }
69
- | { shadow: ColorInput }
70
- // An imported/shared Material instance — valid at runtime; the editor shows it read-only.
71
- | Material
66
+ // The material grammar (`{ lit }` / `{ unlit }` / `{ shadow }` / `{ shader, params }` / a
67
+ // material asset handle / a code instance) lives in ./material.ts — re-exported for callers.
68
+ export type { MaterialDef }
72
69
 
73
70
  // The grammar markers (`use`/`ref`/`make`) live in ./grammar.ts, shared with defineScene2d —
74
71
  // re-exported here so this module remains the one import site for scene-file machinery.
@@ -85,6 +82,10 @@ export type ModelOverrideDef = {
85
82
  eulerAngles?: Vec3Like
86
83
  scale?: Vec3Like | number
87
84
  visible?: boolean
85
+ /** Materials by primitive SLOT of this part (`0` for a single-material mesh; the editor lists
86
+ * the slots with their glTF material names): a material asset, an inline def, or a custom
87
+ * shader. Slots left out keep the glTF material. */
88
+ materials?: Record<number | string, MaterialDef>
88
89
  }
89
90
 
90
91
  /** Camera projection settings, shared by the `camera:` source block and the top-level `camera:`
@@ -142,6 +143,11 @@ export type SceneNodeDef = {
142
143
  editor?: { clip?: string, time?: number }
143
144
  castShadows?: boolean
144
145
  receiveShadows?: boolean
146
+ /** Baked lighting (a scene with `env.lightmap`): is this model/mesh node a lightmap STATIC — a
147
+ * receiver and an occluder in the bake, real-time shadow casting off once the bake applies?
148
+ * Default: static unless a `Physics` aspect moves the node (`dynamic` — Physics' default — or
149
+ * `kinematic`). Set it only to override that rule; prefab subtrees inherit the verdict. */
150
+ lightmap?: boolean
145
151
  // -- capabilities / hierarchy --
146
152
  aspects?: readonly AspectEntry<any>[]
147
153
  children?: Record<string, SceneNodeDef>
@@ -153,8 +159,21 @@ export type SceneCameraDef = CameraProjectionDef & {
153
159
  target?: Vec3Like
154
160
  }
155
161
 
162
+ /** `env.lightmap` — the level's baked lighting (see lightmap.md): the two files
163
+ * `lecodes lightmap bake` writes, plus how the bake is applied. Absent = real-time only. */
164
+ export type SceneLightmapDef = {
165
+ /** `asset('./assets/lightmap/lightmap.bake')` */
166
+ data: string
167
+ /** `asset('./assets/lightmap/lightmap.ktx2')` */
168
+ texture: string
169
+ /** 1 = baked sun shadows at full strength, 0 = ambient occlusion only. Default 1. */
170
+ sunStrength?: number
171
+ /** Multiplier on the ambient share in the shadow math (1 = filament's own darkness). Default 1. */
172
+ ambientScale?: number
173
+ }
174
+
156
175
  export type SceneDef = {
157
- env?: SceneOptions
176
+ env?: SceneOptions & { lightmap?: SceneLightmapDef }
158
177
  camera?: SceneCameraDef
159
178
  nodes?: Record<string, SceneNodeDef>
160
179
  }
@@ -265,6 +284,17 @@ const applyOverridesToRows = (rows: ModelPartRow[], overrides: Record<string, Mo
265
284
  if (o.eulerAngles) node.eulerAngles = o.eulerAngles
266
285
  if (o.scale !== undefined) node.scale = o.scale
267
286
  if (o.visible !== undefined) node.visible = o.visible
287
+ // slot materials load in like textures do (a shader package fetch) — the part keeps its glTF
288
+ // material until then
289
+ if (o.materials) {
290
+ for (const [ slot, def ] of Object.entries(o.materials)) {
291
+ const index = Number(slot)
292
+ if (!Number.isInteger(index) || index < 0) continue
293
+ void assignMaterialDef(node, index, def).catch((e) => {
294
+ console.warn(`[scene] material for "${path}" slot ${index}: ${e instanceof Error ? e.message : String(e)}`)
295
+ })
296
+ }
297
+ }
268
298
  }
269
299
  }
270
300
 
@@ -291,27 +321,42 @@ const attachHost = (parent: Node, def: SceneNodeDef, path: string): Node =>
291
321
 
292
322
  // ---- runtime -------------------------------------------------------------------
293
323
 
294
- const resolveMaterial = (def: MaterialDef): Material => {
295
- if (def instanceof Material) return def
296
- if ("lit" in def) return Material.lit(def.lit)
297
- if ("unlit" in def) return Material.unlit(def.unlit)
298
- return Material.shadow(def.shadow)
299
- }
300
-
301
- const createMesh = (def: MeshDef, material?: MaterialDef): Mesh => {
302
- const mat = material !== undefined ? resolveMaterial(material) : undefined
324
+ /** A mesh node resolves its material BEFORE it appears (a custom shader fetches its compiled
325
+ * package, textures decode) — the same contract a model has with its GLB. Handle users are
326
+ * tracked so the editor can re-assign them when the asset's shader changes. */
327
+ const createMesh = async (def: MeshDef, material?: MaterialDef): Promise<Mesh> => {
328
+ const mat = material !== undefined ? await resolveMaterialDef(material) : undefined
329
+ let mesh: Mesh
303
330
  switch (def.kind) {
304
- case "box": return Mesh.box({ size: def.size, material: mat })
305
- case "sphere": { const { kind: _k, ...opts } = def; return Mesh.sphere({ ...opts, material: mat }) }
306
- case "cylinder": { const { kind: _k, ...opts } = def; return Mesh.cylinder({ ...opts, material: mat }) }
307
- case "plane": { const { kind: _k, ...opts } = def; return Mesh.plane({ ...opts, material: mat }) }
331
+ case "box": mesh = Mesh.box({ size: def.size, material: mat }); break
332
+ case "sphere": { const { kind: _k, ...opts } = def; mesh = Mesh.sphere({ ...opts, material: mat }); break }
333
+ case "cylinder": { const { kind: _k, ...opts } = def; mesh = Mesh.cylinder({ ...opts, material: mat }); break }
334
+ case "plane": { const { kind: _k, ...opts } = def; mesh = Mesh.plane({ ...opts, material: mat }); break }
308
335
  }
336
+ if (material instanceof MaterialHandle) material._users.add({ node: mesh, slot: 0 })
337
+ return mesh
309
338
  }
310
339
 
311
- const createSource = (path: string, def: SceneNodeDef): Node | Promise<Node> => {
340
+ // ---- baked lighting (env.lightmap) ---------------------------------------------------------------
341
+ // Statics are derived, not declared: a model/mesh node bakes unless a Physics aspect moves it
342
+ // (`dynamic` — Physics' default — or `kinematic`); `lightmap: true | false` on the node overrides.
343
+ // Keys are node paths (prefab internals: `wrapper/inner`), so lightmap.bake stays readable and a
344
+ // rename honestly invalidates the rect. make() subtrees are code — they register themselves with
345
+ // Lightmap.add. Edit mode never bakes or applies (the editor shows real-time shadows).
346
+
347
+ type LightmapCtx = { prefix: string }
348
+
349
+ const movesByPhysics = (def: SceneNodeDef): boolean =>
350
+ (def.aspects ?? []).some((e) =>
351
+ (e.ctor as unknown) === Physics && ((e.props as { motion?: string } | undefined)?.motion ?? "dynamic") !== "static")
352
+
353
+ /** The static verdict for one node def (the editor's "Baked lighting" switch shows the same rule). */
354
+ export const isLightmapStatic = (def: SceneNodeDef): boolean => def.lightmap ?? !movesByPhysics(def)
355
+
356
+ const createSource = (path: string, def: SceneNodeDef, lightmap = false): Node | Promise<Node> => {
312
357
  const sources = [ def.mesh, def.model, def.light, def.make, def.prefab, def.camera ].filter((s) => s !== undefined).length
313
358
  if (sources > 1) throw new Error(`Scene node "${path}" declares more than one source (mesh/model/light/make/prefab/camera)`)
314
- if (def.model !== undefined) return Model.load(def.model)
359
+ if (def.model !== undefined) return Model.load(def.model, { lightmap })
315
360
  if (def.mesh !== undefined) return createMesh(def.mesh, def.material)
316
361
  if (def.light !== undefined) { const { kind: _k, ...opts } = def.light; return Light.sun(opts) }
317
362
  // make/prefab/camera/group nodes are plain wrappers — make/prefab subtrees mount under them
@@ -369,30 +414,23 @@ const isGroupDef = (def: SceneNodeDef): boolean =>
369
414
  def.mesh === undefined && def.model === undefined && def.light === undefined
370
415
  && def.make === undefined && def.prefab === undefined && def.camera === undefined
371
416
 
372
- /** Edit mode: give otherwise-invisible nodes (empties, cameras) an `__editor_marker` line-mesh
373
- * child so they render and pick in the viewport. The harness reads `_sceneMarker` to give the
374
- * node itself a box collider (markers are editor nodes — never raycast/placement targets). */
375
- const addEditorMarker = (node: Node, def: SceneNodeDef, scene: Scene): void => {
376
- let marker: Mesh
417
+ /** Edit mode: otherwise-invisible nodes (empties, `camera:` nodes) get an ANCHORED gizmo marker —
418
+ * an axis cross / a frustum in the node's local frame (scene/gizmos.ts) — so they show and pick
419
+ * in the viewport. Never scene content: nothing renders in Filament, nothing outlines, and the
420
+ * engine follows the node live, so the buffer is filled once and lives on the node
421
+ * (`_editorMarker`) — `_editorGizmos()` reads it off the handle's own def nodes, so a removed
422
+ * node's marker goes with its record and prefab / instance internals (not selectable) draw none. */
423
+ const addEditorMarker = (node: Node, def: SceneNodeDef): void => {
424
+ const buffer = new GizmoBuffer()
377
425
  if (def.camera !== undefined) {
378
- // wireframe frustum looking down local -Z + an "up" fin above the near rect
379
- const z = -0.55, w = 0.3, h = 0.21
380
- const segs: number[] = []
381
- for (const [ cx, cy ] of [ [ -w, -h ], [ w, -h ], [ w, h ], [ -w, h ] ] as const) segs.push(0, 0, 0, cx, cy, z)
382
- segs.push(-w, -h, z, w, -h, z, w, -h, z, w, h, z, w, h, z, -w, h, z, -w, h, z, -w, -h, z)
383
- segs.push(-0.12, h, z, 0, h + 0.14, z, 0, h + 0.14, z, 0.12, h, z)
384
- marker = edgesMesh(segs, "#cfd4dd")
385
- ;(node as { _sceneMarker?: string })._sceneMarker = "camera"
426
+ withGizmoScope(buffer, () => Gizmos.frustum(def.camera?.fov ?? CAMERA_DEFAULTS.fov, { node, color: "#cfd4dd" }))
386
427
  } else if (isGroupDef(def)) {
387
- const s = 0.3
388
- marker = edgesMesh([ -s, 0, 0, s, 0, 0, 0, -s, 0, 0, s, 0, 0, 0, -s, 0, 0, s ], "#8f96a3")
389
- ;(node as { _sceneMarker?: string })._sceneMarker = "empty"
428
+ withGizmoScope(buffer, () => Gizmos.cross([ 0, 0, 0 ], 0.3, { node, color: "#8f96a3" }))
390
429
  } else {
391
430
  return
392
431
  }
393
- marker.name = "__editor_marker"
394
- node.add(marker)
395
- scene.add(marker)
432
+ ;(node as { _editorMarker?: GizmoBuffer })._editorMarker = buffer
433
+ gizmoVersion++
396
434
  }
397
435
 
398
436
  /** The ACTIVE `CameraPlace` entry in THIS file's own defs (depth-first; prefab / instance internals
@@ -677,7 +715,7 @@ const prefabPartRows = (defs: Record<string, SceneNodeDef>, local: Record<string
677
715
  return out
678
716
  }
679
717
 
680
- const attachPrefab = async (wrapper: Node, path: string, def: SceneNodeDef, scene: Scene, stack: Set<SceneDef>): Promise<void> => {
718
+ const attachPrefab = async (wrapper: Node, path: string, def: SceneNodeDef, scene: Scene, stack: Set<SceneDef>, lm: LightmapCtx | null): Promise<void> => {
681
719
  const pdef = (def.prefab as { def?: SceneDef } | undefined)?.def
682
720
  if (!pdef || typeof pdef !== "object") {
683
721
  console.error(`[scene] "${path}".prefab is not a scene handle (import the .scene file's default export)`)
@@ -691,7 +729,7 @@ const attachPrefab = async (wrapper: Node, path: string, def: SceneNodeDef, scen
691
729
  }
692
730
  const local: Record<string, Node> = {}
693
731
  const localRuns: EditorRun[] = [] // discarded: instance internals render, but aren't editor-tracked
694
- await buildNodes(pdef.nodes ?? {}, wrapper, scene, local, localRuns, new Set(stack).add(pdef))
732
+ await buildNodes(pdef.nodes ?? {}, wrapper, scene, local, localRuns, new Set(stack).add(pdef), lm)
695
733
  const rows = prefabPartRows(pdef.nodes ?? {}, local, "", 0)
696
734
  ;(wrapper as { _prefabParts?: ModelPartRow[] })._prefabParts = rows
697
735
  if (def.overrides) applyOverridesToRows(rows, def.overrides)
@@ -703,6 +741,7 @@ const attachPrefab = async (wrapper: Node, path: string, def: SceneNodeDef, scen
703
741
  const buildNodes = async (
704
742
  defs: Record<string, SceneNodeDef>, parent: Node | null, scene: Scene,
705
743
  nodes: Record<string, Node>, editorRuns: EditorRun[], stack: Set<SceneDef>,
744
+ lm: LightmapCtx | null = null,
706
745
  ): Promise<void> => {
707
746
  const pending: { path: string, node: Node, def: SceneNodeDef }[] = []
708
747
 
@@ -714,15 +753,17 @@ const buildNodes = async (
714
753
  // the path is derived from def keys BEFORE any await, so it is deterministic even though
715
754
  // Promise.all makes build completion (and record insertion) order nondeterministic
716
755
  const path = parentPath === "" ? name : `${parentPath}/${name}`
717
- const node = await createSource(path, nd)
756
+ const lmStatic = lm !== null && isLightmapStatic(nd)
757
+ const node = await createSource(path, nd, lmStatic)
718
758
  // a `mount` def parents to an internal part of the parent asset — safe here: the parent's
719
759
  // model was awaited and attachPrefab completed before its children build
720
760
  if (parentNode) attachHost(parentNode, nd, path).add(node)
721
761
  // Draw-set membership is separate from parenting (see docs/3d/node.md) — every def node joins.
722
762
  scene.add(node)
723
763
  applyNode(node, name, nd) // engine-side name stays the bare sibling segment
724
- if (isEditMode()) addEditorMarker(node, nd, scene)
725
- if (nd.prefab !== undefined) await attachPrefab(node, path, nd, scene, stack)
764
+ if (lmStatic && (node instanceof Model || node instanceof Mesh)) Lightmap._register(node, lm!.prefix + path)
765
+ if (isEditMode()) addEditorMarker(node, nd)
766
+ if (nd.prefab !== undefined) await attachPrefab(node, path, nd, scene, stack, lmStatic ? { prefix: `${lm!.prefix}${path}/` } : null)
726
767
  pending.push({ path, node, def: nd })
727
768
  nodes[path] = node
728
769
  await Promise.all(Object.entries(nd.children ?? {}).map(([ childName, child ]) => build(childName, child, node, path)))
@@ -740,9 +781,13 @@ const buildNodes = async (
740
781
  }
741
782
 
742
783
  const instantiate = async (def: SceneDef, editorRuns: EditorRun[]): Promise<{ scene: Scene, nodes: Record<string, Node> }> => {
743
- const scene = new Scene(def.env)
784
+ const { lightmap, ...env } = def.env ?? {}
785
+ const scene = new Scene(env)
744
786
  const nodes: Record<string, Node> = {}
745
- await buildNodes(def.nodes ?? {}, null, scene, nodes, editorRuns, new Set([ def ]))
787
+ // baked lighting is a play-mode concern; the level owns the registry (a rebuild starts clean)
788
+ const lm: LightmapCtx | null = lightmap && !isEditMode() ? { prefix: "" } : null
789
+ if (lm) Lightmap.clear()
790
+ await buildNodes(def.nodes ?? {}, null, scene, nodes, editorRuns, new Set([ def ]), lm)
746
791
 
747
792
  // a camera NODE wins over the top-level `camera:` block; in play mode it keeps driving the view
748
793
  // (CameraRig), in edit mode it only seeds the editor's starting viewpoint. The PROJECTION applies
@@ -771,6 +816,12 @@ const instantiate = async (def: SceneDef, editorRuns: EditorRun[]): Promise<{ sc
771
816
  applyCameraProjection(scene, def.camera)
772
817
  }
773
818
 
819
+ // the level is complete: apply the bake — or, under `lecodes lightmap bake`, run it
820
+ if (lm && lightmap) {
821
+ await Lightmap.load(scene, { data: lightmap.data, texture: lightmap.texture },
822
+ { sunStrength: lightmap.sunStrength, ambientScale: lightmap.ambientScale })
823
+ }
824
+
774
825
  return { scene, nodes }
775
826
  }
776
827
 
@@ -896,7 +947,7 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
896
947
  if (parent) attachHost(parent, d, p).add(node)
897
948
  scene.add(node)
898
949
  applyNode(node, p.slice(p.lastIndexOf("/") + 1), d)
899
- if (isEditMode()) addEditorMarker(node, d, scene)
950
+ if (isEditMode()) addEditorMarker(node, d)
900
951
  attachAspects(node, p, d, nodes, scene, this._editorRuns)
901
952
  nodes[p] = node
902
953
  await Promise.all(Object.entries(d.children ?? {}).map(([ cn, cd ]) => build(`${p}/${cn}`, cd, node)))
@@ -928,6 +979,10 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
928
979
  const batches: GizmoBatch[] = []
929
980
  for (const run of this._editorRuns) for (const b of run.gizmos.batches) if (b.segments.length > 0) batches.push(b)
930
981
  for (const runs of foreignRuns.values()) for (const run of runs) for (const b of run.gizmos.batches) if (b.segments.length > 0) batches.push(b)
982
+ for (const node of Object.values(this._live?.nodes ?? {})) {
983
+ const marker = (node as { _editorMarker?: GizmoBuffer })._editorMarker
984
+ if (marker) for (const b of marker.batches) batches.push(b)
985
+ }
931
986
  return { version: gizmoVersion, batches }
932
987
  }
933
988
 
@@ -13,20 +13,33 @@
13
13
  // Every run starts with an EMPTY buffer — what a call draws is the whole picture (like
14
14
  // `generated.clear()`, only implicit). Outside any scope (play mode, hand-attached aspects, a call
15
15
  // after an `await` inside rebuild) the calls are no-ops: rebuild is synchronous by contract.
16
+ //
17
+ // ANCHORED gizmos (`{ node }` in the style): points are in that node's LOCAL frame and the engine
18
+ // follows the node live — through a gizmo drag, not only after it — with the node's scale
19
+ // stripped (an empty scaled ×10 for its children keeps a normal-sized cross). An anchored picture
20
+ // is the node's own: clicking it in the viewport selects the node, and it draws in the selection
21
+ // accent while selected. World-space calls (path lines) are never pickable.
16
22
 
17
23
  import { Mat4, type Mat4Like } from "../math/mat4"
18
24
  import type { Vec3Like } from "../math/vec"
19
25
 
26
+ /** The anchor of a gizmo call: any object with an engine entity id (a `Node`). */
27
+ export type GizmoAnchor = { readonly id: number }
28
+
20
29
  export type GizmoStyle = {
21
30
  /** CSS hex color (`#rgb` / `#rrggbb`); default a neutral light gray. */
22
31
  color?: string
23
32
  /** 0..1, default 1. */
24
33
  alpha?: number
34
+ /** Anchor: points are in this node's LOCAL frame (scale ignored); the lines follow the node
35
+ * live, are pickable (a click selects the node) and turn the selection accent when it is
36
+ * selected. Omit for world-space lines (not pickable). */
37
+ node?: GizmoAnchor | null
25
38
  }
26
39
 
27
- /** One color batch of world-space LINES (flat `[x,y,z, x,y,z]` per segment) — what a host hands
28
- * the engine. */
29
- export type GizmoBatch = { color: string, alpha: number, segments: number[] }
40
+ /** One color batch of LINES (flat `[x,y,z, x,y,z]` per segment) — what a host hands the engine.
41
+ * With `entityId` the segments are in that entity's local frame (see GizmoStyle.node). */
42
+ export type GizmoBatch = { color: string, alpha: number, segments: number[], entityId?: number }
30
43
 
31
44
  const DEFAULT_COLOR = "#cfd4dd"
32
45
 
@@ -44,10 +57,11 @@ export class GizmoBuffer {
44
57
  segments(style: GizmoStyle | undefined): number[] {
45
58
  const color = style?.color ?? DEFAULT_COLOR
46
59
  const alpha = Math.max(0, Math.min(1, style?.alpha ?? 1))
47
- const key = `${color}@${alpha}`
60
+ const entityId = style?.node?.id
61
+ const key = `${color}@${alpha}@${entityId ?? ""}`
48
62
  let b = this._byStyle.get(key)
49
63
  if (!b) {
50
- b = { color, alpha, segments: [] }
64
+ b = entityId ? { color, alpha, segments: [], entityId } : { color, alpha, segments: [] }
51
65
  this._byStyle.set(key, b)
52
66
  this.batches.push(b)
53
67
  }
@@ -102,15 +116,21 @@ export const Gizmos = {
102
116
  for (let i = 0; i < last; i++) out.push(...xyz(points[i]!), ...xyz(points[(i + 1) % n]!))
103
117
  },
104
118
 
105
- /** A wireframe camera frustum looking down a node's local −Z (the `CameraPlace` marker): the
106
- * four edges from the node origin to a far rect `length` metres ahead sized by `fov` (vertical,
107
- * degrees) × `aspect`, plus an "up" fin above the rect. Points are transformed by `worldMatrix`. */
108
- frustum(worldMatrix: Mat4Like, fov: number, aspect = 16 / 9, length = 0.6, style?: GizmoStyle): void {
119
+ /** A wireframe camera frustum looking down −Z: the four edges from the origin to a rect
120
+ * `length` metres ahead sized by `fov` (vertical, degrees) × `aspect` (default 16:9), plus an
121
+ * "up" fin above the rect. Anchor it (`{ node }`) for a camera node's marker — the frustum
122
+ * then follows the node's pose; `matrix` places an unanchored one in world space. */
123
+ frustum(fov: number, style?: GizmoStyle & { aspect?: number, length?: number, matrix?: Mat4Like }): void {
109
124
  const out = target(style)
110
125
  if (!out) return
111
- const m = new Mat4(worldMatrix)
126
+ const aspect = style?.aspect ?? 16 / 9, length = style?.length ?? 0.6
127
+ const m = style?.matrix ? new Mat4(style.matrix) : null
112
128
  const h = Math.tan((fov * Math.PI) / 360) * length, w = h * aspect, z = -length
113
- const P = (x: number, y: number, zz: number): [number, number, number] => { const v = m.transformPoint([ x, y, zz ]); return [ v.x, v.y, v.z ] }
129
+ const P = (x: number, y: number, zz: number): [number, number, number] => {
130
+ if (!m) return [ x, y, zz ]
131
+ const v = m.transformPoint([ x, y, zz ])
132
+ return [ v.x, v.y, v.z ]
133
+ }
114
134
  const o = P(0, 0, 0)
115
135
  const c = [ P(-w, -h, z), P(w, -h, z), P(w, h, z), P(-w, h, z) ]
116
136
  for (const q of c) out.push(...o, ...q)
@@ -0,0 +1,188 @@
1
+ // Materials as data — the `material:` grammar of scene files and the `defineMaterial` document
2
+ // behind `*.material.ts` assets (docs/scene-materials-plan.md).
3
+ //
4
+ // // materials/holo-glass.material.ts
5
+ // export default defineMaterial({
6
+ // shader: asset('../assets/shaders/lens.mat'),
7
+ // params: { tint: '#1d2b26', opacity: 0.22, mask: asset('../assets/weapons/reticle.png') },
8
+ // })
9
+ //
10
+ // // range.scene.ts
11
+ // import holoGlass from './materials/holo-glass.material'
12
+ // lens: { mesh: { kind: 'plane' }, material: holoGlass },
13
+ //
14
+ // One grammar serves the inline form (`material: { lit: {…} }` / `{ shader, params }` on a node),
15
+ // the asset form (the handle) and GLB-part overrides. A handle is ONE shared GPU material: every
16
+ // node using the asset renders the same instance, and editing the asset changes all of them
17
+ // (Unity's rule — two looks are two assets).
18
+ //
19
+ // Parameter values need no shader metadata at runtime: a string is a colour when it starts with
20
+ // `#`, otherwise it is a texture URL (`asset('./x.png')` compiles to one) — the same lowering
21
+ // `Material.set` already applies to colours.
22
+
23
+ import type { ColorInput } from "../core/color"
24
+ import { Material, type LitMaterialOptions, type MaterialColorOptions } from "../gl/Material"
25
+ import { Texture } from "../gl/Texture"
26
+ import type { Node } from "../gl/Node"
27
+ import { EDIT_FLAG } from "./grammar"
28
+
29
+ /** A shader parameter value as scene files write it: number, boolean, `#colour`, texture URL
30
+ * (`asset('./x.png')`), or a numeric vector. */
31
+ export type MaterialParamValue = number | boolean | string | number[]
32
+
33
+ type WithMapUrl<T> = Omit<T, "map"> & {
34
+ /** Base-colour texture — a `Texture`, a `Canvas`, or `asset('./x.png')` (a URL string). */
35
+ map?: LitMaterialOptions["map"] | string
36
+ }
37
+
38
+ export type LitMaterialDef = WithMapUrl<LitMaterialOptions>
39
+ export type UnlitMaterialDef = WithMapUrl<MaterialColorOptions>
40
+
41
+ /** A custom Filament shader (`asset('./x.mat')` — compiled by the platform / `lecodes shaders`)
42
+ * plus its parameter values. */
43
+ export type ShaderMaterialDef = {
44
+ shader: string
45
+ params?: Record<string, MaterialParamValue>
46
+ }
47
+
48
+ export type MaterialDef =
49
+ | { lit: LitMaterialDef }
50
+ | { unlit: UnlitMaterialDef }
51
+ | { shadow: ColorInput }
52
+ | ShaderMaterialDef
53
+ /** A material asset (`import m from './x.material'`) — one shared instance. */
54
+ | MaterialHandle
55
+ /** A code-level Material instance — valid at runtime; the editor shows it read-only. */
56
+ | Material
57
+
58
+ /** The data forms of MaterialDef (everything but a live instance / handle). */
59
+ export type MaterialData = Exclude<MaterialDef, MaterialHandle | Material>
60
+
61
+ const isTextureUrl = (v: unknown): v is string => typeof v === "string" && !v.startsWith("#")
62
+
63
+ /** Push one parameter into a live material. Textures load asynchronously — the returned promise
64
+ * settles once the value is applied (immediately for scalars). */
65
+ export const applyMaterialParam = (material: Material, name: string, value: MaterialParamValue | null): Promise<void> => {
66
+ if (isTextureUrl(value)) {
67
+ return Texture.load(value).then((tex) => { material.set(name, tex) }, (e) => {
68
+ console.warn(`[material] "${name}": ${e instanceof Error ? e.message : String(e)}`)
69
+ })
70
+ }
71
+ material.set(name, value as never)
72
+ return Promise.resolve()
73
+ }
74
+
75
+ /** Build a live Material from a data def. Resolves once every texture it names has loaded. */
76
+ export const createMaterialFromData = async (def: MaterialData): Promise<Material> => {
77
+ if ("shader" in def) {
78
+ const m = await Material.load(def.shader)
79
+ await Promise.all(Object.entries(def.params ?? {}).map(([ k, v ]) => applyMaterialParam(m, k, v)))
80
+ return m
81
+ }
82
+ if ("shadow" in def) return Material.shadow(def.shadow)
83
+ const lit = "lit" in def
84
+ const opts = lit ? def.lit : def.unlit
85
+ const { map, ...rest } = opts
86
+ const m = lit ? Material.lit(rest as LitMaterialOptions) : Material.unlit(rest as MaterialColorOptions)
87
+ if (isTextureUrl(map)) m.map = await Texture.load(map)
88
+ else if (map !== undefined) m.map = map as LitMaterialOptions["map"] as Texture | null
89
+ return m
90
+ }
91
+
92
+ /**
93
+ * A material asset: the default export of a `*.material.ts` file. `load()` yields ONE shared
94
+ * `Material` (cached); scene files hand the handle to `material:` and the loader resolves it.
95
+ * The editor edits the asset live through `setParam` / `reset` — every node the handle was
96
+ * applied to follows (`_users`).
97
+ */
98
+ export class MaterialHandle {
99
+ def: MaterialData
100
+ private _instance: Material | null = null
101
+ private _loading: Promise<Material> | null = null
102
+ /** Every (node, slot) this handle was applied to through a scene — the editor re-assigns them
103
+ * when the asset's shader changes. */
104
+ readonly _users = new Set<{ node: Node, slot: number }>()
105
+
106
+ constructor(def: MaterialData) {
107
+ this.def = def
108
+ }
109
+
110
+ /** The shared material instance (built on first use). */
111
+ load(): Promise<Material> {
112
+ if (this._instance) return Promise.resolve(this._instance)
113
+ this._loading ??= createMaterialFromData(this.def).then((m) => { this._instance = m; return m })
114
+ return this._loading
115
+ }
116
+
117
+ /** The live instance, if built yet. */
118
+ get material(): Material | null { return this._instance }
119
+
120
+ /** Editor: assign this material to `node`'s `slot`, remembering the user. */
121
+ async _applyTo(node: Node, slot: number): Promise<Material> {
122
+ const m = await this.load()
123
+ node.setMaterial(m, slot)
124
+ this._users.add({ node, slot })
125
+ return m
126
+ }
127
+
128
+ /** Editor: one parameter value, applied live to the shared instance (a texture loads in). */
129
+ setParam(name: string, value: MaterialParamValue | null): void {
130
+ const def = this.def
131
+ if ("shader" in def) {
132
+ def.params ??= {}
133
+ if (value === null) delete def.params[name]
134
+ else def.params[name] = value
135
+ } else if ("lit" in def || "unlit" in def) {
136
+ const opts = ("lit" in def ? def.lit : def.unlit) as Record<string, unknown>
137
+ if (value === null) delete opts[name]
138
+ else opts[name] = value
139
+ }
140
+ const m = this._instance
141
+ if (!m) return
142
+ if (!("shader" in def) && name === "color") { m.color = value as string; return }
143
+ if (!("shader" in def) && name === "map") {
144
+ if (isTextureUrl(value)) void Texture.load(value).then((t) => { m.map = t })
145
+ else m.map = null
146
+ return
147
+ }
148
+ void applyMaterialParam(m, name, value)
149
+ }
150
+
151
+ /** Editor: replace the whole definition (a shader/kind switch) — rebuilds the instance and
152
+ * re-assigns every user. */
153
+ async reset(def: MaterialData): Promise<Material> {
154
+ this.def = def
155
+ this._instance = null
156
+ this._loading = null
157
+ const m = await this.load()
158
+ for (const u of this._users) u.node.setMaterial(m, u.slot)
159
+ return m
160
+ }
161
+ }
162
+
163
+ /** Resolve any `material:` value to a live Material (a handle's shared instance, a data def's
164
+ * fresh one, a code instance as is). */
165
+ export const resolveMaterialDef = (def: MaterialDef): Promise<Material> => {
166
+ if (def instanceof Material) return Promise.resolve(def)
167
+ if (def instanceof MaterialHandle) return def.load()
168
+ return createMaterialFromData(def)
169
+ }
170
+
171
+ /** Resolve and assign a `material:` value to one slot of a node (tracking handle users). */
172
+ export const assignMaterialDef = async (node: Node, slot: number, def: MaterialDef): Promise<Material> => {
173
+ if (def instanceof MaterialHandle) return def._applyTo(node, slot)
174
+ const m = await resolveMaterialDef(def)
175
+ node.setMaterial(m, slot)
176
+ return m
177
+ }
178
+
179
+ /**
180
+ * Define a material as data — the default export of a `*.material.ts` file. Import it into scene
181
+ * files (`material: handle`) or code (`const m = await handle.load()`).
182
+ */
183
+ export const defineMaterial = (def: MaterialData): MaterialHandle => {
184
+ const handle = new MaterialHandle(def)
185
+ const g = globalThis as unknown as { [EDIT_FLAG]?: boolean, __lecodesMaterialHandles?: MaterialHandle[] }
186
+ if (g[EDIT_FLAG]) (g.__lecodesMaterialHandles ??= []).push(handle)
187
+ return handle
188
+ }