@needle-tools/addressing 0.1.0 → 0.3.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 +69 -89
- package/package.json +39 -35
- package/src/address.ts +106 -106
- package/src/anchor.ts +1 -16
- package/src/gltf.ts +89 -71
- package/src/hash.ts +58 -113
- package/src/id.ts +45 -32
- package/src/index.ts +2 -2
- package/src/resolve.ts +172 -172
- package/src/uuid.ts +48 -48
package/src/hash.ts
CHANGED
|
@@ -1,113 +1,58 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Vendored
|
|
3
|
-
*
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
}
|
|
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
|
+
|
|
6
|
+
/** SHA-1 digest bytes of `data`. */
|
|
7
|
+
export function sha1(data: Uint8Array): Uint8Array {
|
|
8
|
+
const padded = padMessage(data);
|
|
9
|
+
const dv = new DataView(padded.buffer, padded.byteOffset, padded.byteLength);
|
|
10
|
+
|
|
11
|
+
let h0 = 0x67452301, h1 = 0xefcdab89, h2 = 0x98badcfe, h3 = 0x10325476, h4 = 0xc3d2e1f0;
|
|
12
|
+
const w = new Uint32Array(80);
|
|
13
|
+
for (let off = 0; off < padded.length; off += 64) {
|
|
14
|
+
for (let i = 0; i < 16; i++) w[i] = dv.getUint32(off + i * 4);
|
|
15
|
+
for (let i = 16; i < 80; i++) {
|
|
16
|
+
const n = w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16];
|
|
17
|
+
w[i] = (n << 1) | (n >>> 31);
|
|
18
|
+
}
|
|
19
|
+
let a = h0, b = h1, c = h2, d = h3, e = h4;
|
|
20
|
+
for (let i = 0; i < 80; i++) {
|
|
21
|
+
let f: number, k: number;
|
|
22
|
+
if (i < 20) { f = (b & c) | (~b & d); k = 0x5a827999; }
|
|
23
|
+
else if (i < 40) { f = b ^ c ^ d; k = 0x6ed9eba1; }
|
|
24
|
+
else if (i < 60) { f = (b & c) | (b & d) | (c & d); k = 0x8f1bbcdc; }
|
|
25
|
+
else { f = b ^ c ^ d; k = 0xca62c1d6; }
|
|
26
|
+
const t = ((((a << 5) | (a >>> 27)) + f + e + k + w[i]) | 0);
|
|
27
|
+
e = d; d = c; c = (b << 30) | (b >>> 2); b = a; a = t;
|
|
28
|
+
}
|
|
29
|
+
h0 = (h0 + a) | 0; h1 = (h1 + b) | 0; h2 = (h2 + c) | 0; h3 = (h3 + d) | 0; h4 = (h4 + e) | 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const out = new Uint8Array(20);
|
|
33
|
+
const odv = new DataView(out.buffer);
|
|
34
|
+
odv.setUint32(0, h0 >>> 0);
|
|
35
|
+
odv.setUint32(4, h1 >>> 0);
|
|
36
|
+
odv.setUint32(8, h2 >>> 0);
|
|
37
|
+
odv.setUint32(12, h3 >>> 0);
|
|
38
|
+
odv.setUint32(16, h4 >>> 0);
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function toHex(bytes: Uint8Array): string {
|
|
43
|
+
let s = "";
|
|
44
|
+
for (let i = 0; i < bytes.length; i++) s += bytes[i].toString(16).padStart(2, "0");
|
|
45
|
+
return s;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** append 0x80, zero-pad to 56 mod 64, append big-endian 64-bit bit-length */
|
|
49
|
+
function padMessage(data: Uint8Array): Uint8Array {
|
|
50
|
+
const bitLen = data.length * 8;
|
|
51
|
+
const padded = new Uint8Array((((data.length + 8) >> 6) + 1) * 64);
|
|
52
|
+
padded.set(data);
|
|
53
|
+
padded[data.length] = 0x80;
|
|
54
|
+
const dv = new DataView(padded.buffer);
|
|
55
|
+
dv.setUint32(padded.length - 8, Math.floor(bitLen / 0x100000000));
|
|
56
|
+
dv.setUint32(padded.length - 4, bitLen >>> 0);
|
|
57
|
+
return padded;
|
|
58
|
+
}
|
package/src/id.ts
CHANGED
|
@@ -1,32 +1,45 @@
|
|
|
1
|
-
import { parseAnchorValue } from "./anchor.js";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
1
|
+
import { parseAnchorValue } from "./anchor.js";
|
|
2
|
+
import { uuidv5 } from "./uuid.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The deterministic needle-id of a glTF element.
|
|
6
|
+
*
|
|
7
|
+
* - Anchored name (`… #needle`): `uuidv5("<type>-<anchor>")` — survives ANY content
|
|
8
|
+
* change while the name stays.
|
|
9
|
+
* - Otherwise: `uuidv5("<type>-<name>-<occurrence>[-<baseHash>]")` where `occurrence`
|
|
10
|
+
* is the element's position among SAME-NAMED elements of its type. Uniquely named
|
|
11
|
+
* elements are therefore fully reorder-stable across file versions; duplicates shift
|
|
12
|
+
* only when reordered within their own name group; unnamed elements are positional
|
|
13
|
+
* among the unnamed.
|
|
14
|
+
* - `baseHash` is an optional consumer-supplied salt (e.g. an asset/content id) that
|
|
15
|
+
* scopes ids to an external identity. Omitted by default, so any tool stamping the
|
|
16
|
+
* same element mints the same id with no coordination.
|
|
17
|
+
*
|
|
18
|
+
* Ids are persisted in `extras["needle-id"]` and never overwritten once present —
|
|
19
|
+
* whoever stamps first wins; this rule only governs never-stamped elements.
|
|
20
|
+
*/
|
|
21
|
+
export function computeElementId(type: string, name: string | null | undefined, occurrence: number, baseHash?: string): string {
|
|
22
|
+
const anchor = parseAnchorValue(name);
|
|
23
|
+
const str = anchor !== null
|
|
24
|
+
? `${type}-${anchor}`
|
|
25
|
+
: `${type}-${name ? name : ""}-${occurrence}${baseHash ? `-${baseHash}` : ""}`;
|
|
26
|
+
return uuidv5(str);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The occurrence of the element at `index` among same-named elements of its collection —
|
|
31
|
+
* the positional input of {@link computeElementId}. Exposed so consumers computing ids
|
|
32
|
+
* for individual elements (instead of stamping whole files) use the identical rule.
|
|
33
|
+
*/
|
|
34
|
+
export function occurrenceOfName(elements: ReadonlyArray<{ name?: unknown } | null | undefined>, index: number): number {
|
|
35
|
+
const name = normalizedName(elements[index]);
|
|
36
|
+
let occurrence = 0;
|
|
37
|
+
for (let i = 0; i < index; i++) {
|
|
38
|
+
if (normalizedName(elements[i]) === name) occurrence++;
|
|
39
|
+
}
|
|
40
|
+
return occurrence;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalizedName(element: { name?: unknown } | null | undefined): string {
|
|
44
|
+
return typeof element?.name === "string" ? element.name : "";
|
|
45
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
export { ANCHOR_MARKER, ANCHOR_MARKERS, parseAnchor, parseAnchorValue, type ParsedAnchor } from "./anchor.js";
|
|
2
|
-
export { computeElementId,
|
|
2
|
+
export { computeElementId, occurrenceOfName } from "./id.js";
|
|
3
3
|
export {
|
|
4
4
|
ensureIds,
|
|
5
5
|
getElementId,
|
|
6
6
|
NEEDLE_ID_KEY,
|
|
7
7
|
STAMPED_COLLECTIONS,
|
|
8
|
+
type EnsureIdsOptions,
|
|
8
9
|
type EnsureIdsResult,
|
|
9
10
|
type GltfElement,
|
|
10
11
|
type GltfJson,
|
|
@@ -26,4 +27,3 @@ export {
|
|
|
26
27
|
type ReconcileCandidate,
|
|
27
28
|
} from "./resolve.js";
|
|
28
29
|
export { randomUuid, uuidv5, UUID_NAMESPACE_URL } from "./uuid.js";
|
|
29
|
-
export { md5Hex } from "./hash.js";
|
package/src/resolve.ts
CHANGED
|
@@ -1,172 +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
|
-
}
|
|
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
|
+
}
|