@needle-tools/addressing 0.3.3 → 0.3.4

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.
package/dist/target.js ADDED
@@ -0,0 +1,112 @@
1
+ // the six characters the grammar reserves (+ "%" as the escape lead) — encoded
2
+ // with %xx so decodeURIComponent inverts; everything else stays raw and human-
3
+ // readable ("$scene" must print as "$scene", not "%24scene")
4
+ function encodeToken(value) {
5
+ return value
6
+ .replace(/%/g, "%25")
7
+ .replace(/\//g, "%2F")
8
+ .replace(/:/g, "%3A")
9
+ .replace(/#/g, "%23")
10
+ .replace(/~/g, "%7E")
11
+ .replace(/!/g, "%21")
12
+ .replace(/@/g, "%40");
13
+ }
14
+ function decodeToken(value) {
15
+ try {
16
+ return decodeURIComponent(value);
17
+ }
18
+ catch {
19
+ return null; // a lone "%" — not a token we printed
20
+ }
21
+ }
22
+ /** print a target as its canonical token — null when there is nothing to name */
23
+ export function targetToString(target) {
24
+ const address = target.address;
25
+ const hasAddress = !!address && (address.id !== undefined || address.anchor !== undefined || address.name !== undefined || address.path !== undefined || address.type !== undefined);
26
+ if (!target.ref && !hasAddress)
27
+ return null;
28
+ let token = `@${target.ref ? encodeToken(target.ref) : ""}`;
29
+ if (hasAddress) {
30
+ token += "/";
31
+ const type = address.type ?? "node";
32
+ if (type !== "node")
33
+ token += `${encodeToken(type)}:`;
34
+ if (address.id !== undefined)
35
+ token += encodeToken(address.id);
36
+ if (address.anchor !== undefined)
37
+ token += `#${encodeToken(address.anchor)}`;
38
+ if (address.name !== undefined)
39
+ token += `~${encodeToken(address.name)}`;
40
+ if (address.path !== undefined)
41
+ token += `!${address.path}`;
42
+ }
43
+ return token;
44
+ }
45
+ /** parse a canonical token back to the target — null for anything this module
46
+ * did not print (retired grammars, garbage): a loud miss, never a half-parse */
47
+ export function targetFromString(token) {
48
+ if (typeof token !== "string" || !token.startsWith("@") || token.length < 2)
49
+ return null;
50
+ const body = token.slice(1);
51
+ const slash = body.indexOf("/");
52
+ const refRaw = slash === -1 ? body : body.slice(0, slash);
53
+ const addrRaw = slash === -1 ? undefined : body.slice(slash + 1);
54
+ const ref = refRaw.length > 0 ? decodeToken(refRaw) : undefined;
55
+ if (refRaw.length > 0 && ref === null)
56
+ return null;
57
+ if (addrRaw === undefined || addrRaw.length === 0) {
58
+ return ref ? { ref } : null;
59
+ }
60
+ // consume rungs back to front: path (verbatim remainder), name, anchor
61
+ let rest = addrRaw;
62
+ let path;
63
+ const bang = rest.indexOf("!");
64
+ if (bang !== -1) {
65
+ path = rest.slice(bang + 1);
66
+ rest = rest.slice(0, bang);
67
+ }
68
+ let name;
69
+ const tilde = rest.indexOf("~");
70
+ if (tilde !== -1) {
71
+ const decoded = decodeToken(rest.slice(tilde + 1));
72
+ if (decoded === null)
73
+ return null;
74
+ name = decoded;
75
+ rest = rest.slice(0, tilde);
76
+ }
77
+ let anchor;
78
+ const hash = rest.indexOf("#");
79
+ if (hash !== -1) {
80
+ const decoded = decodeToken(rest.slice(hash + 1));
81
+ if (decoded === null)
82
+ return null;
83
+ anchor = decoded;
84
+ rest = rest.slice(0, hash);
85
+ }
86
+ let type = "node";
87
+ const colon = rest.indexOf(":");
88
+ if (colon !== -1) {
89
+ const decoded = decodeToken(rest.slice(0, colon));
90
+ if (decoded === null || decoded.length === 0)
91
+ return null;
92
+ type = decoded;
93
+ rest = rest.slice(colon + 1);
94
+ }
95
+ let id;
96
+ if (rest.length > 0) {
97
+ const decoded = decodeToken(rest);
98
+ if (decoded === null)
99
+ return null;
100
+ id = decoded;
101
+ }
102
+ const address = { type };
103
+ if (id !== undefined)
104
+ address.id = id;
105
+ if (anchor !== undefined)
106
+ address.anchor = anchor;
107
+ if (name !== undefined)
108
+ address.name = name;
109
+ if (path !== undefined)
110
+ address.path = path;
111
+ return ref ? { ref, address } : { address };
112
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@needle-tools/addressing",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "Persistent addressing for Needle assets: deterministic needle-id computation, #needle anchors, and address resolution over glTF JSON and live scene graphs.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
package/src/address.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  import { parseAnchorValue } from "./anchor.js";
2
- import { getElementId, NEEDLE_ID_KEY, type GltfJson, type StampedElementType } from "./gltf.js";
2
+ import { getElementId, NEEDLE_ID_KEY } from "./id.js";
3
+ import { type GltfJson, type StampedElementType } from "./gltf.js";
3
4
 
4
5
  /**
5
6
  * A persistent pointer to an element inside an asset — the "fat address": every
6
7
  * fallback the resolver may need is captured at CREATION time, while the target is
7
8
  * known, instead of being reconstructed later.
8
9
  *
9
- * Resolution order: `id` (exact) → `anchor` (survives asset updates) → `namePath`
10
+ * Resolution order: `id` (exact) → `anchor` (survives asset updates) → `path`
10
11
  * (best-effort) → reconciliation (candidates + confidence, never a silent guess).
11
12
  */
12
13
  export type NeedleAddress = {
@@ -18,10 +19,42 @@ export type NeedleAddress = {
18
19
  type?: StampedElementType | (string & {});
19
20
  /** the target's name at address-creation time */
20
21
  name?: string;
21
- /** node names from the asset root (exclusive) down to the target (inclusive); nodes only */
22
- namePath?: string[];
22
+ /**
23
+ * Node names from the asset root (exclusive) down to the target (inclusive);
24
+ * nodes only. ONE path syntax everywhere (2026-08-03): `/`-separated segments
25
+ * with JSON-Pointer escaping (`~0` = `~`, `~1` = `/`) — see {@link joinPath} /
26
+ * {@link splitPath}. Empty SEGMENTS are load-bearing (positional walk over
27
+ * unnamed nodes), so `""` is a valid one-segment path — test `!== undefined`,
28
+ * never truthiness. ABSENT means "no path rung".
29
+ */
30
+ path?: string;
23
31
  };
24
32
 
33
+ // #region path segment syntax
34
+ // The one path syntax of the project format (target.path uses the same rules):
35
+ // "/" separates segments; a literal "~" in a name is written "~0", a literal "/"
36
+ // is written "~1" (JSON-Pointer, RFC 6901). Escape order matters on decode.
37
+
38
+ /** escape one segment for embedding in a path string */
39
+ export function encodePathSegment(name: string): string {
40
+ return name.replace(/~/g, "~0").replace(/\//g, "~1");
41
+ }
42
+
43
+ /** decode one path segment back to the literal name */
44
+ export function decodePathSegment(segment: string): string {
45
+ return segment.replace(/~1/g, "/").replace(/~0/g, "~");
46
+ }
47
+
48
+ /** join literal segment names into an escaped path string */
49
+ export function joinPath(segments: readonly string[]): string {
50
+ return segments.map(encodePathSegment).join("/");
51
+ }
52
+
53
+ /** split an escaped path string into literal segment names ("" → one empty segment) */
54
+ export function splitPath(path: string): string[] {
55
+ return path.split("/").map(decodePathSegment);
56
+ }
57
+
25
58
  /** Structural (duck-typed) view of a live scene-graph node — works with any three.js. */
26
59
  export type GraphNode = {
27
60
  name?: string;
@@ -29,7 +62,7 @@ export type GraphNode = {
29
62
  userData?: Record<string, unknown>;
30
63
  };
31
64
 
32
- /** Build an address for a glTF root-collection element. `namePath` only applies to nodes. */
65
+ /** Build an address for a glTF root-collection element. `path` only applies to nodes. */
33
66
  export function createGltfElementAddress(json: GltfJson, type: StampedElementType | string, index: number): NeedleAddress | null {
34
67
  const collection = json[collectionKeyForType(type)];
35
68
  if (!Array.isArray(collection)) return null;
@@ -43,30 +76,30 @@ export function createGltfElementAddress(json: GltfJson, type: StampedElementTyp
43
76
  const anchor = parseAnchorValue(name);
44
77
  if (anchor) address.anchor = anchor;
45
78
  // an empty name is non-information: resolution never reads `name` (id → anchor →
46
- // namePath) and reconciliation falls back to the last namePath segment ("" again).
47
- // NOTE: empty namePath SEGMENTS are load-bearing (positional child-walk) — only the
79
+ // path) and reconciliation falls back to the last path segment ("" again).
80
+ // NOTE: empty path SEGMENTS are load-bearing (positional child-walk) — only the
48
81
  // standalone `name` field is dropped when empty.
49
82
  if (name) address.name = name;
50
83
  if (type === "node") {
51
- const path = gltfNodeNamePath(json, index);
52
- if (path) address.namePath = path;
84
+ const segments = gltfNodeNamePath(json, index);
85
+ if (segments) address.path = joinPath(segments);
53
86
  }
54
87
  return address;
55
88
  }
56
89
 
57
90
  /** Build an address for a live scene-graph object under `root`. */
58
91
  export function createObjectAddress(root: GraphNode, target: GraphNode): NeedleAddress | null {
59
- const path = findPath(root, target);
60
- if (!path) return null;
92
+ const nodes = findPath(root, target);
93
+ if (!nodes || nodes.length === 0) return null; // the root itself has no path — address it by ref alone
61
94
 
62
95
  const address: NeedleAddress = { type: "node" };
63
96
  const id = target.userData?.[NEEDLE_ID_KEY];
64
97
  if (typeof id === "string") address.id = id;
65
98
  const anchor = parseAnchorValue(target.name);
66
99
  if (anchor) address.anchor = anchor;
67
- // empty name dropped — see createGltfElementAddress; namePath segments stay verbatim
100
+ // empty name dropped — see createGltfElementAddress; path segments stay verbatim
68
101
  if (target.name) address.name = target.name;
69
- address.namePath = path.map(n => n.name ?? "");
102
+ address.path = joinPath(nodes.map(n => n.name ?? ""));
70
103
  return address;
71
104
  }
72
105
 
package/src/gltf.ts CHANGED
@@ -1,7 +1,8 @@
1
- import { computeElementId, idNameOf } from "./id.js";
2
-
3
- /** The glTF `extras` key carrying the persistent id. */
4
- export const NEEDLE_ID_KEY = "needle-id";
1
+ /**
2
+ * Structural glTF shapes — the duck-typed views of a glTF JSON root and its
3
+ * elements, plus which collections carry ids. All id READING and MINTING lives
4
+ * in `id.ts`; this module only describes the file format.
5
+ */
5
6
 
6
7
  /** All root-collection element types that receive ids, with their glTF collection key. */
7
8
  export const STAMPED_COLLECTIONS = [
@@ -30,62 +31,3 @@ export type GltfElement = {
30
31
  export type GltfJson = {
31
32
  [collection: string]: unknown;
32
33
  };
33
-
34
- export type EnsureIdsOptions = {
35
- /**
36
- * Optional salt scoping unanchored ids to an external identity (e.g. an
37
- * asset/content id). By default no salt is used — ids derive from the element alone
38
- * (type, name, occurrence-of-name), so any tool mints the same ids with no
39
- * coordination. With a salt, only writers that know it agree — safe because
40
- * existing ids are never overwritten (whoever stamps first wins).
41
- */
42
- baseHash?: string;
43
- };
44
-
45
- export type EnsureIdsResult = {
46
- /** the salt the ids were derived with, when one was provided */
47
- baseHash?: string;
48
- /** how many elements received a new id (0 → the file was already fully stamped) */
49
- assigned: number;
50
- };
51
-
52
- /**
53
- * Idempotently inject `extras["needle-id"]` into every element of the stamped
54
- * collections. Existing ids are never overwritten, so already-stamped files pass
55
- * through unchanged and every id writer agrees with whoever stamped first.
56
- *
57
- * Mutates `json` in place.
58
- */
59
- export function ensureIds(json: GltfJson, options?: EnsureIdsOptions): EnsureIdsResult {
60
- const baseHash = options?.baseHash;
61
- let assigned = 0;
62
- for (const [type, collection] of STAMPED_COLLECTIONS) {
63
- const elements = json[collection];
64
- if (!Array.isArray(elements)) continue;
65
- // occurrence-of-name: the id input is the element's position among same-named
66
- // elements, NOT its collection index — uniquely named elements keep their ids
67
- // across reorders and asset updates. Grouping is by the DERIVATION name
68
- // (anchor marker stripped, normalized), matching occurrenceOfName.
69
- const occurrences = new Map<string, number>();
70
- for (let index = 0; index < elements.length; index++) {
71
- const element = elements[index] as GltfElement;
72
- if (element == null || typeof element !== "object") continue;
73
- const name = typeof element.name === "string" ? element.name : "";
74
- const group = idNameOf(name);
75
- const occurrence = occurrences.get(group) ?? 0;
76
- occurrences.set(group, occurrence + 1);
77
- element.extras ??= {};
78
- if (element.extras[NEEDLE_ID_KEY] === undefined) {
79
- element.extras[NEEDLE_ID_KEY] = computeElementId(type, name, occurrence, baseHash);
80
- assigned++;
81
- }
82
- }
83
- }
84
- return { baseHash, assigned };
85
- }
86
-
87
- /** Read an element's needle-id without computing anything. */
88
- export function getElementId(element: GltfElement | null | undefined): string | null {
89
- const id = element?.extras?.[NEEDLE_ID_KEY];
90
- return typeof id === "string" ? id : null;
91
- }
package/src/id.ts CHANGED
@@ -1,5 +1,20 @@
1
1
  import { normalizeName, parseAnchor } from "./anchor.js";
2
- import { uuidv5 } from "./uuid.js";
2
+ import { STAMPED_COLLECTIONS, type GltfElement, type GltfJson } from "./gltf.js";
3
+ import { randomUuid, uuidv5 } from "./uuid.js";
4
+ import type { GraphNode } from "./address.js";
5
+
6
+ /**
7
+ * THE identity module: every read and every mint of a needle-id lives here —
8
+ * derivation (deterministic ids for glTF elements), glTF stamping/readback
9
+ * (`extras`), and live-node stamping/readback (`userData`). Consumers never
10
+ * touch the key or mint ids themselves.
11
+ */
12
+
13
+ /** The key carrying the persistent id — glTF `extras` for file elements, live
14
+ * `userData` for scene objects (exports copy one into the other). */
15
+ export const NEEDLE_ID_KEY = "needle-id";
16
+
17
+ // #region Derivation
3
18
 
4
19
  /**
5
20
  * The name an id is derived FROM: the anchor marker removed (if any) and space/underscore
@@ -59,3 +74,128 @@ export function occurrenceOfName(elements: ReadonlyArray<{ name?: unknown } | nu
59
74
  }
60
75
  return occurrence;
61
76
  }
77
+
78
+ // #endregion
79
+
80
+ // #region glTF elements (extras)
81
+
82
+ export type EnsureIdsOptions = {
83
+ /**
84
+ * Optional salt scoping unanchored ids to an external identity (e.g. an
85
+ * asset/content id). By default no salt is used — ids derive from the element alone
86
+ * (type, name, occurrence-of-name), so any tool mints the same ids with no
87
+ * coordination. With a salt, only writers that know it agree — safe because
88
+ * existing ids are never overwritten (whoever stamps first wins).
89
+ */
90
+ baseHash?: string;
91
+ };
92
+
93
+ export type EnsureIdsResult = {
94
+ /** the salt the ids were derived with, when one was provided */
95
+ baseHash?: string;
96
+ /** how many elements received a new id (0 → the file was already fully stamped) */
97
+ assigned: number;
98
+ };
99
+
100
+ /**
101
+ * Idempotently inject `extras["needle-id"]` into every element of the stamped
102
+ * collections. Existing ids are never overwritten, so already-stamped files pass
103
+ * through unchanged and every id writer agrees with whoever stamped first.
104
+ *
105
+ * Mutates `json` in place.
106
+ */
107
+ export function ensureIds(json: GltfJson, options?: EnsureIdsOptions): EnsureIdsResult {
108
+ const baseHash = options?.baseHash;
109
+ let assigned = 0;
110
+ for (const [type, collection] of STAMPED_COLLECTIONS) {
111
+ const elements = json[collection];
112
+ if (!Array.isArray(elements)) continue;
113
+ // occurrence-of-name: the id input is the element's position among same-named
114
+ // elements, NOT its collection index — uniquely named elements keep their ids
115
+ // across reorders and asset updates. Grouping is by the DERIVATION name
116
+ // (anchor marker stripped, normalized), matching occurrenceOfName.
117
+ const occurrences = new Map<string, number>();
118
+ for (let index = 0; index < elements.length; index++) {
119
+ const element = elements[index] as GltfElement;
120
+ if (element == null || typeof element !== "object") continue;
121
+ const name = typeof element.name === "string" ? element.name : "";
122
+ const group = idNameOf(name);
123
+ const occurrence = occurrences.get(group) ?? 0;
124
+ occurrences.set(group, occurrence + 1);
125
+ element.extras ??= {};
126
+ if (element.extras[NEEDLE_ID_KEY] === undefined) {
127
+ element.extras[NEEDLE_ID_KEY] = computeElementId(type, name, occurrence, baseHash);
128
+ assigned++;
129
+ }
130
+ }
131
+ }
132
+ return { baseHash, assigned };
133
+ }
134
+
135
+ /** Read an element's needle-id without computing anything. */
136
+ export function getElementId(element: GltfElement | null | undefined): string | null {
137
+ const id = element?.extras?.[NEEDLE_ID_KEY];
138
+ return typeof id === "string" ? id : null;
139
+ }
140
+
141
+ // #endregion
142
+
143
+ // #region Live nodes (userData)
144
+
145
+ /** Read a live node's needle-id from `userData` — the live-object counterpart of
146
+ * {@link getElementId}, which reads glTF `extras`. */
147
+ export function getNodeId(target: GraphNode | null | undefined): string | null {
148
+ const id = target?.userData?.[NEEDLE_ID_KEY];
149
+ return typeof id === "string" ? id : null;
150
+ }
151
+
152
+ /** Ensure a live node carries a needle-id: returns the existing one or MINTS a
153
+ * random uuid into `userData`. Random by design — editor-created (scratch) content
154
+ * has no derivation basis; the id persists because exports write `userData` ids
155
+ * into glTF `extras` (create → save → reload sees the same id). */
156
+ export function ensureNodeId(target: GraphNode): string {
157
+ const userData = (target.userData ??= {});
158
+ const existing = userData[NEEDLE_ID_KEY];
159
+ if (typeof existing === "string") return existing;
160
+ const id = randomUuid();
161
+ userData[NEEDLE_ID_KEY] = id;
162
+ return id;
163
+ }
164
+
165
+ export type ShedIdentityOptions = {
166
+ /** additional identity keys deleted from every `userData.components` entry —
167
+ * the caller names its component-identity keys (e.g. an engine's editor-guid
168
+ * key); addressing owns the traversal and the needle-id rule */
169
+ componentKeys?: readonly string[];
170
+ };
171
+
172
+ /**
173
+ * Shed a subtree's AUTHORED identity — for clones (duplicate/paste) before they are
174
+ * re-stamped. Stamped needle-ids are deleted on every node: sibling id collisions
175
+ * break addressing (an id-addressed edit resolves to whichever holder comes first).
176
+ * With `componentKeys`, the same keys are deleted from `userData.components`
177
+ * entries (a copied component guid would bake the ORIGINAL's identity into the
178
+ * clone's next export). Fresh ids are minted by whatever stamps next.
179
+ */
180
+ export function shedNodeIdentity(root: GraphNode, options?: ShedIdentityOptions): void {
181
+ const componentKeys = options?.componentKeys ?? [];
182
+ const visit = (node: GraphNode): void => {
183
+ const userData = node.userData;
184
+ if (userData) {
185
+ if (typeof userData[NEEDLE_ID_KEY] === "string") delete userData[NEEDLE_ID_KEY];
186
+ const components = userData["components"];
187
+ if (Array.isArray(components)) {
188
+ for (const component of components) {
189
+ if (!component || typeof component !== "object") continue;
190
+ for (const key of componentKeys) {
191
+ if ((component as Record<string, unknown>)[key] !== undefined) delete (component as Record<string, unknown>)[key];
192
+ }
193
+ }
194
+ }
195
+ }
196
+ for (const child of node.children ?? []) visit(child);
197
+ };
198
+ visit(root);
199
+ }
200
+
201
+ // #endregion
package/src/index.ts CHANGED
@@ -1,12 +1,19 @@
1
1
  export { ANCHOR_MARKER, ANCHOR_MARKERS, parseAnchor, parseAnchorValue, type ParsedAnchor } from "./anchor.js";
2
- export { computeElementId, occurrenceOfName } from "./id.js";
3
2
  export {
3
+ computeElementId,
4
4
  ensureIds,
5
+ ensureNodeId,
5
6
  getElementId,
7
+ getNodeId,
6
8
  NEEDLE_ID_KEY,
7
- STAMPED_COLLECTIONS,
9
+ occurrenceOfName,
10
+ shedNodeIdentity,
8
11
  type EnsureIdsOptions,
9
12
  type EnsureIdsResult,
13
+ type ShedIdentityOptions,
14
+ } from "./id.js";
15
+ export {
16
+ STAMPED_COLLECTIONS,
10
17
  type GltfElement,
11
18
  type GltfJson,
12
19
  type StampedElementType,
@@ -15,7 +22,11 @@ export {
15
22
  collectionKeyForType,
16
23
  createGltfElementAddress,
17
24
  createObjectAddress,
25
+ decodePathSegment,
26
+ encodePathSegment,
18
27
  findPath,
28
+ joinPath,
29
+ splitPath,
19
30
  type GraphNode,
20
31
  type NeedleAddress,
21
32
  } from "./address.js";
@@ -31,4 +42,5 @@ export {
31
42
  type ResolutionRung,
32
43
  type ResolveOptions,
33
44
  } from "./resolve.js";
45
+ export { targetFromString, targetToString, type AddressTarget } from "./target.js";
34
46
  export { randomUuid, uuidv5, UUID_NAMESPACE_URL } from "./uuid.js";
package/src/resolve.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { parseAnchorValue } from "./anchor.js";
2
- import { collectionKeyForType, type GraphNode, type NeedleAddress } from "./address.js";
3
- import { NEEDLE_ID_KEY, STAMPED_COLLECTIONS, type GltfJson } from "./gltf.js";
2
+ import { collectionKeyForType, splitPath, type GraphNode, type NeedleAddress } from "./address.js";
3
+ import { NEEDLE_ID_KEY } from "./id.js";
4
+ import { STAMPED_COLLECTIONS, type GltfJson } from "./gltf.js";
4
5
 
5
6
  export type GltfResolveResult = {
6
7
  type: string;
@@ -10,7 +11,7 @@ export type GltfResolveResult = {
10
11
 
11
12
  /**
12
13
  * Resolve an address inside glTF JSON. Same ladder as the live graph —
13
- * **anchor → id → namePath** (nodes) — with the anchor winning only when it matches
14
+ * **anchor → id → path** (nodes) — with the anchor winning only when it matches
14
15
  * exactly one element. Returns null when nothing matches exactly (reconciliation is a
15
16
  * separate, explicit step — resolution never guesses).
16
17
  */
@@ -51,8 +52,8 @@ export function resolveInGltf(address: NeedleAddress, json: GltfJson, options?:
51
52
  }
52
53
  if (byId) return byId;
53
54
 
54
- if (address.namePath?.length && (!address.type || address.type === "node")) {
55
- const index = walkGltfNamePath(json, address.namePath);
55
+ if (address.path !== undefined && (!address.type || address.type === "node")) {
56
+ const index = walkGltfNamePath(json, splitPath(address.path));
56
57
  if (index !== null) {
57
58
  const nodes = json["nodes"] as Array<Record<string, unknown>>;
58
59
  return { type: "node", index, element: nodes[index] };
@@ -62,7 +63,7 @@ export function resolveInGltf(address: NeedleAddress, json: GltfJson, options?:
62
63
  }
63
64
 
64
65
  /** which rung of the ladder produced (or could have produced) a match */
65
- export type ResolutionRung = "id" | "anchor" | "namePath" | "name";
66
+ export type ResolutionRung = "id" | "anchor" | "path" | "name";
66
67
 
67
68
  /**
68
69
  * Something a caller may want to show a human. Never an error — resolution still returns
@@ -90,7 +91,7 @@ export type ResolveOptions = {
90
91
  /**
91
92
  * Resolve an address inside a live scene graph (duck-typed — any three.js).
92
93
  *
93
- * Ladder: **anchor → id → namePath**. The anchor comes first because it is a deliberate,
94
+ * Ladder: **anchor → id → path**. The anchor comes first because it is a deliberate,
94
95
  * human-authored declaration, while an id may be a derived value whose basis shifted when
95
96
  * the content changed. It only wins when it resolves UNIQUELY: anchors are labels and may
96
97
  * legally repeat, and an ambiguous label must never outrank an exact key.
@@ -118,9 +119,9 @@ export function resolveInGraph(address: NeedleAddress, root: GraphNode, options?
118
119
  }
119
120
  if (byId) return byId;
120
121
 
121
- if (address.namePath?.length) {
122
+ if (address.path !== undefined) {
122
123
  let current: GraphNode | null = root;
123
- for (const segment of address.namePath) {
124
+ for (const segment of splitPath(address.path)) {
124
125
  current = current.children?.find(c => (c.name ?? "") === segment) ?? null;
125
126
  if (!current) break;
126
127
  }
@@ -139,7 +140,7 @@ export type AddressableElement = {
139
140
  * Resolve an address against a FLAT collection of live elements — materials, textures and
140
141
  * anything else that carries a name and `userData` but is not part of the scene graph.
141
142
  *
142
- * Same ladder as the graph, minus `namePath` (there is no hierarchy to walk) plus `name`
143
+ * Same ladder as the graph, minus `path` (there is no hierarchy to walk) plus `name`
143
144
  * as the final rung, since a flat collection has no positional fallback. This exists so
144
145
  * every element type resolves through ONE implementation: materials previously had their
145
146
  * own ladders, could not be anchored at all, and disagreed with each other about whether a
@@ -182,19 +183,20 @@ export type ReconcileCandidate = {
182
183
  /**
183
184
  * When exact resolution fails (the asset changed), propose candidates instead of
184
185
  * guessing: nodes matching the address's name, ranked by how much of the recorded
185
- * namePath still matches. Callers decide thresholds and whether to re-anchor.
186
+ * path still matches. Callers decide thresholds and whether to re-anchor.
186
187
  */
187
188
  export function reconcileInGraph(address: NeedleAddress, root: GraphNode): ReconcileCandidate[] {
188
- if (!address.name && !address.namePath?.length) return [];
189
- const targetName = address.name ?? address.namePath![address.namePath!.length - 1];
189
+ const addressSegments = address.path !== undefined ? splitPath(address.path) : null;
190
+ if (!address.name && !addressSegments?.length) return [];
191
+ const targetName = address.name ?? addressSegments![addressSegments!.length - 1];
190
192
 
191
193
  const candidates: ReconcileCandidate[] = [];
192
- collectDescendants(root, (node, namePath) => {
194
+ collectDescendants(root, (node, segments) => {
193
195
  if ((node.name ?? "") !== targetName) return;
194
196
  const reasons = [`name matches "${targetName}"`];
195
197
  let confidence = 0.5;
196
- if (address.namePath?.length) {
197
- const similarity = pathSuffixSimilarity(address.namePath, namePath);
198
+ if (addressSegments?.length) {
199
+ const similarity = pathSuffixSimilarity(addressSegments, segments);
198
200
  confidence += 0.4 * similarity;
199
201
  if (similarity > 0) reasons.push(`path suffix similarity ${(similarity * 100).toFixed(0)}%`);
200
202
  }
@@ -233,14 +235,14 @@ function findInCollection(json: GltfJson, key: string, predicate: (el: Record<st
233
235
  return null;
234
236
  }
235
237
 
236
- function walkGltfNamePath(json: GltfJson, namePath: string[]): number | null {
238
+ function walkGltfNamePath(json: GltfJson, segments: string[]): number | null {
237
239
  const nodes = json["nodes"];
238
240
  const scenes = json["scenes"];
239
241
  if (!Array.isArray(nodes) || !Array.isArray(scenes)) return null;
240
242
  // roots: the union of all scenes' root nodes
241
243
  let candidates: number[] = scenes.flatMap(s => (Array.isArray(s?.nodes) ? s.nodes : []));
242
244
  let match: number | null = null;
243
- for (const segment of namePath) {
245
+ for (const segment of segments) {
244
246
  match = candidates.find(i => ((nodes[i]?.name ?? "") === segment)) ?? null;
245
247
  if (match === null) return null;
246
248
  const children = nodes[match]?.children;
@@ -285,17 +287,17 @@ function findInGraph(root: GraphNode, predicate: (node: GraphNode) => boolean, v
285
287
  }
286
288
 
287
289
  /** visit every descendant of root with its root-exclusive name path (including own name) */
288
- function collectDescendants(root: GraphNode, visit: (node: GraphNode, namePath: string[]) => void): void {
290
+ function collectDescendants(root: GraphNode, visit: (node: GraphNode, segments: string[]) => void): void {
289
291
  const visited = new Set<GraphNode>();
290
- const walk = (node: GraphNode, namePath: string[]) => {
292
+ const walk = (node: GraphNode, segments: string[]) => {
291
293
  if (visited.has(node)) return;
292
294
  visited.add(node);
293
- if (node !== root) visit(node, namePath);
295
+ if (node !== root) visit(node, segments);
294
296
  if (!node.children) return;
295
297
  for (let i = 0; i < node.children.length; i++) {
296
298
  const child = node.children[i];
297
299
  if (child == null) continue;
298
- walk(child, [...namePath, child.name ?? ""]);
300
+ walk(child, [...segments, child.name ?? ""]);
299
301
  }
300
302
  };
301
303
  walk(root, []);