@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/Readme.md
CHANGED
|
@@ -1,89 +1,69 @@
|
|
|
1
|
-
# @needle-tools/addressing
|
|
2
|
-
|
|
3
|
-
Persistent addressing for
|
|
4
|
-
`#needle` anchor scheme, and address resolution
|
|
5
|
-
|
|
6
|
-
Zero runtime dependencies, no three.js
|
|
7
|
-
in browsers, node, and workers.
|
|
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
|
-
|
|
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
|
-
// later / elsewhere: resolve — or reconcile when the asset changed
|
|
71
|
-
const hit = resolveInGraph(address, sceneRoot);
|
|
72
|
-
const candidates = hit ? [] : reconcileInGraph(address, sceneRoot);
|
|
73
|
-
```
|
|
74
|
-
|
|
75
|
-
Out of scope by design: GLB container parsing (callers own their binary handling — this
|
|
76
|
-
package takes parsed JSON), file I/O, session-scoped editor refs, and any composition/
|
|
77
|
-
override semantics (those live in the project format, one layer above).
|
|
78
|
-
|
|
79
|
-
## Development
|
|
80
|
-
|
|
81
|
-
```
|
|
82
|
-
npm install
|
|
83
|
-
npm test # vitest — includes byte-parity golden tests
|
|
84
|
-
npm run build # tsc → dist/
|
|
85
|
-
```
|
|
86
|
-
|
|
87
|
-
Regenerating `test/fixtures/golden.json` requires the reference libraries
|
|
88
|
-
(`js-md5`, `uuid`) and a verbatim copy of needle-cloud's `ensureUUID` — see the
|
|
89
|
-
fixture header comment in `test/id.test.ts`.
|
|
1
|
+
# @needle-tools/addressing
|
|
2
|
+
|
|
3
|
+
Persistent addressing for glTF assets and three.js scene graphs: deterministic
|
|
4
|
+
`needle-id` computation, the `#needle` name-anchor scheme, and address resolution.
|
|
5
|
+
|
|
6
|
+
Zero runtime dependencies, no three.js import (scene graphs are accessed structurally),
|
|
7
|
+
usable in browsers, node, and workers.
|
|
8
|
+
|
|
9
|
+
## Ids
|
|
10
|
+
|
|
11
|
+
Every glTF root-collection element (nodes, scenes, animations, cameras, materials,
|
|
12
|
+
meshes, skins, textures, images, samplers) can carry a UUID in `extras["needle-id"]`:
|
|
13
|
+
|
|
14
|
+
- **Anchored** — the element name ends with `#needle` (case-insensitive): the id derives
|
|
15
|
+
only from the text before the marker → it **survives any content change** as long as
|
|
16
|
+
the name stays.
|
|
17
|
+
- **Unanchored**: the id derives from the element's type, name, and its occurrence among
|
|
18
|
+
same-named elements → uniquely named elements keep their ids across reorders and
|
|
19
|
+
content updates; duplicates disambiguate by position within their name group; unnamed
|
|
20
|
+
elements are positional.
|
|
21
|
+
|
|
22
|
+
`ensureIds(gltfJson)` stamps a parsed glTF idempotently — existing ids are never
|
|
23
|
+
overwritten, and any tool stamping the same element mints the same id.
|
|
24
|
+
|
|
25
|
+
`ensureIds(gltfJson, { baseHash })` optionally salts unanchored ids with a caller-supplied
|
|
26
|
+
identity (e.g. a stable asset id), scoping them to that identity.
|
|
27
|
+
|
|
28
|
+
## Addresses
|
|
29
|
+
|
|
30
|
+
An address captures every fallback at creation time, while the target is known —
|
|
31
|
+
`{ id, anchor?, type?, name?, namePath? }`. Resolution order is strict:
|
|
32
|
+
|
|
33
|
+
1. `id` — exact match (`extras` in glTF JSON, `userData` in live scene graphs)
|
|
34
|
+
2. `anchor` — survives asset updates
|
|
35
|
+
3. `namePath` — best-effort structural match
|
|
36
|
+
4. **reconciliation** — never automatic: `reconcileInGraph` returns ranked candidates
|
|
37
|
+
with confidence + reasons; the caller decides thresholds and whether to re-anchor.
|
|
38
|
+
|
|
39
|
+
## API
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import {
|
|
43
|
+
ensureIds, computeElementId, computeFileHash, // write side
|
|
44
|
+
createGltfElementAddress, createObjectAddress, // address records
|
|
45
|
+
resolveInGltf, resolveInGraph, reconcileInGraph, // resolve side
|
|
46
|
+
parseAnchorValue, randomUuid, NEEDLE_ID_KEY,
|
|
47
|
+
} from "@needle-tools/addressing";
|
|
48
|
+
|
|
49
|
+
// stamp a parsed glTF (idempotent, never overwrites existing ids)
|
|
50
|
+
const { fileHash, assigned } = ensureIds(gltfJson);
|
|
51
|
+
|
|
52
|
+
// capture an address while the target is known
|
|
53
|
+
const address = createObjectAddress(sceneRoot, targetObject);
|
|
54
|
+
|
|
55
|
+
// later / elsewhere: resolve — or reconcile when the asset changed
|
|
56
|
+
const hit = resolveInGraph(address, sceneRoot);
|
|
57
|
+
const candidates = hit ? [] : reconcileInGraph(address, sceneRoot);
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Out of scope by design: GLB container parsing (this package takes parsed JSON), file
|
|
61
|
+
I/O, and any composition/override semantics.
|
|
62
|
+
|
|
63
|
+
## Development
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
npm install
|
|
67
|
+
npm test # vitest — includes golden-fixture id tests
|
|
68
|
+
npm run build # tsc → dist/
|
|
69
|
+
```
|
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/anchor.ts
CHANGED
|
@@ -37,9 +37,7 @@ export function parseAnchor(rawName: string | null | undefined): ParsedAnchor |
|
|
|
37
37
|
const lower = trimmed.toLowerCase();
|
|
38
38
|
for (const marker of byLengthDescending(ANCHOR_MARKERS)) {
|
|
39
39
|
if (!lower.endsWith(marker)) continue;
|
|
40
|
-
const value =
|
|
41
|
-
? legacyNeedleAnchorValue(rawName, trimmed)
|
|
42
|
-
: trimmed.substring(0, trimmed.length - marker.length).trim();
|
|
40
|
+
const value = trimmed.substring(0, trimmed.length - marker.length).trim();
|
|
43
41
|
return value.length > 0 ? { marker, value } : null;
|
|
44
42
|
}
|
|
45
43
|
return null;
|
|
@@ -53,19 +51,6 @@ export function parseAnchorValue(rawName: string | null | undefined): string | n
|
|
|
53
51
|
return parseAnchor(rawName)?.value ?? null;
|
|
54
52
|
}
|
|
55
53
|
|
|
56
|
-
/**
|
|
57
|
-
* `#needle` value extraction, bug-compatible with needle-cloud's `ensureUUID`
|
|
58
|
-
* (`gltf.edit.ts`), which computes the cut index from the TRIMMED name but applies it to
|
|
59
|
-
* the RAW name: a name with leading whitespace yields a shifted (shorter) anchor value
|
|
60
|
-
* (`" Trim#NEEDLE"` → `"Tr"`). Anchored ids are the ids that must survive asset
|
|
61
|
-
* updates, so this quirk is frozen into the contract for THIS marker — fixing it would
|
|
62
|
-
* change every anchored id ever minted for such names. Future markers use the clean
|
|
63
|
-
* trimmed parsing in {@link parseAnchor} instead.
|
|
64
|
-
*/
|
|
65
|
-
function legacyNeedleAnchorValue(rawName: string, trimmed: string): string {
|
|
66
|
-
return rawName.substring(0, trimmed.length - ANCHOR_MARKER.length).trim();
|
|
67
|
-
}
|
|
68
|
-
|
|
69
54
|
function byLengthDescending(markers: readonly string[]): readonly string[] {
|
|
70
55
|
return markers.length > 1 ? [...markers].sort((a, b) => b.length - a.length) : markers;
|
|
71
56
|
}
|
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
|
+
}
|