@needle-tools/addressing 0.3.1-1784560466.reasonable-waffle.0dfaf15 → 0.3.2

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
@@ -1,69 +1,70 @@
1
- # @needle-tools/addressing
2
-
3
- Persistent addressing for glTF assets and three.js scene graphs: deterministic
4
- `needle-id` computation, the `#needle` name-anchor scheme, and address resolution.
5
-
6
- Zero runtime dependencies, no three.js import (scene graphs are accessed structurally),
7
- usable in browsers, node, and workers.
8
-
9
- ## Ids
10
-
11
- Every glTF root-collection element (nodes, scenes, animations, cameras, materials,
12
- meshes, skins, textures, images, samplers) can carry a UUID in `extras["needle-id"]`:
13
-
14
- - **Anchored** — the element name ends with `#needle` (case-insensitive): the id derives
15
- only from the text before the marker → it **survives any content change** as long as
16
- the name stays.
17
- - **Unanchored**: the id derives from the element's type, name, and its occurrence among
18
- same-named elements → uniquely named elements keep their ids across reorders and
19
- content updates; duplicates disambiguate by position within their name group; unnamed
20
- elements are positional.
21
-
22
- `ensureIds(gltfJson)` stamps a parsed glTF idempotently — existing ids are never
23
- overwritten, and any tool stamping the same element mints the same id.
24
-
25
- `ensureIds(gltfJson, { baseHash })` optionally salts unanchored ids with a caller-supplied
26
- identity (e.g. a stable asset id), scoping them to that identity.
27
-
28
- ## Addresses
29
-
30
- An address captures every fallback at creation time, while the target is known —
31
- `{ id, anchor?, type?, name?, namePath? }`. Resolution order is strict:
32
-
33
- 1. `id` — exact match (`extras` in glTF JSON, `userData` in live scene graphs)
34
- 2. `anchor` — survives asset updates
35
- 3. `namePath` — best-effort structural match
36
- 4. **reconciliation** — never automatic: `reconcileInGraph` returns ranked candidates
37
- with confidence + reasons; the caller decides thresholds and whether to re-anchor.
38
-
39
- ## API
40
-
41
- ```ts
42
- import {
43
- ensureIds, computeElementId, computeFileHash, // write side
44
- createGltfElementAddress, createObjectAddress, // address records
45
- resolveInGltf, resolveInGraph, reconcileInGraph, // resolve side
46
- parseAnchorValue, randomUuid, NEEDLE_ID_KEY,
47
- } from "@needle-tools/addressing";
48
-
49
- // stamp a parsed glTF (idempotent, never overwrites existing ids)
50
- const { fileHash, assigned } = ensureIds(gltfJson);
51
-
52
- // capture an address while the target is known
53
- const address = createObjectAddress(sceneRoot, targetObject);
54
-
55
- // later / elsewhere: resolve — or reconcile when the asset changed
56
- const hit = resolveInGraph(address, sceneRoot);
57
- const candidates = hit ? [] : reconcileInGraph(address, sceneRoot);
58
- ```
59
-
60
- Out of scope by design: GLB container parsing (this package takes parsed JSON), file
61
- I/O, and any composition/override semantics.
62
-
63
- ## Development
64
-
65
- ```
66
- npm install
67
- npm test # vitest — includes golden-fixture id tests
68
- npm run build # tsc → dist/
69
- ```
1
+ # @needle-tools/addressing
2
+
3
+ Persistent addressing for glTF assets and three.js scene graphs: deterministic
4
+ `needle-id` computation, the `#needle` name-anchor scheme, and address resolution.
5
+
6
+ Zero runtime dependencies, no three.js import (scene graphs are accessed structurally),
7
+ usable in browsers, node, and workers.
8
+
9
+ ## Ids
10
+
11
+ Every glTF root-collection element (nodes, scenes, animations, cameras, materials,
12
+ meshes, skins, textures, images, samplers) can carry a UUID in `extras["needle-id"]`:
13
+
14
+ - **Anchored** — the element name ends with `#needle` (case-insensitive): the id derives
15
+ only from the text before the marker → it **survives any content change** as long as
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.
18
+ - **Unanchored**: the id derives from the element's type, name, and its occurrence among
19
+ same-named elements → uniquely named elements keep their ids across reorders and
20
+ content updates; duplicates disambiguate by position within their name group; unnamed
21
+ elements are positional.
22
+
23
+ `ensureIds(gltfJson)` stamps a parsed glTF idempotently — existing ids are never
24
+ overwritten, and any tool stamping the same element mints the same id.
25
+
26
+ `ensureIds(gltfJson, { baseHash })` optionally salts unanchored ids with a caller-supplied
27
+ identity (e.g. a stable asset id), scoping them to that identity.
28
+
29
+ ## Addresses
30
+
31
+ An address captures every fallback at creation time, while the target is known —
32
+ `{ id, anchor?, type?, name?, namePath? }`. Resolution order is strict:
33
+
34
+ 1. `id` — exact match (`extras` in glTF JSON, `userData` in live scene graphs)
35
+ 2. `anchor` — survives asset updates
36
+ 3. `namePath` — best-effort structural match
37
+ 4. **reconciliation** — never automatic: `reconcileInGraph` returns ranked candidates
38
+ with confidence + reasons; the caller decides thresholds and whether to re-anchor.
39
+
40
+ ## API
41
+
42
+ ```ts
43
+ import {
44
+ ensureIds, computeElementId, computeFileHash, // write side
45
+ createGltfElementAddress, createObjectAddress, // address records
46
+ resolveInGltf, resolveInGraph, reconcileInGraph, // resolve side
47
+ parseAnchorValue, randomUuid, NEEDLE_ID_KEY,
48
+ } from "@needle-tools/addressing";
49
+
50
+ // stamp a parsed glTF (idempotent, never overwrites existing ids)
51
+ const { fileHash, assigned } = ensureIds(gltfJson);
52
+
53
+ // capture an address while the target is known
54
+ const address = createObjectAddress(sceneRoot, targetObject);
55
+
56
+ // later / elsewhere: resolve — or reconcile when the asset changed
57
+ const hit = resolveInGraph(address, sceneRoot);
58
+ const candidates = hit ? [] : reconcileInGraph(address, sceneRoot);
59
+ ```
60
+
61
+ Out of scope by design: GLB container parsing (this package takes parsed JSON), file
62
+ I/O, and any composition/override semantics.
63
+
64
+ ## Development
65
+
66
+ ```
67
+ npm install
68
+ npm test # vitest — includes golden-fixture id tests
69
+ npm run build # tsc → dist/
70
+ ```
@@ -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,89 @@
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
+ if (name !== undefined)
20
+ address.name = name;
21
+ if (type === "node") {
22
+ const path = gltfNodeNamePath(json, index);
23
+ if (path)
24
+ address.namePath = path;
25
+ }
26
+ return address;
27
+ }
28
+ /** Build an address for a live scene-graph object under `root`. */
29
+ export function createObjectAddress(root, target) {
30
+ const path = findPath(root, target);
31
+ if (!path)
32
+ return null;
33
+ const address = { type: "node" };
34
+ const id = target.userData?.[NEEDLE_ID_KEY];
35
+ if (typeof id === "string")
36
+ address.id = id;
37
+ const anchor = parseAnchorValue(target.name);
38
+ if (anchor)
39
+ address.anchor = anchor;
40
+ if (typeof target.name === "string")
41
+ address.name = target.name;
42
+ address.namePath = path.map(n => n.name ?? "");
43
+ return address;
44
+ }
45
+ export function collectionKeyForType(type) {
46
+ // glTF collection keys are the pluralized type names; only "mesh" is irregular
47
+ return type === "mesh" ? "meshes" : `${type}s`;
48
+ }
49
+ /** names from root (exclusive) to the node at `nodeIndex` (inclusive), via the scene tree */
50
+ function gltfNodeNamePath(json, nodeIndex) {
51
+ const nodes = json["nodes"];
52
+ if (!Array.isArray(nodes))
53
+ return null;
54
+ const parents = new Map();
55
+ for (let i = 0; i < nodes.length; i++) {
56
+ const children = nodes[i]?.children;
57
+ if (Array.isArray(children))
58
+ for (const c of children)
59
+ parents.set(c, i);
60
+ }
61
+ const path = [];
62
+ let current = nodeIndex;
63
+ const seen = new Set();
64
+ while (current !== undefined) {
65
+ if (seen.has(current))
66
+ return null; // malformed (cyclic) input
67
+ seen.add(current);
68
+ path.unshift(typeof nodes[current]?.name === "string" ? nodes[current].name : "");
69
+ current = parents.get(current);
70
+ }
71
+ return path;
72
+ }
73
+ /** path from root (exclusive) to target (inclusive), or null if target is not under root */
74
+ export function findPath(root, target) {
75
+ if (root === target)
76
+ return [];
77
+ const children = root.children;
78
+ if (!children)
79
+ return null;
80
+ for (let i = 0; i < children.length; i++) {
81
+ const child = children[i];
82
+ if (child == null)
83
+ continue;
84
+ const sub = findPath(child, target);
85
+ if (sub)
86
+ return [child, ...sub];
87
+ }
88
+ return null;
89
+ }
@@ -0,0 +1,35 @@
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
+ * Extract the anchor value from an element name, or null when the name carries no anchor.
33
+ * Convenience over {@link parseAnchor} for callers that only need the id input.
34
+ */
35
+ export declare function parseAnchorValue(rawName: string | null | undefined): string | null;
package/dist/anchor.js ADDED
@@ -0,0 +1,57 @@
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 = normalizeAnchorValue(trimmed.substring(0, trimmed.length - marker.length));
33
+ return value.length > 0 ? { marker, value } : null;
34
+ }
35
+ return null;
36
+ }
37
+ /**
38
+ * Anchor values treat SPACES and UNDERSCORES as the same character (runs collapse to
39
+ * one space): three's GLTFLoader sanitizes node names on load (whitespace becomes
40
+ * "_"), so "Door Frame #needle" appears live as "Door_Frame_#needle" — without
41
+ * normalization the stamped anchor and the live anchor would never match. Applied at
42
+ * PARSE time, so id computation and resolution agree from one change point. Accepted
43
+ * trade: names differing only by space-vs-underscore share one anchor.
44
+ */
45
+ function normalizeAnchorValue(value) {
46
+ return value.replace(/[\s_]+/g, " ").trim();
47
+ }
48
+ /**
49
+ * Extract the anchor value from an element name, or null when the name carries no anchor.
50
+ * Convenience over {@link parseAnchor} for callers that only need the id input.
51
+ */
52
+ export function parseAnchorValue(rawName) {
53
+ return parseAnchor(rawName)?.value ?? null;
54
+ }
55
+ function byLengthDescending(markers) {
56
+ return markers.length > 1 ? [...markers].sort((a, b) => b.length - a.length) : markers;
57
+ }
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,55 @@
1
+ import { computeElementId } 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
34
+ const occurrences = new Map();
35
+ for (let index = 0; index < elements.length; index++) {
36
+ const element = elements[index];
37
+ if (element == null || typeof element !== "object")
38
+ continue;
39
+ const name = typeof element.name === "string" ? element.name : "";
40
+ const occurrence = occurrences.get(name) ?? 0;
41
+ occurrences.set(name, occurrence + 1);
42
+ element.extras ?? (element.extras = {});
43
+ if (element.extras[NEEDLE_ID_KEY] === undefined) {
44
+ element.extras[NEEDLE_ID_KEY] = computeElementId(type, name, occurrence, baseHash);
45
+ assigned++;
46
+ }
47
+ }
48
+ }
49
+ return { baseHash, assigned };
50
+ }
51
+ /** Read an element's needle-id without computing anything. */
52
+ export function getElementId(element) {
53
+ const id = element?.extras?.[NEEDLE_ID_KEY];
54
+ return typeof id === "string" ? id : null;
55
+ }
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,26 @@
1
+ /**
2
+ * The deterministic needle-id of a glTF element.
3
+ *
4
+ * - Anchored name (`… #needle`): `uuidv5("<type>-<anchor>")` — survives ANY content
5
+ * change while the name stays.
6
+ * - Otherwise: `uuidv5("<type>-<name>-<occurrence>[-<baseHash>]")` where `occurrence`
7
+ * is the element's position among SAME-NAMED elements of its type. Uniquely named
8
+ * elements are therefore fully reorder-stable across file versions; duplicates shift
9
+ * only when reordered within their own name group; unnamed elements are positional
10
+ * among the unnamed.
11
+ * - `baseHash` is an optional consumer-supplied salt (e.g. an asset/content id) that
12
+ * scopes ids to an external identity. Omitted by default, so any tool stamping the
13
+ * same element mints the same id with no coordination.
14
+ *
15
+ * Ids are persisted in `extras["needle-id"]` and never overwritten once present —
16
+ * whoever stamps first wins; this rule only governs never-stamped elements.
17
+ */
18
+ export declare function computeElementId(type: string, name: string | null | undefined, occurrence: number, baseHash?: string): string;
19
+ /**
20
+ * The occurrence of the element at `index` among same-named elements of its collection —
21
+ * the positional input of {@link computeElementId}. Exposed so consumers computing ids
22
+ * for individual elements (instead of stamping whole files) use the identical rule.
23
+ */
24
+ export declare function occurrenceOfName(elements: ReadonlyArray<{
25
+ name?: unknown;
26
+ } | null | undefined>, index: number): number;
package/dist/id.js ADDED
@@ -0,0 +1,43 @@
1
+ import { parseAnchorValue } from "./anchor.js";
2
+ import { uuidv5 } from "./uuid.js";
3
+ /**
4
+ * The deterministic needle-id of a glTF element.
5
+ *
6
+ * - Anchored name (`… #needle`): `uuidv5("<type>-<anchor>")` — survives ANY content
7
+ * change while the name stays.
8
+ * - Otherwise: `uuidv5("<type>-<name>-<occurrence>[-<baseHash>]")` where `occurrence`
9
+ * is the element's position among SAME-NAMED elements of its type. Uniquely named
10
+ * elements are therefore fully reorder-stable across file versions; duplicates shift
11
+ * only when reordered within their own name group; unnamed elements are positional
12
+ * among the unnamed.
13
+ * - `baseHash` is an optional consumer-supplied salt (e.g. an asset/content id) that
14
+ * scopes ids to an external identity. Omitted by default, so any tool stamping the
15
+ * same element mints the same id with no coordination.
16
+ *
17
+ * Ids are persisted in `extras["needle-id"]` and never overwritten once present —
18
+ * whoever stamps first wins; this rule only governs never-stamped elements.
19
+ */
20
+ export function computeElementId(type, name, occurrence, baseHash) {
21
+ const anchor = parseAnchorValue(name);
22
+ const str = anchor !== null
23
+ ? `${type}-${anchor}`
24
+ : `${type}-${name ? name : ""}-${occurrence}${baseHash ? `-${baseHash}` : ""}`;
25
+ return uuidv5(str);
26
+ }
27
+ /**
28
+ * The occurrence of the element at `index` among same-named elements of its collection —
29
+ * the positional input of {@link computeElementId}. Exposed so consumers computing ids
30
+ * for individual elements (instead of stamping whole files) use the identical rule.
31
+ */
32
+ export function occurrenceOfName(elements, index) {
33
+ const name = normalizedName(elements[index]);
34
+ let occurrence = 0;
35
+ for (let i = 0; i < index; i++) {
36
+ if (normalizedName(elements[i]) === name)
37
+ occurrence++;
38
+ }
39
+ return occurrence;
40
+ }
41
+ function normalizedName(element) {
42
+ return typeof element?.name === "string" ? element.name : "";
43
+ }
@@ -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, resolveInGltf, resolveInGraph, type GltfResolveResult, type ReconcileCandidate, } 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, resolveInGltf, resolveInGraph, } from "./resolve.js";
6
+ export { randomUuid, uuidv5, UUID_NAMESPACE_URL } from "./uuid.js";