@needle-tools/addressing 0.1.1 → 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 +10 -7
- package/package.json +39 -35
- package/src/address.ts +106 -106
- 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/Readme.md
CHANGED
|
@@ -12,15 +12,18 @@ Every glTF root-collection element (nodes, scenes, animations, cameras, material
|
|
|
12
12
|
meshes, skins, textures, images, samplers) can carry a UUID in `extras["needle-id"]`:
|
|
13
13
|
|
|
14
14
|
- **Anchored** — the element name ends with `#needle` (case-insensitive): the id derives
|
|
15
|
-
only from the text before the marker → it **survives content
|
|
16
|
-
|
|
17
|
-
- **Unanchored**: the id derives from the element's type, name,
|
|
18
|
-
|
|
19
|
-
|
|
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.
|
|
20
21
|
|
|
21
22
|
`ensureIds(gltfJson)` stamps a parsed glTF idempotently — existing ids are never
|
|
22
|
-
overwritten, and stamping the same
|
|
23
|
-
|
|
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.
|
|
24
27
|
|
|
25
28
|
## Addresses
|
|
26
29
|
|
package/package.json
CHANGED
|
@@ -1,35 +1,39 @@
|
|
|
1
|
-
{
|
|
2
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@needle-tools/addressing",
|
|
3
|
+
"version": "0.3.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
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "https://github.com/needle-tools/addressing.git"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/address.ts
CHANGED
|
@@ -1,106 +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
|
-
}
|
|
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/gltf.ts
CHANGED
|
@@ -1,71 +1,89 @@
|
|
|
1
|
-
import { computeElementId
|
|
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
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
1
|
+
import { computeElementId } 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 EnsureIdsOptions = {
|
|
35
|
+
/**
|
|
36
|
+
* Optional salt scoping unanchored ids to an external identity (e.g. an
|
|
37
|
+
* asset/content id). By default no salt is used — ids derive from the element alone
|
|
38
|
+
* (type, name, occurrence-of-name), so any tool mints the same ids with no
|
|
39
|
+
* coordination. With a salt, only writers that know it agree — safe because
|
|
40
|
+
* existing ids are never overwritten (whoever stamps first wins).
|
|
41
|
+
*/
|
|
42
|
+
baseHash?: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type EnsureIdsResult = {
|
|
46
|
+
/** the salt the ids were derived with, when one was provided */
|
|
47
|
+
baseHash?: string;
|
|
48
|
+
/** how many elements received a new id (0 → the file was already fully stamped) */
|
|
49
|
+
assigned: number;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Idempotently inject `extras["needle-id"]` into every element of the stamped
|
|
54
|
+
* collections. Existing ids are never overwritten, so already-stamped files pass
|
|
55
|
+
* through unchanged and every id writer agrees with whoever stamped first.
|
|
56
|
+
*
|
|
57
|
+
* Mutates `json` in place.
|
|
58
|
+
*/
|
|
59
|
+
export function ensureIds(json: GltfJson, options?: EnsureIdsOptions): EnsureIdsResult {
|
|
60
|
+
const baseHash = options?.baseHash;
|
|
61
|
+
let assigned = 0;
|
|
62
|
+
for (const [type, collection] of STAMPED_COLLECTIONS) {
|
|
63
|
+
const elements = json[collection];
|
|
64
|
+
if (!Array.isArray(elements)) continue;
|
|
65
|
+
// occurrence-of-name: the id input is the element's position among same-named
|
|
66
|
+
// elements, NOT its collection index — uniquely named elements keep their ids
|
|
67
|
+
// across reorders and asset updates
|
|
68
|
+
const occurrences = new Map<string, number>();
|
|
69
|
+
for (let index = 0; index < elements.length; index++) {
|
|
70
|
+
const element = elements[index] as GltfElement;
|
|
71
|
+
if (element == null || typeof element !== "object") continue;
|
|
72
|
+
const name = typeof element.name === "string" ? element.name : "";
|
|
73
|
+
const occurrence = occurrences.get(name) ?? 0;
|
|
74
|
+
occurrences.set(name, occurrence + 1);
|
|
75
|
+
element.extras ??= {};
|
|
76
|
+
if (element.extras[NEEDLE_ID_KEY] === undefined) {
|
|
77
|
+
element.extras[NEEDLE_ID_KEY] = computeElementId(type, name, occurrence, baseHash);
|
|
78
|
+
assigned++;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return { baseHash, assigned };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Read an element's needle-id without computing anything. */
|
|
86
|
+
export function getElementId(element: GltfElement | null | undefined): string | null {
|
|
87
|
+
const id = element?.extras?.[NEEDLE_ID_KEY];
|
|
88
|
+
return typeof id === "string" ? id : null;
|
|
89
|
+
}
|
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
|
+
}
|
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
|
+
}
|