@needle-tools/addressing 0.3.1 → 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/src/resolve.ts CHANGED
@@ -1,172 +1,172 @@
1
- import { parseAnchorValue } from "./anchor.js";
2
- import { collectionKeyForType, type GraphNode, type NeedleAddress } from "./address.js";
3
- import { NEEDLE_ID_KEY, STAMPED_COLLECTIONS, type GltfJson } 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
- }
1
+ import { parseAnchorValue } from "./anchor.js";
2
+ import { collectionKeyForType, type GraphNode, type NeedleAddress } from "./address.js";
3
+ import { NEEDLE_ID_KEY, STAMPED_COLLECTIONS, type GltfJson } 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 CHANGED
@@ -1,48 +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
- }
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
+ }