lecodes-cli 0.18.2 → 0.19.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.
Files changed (57) hide show
  1. package/dist/index.js +1240 -170
  2. package/package.json +3 -2
  3. package/runtime/scene-harness.json +1 -1
  4. package/runtime/sdk/compile/aspectMacro.ts +86 -0
  5. package/runtime/sdk/compile/assetIconMacro.ts +384 -0
  6. package/runtime/sdk/compile/assetMacro.ts +146 -0
  7. package/runtime/sdk/compile/assetName.ts +50 -0
  8. package/runtime/sdk/compile/bundler.ts +287 -0
  9. package/runtime/sdk/compile/compileProject.ts +129 -0
  10. package/runtime/sdk/compile/detectEntry.ts +128 -0
  11. package/runtime/sdk/compile/fontMacro.ts +459 -0
  12. package/runtime/sdk/compile/fontRegistry.ts +78 -0
  13. package/runtime/sdk/compile/header.ts +67 -0
  14. package/runtime/sdk/compile/index.ts +100 -0
  15. package/runtime/sdk/compile/libraryImports.ts +52 -0
  16. package/runtime/sdk/compile/liteMaterial.ts +247 -0
  17. package/runtime/sdk/compile/sceneEditor.ts +88 -0
  18. package/runtime/sdk/compile/serverSplit.ts +233 -0
  19. package/runtime/sdk/compile/serverTypes.ts +227 -0
  20. package/runtime/sdk/compile/sfnt.ts +98 -0
  21. package/runtime/sdk/compile/shaderSchema.ts +202 -0
  22. package/runtime/sdk/compile/shaderTargets.ts +81 -0
  23. package/runtime/sdk/compile/sourcemap.ts +25 -0
  24. package/runtime/sdk/core/Aspect.ts +65 -9
  25. package/runtime/sdk/core/StateMachine.ts +308 -0
  26. package/runtime/sdk/core/time.ts +3 -0
  27. package/runtime/sdk/gl/CameraPlace.ts +52 -52
  28. package/runtime/sdk/gl/Light.ts +12 -4
  29. package/runtime/sdk/gl/Lightmap.ts +87 -10
  30. package/runtime/sdk/gl/Material.ts +15 -0
  31. package/runtime/sdk/gl/Model.ts +68 -8
  32. package/runtime/sdk/gl/NavAgent.ts +337 -0
  33. package/runtime/sdk/gl/NavMesh.ts +397 -0
  34. package/runtime/sdk/gl/Ragdoll.ts +270 -0
  35. package/runtime/sdk/gl/Scene.ts +7 -0
  36. package/runtime/sdk/gl/Shape.ts +37 -5
  37. package/runtime/sdk/gl/Terrain.ts +1092 -0
  38. package/runtime/sdk/gl/Texture.ts +17 -0
  39. package/runtime/sdk/gl/Trigger.ts +45 -45
  40. package/runtime/sdk/gl/animation/Animator.ts +35 -2
  41. package/runtime/sdk/gl/animation/Layer.ts +3 -1
  42. package/runtime/sdk/gl/animation/Loop.ts +5 -0
  43. package/runtime/sdk/gl/animation/Playback.ts +4 -3
  44. package/runtime/sdk/gl/animation/core.ts +52 -10
  45. package/runtime/sdk/gl/scenarios.ts +291 -291
  46. package/runtime/sdk/gl/state.ts +6 -6
  47. package/runtime/sdk/gl/terrainMesh.ts +219 -0
  48. package/runtime/sdk/inject.ts +12 -1
  49. package/runtime/sdk/net/codec.ts +119 -0
  50. package/runtime/sdk/net/core.ts +384 -0
  51. package/runtime/sdk/net/index.ts +181 -0
  52. package/runtime/sdk/net/replication.ts +622 -0
  53. package/runtime/sdk/scene/defineScene.ts +140 -14
  54. package/runtime/sdk/scene/editorPlugins.ts +18 -0
  55. package/runtime/sdk/scene/gizmos.ts +148 -148
  56. package/runtime/sdk/ui/UINode.ts +4 -0
  57. package/runtime/sdk-types.json +1 -1
@@ -48,6 +48,8 @@ import { Mesh } from "../gl/Mesh"
48
48
  import { Model } from "../gl/Model"
49
49
  import { Physics } from "../gl/Physics"
50
50
  import { Lightmap } from "../gl/Lightmap"
51
+ import { NavMesh } from "../gl/NavMesh"
52
+ import { Terrain, type TerrainLayer, type TerrainRegion, type TerrainOptions } from "../gl/Terrain"
51
53
  import { Light, type SunOptions } from "../gl/Light"
52
54
  import { assignMaterialDef, MaterialHandle, resolveMaterialDef, type MaterialDef } from "./material"
53
55
  import type { CylinderOptions, PlaneOptions, SphereOptions } from "../gl/Geometry"
@@ -63,6 +65,34 @@ export type MeshDef =
63
65
 
64
66
  export type LightDef = { kind: "sun" } & SunOptions
65
67
 
68
+ /** A stamp: the terrain conforms to another node of this file (a road) at load — see Terrain.conform. */
69
+ export type TerrainStampDef = {
70
+ /** Path of the node (a Mesh, a Model, or a node holding them) the ground hugs. */
71
+ node: string
72
+ offset?: number
73
+ falloff?: number
74
+ mode?: "both" | "lower" | "raise"
75
+ }
76
+
77
+ /** The `terrain: {}` source block (docs/terrain-plan.md §3.3): a `.terrain` file OR a flat grid to
78
+ * generate from, its layers, and the stamps / holes applied at load (in order, after every node of the
79
+ * file exists — a road built by `make()` is a valid target). Add `Shape { heightfield: true }` +
80
+ * `Physics { motion: 'static' }` in `aspects` for collision. */
81
+ export type TerrainNodeDef = {
82
+ /** `asset('./assets/terrain/valley.terrain')` — heights / holes / control from a file. */
83
+ data?: string
84
+ size?: number
85
+ sizeX?: number
86
+ sizeZ?: number
87
+ cellSize?: number
88
+ chunk?: number
89
+ layers?: TerrainLayer[]
90
+ normals?: TerrainOptions["normals"]
91
+ tint?: TerrainOptions["tint"]
92
+ stamps?: TerrainStampDef[]
93
+ holes?: TerrainRegion[]
94
+ }
95
+
66
96
  // The material grammar (`{ lit }` / `{ unlit }` / `{ shadow }` / `{ shader, params }` / a
67
97
  // material asset handle / a code instance) lives in ./material.ts — re-exported for callers.
68
98
  export type { MaterialDef }
@@ -108,6 +138,8 @@ export type SceneNodeDef = {
108
138
  /** GLB url — `asset('./hero.glb')`. */
109
139
  model?: string
110
140
  light?: LightDef
141
+ /** A heightmap ground — see {@link TerrainNodeDef}. */
142
+ terrain?: TerrainNodeDef
111
143
  /** The scene camera as a NODE: in play mode `scene.camera` follows this node's world transform
112
144
  * every frame (so movement aspects on it are camera flythroughs); the editor shows a frustum
113
145
  * marker and refuses to delete the last camera node. The first camera node in file order wins;
@@ -141,6 +173,8 @@ export type SceneNodeDef = {
141
173
  * (`time` freezes it at that second instead), so attachments / sight lines / a first-person eye
142
174
  * are placed against the animated pose, not the rest pose. Never applied when the scene runs. */
143
175
  editor?: { clip?: string, time?: number }
176
+ /** `mesh` / `model` nodes: real-time shadow flags. On a `model` they reach every renderable of
177
+ * the GLB instance, so `castShadows: false` on a first-person viewmodel covers the whole gun. */
144
178
  castShadows?: boolean
145
179
  receiveShadows?: boolean
146
180
  /** Baked lighting (a scene with `env.lightmap`): is this model/mesh node a lightmap STATIC — a
@@ -148,6 +182,12 @@ export type SceneNodeDef = {
148
182
  * Default: static unless a `Physics` aspect moves the node (`dynamic` — Physics' default — or
149
183
  * `kinematic`). Set it only to override that rule; prefab subtrees inherit the verdict. */
150
184
  lightmap?: boolean
185
+ /** Navigation (a scene with `env.navmesh`, see navmesh.md): every STATIC body is walkable by
186
+ * default. `false` leaves this one out of the bake; `'unwalkable'` cuts its footprint out
187
+ * (nobody stands on or crosses it). */
188
+ nav?: false | "unwalkable"
189
+ /** The named navmesh area this static body's surface belongs to (a key of `env.navmesh.areas`). */
190
+ navArea?: string
151
191
  // -- capabilities / hierarchy --
152
192
  aspects?: readonly AspectEntry<any>[]
153
193
  children?: Record<string, SceneNodeDef>
@@ -166,14 +206,29 @@ export type SceneLightmapDef = {
166
206
  data: string
167
207
  /** `asset('./assets/lightmap/lightmap.ktx2')` */
168
208
  texture: string
209
+ /** `asset('./assets/lightmap/lightmap.volume')` — the light VOLUME for everything that moves (the
210
+ * same bake writes it): dynamic nodes, and every model loaded in code while the level runs, read
211
+ * the baked sun/ambient at their own position. Absent = movers stay real-time only. */
212
+ volume?: string
169
213
  /** 1 = baked sun shadows at full strength, 0 = ambient occlusion only. Default 1. */
170
214
  sunStrength?: number
171
215
  /** Multiplier on the ambient share in the shadow math (1 = filament's own darkness). Default 1. */
172
216
  ambientScale?: number
173
217
  }
174
218
 
219
+ /** `env.navmesh` — the level's navigation mesh (see navmesh.md): the file `lecodes navmesh bake`
220
+ * writes, the agent size it is built for, and the named areas. Absent = no navigation. */
221
+ export type SceneNavmeshDef = {
222
+ /** `asset('./assets/nav/<scene>.navmesh')` */
223
+ data: string
224
+ /** The agent the mesh is built for (radius 0.4 · height 1.8 · climb 0.4 · slope 50 by default). */
225
+ agent?: { radius?: number, height?: number, climb?: number, slope?: number }
226
+ /** Named areas → traversal cost (1 = plain ground); nodes join one with `navArea`. */
227
+ areas?: Record<string, number>
228
+ }
229
+
175
230
  export type SceneDef = {
176
- env?: SceneOptions & { lightmap?: SceneLightmapDef }
231
+ env?: SceneOptions & { lightmap?: SceneLightmapDef, navmesh?: SceneNavmeshDef }
177
232
  camera?: SceneCameraDef
178
233
  nodes?: Record<string, SceneNodeDef>
179
234
  }
@@ -344,7 +399,7 @@ const createMesh = async (def: MeshDef, material?: MaterialDef): Promise<Mesh> =
344
399
  // rename honestly invalidates the rect. make() subtrees are code — they register themselves with
345
400
  // Lightmap.add. Edit mode never bakes or applies (the editor shows real-time shadows).
346
401
 
347
- type LightmapCtx = { prefix: string }
402
+ type LightmapCtx = { prefix: string, volume: boolean, dynamicOnly?: boolean }
348
403
 
349
404
  const movesByPhysics = (def: SceneNodeDef): boolean =>
350
405
  (def.aspects ?? []).some((e) =>
@@ -353,11 +408,23 @@ const movesByPhysics = (def: SceneNodeDef): boolean =>
353
408
  /** The static verdict for one node def (the editor's "Baked lighting" switch shows the same rule). */
354
409
  export const isLightmapStatic = (def: SceneNodeDef): boolean => def.lightmap ?? !movesByPhysics(def)
355
410
 
356
- const createSource = (path: string, def: SceneNodeDef, lightmap = false): Node | Promise<Node> => {
357
- const sources = [ def.mesh, def.model, def.light, def.make, def.prefab, def.camera ].filter((s) => s !== undefined).length
358
- if (sources > 1) throw new Error(`Scene node "${path}" declares more than one source (mesh/model/light/make/prefab/camera)`)
411
+ // `lightmap` undefined = Model.load's own default ('dynamic' while a level with a light volume runs —
412
+ // this is how a weapon or arms scene instantiated from code reads the volume; an explicit false would win)
413
+ /** A terrain node: from its file or a fresh flat grid; textures load before the node appears (like a
414
+ * mesh's material). Stamps and holes apply later in buildNodes, once every target node exists. */
415
+ const createTerrain = async (def: TerrainNodeDef): Promise<Node> => {
416
+ const { data, stamps: _s, holes: _h, ...opts } = def
417
+ const t = data !== undefined ? await Terrain.load(data, opts) : Terrain.create(opts)
418
+ await t.ready
419
+ return t.node
420
+ }
421
+
422
+ const createSource = (path: string, def: SceneNodeDef, lightmap: boolean | "dynamic" | undefined): Node | Promise<Node> => {
423
+ const sources = [ def.mesh, def.model, def.light, def.make, def.prefab, def.camera, def.terrain ].filter((s) => s !== undefined).length
424
+ if (sources > 1) throw new Error(`Scene node "${path}" declares more than one source (mesh/model/light/make/prefab/camera/terrain)`)
359
425
  if (def.model !== undefined) return Model.load(def.model, { lightmap })
360
426
  if (def.mesh !== undefined) return createMesh(def.mesh, def.material)
427
+ if (def.terrain !== undefined) return createTerrain(def.terrain)
361
428
  if (def.light !== undefined) { const { kind: _k, ...opts } = def.light; return Light.sun(opts) }
362
429
  // make/prefab/camera/group nodes are plain wrappers — make/prefab subtrees mount under them
363
430
  // during the build (attachPrefab) or in phase 2 (attachMake); a camera node drives scene.camera
@@ -400,9 +467,15 @@ const applyNode = (node: Node, name: string, def: SceneNodeDef): void => {
400
467
  // waypoint/def order for aspects that read children (FollowPath) — the engine's live child
401
468
  // order is insertion-based and may not match the file
402
469
  if (def.children) (node as { _sceneChildOrder?: string[] })._sceneChildOrder = Object.keys(def.children)
403
- if (node instanceof Mesh) {
470
+ // shadow flags: a Mesh has one renderable, a Model a whole GLB instance (Model routes the pair
471
+ // through setGlbShadows) — every other node kind is transform-only and carries none
472
+ if (node instanceof Mesh || node instanceof Model) {
404
473
  if (def.castShadows !== undefined) node.castShadows = def.castShadows
405
474
  if (def.receiveShadows !== undefined) node.receiveShadows = def.receiveShadows
475
+ } else if (def.terrain !== undefined) {
476
+ const t = Terrain.of(node)
477
+ if (t && def.castShadows !== undefined) t.castShadows = def.castShadows
478
+ if (t && def.receiveShadows !== undefined) t.receiveShadows = def.receiveShadows
406
479
  }
407
480
  if (def.overrides && def.model !== undefined) applyModelOverrides(node, def.overrides)
408
481
  }
@@ -412,7 +485,7 @@ const applyNode = (node: Node, name: string, def: SceneNodeDef): void => {
412
485
  /** True for a def with no source block at all — a plain group ("Empty" in the editor). */
413
486
  const isGroupDef = (def: SceneNodeDef): boolean =>
414
487
  def.mesh === undefined && def.model === undefined && def.light === undefined
415
- && def.make === undefined && def.prefab === undefined && def.camera === undefined
488
+ && def.make === undefined && def.prefab === undefined && def.camera === undefined && def.terrain === undefined
416
489
 
417
490
  /** Edit mode: otherwise-invisible nodes (empties, `camera:` nodes) get an ANCHORED gizmo marker —
418
491
  * an axis cross / a frustum in the node's local frame (scene/gizmos.ts) — so they show and pick
@@ -753,8 +826,10 @@ const buildNodes = async (
753
826
  // the path is derived from def keys BEFORE any await, so it is deterministic even though
754
827
  // Promise.all makes build completion (and record insertion) order nondeterministic
755
828
  const path = parentPath === "" ? name : `${parentPath}/${name}`
756
- const lmStatic = lm !== null && isLightmapStatic(nd)
757
- const node = await createSource(path, nd, lmStatic)
829
+ const lmStatic = lm !== null && !lm.dynamicOnly && isLightmapStatic(nd)
830
+ // a mover in a level with a light volume reads the volume (models: the engine binds it; meshes: registered below)
831
+ const lmDynamic = lm !== null && !lmStatic && lm.volume
832
+ const node = await createSource(path, nd, lmStatic ? true : lmDynamic ? "dynamic" : undefined)
758
833
  // a `mount` def parents to an internal part of the parent asset — safe here: the parent's
759
834
  // model was awaited and attachPrefab completed before its children build
760
835
  if (parentNode) attachHost(parentNode, nd, path).add(node)
@@ -762,8 +837,11 @@ const buildNodes = async (
762
837
  scene.add(node)
763
838
  applyNode(node, name, nd) // engine-side name stays the bare sibling segment
764
839
  if (lmStatic && (node instanceof Model || node instanceof Mesh)) Lightmap._register(node, lm!.prefix + path)
840
+ else if (lmStatic && Terrain.of(node)) Lightmap._registerTerrain(Terrain.of(node)!, lm!.prefix + path)
841
+ else if (lmDynamic && node instanceof Mesh) Lightmap._registerDynamic(node)
765
842
  if (isEditMode()) addEditorMarker(node, nd)
766
- if (nd.prefab !== undefined) await attachPrefab(node, path, nd, scene, stack, lmStatic ? { prefix: `${lm!.prefix}${path}/` } : null)
843
+ // a prefab under a static wrapper bakes; under a mover its internals stay movers (volume-lit)
844
+ if (nd.prefab !== undefined) await attachPrefab(node, path, nd, scene, stack, lmStatic ? { prefix: `${lm!.prefix}${path}/`, volume: lm!.volume } : lm && lm.volume ? { prefix: `${lm.prefix}${path}/`, volume: true, dynamicOnly: true } : null)
767
845
  pending.push({ path, node, def: nd })
768
846
  nodes[path] = node
769
847
  await Promise.all(Object.entries(nd.children ?? {}).map(([ childName, child ]) => build(childName, child, node, path)))
@@ -778,16 +856,59 @@ const buildNodes = async (
778
856
  if (wait) makeWaits.push(wait)
779
857
  }
780
858
  if (makeWaits.length > 0) await Promise.all(makeWaits)
859
+
860
+ // terrain stamps + holes: every node of the file exists now (make() subtrees included), and a
861
+ // Shape { heightfield } attached above takes the edits through commit() (an in-place SetHeights)
862
+ for (const p of pending) {
863
+ const td = p.def.terrain
864
+ const t = td ? Terrain.of(p.node) : null
865
+ if (!td || !t || (!td.stamps?.length && !td.holes?.length)) continue
866
+ for (const s of td.stamps ?? []) {
867
+ const target = nodes[s.node]
868
+ if (!target) { console.warn(`[scene] "${p.path}".terrain stamp "${s.node}" matches no node — skipped`); continue }
869
+ t.conform(target, { offset: s.offset, falloff: s.falloff, mode: s.mode })
870
+ }
871
+ for (const h of td.holes ?? []) t.hole(h)
872
+ t.commit()
873
+ }
874
+ }
875
+
876
+ // ---- navigation (env.navmesh) ------------------------------------------------------------------
877
+ // Walkable = every STATIC Physics body (what the CharacterController collides with), derived like
878
+ // the lightmap statics — no per-node declaration. `nav: false | 'unwalkable'` and `navArea` are the
879
+ // per-node overrides, keyed by the node's entity for the bake (NavMesh.exclude / unwalkable / area).
880
+ // Prefab internals are not walked (v1): override them in the prefab's own file.
881
+
882
+ const forEachNodeDef = (defs: Record<string, SceneNodeDef> | undefined, prefix: string, fn: (path: string, def: SceneNodeDef) => void): void => {
883
+ for (const [ key, d ] of Object.entries(defs ?? {})) {
884
+ const path = prefix ? `${prefix}/${key}` : key
885
+ fn(path, d)
886
+ forEachNodeDef(d.children, path, fn)
887
+ }
888
+ }
889
+
890
+ const registerNavOverrides = (defs: Record<string, SceneNodeDef> | undefined, nodes: Record<string, Node>): void => {
891
+ NavMesh.clear()
892
+ forEachNodeDef(defs, "", (path, d) => {
893
+ const node = nodes[path]
894
+ if (!node) return
895
+ if (d.nav === false) NavMesh.exclude(node)
896
+ else if (d.nav === "unwalkable") NavMesh.unwalkable(node)
897
+ else if (d.navArea) NavMesh.area(node, d.navArea)
898
+ })
781
899
  }
782
900
 
783
901
  const instantiate = async (def: SceneDef, editorRuns: EditorRun[]): Promise<{ scene: Scene, nodes: Record<string, Node> }> => {
784
- const { lightmap, ...env } = def.env ?? {}
902
+ const { lightmap, navmesh, ...env } = def.env ?? {}
785
903
  const scene = new Scene(env)
786
904
  const nodes: Record<string, Node> = {}
787
905
  // 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
906
+ const lm: LightmapCtx | null = lightmap && !isEditMode() ? { prefix: "", volume: !!lightmap.volume } : null
789
907
  if (lm) Lightmap.clear()
908
+ // with a light volume every model loaded from now on (weapons, spawns, make() subtrees) is a mover by default
909
+ if (lm) Model._lightmapDefault = lm.volume ? "dynamic" : false
790
910
  await buildNodes(def.nodes ?? {}, null, scene, nodes, editorRuns, new Set([ def ]), lm)
911
+ if (navmesh && !isEditMode()) registerNavOverrides(def.nodes, nodes)
791
912
 
792
913
  // a camera NODE wins over the top-level `camera:` block; in play mode it keeps driving the view
793
914
  // (CameraRig), in edit mode it only seeds the editor's starting viewpoint. The PROJECTION applies
@@ -818,9 +939,14 @@ const instantiate = async (def: SceneDef, editorRuns: EditorRun[]): Promise<{ sc
818
939
 
819
940
  // the level is complete: apply the bake — or, under `lecodes lightmap bake`, run it
820
941
  if (lm && lightmap) {
821
- await Lightmap.load(scene, { data: lightmap.data, texture: lightmap.texture },
942
+ await Lightmap.load(scene, { data: lightmap.data, texture: lightmap.texture, volume: lightmap.volume },
822
943
  { sunStrength: lightmap.sunStrength, ambientScale: lightmap.ambientScale })
823
944
  }
945
+ // navigation: load the baked mesh — or, under `lecodes navmesh bake`, dump the level's collision
946
+ // geometry for it (the static bodies exist by now; edit mode never loads it — v1)
947
+ if (navmesh && !isEditMode()) {
948
+ await NavMesh.load(scene, { data: navmesh.data, agent: navmesh.agent, areas: navmesh.areas })
949
+ }
824
950
 
825
951
  return { scene, nodes }
826
952
  }
@@ -943,7 +1069,7 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
943
1069
  if (parent) attachHost(parent, d, p).add(existing)
944
1070
  return existing
945
1071
  }
946
- const node = await createSource(p, d)
1072
+ const node = await createSource(p, d, undefined)
947
1073
  if (parent) attachHost(parent, d, p).add(node)
948
1074
  scene.add(node)
949
1075
  applyNode(node, p.slice(p.lastIndexOf("/") + 1), d)
@@ -52,6 +52,15 @@ export type EditorApi = {
52
52
  raycast(screenX: number, screenY: number): EditorRayHit | null
53
53
  /** Group every doc edit inside `fn` into ONE undo step. */
54
54
  transact(fn: () => void): void
55
+ /** Read one def prop of a node as document data (`asset()` refs come as `{ $asset: "./x" }`);
56
+ * undefined when the node or key doesn't exist. */
57
+ getProp(path: string, key: string): unknown
58
+ /** Write a BINARY asset file for a node (a terrain's `.terrain`, a baked mask…). `ref` = the
59
+ * node's existing def-relative `asset()` ref to overwrite, or null to create `./<name>` at the
60
+ * project root (a free name is picked on collisions). Resolves to the ref to store in the node's
61
+ * def (`{ $asset: ref }` via `setProp`), null when the host can't write files. Not a doc edit —
62
+ * it is not undoable; keep the doc pointing at the file. */
63
+ writeAsset(path: string, ref: string | null, name: string, bytes: Uint8Array): Promise<string | null>
55
64
  }
56
65
 
57
66
  export type EditorWindowFn = (ui: InspectorUI, editor: EditorApi) => void
@@ -65,6 +74,15 @@ export type EditorToolHooks = {
65
74
  /** A viewport click while the tool is active — `hit` is the raycast result under the pointer.
66
75
  * Viewport clicks route to the tool while it is active. */
67
76
  onViewportClick?(hit: EditorRayHit, editor: EditorApi): void
77
+ /** Drag tools (brushes): a primary-button drag in the viewport routes here instead of orbiting
78
+ * the camera — start / every pointer move (with the hit under it, null while off-world) /
79
+ * release. A tool with `onDragStart` gets NO `onViewportClick` for the same gesture. */
80
+ onDragStart?(hit: EditorRayHit, editor: EditorApi): void
81
+ onDrag?(hit: EditorRayHit | null, editor: EditorApi): void
82
+ onDragEnd?(editor: EditorApi): void
83
+ /** The pointer moved over the viewport with no button down (throttled to the frame) — draw a
84
+ * brush cursor through `Gizmos`; null when nothing is under it. */
85
+ onHover?(hit: EditorRayHit | null, editor: EditorApi): void
68
86
  }
69
87
 
70
88
  /** @internal The registry the scene-editor harness reads (windows/tools in registration order). */
@@ -1,148 +1,148 @@
1
- // Editor gizmos: immediate-mode line drawing for code that runs while a scene is EDITED — a
2
- // generator aspect's `rebuild()`, a `make()` factory, an editor tool's hooks. Nothing here touches
3
- // the scene: calls accumulate WORLD-space line segments into the buffer of the run that is
4
- // currently executing (an ambient scope the scene loader opens around each synchronous call), and
5
- // the scene editor pushes those buffers to the lite engine's overlay layer, which draws them over
6
- // the picture — never outlined, never picked, never rendered by Filament, absent in play mode.
7
- //
8
- // rebuild() {
9
- // Gizmos.polyline(points, { color: "#5b8ef0", closed: true })
10
- // for (const p of points) Gizmos.cross(p, 0.07)
11
- // }
12
- //
13
- // Every run starts with an EMPTY buffer — what a call draws is the whole picture (like
14
- // `generated.clear()`, only implicit). Outside any scope (play mode, hand-attached aspects, a call
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.
22
-
23
- import { Mat4, type Mat4Like } from "../math/mat4"
24
- import type { Vec3Like } from "../math/vec"
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
-
29
- export type GizmoStyle = {
30
- /** CSS hex color (`#rgb` / `#rrggbb`); default a neutral light gray. */
31
- color?: string
32
- /** 0..1, default 1. */
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
38
- }
39
-
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 }
43
-
44
- const DEFAULT_COLOR = "#cfd4dd"
45
-
46
- /** The per-run collector (owned by the scene loader's EditorRun). Batches are keyed by style so a
47
- * hundred same-colored segments cost one draw. */
48
- export class GizmoBuffer {
49
- batches: GizmoBatch[] = []
50
- private _byStyle = new Map<string, GizmoBatch>()
51
-
52
- clear(): void {
53
- this.batches = []
54
- this._byStyle.clear()
55
- }
56
-
57
- segments(style: GizmoStyle | undefined): number[] {
58
- const color = style?.color ?? DEFAULT_COLOR
59
- const alpha = Math.max(0, Math.min(1, style?.alpha ?? 1))
60
- const entityId = style?.node?.id
61
- const key = `${color}@${alpha}@${entityId ?? ""}`
62
- let b = this._byStyle.get(key)
63
- if (!b) {
64
- b = entityId ? { color, alpha, segments: [], entityId } : { color, alpha, segments: [] }
65
- this._byStyle.set(key, b)
66
- this.batches.push(b)
67
- }
68
- return b.segments
69
- }
70
- }
71
-
72
- let current: GizmoBuffer | null = null
73
- let warnedOutOfScope = false
74
-
75
- const xyz = (p: Vec3Like): [number, number, number] =>
76
- Array.isArray(p) ? [ p[0] ?? 0, p[1] ?? 0, p[2] ?? 0 ] : [ (p as { x: number }).x, (p as { y: number }).y, (p as { z: number }).z ]
77
-
78
- const target = (style: GizmoStyle | undefined): number[] | null => {
79
- if (current) return current.segments(style)
80
- if (!warnedOutOfScope && (globalThis as { __lecodesSceneEdit?: boolean }).__lecodesSceneEdit) {
81
- warnedOutOfScope = true
82
- console.warn("[Gizmos] draw call outside an editor run — gizmos must be drawn synchronously inside rebuild() / a make() factory / a tool hook")
83
- }
84
- return null
85
- }
86
-
87
- /** @internal Run `fn` with `buffer` as the ambient gizmo target (cleared first). Nested scopes
88
- * restore the outer one. */
89
- export const withGizmoScope = <T>(buffer: GizmoBuffer, fn: () => T): T => {
90
- const prev = current
91
- buffer.clear()
92
- current = buffer
93
- try { return fn() } finally { current = prev }
94
- }
95
-
96
- /**
97
- * Editor-only line drawing, available inside generator `rebuild()`, `make()` factories and editor
98
- * tool hooks. Points are WORLD space. Drawn by the scene editor's viewport overlay (depth-test
99
- * off — a path through a wall is still a path); invisible everywhere else.
100
- */
101
- export const Gizmos = {
102
- /** One segment from `a` to `b`. */
103
- line(a: Vec3Like, b: Vec3Like, style?: GizmoStyle): void {
104
- const out = target(style)
105
- if (!out) return
106
- out.push(...xyz(a), ...xyz(b))
107
- },
108
-
109
- /** Consecutive segments through `points`; `closed` joins the last point back to the first. */
110
- polyline(points: readonly Vec3Like[], style?: GizmoStyle & { closed?: boolean }): void {
111
- if (points.length < 2) return
112
- const out = target(style)
113
- if (!out) return
114
- const n = points.length
115
- const last = style?.closed ? n : n - 1
116
- for (let i = 0; i < last; i++) out.push(...xyz(points[i]!), ...xyz(points[(i + 1) % n]!))
117
- },
118
-
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 {
124
- const out = target(style)
125
- if (!out) return
126
- const aspect = style?.aspect ?? 16 / 9, length = style?.length ?? 0.6
127
- const m = style?.matrix ? new Mat4(style.matrix) : null
128
- const h = Math.tan((fov * Math.PI) / 360) * length, w = h * aspect, z = -length
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
- }
134
- const o = P(0, 0, 0)
135
- const c = [ P(-w, -h, z), P(w, -h, z), P(w, h, z), P(-w, h, z) ]
136
- for (const q of c) out.push(...o, ...q)
137
- for (let i = 0; i < 4; i++) out.push(...c[i]!, ...c[(i + 1) % 4]!)
138
- out.push(...P(-w * 0.4, h, z), ...P(0, h * 1.6, z), ...P(0, h * 1.6, z), ...P(w * 0.4, h, z))
139
- },
140
-
141
- /** A three-axis cross centred on `p` (a point marker); `size` = half extent, default 0.1. */
142
- cross(p: Vec3Like, size = 0.1, style?: GizmoStyle): void {
143
- const out = target(style)
144
- if (!out) return
145
- const [ x, y, z ] = xyz(p)
146
- out.push(x - size, y, z, x + size, y, z, x, y - size, z, x, y + size, z, x, y, z - size, x, y, z + size)
147
- },
148
- }
1
+ // Editor gizmos: immediate-mode line drawing for code that runs while a scene is EDITED — a
2
+ // generator aspect's `rebuild()`, a `make()` factory, an editor tool's hooks. Nothing here touches
3
+ // the scene: calls accumulate WORLD-space line segments into the buffer of the run that is
4
+ // currently executing (an ambient scope the scene loader opens around each synchronous call), and
5
+ // the scene editor pushes those buffers to the lite engine's overlay layer, which draws them over
6
+ // the picture — never outlined, never picked, never rendered by Filament, absent in play mode.
7
+ //
8
+ // rebuild() {
9
+ // Gizmos.polyline(points, { color: "#5b8ef0", closed: true })
10
+ // for (const p of points) Gizmos.cross(p, 0.07)
11
+ // }
12
+ //
13
+ // Every run starts with an EMPTY buffer — what a call draws is the whole picture (like
14
+ // `generated.clear()`, only implicit). Outside any scope (play mode, hand-attached aspects, a call
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.
22
+
23
+ import { Mat4, type Mat4Like } from "../math/mat4"
24
+ import type { Vec3Like } from "../math/vec"
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
+
29
+ export type GizmoStyle = {
30
+ /** CSS hex color (`#rgb` / `#rrggbb`); default a neutral light gray. */
31
+ color?: string
32
+ /** 0..1, default 1. */
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
38
+ }
39
+
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 }
43
+
44
+ const DEFAULT_COLOR = "#cfd4dd"
45
+
46
+ /** The per-run collector (owned by the scene loader's EditorRun). Batches are keyed by style so a
47
+ * hundred same-colored segments cost one draw. */
48
+ export class GizmoBuffer {
49
+ batches: GizmoBatch[] = []
50
+ private _byStyle = new Map<string, GizmoBatch>()
51
+
52
+ clear(): void {
53
+ this.batches = []
54
+ this._byStyle.clear()
55
+ }
56
+
57
+ segments(style: GizmoStyle | undefined): number[] {
58
+ const color = style?.color ?? DEFAULT_COLOR
59
+ const alpha = Math.max(0, Math.min(1, style?.alpha ?? 1))
60
+ const entityId = style?.node?.id
61
+ const key = `${color}@${alpha}@${entityId ?? ""}`
62
+ let b = this._byStyle.get(key)
63
+ if (!b) {
64
+ b = entityId ? { color, alpha, segments: [], entityId } : { color, alpha, segments: [] }
65
+ this._byStyle.set(key, b)
66
+ this.batches.push(b)
67
+ }
68
+ return b.segments
69
+ }
70
+ }
71
+
72
+ let current: GizmoBuffer | null = null
73
+ let warnedOutOfScope = false
74
+
75
+ const xyz = (p: Vec3Like): [number, number, number] =>
76
+ Array.isArray(p) ? [ p[0] ?? 0, p[1] ?? 0, p[2] ?? 0 ] : [ (p as { x: number }).x, (p as { y: number }).y, (p as { z: number }).z ]
77
+
78
+ const target = (style: GizmoStyle | undefined): number[] | null => {
79
+ if (current) return current.segments(style)
80
+ if (!warnedOutOfScope && (globalThis as { __lecodesSceneEdit?: boolean }).__lecodesSceneEdit) {
81
+ warnedOutOfScope = true
82
+ console.warn("[Gizmos] draw call outside an editor run — gizmos must be drawn synchronously inside rebuild() / a make() factory / a tool hook")
83
+ }
84
+ return null
85
+ }
86
+
87
+ /** @internal Run `fn` with `buffer` as the ambient gizmo target (cleared first). Nested scopes
88
+ * restore the outer one. */
89
+ export const withGizmoScope = <T>(buffer: GizmoBuffer, fn: () => T): T => {
90
+ const prev = current
91
+ buffer.clear()
92
+ current = buffer
93
+ try { return fn() } finally { current = prev }
94
+ }
95
+
96
+ /**
97
+ * Editor-only line drawing, available inside generator `rebuild()`, `make()` factories and editor
98
+ * tool hooks. Points are WORLD space. Drawn by the scene editor's viewport overlay (depth-test
99
+ * off — a path through a wall is still a path); invisible everywhere else.
100
+ */
101
+ export const Gizmos = {
102
+ /** One segment from `a` to `b`. */
103
+ line(a: Vec3Like, b: Vec3Like, style?: GizmoStyle): void {
104
+ const out = target(style)
105
+ if (!out) return
106
+ out.push(...xyz(a), ...xyz(b))
107
+ },
108
+
109
+ /** Consecutive segments through `points`; `closed` joins the last point back to the first. */
110
+ polyline(points: readonly Vec3Like[], style?: GizmoStyle & { closed?: boolean }): void {
111
+ if (points.length < 2) return
112
+ const out = target(style)
113
+ if (!out) return
114
+ const n = points.length
115
+ const last = style?.closed ? n : n - 1
116
+ for (let i = 0; i < last; i++) out.push(...xyz(points[i]!), ...xyz(points[(i + 1) % n]!))
117
+ },
118
+
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 {
124
+ const out = target(style)
125
+ if (!out) return
126
+ const aspect = style?.aspect ?? 16 / 9, length = style?.length ?? 0.6
127
+ const m = style?.matrix ? new Mat4(style.matrix) : null
128
+ const h = Math.tan((fov * Math.PI) / 360) * length, w = h * aspect, z = -length
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
+ }
134
+ const o = P(0, 0, 0)
135
+ const c = [ P(-w, -h, z), P(w, -h, z), P(w, h, z), P(-w, h, z) ]
136
+ for (const q of c) out.push(...o, ...q)
137
+ for (let i = 0; i < 4; i++) out.push(...c[i]!, ...c[(i + 1) % 4]!)
138
+ out.push(...P(-w * 0.4, h, z), ...P(0, h * 1.6, z), ...P(0, h * 1.6, z), ...P(w * 0.4, h, z))
139
+ },
140
+
141
+ /** A three-axis cross centred on `p` (a point marker); `size` = half extent, default 0.1. */
142
+ cross(p: Vec3Like, size = 0.1, style?: GizmoStyle): void {
143
+ const out = target(style)
144
+ if (!out) return
145
+ const [ x, y, z ] = xyz(p)
146
+ out.push(x - size, y, z, x + size, y, z, x, y - size, z, x, y + size, z, x, y, z - size, x, y, z + size)
147
+ },
148
+ }
@@ -26,6 +26,10 @@ export type Color = number | string | null
26
26
  export type BaseStyle = {
27
27
  opacity?: number | `${number}`,
28
28
  transform?: string | null,
29
+ /** Pivot of `transform` (CSS `transform-origin` subset): one or two values, each a keyword
30
+ * (`left`/`center`/`right`, `top`/`center`/`bottom`), a percentage of the box, or a px length
31
+ * (bare numbers are px). One value sets x, y stays `50%`. Default `"50% 50%"` = the centre. */
32
+ transformOrigin?: string | null,
29
33
  backgroundColor?: Color | null,
30
34
  bgColor?: Color | null,
31
35
  overflow?: "visible" | "hidden",