lecodes-cli 0.13.0 → 0.13.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.
@@ -17,31 +17,37 @@ import type { InspectorUI } from "../core/InspectorUI"
17
17
  export type EditorRayHit = {
18
18
  point: [number, number, number]
19
19
  normal: [number, number, number]
20
- /** The scene-file node the hit belongs to — null for ground-plane fallback hits. */
20
+ /** Absolute path of the scene-file node the hit belongs to ("city/in1/pt1") — null for
21
+ * ground-plane fallback hits. */
21
22
  node: string | null
22
23
  }
23
24
 
24
25
  /**
25
- * The editor scripting API handed to windows and tools. Doc-op methods write the scene DOCUMENT
26
- * through the editor's normal commit path (undo, file, live patching all included); they return
27
- * false / no-op when the document can't take the edit (duplicate name, unknown node).
26
+ * The editor scripting API handed to windows and tools. Nodes are addressed by ABSOLUTE PATH —
27
+ * '/'-joined names from the scene root; a root node's path is its bare name. Doc-op methods write
28
+ * the scene DOCUMENT through the editor's normal commit path (undo, file, live patching all
29
+ * included); they return false / no-op when the document can't take the edit (sibling-name
30
+ * collision, unknown path).
28
31
  */
29
32
  export type EditorApi = {
30
- /** The currently selected node name (or `model::part` key), null when nothing is selected. */
33
+ /** The currently selected node path (or `path::part` key for asset internals), null when
34
+ * nothing is selected. */
31
35
  readonly selection: string | null
32
- select(name: string | null): void
33
- /** The scene document's nodes (name + source kind: mesh / model / light / group). */
34
- nodes(): { name: string, kind: string }[]
35
- /** First unused "base", "base2", "base3", … node name. */
36
- uniqueName(base: string): string
37
- /** Add a node from plain def data (scene-file grammar; a string `model` value means an asset
38
- * path). One undo step unless grouped by `transact`. */
36
+ select(path: string | null): void
37
+ /** The scene document's nodes: absolute path, sibling-unique display name, source kind
38
+ * (mesh / model / light / group / …). */
39
+ nodes(): { path: string, name: string, kind: string }[]
40
+ /** First unused "base", "base2", "base3", … name among the SIBLINGS under `parentPath`
41
+ * (scene root when omitted). */
42
+ uniqueName(base: string, parentPath?: string): string
43
+ /** Add a ROOT node from plain def data (scene-file grammar; a string `model` value means an
44
+ * asset path). `name` may not contain '/' or ':'. One undo step unless grouped by `transact`. */
39
45
  addNode(name: string, def: Record<string, unknown>): boolean
40
46
  /** Write one def prop (transforms apply live; anything else patches/re-runs the node). */
41
- setProp(name: string, key: string, value: unknown): boolean
42
- removeNode(name: string): void
43
- /** Duplicate a node; returns the copy's name (null when it can't). */
44
- duplicate(name: string): string | null
47
+ setProp(path: string, key: string, value: unknown): boolean
48
+ removeNode(path: string): void
49
+ /** Duplicate a node; returns the copy's path (null when it can't). */
50
+ duplicate(path: string): string | null
45
51
  /** Raycast the scene under a viewport pixel (same hit rules as tool clicks). */
46
52
  raycast(screenX: number, screenY: number): EditorRayHit | null
47
53
  /** Group every doc edit inside `fn` into ONE undo step. */
@@ -43,39 +43,74 @@ export const make = <A extends Record<string, unknown>, N>(
43
43
  ): MakeEntry<A, N> => ({ __make: true, fn, args })
44
44
 
45
45
  /**
46
- * Reference another scene node by name in an aspect's props:
47
- * `use(Road, { from: ref('pointA'), to: ref('pointB') })`. Resolves to the live node when aspects
48
- * attach — after EVERY node of the scene exists, so forward references work. An unknown name
49
- * resolves to `null` (type your aspect field `Node | null`). Scene files only — hand-written code
50
- * passes nodes directly: `node.aspect(Road, { from: nodes.pointA })`.
46
+ * Reference another scene node by PATH in an aspect's props:
47
+ * `use(Road, { from: ref('pointA'), to: ref('lane/pointB') })`. Resolves to the live node when
48
+ * aspects attach — after EVERY node of the scene exists, so declaration order doesn't matter.
49
+ * Resolution is scoped upward from the host node (like variable scoping): the host's own children
50
+ * first, then its siblings, then each ancestor's scope up to the scene root. The FIRST segment
51
+ * binds the scope; the remaining segments descend from there. Refs address def nodes only (no
52
+ * `::`/`name[i]` asset-internal segments); an unknown path resolves to `null` (type your aspect
53
+ * field `Node | null`). Scene files only — hand-written code passes nodes directly:
54
+ * `node.aspect(Road, { from: nodes.pointA })`.
51
55
  */
52
- export const ref = <T = Node>(name: string): T => ({ $ref: name } as unknown as T)
56
+ export const ref = <T = Node>(path: string): T => ({ $ref: path } as unknown as T)
53
57
 
54
58
  export const isNodeRef = (v: unknown): v is { $ref: string } =>
55
59
  typeof v === "object" && v !== null && typeof (v as { $ref?: unknown }).$ref === "string"
56
60
 
57
- /** Swap `ref()` markers in aspect props for the live nodes (top level + one array level deep). */
61
+ /** Resolve one ref string against a path-keyed nodes record, scoped upward from `hostPath` (own
62
+ * children → parent's scope (siblings + the host itself) → each ancestor scope → root, `""`).
63
+ * The ref's FIRST segment binds the scope — a match there is final even when the rest of the
64
+ * path doesn't exist (lexical shadowing). Returns the target's ABSOLUTE path, or null.
65
+ * `Object.hasOwn`, not indexing: a node named `constructor` must not resolve via the prototype. */
66
+ export const resolveRefPath = (
67
+ nodes: Record<string, unknown>, hostPath: string, ref: string,
68
+ ): string | null => {
69
+ const i = ref.indexOf("/")
70
+ const first = i < 0 ? ref : ref.slice(0, i)
71
+ for (let scope = hostPath; ; ) {
72
+ const base = scope === "" ? "" : `${scope}/`
73
+ if (Object.hasOwn(nodes, base + first)) return Object.hasOwn(nodes, base + ref) ? base + ref : null
74
+ if (scope === "") return null
75
+ const cut = scope.lastIndexOf("/")
76
+ scope = cut < 0 ? "" : scope.slice(0, cut)
77
+ }
78
+ }
79
+
80
+ /** Swap `ref()` markers in aspect props for the live nodes (top level + one array level deep),
81
+ * resolved scoped-upward from `hostPath` ("" = root scope only — the 2D flat-map case). */
58
82
  export const resolveRefs = <N>(
59
- props: Record<string, unknown> | undefined, nodes: Record<string, N>,
83
+ props: Record<string, unknown> | undefined, nodes: Record<string, N>, hostPath = "",
60
84
  ): Record<string, unknown> | undefined => {
61
85
  if (!props) return props
86
+ const lookup = (r: string): N | null => {
87
+ const p = resolveRefPath(nodes, hostPath, r)
88
+ return p === null ? null : nodes[p]
89
+ }
62
90
  let out: Record<string, unknown> | undefined
63
91
  for (const [ k, v ] of Object.entries(props)) {
64
92
  if (isNodeRef(v)) {
65
- ;(out ??= { ...props })[k] = nodes[v.$ref] ?? null
93
+ ;(out ??= { ...props })[k] = lookup(v.$ref)
66
94
  } else if (Array.isArray(v) && v.some(isNodeRef)) {
67
- ;(out ??= { ...props })[k] = v.map((el) => (isNodeRef(el) ? nodes[el.$ref] ?? null : el))
95
+ ;(out ??= { ...props })[k] = v.map((el) => (isNodeRef(el) ? lookup(el.$ref) : el))
68
96
  }
69
97
  }
70
98
  return out ?? props
71
99
  }
72
100
 
73
- /** Names of the nodes a props record's ref() values point at (editor dep tracking). */
74
- export const collectRefDeps = (props: Record<string, unknown>): Set<string> => {
101
+ /** ABSOLUTE paths of the nodes a props record's ref() values resolve to (editor dep tracking).
102
+ * An unresolved ref contributes no dep — the handle re-derives deps after structural changes. */
103
+ export const collectRefDeps = (
104
+ props: Record<string, unknown>, nodes: Record<string, unknown>, hostPath: string,
105
+ ): Set<string> => {
75
106
  const deps = new Set<string>()
107
+ const add = (r: string): void => {
108
+ const p = resolveRefPath(nodes, hostPath, r)
109
+ if (p !== null) deps.add(p)
110
+ }
76
111
  for (const v of Object.values(props)) {
77
- if (isNodeRef(v)) deps.add(v.$ref)
78
- else if (Array.isArray(v)) for (const el of v) if (isNodeRef(el)) deps.add(el.$ref)
112
+ if (isNodeRef(v)) add(v.$ref)
113
+ else if (Array.isArray(v)) for (const el of v) if (isNodeRef(el)) add(el.$ref)
79
114
  }
80
115
  return deps
81
116
  }