@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 +132 -132
- package/package.json +2 -2
- package/src/address.ts +169 -169
- package/src/anchor.ts +71 -71
- package/src/document/assets.ts +77 -77
- package/src/document/collectionSelector.ts +37 -37
- package/src/document/evaluate.ts +998 -998
- package/src/document/index.ts +8 -8
- package/src/document/inspection.ts +123 -123
- package/src/document/runtime.ts +66 -66
- package/src/document/schema.json +263 -263
- package/src/document/types.ts +249 -249
- package/src/document/validation.ts +222 -222
- package/src/document/write.ts +112 -112
- package/src/gltf.ts +33 -33
- package/src/hash.ts +58 -58
- package/src/id.ts +206 -206
- package/src/index.ts +49 -49
- package/src/loadedGltf.ts +93 -93
- package/src/resolve.ts +319 -319
- package/src/target.ts +126 -126
- package/src/uuid.ts +48 -48
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
|
+
"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
|
+
}
|