@needle-tools/addressing 0.1.0

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 ADDED
@@ -0,0 +1,89 @@
1
+ # @needle-tools/addressing
2
+
3
+ Persistent addressing for Needle assets: deterministic `needle-id` computation, the
4
+ `#needle` anchor scheme, and address resolution over glTF JSON and live scene graphs.
5
+
6
+ Zero runtime dependencies, no three.js or engine import (duck-typed scene graphs), usable
7
+ in browsers, node, and workers. This package is the **single implementation of the
8
+ addressing contract** shared by needle-cloud (upload-time id injection), the Needle
9
+ editors (load-time computation, address records for edits/marks/overlays), and Needle
10
+ Engine (project-document resolution at load).
11
+
12
+ ## The contract
13
+
14
+ ### Deterministic ids (`needle-id`)
15
+
16
+ Every glTF root-collection element (nodes, scenes, animations, cameras, materials,
17
+ meshes, skins, textures, images, samplers) gets a UUID in `extras["needle-id"]`:
18
+
19
+ - **Anchored** — name ends with `#needle` (case-insensitive):
20
+ `uuidv5("<type>-<anchor>", URL_NAMESPACE)`. Independent of index and file content →
21
+ **survives asset updates** as long as the name stays.
22
+ - **Unanchored**: `uuidv5("<type>-<name>-<index>-<fileHash>", URL_NAMESPACE)` where
23
+ `fileHash = md5hex(JSON.stringify(json))` of the file **before any injection**.
24
+ Stable per asset *version* only — re-uploading changed content changes these ids
25
+ (by design: same-named elements in different files must not collide).
26
+
27
+ Compatibility is pinned by golden tests against the original implementation
28
+ (needle-cloud `gltf.edit.ts` `ensureUUID`, real `js-md5` + `uuid` packages). This
29
+ includes a frozen quirk: the anchor value's cut index is computed from the trimmed name
30
+ but applied to the raw name, so leading whitespace shifts the anchor
31
+ (`" Trim#NEEDLE"` → anchor `"Tr"`). Do not "fix" this — it would change every anchored
32
+ id minted for such names.
33
+
34
+ ### Who writes ids when
35
+
36
+ - **needle-cloud**: injects on upload (frontend today; CLI/optimizer parity planned).
37
+ - **Editors**: **eager compute, lazy persist.** Compute ids at LOAD time against the
38
+ pristine JSON (the inputs — original name, index, file hash — only exist pristinely
39
+ then; computing after edits breaks determinism). Persist into the file only on save.
40
+ A locally saved file is stamped identically to a cloud upload of the same bytes.
41
+ - **Scratch content** (created in an editor, no source file): `randomUuid()` at creation.
42
+
43
+ ### Address records
44
+
45
+ An address is "fat": every fallback is captured at creation time, while the target is
46
+ known — `{ id, anchor?, type?, name?, namePath? }`. Resolution order is strict:
47
+
48
+ 1. `id` — exact match (extras in glTF, `userData` in live graphs)
49
+ 2. `anchor` — survives asset updates
50
+ 3. `namePath` — best-effort structural match
51
+ 4. **reconciliation** — never automatic: `reconcileInGraph` returns ranked candidates
52
+ with confidence + reasons; the caller decides thresholds and whether to re-anchor.
53
+
54
+ ## API
55
+
56
+ ```ts
57
+ import {
58
+ ensureIds, computeElementId, computeFileHash, // write side
59
+ createGltfElementAddress, createObjectAddress, // address records
60
+ resolveInGltf, resolveInGraph, reconcileInGraph, // resolve side
61
+ parseAnchorValue, randomUuid, NEEDLE_ID_KEY,
62
+ } from "@needle-tools/addressing";
63
+
64
+ // stamp a parsed glTF (idempotent, never overwrites existing ids)
65
+ const { fileHash, assigned } = ensureIds(gltfJson);
66
+
67
+ // capture an address while the target is known
68
+ const address = createObjectAddress(sceneRoot, targetObject);
69
+
70
+ // later / elsewhere: resolve — or reconcile when the asset changed
71
+ const hit = resolveInGraph(address, sceneRoot);
72
+ const candidates = hit ? [] : reconcileInGraph(address, sceneRoot);
73
+ ```
74
+
75
+ Out of scope by design: GLB container parsing (callers own their binary handling — this
76
+ package takes parsed JSON), file I/O, session-scoped editor refs, and any composition/
77
+ override semantics (those live in the project format, one layer above).
78
+
79
+ ## Development
80
+
81
+ ```
82
+ npm install
83
+ npm test # vitest — includes byte-parity golden tests
84
+ npm run build # tsc → dist/
85
+ ```
86
+
87
+ Regenerating `test/fixtures/golden.json` requires the reference libraries
88
+ (`js-md5`, `uuid`) and a verbatim copy of needle-cloud's `ensureUUID` — see the
89
+ fixture header comment in `test/id.test.ts`.
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@needle-tools/addressing",
3
+ "version": "0.1.0",
4
+ "description": "Persistent addressing for Needle assets: deterministic needle-id computation, #needle anchors, and address resolution over glTF JSON and live scene graphs.",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.ts",
11
+ "default": "./src/index.ts"
12
+ }
13
+ },
14
+ "files": [
15
+ "src",
16
+ "Readme.md"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsc -p tsconfig.json",
20
+ "test": "vitest run"
21
+ },
22
+ "author": {
23
+ "name": "Needle",
24
+ "email": "hi@needle.tools",
25
+ "url": "https://needle.tools"
26
+ },
27
+ "devDependencies": {
28
+ "typescript": "^5.6.0",
29
+ "vitest": "^3.0.0"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public",
33
+ "registry": "https://registry.npmjs.org"
34
+ }
35
+ }
package/src/address.ts ADDED
@@ -0,0 +1,106 @@
1
+ import { parseAnchorValue } from "./anchor.js";
2
+ import { getElementId, GltfJson, NEEDLE_ID_KEY, StampedElementType } from "./gltf.js";
3
+
4
+ /**
5
+ * A persistent pointer to an element inside an asset — the "fat address": every
6
+ * fallback the resolver may need is captured at CREATION time, while the target is
7
+ * known, instead of being reconstructed later.
8
+ *
9
+ * Resolution order: `id` (exact) → `anchor` (survives asset updates) → `namePath`
10
+ * (best-effort) → reconciliation (candidates + confidence, never a silent guess).
11
+ */
12
+ export type NeedleAddress = {
13
+ /** the element's needle-id (deterministic for file-backed content, random for scratch) */
14
+ id?: string;
15
+ /** `#needle` anchor value, when the target's name carries one */
16
+ anchor?: string;
17
+ /** element type (`node`, `material`, …) — narrows resolution and validates matches */
18
+ type?: StampedElementType | (string & {});
19
+ /** the target's name at address-creation time */
20
+ name?: string;
21
+ /** node names from the asset root (exclusive) down to the target (inclusive); nodes only */
22
+ namePath?: string[];
23
+ };
24
+
25
+ /** Structural (duck-typed) view of a live scene-graph node — works with any three.js. */
26
+ export type GraphNode = {
27
+ name?: string;
28
+ children?: readonly GraphNode[];
29
+ userData?: Record<string, unknown>;
30
+ };
31
+
32
+ /** Build an address for a glTF root-collection element. `namePath` only applies to nodes. */
33
+ export function createGltfElementAddress(json: GltfJson, type: StampedElementType | string, index: number): NeedleAddress | null {
34
+ const collection = json[collectionKeyForType(type)];
35
+ if (!Array.isArray(collection)) return null;
36
+ const element = collection[index];
37
+ if (element == null || typeof element !== "object") return null;
38
+
39
+ const name = typeof element.name === "string" ? element.name : undefined;
40
+ const address: NeedleAddress = { type };
41
+ const id = getElementId(element);
42
+ if (id) address.id = id;
43
+ const anchor = parseAnchorValue(name);
44
+ if (anchor) address.anchor = anchor;
45
+ if (name !== undefined) address.name = name;
46
+ if (type === "node") {
47
+ const path = gltfNodeNamePath(json, index);
48
+ if (path) address.namePath = path;
49
+ }
50
+ return address;
51
+ }
52
+
53
+ /** Build an address for a live scene-graph object under `root`. */
54
+ export function createObjectAddress(root: GraphNode, target: GraphNode): NeedleAddress | null {
55
+ const path = findPath(root, target);
56
+ if (!path) return null;
57
+
58
+ const address: NeedleAddress = { type: "node" };
59
+ const id = target.userData?.[NEEDLE_ID_KEY];
60
+ if (typeof id === "string") address.id = id;
61
+ const anchor = parseAnchorValue(target.name);
62
+ if (anchor) address.anchor = anchor;
63
+ if (typeof target.name === "string") address.name = target.name;
64
+ address.namePath = path.map(n => n.name ?? "");
65
+ return address;
66
+ }
67
+
68
+ export function collectionKeyForType(type: string): string {
69
+ // glTF collection keys are the pluralized type names; only "mesh" is irregular
70
+ return type === "mesh" ? "meshes" : `${type}s`;
71
+ }
72
+
73
+ /** names from root (exclusive) to the node at `nodeIndex` (inclusive), via the scene tree */
74
+ function gltfNodeNamePath(json: GltfJson, nodeIndex: number): string[] | null {
75
+ const nodes = json["nodes"];
76
+ if (!Array.isArray(nodes)) return null;
77
+ const parents = new Map<number, number>();
78
+ for (let i = 0; i < nodes.length; i++) {
79
+ const children = nodes[i]?.children;
80
+ if (Array.isArray(children)) for (const c of children) parents.set(c, i);
81
+ }
82
+ const path: string[] = [];
83
+ let current: number | undefined = nodeIndex;
84
+ const seen = new Set<number>();
85
+ while (current !== undefined) {
86
+ if (seen.has(current)) return null; // malformed (cyclic) input
87
+ seen.add(current);
88
+ path.unshift(typeof nodes[current]?.name === "string" ? nodes[current].name : "");
89
+ current = parents.get(current);
90
+ }
91
+ return path;
92
+ }
93
+
94
+ /** path from root (exclusive) to target (inclusive), or null if target is not under root */
95
+ export function findPath(root: GraphNode, target: GraphNode): GraphNode[] | null {
96
+ if (root === target) return [];
97
+ const children = root.children;
98
+ if (!children) return null;
99
+ for (let i = 0; i < children.length; i++) {
100
+ const child = children[i];
101
+ if (child == null) continue;
102
+ const sub = findPath(child, target);
103
+ if (sub) return [child, ...sub];
104
+ }
105
+ return null;
106
+ }
package/src/anchor.ts ADDED
@@ -0,0 +1,71 @@
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
+
14
+ /** the original marker — kept as a named export for existing consumers */
15
+ export const ANCHOR_MARKER = "#needle";
16
+
17
+ /**
18
+ * All recognized markers, matched longest-first so overlapping suffixes resolve
19
+ * deterministically. Currently one entry; see the contract note before adding more.
20
+ */
21
+ export const ANCHOR_MARKERS: readonly string[] = [ANCHOR_MARKER];
22
+
23
+ export type ParsedAnchor = {
24
+ /** the marker that matched (one of {@link ANCHOR_MARKERS}) */
25
+ marker: string;
26
+ /** the anchor value — the deterministic-id input */
27
+ value: string;
28
+ };
29
+
30
+ /**
31
+ * Parse an element name against all recognized markers (case-insensitive suffix match).
32
+ * Returns null when no marker matches or the anchor value is empty.
33
+ */
34
+ export function parseAnchor(rawName: string | null | undefined): ParsedAnchor | null {
35
+ if (rawName == null) return null;
36
+ const trimmed = rawName.trim();
37
+ const lower = trimmed.toLowerCase();
38
+ for (const marker of byLengthDescending(ANCHOR_MARKERS)) {
39
+ if (!lower.endsWith(marker)) continue;
40
+ const value = marker === ANCHOR_MARKER
41
+ ? legacyNeedleAnchorValue(rawName, trimmed)
42
+ : trimmed.substring(0, trimmed.length - marker.length).trim();
43
+ return value.length > 0 ? { marker, value } : null;
44
+ }
45
+ return null;
46
+ }
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: string | null | undefined): string | null {
53
+ return parseAnchor(rawName)?.value ?? null;
54
+ }
55
+
56
+ /**
57
+ * `#needle` value extraction, bug-compatible with needle-cloud's `ensureUUID`
58
+ * (`gltf.edit.ts`), which computes the cut index from the TRIMMED name but applies it to
59
+ * the RAW name: a name with leading whitespace yields a shifted (shorter) anchor value
60
+ * (`" Trim#NEEDLE"` → `"Tr"`). Anchored ids are the ids that must survive asset
61
+ * updates, so this quirk is frozen into the contract for THIS marker — fixing it would
62
+ * change every anchored id ever minted for such names. Future markers use the clean
63
+ * trimmed parsing in {@link parseAnchor} instead.
64
+ */
65
+ function legacyNeedleAnchorValue(rawName: string, trimmed: string): string {
66
+ return rawName.substring(0, trimmed.length - ANCHOR_MARKER.length).trim();
67
+ }
68
+
69
+ function byLengthDescending(markers: readonly string[]): readonly string[] {
70
+ return markers.length > 1 ? [...markers].sort((a, b) => b.length - a.length) : markers;
71
+ }
package/src/gltf.ts ADDED
@@ -0,0 +1,71 @@
1
+ import { computeElementId, computeFileHash } from "./id.js";
2
+
3
+ /** The glTF `extras` key carrying the persistent id. */
4
+ export const NEEDLE_ID_KEY = "needle-id";
5
+
6
+ /** All root-collection element types that receive ids, with their glTF collection key. */
7
+ export const STAMPED_COLLECTIONS = [
8
+ ["node", "nodes"],
9
+ ["scene", "scenes"],
10
+ ["animation", "animations"],
11
+ ["camera", "cameras"],
12
+ ["material", "materials"],
13
+ ["mesh", "meshes"],
14
+ ["skin", "skins"],
15
+ ["texture", "textures"],
16
+ ["image", "images"],
17
+ ["sampler", "samplers"],
18
+ ] as const satisfies ReadonlyArray<readonly [string, string]>;
19
+
20
+ export type StampedElementType = (typeof STAMPED_COLLECTIONS)[number][0];
21
+
22
+ /** Structural (duck-typed) view of a glTF root element — no glTF library dependency. */
23
+ export type GltfElement = {
24
+ name?: string;
25
+ extras?: Record<string, unknown>;
26
+ [key: string]: unknown;
27
+ };
28
+
29
+ /** Structural view of a glTF JSON root. Collections are looked up by key. */
30
+ export type GltfJson = {
31
+ [collection: string]: unknown;
32
+ };
33
+
34
+ export type EnsureIdsResult = {
35
+ /** the file fingerprint the ids were derived from (of the json BEFORE injection) */
36
+ fileHash: string;
37
+ /** how many elements received a new id (0 → the file was already fully stamped) */
38
+ assigned: number;
39
+ };
40
+
41
+ /**
42
+ * Idempotently inject `extras["needle-id"]` into every element of the stamped
43
+ * collections. Existing ids are never overwritten, so cloud-stamped files pass through
44
+ * unchanged and editor-computed ids agree with what the cloud minted for the same bytes.
45
+ *
46
+ * Mutates `json` in place (same shape as needle-cloud's upload-time `ensureUUID`).
47
+ */
48
+ export function ensureIds(json: GltfJson): EnsureIdsResult {
49
+ const fileHash = computeFileHash(json);
50
+ let assigned = 0;
51
+ for (const [type, collection] of STAMPED_COLLECTIONS) {
52
+ const elements = json[collection];
53
+ if (!Array.isArray(elements)) continue;
54
+ for (let index = 0; index < elements.length; index++) {
55
+ const element = elements[index] as GltfElement;
56
+ if (element == null || typeof element !== "object") continue;
57
+ element.extras ??= {};
58
+ if (element.extras[NEEDLE_ID_KEY] === undefined) {
59
+ element.extras[NEEDLE_ID_KEY] = computeElementId(type, element.name, index, fileHash);
60
+ assigned++;
61
+ }
62
+ }
63
+ }
64
+ return { fileHash, assigned };
65
+ }
66
+
67
+ /** Read an element's needle-id without computing anything. */
68
+ export function getElementId(element: GltfElement | null | undefined): string | null {
69
+ const id = element?.extras?.[NEEDLE_ID_KEY];
70
+ return typeof id === "string" ? id : null;
71
+ }
package/src/hash.ts ADDED
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Vendored hash primitives so the package has zero runtime dependencies.
3
+ * md5Hex must produce byte-identical output to `js-md5`'s `md5(str)` (needle-cloud's
4
+ * `hashStringMD5`) — the file fingerprint is part of the deterministic id contract.
5
+ * sha1 backs UUIDv5 (RFC 4122).
6
+ */
7
+
8
+ const MD5_K = new Uint32Array(64);
9
+ for (let i = 0; i < 64; i++) {
10
+ MD5_K[i] = Math.floor(Math.abs(Math.sin(i + 1)) * 0x100000000);
11
+ }
12
+ // per-round left-rotate amounts
13
+ const MD5_S = [
14
+ 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22,
15
+ 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20,
16
+ 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23,
17
+ 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21,
18
+ ];
19
+
20
+ /** MD5 of the UTF-8 encoding of `input`, as a lowercase hex string. */
21
+ export function md5Hex(input: string | Uint8Array): string {
22
+ const data = typeof input === "string" ? new TextEncoder().encode(input) : input;
23
+ const padded = padMessage(data, /* littleEndianLength */ true);
24
+ const dv = new DataView(padded.buffer, padded.byteOffset, padded.byteLength);
25
+
26
+ let a0 = 0x67452301, b0 = 0xefcdab89, c0 = 0x98badcfe, d0 = 0x10325476;
27
+ const m = new Uint32Array(16);
28
+ for (let off = 0; off < padded.length; off += 64) {
29
+ for (let i = 0; i < 16; i++) m[i] = dv.getUint32(off + i * 4, true);
30
+ let a = a0, b = b0, c = c0, d = d0;
31
+ for (let i = 0; i < 64; i++) {
32
+ let f: number, g: number;
33
+ if (i < 16) { f = (b & c) | (~b & d); g = i; }
34
+ else if (i < 32) { f = (d & b) | (~d & c); g = (5 * i + 1) % 16; }
35
+ else if (i < 48) { f = b ^ c ^ d; g = (3 * i + 5) % 16; }
36
+ else { f = c ^ (b | ~d); g = (7 * i) % 16; }
37
+ const t = (f + a + MD5_K[i] + m[g]) | 0;
38
+ a = d; d = c; c = b;
39
+ b = (b + ((t << MD5_S[i]) | (t >>> (32 - MD5_S[i])))) | 0;
40
+ }
41
+ a0 = (a0 + a) | 0; b0 = (b0 + b) | 0; c0 = (c0 + c) | 0; d0 = (d0 + d) | 0;
42
+ }
43
+
44
+ const out = new Uint8Array(16);
45
+ const odv = new DataView(out.buffer);
46
+ odv.setUint32(0, a0 >>> 0, true);
47
+ odv.setUint32(4, b0 >>> 0, true);
48
+ odv.setUint32(8, c0 >>> 0, true);
49
+ odv.setUint32(12, d0 >>> 0, true);
50
+ return toHex(out);
51
+ }
52
+
53
+ /** SHA-1 digest bytes of `data`. */
54
+ export function sha1(data: Uint8Array): Uint8Array {
55
+ const padded = padMessage(data, /* littleEndianLength */ false);
56
+ const dv = new DataView(padded.buffer, padded.byteOffset, padded.byteLength);
57
+
58
+ let h0 = 0x67452301, h1 = 0xefcdab89, h2 = 0x98badcfe, h3 = 0x10325476, h4 = 0xc3d2e1f0;
59
+ const w = new Uint32Array(80);
60
+ for (let off = 0; off < padded.length; off += 64) {
61
+ for (let i = 0; i < 16; i++) w[i] = dv.getUint32(off + i * 4);
62
+ for (let i = 16; i < 80; i++) {
63
+ const n = w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16];
64
+ w[i] = (n << 1) | (n >>> 31);
65
+ }
66
+ let a = h0, b = h1, c = h2, d = h3, e = h4;
67
+ for (let i = 0; i < 80; i++) {
68
+ let f: number, k: number;
69
+ if (i < 20) { f = (b & c) | (~b & d); k = 0x5a827999; }
70
+ else if (i < 40) { f = b ^ c ^ d; k = 0x6ed9eba1; }
71
+ else if (i < 60) { f = (b & c) | (b & d) | (c & d); k = 0x8f1bbcdc; }
72
+ else { f = b ^ c ^ d; k = 0xca62c1d6; }
73
+ const t = ((((a << 5) | (a >>> 27)) + f + e + k + w[i]) | 0);
74
+ e = d; d = c; c = (b << 30) | (b >>> 2); b = a; a = t;
75
+ }
76
+ h0 = (h0 + a) | 0; h1 = (h1 + b) | 0; h2 = (h2 + c) | 0; h3 = (h3 + d) | 0; h4 = (h4 + e) | 0;
77
+ }
78
+
79
+ const out = new Uint8Array(20);
80
+ const odv = new DataView(out.buffer);
81
+ odv.setUint32(0, h0 >>> 0);
82
+ odv.setUint32(4, h1 >>> 0);
83
+ odv.setUint32(8, h2 >>> 0);
84
+ odv.setUint32(12, h3 >>> 0);
85
+ odv.setUint32(16, h4 >>> 0);
86
+ return out;
87
+ }
88
+
89
+ export function toHex(bytes: Uint8Array): string {
90
+ let s = "";
91
+ for (let i = 0; i < bytes.length; i++) s += bytes[i].toString(16).padStart(2, "0");
92
+ return s;
93
+ }
94
+
95
+ /** append 0x80, zero-pad to 56 mod 64, append 64-bit bit-length (LE for MD5, BE for SHA-1) */
96
+ function padMessage(data: Uint8Array, littleEndianLength: boolean): Uint8Array {
97
+ const bitLen = data.length * 8;
98
+ const padded = new Uint8Array((((data.length + 8) >> 6) + 1) * 64);
99
+ padded.set(data);
100
+ padded[data.length] = 0x80;
101
+ const dv = new DataView(padded.buffer);
102
+ const lo = bitLen >>> 0;
103
+ const hi = Math.floor(bitLen / 0x100000000);
104
+ if (littleEndianLength) {
105
+ dv.setUint32(padded.length - 8, lo, true);
106
+ dv.setUint32(padded.length - 4, hi, true);
107
+ }
108
+ else {
109
+ dv.setUint32(padded.length - 8, hi);
110
+ dv.setUint32(padded.length - 4, lo);
111
+ }
112
+ return padded;
113
+ }
package/src/id.ts ADDED
@@ -0,0 +1,32 @@
1
+ import { parseAnchorValue } from "./anchor.js";
2
+ import { md5Hex } from "./hash.js";
3
+ import { uuidv5 } from "./uuid.js";
4
+
5
+ /**
6
+ * Fingerprint of a glTF file for deterministic id computation: MD5 hex of
7
+ * `JSON.stringify(json)`, taken over the file AS-IS **before any ids are injected**
8
+ * (needle-cloud hashes first, then mutates — order is part of the contract).
9
+ */
10
+ export function computeFileHash(json: unknown): string {
11
+ return md5Hex(JSON.stringify(json));
12
+ }
13
+
14
+ /**
15
+ * The deterministic needle-id of a glTF element. Byte-identical to needle-cloud's
16
+ * `ensureUUID` (upload-time injection):
17
+ *
18
+ * - anchored name (`… #needle`): `uuidv5("<type>-<anchor>", URL)` — stable across
19
+ * asset versions.
20
+ * - otherwise: `uuidv5("<type>-<name>-<index>-<fileHash>", URL)` — stable per asset
21
+ * VERSION only (the file hash is an input by design: same-named elements in
22
+ * different files must not collide).
23
+ *
24
+ * `name` handling matches the source: absent/empty names contribute `""`.
25
+ */
26
+ export function computeElementId(type: string, name: string | null | undefined, index: number, fileHash: string): string {
27
+ const anchor = parseAnchorValue(name);
28
+ const str = anchor !== null
29
+ ? `${type}-${anchor}`
30
+ : `${type}-${name ? name : ""}-${index}-${fileHash}`;
31
+ return uuidv5(str);
32
+ }
package/src/index.ts ADDED
@@ -0,0 +1,29 @@
1
+ export { ANCHOR_MARKER, ANCHOR_MARKERS, parseAnchor, parseAnchorValue, type ParsedAnchor } from "./anchor.js";
2
+ export { computeElementId, computeFileHash } from "./id.js";
3
+ export {
4
+ ensureIds,
5
+ getElementId,
6
+ NEEDLE_ID_KEY,
7
+ STAMPED_COLLECTIONS,
8
+ type EnsureIdsResult,
9
+ type GltfElement,
10
+ type GltfJson,
11
+ type StampedElementType,
12
+ } from "./gltf.js";
13
+ export {
14
+ collectionKeyForType,
15
+ createGltfElementAddress,
16
+ createObjectAddress,
17
+ findPath,
18
+ type GraphNode,
19
+ type NeedleAddress,
20
+ } from "./address.js";
21
+ export {
22
+ reconcileInGraph,
23
+ resolveInGltf,
24
+ resolveInGraph,
25
+ type GltfResolveResult,
26
+ type ReconcileCandidate,
27
+ } from "./resolve.js";
28
+ export { randomUuid, uuidv5, UUID_NAMESPACE_URL } from "./uuid.js";
29
+ export { md5Hex } from "./hash.js";
package/src/resolve.ts ADDED
@@ -0,0 +1,172 @@
1
+ import { parseAnchorValue } from "./anchor.js";
2
+ import { collectionKeyForType, GraphNode, NeedleAddress } from "./address.js";
3
+ import { GltfJson, NEEDLE_ID_KEY, STAMPED_COLLECTIONS } from "./gltf.js";
4
+
5
+ export type GltfResolveResult = {
6
+ type: string;
7
+ index: number;
8
+ element: Record<string, unknown>;
9
+ };
10
+
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).
15
+ */
16
+ export function resolveInGltf(address: NeedleAddress, json: GltfJson): GltfResolveResult | null {
17
+ const searched: Array<readonly [string, string]> = address.type
18
+ ? [[address.type, collectionKeyForType(address.type)]]
19
+ : [...STAMPED_COLLECTIONS];
20
+
21
+ if (address.id) {
22
+ for (const [type, key] of searched) {
23
+ const found = findInCollection(json, key, el => el.extras?.[NEEDLE_ID_KEY] === address.id);
24
+ if (found) return { type, index: found.index, element: found.element };
25
+ }
26
+ }
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 };
31
+ }
32
+ }
33
+ if (address.namePath?.length && (!address.type || address.type === "node")) {
34
+ const index = walkGltfNamePath(json, address.namePath);
35
+ if (index !== null) {
36
+ const nodes = json["nodes"] as Array<Record<string, unknown>>;
37
+ return { type: "node", index, element: nodes[index] };
38
+ }
39
+ }
40
+ return null;
41
+ }
42
+
43
+ /**
44
+ * Resolve an address inside a live scene graph (duck-typed — any three.js).
45
+ * Same order as glTF resolution: id → anchor → namePath.
46
+ */
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
+ }
52
+ if (address.anchor) {
53
+ const byAnchor = findInGraph(root, n => parseAnchorValue(n.name) === address.anchor);
54
+ if (byAnchor) return byAnchor;
55
+ }
56
+ if (address.namePath?.length) {
57
+ let current: GraphNode | null = root;
58
+ for (const segment of address.namePath) {
59
+ current = current.children?.find(c => (c.name ?? "") === segment) ?? null;
60
+ if (!current) break;
61
+ }
62
+ if (current && current !== root) return current;
63
+ }
64
+ return null;
65
+ }
66
+
67
+ export type ReconcileCandidate = {
68
+ object: GraphNode;
69
+ /** 0..1 — heuristic, for ranking and thresholds; 1.0 is reserved for exact resolution */
70
+ confidence: number;
71
+ reasons: string[];
72
+ };
73
+
74
+ /**
75
+ * When exact resolution fails (the asset changed), propose candidates instead of
76
+ * guessing: nodes matching the address's name, ranked by how much of the recorded
77
+ * namePath still matches. Callers decide thresholds and whether to re-anchor.
78
+ */
79
+ export function reconcileInGraph(address: NeedleAddress, root: GraphNode): ReconcileCandidate[] {
80
+ if (!address.name && !address.namePath?.length) return [];
81
+ const targetName = address.name ?? address.namePath![address.namePath!.length - 1];
82
+
83
+ const candidates: ReconcileCandidate[] = [];
84
+ collectDescendants(root, (node, namePath) => {
85
+ if ((node.name ?? "") !== targetName) return;
86
+ const reasons = [`name matches "${targetName}"`];
87
+ let confidence = 0.5;
88
+ if (address.namePath?.length) {
89
+ const similarity = pathSuffixSimilarity(address.namePath, namePath);
90
+ confidence += 0.4 * similarity;
91
+ if (similarity > 0) reasons.push(`path suffix similarity ${(similarity * 100).toFixed(0)}%`);
92
+ }
93
+ candidates.push({ object: node, confidence, reasons });
94
+ });
95
+ if (candidates.length === 1) {
96
+ candidates[0].confidence = Math.min(0.95, candidates[0].confidence + 0.1);
97
+ candidates[0].reasons.push("only candidate with this name");
98
+ }
99
+ return candidates.sort((a, b) => b.confidence - a.confidence);
100
+ }
101
+
102
+ function findInCollection(json: GltfJson, key: string, predicate: (el: Record<string, any>) => boolean): { index: number; element: Record<string, unknown> } | null {
103
+ const collection = json[key];
104
+ if (!Array.isArray(collection)) return null;
105
+ for (let i = 0; i < collection.length; i++) {
106
+ const element = collection[i];
107
+ if (element != null && typeof element === "object" && predicate(element)) {
108
+ return { index: i, element };
109
+ }
110
+ }
111
+ return null;
112
+ }
113
+
114
+ function walkGltfNamePath(json: GltfJson, namePath: string[]): number | null {
115
+ const nodes = json["nodes"];
116
+ const scenes = json["scenes"];
117
+ if (!Array.isArray(nodes) || !Array.isArray(scenes)) return null;
118
+ // roots: the union of all scenes' root nodes
119
+ let candidates: number[] = scenes.flatMap(s => (Array.isArray(s?.nodes) ? s.nodes : []));
120
+ let match: number | null = null;
121
+ for (const segment of namePath) {
122
+ match = candidates.find(i => ((nodes[i]?.name ?? "") === segment)) ?? null;
123
+ if (match === null) return null;
124
+ const children = nodes[match]?.children;
125
+ candidates = Array.isArray(children) ? children : [];
126
+ }
127
+ return match;
128
+ }
129
+
130
+ function findInGraph(root: GraphNode, predicate: (node: GraphNode) => boolean, visited = new Set<GraphNode>()): GraphNode | null {
131
+ if (visited.has(root)) return null;
132
+ visited.add(root);
133
+ if (predicate(root)) return root;
134
+ if (!root.children) return null;
135
+ for (let i = 0; i < root.children.length; i++) {
136
+ const child = root.children[i];
137
+ if (child == null) continue;
138
+ const found = findInGraph(child, predicate, visited);
139
+ if (found) return found;
140
+ }
141
+ return null;
142
+ }
143
+
144
+ /** visit every descendant of root with its root-exclusive name path (including own name) */
145
+ function collectDescendants(root: GraphNode, visit: (node: GraphNode, namePath: string[]) => void): void {
146
+ const visited = new Set<GraphNode>();
147
+ const walk = (node: GraphNode, namePath: string[]) => {
148
+ if (visited.has(node)) return;
149
+ visited.add(node);
150
+ if (node !== root) visit(node, namePath);
151
+ if (!node.children) return;
152
+ for (let i = 0; i < node.children.length; i++) {
153
+ const child = node.children[i];
154
+ if (child == null) continue;
155
+ walk(child, [...namePath, child.name ?? ""]);
156
+ }
157
+ };
158
+ walk(root, []);
159
+ }
160
+
161
+ /** how much of the expected path's tail matches the actual path's tail (0..1) */
162
+ function pathSuffixSimilarity(expected: string[], actual: string[]): number {
163
+ let shared = 0;
164
+ while (
165
+ shared < expected.length &&
166
+ shared < actual.length &&
167
+ expected[expected.length - 1 - shared] === actual[actual.length - 1 - shared]
168
+ ) {
169
+ shared++;
170
+ }
171
+ return shared / Math.max(expected.length, actual.length);
172
+ }
package/src/uuid.ts ADDED
@@ -0,0 +1,48 @@
1
+ import { sha1, toHex } from "./hash.js";
2
+
3
+ /** RFC 4122 URL namespace — the namespace needle-cloud's `ensureUUID` uses (`uuidv5.URL`). */
4
+ export const UUID_NAMESPACE_URL = "6ba7b811-9dad-11d1-80b4-00c04fd430c8";
5
+
6
+ /**
7
+ * RFC 4122 version-5 (SHA-1, name-based) UUID. Byte-identical to the `uuid` npm
8
+ * package's `v5(name, namespace)`.
9
+ */
10
+ export function uuidv5(name: string, namespace: string = UUID_NAMESPACE_URL): string {
11
+ const ns = parseUuidBytes(namespace);
12
+ const nameBytes = new TextEncoder().encode(name);
13
+ const input = new Uint8Array(ns.length + nameBytes.length);
14
+ input.set(ns);
15
+ input.set(nameBytes, ns.length);
16
+ const hash = sha1(input).slice(0, 16);
17
+ hash[6] = (hash[6] & 0x0f) | 0x50; // version 5
18
+ hash[8] = (hash[8] & 0x3f) | 0x80; // RFC 4122 variant
19
+ return formatUuid(hash);
20
+ }
21
+
22
+ /**
23
+ * Random UUID for content that has no source file to be deterministic against
24
+ * (scratch content created in an editor). Uses the platform's crypto when available.
25
+ */
26
+ export function randomUuid(): string {
27
+ const cryptoObj = globalThis.crypto;
28
+ if (cryptoObj?.randomUUID) return cryptoObj.randomUUID();
29
+ const bytes = new Uint8Array(16);
30
+ if (cryptoObj?.getRandomValues) cryptoObj.getRandomValues(bytes);
31
+ else for (let i = 0; i < 16; i++) bytes[i] = Math.floor(Math.random() * 256);
32
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
33
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
34
+ return formatUuid(bytes);
35
+ }
36
+
37
+ function parseUuidBytes(uuid: string): Uint8Array {
38
+ const hex = uuid.replace(/-/g, "");
39
+ if (hex.length !== 32) throw new Error(`Invalid UUID: ${uuid}`);
40
+ const bytes = new Uint8Array(16);
41
+ for (let i = 0; i < 16; i++) bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16);
42
+ return bytes;
43
+ }
44
+
45
+ function formatUuid(bytes: Uint8Array): string {
46
+ const hex = toHex(bytes);
47
+ return `${hex.substring(0, 8)}-${hex.substring(8, 12)}-${hex.substring(12, 16)}-${hex.substring(16, 20)}-${hex.substring(20)}`;
48
+ }