@needle-tools/addressing 0.3.4-1790936280.dramatic-tiramisu.86ca165 → 0.4.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 CHANGED
@@ -1,132 +1,132 @@
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
- ## Project documents
10
-
11
- `@needle-tools/addressing/document` exports the document types, evaluator,
12
- single-def/over application, optional inspection index, and schema validator.
13
- The JSON Schema is available at `@needle-tools/addressing/document/schema.json`.
14
- The document format is unchanged by this package split.
15
-
16
- The shared writer exports `writePropertyOver`, `captureDocumentValue`,
17
- `documentOverKey`, and `mergeDocumentOvers`. These encode property edits, retain
18
- addresses, remove net-undo edits, and merge saved overs. Hosts still supply node
19
- reference encoding, live-target checks, asset assembly, and component serialization.
20
- Non-finite numbers and unsupported values produce a writer issue instead of being
21
- silently changed by JSON serialization.
22
-
23
- `DocumentAssetAssembly` registers deduplicated asset sources and shared material
24
- or HDR entities in the writer's asset/reference tables. Material assignments reuse
25
- saved entity identities by source and material ID, even when asset keys change.
26
- Hosts supply the captured entity IDs and resolve asset ownership; the assembly
27
- contains no engine or editor dependencies.
28
-
29
- The evaluator accepts a `DocumentRuntime` host. It owns asset caching, reference
30
- placement, derived glTF IDs, target resolution, defs, property and parent overs,
31
- entity sharing, and issue reporting. The host supplies loading, object creation,
32
- cloning, and lifecycle scheduling. Needle Engine provides its own adapter;
33
- plain three.js can use the same evaluator:
34
-
35
- ```ts
36
- import { Color, Object3D, Scene } from "three";
37
- import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
38
- import { evaluateProjectDocument, type DocumentRuntime } from "@needle-tools/addressing/document";
39
-
40
- const scene = new Scene();
41
- const runtime: DocumentRuntime = {
42
- scene,
43
- renderer, // your existing renderer, for $renderer opinions
44
- createNode: () => new Object3D(),
45
- createColor: value => new Color().fromArray(value),
46
- parse: (data, src) => new GLTFLoader().parseAsync(data, new URL(".", new URL(src, documentUrl)).href),
47
- instantiate: node => (node as Object3D).clone(),
48
- // Plain objects have no component lifecycle or template registrations.
49
- parkTemplate() {},
50
- activateInstance() {},
51
- disposeTemplate() {},
52
- schedule: work => work(),
53
- loadEnvironment, // your existing HDR/PMREM loader: (url, options) => Promise<Texture | null>
54
- };
55
- const result = await evaluateProjectDocument(runtime, document, { baseUrl: documentUrl });
56
- await result.completed;
57
- ```
58
-
59
- Supply `createDefContext` and the component access hooks if the host supports
60
- components. Without them, component defs are reported as unsupported; node defs
61
- and ordinary three.js properties still work. A skeletal or component-aware host
62
- must supply its matching clone implementation. The default addressing entry point
63
- does not import the document evaluator or any three.js runtime.
64
-
65
- ## Ids
66
-
67
- Every glTF root-collection element (nodes, scenes, animations, cameras, materials,
68
- meshes, skins, textures, images, samplers) can carry a UUID in `extras["needle-id"]`:
69
-
70
- - **Anchored** — the element name ends with `#needle` (case-insensitive): the id derives
71
- only from the text before the marker → it **survives any content change** as long as
72
- the name stays. Spaces and underscores in the value are treated as equal (loaders
73
- sanitize node names), so `Door Frame #needle` and `Door_Frame_#needle` share one id.
74
- - **Unanchored**: the id derives from the element's type, name, and its occurrence among
75
- same-named elements → uniquely named elements keep their ids across reorders and
76
- content updates; duplicates disambiguate by position within their name group; unnamed
77
- elements are positional.
78
-
79
- `ensureIds(gltfJson)` stamps a parsed glTF idempotently — existing ids are never
80
- overwritten, and any tool stamping the same element mints the same id.
81
-
82
- `ensureIds(gltfJson, { baseHash })` optionally salts unanchored ids with a caller-supplied
83
- identity (e.g. a stable asset id), scoping them to that identity.
84
-
85
- ## Addresses
86
-
87
- An address captures every fallback at creation time, while the target is known —
88
- `{ id, anchor?, type?, name?, path? }`. Resolution order is strict (path: "/"-separated segments, JSON-Pointer `~0`/`~1` escaping):
89
-
90
- 1. `id` — exact match (`extras` in glTF JSON, `userData` in live scene graphs)
91
- 2. `anchor` — survives asset updates
92
- 3. `path` — best-effort structural match
93
-
94
- Node paths qualify same-named siblings with `~2n` (zero-based occurrence):
95
- `Cube~21` names the second `Cube`. Literal tildes remain escaped as `~0`.
96
- Unqualified ambiguous paths fail to resolve rather than picking the first child.
97
- These paths assume the original sibling order; use authored anchors or persistent
98
- IDs when the source scene changes.
99
- 4. **reconciliation** — never automatic: `reconcileInGraph` returns ranked candidates
100
- with confidence + reasons; the caller decides thresholds and whether to re-anchor.
101
-
102
- ## API
103
-
104
- ```ts
105
- import {
106
- ensureIds, computeElementId, computeFileHash, // write side
107
- createGltfElementAddress, createObjectAddress, // address records
108
- resolveInGltf, resolveInGraph, reconcileInGraph, // resolve side
109
- parseAnchorValue, randomUuid, NEEDLE_ID_KEY,
110
- } from "@needle-tools/addressing";
111
-
112
- // stamp a parsed glTF (idempotent, never overwrites existing ids)
113
- const { fileHash, assigned } = ensureIds(gltfJson);
114
-
115
- // capture an address while the target is known
116
- const address = createObjectAddress(sceneRoot, targetObject);
117
-
118
- // later / elsewhere: resolve — or reconcile when the asset changed
119
- const hit = resolveInGraph(address, sceneRoot);
120
- const candidates = hit ? [] : reconcileInGraph(address, sceneRoot);
121
- ```
122
-
123
- Out of scope by design: GLB container parsing (this package takes parsed JSON), file
124
- I/O, and any composition/override semantics.
125
-
126
- ## Development
127
-
128
- ```
129
- npm install
130
- npm test # vitest — includes golden-fixture id tests
131
- npm run build # tsc → dist/
132
- ```
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
+ ## Project documents
10
+
11
+ `@needle-tools/addressing/document` exports the document types, evaluator,
12
+ single-def/over application, optional inspection index, and schema validator.
13
+ The JSON Schema is available at `@needle-tools/addressing/document/schema.json`.
14
+ The document format is unchanged by this package split.
15
+
16
+ The shared writer exports `writePropertyOver`, `captureDocumentValue`,
17
+ `documentOverKey`, and `mergeDocumentOvers`. These encode property edits, retain
18
+ addresses, remove net-undo edits, and merge saved overs. Hosts still supply node
19
+ reference encoding, live-target checks, asset assembly, and component serialization.
20
+ Non-finite numbers and unsupported values produce a writer issue instead of being
21
+ silently changed by JSON serialization.
22
+
23
+ `DocumentAssetAssembly` registers deduplicated asset sources and shared material
24
+ or HDR entities in the writer's asset/reference tables. Material assignments reuse
25
+ saved entity identities by source and material ID, even when asset keys change.
26
+ Hosts supply the captured entity IDs and resolve asset ownership; the assembly
27
+ contains no engine or editor dependencies.
28
+
29
+ The evaluator accepts a `DocumentRuntime` host. It owns asset caching, reference
30
+ placement, derived glTF IDs, target resolution, defs, property and parent overs,
31
+ entity sharing, and issue reporting. The host supplies loading, object creation,
32
+ cloning, and lifecycle scheduling. Needle Engine provides its own adapter;
33
+ plain three.js can use the same evaluator:
34
+
35
+ ```ts
36
+ import { Color, Object3D, Scene } from "three";
37
+ import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
38
+ import { evaluateProjectDocument, type DocumentRuntime } from "@needle-tools/addressing/document";
39
+
40
+ const scene = new Scene();
41
+ const runtime: DocumentRuntime = {
42
+ scene,
43
+ renderer, // your existing renderer, for $renderer opinions
44
+ createNode: () => new Object3D(),
45
+ createColor: value => new Color().fromArray(value),
46
+ parse: (data, src) => new GLTFLoader().parseAsync(data, new URL(".", new URL(src, documentUrl)).href),
47
+ instantiate: node => (node as Object3D).clone(),
48
+ // Plain objects have no component lifecycle or template registrations.
49
+ parkTemplate() {},
50
+ activateInstance() {},
51
+ disposeTemplate() {},
52
+ schedule: work => work(),
53
+ loadEnvironment, // your existing HDR/PMREM loader: (url, options) => Promise<Texture | null>
54
+ };
55
+ const result = await evaluateProjectDocument(runtime, document, { baseUrl: documentUrl });
56
+ await result.completed;
57
+ ```
58
+
59
+ Supply `createDefContext` and the component access hooks if the host supports
60
+ components. Without them, component defs are reported as unsupported; node defs
61
+ and ordinary three.js properties still work. A skeletal or component-aware host
62
+ must supply its matching clone implementation. The default addressing entry point
63
+ does not import the document evaluator or any three.js runtime.
64
+
65
+ ## Ids
66
+
67
+ Every glTF root-collection element (nodes, scenes, animations, cameras, materials,
68
+ meshes, skins, textures, images, samplers) can carry a UUID in `extras["needle-id"]`:
69
+
70
+ - **Anchored** — the element name ends with `#needle` (case-insensitive): the id derives
71
+ only from the text before the marker → it **survives any content change** as long as
72
+ the name stays. Spaces and underscores in the value are treated as equal (loaders
73
+ sanitize node names), so `Door Frame #needle` and `Door_Frame_#needle` share one id.
74
+ - **Unanchored**: the id derives from the element's type, name, and its occurrence among
75
+ same-named elements → uniquely named elements keep their ids across reorders and
76
+ content updates; duplicates disambiguate by position within their name group; unnamed
77
+ elements are positional.
78
+
79
+ `ensureIds(gltfJson)` stamps a parsed glTF idempotently — existing ids are never
80
+ overwritten, and any tool stamping the same element mints the same id.
81
+
82
+ `ensureIds(gltfJson, { baseHash })` optionally salts unanchored ids with a caller-supplied
83
+ identity (e.g. a stable asset id), scoping them to that identity.
84
+
85
+ ## Addresses
86
+
87
+ An address captures every fallback at creation time, while the target is known —
88
+ `{ id, anchor?, type?, name?, path? }`. Resolution order is strict (path: "/"-separated segments, JSON-Pointer `~0`/`~1` escaping):
89
+
90
+ 1. `id` — exact match (`extras` in glTF JSON, `userData` in live scene graphs)
91
+ 2. `anchor` — survives asset updates
92
+ 3. `path` — best-effort structural match
93
+
94
+ Node paths qualify same-named siblings with `~2n` (zero-based occurrence):
95
+ `Cube~21` names the second `Cube`. Literal tildes remain escaped as `~0`.
96
+ Unqualified ambiguous paths fail to resolve rather than picking the first child.
97
+ These paths assume the original sibling order; use authored anchors or persistent
98
+ IDs when the source scene changes.
99
+ 4. **reconciliation** — never automatic: `reconcileInGraph` returns ranked candidates
100
+ with confidence + reasons; the caller decides thresholds and whether to re-anchor.
101
+
102
+ ## API
103
+
104
+ ```ts
105
+ import {
106
+ ensureIds, computeElementId, computeFileHash, // write side
107
+ createGltfElementAddress, createObjectAddress, // address records
108
+ resolveInGltf, resolveInGraph, reconcileInGraph, // resolve side
109
+ parseAnchorValue, randomUuid, NEEDLE_ID_KEY,
110
+ } from "@needle-tools/addressing";
111
+
112
+ // stamp a parsed glTF (idempotent, never overwrites existing ids)
113
+ const { fileHash, assigned } = ensureIds(gltfJson);
114
+
115
+ // capture an address while the target is known
116
+ const address = createObjectAddress(sceneRoot, targetObject);
117
+
118
+ // later / elsewhere: resolve — or reconcile when the asset changed
119
+ const hit = resolveInGraph(address, sceneRoot);
120
+ const candidates = hit ? [] : reconcileInGraph(address, sceneRoot);
121
+ ```
122
+
123
+ Out of scope by design: GLB container parsing (this package takes parsed JSON), file
124
+ I/O, and any composition/override semantics.
125
+
126
+ ## Development
127
+
128
+ ```
129
+ npm install
130
+ npm test # vitest — includes golden-fixture id tests
131
+ npm run build # tsc → dist/
132
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@needle-tools/addressing",
3
- "version": "0.3.4-1790936280.dramatic-tiramisu.86ca165",
3
+ "version": "0.4.0",
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",
@@ -47,4 +47,4 @@
47
47
  "type": "git",
48
48
  "url": "https://github.com/needle-tools/addressing.git"
49
49
  }
50
- }
50
+ }
package/src/address.ts CHANGED
@@ -1,169 +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
- * 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
- }
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
+ }