@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/Readme.md CHANGED
@@ -13,7 +13,8 @@ meshes, skins, textures, images, samplers) can carry a UUID in `extras["needle-i
13
13
 
14
14
  - **Anchored** — the element name ends with `#needle` (case-insensitive): the id derives
15
15
  only from the text before the marker → it **survives any content change** as long as
16
- the name stays.
16
+ the name stays. Spaces and underscores in the value are treated as equal (loaders
17
+ sanitize node names), so `Door Frame #needle` and `Door_Frame_#needle` share one id.
17
18
  - **Unanchored**: the id derives from the element's type, name, and its occurrence among
18
19
  same-named elements → uniquely named elements keep their ids across reorders and
19
20
  content updates; duplicates disambiguate by position within their name group; unnamed
@@ -0,0 +1,34 @@
1
+ import { type GltfJson, type StampedElementType } from "./gltf.js";
2
+ /**
3
+ * A persistent pointer to an element inside an asset — the "fat address": every
4
+ * fallback the resolver may need is captured at CREATION time, while the target is
5
+ * known, instead of being reconstructed later.
6
+ *
7
+ * Resolution order: `id` (exact) → `anchor` (survives asset updates) → `namePath`
8
+ * (best-effort) → reconciliation (candidates + confidence, never a silent guess).
9
+ */
10
+ export type NeedleAddress = {
11
+ /** the element's needle-id (deterministic for file-backed content, random for scratch) */
12
+ id?: string;
13
+ /** `#needle` anchor value, when the target's name carries one */
14
+ anchor?: string;
15
+ /** element type (`node`, `material`, …) — narrows resolution and validates matches */
16
+ type?: StampedElementType | (string & {});
17
+ /** the target's name at address-creation time */
18
+ name?: string;
19
+ /** node names from the asset root (exclusive) down to the target (inclusive); nodes only */
20
+ namePath?: string[];
21
+ };
22
+ /** Structural (duck-typed) view of a live scene-graph node — works with any three.js. */
23
+ export type GraphNode = {
24
+ name?: string;
25
+ children?: readonly GraphNode[];
26
+ userData?: Record<string, unknown>;
27
+ };
28
+ /** Build an address for a glTF root-collection element. `namePath` only applies to nodes. */
29
+ export declare function createGltfElementAddress(json: GltfJson, type: StampedElementType | string, index: number): NeedleAddress | null;
30
+ /** Build an address for a live scene-graph object under `root`. */
31
+ export declare function createObjectAddress(root: GraphNode, target: GraphNode): NeedleAddress | null;
32
+ export declare function collectionKeyForType(type: string): string;
33
+ /** path from root (exclusive) to target (inclusive), or null if target is not under root */
34
+ export declare function findPath(root: GraphNode, target: GraphNode): GraphNode[] | null;
@@ -0,0 +1,94 @@
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. */
4
+ export function createGltfElementAddress(json, type, index) {
5
+ const collection = json[collectionKeyForType(type)];
6
+ if (!Array.isArray(collection))
7
+ return null;
8
+ const element = collection[index];
9
+ if (element == null || typeof element !== "object")
10
+ return null;
11
+ const name = typeof element.name === "string" ? element.name : undefined;
12
+ const address = { type };
13
+ const id = getElementId(element);
14
+ if (id)
15
+ address.id = id;
16
+ const anchor = parseAnchorValue(name);
17
+ if (anchor)
18
+ address.anchor = anchor;
19
+ // 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
22
+ // standalone `name` field is dropped when empty.
23
+ if (name)
24
+ address.name = name;
25
+ if (type === "node") {
26
+ const path = gltfNodeNamePath(json, index);
27
+ if (path)
28
+ address.namePath = path;
29
+ }
30
+ return address;
31
+ }
32
+ /** Build an address for a live scene-graph object under `root`. */
33
+ export function createObjectAddress(root, target) {
34
+ const path = findPath(root, target);
35
+ if (!path)
36
+ return null;
37
+ const address = { type: "node" };
38
+ const id = target.userData?.[NEEDLE_ID_KEY];
39
+ if (typeof id === "string")
40
+ address.id = id;
41
+ const anchor = parseAnchorValue(target.name);
42
+ if (anchor)
43
+ address.anchor = anchor;
44
+ // empty name dropped — see createGltfElementAddress; namePath segments stay verbatim
45
+ if (target.name)
46
+ address.name = target.name;
47
+ address.namePath = path.map(n => n.name ?? "");
48
+ return address;
49
+ }
50
+ export function collectionKeyForType(type) {
51
+ // glTF collection keys are the pluralized type names; only "mesh" is irregular
52
+ return type === "mesh" ? "meshes" : `${type}s`;
53
+ }
54
+ /** names from root (exclusive) to the node at `nodeIndex` (inclusive), via the scene tree */
55
+ function gltfNodeNamePath(json, nodeIndex) {
56
+ const nodes = json["nodes"];
57
+ if (!Array.isArray(nodes))
58
+ return null;
59
+ const parents = new Map();
60
+ for (let i = 0; i < nodes.length; i++) {
61
+ const children = nodes[i]?.children;
62
+ if (Array.isArray(children))
63
+ for (const c of children)
64
+ parents.set(c, i);
65
+ }
66
+ const path = [];
67
+ let current = nodeIndex;
68
+ const seen = new Set();
69
+ while (current !== undefined) {
70
+ if (seen.has(current))
71
+ return null; // malformed (cyclic) input
72
+ seen.add(current);
73
+ path.unshift(typeof nodes[current]?.name === "string" ? nodes[current].name : "");
74
+ current = parents.get(current);
75
+ }
76
+ return path;
77
+ }
78
+ /** path from root (exclusive) to target (inclusive), or null if target is not under root */
79
+ export function findPath(root, target) {
80
+ if (root === target)
81
+ return [];
82
+ const children = root.children;
83
+ if (!children)
84
+ return null;
85
+ for (let i = 0; i < children.length; i++) {
86
+ const child = children[i];
87
+ if (child == null)
88
+ continue;
89
+ const sub = findPath(child, target);
90
+ if (sub)
91
+ return [child, ...sub];
92
+ }
93
+ return null;
94
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The name-anchor scheme: an element named `Door #needle` derives its deterministic id
3
+ * from the text before the marker instead of (name, index, file hash) — so the id
4
+ * survives content changes and asset re-uploads as long as the name stays.
5
+ *
6
+ * CONTRACT NOTE — the marker set is part of the id contract. Markers may be ADDED (new
7
+ * names start anchoring), but never removed or re-prioritized, and every WRITER of ids
8
+ * (this package's consumers: cloud upload, editors, CLI) must agree on the set before a
9
+ * new marker ships — a writer that anchors on a marker another writer doesn't know mints
10
+ * DIFFERENT ids for the same file. The anchored id only hashes the VALUE (not the
11
+ * marker), so all markers are aliases into the same id space.
12
+ */
13
+ /** the original marker — kept as a named export for existing consumers */
14
+ export declare const ANCHOR_MARKER = "#needle";
15
+ /**
16
+ * All recognized markers, matched longest-first so overlapping suffixes resolve
17
+ * deterministically. Currently one entry; see the contract note before adding more.
18
+ */
19
+ export declare const ANCHOR_MARKERS: readonly string[];
20
+ export type ParsedAnchor = {
21
+ /** the marker that matched (one of {@link ANCHOR_MARKERS}) */
22
+ marker: string;
23
+ /** the anchor value — the deterministic-id input */
24
+ value: string;
25
+ };
26
+ /**
27
+ * Parse an element name against all recognized markers (case-insensitive suffix match).
28
+ * Returns null when no marker matches or the anchor value is empty.
29
+ */
30
+ export declare function parseAnchor(rawName: string | null | undefined): ParsedAnchor | null;
31
+ /**
32
+ * Names treat SPACES and UNDERSCORES as the same character (runs collapse to one space):
33
+ * three's GLTFLoader sanitizes node names on load (whitespace becomes "_"), so
34
+ * "Door Frame #needle" appears live as "Door_Frame_#needle" — without normalization a
35
+ * stamped anchor and the live anchor would never match. Applied at PARSE time, so id
36
+ * computation and resolution agree from one change point. Accepted trade: names differing
37
+ * only by space-vs-underscore are one name.
38
+ *
39
+ * Exported because id derivation applies the SAME rule to unanchored names (see
40
+ * {@link import("./id.js").idNameOf}) — one normalization, one change point.
41
+ */
42
+ export declare function normalizeName(value: string): string;
43
+ /**
44
+ * Extract the anchor value from an element name, or null when the name carries no anchor.
45
+ * Convenience over {@link parseAnchor} for callers that only need the id input.
46
+ */
47
+ export declare function parseAnchorValue(rawName: string | null | undefined): string | null;
package/dist/anchor.js ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The name-anchor scheme: an element named `Door #needle` derives its deterministic id
3
+ * from the text before the marker instead of (name, index, file hash) — so the id
4
+ * survives content changes and asset re-uploads as long as the name stays.
5
+ *
6
+ * CONTRACT NOTE — the marker set is part of the id contract. Markers may be ADDED (new
7
+ * names start anchoring), but never removed or re-prioritized, and every WRITER of ids
8
+ * (this package's consumers: cloud upload, editors, CLI) must agree on the set before a
9
+ * new marker ships — a writer that anchors on a marker another writer doesn't know mints
10
+ * DIFFERENT ids for the same file. The anchored id only hashes the VALUE (not the
11
+ * marker), so all markers are aliases into the same id space.
12
+ */
13
+ /** the original marker — kept as a named export for existing consumers */
14
+ export const ANCHOR_MARKER = "#needle";
15
+ /**
16
+ * All recognized markers, matched longest-first so overlapping suffixes resolve
17
+ * deterministically. Currently one entry; see the contract note before adding more.
18
+ */
19
+ export const ANCHOR_MARKERS = [ANCHOR_MARKER];
20
+ /**
21
+ * Parse an element name against all recognized markers (case-insensitive suffix match).
22
+ * Returns null when no marker matches or the anchor value is empty.
23
+ */
24
+ export function parseAnchor(rawName) {
25
+ if (rawName == null)
26
+ return null;
27
+ const trimmed = rawName.trim();
28
+ const lower = trimmed.toLowerCase();
29
+ for (const marker of byLengthDescending(ANCHOR_MARKERS)) {
30
+ if (!lower.endsWith(marker))
31
+ continue;
32
+ const value = normalizeName(trimmed.substring(0, trimmed.length - marker.length));
33
+ return value.length > 0 ? { marker, value } : null;
34
+ }
35
+ return null;
36
+ }
37
+ /**
38
+ * Names treat SPACES and UNDERSCORES as the same character (runs collapse to one space):
39
+ * three's GLTFLoader sanitizes node names on load (whitespace becomes "_"), so
40
+ * "Door Frame #needle" appears live as "Door_Frame_#needle" — without normalization a
41
+ * stamped anchor and the live anchor would never match. Applied at PARSE time, so id
42
+ * computation and resolution agree from one change point. Accepted trade: names differing
43
+ * only by space-vs-underscore are one name.
44
+ *
45
+ * Exported because id derivation applies the SAME rule to unanchored names (see
46
+ * {@link import("./id.js").idNameOf}) — one normalization, one change point.
47
+ */
48
+ export function normalizeName(value) {
49
+ return value.replace(/[\s_]+/g, " ").trim();
50
+ }
51
+ /**
52
+ * Extract the anchor value from an element name, or null when the name carries no anchor.
53
+ * Convenience over {@link parseAnchor} for callers that only need the id input.
54
+ */
55
+ export function parseAnchorValue(rawName) {
56
+ return parseAnchor(rawName)?.value ?? null;
57
+ }
58
+ function byLengthDescending(markers) {
59
+ return markers.length > 1 ? [...markers].sort((a, b) => b.length - a.length) : markers;
60
+ }
package/dist/gltf.d.ts ADDED
@@ -0,0 +1,41 @@
1
+ /** The glTF `extras` key carrying the persistent id. */
2
+ export declare const NEEDLE_ID_KEY = "needle-id";
3
+ /** All root-collection element types that receive ids, with their glTF collection key. */
4
+ 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
+ export type StampedElementType = (typeof STAMPED_COLLECTIONS)[number][0];
6
+ /** Structural (duck-typed) view of a glTF root element — no glTF library dependency. */
7
+ export type GltfElement = {
8
+ name?: string;
9
+ extras?: Record<string, unknown>;
10
+ [key: string]: unknown;
11
+ };
12
+ /** Structural view of a glTF JSON root. Collections are looked up by key. */
13
+ export type GltfJson = {
14
+ [collection: string]: unknown;
15
+ };
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 ADDED
@@ -0,0 +1,57 @@
1
+ import { computeElementId, idNameOf } from "./id.js";
2
+ /** The glTF `extras` key carrying the persistent id. */
3
+ export const NEEDLE_ID_KEY = "needle-id";
4
+ /** All root-collection element types that receive ids, with their glTF collection key. */
5
+ export const STAMPED_COLLECTIONS = [
6
+ ["node", "nodes"],
7
+ ["scene", "scenes"],
8
+ ["animation", "animations"],
9
+ ["camera", "cameras"],
10
+ ["material", "materials"],
11
+ ["mesh", "meshes"],
12
+ ["skin", "skins"],
13
+ ["texture", "textures"],
14
+ ["image", "images"],
15
+ ["sampler", "samplers"],
16
+ ];
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/hash.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Vendored SHA-1 so the package has zero runtime dependencies — it backs UUIDv5
3
+ * (RFC 4122). Correctness is pinned by golden tests against the `uuid` npm package.
4
+ */
5
+ /** SHA-1 digest bytes of `data`. */
6
+ export declare function sha1(data: Uint8Array): Uint8Array;
7
+ export declare function toHex(bytes: Uint8Array): string;
package/dist/hash.js ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Vendored SHA-1 so the package has zero runtime dependencies — it backs UUIDv5
3
+ * (RFC 4122). Correctness is pinned by golden tests against the `uuid` npm package.
4
+ */
5
+ /** SHA-1 digest bytes of `data`. */
6
+ export function sha1(data) {
7
+ const padded = padMessage(data);
8
+ const dv = new DataView(padded.buffer, padded.byteOffset, padded.byteLength);
9
+ let h0 = 0x67452301, h1 = 0xefcdab89, h2 = 0x98badcfe, h3 = 0x10325476, h4 = 0xc3d2e1f0;
10
+ const w = new Uint32Array(80);
11
+ for (let off = 0; off < padded.length; off += 64) {
12
+ for (let i = 0; i < 16; i++)
13
+ w[i] = dv.getUint32(off + i * 4);
14
+ for (let i = 16; i < 80; i++) {
15
+ const n = w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16];
16
+ w[i] = (n << 1) | (n >>> 31);
17
+ }
18
+ let a = h0, b = h1, c = h2, d = h3, e = h4;
19
+ for (let i = 0; i < 80; i++) {
20
+ let f, k;
21
+ if (i < 20) {
22
+ f = (b & c) | (~b & d);
23
+ k = 0x5a827999;
24
+ }
25
+ else if (i < 40) {
26
+ f = b ^ c ^ d;
27
+ k = 0x6ed9eba1;
28
+ }
29
+ else if (i < 60) {
30
+ f = (b & c) | (b & d) | (c & d);
31
+ k = 0x8f1bbcdc;
32
+ }
33
+ else {
34
+ f = b ^ c ^ d;
35
+ k = 0xca62c1d6;
36
+ }
37
+ const t = ((((a << 5) | (a >>> 27)) + f + e + k + w[i]) | 0);
38
+ e = d;
39
+ d = c;
40
+ c = (b << 30) | (b >>> 2);
41
+ b = a;
42
+ a = t;
43
+ }
44
+ h0 = (h0 + a) | 0;
45
+ h1 = (h1 + b) | 0;
46
+ h2 = (h2 + c) | 0;
47
+ h3 = (h3 + d) | 0;
48
+ h4 = (h4 + e) | 0;
49
+ }
50
+ const out = new Uint8Array(20);
51
+ const odv = new DataView(out.buffer);
52
+ odv.setUint32(0, h0 >>> 0);
53
+ odv.setUint32(4, h1 >>> 0);
54
+ odv.setUint32(8, h2 >>> 0);
55
+ odv.setUint32(12, h3 >>> 0);
56
+ odv.setUint32(16, h4 >>> 0);
57
+ return out;
58
+ }
59
+ export function toHex(bytes) {
60
+ let s = "";
61
+ for (let i = 0; i < bytes.length; i++)
62
+ s += bytes[i].toString(16).padStart(2, "0");
63
+ return s;
64
+ }
65
+ /** append 0x80, zero-pad to 56 mod 64, append big-endian 64-bit bit-length */
66
+ function padMessage(data) {
67
+ const bitLen = data.length * 8;
68
+ const padded = new Uint8Array((((data.length + 8) >> 6) + 1) * 64);
69
+ padded.set(data);
70
+ padded[data.length] = 0x80;
71
+ const dv = new DataView(padded.buffer);
72
+ dv.setUint32(padded.length - 8, Math.floor(bitLen / 0x100000000));
73
+ dv.setUint32(padded.length - 4, bitLen >>> 0);
74
+ return padded;
75
+ }
package/dist/id.d.ts ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The name an id is derived FROM: the anchor marker removed (if any) and space/underscore
3
+ * runs collapsed.
4
+ *
5
+ * The marker is stripped because **ids are minted as if no anchor existed** — an anchor is
6
+ * a LABEL, not an identity, and the two must be able to change independently. Both halves
7
+ * of that are load-bearing:
8
+ * - stripping the marker means ADDING or REMOVING `#needle` leaves the id untouched (under
9
+ * the previous rule, anchoring an object CHANGED its id — so the very act recommended for
10
+ * making something stable orphaned every edit already addressed to it);
11
+ * - {@link occurrenceOfName} groups on this same value, so anchoring ONE of two `Door`s
12
+ * does not shift the OTHER one's occurrence.
13
+ *
14
+ * Note what is decoupled: the id is independent of the anchor MARKER, not of the NAME.
15
+ * Renaming `Door #needle` → `Entrance #needle` still changes a DERIVED id; a STAMPED id is
16
+ * unaffected, since existing ids are never overwritten.
17
+ */
18
+ export declare function idNameOf(name: string | null | undefined): string;
19
+ /**
20
+ * The deterministic needle-id of a glTF element:
21
+ * `uuidv5("<type>-<idName>-<occurrence>[-<baseHash>]")`, where `idName` is the name with
22
+ * any anchor marker removed ({@link idNameOf}) and `occurrence` is the element's position
23
+ * among same-named elements of its type. Uniquely named elements are therefore fully
24
+ * reorder-stable across file versions; duplicates shift only when reordered within their
25
+ * own name group; unnamed elements are positional among the unnamed.
26
+ *
27
+ * `baseHash` is an optional consumer-supplied salt (e.g. an asset/content id) that scopes
28
+ * ids to an external identity. Omitted by default, so any tool stamping the same element
29
+ * mints the same id with no coordination.
30
+ *
31
+ * Ids are persisted in `extras["needle-id"]` and never overwritten once present —
32
+ * whoever stamps first wins; this rule only governs never-stamped elements.
33
+ */
34
+ export declare function computeElementId(type: string, name: string | null | undefined, occurrence: number, baseHash?: string): string;
35
+ /**
36
+ * The occurrence of the element at `index` among same-named elements of its collection —
37
+ * the positional input of {@link computeElementId}. Exposed so consumers computing ids
38
+ * for individual elements (instead of stamping whole files) use the identical rule.
39
+ *
40
+ * Grouping is by {@link idNameOf}, so an anchored element counts in the same group as its
41
+ * unanchored namesakes.
42
+ */
43
+ export declare function occurrenceOfName(elements: ReadonlyArray<{
44
+ name?: unknown;
45
+ } | null | undefined>, index: number): number;
package/dist/id.js ADDED
@@ -0,0 +1,60 @@
1
+ import { normalizeName, parseAnchor } from "./anchor.js";
2
+ import { uuidv5 } from "./uuid.js";
3
+ /**
4
+ * The name an id is derived FROM: the anchor marker removed (if any) and space/underscore
5
+ * runs collapsed.
6
+ *
7
+ * The marker is stripped because **ids are minted as if no anchor existed** — an anchor is
8
+ * a LABEL, not an identity, and the two must be able to change independently. Both halves
9
+ * of that are load-bearing:
10
+ * - stripping the marker means ADDING or REMOVING `#needle` leaves the id untouched (under
11
+ * the previous rule, anchoring an object CHANGED its id — so the very act recommended for
12
+ * making something stable orphaned every edit already addressed to it);
13
+ * - {@link occurrenceOfName} groups on this same value, so anchoring ONE of two `Door`s
14
+ * does not shift the OTHER one's occurrence.
15
+ *
16
+ * Note what is decoupled: the id is independent of the anchor MARKER, not of the NAME.
17
+ * Renaming `Door #needle` → `Entrance #needle` still changes a DERIVED id; a STAMPED id is
18
+ * unaffected, since existing ids are never overwritten.
19
+ */
20
+ export function idNameOf(name) {
21
+ if (typeof name !== "string")
22
+ return "";
23
+ // an anchor's value is already marker-stripped and normalized
24
+ return parseAnchor(name)?.value ?? normalizeName(name);
25
+ }
26
+ /**
27
+ * The deterministic needle-id of a glTF element:
28
+ * `uuidv5("<type>-<idName>-<occurrence>[-<baseHash>]")`, where `idName` is the name with
29
+ * any anchor marker removed ({@link idNameOf}) and `occurrence` is the element's position
30
+ * among same-named elements of its type. Uniquely named elements are therefore fully
31
+ * reorder-stable across file versions; duplicates shift only when reordered within their
32
+ * own name group; unnamed elements are positional among the unnamed.
33
+ *
34
+ * `baseHash` is an optional consumer-supplied salt (e.g. an asset/content id) that scopes
35
+ * ids to an external identity. Omitted by default, so any tool stamping the same element
36
+ * mints the same id with no coordination.
37
+ *
38
+ * Ids are persisted in `extras["needle-id"]` and never overwritten once present —
39
+ * whoever stamps first wins; this rule only governs never-stamped elements.
40
+ */
41
+ export function computeElementId(type, name, occurrence, baseHash) {
42
+ return uuidv5(`${type}-${idNameOf(name)}-${occurrence}${baseHash ? `-${baseHash}` : ""}`);
43
+ }
44
+ /**
45
+ * The occurrence of the element at `index` among same-named elements of its collection —
46
+ * the positional input of {@link computeElementId}. Exposed so consumers computing ids
47
+ * for individual elements (instead of stamping whole files) use the identical rule.
48
+ *
49
+ * Grouping is by {@link idNameOf}, so an anchored element counts in the same group as its
50
+ * unanchored namesakes.
51
+ */
52
+ export function occurrenceOfName(elements, index) {
53
+ const name = idNameOf(elements[index]?.name);
54
+ let occurrence = 0;
55
+ for (let i = 0; i < index; i++) {
56
+ if (idNameOf(elements[i]?.name) === name)
57
+ occurrence++;
58
+ }
59
+ return occurrence;
60
+ }
@@ -0,0 +1,6 @@
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";
5
+ export { reconcileInGraph, resolveInCollection, resolveInGltf, resolveInGraph, type AddressableElement, type GltfResolveResult, type ReconcileCandidate, type ResolutionIssue, type ResolutionRung, type ResolveOptions, } from "./resolve.js";
6
+ export { randomUuid, uuidv5, UUID_NAMESPACE_URL } from "./uuid.js";
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
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";
5
+ export { reconcileInGraph, resolveInCollection, resolveInGltf, resolveInGraph, } from "./resolve.js";
6
+ export { randomUuid, uuidv5, UUID_NAMESPACE_URL } from "./uuid.js";
@@ -0,0 +1,86 @@
1
+ import { type GraphNode, type NeedleAddress } from "./address.js";
2
+ import { type GltfJson } from "./gltf.js";
3
+ export type GltfResolveResult = {
4
+ type: string;
5
+ index: number;
6
+ element: Record<string, unknown>;
7
+ };
8
+ /**
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
11
+ * exactly one element. Returns null when nothing matches exactly (reconciliation is a
12
+ * separate, explicit step — resolution never guesses).
13
+ */
14
+ export declare function resolveInGltf(address: NeedleAddress, json: GltfJson, options?: ResolveOptions): GltfResolveResult | null;
15
+ /** which rung of the ladder produced (or could have produced) a match */
16
+ export type ResolutionRung = "id" | "anchor" | "namePath" | "name";
17
+ /**
18
+ * Something a caller may want to show a human. Never an error — resolution still returns
19
+ * its best answer; these say the answer was not the only defensible one.
20
+ */
21
+ export type ResolutionIssue =
22
+ /** an anchor matched several elements, so it could not outrank an exact key */
23
+ {
24
+ kind: "ambiguous-anchor";
25
+ anchor: string;
26
+ count: number;
27
+ }
28
+ /** a name matched several elements in a flat collection */
29
+ | {
30
+ kind: "ambiguous-name";
31
+ name: string;
32
+ count: number;
33
+ }
34
+ /** two rungs resolved to DIFFERENT elements — the content changed underneath */
35
+ | {
36
+ kind: "conflict";
37
+ preferred: ResolutionRung;
38
+ other: ResolutionRung;
39
+ };
40
+ export type ResolveOptions = {
41
+ /**
42
+ * Collect ambiguity/conflict evidence. OPT-IN because it costs extra traversals:
43
+ * detecting that two rungs disagree means evaluating both instead of stopping at the
44
+ * first hit, and resolution already walks from the root per address. The runtime
45
+ * short-circuits and pays nothing; the editor asks for evidence so it can surface
46
+ * drift to the user.
47
+ */
48
+ report?: (issue: ResolutionIssue) => void;
49
+ };
50
+ /**
51
+ * Resolve an address inside a live scene graph (duck-typed — any three.js).
52
+ *
53
+ * Ladder: **anchor → id → namePath**. The anchor comes first because it is a deliberate,
54
+ * human-authored declaration, while an id may be a derived value whose basis shifted when
55
+ * the content changed. It only wins when it resolves UNIQUELY: anchors are labels and may
56
+ * legally repeat, and an ambiguous label must never outrank an exact key.
57
+ */
58
+ export declare function resolveInGraph(address: NeedleAddress, root: GraphNode, options?: ResolveOptions): GraphNode | null;
59
+ /** duck-typed view of an element that is not a graph node (a material, texture, …) */
60
+ export type AddressableElement = {
61
+ name?: string;
62
+ userData?: Record<string, unknown>;
63
+ };
64
+ /**
65
+ * Resolve an address against a FLAT collection of live elements — materials, textures and
66
+ * anything else that carries a name and `userData` but is not part of the scene graph.
67
+ *
68
+ * Same ladder as the graph, minus `namePath` (there is no hierarchy to walk) plus `name`
69
+ * as the final rung, since a flat collection has no positional fallback. This exists so
70
+ * every element type resolves through ONE implementation: materials previously had their
71
+ * own ladders, could not be anchored at all, and disagreed with each other about whether a
72
+ * name match had to be unique.
73
+ */
74
+ export declare function resolveInCollection<T extends AddressableElement>(address: NeedleAddress, elements: readonly T[], options?: ResolveOptions): T | null;
75
+ export type ReconcileCandidate = {
76
+ object: GraphNode;
77
+ /** 0..1 — heuristic, for ranking and thresholds; 1.0 is reserved for exact resolution */
78
+ confidence: number;
79
+ reasons: string[];
80
+ };
81
+ /**
82
+ * When exact resolution fails (the asset changed), propose candidates instead of
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.
85
+ */
86
+ export declare function reconcileInGraph(address: NeedleAddress, root: GraphNode): ReconcileCandidate[];