@needle-tools/addressing 0.3.3 → 0.3.4
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 +2 -2
- package/dist/address.d.ts +19 -4
- package/dist/address.js +32 -12
- package/dist/gltf.d.ts +5 -28
- package/dist/gltf.js +5 -44
- package/dist/id.d.ts +60 -0
- package/dist/id.js +107 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.js +4 -3
- package/dist/resolve.d.ts +5 -5
- package/dist/resolve.js +22 -20
- package/dist/target.d.ts +29 -0
- package/dist/target.js +112 -0
- package/package.json +1 -1
- package/src/address.ts +46 -13
- package/src/gltf.ts +5 -63
- package/src/id.ts +141 -1
- package/src/index.ts +14 -2
- package/src/resolve.ts +24 -22
- package/src/target.ts +126 -0
package/Readme.md
CHANGED
|
@@ -29,11 +29,11 @@ identity (e.g. a stable asset id), scoping them to that identity.
|
|
|
29
29
|
## Addresses
|
|
30
30
|
|
|
31
31
|
An address captures every fallback at creation time, while the target is known —
|
|
32
|
-
`{ id, anchor?, type?, name?,
|
|
32
|
+
`{ id, anchor?, type?, name?, path? }`. Resolution order is strict (path: "/"-separated segments, JSON-Pointer `~0`/`~1` escaping):
|
|
33
33
|
|
|
34
34
|
1. `id` — exact match (`extras` in glTF JSON, `userData` in live scene graphs)
|
|
35
35
|
2. `anchor` — survives asset updates
|
|
36
|
-
3. `
|
|
36
|
+
3. `path` — best-effort structural match
|
|
37
37
|
4. **reconciliation** — never automatic: `reconcileInGraph` returns ranked candidates
|
|
38
38
|
with confidence + reasons; the caller decides thresholds and whether to re-anchor.
|
|
39
39
|
|
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
|
-
import { getElementId, NEEDLE_ID_KEY } from "./
|
|
3
|
-
|
|
2
|
+
import { getElementId, NEEDLE_ID_KEY } from "./id.js";
|
|
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))
|
|
@@ -17,23 +37,23 @@ export function createGltfElementAddress(json, type, index) {
|
|
|
17
37
|
if (anchor)
|
|
18
38
|
address.anchor = anchor;
|
|
19
39
|
// an empty name is non-information: resolution never reads `name` (id → anchor →
|
|
20
|
-
//
|
|
21
|
-
// NOTE: empty
|
|
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
|
|
22
42
|
// standalone `name` field is dropped when empty.
|
|
23
43
|
if (name)
|
|
24
44
|
address.name = name;
|
|
25
45
|
if (type === "node") {
|
|
26
|
-
const
|
|
27
|
-
if (
|
|
28
|
-
address.
|
|
46
|
+
const segments = gltfNodeNamePath(json, index);
|
|
47
|
+
if (segments)
|
|
48
|
+
address.path = joinPath(segments);
|
|
29
49
|
}
|
|
30
50
|
return address;
|
|
31
51
|
}
|
|
32
52
|
/** Build an address for a live scene-graph object under `root`. */
|
|
33
53
|
export function createObjectAddress(root, target) {
|
|
34
|
-
const
|
|
35
|
-
if (!
|
|
36
|
-
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
|
|
37
57
|
const address = { type: "node" };
|
|
38
58
|
const id = target.userData?.[NEEDLE_ID_KEY];
|
|
39
59
|
if (typeof id === "string")
|
|
@@ -41,10 +61,10 @@ export function createObjectAddress(root, target) {
|
|
|
41
61
|
const anchor = parseAnchorValue(target.name);
|
|
42
62
|
if (anchor)
|
|
43
63
|
address.anchor = anchor;
|
|
44
|
-
// empty name dropped — see createGltfElementAddress;
|
|
64
|
+
// empty name dropped — see createGltfElementAddress; path segments stay verbatim
|
|
45
65
|
if (target.name)
|
|
46
66
|
address.name = target.name;
|
|
47
|
-
address.
|
|
67
|
+
address.path = joinPath(nodes.map(n => n.name ?? ""));
|
|
48
68
|
return address;
|
|
49
69
|
}
|
|
50
70
|
export function collectionKeyForType(type) {
|
package/dist/gltf.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Structural glTF shapes — the duck-typed views of a glTF JSON root and its
|
|
3
|
+
* elements, plus which collections carry ids. All id READING and MINTING lives
|
|
4
|
+
* in `id.ts`; this module only describes the file format.
|
|
5
|
+
*/
|
|
3
6
|
/** All root-collection element types that receive ids, with their glTF collection key. */
|
|
4
7
|
export declare const STAMPED_COLLECTIONS: readonly [readonly ["node", "nodes"], readonly ["scene", "scenes"], readonly ["animation", "animations"], readonly ["camera", "cameras"], readonly ["material", "materials"], readonly ["mesh", "meshes"], readonly ["skin", "skins"], readonly ["texture", "textures"], readonly ["image", "images"], readonly ["sampler", "samplers"]];
|
|
5
8
|
export type StampedElementType = (typeof STAMPED_COLLECTIONS)[number][0];
|
|
@@ -13,29 +16,3 @@ export type GltfElement = {
|
|
|
13
16
|
export type GltfJson = {
|
|
14
17
|
[collection: string]: unknown;
|
|
15
18
|
};
|
|
16
|
-
export type EnsureIdsOptions = {
|
|
17
|
-
/**
|
|
18
|
-
* Optional salt scoping unanchored ids to an external identity (e.g. an
|
|
19
|
-
* asset/content id). By default no salt is used — ids derive from the element alone
|
|
20
|
-
* (type, name, occurrence-of-name), so any tool mints the same ids with no
|
|
21
|
-
* coordination. With a salt, only writers that know it agree — safe because
|
|
22
|
-
* existing ids are never overwritten (whoever stamps first wins).
|
|
23
|
-
*/
|
|
24
|
-
baseHash?: string;
|
|
25
|
-
};
|
|
26
|
-
export type EnsureIdsResult = {
|
|
27
|
-
/** the salt the ids were derived with, when one was provided */
|
|
28
|
-
baseHash?: string;
|
|
29
|
-
/** how many elements received a new id (0 → the file was already fully stamped) */
|
|
30
|
-
assigned: number;
|
|
31
|
-
};
|
|
32
|
-
/**
|
|
33
|
-
* Idempotently inject `extras["needle-id"]` into every element of the stamped
|
|
34
|
-
* collections. Existing ids are never overwritten, so already-stamped files pass
|
|
35
|
-
* through unchanged and every id writer agrees with whoever stamped first.
|
|
36
|
-
*
|
|
37
|
-
* Mutates `json` in place.
|
|
38
|
-
*/
|
|
39
|
-
export declare function ensureIds(json: GltfJson, options?: EnsureIdsOptions): EnsureIdsResult;
|
|
40
|
-
/** Read an element's needle-id without computing anything. */
|
|
41
|
-
export declare function getElementId(element: GltfElement | null | undefined): string | null;
|
package/dist/gltf.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Structural glTF shapes — the duck-typed views of a glTF JSON root and its
|
|
3
|
+
* elements, plus which collections carry ids. All id READING and MINTING lives
|
|
4
|
+
* in `id.ts`; this module only describes the file format.
|
|
5
|
+
*/
|
|
4
6
|
/** All root-collection element types that receive ids, with their glTF collection key. */
|
|
5
7
|
export const STAMPED_COLLECTIONS = [
|
|
6
8
|
["node", "nodes"],
|
|
@@ -14,44 +16,3 @@ export const STAMPED_COLLECTIONS = [
|
|
|
14
16
|
["image", "images"],
|
|
15
17
|
["sampler", "samplers"],
|
|
16
18
|
];
|
|
17
|
-
/**
|
|
18
|
-
* Idempotently inject `extras["needle-id"]` into every element of the stamped
|
|
19
|
-
* collections. Existing ids are never overwritten, so already-stamped files pass
|
|
20
|
-
* through unchanged and every id writer agrees with whoever stamped first.
|
|
21
|
-
*
|
|
22
|
-
* Mutates `json` in place.
|
|
23
|
-
*/
|
|
24
|
-
export function ensureIds(json, options) {
|
|
25
|
-
const baseHash = options?.baseHash;
|
|
26
|
-
let assigned = 0;
|
|
27
|
-
for (const [type, collection] of STAMPED_COLLECTIONS) {
|
|
28
|
-
const elements = json[collection];
|
|
29
|
-
if (!Array.isArray(elements))
|
|
30
|
-
continue;
|
|
31
|
-
// occurrence-of-name: the id input is the element's position among same-named
|
|
32
|
-
// elements, NOT its collection index — uniquely named elements keep their ids
|
|
33
|
-
// across reorders and asset updates. Grouping is by the DERIVATION name
|
|
34
|
-
// (anchor marker stripped, normalized), matching occurrenceOfName.
|
|
35
|
-
const occurrences = new Map();
|
|
36
|
-
for (let index = 0; index < elements.length; index++) {
|
|
37
|
-
const element = elements[index];
|
|
38
|
-
if (element == null || typeof element !== "object")
|
|
39
|
-
continue;
|
|
40
|
-
const name = typeof element.name === "string" ? element.name : "";
|
|
41
|
-
const group = idNameOf(name);
|
|
42
|
-
const occurrence = occurrences.get(group) ?? 0;
|
|
43
|
-
occurrences.set(group, occurrence + 1);
|
|
44
|
-
element.extras ?? (element.extras = {});
|
|
45
|
-
if (element.extras[NEEDLE_ID_KEY] === undefined) {
|
|
46
|
-
element.extras[NEEDLE_ID_KEY] = computeElementId(type, name, occurrence, baseHash);
|
|
47
|
-
assigned++;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
return { baseHash, assigned };
|
|
52
|
-
}
|
|
53
|
-
/** Read an element's needle-id without computing anything. */
|
|
54
|
-
export function getElementId(element) {
|
|
55
|
-
const id = element?.extras?.[NEEDLE_ID_KEY];
|
|
56
|
-
return typeof id === "string" ? id : null;
|
|
57
|
-
}
|
package/dist/id.d.ts
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
import { type GltfElement, type GltfJson } from "./gltf.js";
|
|
2
|
+
import type { GraphNode } from "./address.js";
|
|
3
|
+
/**
|
|
4
|
+
* THE identity module: every read and every mint of a needle-id lives here —
|
|
5
|
+
* derivation (deterministic ids for glTF elements), glTF stamping/readback
|
|
6
|
+
* (`extras`), and live-node stamping/readback (`userData`). Consumers never
|
|
7
|
+
* touch the key or mint ids themselves.
|
|
8
|
+
*/
|
|
9
|
+
/** The key carrying the persistent id — glTF `extras` for file elements, live
|
|
10
|
+
* `userData` for scene objects (exports copy one into the other). */
|
|
11
|
+
export declare const NEEDLE_ID_KEY = "needle-id";
|
|
1
12
|
/**
|
|
2
13
|
* The name an id is derived FROM: the anchor marker removed (if any) and space/underscore
|
|
3
14
|
* runs collapsed.
|
|
@@ -43,3 +54,52 @@ export declare function computeElementId(type: string, name: string | null | und
|
|
|
43
54
|
export declare function occurrenceOfName(elements: ReadonlyArray<{
|
|
44
55
|
name?: unknown;
|
|
45
56
|
} | null | undefined>, index: number): number;
|
|
57
|
+
export type EnsureIdsOptions = {
|
|
58
|
+
/**
|
|
59
|
+
* Optional salt scoping unanchored ids to an external identity (e.g. an
|
|
60
|
+
* asset/content id). By default no salt is used — ids derive from the element alone
|
|
61
|
+
* (type, name, occurrence-of-name), so any tool mints the same ids with no
|
|
62
|
+
* coordination. With a salt, only writers that know it agree — safe because
|
|
63
|
+
* existing ids are never overwritten (whoever stamps first wins).
|
|
64
|
+
*/
|
|
65
|
+
baseHash?: string;
|
|
66
|
+
};
|
|
67
|
+
export type EnsureIdsResult = {
|
|
68
|
+
/** the salt the ids were derived with, when one was provided */
|
|
69
|
+
baseHash?: string;
|
|
70
|
+
/** how many elements received a new id (0 → the file was already fully stamped) */
|
|
71
|
+
assigned: number;
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Idempotently inject `extras["needle-id"]` into every element of the stamped
|
|
75
|
+
* collections. Existing ids are never overwritten, so already-stamped files pass
|
|
76
|
+
* through unchanged and every id writer agrees with whoever stamped first.
|
|
77
|
+
*
|
|
78
|
+
* Mutates `json` in place.
|
|
79
|
+
*/
|
|
80
|
+
export declare function ensureIds(json: GltfJson, options?: EnsureIdsOptions): EnsureIdsResult;
|
|
81
|
+
/** Read an element's needle-id without computing anything. */
|
|
82
|
+
export declare function getElementId(element: GltfElement | null | undefined): string | null;
|
|
83
|
+
/** Read a live node's needle-id from `userData` — the live-object counterpart of
|
|
84
|
+
* {@link getElementId}, which reads glTF `extras`. */
|
|
85
|
+
export declare function getNodeId(target: GraphNode | null | undefined): string | null;
|
|
86
|
+
/** Ensure a live node carries a needle-id: returns the existing one or MINTS a
|
|
87
|
+
* random uuid into `userData`. Random by design — editor-created (scratch) content
|
|
88
|
+
* has no derivation basis; the id persists because exports write `userData` ids
|
|
89
|
+
* into glTF `extras` (create → save → reload sees the same id). */
|
|
90
|
+
export declare function ensureNodeId(target: GraphNode): string;
|
|
91
|
+
export type ShedIdentityOptions = {
|
|
92
|
+
/** additional identity keys deleted from every `userData.components` entry —
|
|
93
|
+
* the caller names its component-identity keys (e.g. an engine's editor-guid
|
|
94
|
+
* key); addressing owns the traversal and the needle-id rule */
|
|
95
|
+
componentKeys?: readonly string[];
|
|
96
|
+
};
|
|
97
|
+
/**
|
|
98
|
+
* Shed a subtree's AUTHORED identity — for clones (duplicate/paste) before they are
|
|
99
|
+
* re-stamped. Stamped needle-ids are deleted on every node: sibling id collisions
|
|
100
|
+
* break addressing (an id-addressed edit resolves to whichever holder comes first).
|
|
101
|
+
* With `componentKeys`, the same keys are deleted from `userData.components`
|
|
102
|
+
* entries (a copied component guid would bake the ORIGINAL's identity into the
|
|
103
|
+
* clone's next export). Fresh ids are minted by whatever stamps next.
|
|
104
|
+
*/
|
|
105
|
+
export declare function shedNodeIdentity(root: GraphNode, options?: ShedIdentityOptions): void;
|
package/dist/id.js
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import { normalizeName, parseAnchor } from "./anchor.js";
|
|
2
|
-
import {
|
|
2
|
+
import { STAMPED_COLLECTIONS } from "./gltf.js";
|
|
3
|
+
import { randomUuid, uuidv5 } from "./uuid.js";
|
|
4
|
+
/**
|
|
5
|
+
* THE identity module: every read and every mint of a needle-id lives here —
|
|
6
|
+
* derivation (deterministic ids for glTF elements), glTF stamping/readback
|
|
7
|
+
* (`extras`), and live-node stamping/readback (`userData`). Consumers never
|
|
8
|
+
* touch the key or mint ids themselves.
|
|
9
|
+
*/
|
|
10
|
+
/** The key carrying the persistent id — glTF `extras` for file elements, live
|
|
11
|
+
* `userData` for scene objects (exports copy one into the other). */
|
|
12
|
+
export const NEEDLE_ID_KEY = "needle-id";
|
|
13
|
+
// #region Derivation
|
|
3
14
|
/**
|
|
4
15
|
* The name an id is derived FROM: the anchor marker removed (if any) and space/underscore
|
|
5
16
|
* runs collapsed.
|
|
@@ -58,3 +69,98 @@ export function occurrenceOfName(elements, index) {
|
|
|
58
69
|
}
|
|
59
70
|
return occurrence;
|
|
60
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Idempotently inject `extras["needle-id"]` into every element of the stamped
|
|
74
|
+
* collections. Existing ids are never overwritten, so already-stamped files pass
|
|
75
|
+
* through unchanged and every id writer agrees with whoever stamped first.
|
|
76
|
+
*
|
|
77
|
+
* Mutates `json` in place.
|
|
78
|
+
*/
|
|
79
|
+
export function ensureIds(json, options) {
|
|
80
|
+
const baseHash = options?.baseHash;
|
|
81
|
+
let assigned = 0;
|
|
82
|
+
for (const [type, collection] of STAMPED_COLLECTIONS) {
|
|
83
|
+
const elements = json[collection];
|
|
84
|
+
if (!Array.isArray(elements))
|
|
85
|
+
continue;
|
|
86
|
+
// occurrence-of-name: the id input is the element's position among same-named
|
|
87
|
+
// elements, NOT its collection index — uniquely named elements keep their ids
|
|
88
|
+
// across reorders and asset updates. Grouping is by the DERIVATION name
|
|
89
|
+
// (anchor marker stripped, normalized), matching occurrenceOfName.
|
|
90
|
+
const occurrences = new Map();
|
|
91
|
+
for (let index = 0; index < elements.length; index++) {
|
|
92
|
+
const element = elements[index];
|
|
93
|
+
if (element == null || typeof element !== "object")
|
|
94
|
+
continue;
|
|
95
|
+
const name = typeof element.name === "string" ? element.name : "";
|
|
96
|
+
const group = idNameOf(name);
|
|
97
|
+
const occurrence = occurrences.get(group) ?? 0;
|
|
98
|
+
occurrences.set(group, occurrence + 1);
|
|
99
|
+
element.extras ?? (element.extras = {});
|
|
100
|
+
if (element.extras[NEEDLE_ID_KEY] === undefined) {
|
|
101
|
+
element.extras[NEEDLE_ID_KEY] = computeElementId(type, name, occurrence, baseHash);
|
|
102
|
+
assigned++;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return { baseHash, assigned };
|
|
107
|
+
}
|
|
108
|
+
/** Read an element's needle-id without computing anything. */
|
|
109
|
+
export function getElementId(element) {
|
|
110
|
+
const id = element?.extras?.[NEEDLE_ID_KEY];
|
|
111
|
+
return typeof id === "string" ? id : null;
|
|
112
|
+
}
|
|
113
|
+
// #endregion
|
|
114
|
+
// #region Live nodes (userData)
|
|
115
|
+
/** Read a live node's needle-id from `userData` — the live-object counterpart of
|
|
116
|
+
* {@link getElementId}, which reads glTF `extras`. */
|
|
117
|
+
export function getNodeId(target) {
|
|
118
|
+
const id = target?.userData?.[NEEDLE_ID_KEY];
|
|
119
|
+
return typeof id === "string" ? id : null;
|
|
120
|
+
}
|
|
121
|
+
/** Ensure a live node carries a needle-id: returns the existing one or MINTS a
|
|
122
|
+
* random uuid into `userData`. Random by design — editor-created (scratch) content
|
|
123
|
+
* has no derivation basis; the id persists because exports write `userData` ids
|
|
124
|
+
* into glTF `extras` (create → save → reload sees the same id). */
|
|
125
|
+
export function ensureNodeId(target) {
|
|
126
|
+
const userData = (target.userData ?? (target.userData = {}));
|
|
127
|
+
const existing = userData[NEEDLE_ID_KEY];
|
|
128
|
+
if (typeof existing === "string")
|
|
129
|
+
return existing;
|
|
130
|
+
const id = randomUuid();
|
|
131
|
+
userData[NEEDLE_ID_KEY] = id;
|
|
132
|
+
return id;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Shed a subtree's AUTHORED identity — for clones (duplicate/paste) before they are
|
|
136
|
+
* re-stamped. Stamped needle-ids are deleted on every node: sibling id collisions
|
|
137
|
+
* break addressing (an id-addressed edit resolves to whichever holder comes first).
|
|
138
|
+
* With `componentKeys`, the same keys are deleted from `userData.components`
|
|
139
|
+
* entries (a copied component guid would bake the ORIGINAL's identity into the
|
|
140
|
+
* clone's next export). Fresh ids are minted by whatever stamps next.
|
|
141
|
+
*/
|
|
142
|
+
export function shedNodeIdentity(root, options) {
|
|
143
|
+
const componentKeys = options?.componentKeys ?? [];
|
|
144
|
+
const visit = (node) => {
|
|
145
|
+
const userData = node.userData;
|
|
146
|
+
if (userData) {
|
|
147
|
+
if (typeof userData[NEEDLE_ID_KEY] === "string")
|
|
148
|
+
delete userData[NEEDLE_ID_KEY];
|
|
149
|
+
const components = userData["components"];
|
|
150
|
+
if (Array.isArray(components)) {
|
|
151
|
+
for (const component of components) {
|
|
152
|
+
if (!component || typeof component !== "object")
|
|
153
|
+
continue;
|
|
154
|
+
for (const key of componentKeys) {
|
|
155
|
+
if (component[key] !== undefined)
|
|
156
|
+
delete component[key];
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
for (const child of node.children ?? [])
|
|
162
|
+
visit(child);
|
|
163
|
+
};
|
|
164
|
+
visit(root);
|
|
165
|
+
}
|
|
166
|
+
// #endregion
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { ANCHOR_MARKER, ANCHOR_MARKERS, parseAnchor, parseAnchorValue, type ParsedAnchor } from "./anchor.js";
|
|
2
|
-
export { computeElementId, occurrenceOfName } from "./id.js";
|
|
3
|
-
export {
|
|
4
|
-
export { collectionKeyForType, createGltfElementAddress, createObjectAddress, findPath, type GraphNode, type NeedleAddress, } from "./address.js";
|
|
2
|
+
export { computeElementId, ensureIds, ensureNodeId, getElementId, getNodeId, NEEDLE_ID_KEY, occurrenceOfName, shedNodeIdentity, type EnsureIdsOptions, type EnsureIdsResult, type ShedIdentityOptions, } from "./id.js";
|
|
3
|
+
export { STAMPED_COLLECTIONS, type GltfElement, type GltfJson, type StampedElementType, } from "./gltf.js";
|
|
4
|
+
export { collectionKeyForType, createGltfElementAddress, createObjectAddress, decodePathSegment, encodePathSegment, findPath, joinPath, splitPath, type GraphNode, type NeedleAddress, } from "./address.js";
|
|
5
5
|
export { reconcileInGraph, resolveInCollection, resolveInGltf, resolveInGraph, type AddressableElement, type GltfResolveResult, type ReconcileCandidate, type ResolutionIssue, type ResolutionRung, type ResolveOptions, } from "./resolve.js";
|
|
6
|
+
export { targetFromString, targetToString, type AddressTarget } from "./target.js";
|
|
6
7
|
export { randomUuid, uuidv5, UUID_NAMESPACE_URL } from "./uuid.js";
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { ANCHOR_MARKER, ANCHOR_MARKERS, parseAnchor, parseAnchorValue } from "./anchor.js";
|
|
2
|
-
export { computeElementId, occurrenceOfName } from "./id.js";
|
|
3
|
-
export {
|
|
4
|
-
export { collectionKeyForType, createGltfElementAddress, createObjectAddress, findPath, } from "./address.js";
|
|
2
|
+
export { computeElementId, ensureIds, ensureNodeId, getElementId, getNodeId, NEEDLE_ID_KEY, occurrenceOfName, shedNodeIdentity, } from "./id.js";
|
|
3
|
+
export { STAMPED_COLLECTIONS, } from "./gltf.js";
|
|
4
|
+
export { collectionKeyForType, createGltfElementAddress, createObjectAddress, decodePathSegment, encodePathSegment, findPath, joinPath, splitPath, } from "./address.js";
|
|
5
5
|
export { reconcileInGraph, resolveInCollection, resolveInGltf, resolveInGraph, } from "./resolve.js";
|
|
6
|
+
export { targetFromString, targetToString } from "./target.js";
|
|
6
7
|
export { randomUuid, uuidv5, UUID_NAMESPACE_URL } from "./uuid.js";
|
package/dist/resolve.d.ts
CHANGED
|
@@ -7,13 +7,13 @@ export type GltfResolveResult = {
|
|
|
7
7
|
};
|
|
8
8
|
/**
|
|
9
9
|
* Resolve an address inside glTF JSON. Same ladder as the live graph —
|
|
10
|
-
* **anchor → id →
|
|
10
|
+
* **anchor → id → path** (nodes) — with the anchor winning only when it matches
|
|
11
11
|
* exactly one element. Returns null when nothing matches exactly (reconciliation is a
|
|
12
12
|
* separate, explicit step — resolution never guesses).
|
|
13
13
|
*/
|
|
14
14
|
export declare function resolveInGltf(address: NeedleAddress, json: GltfJson, options?: ResolveOptions): GltfResolveResult | null;
|
|
15
15
|
/** which rung of the ladder produced (or could have produced) a match */
|
|
16
|
-
export type ResolutionRung = "id" | "anchor" | "
|
|
16
|
+
export type ResolutionRung = "id" | "anchor" | "path" | "name";
|
|
17
17
|
/**
|
|
18
18
|
* Something a caller may want to show a human. Never an error — resolution still returns
|
|
19
19
|
* its best answer; these say the answer was not the only defensible one.
|
|
@@ -50,7 +50,7 @@ export type ResolveOptions = {
|
|
|
50
50
|
/**
|
|
51
51
|
* Resolve an address inside a live scene graph (duck-typed — any three.js).
|
|
52
52
|
*
|
|
53
|
-
* Ladder: **anchor → id →
|
|
53
|
+
* Ladder: **anchor → id → path**. The anchor comes first because it is a deliberate,
|
|
54
54
|
* human-authored declaration, while an id may be a derived value whose basis shifted when
|
|
55
55
|
* the content changed. It only wins when it resolves UNIQUELY: anchors are labels and may
|
|
56
56
|
* legally repeat, and an ambiguous label must never outrank an exact key.
|
|
@@ -65,7 +65,7 @@ export type AddressableElement = {
|
|
|
65
65
|
* Resolve an address against a FLAT collection of live elements — materials, textures and
|
|
66
66
|
* anything else that carries a name and `userData` but is not part of the scene graph.
|
|
67
67
|
*
|
|
68
|
-
* Same ladder as the graph, minus `
|
|
68
|
+
* Same ladder as the graph, minus `path` (there is no hierarchy to walk) plus `name`
|
|
69
69
|
* as the final rung, since a flat collection has no positional fallback. This exists so
|
|
70
70
|
* every element type resolves through ONE implementation: materials previously had their
|
|
71
71
|
* own ladders, could not be anchored at all, and disagreed with each other about whether a
|
|
@@ -81,6 +81,6 @@ export type ReconcileCandidate = {
|
|
|
81
81
|
/**
|
|
82
82
|
* When exact resolution fails (the asset changed), propose candidates instead of
|
|
83
83
|
* guessing: nodes matching the address's name, ranked by how much of the recorded
|
|
84
|
-
*
|
|
84
|
+
* path still matches. Callers decide thresholds and whether to re-anchor.
|
|
85
85
|
*/
|
|
86
86
|
export declare function reconcileInGraph(address: NeedleAddress, root: GraphNode): ReconcileCandidate[];
|
package/dist/resolve.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { parseAnchorValue } from "./anchor.js";
|
|
2
|
-
import { collectionKeyForType } from "./address.js";
|
|
3
|
-
import { NEEDLE_ID_KEY
|
|
2
|
+
import { collectionKeyForType, splitPath } from "./address.js";
|
|
3
|
+
import { NEEDLE_ID_KEY } from "./id.js";
|
|
4
|
+
import { STAMPED_COLLECTIONS } from "./gltf.js";
|
|
4
5
|
/**
|
|
5
6
|
* Resolve an address inside glTF JSON. Same ladder as the live graph —
|
|
6
|
-
* **anchor → id →
|
|
7
|
+
* **anchor → id → path** (nodes) — with the anchor winning only when it matches
|
|
7
8
|
* exactly one element. Returns null when nothing matches exactly (reconciliation is a
|
|
8
9
|
* separate, explicit step — resolution never guesses).
|
|
9
10
|
*/
|
|
@@ -49,8 +50,8 @@ export function resolveInGltf(address, json, options) {
|
|
|
49
50
|
}
|
|
50
51
|
if (byId)
|
|
51
52
|
return byId;
|
|
52
|
-
if (address.
|
|
53
|
-
const index = walkGltfNamePath(json, address.
|
|
53
|
+
if (address.path !== undefined && (!address.type || address.type === "node")) {
|
|
54
|
+
const index = walkGltfNamePath(json, splitPath(address.path));
|
|
54
55
|
if (index !== null) {
|
|
55
56
|
const nodes = json["nodes"];
|
|
56
57
|
return { type: "node", index, element: nodes[index] };
|
|
@@ -61,7 +62,7 @@ export function resolveInGltf(address, json, options) {
|
|
|
61
62
|
/**
|
|
62
63
|
* Resolve an address inside a live scene graph (duck-typed — any three.js).
|
|
63
64
|
*
|
|
64
|
-
* Ladder: **anchor → id →
|
|
65
|
+
* Ladder: **anchor → id → path**. The anchor comes first because it is a deliberate,
|
|
65
66
|
* human-authored declaration, while an id may be a derived value whose basis shifted when
|
|
66
67
|
* the content changed. It only wins when it resolves UNIQUELY: anchors are labels and may
|
|
67
68
|
* legally repeat, and an ambiguous label must never outrank an exact key.
|
|
@@ -91,9 +92,9 @@ export function resolveInGraph(address, root, options) {
|
|
|
91
92
|
}
|
|
92
93
|
if (byId)
|
|
93
94
|
return byId;
|
|
94
|
-
if (address.
|
|
95
|
+
if (address.path !== undefined) {
|
|
95
96
|
let current = root;
|
|
96
|
-
for (const segment of address.
|
|
97
|
+
for (const segment of splitPath(address.path)) {
|
|
97
98
|
current = current.children?.find(c => (c.name ?? "") === segment) ?? null;
|
|
98
99
|
if (!current)
|
|
99
100
|
break;
|
|
@@ -107,7 +108,7 @@ export function resolveInGraph(address, root, options) {
|
|
|
107
108
|
* Resolve an address against a FLAT collection of live elements — materials, textures and
|
|
108
109
|
* anything else that carries a name and `userData` but is not part of the scene graph.
|
|
109
110
|
*
|
|
110
|
-
* Same ladder as the graph, minus `
|
|
111
|
+
* Same ladder as the graph, minus `path` (there is no hierarchy to walk) plus `name`
|
|
111
112
|
* as the final rung, since a flat collection has no positional fallback. This exists so
|
|
112
113
|
* every element type resolves through ONE implementation: materials previously had their
|
|
113
114
|
* own ladders, could not be anchored at all, and disagreed with each other about whether a
|
|
@@ -145,20 +146,21 @@ export function resolveInCollection(address, elements, options) {
|
|
|
145
146
|
/**
|
|
146
147
|
* When exact resolution fails (the asset changed), propose candidates instead of
|
|
147
148
|
* guessing: nodes matching the address's name, ranked by how much of the recorded
|
|
148
|
-
*
|
|
149
|
+
* path still matches. Callers decide thresholds and whether to re-anchor.
|
|
149
150
|
*/
|
|
150
151
|
export function reconcileInGraph(address, root) {
|
|
151
|
-
|
|
152
|
+
const addressSegments = address.path !== undefined ? splitPath(address.path) : null;
|
|
153
|
+
if (!address.name && !addressSegments?.length)
|
|
152
154
|
return [];
|
|
153
|
-
const targetName = address.name ??
|
|
155
|
+
const targetName = address.name ?? addressSegments[addressSegments.length - 1];
|
|
154
156
|
const candidates = [];
|
|
155
|
-
collectDescendants(root, (node,
|
|
157
|
+
collectDescendants(root, (node, segments) => {
|
|
156
158
|
if ((node.name ?? "") !== targetName)
|
|
157
159
|
return;
|
|
158
160
|
const reasons = [`name matches "${targetName}"`];
|
|
159
161
|
let confidence = 0.5;
|
|
160
|
-
if (
|
|
161
|
-
const similarity = pathSuffixSimilarity(
|
|
162
|
+
if (addressSegments?.length) {
|
|
163
|
+
const similarity = pathSuffixSimilarity(addressSegments, segments);
|
|
162
164
|
confidence += 0.4 * similarity;
|
|
163
165
|
if (similarity > 0)
|
|
164
166
|
reasons.push(`path suffix similarity ${(similarity * 100).toFixed(0)}%`);
|
|
@@ -197,7 +199,7 @@ function findInCollection(json, key, predicate) {
|
|
|
197
199
|
}
|
|
198
200
|
return null;
|
|
199
201
|
}
|
|
200
|
-
function walkGltfNamePath(json,
|
|
202
|
+
function walkGltfNamePath(json, segments) {
|
|
201
203
|
const nodes = json["nodes"];
|
|
202
204
|
const scenes = json["scenes"];
|
|
203
205
|
if (!Array.isArray(nodes) || !Array.isArray(scenes))
|
|
@@ -205,7 +207,7 @@ function walkGltfNamePath(json, namePath) {
|
|
|
205
207
|
// roots: the union of all scenes' root nodes
|
|
206
208
|
let candidates = scenes.flatMap(s => (Array.isArray(s?.nodes) ? s.nodes : []));
|
|
207
209
|
let match = null;
|
|
208
|
-
for (const segment of
|
|
210
|
+
for (const segment of segments) {
|
|
209
211
|
match = candidates.find(i => ((nodes[i]?.name ?? "") === segment)) ?? null;
|
|
210
212
|
if (match === null)
|
|
211
213
|
return null;
|
|
@@ -259,19 +261,19 @@ function findInGraph(root, predicate, visited = new Set()) {
|
|
|
259
261
|
/** visit every descendant of root with its root-exclusive name path (including own name) */
|
|
260
262
|
function collectDescendants(root, visit) {
|
|
261
263
|
const visited = new Set();
|
|
262
|
-
const walk = (node,
|
|
264
|
+
const walk = (node, segments) => {
|
|
263
265
|
if (visited.has(node))
|
|
264
266
|
return;
|
|
265
267
|
visited.add(node);
|
|
266
268
|
if (node !== root)
|
|
267
|
-
visit(node,
|
|
269
|
+
visit(node, segments);
|
|
268
270
|
if (!node.children)
|
|
269
271
|
return;
|
|
270
272
|
for (let i = 0; i < node.children.length; i++) {
|
|
271
273
|
const child = node.children[i];
|
|
272
274
|
if (child == null)
|
|
273
275
|
continue;
|
|
274
|
-
walk(child, [...
|
|
276
|
+
walk(child, [...segments, child.name ?? ""]);
|
|
275
277
|
}
|
|
276
278
|
};
|
|
277
279
|
walk(root, []);
|
package/dist/target.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { NeedleAddress } from "./address.js";
|
|
2
|
+
/**
|
|
3
|
+
* The canonical STRING form of a document target — the ONE print/parse pair
|
|
4
|
+
* for `{ ref?, address? }` (plans/one-address-vocabulary.md: one vocabulary;
|
|
5
|
+
* this is a second wire FORM of it, never a second dialect — no other code
|
|
6
|
+
* may format or parse these tokens).
|
|
7
|
+
*
|
|
8
|
+
* Grammar (rungs optional, fixed order):
|
|
9
|
+
*
|
|
10
|
+
* "@" [ref] [ "/" [type ":"] [id] ["#" anchor] ["~" name] ["!" path] ]
|
|
11
|
+
*
|
|
12
|
+
* - `@` leads every token — the RETIRED session grammars ("<uuid>/<path>"
|
|
13
|
+
* handles, bare hierarchy ids) can never half-parse as a target; a stale
|
|
14
|
+
* token fails loud.
|
|
15
|
+
* - the `node` type is implicit (omitted on print, re-minted on parse) —
|
|
16
|
+
* the canonical form normalizes a type-less address to `type: "node"`.
|
|
17
|
+
* - ref/id/anchor/name are token-escaped; the PATH rung is last and
|
|
18
|
+
* verbatim (it is already the stored escaped form from joinPath and may
|
|
19
|
+
* contain any character).
|
|
20
|
+
*/
|
|
21
|
+
export type AddressTarget = {
|
|
22
|
+
ref?: string;
|
|
23
|
+
address?: NeedleAddress;
|
|
24
|
+
};
|
|
25
|
+
/** print a target as its canonical token — null when there is nothing to name */
|
|
26
|
+
export declare function targetToString(target: AddressTarget): string | null;
|
|
27
|
+
/** parse a canonical token back to the target — null for anything this module
|
|
28
|
+
* did not print (retired grammars, garbage): a loud miss, never a half-parse */
|
|
29
|
+
export declare function targetFromString(token: string): AddressTarget | null;
|