@needle-tools/addressing 0.3.1 → 0.3.3

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/src/resolve.ts CHANGED
@@ -9,27 +9,48 @@ export type GltfResolveResult = {
9
9
  };
10
10
 
11
11
  /**
12
- * Resolve an address inside glTF JSON. Tries id → anchor → namePath (nodes), in that
13
- * order; returns null when nothing matches exactly (reconciliation is a separate,
14
- * explicit step — resolution never guesses).
12
+ * 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
+ * exactly one element. Returns null when nothing matches exactly (reconciliation is a
15
+ * separate, explicit step — resolution never guesses).
15
16
  */
16
- export function resolveInGltf(address: NeedleAddress, json: GltfJson): GltfResolveResult | null {
17
+ export function resolveInGltf(address: NeedleAddress, json: GltfJson, options?: ResolveOptions): GltfResolveResult | null {
18
+ const report = options?.report;
17
19
  const searched: Array<readonly [string, string]> = address.type
18
20
  ? [[address.type, collectionKeyForType(address.type)]]
19
21
  : [...STAMPED_COLLECTIONS];
20
22
 
23
+ let byAnchor: GltfResolveResult | null = null;
24
+ if (address.anchor) {
25
+ const matches: GltfResolveResult[] = [];
26
+ for (const [type, key] of searched) {
27
+ for (const found of collectInJsonCollection(json, key, el => parseAnchorValue(el.name as string | undefined) === address.anchor)) {
28
+ matches.push({ type, index: found.index, element: found.element });
29
+ if (matches.length > 1 && !report) break;
30
+ }
31
+ if (matches.length > 1 && !report) break;
32
+ }
33
+ if (matches.length === 1) byAnchor = matches[0];
34
+ else if (matches.length > 1) report?.({ kind: "ambiguous-anchor", anchor: address.anchor, count: matches.length });
35
+ }
36
+ if (byAnchor && !report) return byAnchor;
37
+
38
+ let byId: GltfResolveResult | null = null;
21
39
  if (address.id) {
22
40
  for (const [type, key] of searched) {
23
41
  const found = findInCollection(json, key, el => el.extras?.[NEEDLE_ID_KEY] === address.id);
24
- if (found) return { type, index: found.index, element: found.element };
42
+ if (found) { byId = { type, index: found.index, element: found.element }; break; }
25
43
  }
26
44
  }
27
- if (address.anchor) {
28
- for (const [type, key] of searched) {
29
- const found = findInCollection(json, key, el => parseAnchorValue(el.name as string | undefined) === address.anchor);
30
- if (found) return { type, index: found.index, element: found.element };
45
+
46
+ if (byAnchor) {
47
+ if (byId && (byId.type !== byAnchor.type || byId.index !== byAnchor.index)) {
48
+ report?.({ kind: "conflict", preferred: "anchor", other: "id" });
31
49
  }
50
+ return byAnchor;
32
51
  }
52
+ if (byId) return byId;
53
+
33
54
  if (address.namePath?.length && (!address.type || address.type === "node")) {
34
55
  const index = walkGltfNamePath(json, address.namePath);
35
56
  if (index !== null) {
@@ -40,19 +61,63 @@ export function resolveInGltf(address: NeedleAddress, json: GltfJson): GltfResol
40
61
  return null;
41
62
  }
42
63
 
64
+ /** which rung of the ladder produced (or could have produced) a match */
65
+ export type ResolutionRung = "id" | "anchor" | "namePath" | "name";
66
+
67
+ /**
68
+ * Something a caller may want to show a human. Never an error — resolution still returns
69
+ * its best answer; these say the answer was not the only defensible one.
70
+ */
71
+ export type ResolutionIssue =
72
+ /** an anchor matched several elements, so it could not outrank an exact key */
73
+ | { kind: "ambiguous-anchor"; anchor: string; count: number }
74
+ /** a name matched several elements in a flat collection */
75
+ | { kind: "ambiguous-name"; name: string; count: number }
76
+ /** two rungs resolved to DIFFERENT elements — the content changed underneath */
77
+ | { kind: "conflict"; preferred: ResolutionRung; other: ResolutionRung };
78
+
79
+ export type ResolveOptions = {
80
+ /**
81
+ * Collect ambiguity/conflict evidence. OPT-IN because it costs extra traversals:
82
+ * detecting that two rungs disagree means evaluating both instead of stopping at the
83
+ * first hit, and resolution already walks from the root per address. The runtime
84
+ * short-circuits and pays nothing; the editor asks for evidence so it can surface
85
+ * drift to the user.
86
+ */
87
+ report?: (issue: ResolutionIssue) => void;
88
+ };
89
+
43
90
  /**
44
91
  * Resolve an address inside a live scene graph (duck-typed — any three.js).
45
- * Same order as glTF resolution: id → anchor → namePath.
92
+ *
93
+ * Ladder: **anchor → id → namePath**. The anchor comes first because it is a deliberate,
94
+ * human-authored declaration, while an id may be a derived value whose basis shifted when
95
+ * the content changed. It only wins when it resolves UNIQUELY: anchors are labels and may
96
+ * legally repeat, and an ambiguous label must never outrank an exact key.
46
97
  */
47
- export function resolveInGraph(address: NeedleAddress, root: GraphNode): GraphNode | null {
48
- if (address.id) {
49
- const byId = findInGraph(root, n => n.userData?.[NEEDLE_ID_KEY] === address.id);
50
- if (byId) return byId;
51
- }
98
+ export function resolveInGraph(address: NeedleAddress, root: GraphNode, options?: ResolveOptions): GraphNode | null {
99
+ const report = options?.report;
100
+
101
+ let byAnchor: GraphNode | null = null;
52
102
  if (address.anchor) {
53
- const byAnchor = findInGraph(root, n => parseAnchorValue(n.name) === address.anchor);
54
- if (byAnchor) return byAnchor;
103
+ // without a reporter, stop at two — "exactly one" vs "more than one" is all the
104
+ // ladder needs, and the count only matters for the message
105
+ const matches = collectInGraph(root, n => parseAnchorValue(n.name) === address.anchor, report ? Infinity : 2);
106
+ if (matches.length === 1) byAnchor = matches[0];
107
+ else if (matches.length > 1) report?.({ kind: "ambiguous-anchor", anchor: address.anchor, count: matches.length });
108
+ }
109
+ // fast path: nothing left to learn unless the caller wants the disagreement reported
110
+ if (byAnchor && !report) return byAnchor;
111
+
112
+ let byId: GraphNode | null = null;
113
+ if (address.id) byId = findInGraph(root, n => n.userData?.[NEEDLE_ID_KEY] === address.id);
114
+
115
+ if (byAnchor) {
116
+ if (byId && byId !== byAnchor) report?.({ kind: "conflict", preferred: "anchor", other: "id" });
117
+ return byAnchor;
55
118
  }
119
+ if (byId) return byId;
120
+
56
121
  if (address.namePath?.length) {
57
122
  let current: GraphNode | null = root;
58
123
  for (const segment of address.namePath) {
@@ -64,6 +129,49 @@ export function resolveInGraph(address: NeedleAddress, root: GraphNode): GraphNo
64
129
  return null;
65
130
  }
66
131
 
132
+ /** duck-typed view of an element that is not a graph node (a material, texture, …) */
133
+ export type AddressableElement = {
134
+ name?: string;
135
+ userData?: Record<string, unknown>;
136
+ };
137
+
138
+ /**
139
+ * Resolve an address against a FLAT collection of live elements — materials, textures and
140
+ * anything else that carries a name and `userData` but is not part of the scene graph.
141
+ *
142
+ * Same ladder as the graph, minus `namePath` (there is no hierarchy to walk) plus `name`
143
+ * as the final rung, since a flat collection has no positional fallback. This exists so
144
+ * every element type resolves through ONE implementation: materials previously had their
145
+ * own ladders, could not be anchored at all, and disagreed with each other about whether a
146
+ * name match had to be unique.
147
+ */
148
+ export function resolveInCollection<T extends AddressableElement>(address: NeedleAddress, elements: readonly T[], options?: ResolveOptions): T | null {
149
+ const report = options?.report;
150
+
151
+ let byAnchor: T | null = null;
152
+ if (address.anchor) {
153
+ const matches = elements.filter(e => parseAnchorValue(e.name) === address.anchor);
154
+ if (matches.length === 1) byAnchor = matches[0];
155
+ else if (matches.length > 1) report?.({ kind: "ambiguous-anchor", anchor: address.anchor, count: matches.length });
156
+ }
157
+ if (byAnchor && !report) return byAnchor;
158
+
159
+ const byId = address.id ? elements.find(e => e.userData?.[NEEDLE_ID_KEY] === address.id) ?? null : null;
160
+
161
+ if (byAnchor) {
162
+ if (byId && byId !== byAnchor) report?.({ kind: "conflict", preferred: "anchor", other: "id" });
163
+ return byAnchor;
164
+ }
165
+ if (byId) return byId;
166
+
167
+ if (address.name) {
168
+ const named = elements.filter(e => e.name === address.name);
169
+ if (named.length === 1) return named[0];
170
+ if (named.length > 1) report?.({ kind: "ambiguous-name", name: address.name, count: named.length });
171
+ }
172
+ return null;
173
+ }
174
+
67
175
  export type ReconcileCandidate = {
68
176
  object: GraphNode;
69
177
  /** 0..1 — heuristic, for ranking and thresholds; 1.0 is reserved for exact resolution */
@@ -99,6 +207,20 @@ export function reconcileInGraph(address: NeedleAddress, root: GraphNode): Recon
99
207
  return candidates.sort((a, b) => b.confidence - a.confidence);
100
208
  }
101
209
 
210
+ /** every element of one glTF collection matching `predicate` */
211
+ function collectInJsonCollection(json: GltfJson, key: string, predicate: (el: Record<string, any>) => boolean): Array<{ index: number; element: Record<string, unknown> }> {
212
+ const collection = json[key];
213
+ if (!Array.isArray(collection)) return [];
214
+ const found: Array<{ index: number; element: Record<string, unknown> }> = [];
215
+ for (let i = 0; i < collection.length; i++) {
216
+ const element = collection[i];
217
+ if (element != null && typeof element === "object" && predicate(element)) {
218
+ found.push({ index: i, element });
219
+ }
220
+ }
221
+ return found;
222
+ }
223
+
102
224
  function findInCollection(json: GltfJson, key: string, predicate: (el: Record<string, any>) => boolean): { index: number; element: Record<string, unknown> } | null {
103
225
  const collection = json[key];
104
226
  if (!Array.isArray(collection)) return null;
@@ -127,6 +249,27 @@ function walkGltfNamePath(json: GltfJson, namePath: string[]): number | null {
127
249
  return match;
128
250
  }
129
251
 
252
+ /** every node matching `predicate`, stopping once `limit` are found (cycle-safe) */
253
+ function collectInGraph(root: GraphNode, predicate: (node: GraphNode) => boolean, limit: number): GraphNode[] {
254
+ const found: GraphNode[] = [];
255
+ const visited = new Set<GraphNode>();
256
+ const walk = (node: GraphNode) => {
257
+ if (found.length >= limit || visited.has(node)) return;
258
+ visited.add(node);
259
+ if (predicate(node)) {
260
+ found.push(node);
261
+ if (found.length >= limit) return;
262
+ }
263
+ if (!node.children) return;
264
+ for (let i = 0; i < node.children.length; i++) {
265
+ const child = node.children[i];
266
+ if (child != null) walk(child);
267
+ }
268
+ };
269
+ walk(root);
270
+ return found;
271
+ }
272
+
130
273
  function findInGraph(root: GraphNode, predicate: (node: GraphNode) => boolean, visited = new Set<GraphNode>()): GraphNode | null {
131
274
  if (visited.has(root)) return null;
132
275
  visited.add(root);