@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/dist/target.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// the six characters the grammar reserves (+ "%" as the escape lead) — encoded
|
|
2
|
+
// with %xx so decodeURIComponent inverts; everything else stays raw and human-
|
|
3
|
+
// readable ("$scene" must print as "$scene", not "%24scene")
|
|
4
|
+
function encodeToken(value) {
|
|
5
|
+
return value
|
|
6
|
+
.replace(/%/g, "%25")
|
|
7
|
+
.replace(/\//g, "%2F")
|
|
8
|
+
.replace(/:/g, "%3A")
|
|
9
|
+
.replace(/#/g, "%23")
|
|
10
|
+
.replace(/~/g, "%7E")
|
|
11
|
+
.replace(/!/g, "%21")
|
|
12
|
+
.replace(/@/g, "%40");
|
|
13
|
+
}
|
|
14
|
+
function decodeToken(value) {
|
|
15
|
+
try {
|
|
16
|
+
return decodeURIComponent(value);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return null; // a lone "%" — not a token we printed
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** print a target as its canonical token — null when there is nothing to name */
|
|
23
|
+
export function targetToString(target) {
|
|
24
|
+
const address = target.address;
|
|
25
|
+
const hasAddress = !!address && (address.id !== undefined || address.anchor !== undefined || address.name !== undefined || address.path !== undefined || address.type !== undefined);
|
|
26
|
+
if (!target.ref && !hasAddress)
|
|
27
|
+
return null;
|
|
28
|
+
let token = `@${target.ref ? encodeToken(target.ref) : ""}`;
|
|
29
|
+
if (hasAddress) {
|
|
30
|
+
token += "/";
|
|
31
|
+
const type = address.type ?? "node";
|
|
32
|
+
if (type !== "node")
|
|
33
|
+
token += `${encodeToken(type)}:`;
|
|
34
|
+
if (address.id !== undefined)
|
|
35
|
+
token += encodeToken(address.id);
|
|
36
|
+
if (address.anchor !== undefined)
|
|
37
|
+
token += `#${encodeToken(address.anchor)}`;
|
|
38
|
+
if (address.name !== undefined)
|
|
39
|
+
token += `~${encodeToken(address.name)}`;
|
|
40
|
+
if (address.path !== undefined)
|
|
41
|
+
token += `!${address.path}`;
|
|
42
|
+
}
|
|
43
|
+
return token;
|
|
44
|
+
}
|
|
45
|
+
/** parse a canonical token back to the target — null for anything this module
|
|
46
|
+
* did not print (retired grammars, garbage): a loud miss, never a half-parse */
|
|
47
|
+
export function targetFromString(token) {
|
|
48
|
+
if (typeof token !== "string" || !token.startsWith("@") || token.length < 2)
|
|
49
|
+
return null;
|
|
50
|
+
const body = token.slice(1);
|
|
51
|
+
const slash = body.indexOf("/");
|
|
52
|
+
const refRaw = slash === -1 ? body : body.slice(0, slash);
|
|
53
|
+
const addrRaw = slash === -1 ? undefined : body.slice(slash + 1);
|
|
54
|
+
const ref = refRaw.length > 0 ? decodeToken(refRaw) : undefined;
|
|
55
|
+
if (refRaw.length > 0 && ref === null)
|
|
56
|
+
return null;
|
|
57
|
+
if (addrRaw === undefined || addrRaw.length === 0) {
|
|
58
|
+
return ref ? { ref } : null;
|
|
59
|
+
}
|
|
60
|
+
// consume rungs back to front: path (verbatim remainder), name, anchor
|
|
61
|
+
let rest = addrRaw;
|
|
62
|
+
let path;
|
|
63
|
+
const bang = rest.indexOf("!");
|
|
64
|
+
if (bang !== -1) {
|
|
65
|
+
path = rest.slice(bang + 1);
|
|
66
|
+
rest = rest.slice(0, bang);
|
|
67
|
+
}
|
|
68
|
+
let name;
|
|
69
|
+
const tilde = rest.indexOf("~");
|
|
70
|
+
if (tilde !== -1) {
|
|
71
|
+
const decoded = decodeToken(rest.slice(tilde + 1));
|
|
72
|
+
if (decoded === null)
|
|
73
|
+
return null;
|
|
74
|
+
name = decoded;
|
|
75
|
+
rest = rest.slice(0, tilde);
|
|
76
|
+
}
|
|
77
|
+
let anchor;
|
|
78
|
+
const hash = rest.indexOf("#");
|
|
79
|
+
if (hash !== -1) {
|
|
80
|
+
const decoded = decodeToken(rest.slice(hash + 1));
|
|
81
|
+
if (decoded === null)
|
|
82
|
+
return null;
|
|
83
|
+
anchor = decoded;
|
|
84
|
+
rest = rest.slice(0, hash);
|
|
85
|
+
}
|
|
86
|
+
let type = "node";
|
|
87
|
+
const colon = rest.indexOf(":");
|
|
88
|
+
if (colon !== -1) {
|
|
89
|
+
const decoded = decodeToken(rest.slice(0, colon));
|
|
90
|
+
if (decoded === null || decoded.length === 0)
|
|
91
|
+
return null;
|
|
92
|
+
type = decoded;
|
|
93
|
+
rest = rest.slice(colon + 1);
|
|
94
|
+
}
|
|
95
|
+
let id;
|
|
96
|
+
if (rest.length > 0) {
|
|
97
|
+
const decoded = decodeToken(rest);
|
|
98
|
+
if (decoded === null)
|
|
99
|
+
return null;
|
|
100
|
+
id = decoded;
|
|
101
|
+
}
|
|
102
|
+
const address = { type };
|
|
103
|
+
if (id !== undefined)
|
|
104
|
+
address.id = id;
|
|
105
|
+
if (anchor !== undefined)
|
|
106
|
+
address.anchor = anchor;
|
|
107
|
+
if (name !== undefined)
|
|
108
|
+
address.name = name;
|
|
109
|
+
if (path !== undefined)
|
|
110
|
+
address.path = path;
|
|
111
|
+
return ref ? { ref, address } : { address };
|
|
112
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@needle-tools/addressing",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
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",
|
package/src/address.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { parseAnchorValue } from "./anchor.js";
|
|
2
|
-
import { getElementId, NEEDLE_ID_KEY
|
|
2
|
+
import { getElementId, NEEDLE_ID_KEY } from "./id.js";
|
|
3
|
+
import { type GltfJson, type StampedElementType } from "./gltf.js";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* A persistent pointer to an element inside an asset — the "fat address": every
|
|
6
7
|
* fallback the resolver may need is captured at CREATION time, while the target is
|
|
7
8
|
* known, instead of being reconstructed later.
|
|
8
9
|
*
|
|
9
|
-
* Resolution order: `id` (exact) → `anchor` (survives asset updates) → `
|
|
10
|
+
* Resolution order: `id` (exact) → `anchor` (survives asset updates) → `path`
|
|
10
11
|
* (best-effort) → reconciliation (candidates + confidence, never a silent guess).
|
|
11
12
|
*/
|
|
12
13
|
export type NeedleAddress = {
|
|
@@ -18,10 +19,42 @@ export type NeedleAddress = {
|
|
|
18
19
|
type?: StampedElementType | (string & {});
|
|
19
20
|
/** the target's name at address-creation time */
|
|
20
21
|
name?: string;
|
|
21
|
-
/**
|
|
22
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Node names from the asset root (exclusive) down to the target (inclusive);
|
|
24
|
+
* nodes only. ONE path syntax everywhere (2026-08-03): `/`-separated segments
|
|
25
|
+
* with JSON-Pointer escaping (`~0` = `~`, `~1` = `/`) — see {@link joinPath} /
|
|
26
|
+
* {@link splitPath}. Empty SEGMENTS are load-bearing (positional walk over
|
|
27
|
+
* unnamed nodes), so `""` is a valid one-segment path — test `!== undefined`,
|
|
28
|
+
* never truthiness. ABSENT means "no path rung".
|
|
29
|
+
*/
|
|
30
|
+
path?: string;
|
|
23
31
|
};
|
|
24
32
|
|
|
33
|
+
// #region path segment syntax
|
|
34
|
+
// The one path syntax of the project format (target.path uses the same rules):
|
|
35
|
+
// "/" separates segments; a literal "~" in a name is written "~0", a literal "/"
|
|
36
|
+
// is written "~1" (JSON-Pointer, RFC 6901). Escape order matters on decode.
|
|
37
|
+
|
|
38
|
+
/** escape one segment for embedding in a path string */
|
|
39
|
+
export function encodePathSegment(name: string): string {
|
|
40
|
+
return name.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** decode one path segment back to the literal name */
|
|
44
|
+
export function decodePathSegment(segment: string): string {
|
|
45
|
+
return segment.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** join literal segment names into an escaped path string */
|
|
49
|
+
export function joinPath(segments: readonly string[]): string {
|
|
50
|
+
return segments.map(encodePathSegment).join("/");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** split an escaped path string into literal segment names ("" → one empty segment) */
|
|
54
|
+
export function splitPath(path: string): string[] {
|
|
55
|
+
return path.split("/").map(decodePathSegment);
|
|
56
|
+
}
|
|
57
|
+
|
|
25
58
|
/** Structural (duck-typed) view of a live scene-graph node — works with any three.js. */
|
|
26
59
|
export type GraphNode = {
|
|
27
60
|
name?: string;
|
|
@@ -29,7 +62,7 @@ export type GraphNode = {
|
|
|
29
62
|
userData?: Record<string, unknown>;
|
|
30
63
|
};
|
|
31
64
|
|
|
32
|
-
/** Build an address for a glTF root-collection element. `
|
|
65
|
+
/** Build an address for a glTF root-collection element. `path` only applies to nodes. */
|
|
33
66
|
export function createGltfElementAddress(json: GltfJson, type: StampedElementType | string, index: number): NeedleAddress | null {
|
|
34
67
|
const collection = json[collectionKeyForType(type)];
|
|
35
68
|
if (!Array.isArray(collection)) return null;
|
|
@@ -43,30 +76,30 @@ export function createGltfElementAddress(json: GltfJson, type: StampedElementTyp
|
|
|
43
76
|
const anchor = parseAnchorValue(name);
|
|
44
77
|
if (anchor) address.anchor = anchor;
|
|
45
78
|
// an empty name is non-information: resolution never reads `name` (id → anchor →
|
|
46
|
-
//
|
|
47
|
-
// NOTE: empty
|
|
79
|
+
// path) and reconciliation falls back to the last path segment ("" again).
|
|
80
|
+
// NOTE: empty path SEGMENTS are load-bearing (positional child-walk) — only the
|
|
48
81
|
// standalone `name` field is dropped when empty.
|
|
49
82
|
if (name) address.name = name;
|
|
50
83
|
if (type === "node") {
|
|
51
|
-
const
|
|
52
|
-
if (
|
|
84
|
+
const segments = gltfNodeNamePath(json, index);
|
|
85
|
+
if (segments) address.path = joinPath(segments);
|
|
53
86
|
}
|
|
54
87
|
return address;
|
|
55
88
|
}
|
|
56
89
|
|
|
57
90
|
/** Build an address for a live scene-graph object under `root`. */
|
|
58
91
|
export function createObjectAddress(root: GraphNode, target: GraphNode): NeedleAddress | null {
|
|
59
|
-
const
|
|
60
|
-
if (!
|
|
92
|
+
const nodes = findPath(root, target);
|
|
93
|
+
if (!nodes || nodes.length === 0) return null; // the root itself has no path — address it by ref alone
|
|
61
94
|
|
|
62
95
|
const address: NeedleAddress = { type: "node" };
|
|
63
96
|
const id = target.userData?.[NEEDLE_ID_KEY];
|
|
64
97
|
if (typeof id === "string") address.id = id;
|
|
65
98
|
const anchor = parseAnchorValue(target.name);
|
|
66
99
|
if (anchor) address.anchor = anchor;
|
|
67
|
-
// empty name dropped — see createGltfElementAddress;
|
|
100
|
+
// empty name dropped — see createGltfElementAddress; path segments stay verbatim
|
|
68
101
|
if (target.name) address.name = target.name;
|
|
69
|
-
address.
|
|
102
|
+
address.path = joinPath(nodes.map(n => n.name ?? ""));
|
|
70
103
|
return address;
|
|
71
104
|
}
|
|
72
105
|
|
package/src/gltf.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
+
*/
|
|
5
6
|
|
|
6
7
|
/** All root-collection element types that receive ids, with their glTF collection key. */
|
|
7
8
|
export const STAMPED_COLLECTIONS = [
|
|
@@ -30,62 +31,3 @@ export type GltfElement = {
|
|
|
30
31
|
export type GltfJson = {
|
|
31
32
|
[collection: string]: unknown;
|
|
32
33
|
};
|
|
33
|
-
|
|
34
|
-
export type EnsureIdsOptions = {
|
|
35
|
-
/**
|
|
36
|
-
* Optional salt scoping unanchored ids to an external identity (e.g. an
|
|
37
|
-
* asset/content id). By default no salt is used — ids derive from the element alone
|
|
38
|
-
* (type, name, occurrence-of-name), so any tool mints the same ids with no
|
|
39
|
-
* coordination. With a salt, only writers that know it agree — safe because
|
|
40
|
-
* existing ids are never overwritten (whoever stamps first wins).
|
|
41
|
-
*/
|
|
42
|
-
baseHash?: string;
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
export type EnsureIdsResult = {
|
|
46
|
-
/** the salt the ids were derived with, when one was provided */
|
|
47
|
-
baseHash?: string;
|
|
48
|
-
/** how many elements received a new id (0 → the file was already fully stamped) */
|
|
49
|
-
assigned: number;
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Idempotently inject `extras["needle-id"]` into every element of the stamped
|
|
54
|
-
* collections. Existing ids are never overwritten, so already-stamped files pass
|
|
55
|
-
* through unchanged and every id writer agrees with whoever stamped first.
|
|
56
|
-
*
|
|
57
|
-
* Mutates `json` in place.
|
|
58
|
-
*/
|
|
59
|
-
export function ensureIds(json: GltfJson, options?: EnsureIdsOptions): EnsureIdsResult {
|
|
60
|
-
const baseHash = options?.baseHash;
|
|
61
|
-
let assigned = 0;
|
|
62
|
-
for (const [type, collection] of STAMPED_COLLECTIONS) {
|
|
63
|
-
const elements = json[collection];
|
|
64
|
-
if (!Array.isArray(elements)) continue;
|
|
65
|
-
// occurrence-of-name: the id input is the element's position among same-named
|
|
66
|
-
// elements, NOT its collection index — uniquely named elements keep their ids
|
|
67
|
-
// across reorders and asset updates. Grouping is by the DERIVATION name
|
|
68
|
-
// (anchor marker stripped, normalized), matching occurrenceOfName.
|
|
69
|
-
const occurrences = new Map<string, number>();
|
|
70
|
-
for (let index = 0; index < elements.length; index++) {
|
|
71
|
-
const element = elements[index] as GltfElement;
|
|
72
|
-
if (element == null || typeof element !== "object") continue;
|
|
73
|
-
const name = typeof element.name === "string" ? element.name : "";
|
|
74
|
-
const group = idNameOf(name);
|
|
75
|
-
const occurrence = occurrences.get(group) ?? 0;
|
|
76
|
-
occurrences.set(group, occurrence + 1);
|
|
77
|
-
element.extras ??= {};
|
|
78
|
-
if (element.extras[NEEDLE_ID_KEY] === undefined) {
|
|
79
|
-
element.extras[NEEDLE_ID_KEY] = computeElementId(type, name, occurrence, baseHash);
|
|
80
|
-
assigned++;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
return { baseHash, assigned };
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** Read an element's needle-id without computing anything. */
|
|
88
|
-
export function getElementId(element: GltfElement | null | undefined): string | null {
|
|
89
|
-
const id = element?.extras?.[NEEDLE_ID_KEY];
|
|
90
|
-
return typeof id === "string" ? id : null;
|
|
91
|
-
}
|
package/src/id.ts
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
import { normalizeName, parseAnchor } from "./anchor.js";
|
|
2
|
-
import {
|
|
2
|
+
import { STAMPED_COLLECTIONS, type GltfElement, type GltfJson } from "./gltf.js";
|
|
3
|
+
import { randomUuid, uuidv5 } from "./uuid.js";
|
|
4
|
+
import type { GraphNode } from "./address.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* THE identity module: every read and every mint of a needle-id lives here —
|
|
8
|
+
* derivation (deterministic ids for glTF elements), glTF stamping/readback
|
|
9
|
+
* (`extras`), and live-node stamping/readback (`userData`). Consumers never
|
|
10
|
+
* touch the key or mint ids themselves.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** The key carrying the persistent id — glTF `extras` for file elements, live
|
|
14
|
+
* `userData` for scene objects (exports copy one into the other). */
|
|
15
|
+
export const NEEDLE_ID_KEY = "needle-id";
|
|
16
|
+
|
|
17
|
+
// #region Derivation
|
|
3
18
|
|
|
4
19
|
/**
|
|
5
20
|
* The name an id is derived FROM: the anchor marker removed (if any) and space/underscore
|
|
@@ -59,3 +74,128 @@ export function occurrenceOfName(elements: ReadonlyArray<{ name?: unknown } | nu
|
|
|
59
74
|
}
|
|
60
75
|
return occurrence;
|
|
61
76
|
}
|
|
77
|
+
|
|
78
|
+
// #endregion
|
|
79
|
+
|
|
80
|
+
// #region glTF elements (extras)
|
|
81
|
+
|
|
82
|
+
export type EnsureIdsOptions = {
|
|
83
|
+
/**
|
|
84
|
+
* Optional salt scoping unanchored ids to an external identity (e.g. an
|
|
85
|
+
* asset/content id). By default no salt is used — ids derive from the element alone
|
|
86
|
+
* (type, name, occurrence-of-name), so any tool mints the same ids with no
|
|
87
|
+
* coordination. With a salt, only writers that know it agree — safe because
|
|
88
|
+
* existing ids are never overwritten (whoever stamps first wins).
|
|
89
|
+
*/
|
|
90
|
+
baseHash?: string;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export type EnsureIdsResult = {
|
|
94
|
+
/** the salt the ids were derived with, when one was provided */
|
|
95
|
+
baseHash?: string;
|
|
96
|
+
/** how many elements received a new id (0 → the file was already fully stamped) */
|
|
97
|
+
assigned: number;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Idempotently inject `extras["needle-id"]` into every element of the stamped
|
|
102
|
+
* collections. Existing ids are never overwritten, so already-stamped files pass
|
|
103
|
+
* through unchanged and every id writer agrees with whoever stamped first.
|
|
104
|
+
*
|
|
105
|
+
* Mutates `json` in place.
|
|
106
|
+
*/
|
|
107
|
+
export function ensureIds(json: GltfJson, options?: EnsureIdsOptions): EnsureIdsResult {
|
|
108
|
+
const baseHash = options?.baseHash;
|
|
109
|
+
let assigned = 0;
|
|
110
|
+
for (const [type, collection] of STAMPED_COLLECTIONS) {
|
|
111
|
+
const elements = json[collection];
|
|
112
|
+
if (!Array.isArray(elements)) continue;
|
|
113
|
+
// occurrence-of-name: the id input is the element's position among same-named
|
|
114
|
+
// elements, NOT its collection index — uniquely named elements keep their ids
|
|
115
|
+
// across reorders and asset updates. Grouping is by the DERIVATION name
|
|
116
|
+
// (anchor marker stripped, normalized), matching occurrenceOfName.
|
|
117
|
+
const occurrences = new Map<string, number>();
|
|
118
|
+
for (let index = 0; index < elements.length; index++) {
|
|
119
|
+
const element = elements[index] as GltfElement;
|
|
120
|
+
if (element == null || typeof element !== "object") continue;
|
|
121
|
+
const name = typeof element.name === "string" ? element.name : "";
|
|
122
|
+
const group = idNameOf(name);
|
|
123
|
+
const occurrence = occurrences.get(group) ?? 0;
|
|
124
|
+
occurrences.set(group, occurrence + 1);
|
|
125
|
+
element.extras ??= {};
|
|
126
|
+
if (element.extras[NEEDLE_ID_KEY] === undefined) {
|
|
127
|
+
element.extras[NEEDLE_ID_KEY] = computeElementId(type, name, occurrence, baseHash);
|
|
128
|
+
assigned++;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return { baseHash, assigned };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Read an element's needle-id without computing anything. */
|
|
136
|
+
export function getElementId(element: GltfElement | null | undefined): string | null {
|
|
137
|
+
const id = element?.extras?.[NEEDLE_ID_KEY];
|
|
138
|
+
return typeof id === "string" ? id : null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// #endregion
|
|
142
|
+
|
|
143
|
+
// #region Live nodes (userData)
|
|
144
|
+
|
|
145
|
+
/** Read a live node's needle-id from `userData` — the live-object counterpart of
|
|
146
|
+
* {@link getElementId}, which reads glTF `extras`. */
|
|
147
|
+
export function getNodeId(target: GraphNode | null | undefined): string | null {
|
|
148
|
+
const id = target?.userData?.[NEEDLE_ID_KEY];
|
|
149
|
+
return typeof id === "string" ? id : null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Ensure a live node carries a needle-id: returns the existing one or MINTS a
|
|
153
|
+
* random uuid into `userData`. Random by design — editor-created (scratch) content
|
|
154
|
+
* has no derivation basis; the id persists because exports write `userData` ids
|
|
155
|
+
* into glTF `extras` (create → save → reload sees the same id). */
|
|
156
|
+
export function ensureNodeId(target: GraphNode): string {
|
|
157
|
+
const userData = (target.userData ??= {});
|
|
158
|
+
const existing = userData[NEEDLE_ID_KEY];
|
|
159
|
+
if (typeof existing === "string") return existing;
|
|
160
|
+
const id = randomUuid();
|
|
161
|
+
userData[NEEDLE_ID_KEY] = id;
|
|
162
|
+
return id;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export type ShedIdentityOptions = {
|
|
166
|
+
/** additional identity keys deleted from every `userData.components` entry —
|
|
167
|
+
* the caller names its component-identity keys (e.g. an engine's editor-guid
|
|
168
|
+
* key); addressing owns the traversal and the needle-id rule */
|
|
169
|
+
componentKeys?: readonly string[];
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Shed a subtree's AUTHORED identity — for clones (duplicate/paste) before they are
|
|
174
|
+
* re-stamped. Stamped needle-ids are deleted on every node: sibling id collisions
|
|
175
|
+
* break addressing (an id-addressed edit resolves to whichever holder comes first).
|
|
176
|
+
* With `componentKeys`, the same keys are deleted from `userData.components`
|
|
177
|
+
* entries (a copied component guid would bake the ORIGINAL's identity into the
|
|
178
|
+
* clone's next export). Fresh ids are minted by whatever stamps next.
|
|
179
|
+
*/
|
|
180
|
+
export function shedNodeIdentity(root: GraphNode, options?: ShedIdentityOptions): void {
|
|
181
|
+
const componentKeys = options?.componentKeys ?? [];
|
|
182
|
+
const visit = (node: GraphNode): void => {
|
|
183
|
+
const userData = node.userData;
|
|
184
|
+
if (userData) {
|
|
185
|
+
if (typeof userData[NEEDLE_ID_KEY] === "string") delete userData[NEEDLE_ID_KEY];
|
|
186
|
+
const components = userData["components"];
|
|
187
|
+
if (Array.isArray(components)) {
|
|
188
|
+
for (const component of components) {
|
|
189
|
+
if (!component || typeof component !== "object") continue;
|
|
190
|
+
for (const key of componentKeys) {
|
|
191
|
+
if ((component as Record<string, unknown>)[key] !== undefined) delete (component as Record<string, unknown>)[key];
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
for (const child of node.children ?? []) visit(child);
|
|
197
|
+
};
|
|
198
|
+
visit(root);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// #endregion
|
package/src/index.ts
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
export { ANCHOR_MARKER, ANCHOR_MARKERS, parseAnchor, parseAnchorValue, type ParsedAnchor } from "./anchor.js";
|
|
2
|
-
export { computeElementId, occurrenceOfName } from "./id.js";
|
|
3
2
|
export {
|
|
3
|
+
computeElementId,
|
|
4
4
|
ensureIds,
|
|
5
|
+
ensureNodeId,
|
|
5
6
|
getElementId,
|
|
7
|
+
getNodeId,
|
|
6
8
|
NEEDLE_ID_KEY,
|
|
7
|
-
|
|
9
|
+
occurrenceOfName,
|
|
10
|
+
shedNodeIdentity,
|
|
8
11
|
type EnsureIdsOptions,
|
|
9
12
|
type EnsureIdsResult,
|
|
13
|
+
type ShedIdentityOptions,
|
|
14
|
+
} from "./id.js";
|
|
15
|
+
export {
|
|
16
|
+
STAMPED_COLLECTIONS,
|
|
10
17
|
type GltfElement,
|
|
11
18
|
type GltfJson,
|
|
12
19
|
type StampedElementType,
|
|
@@ -15,7 +22,11 @@ export {
|
|
|
15
22
|
collectionKeyForType,
|
|
16
23
|
createGltfElementAddress,
|
|
17
24
|
createObjectAddress,
|
|
25
|
+
decodePathSegment,
|
|
26
|
+
encodePathSegment,
|
|
18
27
|
findPath,
|
|
28
|
+
joinPath,
|
|
29
|
+
splitPath,
|
|
19
30
|
type GraphNode,
|
|
20
31
|
type NeedleAddress,
|
|
21
32
|
} from "./address.js";
|
|
@@ -31,4 +42,5 @@ export {
|
|
|
31
42
|
type ResolutionRung,
|
|
32
43
|
type ResolveOptions,
|
|
33
44
|
} from "./resolve.js";
|
|
45
|
+
export { targetFromString, targetToString, type AddressTarget } from "./target.js";
|
|
34
46
|
export { randomUuid, uuidv5, UUID_NAMESPACE_URL } from "./uuid.js";
|
package/src/resolve.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { parseAnchorValue } from "./anchor.js";
|
|
2
|
-
import { collectionKeyForType, type GraphNode, type NeedleAddress } from "./address.js";
|
|
3
|
-
import { NEEDLE_ID_KEY
|
|
2
|
+
import { collectionKeyForType, splitPath, type GraphNode, type NeedleAddress } from "./address.js";
|
|
3
|
+
import { NEEDLE_ID_KEY } from "./id.js";
|
|
4
|
+
import { STAMPED_COLLECTIONS, type GltfJson } from "./gltf.js";
|
|
4
5
|
|
|
5
6
|
export type GltfResolveResult = {
|
|
6
7
|
type: string;
|
|
@@ -10,7 +11,7 @@ export type GltfResolveResult = {
|
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Resolve an address inside glTF JSON. Same ladder as the live graph —
|
|
13
|
-
* **anchor → id →
|
|
14
|
+
* **anchor → id → path** (nodes) — with the anchor winning only when it matches
|
|
14
15
|
* exactly one element. Returns null when nothing matches exactly (reconciliation is a
|
|
15
16
|
* separate, explicit step — resolution never guesses).
|
|
16
17
|
*/
|
|
@@ -51,8 +52,8 @@ export function resolveInGltf(address: NeedleAddress, json: GltfJson, options?:
|
|
|
51
52
|
}
|
|
52
53
|
if (byId) return byId;
|
|
53
54
|
|
|
54
|
-
if (address.
|
|
55
|
-
const index = walkGltfNamePath(json, address.
|
|
55
|
+
if (address.path !== undefined && (!address.type || address.type === "node")) {
|
|
56
|
+
const index = walkGltfNamePath(json, splitPath(address.path));
|
|
56
57
|
if (index !== null) {
|
|
57
58
|
const nodes = json["nodes"] as Array<Record<string, unknown>>;
|
|
58
59
|
return { type: "node", index, element: nodes[index] };
|
|
@@ -62,7 +63,7 @@ export function resolveInGltf(address: NeedleAddress, json: GltfJson, options?:
|
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
/** which rung of the ladder produced (or could have produced) a match */
|
|
65
|
-
export type ResolutionRung = "id" | "anchor" | "
|
|
66
|
+
export type ResolutionRung = "id" | "anchor" | "path" | "name";
|
|
66
67
|
|
|
67
68
|
/**
|
|
68
69
|
* Something a caller may want to show a human. Never an error — resolution still returns
|
|
@@ -90,7 +91,7 @@ export type ResolveOptions = {
|
|
|
90
91
|
/**
|
|
91
92
|
* Resolve an address inside a live scene graph (duck-typed — any three.js).
|
|
92
93
|
*
|
|
93
|
-
* Ladder: **anchor → id →
|
|
94
|
+
* Ladder: **anchor → id → path**. The anchor comes first because it is a deliberate,
|
|
94
95
|
* human-authored declaration, while an id may be a derived value whose basis shifted when
|
|
95
96
|
* the content changed. It only wins when it resolves UNIQUELY: anchors are labels and may
|
|
96
97
|
* legally repeat, and an ambiguous label must never outrank an exact key.
|
|
@@ -118,9 +119,9 @@ export function resolveInGraph(address: NeedleAddress, root: GraphNode, options?
|
|
|
118
119
|
}
|
|
119
120
|
if (byId) return byId;
|
|
120
121
|
|
|
121
|
-
if (address.
|
|
122
|
+
if (address.path !== undefined) {
|
|
122
123
|
let current: GraphNode | null = root;
|
|
123
|
-
for (const segment of address.
|
|
124
|
+
for (const segment of splitPath(address.path)) {
|
|
124
125
|
current = current.children?.find(c => (c.name ?? "") === segment) ?? null;
|
|
125
126
|
if (!current) break;
|
|
126
127
|
}
|
|
@@ -139,7 +140,7 @@ export type AddressableElement = {
|
|
|
139
140
|
* Resolve an address against a FLAT collection of live elements — materials, textures and
|
|
140
141
|
* anything else that carries a name and `userData` but is not part of the scene graph.
|
|
141
142
|
*
|
|
142
|
-
* Same ladder as the graph, minus `
|
|
143
|
+
* Same ladder as the graph, minus `path` (there is no hierarchy to walk) plus `name`
|
|
143
144
|
* as the final rung, since a flat collection has no positional fallback. This exists so
|
|
144
145
|
* every element type resolves through ONE implementation: materials previously had their
|
|
145
146
|
* own ladders, could not be anchored at all, and disagreed with each other about whether a
|
|
@@ -182,19 +183,20 @@ export type ReconcileCandidate = {
|
|
|
182
183
|
/**
|
|
183
184
|
* When exact resolution fails (the asset changed), propose candidates instead of
|
|
184
185
|
* guessing: nodes matching the address's name, ranked by how much of the recorded
|
|
185
|
-
*
|
|
186
|
+
* path still matches. Callers decide thresholds and whether to re-anchor.
|
|
186
187
|
*/
|
|
187
188
|
export function reconcileInGraph(address: NeedleAddress, root: GraphNode): ReconcileCandidate[] {
|
|
188
|
-
|
|
189
|
-
|
|
189
|
+
const addressSegments = address.path !== undefined ? splitPath(address.path) : null;
|
|
190
|
+
if (!address.name && !addressSegments?.length) return [];
|
|
191
|
+
const targetName = address.name ?? addressSegments![addressSegments!.length - 1];
|
|
190
192
|
|
|
191
193
|
const candidates: ReconcileCandidate[] = [];
|
|
192
|
-
collectDescendants(root, (node,
|
|
194
|
+
collectDescendants(root, (node, segments) => {
|
|
193
195
|
if ((node.name ?? "") !== targetName) return;
|
|
194
196
|
const reasons = [`name matches "${targetName}"`];
|
|
195
197
|
let confidence = 0.5;
|
|
196
|
-
if (
|
|
197
|
-
const similarity = pathSuffixSimilarity(
|
|
198
|
+
if (addressSegments?.length) {
|
|
199
|
+
const similarity = pathSuffixSimilarity(addressSegments, segments);
|
|
198
200
|
confidence += 0.4 * similarity;
|
|
199
201
|
if (similarity > 0) reasons.push(`path suffix similarity ${(similarity * 100).toFixed(0)}%`);
|
|
200
202
|
}
|
|
@@ -233,14 +235,14 @@ function findInCollection(json: GltfJson, key: string, predicate: (el: Record<st
|
|
|
233
235
|
return null;
|
|
234
236
|
}
|
|
235
237
|
|
|
236
|
-
function walkGltfNamePath(json: GltfJson,
|
|
238
|
+
function walkGltfNamePath(json: GltfJson, segments: string[]): number | null {
|
|
237
239
|
const nodes = json["nodes"];
|
|
238
240
|
const scenes = json["scenes"];
|
|
239
241
|
if (!Array.isArray(nodes) || !Array.isArray(scenes)) return null;
|
|
240
242
|
// roots: the union of all scenes' root nodes
|
|
241
243
|
let candidates: number[] = scenes.flatMap(s => (Array.isArray(s?.nodes) ? s.nodes : []));
|
|
242
244
|
let match: number | null = null;
|
|
243
|
-
for (const segment of
|
|
245
|
+
for (const segment of segments) {
|
|
244
246
|
match = candidates.find(i => ((nodes[i]?.name ?? "") === segment)) ?? null;
|
|
245
247
|
if (match === null) return null;
|
|
246
248
|
const children = nodes[match]?.children;
|
|
@@ -285,17 +287,17 @@ function findInGraph(root: GraphNode, predicate: (node: GraphNode) => boolean, v
|
|
|
285
287
|
}
|
|
286
288
|
|
|
287
289
|
/** visit every descendant of root with its root-exclusive name path (including own name) */
|
|
288
|
-
function collectDescendants(root: GraphNode, visit: (node: GraphNode,
|
|
290
|
+
function collectDescendants(root: GraphNode, visit: (node: GraphNode, segments: string[]) => void): void {
|
|
289
291
|
const visited = new Set<GraphNode>();
|
|
290
|
-
const walk = (node: GraphNode,
|
|
292
|
+
const walk = (node: GraphNode, segments: string[]) => {
|
|
291
293
|
if (visited.has(node)) return;
|
|
292
294
|
visited.add(node);
|
|
293
|
-
if (node !== root) visit(node,
|
|
295
|
+
if (node !== root) visit(node, segments);
|
|
294
296
|
if (!node.children) return;
|
|
295
297
|
for (let i = 0; i < node.children.length; i++) {
|
|
296
298
|
const child = node.children[i];
|
|
297
299
|
if (child == null) continue;
|
|
298
|
-
walk(child, [...
|
|
300
|
+
walk(child, [...segments, child.name ?? ""]);
|
|
299
301
|
}
|
|
300
302
|
};
|
|
301
303
|
walk(root, []);
|