@needle-tools/addressing 0.3.2 → 0.3.3
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 +70 -70
- package/dist/address.js +7 -2
- package/dist/anchor.d.ts +12 -0
- package/dist/anchor.js +11 -8
- package/dist/gltf.js +6 -4
- package/dist/id.d.ts +30 -11
- package/dist/id.js +39 -22
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/resolve.d.ts +62 -6
- package/dist/resolve.js +141 -21
- package/package.json +1 -1
- package/src/address.ts +111 -106
- package/src/anchor.ts +71 -68
- package/src/gltf.ts +91 -89
- package/src/hash.ts +58 -58
- package/src/id.ts +61 -45
- package/src/index.ts +34 -29
- package/src/resolve.ts +315 -172
- package/src/uuid.ts +48 -48
package/dist/resolve.js
CHANGED
|
@@ -2,28 +2,53 @@ import { parseAnchorValue } from "./anchor.js";
|
|
|
2
2
|
import { collectionKeyForType } from "./address.js";
|
|
3
3
|
import { NEEDLE_ID_KEY, STAMPED_COLLECTIONS } from "./gltf.js";
|
|
4
4
|
/**
|
|
5
|
-
* Resolve an address inside glTF JSON.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Resolve an address inside glTF JSON. Same ladder as the live graph —
|
|
6
|
+
* **anchor → id → namePath** (nodes) — with the anchor winning only when it matches
|
|
7
|
+
* exactly one element. Returns null when nothing matches exactly (reconciliation is a
|
|
8
|
+
* separate, explicit step — resolution never guesses).
|
|
8
9
|
*/
|
|
9
|
-
export function resolveInGltf(address, json) {
|
|
10
|
+
export function resolveInGltf(address, json, options) {
|
|
11
|
+
const report = options?.report;
|
|
10
12
|
const searched = address.type
|
|
11
13
|
? [[address.type, collectionKeyForType(address.type)]]
|
|
12
14
|
: [...STAMPED_COLLECTIONS];
|
|
15
|
+
let byAnchor = null;
|
|
16
|
+
if (address.anchor) {
|
|
17
|
+
const matches = [];
|
|
18
|
+
for (const [type, key] of searched) {
|
|
19
|
+
for (const found of collectInJsonCollection(json, key, el => parseAnchorValue(el.name) === address.anchor)) {
|
|
20
|
+
matches.push({ type, index: found.index, element: found.element });
|
|
21
|
+
if (matches.length > 1 && !report)
|
|
22
|
+
break;
|
|
23
|
+
}
|
|
24
|
+
if (matches.length > 1 && !report)
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
if (matches.length === 1)
|
|
28
|
+
byAnchor = matches[0];
|
|
29
|
+
else if (matches.length > 1)
|
|
30
|
+
report?.({ kind: "ambiguous-anchor", anchor: address.anchor, count: matches.length });
|
|
31
|
+
}
|
|
32
|
+
if (byAnchor && !report)
|
|
33
|
+
return byAnchor;
|
|
34
|
+
let byId = null;
|
|
13
35
|
if (address.id) {
|
|
14
36
|
for (const [type, key] of searched) {
|
|
15
37
|
const found = findInCollection(json, key, el => el.extras?.[NEEDLE_ID_KEY] === address.id);
|
|
16
|
-
if (found)
|
|
17
|
-
|
|
38
|
+
if (found) {
|
|
39
|
+
byId = { type, index: found.index, element: found.element };
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
18
42
|
}
|
|
19
43
|
}
|
|
20
|
-
if (
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
if (found)
|
|
24
|
-
return { type, index: found.index, element: found.element };
|
|
44
|
+
if (byAnchor) {
|
|
45
|
+
if (byId && (byId.type !== byAnchor.type || byId.index !== byAnchor.index)) {
|
|
46
|
+
report?.({ kind: "conflict", preferred: "anchor", other: "id" });
|
|
25
47
|
}
|
|
48
|
+
return byAnchor;
|
|
26
49
|
}
|
|
50
|
+
if (byId)
|
|
51
|
+
return byId;
|
|
27
52
|
if (address.namePath?.length && (!address.type || address.type === "node")) {
|
|
28
53
|
const index = walkGltfNamePath(json, address.namePath);
|
|
29
54
|
if (index !== null) {
|
|
@@ -35,19 +60,37 @@ export function resolveInGltf(address, json) {
|
|
|
35
60
|
}
|
|
36
61
|
/**
|
|
37
62
|
* Resolve an address inside a live scene graph (duck-typed — any three.js).
|
|
38
|
-
*
|
|
63
|
+
*
|
|
64
|
+
* Ladder: **anchor → id → namePath**. The anchor comes first because it is a deliberate,
|
|
65
|
+
* human-authored declaration, while an id may be a derived value whose basis shifted when
|
|
66
|
+
* the content changed. It only wins when it resolves UNIQUELY: anchors are labels and may
|
|
67
|
+
* legally repeat, and an ambiguous label must never outrank an exact key.
|
|
39
68
|
*/
|
|
40
|
-
export function resolveInGraph(address, root) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if (byId)
|
|
44
|
-
return byId;
|
|
45
|
-
}
|
|
69
|
+
export function resolveInGraph(address, root, options) {
|
|
70
|
+
const report = options?.report;
|
|
71
|
+
let byAnchor = null;
|
|
46
72
|
if (address.anchor) {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
73
|
+
// without a reporter, stop at two — "exactly one" vs "more than one" is all the
|
|
74
|
+
// ladder needs, and the count only matters for the message
|
|
75
|
+
const matches = collectInGraph(root, n => parseAnchorValue(n.name) === address.anchor, report ? Infinity : 2);
|
|
76
|
+
if (matches.length === 1)
|
|
77
|
+
byAnchor = matches[0];
|
|
78
|
+
else if (matches.length > 1)
|
|
79
|
+
report?.({ kind: "ambiguous-anchor", anchor: address.anchor, count: matches.length });
|
|
50
80
|
}
|
|
81
|
+
// fast path: nothing left to learn unless the caller wants the disagreement reported
|
|
82
|
+
if (byAnchor && !report)
|
|
83
|
+
return byAnchor;
|
|
84
|
+
let byId = null;
|
|
85
|
+
if (address.id)
|
|
86
|
+
byId = findInGraph(root, n => n.userData?.[NEEDLE_ID_KEY] === address.id);
|
|
87
|
+
if (byAnchor) {
|
|
88
|
+
if (byId && byId !== byAnchor)
|
|
89
|
+
report?.({ kind: "conflict", preferred: "anchor", other: "id" });
|
|
90
|
+
return byAnchor;
|
|
91
|
+
}
|
|
92
|
+
if (byId)
|
|
93
|
+
return byId;
|
|
51
94
|
if (address.namePath?.length) {
|
|
52
95
|
let current = root;
|
|
53
96
|
for (const segment of address.namePath) {
|
|
@@ -60,6 +103,45 @@ export function resolveInGraph(address, root) {
|
|
|
60
103
|
}
|
|
61
104
|
return null;
|
|
62
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* Resolve an address against a FLAT collection of live elements — materials, textures and
|
|
108
|
+
* anything else that carries a name and `userData` but is not part of the scene graph.
|
|
109
|
+
*
|
|
110
|
+
* Same ladder as the graph, minus `namePath` (there is no hierarchy to walk) plus `name`
|
|
111
|
+
* as the final rung, since a flat collection has no positional fallback. This exists so
|
|
112
|
+
* every element type resolves through ONE implementation: materials previously had their
|
|
113
|
+
* own ladders, could not be anchored at all, and disagreed with each other about whether a
|
|
114
|
+
* name match had to be unique.
|
|
115
|
+
*/
|
|
116
|
+
export function resolveInCollection(address, elements, options) {
|
|
117
|
+
const report = options?.report;
|
|
118
|
+
let byAnchor = null;
|
|
119
|
+
if (address.anchor) {
|
|
120
|
+
const matches = elements.filter(e => parseAnchorValue(e.name) === address.anchor);
|
|
121
|
+
if (matches.length === 1)
|
|
122
|
+
byAnchor = matches[0];
|
|
123
|
+
else if (matches.length > 1)
|
|
124
|
+
report?.({ kind: "ambiguous-anchor", anchor: address.anchor, count: matches.length });
|
|
125
|
+
}
|
|
126
|
+
if (byAnchor && !report)
|
|
127
|
+
return byAnchor;
|
|
128
|
+
const byId = address.id ? elements.find(e => e.userData?.[NEEDLE_ID_KEY] === address.id) ?? null : null;
|
|
129
|
+
if (byAnchor) {
|
|
130
|
+
if (byId && byId !== byAnchor)
|
|
131
|
+
report?.({ kind: "conflict", preferred: "anchor", other: "id" });
|
|
132
|
+
return byAnchor;
|
|
133
|
+
}
|
|
134
|
+
if (byId)
|
|
135
|
+
return byId;
|
|
136
|
+
if (address.name) {
|
|
137
|
+
const named = elements.filter(e => e.name === address.name);
|
|
138
|
+
if (named.length === 1)
|
|
139
|
+
return named[0];
|
|
140
|
+
if (named.length > 1)
|
|
141
|
+
report?.({ kind: "ambiguous-name", name: address.name, count: named.length });
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
63
145
|
/**
|
|
64
146
|
* When exact resolution fails (the asset changed), propose candidates instead of
|
|
65
147
|
* guessing: nodes matching the address's name, ranked by how much of the recorded
|
|
@@ -89,6 +171,20 @@ export function reconcileInGraph(address, root) {
|
|
|
89
171
|
}
|
|
90
172
|
return candidates.sort((a, b) => b.confidence - a.confidence);
|
|
91
173
|
}
|
|
174
|
+
/** every element of one glTF collection matching `predicate` */
|
|
175
|
+
function collectInJsonCollection(json, key, predicate) {
|
|
176
|
+
const collection = json[key];
|
|
177
|
+
if (!Array.isArray(collection))
|
|
178
|
+
return [];
|
|
179
|
+
const found = [];
|
|
180
|
+
for (let i = 0; i < collection.length; i++) {
|
|
181
|
+
const element = collection[i];
|
|
182
|
+
if (element != null && typeof element === "object" && predicate(element)) {
|
|
183
|
+
found.push({ index: i, element });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return found;
|
|
187
|
+
}
|
|
92
188
|
function findInCollection(json, key, predicate) {
|
|
93
189
|
const collection = json[key];
|
|
94
190
|
if (!Array.isArray(collection))
|
|
@@ -118,6 +214,30 @@ function walkGltfNamePath(json, namePath) {
|
|
|
118
214
|
}
|
|
119
215
|
return match;
|
|
120
216
|
}
|
|
217
|
+
/** every node matching `predicate`, stopping once `limit` are found (cycle-safe) */
|
|
218
|
+
function collectInGraph(root, predicate, limit) {
|
|
219
|
+
const found = [];
|
|
220
|
+
const visited = new Set();
|
|
221
|
+
const walk = (node) => {
|
|
222
|
+
if (found.length >= limit || visited.has(node))
|
|
223
|
+
return;
|
|
224
|
+
visited.add(node);
|
|
225
|
+
if (predicate(node)) {
|
|
226
|
+
found.push(node);
|
|
227
|
+
if (found.length >= limit)
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (!node.children)
|
|
231
|
+
return;
|
|
232
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
233
|
+
const child = node.children[i];
|
|
234
|
+
if (child != null)
|
|
235
|
+
walk(child);
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
walk(root);
|
|
239
|
+
return found;
|
|
240
|
+
}
|
|
121
241
|
function findInGraph(root, predicate, visited = new Set()) {
|
|
122
242
|
if (visited.has(root))
|
|
123
243
|
return null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@needle-tools/addressing",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "Persistent addressing for Needle assets: deterministic needle-id computation, #needle anchors, and address resolution over glTF JSON and live scene graphs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
package/src/address.ts
CHANGED
|
@@ -1,106 +1,111 @@
|
|
|
1
|
-
import { parseAnchorValue } from "./anchor.js";
|
|
2
|
-
import { getElementId, NEEDLE_ID_KEY, type GltfJson, type 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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
if (
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
return
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
let
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
1
|
+
import { parseAnchorValue } from "./anchor.js";
|
|
2
|
+
import { getElementId, NEEDLE_ID_KEY, type GltfJson, type 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
|
+
// an empty name is non-information: resolution never reads `name` (id → anchor →
|
|
46
|
+
// namePath) and reconciliation falls back to the last namePath segment ("" again).
|
|
47
|
+
// NOTE: empty namePath SEGMENTS are load-bearing (positional child-walk) — only the
|
|
48
|
+
// standalone `name` field is dropped when empty.
|
|
49
|
+
if (name) address.name = name;
|
|
50
|
+
if (type === "node") {
|
|
51
|
+
const path = gltfNodeNamePath(json, index);
|
|
52
|
+
if (path) address.namePath = path;
|
|
53
|
+
}
|
|
54
|
+
return address;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Build an address for a live scene-graph object under `root`. */
|
|
58
|
+
export function createObjectAddress(root: GraphNode, target: GraphNode): NeedleAddress | null {
|
|
59
|
+
const path = findPath(root, target);
|
|
60
|
+
if (!path) return null;
|
|
61
|
+
|
|
62
|
+
const address: NeedleAddress = { type: "node" };
|
|
63
|
+
const id = target.userData?.[NEEDLE_ID_KEY];
|
|
64
|
+
if (typeof id === "string") address.id = id;
|
|
65
|
+
const anchor = parseAnchorValue(target.name);
|
|
66
|
+
if (anchor) address.anchor = anchor;
|
|
67
|
+
// empty name dropped — see createGltfElementAddress; namePath segments stay verbatim
|
|
68
|
+
if (target.name) address.name = target.name;
|
|
69
|
+
address.namePath = path.map(n => n.name ?? "");
|
|
70
|
+
return address;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function collectionKeyForType(type: string): string {
|
|
74
|
+
// glTF collection keys are the pluralized type names; only "mesh" is irregular
|
|
75
|
+
return type === "mesh" ? "meshes" : `${type}s`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** names from root (exclusive) to the node at `nodeIndex` (inclusive), via the scene tree */
|
|
79
|
+
function gltfNodeNamePath(json: GltfJson, nodeIndex: number): string[] | null {
|
|
80
|
+
const nodes = json["nodes"];
|
|
81
|
+
if (!Array.isArray(nodes)) return null;
|
|
82
|
+
const parents = new Map<number, number>();
|
|
83
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
84
|
+
const children = nodes[i]?.children;
|
|
85
|
+
if (Array.isArray(children)) for (const c of children) parents.set(c, i);
|
|
86
|
+
}
|
|
87
|
+
const path: string[] = [];
|
|
88
|
+
let current: number | undefined = nodeIndex;
|
|
89
|
+
const seen = new Set<number>();
|
|
90
|
+
while (current !== undefined) {
|
|
91
|
+
if (seen.has(current)) return null; // malformed (cyclic) input
|
|
92
|
+
seen.add(current);
|
|
93
|
+
path.unshift(typeof nodes[current]?.name === "string" ? nodes[current].name : "");
|
|
94
|
+
current = parents.get(current);
|
|
95
|
+
}
|
|
96
|
+
return path;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** path from root (exclusive) to target (inclusive), or null if target is not under root */
|
|
100
|
+
export function findPath(root: GraphNode, target: GraphNode): GraphNode[] | null {
|
|
101
|
+
if (root === target) return [];
|
|
102
|
+
const children = root.children;
|
|
103
|
+
if (!children) return null;
|
|
104
|
+
for (let i = 0; i < children.length; i++) {
|
|
105
|
+
const child = children[i];
|
|
106
|
+
if (child == null) continue;
|
|
107
|
+
const sub = findPath(child, target);
|
|
108
|
+
if (sub) return [child, ...sub];
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
package/src/anchor.ts
CHANGED
|
@@ -1,68 +1,71 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The name-anchor scheme: an element named `Door #needle` derives its deterministic id
|
|
3
|
-
* from the text before the marker instead of (name, index, file hash) — so the id
|
|
4
|
-
* survives content changes and asset re-uploads as long as the name stays.
|
|
5
|
-
*
|
|
6
|
-
* CONTRACT NOTE — the marker set is part of the id contract. Markers may be ADDED (new
|
|
7
|
-
* names start anchoring), but never removed or re-prioritized, and every WRITER of ids
|
|
8
|
-
* (this package's consumers: cloud upload, editors, CLI) must agree on the set before a
|
|
9
|
-
* new marker ships — a writer that anchors on a marker another writer doesn't know mints
|
|
10
|
-
* DIFFERENT ids for the same file. The anchored id only hashes the VALUE (not the
|
|
11
|
-
* marker), so all markers are aliases into the same id space.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
/** the original marker — kept as a named export for existing consumers */
|
|
15
|
-
export const ANCHOR_MARKER = "#needle";
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* All recognized markers, matched longest-first so overlapping suffixes resolve
|
|
19
|
-
* deterministically. Currently one entry; see the contract note before adding more.
|
|
20
|
-
*/
|
|
21
|
-
export const ANCHOR_MARKERS: readonly string[] = [ANCHOR_MARKER];
|
|
22
|
-
|
|
23
|
-
export type ParsedAnchor = {
|
|
24
|
-
/** the marker that matched (one of {@link ANCHOR_MARKERS}) */
|
|
25
|
-
marker: string;
|
|
26
|
-
/** the anchor value — the deterministic-id input */
|
|
27
|
-
value: string;
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Parse an element name against all recognized markers (case-insensitive suffix match).
|
|
32
|
-
* Returns null when no marker matches or the anchor value is empty.
|
|
33
|
-
*/
|
|
34
|
-
export function parseAnchor(rawName: string | null | undefined): ParsedAnchor | null {
|
|
35
|
-
if (rawName == null) return null;
|
|
36
|
-
const trimmed = rawName.trim();
|
|
37
|
-
const lower = trimmed.toLowerCase();
|
|
38
|
-
for (const marker of byLengthDescending(ANCHOR_MARKERS)) {
|
|
39
|
-
if (!lower.endsWith(marker)) continue;
|
|
40
|
-
const value =
|
|
41
|
-
return value.length > 0 ? { marker, value } : null;
|
|
42
|
-
}
|
|
43
|
-
return null;
|
|
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
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The name-anchor scheme: an element named `Door #needle` derives its deterministic id
|
|
3
|
+
* from the text before the marker instead of (name, index, file hash) — so the id
|
|
4
|
+
* survives content changes and asset re-uploads as long as the name stays.
|
|
5
|
+
*
|
|
6
|
+
* CONTRACT NOTE — the marker set is part of the id contract. Markers may be ADDED (new
|
|
7
|
+
* names start anchoring), but never removed or re-prioritized, and every WRITER of ids
|
|
8
|
+
* (this package's consumers: cloud upload, editors, CLI) must agree on the set before a
|
|
9
|
+
* new marker ships — a writer that anchors on a marker another writer doesn't know mints
|
|
10
|
+
* DIFFERENT ids for the same file. The anchored id only hashes the VALUE (not the
|
|
11
|
+
* marker), so all markers are aliases into the same id space.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** the original marker — kept as a named export for existing consumers */
|
|
15
|
+
export const ANCHOR_MARKER = "#needle";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* All recognized markers, matched longest-first so overlapping suffixes resolve
|
|
19
|
+
* deterministically. Currently one entry; see the contract note before adding more.
|
|
20
|
+
*/
|
|
21
|
+
export const ANCHOR_MARKERS: readonly string[] = [ANCHOR_MARKER];
|
|
22
|
+
|
|
23
|
+
export type ParsedAnchor = {
|
|
24
|
+
/** the marker that matched (one of {@link ANCHOR_MARKERS}) */
|
|
25
|
+
marker: string;
|
|
26
|
+
/** the anchor value — the deterministic-id input */
|
|
27
|
+
value: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Parse an element name against all recognized markers (case-insensitive suffix match).
|
|
32
|
+
* Returns null when no marker matches or the anchor value is empty.
|
|
33
|
+
*/
|
|
34
|
+
export function parseAnchor(rawName: string | null | undefined): ParsedAnchor | null {
|
|
35
|
+
if (rawName == null) return null;
|
|
36
|
+
const trimmed = rawName.trim();
|
|
37
|
+
const lower = trimmed.toLowerCase();
|
|
38
|
+
for (const marker of byLengthDescending(ANCHOR_MARKERS)) {
|
|
39
|
+
if (!lower.endsWith(marker)) continue;
|
|
40
|
+
const value = normalizeName(trimmed.substring(0, trimmed.length - marker.length));
|
|
41
|
+
return value.length > 0 ? { marker, value } : null;
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Names treat SPACES and UNDERSCORES as the same character (runs collapse to one space):
|
|
48
|
+
* three's GLTFLoader sanitizes node names on load (whitespace becomes "_"), so
|
|
49
|
+
* "Door Frame #needle" appears live as "Door_Frame_#needle" — without normalization a
|
|
50
|
+
* stamped anchor and the live anchor would never match. Applied at PARSE time, so id
|
|
51
|
+
* computation and resolution agree from one change point. Accepted trade: names differing
|
|
52
|
+
* only by space-vs-underscore are one name.
|
|
53
|
+
*
|
|
54
|
+
* Exported because id derivation applies the SAME rule to unanchored names (see
|
|
55
|
+
* {@link import("./id.js").idNameOf}) — one normalization, one change point.
|
|
56
|
+
*/
|
|
57
|
+
export function normalizeName(value: string): string {
|
|
58
|
+
return value.replace(/[\s_]+/g, " ").trim();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Extract the anchor value from an element name, or null when the name carries no anchor.
|
|
63
|
+
* Convenience over {@link parseAnchor} for callers that only need the id input.
|
|
64
|
+
*/
|
|
65
|
+
export function parseAnchorValue(rawName: string | null | undefined): string | null {
|
|
66
|
+
return parseAnchor(rawName)?.value ?? null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function byLengthDescending(markers: readonly string[]): readonly string[] {
|
|
70
|
+
return markers.length > 1 ? [...markers].sort((a, b) => b.length - a.length) : markers;
|
|
71
|
+
}
|