@needle-tools/addressing 0.3.2 → 0.3.3-1785755225.genghis-khan.fb057b7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Readme.md +70 -70
- package/dist/address.d.ts +19 -4
- package/dist/address.js +35 -10
- package/dist/anchor.d.ts +12 -0
- package/dist/anchor.js +11 -8
- package/dist/gltf.js +6 -4
- package/dist/id.d.ts +30 -11
- package/dist/id.js +39 -22
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/resolve.d.ts +63 -7
- package/dist/resolve.js +158 -37
- package/package.json +2 -2
- package/src/address.ts +143 -106
- package/src/anchor.ts +71 -68
- package/src/gltf.ts +91 -89
- package/src/hash.ts +58 -58
- package/src/id.ts +61 -45
- package/src/index.ts +38 -29
- package/src/resolve.ts +316 -172
- package/src/uuid.ts +48 -48
package/Readme.md
CHANGED
|
@@ -1,70 +1,70 @@
|
|
|
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
|
-
## Ids
|
|
10
|
-
|
|
11
|
-
Every glTF root-collection element (nodes, scenes, animations, cameras, materials,
|
|
12
|
-
meshes, skins, textures, images, samplers) can carry a UUID in `extras["needle-id"]`:
|
|
13
|
-
|
|
14
|
-
- **Anchored** — the element name ends with `#needle` (case-insensitive): the id derives
|
|
15
|
-
only from the text before the marker → it **survives any content change** as long as
|
|
16
|
-
the name stays. Spaces and underscores in the value are treated as equal (loaders
|
|
17
|
-
sanitize node names), so `Door Frame #needle` and `Door_Frame_#needle` share one id.
|
|
18
|
-
- **Unanchored**: the id derives from the element's type, name, and its occurrence among
|
|
19
|
-
same-named elements → uniquely named elements keep their ids across reorders and
|
|
20
|
-
content updates; duplicates disambiguate by position within their name group; unnamed
|
|
21
|
-
elements are positional.
|
|
22
|
-
|
|
23
|
-
`ensureIds(gltfJson)` stamps a parsed glTF idempotently — existing ids are never
|
|
24
|
-
overwritten, and any tool stamping the same element mints the same id.
|
|
25
|
-
|
|
26
|
-
`ensureIds(gltfJson, { baseHash })` optionally salts unanchored ids with a caller-supplied
|
|
27
|
-
identity (e.g. a stable asset id), scoping them to that identity.
|
|
28
|
-
|
|
29
|
-
## Addresses
|
|
30
|
-
|
|
31
|
-
An address captures every fallback at creation time, while the target is known —
|
|
32
|
-
`{ id, anchor?, type?, name?,
|
|
33
|
-
|
|
34
|
-
1. `id` — exact match (`extras` in glTF JSON, `userData` in live scene graphs)
|
|
35
|
-
2. `anchor` — survives asset updates
|
|
36
|
-
3. `
|
|
37
|
-
4. **reconciliation** — never automatic: `reconcileInGraph` returns ranked candidates
|
|
38
|
-
with confidence + reasons; the caller decides thresholds and whether to re-anchor.
|
|
39
|
-
|
|
40
|
-
## API
|
|
41
|
-
|
|
42
|
-
```ts
|
|
43
|
-
import {
|
|
44
|
-
ensureIds, computeElementId, computeFileHash, // write side
|
|
45
|
-
createGltfElementAddress, createObjectAddress, // address records
|
|
46
|
-
resolveInGltf, resolveInGraph, reconcileInGraph, // resolve side
|
|
47
|
-
parseAnchorValue, randomUuid, NEEDLE_ID_KEY,
|
|
48
|
-
} from "@needle-tools/addressing";
|
|
49
|
-
|
|
50
|
-
// stamp a parsed glTF (idempotent, never overwrites existing ids)
|
|
51
|
-
const { fileHash, assigned } = ensureIds(gltfJson);
|
|
52
|
-
|
|
53
|
-
// capture an address while the target is known
|
|
54
|
-
const address = createObjectAddress(sceneRoot, targetObject);
|
|
55
|
-
|
|
56
|
-
// later / elsewhere: resolve — or reconcile when the asset changed
|
|
57
|
-
const hit = resolveInGraph(address, sceneRoot);
|
|
58
|
-
const candidates = hit ? [] : reconcileInGraph(address, sceneRoot);
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
Out of scope by design: GLB container parsing (this package takes parsed JSON), file
|
|
62
|
-
I/O, and any composition/override semantics.
|
|
63
|
-
|
|
64
|
-
## Development
|
|
65
|
-
|
|
66
|
-
```
|
|
67
|
-
npm install
|
|
68
|
-
npm test # vitest — includes golden-fixture id tests
|
|
69
|
-
npm run build # tsc → dist/
|
|
70
|
-
```
|
|
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
|
+
## Ids
|
|
10
|
+
|
|
11
|
+
Every glTF root-collection element (nodes, scenes, animations, cameras, materials,
|
|
12
|
+
meshes, skins, textures, images, samplers) can carry a UUID in `extras["needle-id"]`:
|
|
13
|
+
|
|
14
|
+
- **Anchored** — the element name ends with `#needle` (case-insensitive): the id derives
|
|
15
|
+
only from the text before the marker → it **survives any content change** as long as
|
|
16
|
+
the name stays. Spaces and underscores in the value are treated as equal (loaders
|
|
17
|
+
sanitize node names), so `Door Frame #needle` and `Door_Frame_#needle` share one id.
|
|
18
|
+
- **Unanchored**: the id derives from the element's type, name, and its occurrence among
|
|
19
|
+
same-named elements → uniquely named elements keep their ids across reorders and
|
|
20
|
+
content updates; duplicates disambiguate by position within their name group; unnamed
|
|
21
|
+
elements are positional.
|
|
22
|
+
|
|
23
|
+
`ensureIds(gltfJson)` stamps a parsed glTF idempotently — existing ids are never
|
|
24
|
+
overwritten, and any tool stamping the same element mints the same id.
|
|
25
|
+
|
|
26
|
+
`ensureIds(gltfJson, { baseHash })` optionally salts unanchored ids with a caller-supplied
|
|
27
|
+
identity (e.g. a stable asset id), scoping them to that identity.
|
|
28
|
+
|
|
29
|
+
## Addresses
|
|
30
|
+
|
|
31
|
+
An address captures every fallback at creation time, while the target is known —
|
|
32
|
+
`{ id, anchor?, type?, name?, path? }`. Resolution order is strict (path: "/"-separated segments, JSON-Pointer `~0`/`~1` escaping):
|
|
33
|
+
|
|
34
|
+
1. `id` — exact match (`extras` in glTF JSON, `userData` in live scene graphs)
|
|
35
|
+
2. `anchor` — survives asset updates
|
|
36
|
+
3. `path` — best-effort structural match
|
|
37
|
+
4. **reconciliation** — never automatic: `reconcileInGraph` returns ranked candidates
|
|
38
|
+
with confidence + reasons; the caller decides thresholds and whether to re-anchor.
|
|
39
|
+
|
|
40
|
+
## API
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import {
|
|
44
|
+
ensureIds, computeElementId, computeFileHash, // write side
|
|
45
|
+
createGltfElementAddress, createObjectAddress, // address records
|
|
46
|
+
resolveInGltf, resolveInGraph, reconcileInGraph, // resolve side
|
|
47
|
+
parseAnchorValue, randomUuid, NEEDLE_ID_KEY,
|
|
48
|
+
} from "@needle-tools/addressing";
|
|
49
|
+
|
|
50
|
+
// stamp a parsed glTF (idempotent, never overwrites existing ids)
|
|
51
|
+
const { fileHash, assigned } = ensureIds(gltfJson);
|
|
52
|
+
|
|
53
|
+
// capture an address while the target is known
|
|
54
|
+
const address = createObjectAddress(sceneRoot, targetObject);
|
|
55
|
+
|
|
56
|
+
// later / elsewhere: resolve — or reconcile when the asset changed
|
|
57
|
+
const hit = resolveInGraph(address, sceneRoot);
|
|
58
|
+
const candidates = hit ? [] : reconcileInGraph(address, sceneRoot);
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Out of scope by design: GLB container parsing (this package takes parsed JSON), file
|
|
62
|
+
I/O, and any composition/override semantics.
|
|
63
|
+
|
|
64
|
+
## Development
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
npm install
|
|
68
|
+
npm test # vitest — includes golden-fixture id tests
|
|
69
|
+
npm run build # tsc → dist/
|
|
70
|
+
```
|
package/dist/address.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { type GltfJson, type StampedElementType } from "./gltf.js";
|
|
|
4
4
|
* fallback the resolver may need is captured at CREATION time, while the target is
|
|
5
5
|
* known, instead of being reconstructed later.
|
|
6
6
|
*
|
|
7
|
-
* Resolution order: `id` (exact) → `anchor` (survives asset updates) → `
|
|
7
|
+
* Resolution order: `id` (exact) → `anchor` (survives asset updates) → `path`
|
|
8
8
|
* (best-effort) → reconciliation (candidates + confidence, never a silent guess).
|
|
9
9
|
*/
|
|
10
10
|
export type NeedleAddress = {
|
|
@@ -16,16 +16,31 @@ export type NeedleAddress = {
|
|
|
16
16
|
type?: StampedElementType | (string & {});
|
|
17
17
|
/** the target's name at address-creation time */
|
|
18
18
|
name?: string;
|
|
19
|
-
/**
|
|
20
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Node names from the asset root (exclusive) down to the target (inclusive);
|
|
21
|
+
* nodes only. ONE path syntax everywhere (2026-08-03): `/`-separated segments
|
|
22
|
+
* with JSON-Pointer escaping (`~0` = `~`, `~1` = `/`) — see {@link joinPath} /
|
|
23
|
+
* {@link splitPath}. Empty SEGMENTS are load-bearing (positional walk over
|
|
24
|
+
* unnamed nodes), so `""` is a valid one-segment path — test `!== undefined`,
|
|
25
|
+
* never truthiness. ABSENT means "no path rung".
|
|
26
|
+
*/
|
|
27
|
+
path?: string;
|
|
21
28
|
};
|
|
29
|
+
/** escape one segment for embedding in a path string */
|
|
30
|
+
export declare function encodePathSegment(name: string): string;
|
|
31
|
+
/** decode one path segment back to the literal name */
|
|
32
|
+
export declare function decodePathSegment(segment: string): string;
|
|
33
|
+
/** join literal segment names into an escaped path string */
|
|
34
|
+
export declare function joinPath(segments: readonly string[]): string;
|
|
35
|
+
/** split an escaped path string into literal segment names ("" → one empty segment) */
|
|
36
|
+
export declare function splitPath(path: string): string[];
|
|
22
37
|
/** Structural (duck-typed) view of a live scene-graph node — works with any three.js. */
|
|
23
38
|
export type GraphNode = {
|
|
24
39
|
name?: string;
|
|
25
40
|
children?: readonly GraphNode[];
|
|
26
41
|
userData?: Record<string, unknown>;
|
|
27
42
|
};
|
|
28
|
-
/** Build an address for a glTF root-collection element. `
|
|
43
|
+
/** Build an address for a glTF root-collection element. `path` only applies to nodes. */
|
|
29
44
|
export declare function createGltfElementAddress(json: GltfJson, type: StampedElementType | string, index: number): NeedleAddress | null;
|
|
30
45
|
/** Build an address for a live scene-graph object under `root`. */
|
|
31
46
|
export declare function createObjectAddress(root: GraphNode, target: GraphNode): NeedleAddress | null;
|
package/dist/address.js
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
import { parseAnchorValue } from "./anchor.js";
|
|
2
2
|
import { getElementId, NEEDLE_ID_KEY } from "./gltf.js";
|
|
3
|
-
|
|
3
|
+
// #region path segment syntax
|
|
4
|
+
// The one path syntax of the project format (target.path uses the same rules):
|
|
5
|
+
// "/" separates segments; a literal "~" in a name is written "~0", a literal "/"
|
|
6
|
+
// is written "~1" (JSON-Pointer, RFC 6901). Escape order matters on decode.
|
|
7
|
+
/** escape one segment for embedding in a path string */
|
|
8
|
+
export function encodePathSegment(name) {
|
|
9
|
+
return name.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
10
|
+
}
|
|
11
|
+
/** decode one path segment back to the literal name */
|
|
12
|
+
export function decodePathSegment(segment) {
|
|
13
|
+
return segment.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
14
|
+
}
|
|
15
|
+
/** join literal segment names into an escaped path string */
|
|
16
|
+
export function joinPath(segments) {
|
|
17
|
+
return segments.map(encodePathSegment).join("/");
|
|
18
|
+
}
|
|
19
|
+
/** split an escaped path string into literal segment names ("" → one empty segment) */
|
|
20
|
+
export function splitPath(path) {
|
|
21
|
+
return path.split("/").map(decodePathSegment);
|
|
22
|
+
}
|
|
23
|
+
/** Build an address for a glTF root-collection element. `path` only applies to nodes. */
|
|
4
24
|
export function createGltfElementAddress(json, type, index) {
|
|
5
25
|
const collection = json[collectionKeyForType(type)];
|
|
6
26
|
if (!Array.isArray(collection))
|
|
@@ -16,20 +36,24 @@ export function createGltfElementAddress(json, type, index) {
|
|
|
16
36
|
const anchor = parseAnchorValue(name);
|
|
17
37
|
if (anchor)
|
|
18
38
|
address.anchor = anchor;
|
|
19
|
-
|
|
39
|
+
// an empty name is non-information: resolution never reads `name` (id → anchor →
|
|
40
|
+
// path) and reconciliation falls back to the last path segment ("" again).
|
|
41
|
+
// NOTE: empty path SEGMENTS are load-bearing (positional child-walk) — only the
|
|
42
|
+
// standalone `name` field is dropped when empty.
|
|
43
|
+
if (name)
|
|
20
44
|
address.name = name;
|
|
21
45
|
if (type === "node") {
|
|
22
|
-
const
|
|
23
|
-
if (
|
|
24
|
-
address.
|
|
46
|
+
const segments = gltfNodeNamePath(json, index);
|
|
47
|
+
if (segments)
|
|
48
|
+
address.path = joinPath(segments);
|
|
25
49
|
}
|
|
26
50
|
return address;
|
|
27
51
|
}
|
|
28
52
|
/** Build an address for a live scene-graph object under `root`. */
|
|
29
53
|
export function createObjectAddress(root, target) {
|
|
30
|
-
const
|
|
31
|
-
if (!
|
|
32
|
-
return null;
|
|
54
|
+
const nodes = findPath(root, target);
|
|
55
|
+
if (!nodes || nodes.length === 0)
|
|
56
|
+
return null; // the root itself has no path — address it by ref alone
|
|
33
57
|
const address = { type: "node" };
|
|
34
58
|
const id = target.userData?.[NEEDLE_ID_KEY];
|
|
35
59
|
if (typeof id === "string")
|
|
@@ -37,9 +61,10 @@ export function createObjectAddress(root, target) {
|
|
|
37
61
|
const anchor = parseAnchorValue(target.name);
|
|
38
62
|
if (anchor)
|
|
39
63
|
address.anchor = anchor;
|
|
40
|
-
|
|
64
|
+
// empty name dropped — see createGltfElementAddress; path segments stay verbatim
|
|
65
|
+
if (target.name)
|
|
41
66
|
address.name = target.name;
|
|
42
|
-
address.
|
|
67
|
+
address.path = joinPath(nodes.map(n => n.name ?? ""));
|
|
43
68
|
return address;
|
|
44
69
|
}
|
|
45
70
|
export function collectionKeyForType(type) {
|
package/dist/anchor.d.ts
CHANGED
|
@@ -28,6 +28,18 @@ export type ParsedAnchor = {
|
|
|
28
28
|
* Returns null when no marker matches or the anchor value is empty.
|
|
29
29
|
*/
|
|
30
30
|
export declare function parseAnchor(rawName: string | null | undefined): ParsedAnchor | null;
|
|
31
|
+
/**
|
|
32
|
+
* Names treat SPACES and UNDERSCORES as the same character (runs collapse to one space):
|
|
33
|
+
* three's GLTFLoader sanitizes node names on load (whitespace becomes "_"), so
|
|
34
|
+
* "Door Frame #needle" appears live as "Door_Frame_#needle" — without normalization a
|
|
35
|
+
* stamped anchor and the live anchor would never match. Applied at PARSE time, so id
|
|
36
|
+
* computation and resolution agree from one change point. Accepted trade: names differing
|
|
37
|
+
* only by space-vs-underscore are one name.
|
|
38
|
+
*
|
|
39
|
+
* Exported because id derivation applies the SAME rule to unanchored names (see
|
|
40
|
+
* {@link import("./id.js").idNameOf}) — one normalization, one change point.
|
|
41
|
+
*/
|
|
42
|
+
export declare function normalizeName(value: string): string;
|
|
31
43
|
/**
|
|
32
44
|
* Extract the anchor value from an element name, or null when the name carries no anchor.
|
|
33
45
|
* Convenience over {@link parseAnchor} for callers that only need the id input.
|
package/dist/anchor.js
CHANGED
|
@@ -29,20 +29,23 @@ export function parseAnchor(rawName) {
|
|
|
29
29
|
for (const marker of byLengthDescending(ANCHOR_MARKERS)) {
|
|
30
30
|
if (!lower.endsWith(marker))
|
|
31
31
|
continue;
|
|
32
|
-
const value =
|
|
32
|
+
const value = normalizeName(trimmed.substring(0, trimmed.length - marker.length));
|
|
33
33
|
return value.length > 0 ? { marker, value } : null;
|
|
34
34
|
}
|
|
35
35
|
return null;
|
|
36
36
|
}
|
|
37
37
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* "
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
38
|
+
* Names treat SPACES and UNDERSCORES as the same character (runs collapse to one space):
|
|
39
|
+
* three's GLTFLoader sanitizes node names on load (whitespace becomes "_"), so
|
|
40
|
+
* "Door Frame #needle" appears live as "Door_Frame_#needle" — without normalization a
|
|
41
|
+
* stamped anchor and the live anchor would never match. Applied at PARSE time, so id
|
|
42
|
+
* computation and resolution agree from one change point. Accepted trade: names differing
|
|
43
|
+
* only by space-vs-underscore are one name.
|
|
44
|
+
*
|
|
45
|
+
* Exported because id derivation applies the SAME rule to unanchored names (see
|
|
46
|
+
* {@link import("./id.js").idNameOf}) — one normalization, one change point.
|
|
44
47
|
*/
|
|
45
|
-
function
|
|
48
|
+
export function normalizeName(value) {
|
|
46
49
|
return value.replace(/[\s_]+/g, " ").trim();
|
|
47
50
|
}
|
|
48
51
|
/**
|
package/dist/gltf.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { computeElementId } from "./id.js";
|
|
1
|
+
import { computeElementId, idNameOf } from "./id.js";
|
|
2
2
|
/** The glTF `extras` key carrying the persistent id. */
|
|
3
3
|
export const NEEDLE_ID_KEY = "needle-id";
|
|
4
4
|
/** All root-collection element types that receive ids, with their glTF collection key. */
|
|
@@ -30,15 +30,17 @@ export function ensureIds(json, options) {
|
|
|
30
30
|
continue;
|
|
31
31
|
// occurrence-of-name: the id input is the element's position among same-named
|
|
32
32
|
// elements, NOT its collection index — uniquely named elements keep their ids
|
|
33
|
-
// across reorders and asset updates
|
|
33
|
+
// across reorders and asset updates. Grouping is by the DERIVATION name
|
|
34
|
+
// (anchor marker stripped, normalized), matching occurrenceOfName.
|
|
34
35
|
const occurrences = new Map();
|
|
35
36
|
for (let index = 0; index < elements.length; index++) {
|
|
36
37
|
const element = elements[index];
|
|
37
38
|
if (element == null || typeof element !== "object")
|
|
38
39
|
continue;
|
|
39
40
|
const name = typeof element.name === "string" ? element.name : "";
|
|
40
|
-
const
|
|
41
|
-
occurrences.
|
|
41
|
+
const group = idNameOf(name);
|
|
42
|
+
const occurrence = occurrences.get(group) ?? 0;
|
|
43
|
+
occurrences.set(group, occurrence + 1);
|
|
42
44
|
element.extras ?? (element.extras = {});
|
|
43
45
|
if (element.extras[NEEDLE_ID_KEY] === undefined) {
|
|
44
46
|
element.extras[NEEDLE_ID_KEY] = computeElementId(type, name, occurrence, baseHash);
|
package/dist/id.d.ts
CHANGED
|
@@ -1,16 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
2
|
+
* The name an id is derived FROM: the anchor marker removed (if any) and space/underscore
|
|
3
|
+
* runs collapsed.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
5
|
+
* The marker is stripped because **ids are minted as if no anchor existed** — an anchor is
|
|
6
|
+
* a LABEL, not an identity, and the two must be able to change independently. Both halves
|
|
7
|
+
* of that are load-bearing:
|
|
8
|
+
* - stripping the marker means ADDING or REMOVING `#needle` leaves the id untouched (under
|
|
9
|
+
* the previous rule, anchoring an object CHANGED its id — so the very act recommended for
|
|
10
|
+
* making something stable orphaned every edit already addressed to it);
|
|
11
|
+
* - {@link occurrenceOfName} groups on this same value, so anchoring ONE of two `Door`s
|
|
12
|
+
* does not shift the OTHER one's occurrence.
|
|
13
|
+
*
|
|
14
|
+
* Note what is decoupled: the id is independent of the anchor MARKER, not of the NAME.
|
|
15
|
+
* Renaming `Door #needle` → `Entrance #needle` still changes a DERIVED id; a STAMPED id is
|
|
16
|
+
* unaffected, since existing ids are never overwritten.
|
|
17
|
+
*/
|
|
18
|
+
export declare function idNameOf(name: string | null | undefined): string;
|
|
19
|
+
/**
|
|
20
|
+
* The deterministic needle-id of a glTF element:
|
|
21
|
+
* `uuidv5("<type>-<idName>-<occurrence>[-<baseHash>]")`, where `idName` is the name with
|
|
22
|
+
* any anchor marker removed ({@link idNameOf}) and `occurrence` is the element's position
|
|
23
|
+
* among same-named elements of its type. Uniquely named elements are therefore fully
|
|
24
|
+
* reorder-stable across file versions; duplicates shift only when reordered within their
|
|
25
|
+
* own name group; unnamed elements are positional among the unnamed.
|
|
26
|
+
*
|
|
27
|
+
* `baseHash` is an optional consumer-supplied salt (e.g. an asset/content id) that scopes
|
|
28
|
+
* ids to an external identity. Omitted by default, so any tool stamping the same element
|
|
29
|
+
* mints the same id with no coordination.
|
|
14
30
|
*
|
|
15
31
|
* Ids are persisted in `extras["needle-id"]` and never overwritten once present —
|
|
16
32
|
* whoever stamps first wins; this rule only governs never-stamped elements.
|
|
@@ -20,6 +36,9 @@ export declare function computeElementId(type: string, name: string | null | und
|
|
|
20
36
|
* The occurrence of the element at `index` among same-named elements of its collection —
|
|
21
37
|
* the positional input of {@link computeElementId}. Exposed so consumers computing ids
|
|
22
38
|
* for individual elements (instead of stamping whole files) use the identical rule.
|
|
39
|
+
*
|
|
40
|
+
* Grouping is by {@link idNameOf}, so an anchored element counts in the same group as its
|
|
41
|
+
* unanchored namesakes.
|
|
23
42
|
*/
|
|
24
43
|
export declare function occurrenceOfName(elements: ReadonlyArray<{
|
|
25
44
|
name?: unknown;
|
package/dist/id.js
CHANGED
|
@@ -1,43 +1,60 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { normalizeName, parseAnchor } from "./anchor.js";
|
|
2
2
|
import { uuidv5 } from "./uuid.js";
|
|
3
3
|
/**
|
|
4
|
-
* The
|
|
4
|
+
* The name an id is derived FROM: the anchor marker removed (if any) and space/underscore
|
|
5
|
+
* runs collapsed.
|
|
5
6
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
7
|
+
* The marker is stripped because **ids are minted as if no anchor existed** — an anchor is
|
|
8
|
+
* a LABEL, not an identity, and the two must be able to change independently. Both halves
|
|
9
|
+
* of that are load-bearing:
|
|
10
|
+
* - stripping the marker means ADDING or REMOVING `#needle` leaves the id untouched (under
|
|
11
|
+
* the previous rule, anchoring an object CHANGED its id — so the very act recommended for
|
|
12
|
+
* making something stable orphaned every edit already addressed to it);
|
|
13
|
+
* - {@link occurrenceOfName} groups on this same value, so anchoring ONE of two `Door`s
|
|
14
|
+
* does not shift the OTHER one's occurrence.
|
|
15
|
+
*
|
|
16
|
+
* Note what is decoupled: the id is independent of the anchor MARKER, not of the NAME.
|
|
17
|
+
* Renaming `Door #needle` → `Entrance #needle` still changes a DERIVED id; a STAMPED id is
|
|
18
|
+
* unaffected, since existing ids are never overwritten.
|
|
19
|
+
*/
|
|
20
|
+
export function idNameOf(name) {
|
|
21
|
+
if (typeof name !== "string")
|
|
22
|
+
return "";
|
|
23
|
+
// an anchor's value is already marker-stripped and normalized
|
|
24
|
+
return parseAnchor(name)?.value ?? normalizeName(name);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The deterministic needle-id of a glTF element:
|
|
28
|
+
* `uuidv5("<type>-<idName>-<occurrence>[-<baseHash>]")`, where `idName` is the name with
|
|
29
|
+
* any anchor marker removed ({@link idNameOf}) and `occurrence` is the element's position
|
|
30
|
+
* among same-named elements of its type. Uniquely named elements are therefore fully
|
|
31
|
+
* reorder-stable across file versions; duplicates shift only when reordered within their
|
|
32
|
+
* own name group; unnamed elements are positional among the unnamed.
|
|
33
|
+
*
|
|
34
|
+
* `baseHash` is an optional consumer-supplied salt (e.g. an asset/content id) that scopes
|
|
35
|
+
* ids to an external identity. Omitted by default, so any tool stamping the same element
|
|
36
|
+
* mints the same id with no coordination.
|
|
16
37
|
*
|
|
17
38
|
* Ids are persisted in `extras["needle-id"]` and never overwritten once present —
|
|
18
39
|
* whoever stamps first wins; this rule only governs never-stamped elements.
|
|
19
40
|
*/
|
|
20
41
|
export function computeElementId(type, name, occurrence, baseHash) {
|
|
21
|
-
|
|
22
|
-
const str = anchor !== null
|
|
23
|
-
? `${type}-${anchor}`
|
|
24
|
-
: `${type}-${name ? name : ""}-${occurrence}${baseHash ? `-${baseHash}` : ""}`;
|
|
25
|
-
return uuidv5(str);
|
|
42
|
+
return uuidv5(`${type}-${idNameOf(name)}-${occurrence}${baseHash ? `-${baseHash}` : ""}`);
|
|
26
43
|
}
|
|
27
44
|
/**
|
|
28
45
|
* The occurrence of the element at `index` among same-named elements of its collection —
|
|
29
46
|
* the positional input of {@link computeElementId}. Exposed so consumers computing ids
|
|
30
47
|
* for individual elements (instead of stamping whole files) use the identical rule.
|
|
48
|
+
*
|
|
49
|
+
* Grouping is by {@link idNameOf}, so an anchored element counts in the same group as its
|
|
50
|
+
* unanchored namesakes.
|
|
31
51
|
*/
|
|
32
52
|
export function occurrenceOfName(elements, index) {
|
|
33
|
-
const name =
|
|
53
|
+
const name = idNameOf(elements[index]?.name);
|
|
34
54
|
let occurrence = 0;
|
|
35
55
|
for (let i = 0; i < index; i++) {
|
|
36
|
-
if (
|
|
56
|
+
if (idNameOf(elements[i]?.name) === name)
|
|
37
57
|
occurrence++;
|
|
38
58
|
}
|
|
39
59
|
return occurrence;
|
|
40
60
|
}
|
|
41
|
-
function normalizedName(element) {
|
|
42
|
-
return typeof element?.name === "string" ? element.name : "";
|
|
43
|
-
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { ANCHOR_MARKER, ANCHOR_MARKERS, parseAnchor, parseAnchorValue, type ParsedAnchor } from "./anchor.js";
|
|
2
2
|
export { computeElementId, occurrenceOfName } from "./id.js";
|
|
3
3
|
export { ensureIds, getElementId, NEEDLE_ID_KEY, STAMPED_COLLECTIONS, type EnsureIdsOptions, type EnsureIdsResult, type GltfElement, type GltfJson, type StampedElementType, } from "./gltf.js";
|
|
4
|
-
export { collectionKeyForType, createGltfElementAddress, createObjectAddress, findPath, type GraphNode, type NeedleAddress, } from "./address.js";
|
|
5
|
-
export { reconcileInGraph, resolveInGltf, resolveInGraph, type GltfResolveResult, type ReconcileCandidate, } from "./resolve.js";
|
|
4
|
+
export { collectionKeyForType, createGltfElementAddress, createObjectAddress, decodePathSegment, encodePathSegment, findPath, joinPath, splitPath, type GraphNode, type NeedleAddress, } from "./address.js";
|
|
5
|
+
export { reconcileInGraph, resolveInCollection, resolveInGltf, resolveInGraph, type AddressableElement, type GltfResolveResult, type ReconcileCandidate, type ResolutionIssue, type ResolutionRung, type ResolveOptions, } from "./resolve.js";
|
|
6
6
|
export { randomUuid, uuidv5, UUID_NAMESPACE_URL } from "./uuid.js";
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { ANCHOR_MARKER, ANCHOR_MARKERS, parseAnchor, parseAnchorValue } from "./anchor.js";
|
|
2
2
|
export { computeElementId, occurrenceOfName } from "./id.js";
|
|
3
3
|
export { ensureIds, getElementId, NEEDLE_ID_KEY, STAMPED_COLLECTIONS, } from "./gltf.js";
|
|
4
|
-
export { collectionKeyForType, createGltfElementAddress, createObjectAddress, findPath, } from "./address.js";
|
|
5
|
-
export { reconcileInGraph, resolveInGltf, resolveInGraph, } from "./resolve.js";
|
|
4
|
+
export { collectionKeyForType, createGltfElementAddress, createObjectAddress, decodePathSegment, encodePathSegment, findPath, joinPath, splitPath, } from "./address.js";
|
|
5
|
+
export { reconcileInGraph, resolveInCollection, resolveInGltf, resolveInGraph, } from "./resolve.js";
|
|
6
6
|
export { randomUuid, uuidv5, UUID_NAMESPACE_URL } from "./uuid.js";
|
package/dist/resolve.d.ts
CHANGED
|
@@ -6,16 +6,72 @@ export type GltfResolveResult = {
|
|
|
6
6
|
element: Record<string, unknown>;
|
|
7
7
|
};
|
|
8
8
|
/**
|
|
9
|
-
* Resolve an address inside glTF JSON.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* Resolve an address inside glTF JSON. Same ladder as the live graph —
|
|
10
|
+
* **anchor → id → path** (nodes) — with the anchor winning only when it matches
|
|
11
|
+
* exactly one element. Returns null when nothing matches exactly (reconciliation is a
|
|
12
|
+
* separate, explicit step — resolution never guesses).
|
|
12
13
|
*/
|
|
13
|
-
export declare function resolveInGltf(address: NeedleAddress, json: GltfJson): GltfResolveResult | null;
|
|
14
|
+
export declare function resolveInGltf(address: NeedleAddress, json: GltfJson, options?: ResolveOptions): GltfResolveResult | null;
|
|
15
|
+
/** which rung of the ladder produced (or could have produced) a match */
|
|
16
|
+
export type ResolutionRung = "id" | "anchor" | "path" | "name";
|
|
17
|
+
/**
|
|
18
|
+
* Something a caller may want to show a human. Never an error — resolution still returns
|
|
19
|
+
* its best answer; these say the answer was not the only defensible one.
|
|
20
|
+
*/
|
|
21
|
+
export type ResolutionIssue =
|
|
22
|
+
/** an anchor matched several elements, so it could not outrank an exact key */
|
|
23
|
+
{
|
|
24
|
+
kind: "ambiguous-anchor";
|
|
25
|
+
anchor: string;
|
|
26
|
+
count: number;
|
|
27
|
+
}
|
|
28
|
+
/** a name matched several elements in a flat collection */
|
|
29
|
+
| {
|
|
30
|
+
kind: "ambiguous-name";
|
|
31
|
+
name: string;
|
|
32
|
+
count: number;
|
|
33
|
+
}
|
|
34
|
+
/** two rungs resolved to DIFFERENT elements — the content changed underneath */
|
|
35
|
+
| {
|
|
36
|
+
kind: "conflict";
|
|
37
|
+
preferred: ResolutionRung;
|
|
38
|
+
other: ResolutionRung;
|
|
39
|
+
};
|
|
40
|
+
export type ResolveOptions = {
|
|
41
|
+
/**
|
|
42
|
+
* Collect ambiguity/conflict evidence. OPT-IN because it costs extra traversals:
|
|
43
|
+
* detecting that two rungs disagree means evaluating both instead of stopping at the
|
|
44
|
+
* first hit, and resolution already walks from the root per address. The runtime
|
|
45
|
+
* short-circuits and pays nothing; the editor asks for evidence so it can surface
|
|
46
|
+
* drift to the user.
|
|
47
|
+
*/
|
|
48
|
+
report?: (issue: ResolutionIssue) => void;
|
|
49
|
+
};
|
|
14
50
|
/**
|
|
15
51
|
* Resolve an address inside a live scene graph (duck-typed — any three.js).
|
|
16
|
-
*
|
|
52
|
+
*
|
|
53
|
+
* Ladder: **anchor → id → path**. The anchor comes first because it is a deliberate,
|
|
54
|
+
* human-authored declaration, while an id may be a derived value whose basis shifted when
|
|
55
|
+
* the content changed. It only wins when it resolves UNIQUELY: anchors are labels and may
|
|
56
|
+
* legally repeat, and an ambiguous label must never outrank an exact key.
|
|
57
|
+
*/
|
|
58
|
+
export declare function resolveInGraph(address: NeedleAddress, root: GraphNode, options?: ResolveOptions): GraphNode | null;
|
|
59
|
+
/** duck-typed view of an element that is not a graph node (a material, texture, …) */
|
|
60
|
+
export type AddressableElement = {
|
|
61
|
+
name?: string;
|
|
62
|
+
userData?: Record<string, unknown>;
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Resolve an address against a FLAT collection of live elements — materials, textures and
|
|
66
|
+
* anything else that carries a name and `userData` but is not part of the scene graph.
|
|
67
|
+
*
|
|
68
|
+
* Same ladder as the graph, minus `path` (there is no hierarchy to walk) plus `name`
|
|
69
|
+
* as the final rung, since a flat collection has no positional fallback. This exists so
|
|
70
|
+
* every element type resolves through ONE implementation: materials previously had their
|
|
71
|
+
* own ladders, could not be anchored at all, and disagreed with each other about whether a
|
|
72
|
+
* name match had to be unique.
|
|
17
73
|
*/
|
|
18
|
-
export declare function
|
|
74
|
+
export declare function resolveInCollection<T extends AddressableElement>(address: NeedleAddress, elements: readonly T[], options?: ResolveOptions): T | null;
|
|
19
75
|
export type ReconcileCandidate = {
|
|
20
76
|
object: GraphNode;
|
|
21
77
|
/** 0..1 — heuristic, for ranking and thresholds; 1.0 is reserved for exact resolution */
|
|
@@ -25,6 +81,6 @@ export type ReconcileCandidate = {
|
|
|
25
81
|
/**
|
|
26
82
|
* When exact resolution fails (the asset changed), propose candidates instead of
|
|
27
83
|
* guessing: nodes matching the address's name, ranked by how much of the recorded
|
|
28
|
-
*
|
|
84
|
+
* path still matches. Callers decide thresholds and whether to re-anchor.
|
|
29
85
|
*/
|
|
30
86
|
export declare function reconcileInGraph(address: NeedleAddress, root: GraphNode): ReconcileCandidate[];
|