@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.
@@ -0,0 +1,288 @@
1
+ import { parseAnchorValue } from "./anchor.js";
2
+ import { collectionKeyForType } from "./address.js";
3
+ import { NEEDLE_ID_KEY, STAMPED_COLLECTIONS } from "./gltf.js";
4
+ /**
5
+ * 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
+ * exactly one element. Returns null when nothing matches exactly (reconciliation is a
8
+ * separate, explicit step — resolution never guesses).
9
+ */
10
+ export function resolveInGltf(address, json, options) {
11
+ const report = options?.report;
12
+ const searched = address.type
13
+ ? [[address.type, collectionKeyForType(address.type)]]
14
+ : [...STAMPED_COLLECTIONS];
15
+ let byAnchor = null;
16
+ if (address.anchor) {
17
+ const matches = [];
18
+ for (const [type, key] of searched) {
19
+ for (const found of collectInJsonCollection(json, key, el => parseAnchorValue(el.name) === address.anchor)) {
20
+ matches.push({ type, index: found.index, element: found.element });
21
+ if (matches.length > 1 && !report)
22
+ break;
23
+ }
24
+ if (matches.length > 1 && !report)
25
+ break;
26
+ }
27
+ if (matches.length === 1)
28
+ byAnchor = matches[0];
29
+ else if (matches.length > 1)
30
+ report?.({ kind: "ambiguous-anchor", anchor: address.anchor, count: matches.length });
31
+ }
32
+ if (byAnchor && !report)
33
+ return byAnchor;
34
+ let byId = null;
35
+ if (address.id) {
36
+ for (const [type, key] of searched) {
37
+ const found = findInCollection(json, key, el => el.extras?.[NEEDLE_ID_KEY] === address.id);
38
+ if (found) {
39
+ byId = { type, index: found.index, element: found.element };
40
+ break;
41
+ }
42
+ }
43
+ }
44
+ if (byAnchor) {
45
+ if (byId && (byId.type !== byAnchor.type || byId.index !== byAnchor.index)) {
46
+ report?.({ kind: "conflict", preferred: "anchor", other: "id" });
47
+ }
48
+ return byAnchor;
49
+ }
50
+ if (byId)
51
+ return byId;
52
+ if (address.namePath?.length && (!address.type || address.type === "node")) {
53
+ const index = walkGltfNamePath(json, address.namePath);
54
+ if (index !== null) {
55
+ const nodes = json["nodes"];
56
+ return { type: "node", index, element: nodes[index] };
57
+ }
58
+ }
59
+ return null;
60
+ }
61
+ /**
62
+ * Resolve an address inside a live scene graph (duck-typed — any three.js).
63
+ *
64
+ * Ladder: **anchor → id → namePath**. The anchor comes first because it is a deliberate,
65
+ * human-authored declaration, while an id may be a derived value whose basis shifted when
66
+ * the content changed. It only wins when it resolves UNIQUELY: anchors are labels and may
67
+ * legally repeat, and an ambiguous label must never outrank an exact key.
68
+ */
69
+ export function resolveInGraph(address, root, options) {
70
+ const report = options?.report;
71
+ let byAnchor = null;
72
+ if (address.anchor) {
73
+ // without a reporter, stop at two — "exactly one" vs "more than one" is all the
74
+ // ladder needs, and the count only matters for the message
75
+ const matches = collectInGraph(root, n => parseAnchorValue(n.name) === address.anchor, report ? Infinity : 2);
76
+ if (matches.length === 1)
77
+ byAnchor = matches[0];
78
+ else if (matches.length > 1)
79
+ report?.({ kind: "ambiguous-anchor", anchor: address.anchor, count: matches.length });
80
+ }
81
+ // fast path: nothing left to learn unless the caller wants the disagreement reported
82
+ if (byAnchor && !report)
83
+ return byAnchor;
84
+ let byId = null;
85
+ if (address.id)
86
+ byId = findInGraph(root, n => n.userData?.[NEEDLE_ID_KEY] === address.id);
87
+ if (byAnchor) {
88
+ if (byId && byId !== byAnchor)
89
+ report?.({ kind: "conflict", preferred: "anchor", other: "id" });
90
+ return byAnchor;
91
+ }
92
+ if (byId)
93
+ return byId;
94
+ if (address.namePath?.length) {
95
+ let current = root;
96
+ for (const segment of address.namePath) {
97
+ current = current.children?.find(c => (c.name ?? "") === segment) ?? null;
98
+ if (!current)
99
+ break;
100
+ }
101
+ if (current && current !== root)
102
+ return current;
103
+ }
104
+ return null;
105
+ }
106
+ /**
107
+ * Resolve an address against a FLAT collection of live elements — materials, textures and
108
+ * anything else that carries a name and `userData` but is not part of the scene graph.
109
+ *
110
+ * Same ladder as the graph, minus `namePath` (there is no hierarchy to walk) plus `name`
111
+ * as the final rung, since a flat collection has no positional fallback. This exists so
112
+ * every element type resolves through ONE implementation: materials previously had their
113
+ * own ladders, could not be anchored at all, and disagreed with each other about whether a
114
+ * name match had to be unique.
115
+ */
116
+ export function resolveInCollection(address, elements, options) {
117
+ const report = options?.report;
118
+ let byAnchor = null;
119
+ if (address.anchor) {
120
+ const matches = elements.filter(e => parseAnchorValue(e.name) === address.anchor);
121
+ if (matches.length === 1)
122
+ byAnchor = matches[0];
123
+ else if (matches.length > 1)
124
+ report?.({ kind: "ambiguous-anchor", anchor: address.anchor, count: matches.length });
125
+ }
126
+ if (byAnchor && !report)
127
+ return byAnchor;
128
+ const byId = address.id ? elements.find(e => e.userData?.[NEEDLE_ID_KEY] === address.id) ?? null : null;
129
+ if (byAnchor) {
130
+ if (byId && byId !== byAnchor)
131
+ report?.({ kind: "conflict", preferred: "anchor", other: "id" });
132
+ return byAnchor;
133
+ }
134
+ if (byId)
135
+ return byId;
136
+ if (address.name) {
137
+ const named = elements.filter(e => e.name === address.name);
138
+ if (named.length === 1)
139
+ return named[0];
140
+ if (named.length > 1)
141
+ report?.({ kind: "ambiguous-name", name: address.name, count: named.length });
142
+ }
143
+ return null;
144
+ }
145
+ /**
146
+ * When exact resolution fails (the asset changed), propose candidates instead of
147
+ * 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
+ */
150
+ export function reconcileInGraph(address, root) {
151
+ if (!address.name && !address.namePath?.length)
152
+ return [];
153
+ const targetName = address.name ?? address.namePath[address.namePath.length - 1];
154
+ const candidates = [];
155
+ collectDescendants(root, (node, namePath) => {
156
+ if ((node.name ?? "") !== targetName)
157
+ return;
158
+ const reasons = [`name matches "${targetName}"`];
159
+ let confidence = 0.5;
160
+ if (address.namePath?.length) {
161
+ const similarity = pathSuffixSimilarity(address.namePath, namePath);
162
+ confidence += 0.4 * similarity;
163
+ if (similarity > 0)
164
+ reasons.push(`path suffix similarity ${(similarity * 100).toFixed(0)}%`);
165
+ }
166
+ candidates.push({ object: node, confidence, reasons });
167
+ });
168
+ if (candidates.length === 1) {
169
+ candidates[0].confidence = Math.min(0.95, candidates[0].confidence + 0.1);
170
+ candidates[0].reasons.push("only candidate with this name");
171
+ }
172
+ return candidates.sort((a, b) => b.confidence - a.confidence);
173
+ }
174
+ /** every element of one glTF collection matching `predicate` */
175
+ function collectInJsonCollection(json, key, predicate) {
176
+ const collection = json[key];
177
+ if (!Array.isArray(collection))
178
+ return [];
179
+ const found = [];
180
+ for (let i = 0; i < collection.length; i++) {
181
+ const element = collection[i];
182
+ if (element != null && typeof element === "object" && predicate(element)) {
183
+ found.push({ index: i, element });
184
+ }
185
+ }
186
+ return found;
187
+ }
188
+ function findInCollection(json, key, predicate) {
189
+ const collection = json[key];
190
+ if (!Array.isArray(collection))
191
+ return null;
192
+ for (let i = 0; i < collection.length; i++) {
193
+ const element = collection[i];
194
+ if (element != null && typeof element === "object" && predicate(element)) {
195
+ return { index: i, element };
196
+ }
197
+ }
198
+ return null;
199
+ }
200
+ function walkGltfNamePath(json, namePath) {
201
+ const nodes = json["nodes"];
202
+ const scenes = json["scenes"];
203
+ if (!Array.isArray(nodes) || !Array.isArray(scenes))
204
+ return null;
205
+ // roots: the union of all scenes' root nodes
206
+ let candidates = scenes.flatMap(s => (Array.isArray(s?.nodes) ? s.nodes : []));
207
+ let match = null;
208
+ for (const segment of namePath) {
209
+ match = candidates.find(i => ((nodes[i]?.name ?? "") === segment)) ?? null;
210
+ if (match === null)
211
+ return null;
212
+ const children = nodes[match]?.children;
213
+ candidates = Array.isArray(children) ? children : [];
214
+ }
215
+ return match;
216
+ }
217
+ /** every node matching `predicate`, stopping once `limit` are found (cycle-safe) */
218
+ function collectInGraph(root, predicate, limit) {
219
+ const found = [];
220
+ const visited = new Set();
221
+ const walk = (node) => {
222
+ if (found.length >= limit || visited.has(node))
223
+ return;
224
+ visited.add(node);
225
+ if (predicate(node)) {
226
+ found.push(node);
227
+ if (found.length >= limit)
228
+ return;
229
+ }
230
+ if (!node.children)
231
+ return;
232
+ for (let i = 0; i < node.children.length; i++) {
233
+ const child = node.children[i];
234
+ if (child != null)
235
+ walk(child);
236
+ }
237
+ };
238
+ walk(root);
239
+ return found;
240
+ }
241
+ function findInGraph(root, predicate, visited = new Set()) {
242
+ if (visited.has(root))
243
+ return null;
244
+ visited.add(root);
245
+ if (predicate(root))
246
+ return root;
247
+ if (!root.children)
248
+ return null;
249
+ for (let i = 0; i < root.children.length; i++) {
250
+ const child = root.children[i];
251
+ if (child == null)
252
+ continue;
253
+ const found = findInGraph(child, predicate, visited);
254
+ if (found)
255
+ return found;
256
+ }
257
+ return null;
258
+ }
259
+ /** visit every descendant of root with its root-exclusive name path (including own name) */
260
+ function collectDescendants(root, visit) {
261
+ const visited = new Set();
262
+ const walk = (node, namePath) => {
263
+ if (visited.has(node))
264
+ return;
265
+ visited.add(node);
266
+ if (node !== root)
267
+ visit(node, namePath);
268
+ if (!node.children)
269
+ return;
270
+ for (let i = 0; i < node.children.length; i++) {
271
+ const child = node.children[i];
272
+ if (child == null)
273
+ continue;
274
+ walk(child, [...namePath, child.name ?? ""]);
275
+ }
276
+ };
277
+ walk(root, []);
278
+ }
279
+ /** how much of the expected path's tail matches the actual path's tail (0..1) */
280
+ function pathSuffixSimilarity(expected, actual) {
281
+ let shared = 0;
282
+ while (shared < expected.length &&
283
+ shared < actual.length &&
284
+ expected[expected.length - 1 - shared] === actual[actual.length - 1 - shared]) {
285
+ shared++;
286
+ }
287
+ return shared / Math.max(expected.length, actual.length);
288
+ }
package/dist/uuid.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ /** RFC 4122 URL namespace — the namespace needle-cloud's `ensureUUID` uses (`uuidv5.URL`). */
2
+ export declare const UUID_NAMESPACE_URL = "6ba7b811-9dad-11d1-80b4-00c04fd430c8";
3
+ /**
4
+ * RFC 4122 version-5 (SHA-1, name-based) UUID. Byte-identical to the `uuid` npm
5
+ * package's `v5(name, namespace)`.
6
+ */
7
+ export declare function uuidv5(name: string, namespace?: string): string;
8
+ /**
9
+ * Random UUID for content that has no source file to be deterministic against
10
+ * (scratch content created in an editor). Uses the platform's crypto when available.
11
+ */
12
+ export declare function randomUuid(): string;
package/dist/uuid.js ADDED
@@ -0,0 +1,49 @@
1
+ import { sha1, toHex } from "./hash.js";
2
+ /** RFC 4122 URL namespace — the namespace needle-cloud's `ensureUUID` uses (`uuidv5.URL`). */
3
+ export const UUID_NAMESPACE_URL = "6ba7b811-9dad-11d1-80b4-00c04fd430c8";
4
+ /**
5
+ * RFC 4122 version-5 (SHA-1, name-based) UUID. Byte-identical to the `uuid` npm
6
+ * package's `v5(name, namespace)`.
7
+ */
8
+ export function uuidv5(name, namespace = UUID_NAMESPACE_URL) {
9
+ const ns = parseUuidBytes(namespace);
10
+ const nameBytes = new TextEncoder().encode(name);
11
+ const input = new Uint8Array(ns.length + nameBytes.length);
12
+ input.set(ns);
13
+ input.set(nameBytes, ns.length);
14
+ const hash = sha1(input).slice(0, 16);
15
+ hash[6] = (hash[6] & 0x0f) | 0x50; // version 5
16
+ hash[8] = (hash[8] & 0x3f) | 0x80; // RFC 4122 variant
17
+ return formatUuid(hash);
18
+ }
19
+ /**
20
+ * Random UUID for content that has no source file to be deterministic against
21
+ * (scratch content created in an editor). Uses the platform's crypto when available.
22
+ */
23
+ export function randomUuid() {
24
+ const cryptoObj = globalThis.crypto;
25
+ if (cryptoObj?.randomUUID)
26
+ return cryptoObj.randomUUID();
27
+ const bytes = new Uint8Array(16);
28
+ if (cryptoObj?.getRandomValues)
29
+ cryptoObj.getRandomValues(bytes);
30
+ else
31
+ for (let i = 0; i < 16; i++)
32
+ bytes[i] = Math.floor(Math.random() * 256);
33
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
34
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
35
+ return formatUuid(bytes);
36
+ }
37
+ function parseUuidBytes(uuid) {
38
+ const hex = uuid.replace(/-/g, "");
39
+ if (hex.length !== 32)
40
+ throw new Error(`Invalid UUID: ${uuid}`);
41
+ const bytes = new Uint8Array(16);
42
+ for (let i = 0; i < 16; i++)
43
+ bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16);
44
+ return bytes;
45
+ }
46
+ function formatUuid(bytes) {
47
+ const hex = toHex(bytes);
48
+ return `${hex.substring(0, 8)}-${hex.substring(8, 12)}-${hex.substring(12, 16)}-${hex.substring(16, 20)}-${hex.substring(20)}`;
49
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@needle-tools/addressing",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
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",
@@ -8,16 +8,19 @@
8
8
  "exports": {
9
9
  ".": {
10
10
  "types": "./src/index.ts",
11
+ "node": "./dist/index.js",
11
12
  "default": "./src/index.ts"
12
13
  }
13
14
  },
14
15
  "files": [
16
+ "dist",
15
17
  "src",
16
18
  "Readme.md"
17
19
  ],
18
20
  "scripts": {
19
21
  "build": "tsc -p tsconfig.json",
20
- "test": "vitest run"
22
+ "test": "vitest run",
23
+ "prepack": "npm run build"
21
24
  },
22
25
  "author": {
23
26
  "name": "Needle",
package/src/address.ts CHANGED
@@ -42,7 +42,11 @@ export function createGltfElementAddress(json: GltfJson, type: StampedElementTyp
42
42
  if (id) address.id = id;
43
43
  const anchor = parseAnchorValue(name);
44
44
  if (anchor) address.anchor = anchor;
45
- if (name !== undefined) address.name = name;
45
+ // 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
48
+ // standalone `name` field is dropped when empty.
49
+ if (name) address.name = name;
46
50
  if (type === "node") {
47
51
  const path = gltfNodeNamePath(json, index);
48
52
  if (path) address.namePath = path;
@@ -60,7 +64,8 @@ export function createObjectAddress(root: GraphNode, target: GraphNode): NeedleA
60
64
  if (typeof id === "string") address.id = id;
61
65
  const anchor = parseAnchorValue(target.name);
62
66
  if (anchor) address.anchor = anchor;
63
- if (typeof target.name === "string") address.name = target.name;
67
+ // empty name dropped — see createGltfElementAddress; namePath segments stay verbatim
68
+ if (target.name) address.name = target.name;
64
69
  address.namePath = path.map(n => n.name ?? "");
65
70
  return address;
66
71
  }
package/src/anchor.ts CHANGED
@@ -37,12 +37,27 @@ export function parseAnchor(rawName: string | null | undefined): ParsedAnchor |
37
37
  const lower = trimmed.toLowerCase();
38
38
  for (const marker of byLengthDescending(ANCHOR_MARKERS)) {
39
39
  if (!lower.endsWith(marker)) continue;
40
- const value = trimmed.substring(0, trimmed.length - marker.length).trim();
40
+ const value = normalizeName(trimmed.substring(0, trimmed.length - marker.length));
41
41
  return value.length > 0 ? { marker, value } : null;
42
42
  }
43
43
  return null;
44
44
  }
45
45
 
46
+ /**
47
+ * Names treat SPACES and UNDERSCORES as the same character (runs collapse to one space):
48
+ * three's GLTFLoader sanitizes node names on load (whitespace becomes "_"), so
49
+ * "Door Frame #needle" appears live as "Door_Frame_#needle" — without normalization a
50
+ * stamped anchor and the live anchor would never match. Applied at PARSE time, so id
51
+ * computation and resolution agree from one change point. Accepted trade: names differing
52
+ * only by space-vs-underscore are one name.
53
+ *
54
+ * Exported because id derivation applies the SAME rule to unanchored names (see
55
+ * {@link import("./id.js").idNameOf}) — one normalization, one change point.
56
+ */
57
+ export function normalizeName(value: string): string {
58
+ return value.replace(/[\s_]+/g, " ").trim();
59
+ }
60
+
46
61
  /**
47
62
  * Extract the anchor value from an element name, or null when the name carries no anchor.
48
63
  * Convenience over {@link parseAnchor} for callers that only need the id input.
package/src/gltf.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { computeElementId } from "./id.js";
1
+ import { computeElementId, idNameOf } from "./id.js";
2
2
 
3
3
  /** The glTF `extras` key carrying the persistent id. */
4
4
  export const NEEDLE_ID_KEY = "needle-id";
@@ -64,14 +64,16 @@ export function ensureIds(json: GltfJson, options?: EnsureIdsOptions): EnsureIds
64
64
  if (!Array.isArray(elements)) continue;
65
65
  // occurrence-of-name: the id input is the element's position among same-named
66
66
  // elements, NOT its collection index — uniquely named elements keep their ids
67
- // across reorders and asset updates
67
+ // across reorders and asset updates. Grouping is by the DERIVATION name
68
+ // (anchor marker stripped, normalized), matching occurrenceOfName.
68
69
  const occurrences = new Map<string, number>();
69
70
  for (let index = 0; index < elements.length; index++) {
70
71
  const element = elements[index] as GltfElement;
71
72
  if (element == null || typeof element !== "object") continue;
72
73
  const name = typeof element.name === "string" ? element.name : "";
73
- const occurrence = occurrences.get(name) ?? 0;
74
- occurrences.set(name, occurrence + 1);
74
+ const group = idNameOf(name);
75
+ const occurrence = occurrences.get(group) ?? 0;
76
+ occurrences.set(group, occurrence + 1);
75
77
  element.extras ??= {};
76
78
  if (element.extras[NEEDLE_ID_KEY] === undefined) {
77
79
  element.extras[NEEDLE_ID_KEY] = computeElementId(type, name, occurrence, baseHash);
package/src/id.ts CHANGED
@@ -1,45 +1,61 @@
1
- import { parseAnchorValue } from "./anchor.js";
1
+ import { normalizeName, parseAnchor } from "./anchor.js";
2
2
  import { uuidv5 } from "./uuid.js";
3
3
 
4
4
  /**
5
- * The deterministic needle-id of a glTF element.
5
+ * The name an id is derived FROM: the anchor marker removed (if any) and space/underscore
6
+ * runs collapsed.
6
7
  *
7
- * - Anchored name (`… #needle`): `uuidv5("<type>-<anchor>")` — survives ANY content
8
- * change while the name stays.
9
- * - Otherwise: `uuidv5("<type>-<name>-<occurrence>[-<baseHash>]")` where `occurrence`
10
- * is the element's position among SAME-NAMED elements of its type. Uniquely named
11
- * elements are therefore fully reorder-stable across file versions; duplicates shift
12
- * only when reordered within their own name group; unnamed elements are positional
13
- * among the unnamed.
14
- * - `baseHash` is an optional consumer-supplied salt (e.g. an asset/content id) that
15
- * scopes ids to an external identity. Omitted by default, so any tool stamping the
16
- * same element mints the same id with no coordination.
8
+ * The marker is stripped because **ids are minted as if no anchor existed** — an anchor is
9
+ * a LABEL, not an identity, and the two must be able to change independently. Both halves
10
+ * of that are load-bearing:
11
+ * - stripping the marker means ADDING or REMOVING `#needle` leaves the id untouched (under
12
+ * the previous rule, anchoring an object CHANGED its id — so the very act recommended for
13
+ * making something stable orphaned every edit already addressed to it);
14
+ * - {@link occurrenceOfName} groups on this same value, so anchoring ONE of two `Door`s
15
+ * does not shift the OTHER one's occurrence.
16
+ *
17
+ * Note what is decoupled: the id is independent of the anchor MARKER, not of the NAME.
18
+ * Renaming `Door #needle` → `Entrance #needle` still changes a DERIVED id; a STAMPED id is
19
+ * unaffected, since existing ids are never overwritten.
20
+ */
21
+ export function idNameOf(name: string | null | undefined): string {
22
+ if (typeof name !== "string") return "";
23
+ // an anchor's value is already marker-stripped and normalized
24
+ return parseAnchor(name)?.value ?? normalizeName(name);
25
+ }
26
+
27
+ /**
28
+ * The deterministic needle-id of a glTF element:
29
+ * `uuidv5("<type>-<idName>-<occurrence>[-<baseHash>]")`, where `idName` is the name with
30
+ * any anchor marker removed ({@link idNameOf}) and `occurrence` is the element's position
31
+ * among same-named elements of its type. Uniquely named elements are therefore fully
32
+ * reorder-stable across file versions; duplicates shift only when reordered within their
33
+ * own name group; unnamed elements are positional among the unnamed.
34
+ *
35
+ * `baseHash` is an optional consumer-supplied salt (e.g. an asset/content id) that scopes
36
+ * ids to an external identity. Omitted by default, so any tool stamping the same element
37
+ * mints the same id with no coordination.
17
38
  *
18
39
  * Ids are persisted in `extras["needle-id"]` and never overwritten once present —
19
40
  * whoever stamps first wins; this rule only governs never-stamped elements.
20
41
  */
21
42
  export function computeElementId(type: string, name: string | null | undefined, occurrence: number, baseHash?: string): string {
22
- const anchor = parseAnchorValue(name);
23
- const str = anchor !== null
24
- ? `${type}-${anchor}`
25
- : `${type}-${name ? name : ""}-${occurrence}${baseHash ? `-${baseHash}` : ""}`;
26
- return uuidv5(str);
43
+ return uuidv5(`${type}-${idNameOf(name)}-${occurrence}${baseHash ? `-${baseHash}` : ""}`);
27
44
  }
28
45
 
29
46
  /**
30
47
  * The occurrence of the element at `index` among same-named elements of its collection —
31
48
  * the positional input of {@link computeElementId}. Exposed so consumers computing ids
32
49
  * for individual elements (instead of stamping whole files) use the identical rule.
50
+ *
51
+ * Grouping is by {@link idNameOf}, so an anchored element counts in the same group as its
52
+ * unanchored namesakes.
33
53
  */
34
54
  export function occurrenceOfName(elements: ReadonlyArray<{ name?: unknown } | null | undefined>, index: number): number {
35
- const name = normalizedName(elements[index]);
55
+ const name = idNameOf(elements[index]?.name as string | null | undefined);
36
56
  let occurrence = 0;
37
57
  for (let i = 0; i < index; i++) {
38
- if (normalizedName(elements[i]) === name) occurrence++;
58
+ if (idNameOf(elements[i]?.name as string | null | undefined) === name) occurrence++;
39
59
  }
40
60
  return occurrence;
41
61
  }
42
-
43
- function normalizedName(element: { name?: unknown } | null | undefined): string {
44
- return typeof element?.name === "string" ? element.name : "";
45
- }
package/src/index.ts CHANGED
@@ -21,9 +21,14 @@ export {
21
21
  } from "./address.js";
22
22
  export {
23
23
  reconcileInGraph,
24
+ resolveInCollection,
24
25
  resolveInGltf,
25
26
  resolveInGraph,
27
+ type AddressableElement,
26
28
  type GltfResolveResult,
27
29
  type ReconcileCandidate,
30
+ type ResolutionIssue,
31
+ type ResolutionRung,
32
+ type ResolveOptions,
28
33
  } from "./resolve.js";
29
34
  export { randomUuid, uuidv5, UUID_NAMESPACE_URL } from "./uuid.js";