@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.
Files changed (58) hide show
  1. package/Readme.md +151 -70
  2. package/dist/address.d.ts +5 -0
  3. package/dist/address.js +25 -3
  4. package/dist/document/assets.d.ts +27 -0
  5. package/dist/document/assets.js +75 -0
  6. package/dist/document/collectionSelector.d.ts +9 -0
  7. package/dist/document/collectionSelector.js +29 -0
  8. package/dist/document/evaluate.d.ts +86 -0
  9. package/dist/document/evaluate.js +1082 -0
  10. package/dist/document/index.d.ts +11 -0
  11. package/dist/document/index.js +11 -0
  12. package/dist/document/inspection.d.ts +30 -0
  13. package/dist/document/inspection.js +130 -0
  14. package/dist/document/loadedDocument.d.ts +18 -0
  15. package/dist/document/loadedDocument.js +35 -0
  16. package/dist/document/references.d.ts +7 -0
  17. package/dist/document/references.js +36 -0
  18. package/dist/document/runtime.d.ts +119 -0
  19. package/dist/document/runtime.js +1 -0
  20. package/dist/document/types.d.ts +240 -0
  21. package/dist/document/types.js +43 -0
  22. package/dist/document/validation.d.ts +36 -0
  23. package/dist/document/validation.js +222 -0
  24. package/dist/document/valueCodecs.d.ts +20 -0
  25. package/dist/document/valueCodecs.js +3 -0
  26. package/dist/document/write.d.ts +31 -0
  27. package/dist/document/write.js +131 -0
  28. package/dist/id.d.ts +2 -0
  29. package/dist/id.js +4 -0
  30. package/dist/index.d.ts +2 -1
  31. package/dist/index.js +2 -1
  32. package/dist/loadedGltf.d.ts +32 -0
  33. package/dist/loadedGltf.js +62 -0
  34. package/dist/resolve.js +8 -6
  35. package/package.json +10 -2
  36. package/src/address.ts +169 -144
  37. package/src/anchor.ts +71 -71
  38. package/src/document/assets.ts +77 -0
  39. package/src/document/collectionSelector.ts +37 -0
  40. package/src/document/evaluate.ts +1111 -0
  41. package/src/document/index.ts +11 -0
  42. package/src/document/inspection.ts +123 -0
  43. package/src/document/loadedDocument.ts +48 -0
  44. package/src/document/references.ts +41 -0
  45. package/src/document/runtime.ts +113 -0
  46. package/src/document/schema.json +293 -0
  47. package/src/document/types.ts +265 -0
  48. package/src/document/validation.ts +226 -0
  49. package/src/document/valueCodecs.ts +23 -0
  50. package/src/document/write.ts +132 -0
  51. package/src/gltf.ts +33 -33
  52. package/src/hash.ts +58 -58
  53. package/src/id.ts +206 -201
  54. package/src/index.ts +49 -46
  55. package/src/loadedGltf.ts +93 -0
  56. package/src/resolve.ts +319 -317
  57. package/src/target.ts +126 -126
  58. 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, splitPath } from "./address.js";
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, splitPath(address.path));
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 splitPath(address.path)) {
98
- current = current.children?.find(c => (c.name ?? "") === segment) ?? null;
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 ? splitPath(address.path) : null;
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
- match = candidates.find(i => ((nodes[i]?.name ?? "") === segment)) ?? null;
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.4",
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
- path?: string;
31
- };
32
-
33
- // #region path segment syntax
34
- // The one path syntax of the project format (target.path uses the same rules):
35
- // "/" separates segments; a literal "~" in a name is written "~0", a literal "/"
36
- // is written "~1" (JSON-Pointer, RFC 6901). Escape order matters on decode.
37
-
38
- /** escape one segment for embedding in a path string */
39
- export function encodePathSegment(name: string): string {
40
- return name.replace(/~/g, "~0").replace(/\//g, "~1");
41
- }
42
-
43
- /** decode one path segment back to the literal name */
44
- export function decodePathSegment(segment: string): string {
45
- return segment.replace(/~1/g, "/").replace(/~0/g, "~");
46
- }
47
-
48
- /** join literal segment names into an escaped path string */
49
- export function joinPath(segments: readonly string[]): string {
50
- return segments.map(encodePathSegment).join("/");
51
- }
52
-
53
- /** split an escaped path string into literal segment names ("" → one empty segment) */
54
- export function splitPath(path: string): string[] {
55
- return path.split("/").map(decodePathSegment);
56
- }
57
-
58
- /** Structural (duck-typed) view of a live scene-graph node — works with any three.js. */
59
- export type GraphNode = {
60
- name?: string;
61
- children?: readonly GraphNode[];
62
- userData?: Record<string, unknown>;
63
- };
64
-
65
- /** Build an address for a glTF root-collection element. `path` only applies to nodes. */
66
- export function createGltfElementAddress(json: GltfJson, type: StampedElementType | string, index: number): NeedleAddress | null {
67
- const collection = json[collectionKeyForType(type)];
68
- if (!Array.isArray(collection)) return null;
69
- const element = collection[index];
70
- if (element == null || typeof element !== "object") return null;
71
-
72
- const name = typeof element.name === "string" ? element.name : undefined;
73
- const address: NeedleAddress = { type };
74
- const id = getElementId(element);
75
- if (id) address.id = id;
76
- const anchor = parseAnchorValue(name);
77
- if (anchor) address.anchor = anchor;
78
- // an empty name is non-information: resolution never reads `name` (id → anchor →
79
- // path) and reconciliation falls back to the last path segment ("" again).
80
- // NOTE: empty path SEGMENTS are load-bearing (positional child-walk) — only the
81
- // standalone `name` field is dropped when empty.
82
- if (name) address.name = name;
83
- if (type === "node") {
84
- const segments = gltfNodeNamePath(json, index);
85
- if (segments) address.path = joinPath(segments);
86
- }
87
- return address;
88
- }
89
-
90
- /** Build an address for a live scene-graph object under `root`. */
91
- export function createObjectAddress(root: GraphNode, target: GraphNode): NeedleAddress | null {
92
- const nodes = findPath(root, target);
93
- if (!nodes || nodes.length === 0) return null; // the root itself has no path — address it by ref alone
94
-
95
- const address: NeedleAddress = { type: "node" };
96
- const id = target.userData?.[NEEDLE_ID_KEY];
97
- if (typeof id === "string") address.id = id;
98
- const anchor = parseAnchorValue(target.name);
99
- if (anchor) address.anchor = anchor;
100
- // empty name dropped — see createGltfElementAddress; path segments stay verbatim
101
- if (target.name) address.name = target.name;
102
- address.path = joinPath(nodes.map(n => n.name ?? ""));
103
- return address;
104
- }
105
-
106
- export function collectionKeyForType(type: string): string {
107
- // glTF collection keys are the pluralized type names; only "mesh" is irregular
108
- return type === "mesh" ? "meshes" : `${type}s`;
109
- }
110
-
111
- /** names from root (exclusive) to the node at `nodeIndex` (inclusive), via the scene tree */
112
- function gltfNodeNamePath(json: GltfJson, nodeIndex: number): string[] | null {
113
- const nodes = json["nodes"];
114
- if (!Array.isArray(nodes)) return null;
115
- const parents = new Map<number, number>();
116
- for (let i = 0; i < nodes.length; i++) {
117
- const children = nodes[i]?.children;
118
- if (Array.isArray(children)) for (const c of children) parents.set(c, i);
119
- }
120
- const path: string[] = [];
121
- let current: number | undefined = nodeIndex;
122
- const seen = new Set<number>();
123
- while (current !== undefined) {
124
- if (seen.has(current)) return null; // malformed (cyclic) input
125
- seen.add(current);
126
- path.unshift(typeof nodes[current]?.name === "string" ? nodes[current].name : "");
127
- current = parents.get(current);
128
- }
129
- return path;
130
- }
131
-
132
- /** path from root (exclusive) to target (inclusive), or null if target is not under root */
133
- export function findPath(root: GraphNode, target: GraphNode): GraphNode[] | null {
134
- if (root === target) return [];
135
- const children = root.children;
136
- if (!children) return null;
137
- for (let i = 0; i < children.length; i++) {
138
- const child = children[i];
139
- if (child == null) continue;
140
- const sub = findPath(child, target);
141
- if (sub) return [child, ...sub];
142
- }
143
- return null;
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
+ }