@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/Readme.md CHANGED
@@ -29,11 +29,11 @@ identity (e.g. a stable asset id), scoping them to that identity.
29
29
  ## Addresses
30
30
 
31
31
  An address captures every fallback at creation time, while the target is known —
32
- `{ id, anchor?, type?, name?, namePath? }`. Resolution order is strict:
32
+ `{ id, anchor?, type?, name?, path? }`. Resolution order is strict (path: "/"-separated segments, JSON-Pointer `~0`/`~1` escaping):
33
33
 
34
34
  1. `id` — exact match (`extras` in glTF JSON, `userData` in live scene graphs)
35
35
  2. `anchor` — survives asset updates
36
- 3. `namePath` — best-effort structural match
36
+ 3. `path` — best-effort structural match
37
37
  4. **reconciliation** — never automatic: `reconcileInGraph` returns ranked candidates
38
38
  with confidence + reasons; the caller decides thresholds and whether to re-anchor.
39
39
 
package/dist/address.d.ts CHANGED
@@ -4,7 +4,7 @@ import { type GltfJson, type StampedElementType } from "./gltf.js";
4
4
  * fallback the resolver may need is captured at CREATION time, while the target is
5
5
  * known, instead of being reconstructed later.
6
6
  *
7
- * Resolution order: `id` (exact) → `anchor` (survives asset updates) → `namePath`
7
+ * Resolution order: `id` (exact) → `anchor` (survives asset updates) → `path`
8
8
  * (best-effort) → reconciliation (candidates + confidence, never a silent guess).
9
9
  */
10
10
  export type NeedleAddress = {
@@ -16,16 +16,31 @@ export type NeedleAddress = {
16
16
  type?: StampedElementType | (string & {});
17
17
  /** the target's name at address-creation time */
18
18
  name?: string;
19
- /** node names from the asset root (exclusive) down to the target (inclusive); nodes only */
20
- namePath?: string[];
19
+ /**
20
+ * Node names from the asset root (exclusive) down to the target (inclusive);
21
+ * nodes only. ONE path syntax everywhere (2026-08-03): `/`-separated segments
22
+ * with JSON-Pointer escaping (`~0` = `~`, `~1` = `/`) — see {@link joinPath} /
23
+ * {@link splitPath}. Empty SEGMENTS are load-bearing (positional walk over
24
+ * unnamed nodes), so `""` is a valid one-segment path — test `!== undefined`,
25
+ * never truthiness. ABSENT means "no path rung".
26
+ */
27
+ path?: string;
21
28
  };
29
+ /** escape one segment for embedding in a path string */
30
+ export declare function encodePathSegment(name: string): string;
31
+ /** decode one path segment back to the literal name */
32
+ export declare function decodePathSegment(segment: string): string;
33
+ /** join literal segment names into an escaped path string */
34
+ export declare function joinPath(segments: readonly string[]): string;
35
+ /** split an escaped path string into literal segment names ("" → one empty segment) */
36
+ export declare function splitPath(path: string): string[];
22
37
  /** Structural (duck-typed) view of a live scene-graph node — works with any three.js. */
23
38
  export type GraphNode = {
24
39
  name?: string;
25
40
  children?: readonly GraphNode[];
26
41
  userData?: Record<string, unknown>;
27
42
  };
28
- /** Build an address for a glTF root-collection element. `namePath` only applies to nodes. */
43
+ /** Build an address for a glTF root-collection element. `path` only applies to nodes. */
29
44
  export declare function createGltfElementAddress(json: GltfJson, type: StampedElementType | string, index: number): NeedleAddress | null;
30
45
  /** Build an address for a live scene-graph object under `root`. */
31
46
  export declare function createObjectAddress(root: GraphNode, target: GraphNode): NeedleAddress | null;
package/dist/address.js CHANGED
@@ -1,6 +1,26 @@
1
1
  import { parseAnchorValue } from "./anchor.js";
2
- import { getElementId, NEEDLE_ID_KEY } from "./gltf.js";
3
- /** Build an address for a glTF root-collection element. `namePath` only applies to nodes. */
2
+ import { getElementId, NEEDLE_ID_KEY } from "./id.js";
3
+ // #region path segment syntax
4
+ // The one path syntax of the project format (target.path uses the same rules):
5
+ // "/" separates segments; a literal "~" in a name is written "~0", a literal "/"
6
+ // is written "~1" (JSON-Pointer, RFC 6901). Escape order matters on decode.
7
+ /** escape one segment for embedding in a path string */
8
+ export function encodePathSegment(name) {
9
+ return name.replace(/~/g, "~0").replace(/\//g, "~1");
10
+ }
11
+ /** decode one path segment back to the literal name */
12
+ export function decodePathSegment(segment) {
13
+ return segment.replace(/~1/g, "/").replace(/~0/g, "~");
14
+ }
15
+ /** join literal segment names into an escaped path string */
16
+ export function joinPath(segments) {
17
+ return segments.map(encodePathSegment).join("/");
18
+ }
19
+ /** split an escaped path string into literal segment names ("" → one empty segment) */
20
+ export function splitPath(path) {
21
+ return path.split("/").map(decodePathSegment);
22
+ }
23
+ /** Build an address for a glTF root-collection element. `path` only applies to nodes. */
4
24
  export function createGltfElementAddress(json, type, index) {
5
25
  const collection = json[collectionKeyForType(type)];
6
26
  if (!Array.isArray(collection))
@@ -17,23 +37,23 @@ export function createGltfElementAddress(json, type, index) {
17
37
  if (anchor)
18
38
  address.anchor = anchor;
19
39
  // an empty name is non-information: resolution never reads `name` (id → anchor →
20
- // namePath) and reconciliation falls back to the last namePath segment ("" again).
21
- // NOTE: empty namePath SEGMENTS are load-bearing (positional child-walk) — only the
40
+ // path) and reconciliation falls back to the last path segment ("" again).
41
+ // NOTE: empty path SEGMENTS are load-bearing (positional child-walk) — only the
22
42
  // standalone `name` field is dropped when empty.
23
43
  if (name)
24
44
  address.name = name;
25
45
  if (type === "node") {
26
- const path = gltfNodeNamePath(json, index);
27
- if (path)
28
- address.namePath = path;
46
+ const segments = gltfNodeNamePath(json, index);
47
+ if (segments)
48
+ address.path = joinPath(segments);
29
49
  }
30
50
  return address;
31
51
  }
32
52
  /** Build an address for a live scene-graph object under `root`. */
33
53
  export function createObjectAddress(root, target) {
34
- const path = findPath(root, target);
35
- if (!path)
36
- return null;
54
+ const nodes = findPath(root, target);
55
+ if (!nodes || nodes.length === 0)
56
+ return null; // the root itself has no path — address it by ref alone
37
57
  const address = { type: "node" };
38
58
  const id = target.userData?.[NEEDLE_ID_KEY];
39
59
  if (typeof id === "string")
@@ -41,10 +61,10 @@ export function createObjectAddress(root, target) {
41
61
  const anchor = parseAnchorValue(target.name);
42
62
  if (anchor)
43
63
  address.anchor = anchor;
44
- // empty name dropped — see createGltfElementAddress; namePath segments stay verbatim
64
+ // empty name dropped — see createGltfElementAddress; path segments stay verbatim
45
65
  if (target.name)
46
66
  address.name = target.name;
47
- address.namePath = path.map(n => n.name ?? "");
67
+ address.path = joinPath(nodes.map(n => n.name ?? ""));
48
68
  return address;
49
69
  }
50
70
  export function collectionKeyForType(type) {
package/dist/gltf.d.ts CHANGED
@@ -1,5 +1,8 @@
1
- /** The glTF `extras` key carrying the persistent id. */
2
- export declare 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
+ */
3
6
  /** All root-collection element types that receive ids, with their glTF collection key. */
4
7
  export declare const STAMPED_COLLECTIONS: readonly [readonly ["node", "nodes"], readonly ["scene", "scenes"], readonly ["animation", "animations"], readonly ["camera", "cameras"], readonly ["material", "materials"], readonly ["mesh", "meshes"], readonly ["skin", "skins"], readonly ["texture", "textures"], readonly ["image", "images"], readonly ["sampler", "samplers"]];
5
8
  export type StampedElementType = (typeof STAMPED_COLLECTIONS)[number][0];
@@ -13,29 +16,3 @@ export type GltfElement = {
13
16
  export type GltfJson = {
14
17
  [collection: string]: unknown;
15
18
  };
16
- export type EnsureIdsOptions = {
17
- /**
18
- * Optional salt scoping unanchored ids to an external identity (e.g. an
19
- * asset/content id). By default no salt is used — ids derive from the element alone
20
- * (type, name, occurrence-of-name), so any tool mints the same ids with no
21
- * coordination. With a salt, only writers that know it agree — safe because
22
- * existing ids are never overwritten (whoever stamps first wins).
23
- */
24
- baseHash?: string;
25
- };
26
- export type EnsureIdsResult = {
27
- /** the salt the ids were derived with, when one was provided */
28
- baseHash?: string;
29
- /** how many elements received a new id (0 → the file was already fully stamped) */
30
- assigned: number;
31
- };
32
- /**
33
- * Idempotently inject `extras["needle-id"]` into every element of the stamped
34
- * collections. Existing ids are never overwritten, so already-stamped files pass
35
- * through unchanged and every id writer agrees with whoever stamped first.
36
- *
37
- * Mutates `json` in place.
38
- */
39
- export declare function ensureIds(json: GltfJson, options?: EnsureIdsOptions): EnsureIdsResult;
40
- /** Read an element's needle-id without computing anything. */
41
- export declare function getElementId(element: GltfElement | null | undefined): string | null;
package/dist/gltf.js CHANGED
@@ -1,6 +1,8 @@
1
- import { computeElementId, idNameOf } from "./id.js";
2
- /** The glTF `extras` key carrying the persistent id. */
3
- 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
+ */
4
6
  /** All root-collection element types that receive ids, with their glTF collection key. */
5
7
  export const STAMPED_COLLECTIONS = [
6
8
  ["node", "nodes"],
@@ -14,44 +16,3 @@ export const STAMPED_COLLECTIONS = [
14
16
  ["image", "images"],
15
17
  ["sampler", "samplers"],
16
18
  ];
17
- /**
18
- * Idempotently inject `extras["needle-id"]` into every element of the stamped
19
- * collections. Existing ids are never overwritten, so already-stamped files pass
20
- * through unchanged and every id writer agrees with whoever stamped first.
21
- *
22
- * Mutates `json` in place.
23
- */
24
- export function ensureIds(json, options) {
25
- const baseHash = options?.baseHash;
26
- let assigned = 0;
27
- for (const [type, collection] of STAMPED_COLLECTIONS) {
28
- const elements = json[collection];
29
- if (!Array.isArray(elements))
30
- continue;
31
- // occurrence-of-name: the id input is the element's position among same-named
32
- // elements, NOT its collection index — uniquely named elements keep their ids
33
- // across reorders and asset updates. Grouping is by the DERIVATION name
34
- // (anchor marker stripped, normalized), matching occurrenceOfName.
35
- const occurrences = new Map();
36
- for (let index = 0; index < elements.length; index++) {
37
- const element = elements[index];
38
- if (element == null || typeof element !== "object")
39
- continue;
40
- const name = typeof element.name === "string" ? element.name : "";
41
- const group = idNameOf(name);
42
- const occurrence = occurrences.get(group) ?? 0;
43
- occurrences.set(group, occurrence + 1);
44
- element.extras ?? (element.extras = {});
45
- if (element.extras[NEEDLE_ID_KEY] === undefined) {
46
- element.extras[NEEDLE_ID_KEY] = computeElementId(type, name, occurrence, baseHash);
47
- assigned++;
48
- }
49
- }
50
- }
51
- return { baseHash, assigned };
52
- }
53
- /** Read an element's needle-id without computing anything. */
54
- export function getElementId(element) {
55
- const id = element?.extras?.[NEEDLE_ID_KEY];
56
- return typeof id === "string" ? id : null;
57
- }
package/dist/id.d.ts CHANGED
@@ -1,3 +1,14 @@
1
+ import { type GltfElement, type GltfJson } from "./gltf.js";
2
+ import type { GraphNode } from "./address.js";
3
+ /**
4
+ * THE identity module: every read and every mint of a needle-id lives here —
5
+ * derivation (deterministic ids for glTF elements), glTF stamping/readback
6
+ * (`extras`), and live-node stamping/readback (`userData`). Consumers never
7
+ * touch the key or mint ids themselves.
8
+ */
9
+ /** The key carrying the persistent id — glTF `extras` for file elements, live
10
+ * `userData` for scene objects (exports copy one into the other). */
11
+ export declare const NEEDLE_ID_KEY = "needle-id";
1
12
  /**
2
13
  * The name an id is derived FROM: the anchor marker removed (if any) and space/underscore
3
14
  * runs collapsed.
@@ -43,3 +54,52 @@ export declare function computeElementId(type: string, name: string | null | und
43
54
  export declare function occurrenceOfName(elements: ReadonlyArray<{
44
55
  name?: unknown;
45
56
  } | null | undefined>, index: number): number;
57
+ export type EnsureIdsOptions = {
58
+ /**
59
+ * Optional salt scoping unanchored ids to an external identity (e.g. an
60
+ * asset/content id). By default no salt is used — ids derive from the element alone
61
+ * (type, name, occurrence-of-name), so any tool mints the same ids with no
62
+ * coordination. With a salt, only writers that know it agree — safe because
63
+ * existing ids are never overwritten (whoever stamps first wins).
64
+ */
65
+ baseHash?: string;
66
+ };
67
+ export type EnsureIdsResult = {
68
+ /** the salt the ids were derived with, when one was provided */
69
+ baseHash?: string;
70
+ /** how many elements received a new id (0 → the file was already fully stamped) */
71
+ assigned: number;
72
+ };
73
+ /**
74
+ * Idempotently inject `extras["needle-id"]` into every element of the stamped
75
+ * collections. Existing ids are never overwritten, so already-stamped files pass
76
+ * through unchanged and every id writer agrees with whoever stamped first.
77
+ *
78
+ * Mutates `json` in place.
79
+ */
80
+ export declare function ensureIds(json: GltfJson, options?: EnsureIdsOptions): EnsureIdsResult;
81
+ /** Read an element's needle-id without computing anything. */
82
+ export declare function getElementId(element: GltfElement | null | undefined): string | null;
83
+ /** Read a live node's needle-id from `userData` — the live-object counterpart of
84
+ * {@link getElementId}, which reads glTF `extras`. */
85
+ export declare function getNodeId(target: GraphNode | null | undefined): string | null;
86
+ /** Ensure a live node carries a needle-id: returns the existing one or MINTS a
87
+ * random uuid into `userData`. Random by design — editor-created (scratch) content
88
+ * has no derivation basis; the id persists because exports write `userData` ids
89
+ * into glTF `extras` (create → save → reload sees the same id). */
90
+ export declare function ensureNodeId(target: GraphNode): string;
91
+ export type ShedIdentityOptions = {
92
+ /** additional identity keys deleted from every `userData.components` entry —
93
+ * the caller names its component-identity keys (e.g. an engine's editor-guid
94
+ * key); addressing owns the traversal and the needle-id rule */
95
+ componentKeys?: readonly string[];
96
+ };
97
+ /**
98
+ * Shed a subtree's AUTHORED identity — for clones (duplicate/paste) before they are
99
+ * re-stamped. Stamped needle-ids are deleted on every node: sibling id collisions
100
+ * break addressing (an id-addressed edit resolves to whichever holder comes first).
101
+ * With `componentKeys`, the same keys are deleted from `userData.components`
102
+ * entries (a copied component guid would bake the ORIGINAL's identity into the
103
+ * clone's next export). Fresh ids are minted by whatever stamps next.
104
+ */
105
+ export declare function shedNodeIdentity(root: GraphNode, options?: ShedIdentityOptions): void;
package/dist/id.js CHANGED
@@ -1,5 +1,16 @@
1
1
  import { normalizeName, parseAnchor } from "./anchor.js";
2
- import { uuidv5 } from "./uuid.js";
2
+ import { STAMPED_COLLECTIONS } from "./gltf.js";
3
+ import { randomUuid, uuidv5 } from "./uuid.js";
4
+ /**
5
+ * THE identity module: every read and every mint of a needle-id lives here —
6
+ * derivation (deterministic ids for glTF elements), glTF stamping/readback
7
+ * (`extras`), and live-node stamping/readback (`userData`). Consumers never
8
+ * touch the key or mint ids themselves.
9
+ */
10
+ /** The key carrying the persistent id — glTF `extras` for file elements, live
11
+ * `userData` for scene objects (exports copy one into the other). */
12
+ export const NEEDLE_ID_KEY = "needle-id";
13
+ // #region Derivation
3
14
  /**
4
15
  * The name an id is derived FROM: the anchor marker removed (if any) and space/underscore
5
16
  * runs collapsed.
@@ -58,3 +69,98 @@ export function occurrenceOfName(elements, index) {
58
69
  }
59
70
  return occurrence;
60
71
  }
72
+ /**
73
+ * Idempotently inject `extras["needle-id"]` into every element of the stamped
74
+ * collections. Existing ids are never overwritten, so already-stamped files pass
75
+ * through unchanged and every id writer agrees with whoever stamped first.
76
+ *
77
+ * Mutates `json` in place.
78
+ */
79
+ export function ensureIds(json, options) {
80
+ const baseHash = options?.baseHash;
81
+ let assigned = 0;
82
+ for (const [type, collection] of STAMPED_COLLECTIONS) {
83
+ const elements = json[collection];
84
+ if (!Array.isArray(elements))
85
+ continue;
86
+ // occurrence-of-name: the id input is the element's position among same-named
87
+ // elements, NOT its collection index — uniquely named elements keep their ids
88
+ // across reorders and asset updates. Grouping is by the DERIVATION name
89
+ // (anchor marker stripped, normalized), matching occurrenceOfName.
90
+ const occurrences = new Map();
91
+ for (let index = 0; index < elements.length; index++) {
92
+ const element = elements[index];
93
+ if (element == null || typeof element !== "object")
94
+ continue;
95
+ const name = typeof element.name === "string" ? element.name : "";
96
+ const group = idNameOf(name);
97
+ const occurrence = occurrences.get(group) ?? 0;
98
+ occurrences.set(group, occurrence + 1);
99
+ element.extras ?? (element.extras = {});
100
+ if (element.extras[NEEDLE_ID_KEY] === undefined) {
101
+ element.extras[NEEDLE_ID_KEY] = computeElementId(type, name, occurrence, baseHash);
102
+ assigned++;
103
+ }
104
+ }
105
+ }
106
+ return { baseHash, assigned };
107
+ }
108
+ /** Read an element's needle-id without computing anything. */
109
+ export function getElementId(element) {
110
+ const id = element?.extras?.[NEEDLE_ID_KEY];
111
+ return typeof id === "string" ? id : null;
112
+ }
113
+ // #endregion
114
+ // #region Live nodes (userData)
115
+ /** Read a live node's needle-id from `userData` — the live-object counterpart of
116
+ * {@link getElementId}, which reads glTF `extras`. */
117
+ export function getNodeId(target) {
118
+ const id = target?.userData?.[NEEDLE_ID_KEY];
119
+ return typeof id === "string" ? id : null;
120
+ }
121
+ /** Ensure a live node carries a needle-id: returns the existing one or MINTS a
122
+ * random uuid into `userData`. Random by design — editor-created (scratch) content
123
+ * has no derivation basis; the id persists because exports write `userData` ids
124
+ * into glTF `extras` (create → save → reload sees the same id). */
125
+ export function ensureNodeId(target) {
126
+ const userData = (target.userData ?? (target.userData = {}));
127
+ const existing = userData[NEEDLE_ID_KEY];
128
+ if (typeof existing === "string")
129
+ return existing;
130
+ const id = randomUuid();
131
+ userData[NEEDLE_ID_KEY] = id;
132
+ return id;
133
+ }
134
+ /**
135
+ * Shed a subtree's AUTHORED identity — for clones (duplicate/paste) before they are
136
+ * re-stamped. Stamped needle-ids are deleted on every node: sibling id collisions
137
+ * break addressing (an id-addressed edit resolves to whichever holder comes first).
138
+ * With `componentKeys`, the same keys are deleted from `userData.components`
139
+ * entries (a copied component guid would bake the ORIGINAL's identity into the
140
+ * clone's next export). Fresh ids are minted by whatever stamps next.
141
+ */
142
+ export function shedNodeIdentity(root, options) {
143
+ const componentKeys = options?.componentKeys ?? [];
144
+ const visit = (node) => {
145
+ const userData = node.userData;
146
+ if (userData) {
147
+ if (typeof userData[NEEDLE_ID_KEY] === "string")
148
+ delete userData[NEEDLE_ID_KEY];
149
+ const components = userData["components"];
150
+ if (Array.isArray(components)) {
151
+ for (const component of components) {
152
+ if (!component || typeof component !== "object")
153
+ continue;
154
+ for (const key of componentKeys) {
155
+ if (component[key] !== undefined)
156
+ delete component[key];
157
+ }
158
+ }
159
+ }
160
+ }
161
+ for (const child of node.children ?? [])
162
+ visit(child);
163
+ };
164
+ visit(root);
165
+ }
166
+ // #endregion
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { ANCHOR_MARKER, ANCHOR_MARKERS, parseAnchor, parseAnchorValue, type ParsedAnchor } from "./anchor.js";
2
- export { computeElementId, occurrenceOfName } from "./id.js";
3
- export { ensureIds, getElementId, NEEDLE_ID_KEY, STAMPED_COLLECTIONS, type EnsureIdsOptions, type EnsureIdsResult, type GltfElement, type GltfJson, type StampedElementType, } from "./gltf.js";
4
- export { collectionKeyForType, createGltfElementAddress, createObjectAddress, findPath, type GraphNode, type NeedleAddress, } from "./address.js";
2
+ export { computeElementId, ensureIds, ensureNodeId, getElementId, getNodeId, NEEDLE_ID_KEY, occurrenceOfName, shedNodeIdentity, type EnsureIdsOptions, type EnsureIdsResult, type ShedIdentityOptions, } from "./id.js";
3
+ export { STAMPED_COLLECTIONS, type GltfElement, type GltfJson, type StampedElementType, } from "./gltf.js";
4
+ export { collectionKeyForType, createGltfElementAddress, createObjectAddress, decodePathSegment, encodePathSegment, findPath, joinPath, splitPath, type GraphNode, type NeedleAddress, } from "./address.js";
5
5
  export { reconcileInGraph, resolveInCollection, resolveInGltf, resolveInGraph, type AddressableElement, type GltfResolveResult, type ReconcileCandidate, type ResolutionIssue, type ResolutionRung, type ResolveOptions, } from "./resolve.js";
6
+ export { targetFromString, targetToString, type AddressTarget } from "./target.js";
6
7
  export { randomUuid, uuidv5, UUID_NAMESPACE_URL } from "./uuid.js";
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export { ANCHOR_MARKER, ANCHOR_MARKERS, parseAnchor, parseAnchorValue } from "./anchor.js";
2
- export { computeElementId, occurrenceOfName } from "./id.js";
3
- export { ensureIds, getElementId, NEEDLE_ID_KEY, STAMPED_COLLECTIONS, } from "./gltf.js";
4
- export { collectionKeyForType, createGltfElementAddress, createObjectAddress, findPath, } from "./address.js";
2
+ export { computeElementId, ensureIds, ensureNodeId, getElementId, getNodeId, NEEDLE_ID_KEY, occurrenceOfName, shedNodeIdentity, } from "./id.js";
3
+ export { STAMPED_COLLECTIONS, } from "./gltf.js";
4
+ export { collectionKeyForType, createGltfElementAddress, createObjectAddress, decodePathSegment, encodePathSegment, findPath, joinPath, splitPath, } from "./address.js";
5
5
  export { reconcileInGraph, resolveInCollection, resolveInGltf, resolveInGraph, } from "./resolve.js";
6
+ export { targetFromString, targetToString } from "./target.js";
6
7
  export { randomUuid, uuidv5, UUID_NAMESPACE_URL } from "./uuid.js";
package/dist/resolve.d.ts CHANGED
@@ -7,13 +7,13 @@ export type GltfResolveResult = {
7
7
  };
8
8
  /**
9
9
  * Resolve an address inside glTF JSON. Same ladder as the live graph —
10
- * **anchor → id → namePath** (nodes) — with the anchor winning only when it matches
10
+ * **anchor → id → path** (nodes) — with the anchor winning only when it matches
11
11
  * exactly one element. Returns null when nothing matches exactly (reconciliation is a
12
12
  * separate, explicit step — resolution never guesses).
13
13
  */
14
14
  export declare function resolveInGltf(address: NeedleAddress, json: GltfJson, options?: ResolveOptions): GltfResolveResult | null;
15
15
  /** which rung of the ladder produced (or could have produced) a match */
16
- export type ResolutionRung = "id" | "anchor" | "namePath" | "name";
16
+ export type ResolutionRung = "id" | "anchor" | "path" | "name";
17
17
  /**
18
18
  * Something a caller may want to show a human. Never an error — resolution still returns
19
19
  * its best answer; these say the answer was not the only defensible one.
@@ -50,7 +50,7 @@ export type ResolveOptions = {
50
50
  /**
51
51
  * Resolve an address inside a live scene graph (duck-typed — any three.js).
52
52
  *
53
- * Ladder: **anchor → id → namePath**. The anchor comes first because it is a deliberate,
53
+ * Ladder: **anchor → id → path**. The anchor comes first because it is a deliberate,
54
54
  * human-authored declaration, while an id may be a derived value whose basis shifted when
55
55
  * the content changed. It only wins when it resolves UNIQUELY: anchors are labels and may
56
56
  * legally repeat, and an ambiguous label must never outrank an exact key.
@@ -65,7 +65,7 @@ export type AddressableElement = {
65
65
  * Resolve an address against a FLAT collection of live elements — materials, textures and
66
66
  * anything else that carries a name and `userData` but is not part of the scene graph.
67
67
  *
68
- * Same ladder as the graph, minus `namePath` (there is no hierarchy to walk) plus `name`
68
+ * Same ladder as the graph, minus `path` (there is no hierarchy to walk) plus `name`
69
69
  * as the final rung, since a flat collection has no positional fallback. This exists so
70
70
  * every element type resolves through ONE implementation: materials previously had their
71
71
  * own ladders, could not be anchored at all, and disagreed with each other about whether a
@@ -81,6 +81,6 @@ export type ReconcileCandidate = {
81
81
  /**
82
82
  * When exact resolution fails (the asset changed), propose candidates instead of
83
83
  * guessing: nodes matching the address's name, ranked by how much of the recorded
84
- * namePath still matches. Callers decide thresholds and whether to re-anchor.
84
+ * path still matches. Callers decide thresholds and whether to re-anchor.
85
85
  */
86
86
  export declare function reconcileInGraph(address: NeedleAddress, root: GraphNode): ReconcileCandidate[];
package/dist/resolve.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { parseAnchorValue } from "./anchor.js";
2
- import { collectionKeyForType } from "./address.js";
3
- import { NEEDLE_ID_KEY, STAMPED_COLLECTIONS } from "./gltf.js";
2
+ import { collectionKeyForType, splitPath } from "./address.js";
3
+ import { NEEDLE_ID_KEY } from "./id.js";
4
+ import { STAMPED_COLLECTIONS } from "./gltf.js";
4
5
  /**
5
6
  * Resolve an address inside glTF JSON. Same ladder as the live graph —
6
- * **anchor → id → namePath** (nodes) — with the anchor winning only when it matches
7
+ * **anchor → id → path** (nodes) — with the anchor winning only when it matches
7
8
  * exactly one element. Returns null when nothing matches exactly (reconciliation is a
8
9
  * separate, explicit step — resolution never guesses).
9
10
  */
@@ -49,8 +50,8 @@ export function resolveInGltf(address, json, options) {
49
50
  }
50
51
  if (byId)
51
52
  return byId;
52
- if (address.namePath?.length && (!address.type || address.type === "node")) {
53
- const index = walkGltfNamePath(json, address.namePath);
53
+ if (address.path !== undefined && (!address.type || address.type === "node")) {
54
+ const index = walkGltfNamePath(json, splitPath(address.path));
54
55
  if (index !== null) {
55
56
  const nodes = json["nodes"];
56
57
  return { type: "node", index, element: nodes[index] };
@@ -61,7 +62,7 @@ export function resolveInGltf(address, json, options) {
61
62
  /**
62
63
  * Resolve an address inside a live scene graph (duck-typed — any three.js).
63
64
  *
64
- * Ladder: **anchor → id → namePath**. The anchor comes first because it is a deliberate,
65
+ * Ladder: **anchor → id → path**. The anchor comes first because it is a deliberate,
65
66
  * human-authored declaration, while an id may be a derived value whose basis shifted when
66
67
  * the content changed. It only wins when it resolves UNIQUELY: anchors are labels and may
67
68
  * legally repeat, and an ambiguous label must never outrank an exact key.
@@ -91,9 +92,9 @@ export function resolveInGraph(address, root, options) {
91
92
  }
92
93
  if (byId)
93
94
  return byId;
94
- if (address.namePath?.length) {
95
+ if (address.path !== undefined) {
95
96
  let current = root;
96
- for (const segment of address.namePath) {
97
+ for (const segment of splitPath(address.path)) {
97
98
  current = current.children?.find(c => (c.name ?? "") === segment) ?? null;
98
99
  if (!current)
99
100
  break;
@@ -107,7 +108,7 @@ export function resolveInGraph(address, root, options) {
107
108
  * Resolve an address against a FLAT collection of live elements — materials, textures and
108
109
  * anything else that carries a name and `userData` but is not part of the scene graph.
109
110
  *
110
- * Same ladder as the graph, minus `namePath` (there is no hierarchy to walk) plus `name`
111
+ * Same ladder as the graph, minus `path` (there is no hierarchy to walk) plus `name`
111
112
  * as the final rung, since a flat collection has no positional fallback. This exists so
112
113
  * every element type resolves through ONE implementation: materials previously had their
113
114
  * own ladders, could not be anchored at all, and disagreed with each other about whether a
@@ -145,20 +146,21 @@ export function resolveInCollection(address, elements, options) {
145
146
  /**
146
147
  * When exact resolution fails (the asset changed), propose candidates instead of
147
148
  * guessing: nodes matching the address's name, ranked by how much of the recorded
148
- * namePath still matches. Callers decide thresholds and whether to re-anchor.
149
+ * path still matches. Callers decide thresholds and whether to re-anchor.
149
150
  */
150
151
  export function reconcileInGraph(address, root) {
151
- if (!address.name && !address.namePath?.length)
152
+ const addressSegments = address.path !== undefined ? splitPath(address.path) : null;
153
+ if (!address.name && !addressSegments?.length)
152
154
  return [];
153
- const targetName = address.name ?? address.namePath[address.namePath.length - 1];
155
+ const targetName = address.name ?? addressSegments[addressSegments.length - 1];
154
156
  const candidates = [];
155
- collectDescendants(root, (node, namePath) => {
157
+ collectDescendants(root, (node, segments) => {
156
158
  if ((node.name ?? "") !== targetName)
157
159
  return;
158
160
  const reasons = [`name matches "${targetName}"`];
159
161
  let confidence = 0.5;
160
- if (address.namePath?.length) {
161
- const similarity = pathSuffixSimilarity(address.namePath, namePath);
162
+ if (addressSegments?.length) {
163
+ const similarity = pathSuffixSimilarity(addressSegments, segments);
162
164
  confidence += 0.4 * similarity;
163
165
  if (similarity > 0)
164
166
  reasons.push(`path suffix similarity ${(similarity * 100).toFixed(0)}%`);
@@ -197,7 +199,7 @@ function findInCollection(json, key, predicate) {
197
199
  }
198
200
  return null;
199
201
  }
200
- function walkGltfNamePath(json, namePath) {
202
+ function walkGltfNamePath(json, segments) {
201
203
  const nodes = json["nodes"];
202
204
  const scenes = json["scenes"];
203
205
  if (!Array.isArray(nodes) || !Array.isArray(scenes))
@@ -205,7 +207,7 @@ function walkGltfNamePath(json, namePath) {
205
207
  // roots: the union of all scenes' root nodes
206
208
  let candidates = scenes.flatMap(s => (Array.isArray(s?.nodes) ? s.nodes : []));
207
209
  let match = null;
208
- for (const segment of namePath) {
210
+ for (const segment of segments) {
209
211
  match = candidates.find(i => ((nodes[i]?.name ?? "") === segment)) ?? null;
210
212
  if (match === null)
211
213
  return null;
@@ -259,19 +261,19 @@ function findInGraph(root, predicate, visited = new Set()) {
259
261
  /** visit every descendant of root with its root-exclusive name path (including own name) */
260
262
  function collectDescendants(root, visit) {
261
263
  const visited = new Set();
262
- const walk = (node, namePath) => {
264
+ const walk = (node, segments) => {
263
265
  if (visited.has(node))
264
266
  return;
265
267
  visited.add(node);
266
268
  if (node !== root)
267
- visit(node, namePath);
269
+ visit(node, segments);
268
270
  if (!node.children)
269
271
  return;
270
272
  for (let i = 0; i < node.children.length; i++) {
271
273
  const child = node.children[i];
272
274
  if (child == null)
273
275
  continue;
274
- walk(child, [...namePath, child.name ?? ""]);
276
+ walk(child, [...segments, child.name ?? ""]);
275
277
  }
276
278
  };
277
279
  walk(root, []);
@@ -0,0 +1,29 @@
1
+ import type { NeedleAddress } from "./address.js";
2
+ /**
3
+ * The canonical STRING form of a document target — the ONE print/parse pair
4
+ * for `{ ref?, address? }` (plans/one-address-vocabulary.md: one vocabulary;
5
+ * this is a second wire FORM of it, never a second dialect — no other code
6
+ * may format or parse these tokens).
7
+ *
8
+ * Grammar (rungs optional, fixed order):
9
+ *
10
+ * "@" [ref] [ "/" [type ":"] [id] ["#" anchor] ["~" name] ["!" path] ]
11
+ *
12
+ * - `@` leads every token — the RETIRED session grammars ("<uuid>/<path>"
13
+ * handles, bare hierarchy ids) can never half-parse as a target; a stale
14
+ * token fails loud.
15
+ * - the `node` type is implicit (omitted on print, re-minted on parse) —
16
+ * the canonical form normalizes a type-less address to `type: "node"`.
17
+ * - ref/id/anchor/name are token-escaped; the PATH rung is last and
18
+ * verbatim (it is already the stored escaped form from joinPath and may
19
+ * contain any character).
20
+ */
21
+ export type AddressTarget = {
22
+ ref?: string;
23
+ address?: NeedleAddress;
24
+ };
25
+ /** print a target as its canonical token — null when there is nothing to name */
26
+ export declare function targetToString(target: AddressTarget): string | null;
27
+ /** parse a canonical token back to the target — null for anything this module
28
+ * did not print (retired grammars, garbage): a loud miss, never a half-parse */
29
+ export declare function targetFromString(token: string): AddressTarget | null;