@needle-tools/addressing 0.3.4 → 0.4.0-1791982227.dramatic-tempura.8b3a85e
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 +151 -70
- package/dist/address.d.ts +5 -0
- package/dist/address.js +25 -3
- package/dist/document/assets.d.ts +27 -0
- package/dist/document/assets.js +75 -0
- package/dist/document/collectionSelector.d.ts +9 -0
- package/dist/document/collectionSelector.js +29 -0
- package/dist/document/evaluate.d.ts +86 -0
- package/dist/document/evaluate.js +1082 -0
- package/dist/document/index.d.ts +11 -0
- package/dist/document/index.js +11 -0
- package/dist/document/inspection.d.ts +30 -0
- package/dist/document/inspection.js +130 -0
- package/dist/document/loadedDocument.d.ts +18 -0
- package/dist/document/loadedDocument.js +35 -0
- package/dist/document/references.d.ts +7 -0
- package/dist/document/references.js +36 -0
- package/dist/document/runtime.d.ts +119 -0
- package/dist/document/runtime.js +1 -0
- package/dist/document/types.d.ts +240 -0
- package/dist/document/types.js +43 -0
- package/dist/document/validation.d.ts +36 -0
- package/dist/document/validation.js +222 -0
- package/dist/document/valueCodecs.d.ts +20 -0
- package/dist/document/valueCodecs.js +3 -0
- package/dist/document/write.d.ts +31 -0
- package/dist/document/write.js +131 -0
- package/dist/id.d.ts +2 -0
- package/dist/id.js +4 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/loadedGltf.d.ts +32 -0
- package/dist/loadedGltf.js +62 -0
- package/dist/resolve.js +8 -6
- package/package.json +10 -2
- package/src/address.ts +169 -144
- package/src/anchor.ts +71 -71
- package/src/document/assets.ts +77 -0
- package/src/document/collectionSelector.ts +37 -0
- package/src/document/evaluate.ts +1111 -0
- package/src/document/index.ts +11 -0
- package/src/document/inspection.ts +123 -0
- package/src/document/loadedDocument.ts +48 -0
- package/src/document/references.ts +41 -0
- package/src/document/runtime.ts +113 -0
- package/src/document/schema.json +293 -0
- package/src/document/types.ts +265 -0
- package/src/document/validation.ts +226 -0
- package/src/document/valueCodecs.ts +23 -0
- package/src/document/write.ts +132 -0
- package/src/gltf.ts +33 -33
- package/src/hash.ts +58 -58
- package/src/id.ts +206 -201
- package/src/index.ts +49 -46
- package/src/loadedGltf.ts +93 -0
- package/src/resolve.ts +319 -317
- package/src/target.ts +126 -126
- package/src/uuid.ts +48 -48
package/dist/resolve.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { parseAnchorValue } from "./anchor.js";
|
|
2
|
-
import { collectionKeyForType,
|
|
2
|
+
import { collectionKeyForType, splitNodePath } from "./address.js";
|
|
3
3
|
import { NEEDLE_ID_KEY } from "./id.js";
|
|
4
4
|
import { STAMPED_COLLECTIONS } from "./gltf.js";
|
|
5
5
|
/**
|
|
@@ -51,7 +51,7 @@ export function resolveInGltf(address, json, options) {
|
|
|
51
51
|
if (byId)
|
|
52
52
|
return byId;
|
|
53
53
|
if (address.path !== undefined && (!address.type || address.type === "node")) {
|
|
54
|
-
const index = walkGltfNamePath(json,
|
|
54
|
+
const index = walkGltfNamePath(json, splitNodePath(address.path));
|
|
55
55
|
if (index !== null) {
|
|
56
56
|
const nodes = json["nodes"];
|
|
57
57
|
return { type: "node", index, element: nodes[index] };
|
|
@@ -94,8 +94,9 @@ export function resolveInGraph(address, root, options) {
|
|
|
94
94
|
return byId;
|
|
95
95
|
if (address.path !== undefined) {
|
|
96
96
|
let current = root;
|
|
97
|
-
for (const segment of
|
|
98
|
-
|
|
97
|
+
for (const segment of splitNodePath(address.path)) {
|
|
98
|
+
const matches = current.children?.filter(c => (c.name ?? "") === segment.name) ?? [];
|
|
99
|
+
current = segment.occurrence !== undefined ? matches[segment.occurrence] ?? null : matches.length === 1 ? matches[0] : null;
|
|
99
100
|
if (!current)
|
|
100
101
|
break;
|
|
101
102
|
}
|
|
@@ -149,7 +150,7 @@ export function resolveInCollection(address, elements, options) {
|
|
|
149
150
|
* path still matches. Callers decide thresholds and whether to re-anchor.
|
|
150
151
|
*/
|
|
151
152
|
export function reconcileInGraph(address, root) {
|
|
152
|
-
const addressSegments = address.path !== undefined ?
|
|
153
|
+
const addressSegments = address.path !== undefined ? splitNodePath(address.path).map(segment => segment.name) : null;
|
|
153
154
|
if (!address.name && !addressSegments?.length)
|
|
154
155
|
return [];
|
|
155
156
|
const targetName = address.name ?? addressSegments[addressSegments.length - 1];
|
|
@@ -208,7 +209,8 @@ function walkGltfNamePath(json, segments) {
|
|
|
208
209
|
let candidates = scenes.flatMap(s => (Array.isArray(s?.nodes) ? s.nodes : []));
|
|
209
210
|
let match = null;
|
|
210
211
|
for (const segment of segments) {
|
|
211
|
-
|
|
212
|
+
const matches = [...new Set(candidates)].filter(i => (nodes[i]?.name ?? "") === segment.name);
|
|
213
|
+
match = segment.occurrence !== undefined ? matches[segment.occurrence] ?? null : matches.length === 1 ? matches[0] : null;
|
|
212
214
|
if (match === null)
|
|
213
215
|
return null;
|
|
214
216
|
const children = nodes[match]?.children;
|
package/package.json
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@needle-tools/addressing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0-1791982227.dramatic-tempura.8b3a85e",
|
|
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",
|
|
7
7
|
"types": "./src/index.ts",
|
|
8
8
|
"exports": {
|
|
9
|
+
"./document/schema.json": "./src/document/schema.json",
|
|
10
|
+
"./document": {
|
|
11
|
+
"types": "./src/document/index.ts",
|
|
12
|
+
"node": "./dist/document/index.js",
|
|
13
|
+
"default": "./src/document/index.ts"
|
|
14
|
+
},
|
|
9
15
|
".": {
|
|
10
16
|
"types": "./src/index.ts",
|
|
11
17
|
"node": "./dist/index.js",
|
|
@@ -28,6 +34,8 @@
|
|
|
28
34
|
"url": "https://needle.tools"
|
|
29
35
|
},
|
|
30
36
|
"devDependencies": {
|
|
37
|
+
"three": "^0.185.0",
|
|
38
|
+
"@types/three": "^0.185.0",
|
|
31
39
|
"typescript": "^5.6.0",
|
|
32
40
|
"vitest": "^3.0.0"
|
|
33
41
|
},
|
|
@@ -39,4 +47,4 @@
|
|
|
39
47
|
"type": "git",
|
|
40
48
|
"url": "https://github.com/needle-tools/addressing.git"
|
|
41
49
|
}
|
|
42
|
-
}
|
|
50
|
+
}
|
package/src/address.ts
CHANGED
|
@@ -1,144 +1,169 @@
|
|
|
1
|
-
import { parseAnchorValue } from "./anchor.js";
|
|
2
|
-
import { getElementId, NEEDLE_ID_KEY } from "./id.js";
|
|
3
|
-
import { type GltfJson, type StampedElementType } from "./gltf.js";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* A persistent pointer to an element inside an asset — the "fat address": every
|
|
7
|
-
* fallback the resolver may need is captured at CREATION time, while the target is
|
|
8
|
-
* known, instead of being reconstructed later.
|
|
9
|
-
*
|
|
10
|
-
* Resolution order: `id` (exact) → `anchor` (survives asset updates) → `path`
|
|
11
|
-
* (best-effort) → reconciliation (candidates + confidence, never a silent guess).
|
|
12
|
-
*/
|
|
13
|
-
export type NeedleAddress = {
|
|
14
|
-
/** the element's needle-id (deterministic for file-backed content, random for scratch) */
|
|
15
|
-
id?: string;
|
|
16
|
-
/** `#needle` anchor value, when the target's name carries one */
|
|
17
|
-
anchor?: string;
|
|
18
|
-
/** element type (`node`, `material`, …) — narrows resolution and validates matches */
|
|
19
|
-
type?: StampedElementType | (string & {});
|
|
20
|
-
/** the target's name at address-creation time */
|
|
21
|
-
name?: string;
|
|
22
|
-
/**
|
|
23
|
-
* Node names from the asset root (exclusive) down to the target (inclusive);
|
|
24
|
-
* nodes only. ONE path syntax everywhere (2026-08-03): `/`-separated segments
|
|
25
|
-
* with JSON-Pointer escaping (`~0` = `~`, `~1` = `/`) — see {@link joinPath} /
|
|
26
|
-
* {@link splitPath}. Empty SEGMENTS are load-bearing (positional walk over
|
|
27
|
-
* unnamed nodes), so `""` is a valid one-segment path — test `!== undefined`,
|
|
28
|
-
* never truthiness. ABSENT means "no path rung".
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
if (
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const
|
|
136
|
-
if (!
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
1
|
+
import { parseAnchorValue } from "./anchor.js";
|
|
2
|
+
import { getElementId, NEEDLE_ID_KEY } from "./id.js";
|
|
3
|
+
import { type GltfJson, type StampedElementType } from "./gltf.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A persistent pointer to an element inside an asset — the "fat address": every
|
|
7
|
+
* fallback the resolver may need is captured at CREATION time, while the target is
|
|
8
|
+
* known, instead of being reconstructed later.
|
|
9
|
+
*
|
|
10
|
+
* Resolution order: `id` (exact) → `anchor` (survives asset updates) → `path`
|
|
11
|
+
* (best-effort) → reconciliation (candidates + confidence, never a silent guess).
|
|
12
|
+
*/
|
|
13
|
+
export type NeedleAddress = {
|
|
14
|
+
/** the element's needle-id (deterministic for file-backed content, random for scratch) */
|
|
15
|
+
id?: string;
|
|
16
|
+
/** `#needle` anchor value, when the target's name carries one */
|
|
17
|
+
anchor?: string;
|
|
18
|
+
/** element type (`node`, `material`, …) — narrows resolution and validates matches */
|
|
19
|
+
type?: StampedElementType | (string & {});
|
|
20
|
+
/** the target's name at address-creation time */
|
|
21
|
+
name?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Node names from the asset root (exclusive) down to the target (inclusive);
|
|
24
|
+
* nodes only. ONE path syntax everywhere (2026-08-03): `/`-separated segments
|
|
25
|
+
* with JSON-Pointer escaping (`~0` = `~`, `~1` = `/`) — see {@link joinPath} /
|
|
26
|
+
* {@link splitPath}. Empty SEGMENTS are load-bearing (positional walk over
|
|
27
|
+
* unnamed nodes), so `""` is a valid one-segment path — test `!== undefined`,
|
|
28
|
+
* never truthiness. ABSENT means "no path rung".
|
|
29
|
+
* A `~2n` suffix selects the zero-based occurrence among same-named siblings.
|
|
30
|
+
*/
|
|
31
|
+
path?: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// #region path segment syntax
|
|
35
|
+
// The one path syntax of the project format (target.path uses the same rules):
|
|
36
|
+
// "/" separates segments; a literal "~" in a name is written "~0", a literal "/"
|
|
37
|
+
// is written "~1" (JSON-Pointer, RFC 6901). Escape order matters on decode.
|
|
38
|
+
|
|
39
|
+
/** escape one segment for embedding in a path string */
|
|
40
|
+
export function encodePathSegment(name: string): string {
|
|
41
|
+
return name.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** decode one path segment back to the literal name */
|
|
45
|
+
export function decodePathSegment(segment: string): string {
|
|
46
|
+
return segment.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** join literal segment names into an escaped path string */
|
|
50
|
+
export function joinPath(segments: readonly string[]): string {
|
|
51
|
+
return segments.map(encodePathSegment).join("/");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** split an escaped path string into literal segment names ("" → one empty segment) */
|
|
55
|
+
export function splitPath(path: string): string[] {
|
|
56
|
+
return path.split("/").map(decodePathSegment);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Structural (duck-typed) view of a live scene-graph node — works with any three.js. */
|
|
60
|
+
export type GraphNode = {
|
|
61
|
+
name?: string;
|
|
62
|
+
children?: readonly GraphNode[];
|
|
63
|
+
userData?: Record<string, unknown>;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Build an address for a glTF root-collection element. `path` only applies to nodes. */
|
|
67
|
+
export function createGltfElementAddress(json: GltfJson, type: StampedElementType | string, index: number): NeedleAddress | null {
|
|
68
|
+
const collection = json[collectionKeyForType(type)];
|
|
69
|
+
if (!Array.isArray(collection)) return null;
|
|
70
|
+
const element = collection[index];
|
|
71
|
+
if (element == null || typeof element !== "object") return null;
|
|
72
|
+
|
|
73
|
+
const name = typeof element.name === "string" ? element.name : undefined;
|
|
74
|
+
const address: NeedleAddress = { type };
|
|
75
|
+
const id = getElementId(element);
|
|
76
|
+
if (id) address.id = id;
|
|
77
|
+
const anchor = parseAnchorValue(name);
|
|
78
|
+
if (anchor) address.anchor = anchor;
|
|
79
|
+
// an empty name is non-information: resolution never reads `name` (id → anchor →
|
|
80
|
+
// path) and reconciliation falls back to the last path segment ("" again).
|
|
81
|
+
// NOTE: empty path SEGMENTS are load-bearing (positional child-walk) — only the
|
|
82
|
+
// standalone `name` field is dropped when empty.
|
|
83
|
+
if (name) address.name = name;
|
|
84
|
+
if (type === "node") {
|
|
85
|
+
const segments = gltfNodeNamePath(json, index);
|
|
86
|
+
if (segments) address.path = segments.join("/");
|
|
87
|
+
}
|
|
88
|
+
return address;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Build an address for a live scene-graph object under `root`. */
|
|
92
|
+
export function createObjectAddress(root: GraphNode, target: GraphNode): NeedleAddress | null {
|
|
93
|
+
const nodes = findPath(root, target);
|
|
94
|
+
if (!nodes || nodes.length === 0) return null; // the root itself has no path — address it by ref alone
|
|
95
|
+
|
|
96
|
+
const address: NeedleAddress = { type: "node" };
|
|
97
|
+
const id = target.userData?.[NEEDLE_ID_KEY];
|
|
98
|
+
if (typeof id === "string") address.id = id;
|
|
99
|
+
const anchor = parseAnchorValue(target.name);
|
|
100
|
+
if (anchor) address.anchor = anchor;
|
|
101
|
+
// empty name dropped — see createGltfElementAddress; path segments stay verbatim
|
|
102
|
+
if (target.name) address.name = target.name;
|
|
103
|
+
let parent = root;
|
|
104
|
+
address.path = nodes.map(node => {
|
|
105
|
+
const siblings = parent.children ?? [];
|
|
106
|
+
parent = node;
|
|
107
|
+
return nodePathSegment(node.name ?? "", siblings, node);
|
|
108
|
+
}).join("/");
|
|
109
|
+
return address;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** `~2n` selects a same-named sibling; literal tildes were already escaped as `~0`. */
|
|
113
|
+
function nodePathSegment(name: string, siblings: readonly GraphNode[], node: GraphNode): string {
|
|
114
|
+
const matches = siblings.filter(sibling => (sibling.name ?? "") === name);
|
|
115
|
+
const escaped = encodePathSegment(name);
|
|
116
|
+
return matches.length > 1 ? `${escaped}~2${matches.indexOf(node)}` : escaped;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function splitNodePath(path: string): Array<{ name: string; occurrence?: number }> {
|
|
120
|
+
return path.split("/").map(segment => {
|
|
121
|
+
const qualifier = /~2(0|[1-9][0-9]*)$/.exec(segment);
|
|
122
|
+
return qualifier
|
|
123
|
+
? { name: decodePathSegment(segment.slice(0, qualifier.index)), occurrence: Number(qualifier[1]) }
|
|
124
|
+
: { name: decodePathSegment(segment) };
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function collectionKeyForType(type: string): string {
|
|
129
|
+
// glTF collection keys are the pluralized type names; only "mesh" is irregular
|
|
130
|
+
return type === "mesh" ? "meshes" : `${type}s`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** names from root (exclusive) to the node at `nodeIndex` (inclusive), via the scene tree */
|
|
134
|
+
function gltfNodeNamePath(json: GltfJson, nodeIndex: number): string[] | null {
|
|
135
|
+
const nodes = json["nodes"];
|
|
136
|
+
if (!Array.isArray(nodes)) return null;
|
|
137
|
+
const parents = new Map<number, number>();
|
|
138
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
139
|
+
const children = nodes[i]?.children;
|
|
140
|
+
if (Array.isArray(children)) for (const c of children) parents.set(c, i);
|
|
141
|
+
}
|
|
142
|
+
const path: string[] = [];
|
|
143
|
+
let current: number | undefined = nodeIndex;
|
|
144
|
+
const seen = new Set<number>();
|
|
145
|
+
while (current !== undefined) {
|
|
146
|
+
if (seen.has(current)) return null; // malformed (cyclic) input
|
|
147
|
+
seen.add(current);
|
|
148
|
+
const parent = parents.get(current);
|
|
149
|
+
const siblingIndices: number[] = parent !== undefined ? nodes[parent]?.children ?? []
|
|
150
|
+
: [...new Set((Array.isArray(json.scenes) ? json.scenes : []).flatMap(scene => scene.nodes ?? []))];
|
|
151
|
+
path.unshift(nodePathSegment(typeof nodes[current]?.name === "string" ? nodes[current].name : "", siblingIndices.map(i => nodes[i]), nodes[current]));
|
|
152
|
+
current = parents.get(current);
|
|
153
|
+
}
|
|
154
|
+
return path;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** path from root (exclusive) to target (inclusive), or null if target is not under root */
|
|
158
|
+
export function findPath(root: GraphNode, target: GraphNode): GraphNode[] | null {
|
|
159
|
+
if (root === target) return [];
|
|
160
|
+
const children = root.children;
|
|
161
|
+
if (!children) return null;
|
|
162
|
+
for (let i = 0; i < children.length; i++) {
|
|
163
|
+
const child = children[i];
|
|
164
|
+
if (child == null) continue;
|
|
165
|
+
const sub = findPath(child, target);
|
|
166
|
+
if (sub) return [child, ...sub];
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
}
|
package/src/anchor.ts
CHANGED
|
@@ -1,71 +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 = 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
|
-
}
|
|
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
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { ProjectDocument, ProjectDocumentAsset, ProjectDocumentReference } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/** Asset keys and shared resource identities for one document assembly. */
|
|
4
|
+
export class DocumentAssetAssembly {
|
|
5
|
+
private readonly assetBySrc = new Map<string, string>();
|
|
6
|
+
private readonly referenceById = new Map<string, ProjectDocumentReference>();
|
|
7
|
+
private readonly materialBySource = new Map<string, ProjectDocumentReference>();
|
|
8
|
+
private readonly baseMaterialBySource = new Map<string, ProjectDocumentReference>();
|
|
9
|
+
|
|
10
|
+
constructor(
|
|
11
|
+
private readonly assets: Record<string, ProjectDocumentAsset>,
|
|
12
|
+
private readonly references: ProjectDocumentReference[],
|
|
13
|
+
base?: ProjectDocument | null,
|
|
14
|
+
) {
|
|
15
|
+
for (const [key, asset] of Object.entries(assets)) this.assetBySrc.set(asset.src, key);
|
|
16
|
+
for (const reference of references) {
|
|
17
|
+
this.referenceById.set(reference.id, reference);
|
|
18
|
+
const key = this.materialKey(reference, assets);
|
|
19
|
+
if (key) this.materialBySource.set(key, reference);
|
|
20
|
+
}
|
|
21
|
+
for (const reference of base?.references ?? []) {
|
|
22
|
+
const key = this.materialKey(reference, base?.assets ?? {});
|
|
23
|
+
if (key) this.baseMaterialBySource.set(key, reference);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
addAsset(input: { src: string; name?: string; kind: string; identity?: { id: string; provider?: string } }): string {
|
|
28
|
+
const existing = this.assetBySrc.get(input.src);
|
|
29
|
+
if (existing !== undefined) return existing;
|
|
30
|
+
const filename = input.src.split("?")[0].split("/").pop() || "material";
|
|
31
|
+
const dot = filename.lastIndexOf(".");
|
|
32
|
+
const stem = input.name || (dot > 0 ? filename.substring(0, dot) : filename);
|
|
33
|
+
let key = stem;
|
|
34
|
+
let n = 2;
|
|
35
|
+
while (key in this.assets) key = `${stem}-${n++}`;
|
|
36
|
+
this.assets[key] = { src: input.src, kind: input.kind, ...(input.identity ? { asset: { ...input.identity } } : {}) };
|
|
37
|
+
this.assetBySrc.set(input.src, key);
|
|
38
|
+
return key;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
materialEntity(asset: string, material: { id: string; name?: string }, entityId: string): string {
|
|
42
|
+
if (!this.assets[asset]) throw new Error(`Unknown document asset: ${asset}`);
|
|
43
|
+
const key = JSON.stringify([this.assets[asset].src, material.id]);
|
|
44
|
+
const existing = this.materialBySource.get(key);
|
|
45
|
+
if (existing) return existing.id;
|
|
46
|
+
const base = this.baseMaterialBySource.get(key);
|
|
47
|
+
const reference: ProjectDocumentReference = base ? { ...base, asset } : {
|
|
48
|
+
id: entityId, asset, address: { type: "material", id: material.id, ...(material.name ? { name: material.name } : {}) },
|
|
49
|
+
};
|
|
50
|
+
this.addReference(reference);
|
|
51
|
+
this.materialBySource.set(key, reference);
|
|
52
|
+
return reference.id;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
environmentEntity(asset: string, entityId: string): string {
|
|
56
|
+
if (!this.assets[asset]) throw new Error(`Unknown document asset: ${asset}`);
|
|
57
|
+
this.addReference({ id: entityId, asset, address: { type: "hdr" } });
|
|
58
|
+
return entityId;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private addReference(reference: ProjectDocumentReference): void {
|
|
62
|
+
const existing = this.referenceById.get(reference.id);
|
|
63
|
+
if (existing) {
|
|
64
|
+
if (existing.asset !== reference.asset || existing.address?.type !== reference.address?.type || existing.address?.id !== reference.address?.id) {
|
|
65
|
+
throw new Error(`Document reference id '${reference.id}' already identifies another resource`);
|
|
66
|
+
}
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
this.references.push(reference);
|
|
70
|
+
this.referenceById.set(reference.id, reference);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private materialKey(reference: ProjectDocumentReference, assets: Record<string, ProjectDocumentAsset>): string | null {
|
|
74
|
+
return reference.address?.type === "material" && reference.address.id && assets[reference.asset]
|
|
75
|
+
? JSON.stringify([assets[reference.asset].src, reference.address.id]) : null;
|
|
76
|
+
}
|
|
77
|
+
}
|