@needle-tools/addressing 0.3.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/Readme.md +62 -0
  2. package/dist/address.d.ts +5 -0
  3. package/dist/address.js +25 -3
  4. package/dist/document/assets.d.ts +27 -0
  5. package/dist/document/assets.js +75 -0
  6. package/dist/document/collectionSelector.d.ts +9 -0
  7. package/dist/document/collectionSelector.js +29 -0
  8. package/dist/document/evaluate.d.ts +72 -0
  9. package/dist/document/evaluate.js +971 -0
  10. package/dist/document/index.d.ts +8 -0
  11. package/dist/document/index.js +8 -0
  12. package/dist/document/inspection.d.ts +30 -0
  13. package/dist/document/inspection.js +130 -0
  14. package/dist/document/runtime.d.ts +71 -0
  15. package/dist/document/runtime.js +1 -0
  16. package/dist/document/types.d.ts +225 -0
  17. package/dist/document/types.js +43 -0
  18. package/dist/document/validation.d.ts +36 -0
  19. package/dist/document/validation.js +219 -0
  20. package/dist/document/write.d.ts +31 -0
  21. package/dist/document/write.js +106 -0
  22. package/dist/id.d.ts +2 -0
  23. package/dist/id.js +4 -0
  24. package/dist/index.d.ts +2 -1
  25. package/dist/index.js +2 -1
  26. package/dist/loadedGltf.d.ts +32 -0
  27. package/dist/loadedGltf.js +62 -0
  28. package/dist/resolve.js +8 -6
  29. package/package.json +9 -1
  30. package/src/address.ts +28 -3
  31. package/src/document/assets.ts +77 -0
  32. package/src/document/collectionSelector.ts +37 -0
  33. package/src/document/evaluate.ts +998 -0
  34. package/src/document/index.ts +8 -0
  35. package/src/document/inspection.ts +123 -0
  36. package/src/document/runtime.ts +66 -0
  37. package/src/document/schema.json +263 -0
  38. package/src/document/types.ts +249 -0
  39. package/src/document/validation.ts +222 -0
  40. package/src/document/write.ts +112 -0
  41. package/src/id.ts +5 -0
  42. package/src/index.ts +3 -0
  43. package/src/loadedGltf.ts +93 -0
  44. package/src/resolve.ts +9 -7
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Structural validation of Needle project documents against the JSON Schema in
3
+ * `schema.json` — the format's machine-readable spec.
4
+ *
5
+ * The schema is the SINGLE source of truth: this file interprets it, it does not
6
+ * restate it. That is the whole point — a hand-written second validator would drift
7
+ * from the schema exactly like the plan's example drifted from the types.
8
+ *
9
+ * Deliberately dependency-free (a JSON Schema library is a lot of surface for the
10
+ * handful of keywords this schema uses), and STRICT about that: any keyword the
11
+ * schema uses and this interpreter does not implement THROWS. A validator that
12
+ * silently ignores keywords it doesn't know is worse than no validator — it reports
13
+ * "valid" for documents it never actually checked. If you extend the schema with a
14
+ * new keyword, this file fails loudly until you implement it.
15
+ *
16
+ * This validates SHAPE, not resolvability: whether an address actually hits a node in
17
+ * the loaded content is the evaluator's job (it reports issues and skips the arc).
18
+ */
19
+ /** keywords that carry no constraint — annotations, identity, and the definition bag */
20
+ const IGNORED_KEYWORDS = new Set(["$schema", "$id", "$comment", "title", "description", "default", "examples", "definitions"]);
21
+ /** keywords this interpreter implements; anything else in the schema is a hard error */
22
+ const SUPPORTED_KEYWORDS = new Set([
23
+ "$ref", "type", "enum", "const", "required", "properties", "additionalProperties",
24
+ "items", "minItems", "maxItems", "minLength", "allOf", "anyOf", "not",
25
+ ]);
26
+ /**
27
+ * Validate `value` against `schema`. Returns every error found (not just the first) —
28
+ * a writer regression usually shows up as several related failures and seeing them
29
+ * together is what makes the cause obvious.
30
+ *
31
+ * @throws if the schema uses a keyword this interpreter does not implement.
32
+ */
33
+ export function validateAgainstSchema(schema, value) {
34
+ const errors = [];
35
+ validateNode(schema, value, "", schema, errors);
36
+ return errors;
37
+ }
38
+ /** one-line-per-error rendering for assertion messages */
39
+ export function formatSchemaErrors(errors) {
40
+ return errors.map(e => ` ${e.path || "<root>"}: ${e.message}`).join("\n");
41
+ }
42
+ function validateNode(schema, value, path, root, errors) {
43
+ for (const keyword of Object.keys(schema)) {
44
+ if (IGNORED_KEYWORDS.has(keyword) || SUPPORTED_KEYWORDS.has(keyword))
45
+ continue;
46
+ throw new Error(`projectDocumentValidation: unsupported JSON Schema keyword "${keyword}" at "${path || "<root>"}" — implement it before using it in the schema`);
47
+ }
48
+ assertWellFormed(schema, path);
49
+ if (typeof schema.$ref === "string") {
50
+ validateNode(resolveRef(schema.$ref, root), value, path, root, errors);
51
+ // sibling keywords next to $ref are ignored in draft-07 — our schema never mixes them
52
+ return;
53
+ }
54
+ if (schema.type !== undefined && !matchesType(schema.type, value)) {
55
+ errors.push({ path, message: `expected type ${JSON.stringify(schema.type)}, got ${describe(value)}` });
56
+ return; // every remaining keyword assumes the type held — reporting them too is noise
57
+ }
58
+ if (Array.isArray(schema.enum) && !schema.enum.some(candidate => deepEquals(candidate, value))) {
59
+ errors.push({ path, message: `expected one of ${JSON.stringify(schema.enum)}, got ${describe(value)}` });
60
+ }
61
+ if ("const" in schema && !deepEquals(schema.const, value)) {
62
+ errors.push({ path, message: `expected ${JSON.stringify(schema.const)}, got ${describe(value)}` });
63
+ }
64
+ if (typeof schema.minLength === "number" && typeof value === "string" && value.length < schema.minLength) {
65
+ errors.push({ path, message: `expected at least ${schema.minLength} character(s), got ${value.length}` });
66
+ }
67
+ if (Array.isArray(value)) {
68
+ if (typeof schema.minItems === "number" && value.length < schema.minItems) {
69
+ errors.push({ path, message: `expected at least ${schema.minItems} item(s), got ${value.length}` });
70
+ }
71
+ if (typeof schema.maxItems === "number" && value.length > schema.maxItems) {
72
+ errors.push({ path, message: `expected at most ${schema.maxItems} item(s), got ${value.length}` });
73
+ }
74
+ if (isSchema(schema.items)) {
75
+ for (let i = 0; i < value.length; i++)
76
+ validateNode(schema.items, value[i], join(path, String(i)), root, errors);
77
+ }
78
+ }
79
+ if (isPlainObject(value)) {
80
+ if (Array.isArray(schema.required)) {
81
+ for (const key of schema.required) {
82
+ if (typeof key === "string" && !(key in value)) {
83
+ errors.push({ path, message: `missing required property "${key}"` });
84
+ }
85
+ }
86
+ }
87
+ const properties = isPlainObject(schema.properties) ? schema.properties : undefined;
88
+ if (properties) {
89
+ for (const [key, sub] of Object.entries(properties)) {
90
+ if (key in value && isSchema(sub))
91
+ validateNode(sub, value[key], join(path, key), root, errors);
92
+ }
93
+ }
94
+ const additional = schema.additionalProperties;
95
+ if (additional === false || isSchema(additional)) {
96
+ for (const [key, entry] of Object.entries(value)) {
97
+ if (properties && key in properties)
98
+ continue;
99
+ if (additional === false) {
100
+ errors.push({ path: join(path, key), message: "unexpected property" });
101
+ }
102
+ else {
103
+ validateNode(additional, entry, join(path, key), root, errors);
104
+ }
105
+ }
106
+ }
107
+ }
108
+ if (Array.isArray(schema.allOf)) {
109
+ for (const sub of schema.allOf)
110
+ if (isSchema(sub))
111
+ validateNode(sub, value, path, root, errors);
112
+ }
113
+ if (Array.isArray(schema.anyOf)) {
114
+ const matched = schema.anyOf.some(sub => isSchema(sub) && validateAgainstSchemaIn(sub, value, root).length === 0);
115
+ if (!matched)
116
+ errors.push({ path, message: "matched none of the allowed forms (anyOf)" });
117
+ }
118
+ if (isSchema(schema.not) && validateAgainstSchemaIn(schema.not, value, root).length === 0) {
119
+ // our schema uses `not: {}` to mark RETIRED fields — say so rather than leaking
120
+ // "must not match the empty schema" at the reader
121
+ const why = typeof schema.description === "string" ? schema.description : "value is not allowed here";
122
+ errors.push({ path, message: why });
123
+ }
124
+ }
125
+ /**
126
+ * A keyword whose VALUE has an unexpected shape would be skipped by the checks below —
127
+ * the same silent-pass hazard as an unknown keyword, so it is the same hard error.
128
+ */
129
+ function assertWellFormed(schema, path) {
130
+ const bad = (keyword, expected) => {
131
+ throw new Error(`projectDocumentValidation: "${keyword}" at "${path || "<root>"}" must be ${expected}`);
132
+ };
133
+ if ("$ref" in schema && typeof schema.$ref !== "string")
134
+ bad("$ref", "a string");
135
+ if ("type" in schema && typeof schema.type !== "string" && !Array.isArray(schema.type))
136
+ bad("type", "a string or array of strings");
137
+ if ("enum" in schema && !Array.isArray(schema.enum))
138
+ bad("enum", "an array");
139
+ if ("required" in schema && !Array.isArray(schema.required))
140
+ bad("required", "an array");
141
+ if ("properties" in schema && !isPlainObject(schema.properties))
142
+ bad("properties", "an object");
143
+ if ("items" in schema && !isPlainObject(schema.items))
144
+ bad("items", "a schema object (tuple form is not supported)");
145
+ if ("not" in schema && !isPlainObject(schema.not))
146
+ bad("not", "a schema object");
147
+ if ("allOf" in schema && !Array.isArray(schema.allOf))
148
+ bad("allOf", "an array");
149
+ if ("anyOf" in schema && !Array.isArray(schema.anyOf))
150
+ bad("anyOf", "an array");
151
+ if ("additionalProperties" in schema && typeof schema.additionalProperties !== "boolean" && !isPlainObject(schema.additionalProperties))
152
+ bad("additionalProperties", "a boolean or schema object");
153
+ for (const keyword of ["minItems", "maxItems", "minLength"]) {
154
+ if (keyword in schema && typeof schema[keyword] !== "number")
155
+ bad(keyword, "a number");
156
+ }
157
+ }
158
+ /** sub-validation that keeps the same `$ref` root (used by anyOf/not) */
159
+ function validateAgainstSchemaIn(schema, value, root) {
160
+ const errors = [];
161
+ validateNode(schema, value, "", root, errors);
162
+ return errors;
163
+ }
164
+ function resolveRef(ref, root) {
165
+ if (!ref.startsWith("#/"))
166
+ throw new Error(`projectDocumentValidation: only local "#/..." $refs are supported, got "${ref}"`);
167
+ let current = root;
168
+ for (const segment of ref.slice(2).split("/")) {
169
+ if (!isPlainObject(current))
170
+ throw new Error(`projectDocumentValidation: $ref "${ref}" does not resolve`);
171
+ current = current[decodeURIComponent(segment.replace(/~1/g, "/").replace(/~0/g, "~"))];
172
+ }
173
+ if (!isSchema(current))
174
+ throw new Error(`projectDocumentValidation: $ref "${ref}" does not resolve to a schema`);
175
+ return current;
176
+ }
177
+ function matchesType(type, value) {
178
+ if (Array.isArray(type))
179
+ return type.some(t => matchesType(t, value));
180
+ switch (type) {
181
+ case "object": return isPlainObject(value);
182
+ case "array": return Array.isArray(value);
183
+ case "string": return typeof value === "string";
184
+ case "number": return typeof value === "number" && Number.isFinite(value);
185
+ case "integer": return typeof value === "number" && Number.isInteger(value);
186
+ case "boolean": return typeof value === "boolean";
187
+ case "null": return value === null;
188
+ default: throw new Error(`projectDocumentValidation: unsupported "type" value ${JSON.stringify(type)}`);
189
+ }
190
+ }
191
+ function isPlainObject(value) {
192
+ return typeof value === "object" && value !== null && !Array.isArray(value);
193
+ }
194
+ function isSchema(value) {
195
+ return isPlainObject(value);
196
+ }
197
+ function join(path, segment) {
198
+ return path ? `${path}/${segment}` : segment;
199
+ }
200
+ function describe(value) {
201
+ if (value === null)
202
+ return "null";
203
+ if (Array.isArray(value))
204
+ return "array";
205
+ if (value === undefined)
206
+ return "undefined";
207
+ return typeof value;
208
+ }
209
+ function deepEquals(a, b) {
210
+ if (a === b)
211
+ return true;
212
+ if (Array.isArray(a) && Array.isArray(b))
213
+ return a.length === b.length && a.every((v, i) => deepEquals(v, b[i]));
214
+ if (isPlainObject(a) && isPlainObject(b)) {
215
+ const keys = Object.keys(a);
216
+ return keys.length === Object.keys(b).length && keys.every(k => k in b && deepEquals(a[k], b[k]));
217
+ }
218
+ return false;
219
+ }
@@ -0,0 +1,31 @@
1
+ import type { NeedleAddress } from "../address.js";
2
+ import type { ProjectDocumentOver, ProjectDocumentTarget } from "./types.js";
3
+ export interface PropertyOverInput {
4
+ readonly target: ProjectDocumentTarget;
5
+ readonly value: unknown;
6
+ readonly previousValue: unknown;
7
+ readonly sequence?: number;
8
+ readonly alwaysWrite?: boolean;
9
+ }
10
+ export type PropertyOverResult = {
11
+ over: ProjectDocumentOver;
12
+ sequence: number;
13
+ } | {
14
+ reverted: ProjectDocumentOver;
15
+ } | {
16
+ issue: string;
17
+ };
18
+ /** Serialize one captured edit; adapters supply ownership and live-target checks. */
19
+ export declare function writePropertyOver(input: PropertyOverInput, encodeNode?: (object: object) => unknown): PropertyOverResult;
20
+ export declare function isDocumentReferenceValue(value: unknown): value is {
21
+ ref: string;
22
+ address?: NeedleAddress;
23
+ };
24
+ export declare function captureDocumentValue(value: unknown, encodeNode?: (object: object) => unknown): unknown;
25
+ export declare function documentOverKey(over: ProjectDocumentOver): string;
26
+ /**
27
+ * Merge base-document overs with freshly captured ones: a new over SUPERSEDES a base
28
+ * over on the same target (the user re-edited it); base overs whose reference is gone
29
+ * are dropped; everything else is preserved.
30
+ */
31
+ export declare function mergeDocumentOvers(baseOvers: readonly ProjectDocumentOver[], newOvers: readonly ProjectDocumentOver[], liveRefs: ReadonlySet<string>, contentRefIds: ReadonlySet<string>, alwaysSuperseded?: ReadonlySet<string>, skipped?: string[]): ProjectDocumentOver[];
@@ -0,0 +1,106 @@
1
+ /** Serialize one captured edit; adapters supply ownership and live-target checks. */
2
+ export function writePropertyOver(input, encodeNode) {
3
+ const value = captureDocumentValue(input.value, encodeNode);
4
+ if (value === undefined)
5
+ return { issue: "value type is not expressible in the document yet" };
6
+ const base = captureDocumentValue(input.previousValue, encodeNode);
7
+ if (!input.alwaysWrite && base !== undefined && JSON.stringify(base) === JSON.stringify(value)) {
8
+ return { reverted: { target: input.target, value: base } };
9
+ }
10
+ return { over: { target: input.target, value }, sequence: input.sequence ?? 0 };
11
+ }
12
+ export function isDocumentReferenceValue(value) {
13
+ if (!value || typeof value !== "object")
14
+ return false;
15
+ const candidate = value;
16
+ if (typeof candidate.ref !== "string" || Object.keys(candidate).some(key => key !== "ref" && key !== "address"))
17
+ return false;
18
+ const address = candidate.address;
19
+ return address === undefined || (!!address && typeof address === "object" && !Array.isArray(address)
20
+ && Object.entries(address).every(([key, entry]) => ["id", "anchor", "type", "name", "path"].includes(key) && typeof entry === "string"));
21
+ }
22
+ export function captureDocumentValue(value, encodeNode) {
23
+ if (value === null)
24
+ return null;
25
+ if (isDocumentReferenceValue(value))
26
+ return { ref: value.ref, ...(value.address ? { address: { ...value.address } } : {}) };
27
+ switch (typeof value) {
28
+ case "number": return Number.isFinite(value) ? value : undefined;
29
+ case "string":
30
+ case "boolean":
31
+ return value;
32
+ case "object": {
33
+ // scene-object values (object slots) → NODE REFERENCES ({ref, address?});
34
+ // unattributable objects fall through to undefined = "not expressible"
35
+ if (value.isObject3D === true) {
36
+ return encodeNode?.(value);
37
+ }
38
+ const toArray = value.toArray;
39
+ if (typeof toArray === "function") {
40
+ const array = value.toArray();
41
+ if (Array.isArray(array)) {
42
+ // three value types are numeric arrays: Vector[234]/Quaternion/Color
43
+ // ([x,y,z(,w)]). Euler trails a string `order` — take the leading
44
+ // numeric run; the evaluator's Euler.fromArray([x,y,z]) keeps the
45
+ // node's own order. Reject anything with more than one trailing
46
+ // non-number (not a known value type).
47
+ const nums = [];
48
+ for (const v of array) {
49
+ if (typeof v === "number" && Number.isFinite(v))
50
+ nums.push(v);
51
+ else
52
+ break;
53
+ }
54
+ if (nums.length > 0 && (nums.length === array.length || (nums.length === array.length - 1 && typeof array[array.length - 1] === "string")))
55
+ return nums;
56
+ }
57
+ }
58
+ if (Array.isArray(value) && value.every(v => (typeof v === "number" && Number.isFinite(v)) || typeof v === "string" || typeof v === "boolean")) {
59
+ return [...value];
60
+ }
61
+ return undefined;
62
+ }
63
+ default:
64
+ return undefined;
65
+ }
66
+ }
67
+ export function documentOverKey(over) {
68
+ const t = over.target;
69
+ const addressKey = t.address?.id ?? t.address?.path ?? t.address?.name ?? "";
70
+ return `${t.ref ?? ""}::${addressKey}::${t.path ?? ""}`;
71
+ }
72
+ /**
73
+ * Merge base-document overs with freshly captured ones: a new over SUPERSEDES a base
74
+ * over on the same target (the user re-edited it); base overs whose reference is gone
75
+ * are dropped; everything else is preserved.
76
+ */
77
+ export function mergeDocumentOvers(baseOvers, newOvers, liveRefs, contentRefIds, alwaysSuperseded, skipped) {
78
+ const superseded = new Set(newOvers.map(documentOverKey));
79
+ const result = [];
80
+ for (const over of baseOvers) {
81
+ // parent overs of the REGENERATED classes never merge back (re-derived
82
+ // live every save): address-less = reference roots; addressed on a CONTENT
83
+ // ref = content children. Addressed parent overs on OTHER refs are
84
+ // ops-recorded asset moves — they PERSIST across sessions and supersede
85
+ // by key on re-move.
86
+ if (over.target.path === "parent" && (!over.target.address || (over.target.ref !== undefined && contentRefIds.has(over.target.ref))))
87
+ continue;
88
+ if (over.target.ref && !liveRefs.has(over.target.ref)) {
89
+ // reference removed — the over dies with it. EXPECTED after a reference
90
+ // delete, but SURFACED regardless ("Save Project never drops an edit
91
+ // silently"): an unexpected entry here is the symptom of an attribution
92
+ // bug (wrong ref id), and silence cost a debugging session once
93
+ // (plans/project-format-v0.md item 7b).
94
+ skipped?.push(`over ${over.target.path ?? "?"} on reference "${over.target.ref}" — reference no longer exists, over dropped`);
95
+ continue;
96
+ }
97
+ const key = documentOverKey(over);
98
+ if (superseded.has(key))
99
+ continue; // re-edited below
100
+ if (alwaysSuperseded?.has(key))
101
+ continue; // rebuilt-from-live paths (placement)
102
+ result.push(over);
103
+ }
104
+ result.push(...newOvers);
105
+ return result;
106
+ }
package/dist/id.d.ts CHANGED
@@ -9,6 +9,8 @@ import type { GraphNode } from "./address.js";
9
9
  /** The key carrying the persistent id — glTF `extras` for file elements, live
10
10
  * `userData` for scene objects (exports copy one into the other). */
11
11
  export declare const NEEDLE_ID_KEY = "needle-id";
12
+ /** Stable instance identity, preserving the namespace used by Engine duplication. */
13
+ export declare function deriveInstanceId(scope: string, originalId: string): string;
12
14
  /**
13
15
  * The name an id is derived FROM: the anchor marker removed (if any) and space/underscore
14
16
  * runs collapsed.
package/dist/id.js CHANGED
@@ -11,6 +11,10 @@ import { randomUuid, uuidv5 } from "./uuid.js";
11
11
  * `userData` for scene objects (exports copy one into the other). */
12
12
  export const NEEDLE_ID_KEY = "needle-id";
13
13
  // #region Derivation
14
+ /** Stable instance identity, preserving the namespace used by Engine duplication. */
15
+ export function deriveInstanceId(scope, originalId) {
16
+ return uuidv5(`${scope}:${originalId}`, "eff8ba80-635d-11ec-90d6-0242ac120003");
17
+ }
14
18
  /**
15
19
  * The name an id is derived FROM: the anchor marker removed (if any) and space/underscore
16
20
  * runs collapsed.
package/dist/index.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  export { ANCHOR_MARKER, ANCHOR_MARKERS, parseAnchor, parseAnchorValue, type ParsedAnchor } from "./anchor.js";
2
- export { computeElementId, ensureIds, ensureNodeId, getElementId, getNodeId, NEEDLE_ID_KEY, occurrenceOfName, shedNodeIdentity, type EnsureIdsOptions, type EnsureIdsResult, type ShedIdentityOptions, } from "./id.js";
2
+ export { computeElementId, deriveInstanceId, ensureIds, ensureNodeId, getElementId, getNodeId, NEEDLE_ID_KEY, occurrenceOfName, shedNodeIdentity, type EnsureIdsOptions, type EnsureIdsResult, type ShedIdentityOptions, } from "./id.js";
3
3
  export { STAMPED_COLLECTIONS, type GltfElement, type GltfJson, type StampedElementType, } from "./gltf.js";
4
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
6
  export { targetFromString, targetToString, type AddressTarget } from "./target.js";
7
7
  export { randomUuid, uuidv5, UUID_NAMESPACE_URL } from "./uuid.js";
8
+ export { attachNeedleIdsToLoadedGltf, type GltfAssociation, type AttachNeedleIdsResult } from "./loadedGltf.js";
package/dist/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  export { ANCHOR_MARKER, ANCHOR_MARKERS, parseAnchor, parseAnchorValue } from "./anchor.js";
2
- export { computeElementId, ensureIds, ensureNodeId, getElementId, getNodeId, NEEDLE_ID_KEY, occurrenceOfName, shedNodeIdentity, } from "./id.js";
2
+ export { computeElementId, deriveInstanceId, ensureIds, ensureNodeId, getElementId, getNodeId, NEEDLE_ID_KEY, occurrenceOfName, shedNodeIdentity, } from "./id.js";
3
3
  export { STAMPED_COLLECTIONS, } from "./gltf.js";
4
4
  export { collectionKeyForType, createGltfElementAddress, createObjectAddress, decodePathSegment, encodePathSegment, findPath, joinPath, splitPath, } from "./address.js";
5
5
  export { reconcileInGraph, resolveInCollection, resolveInGltf, resolveInGraph, } from "./resolve.js";
6
6
  export { targetFromString, targetToString } from "./target.js";
7
7
  export { randomUuid, uuidv5, UUID_NAMESPACE_URL } from "./uuid.js";
8
+ export { attachNeedleIdsToLoadedGltf } from "./loadedGltf.js";
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Eager-compute half of the persistent-id contract:
3
+ * ids are computed at LOAD time against the pristine glTF JSON — the deterministic inputs
4
+ * (original names and their order) only exist pristinely then — and attached to the LIVE
5
+ * objects' userData. The source file is not touched; persistence happens on save, where
6
+ * the exporter writes userData back into extras.
7
+ *
8
+ * Already-stamped assets carry ids in extras (which the loader copies to userData); those
9
+ * are respected and always win over recomputation.
10
+ */
11
+ /** subset of three GLTFLoader's `parser.associations` values that we consume */
12
+ export type GltfAssociation = {
13
+ nodes?: number;
14
+ materials?: number;
15
+ textures?: number;
16
+ cameras?: number;
17
+ };
18
+ type UserDataHost = {
19
+ userData?: Record<string, unknown>;
20
+ };
21
+ export type AttachNeedleIdsResult = {
22
+ /** how many live objects received an id (already-stamped userData is left untouched) */
23
+ attached: number;
24
+ };
25
+ /**
26
+ * Attach needle-ids to the live objects of a loaded glTF.
27
+ * @param json the PRISTINE parsed glTF JSON (as read from the file, before any mutation)
28
+ * @param associations three GLTFLoader `parser.associations` (live object → element indices)
29
+ * @param scenes loaded scene objects, ordered like `json.scenes`
30
+ */
31
+ export declare function attachNeedleIdsToLoadedGltf(json: Record<string, unknown>, associations: ReadonlyMap<object, GltfAssociation | undefined> | null | undefined, scenes?: readonly (UserDataHost | null | undefined)[] | null): AttachNeedleIdsResult;
32
+ export {};
@@ -0,0 +1,62 @@
1
+ import { computeElementId, getElementId, NEEDLE_ID_KEY, occurrenceOfName } from "./id.js";
2
+ /**
3
+ * association key → (glTF collection, element type) — in precedence order: the scene-graph
4
+ * identity (node) wins when one live object carries several associations (e.g. a Mesh
5
+ * created for a single-use glTF node). glTF `mesh` elements are deliberately NOT attached:
6
+ * they are shared data, and stamping a scene object with a mesh id would alias two
7
+ * identities (a mesh used by two nodes would give both objects the same id).
8
+ */
9
+ const ASSOCIATION_MAPPING = [
10
+ ["nodes", "node"],
11
+ ["materials", "material"],
12
+ ["textures", "texture"],
13
+ ["cameras", "camera"],
14
+ ];
15
+ /**
16
+ * Attach needle-ids to the live objects of a loaded glTF.
17
+ * @param json the PRISTINE parsed glTF JSON (as read from the file, before any mutation)
18
+ * @param associations three GLTFLoader `parser.associations` (live object → element indices)
19
+ * @param scenes loaded scene objects, ordered like `json.scenes`
20
+ */
21
+ export function attachNeedleIdsToLoadedGltf(json, associations, scenes) {
22
+ let attached = 0;
23
+ const attach = (target, type, collection, index) => {
24
+ const userData = target.userData;
25
+ if (!userData || typeof userData !== "object")
26
+ return;
27
+ if (typeof userData[NEEDLE_ID_KEY] === "string")
28
+ return; // loader copied extras, or already attached
29
+ const elements = json[collection];
30
+ if (!Array.isArray(elements))
31
+ return;
32
+ const element = elements[index];
33
+ if (!element || typeof element !== "object")
34
+ return;
35
+ // occurrence-of-name (not index) is the id input — computed from the pristine
36
+ // collection so ids match what any other tool stamps for this file
37
+ const id = getElementId(element) ?? computeElementId(type, element.name, occurrenceOfName(elements, index));
38
+ userData[NEEDLE_ID_KEY] = id;
39
+ attached++;
40
+ };
41
+ if (associations) {
42
+ for (const [object, association] of associations) {
43
+ if (!association || typeof object !== "object")
44
+ continue;
45
+ for (const [key, type] of ASSOCIATION_MAPPING) {
46
+ const index = association[key];
47
+ if (typeof index === "number") {
48
+ attach(object, type, key, index);
49
+ break; // precedence: first matching association identifies the object
50
+ }
51
+ }
52
+ }
53
+ }
54
+ if (scenes) {
55
+ for (let i = 0; i < scenes.length; i++) {
56
+ const scene = scenes[i];
57
+ if (scene)
58
+ attach(scene, "scene", "scenes", i);
59
+ }
60
+ }
61
+ return { attached };
62
+ }
package/dist/resolve.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { parseAnchorValue } from "./anchor.js";
2
- import { collectionKeyForType, splitPath } from "./address.js";
2
+ import { collectionKeyForType, splitNodePath } from "./address.js";
3
3
  import { NEEDLE_ID_KEY } from "./id.js";
4
4
  import { STAMPED_COLLECTIONS } from "./gltf.js";
5
5
  /**
@@ -51,7 +51,7 @@ export function resolveInGltf(address, json, options) {
51
51
  if (byId)
52
52
  return byId;
53
53
  if (address.path !== undefined && (!address.type || address.type === "node")) {
54
- const index = walkGltfNamePath(json, splitPath(address.path));
54
+ const index = walkGltfNamePath(json, splitNodePath(address.path));
55
55
  if (index !== null) {
56
56
  const nodes = json["nodes"];
57
57
  return { type: "node", index, element: nodes[index] };
@@ -94,8 +94,9 @@ export function resolveInGraph(address, root, options) {
94
94
  return byId;
95
95
  if (address.path !== undefined) {
96
96
  let current = root;
97
- for (const segment of splitPath(address.path)) {
98
- current = current.children?.find(c => (c.name ?? "") === segment) ?? null;
97
+ for (const segment of splitNodePath(address.path)) {
98
+ const matches = current.children?.filter(c => (c.name ?? "") === segment.name) ?? [];
99
+ current = segment.occurrence !== undefined ? matches[segment.occurrence] ?? null : matches.length === 1 ? matches[0] : null;
99
100
  if (!current)
100
101
  break;
101
102
  }
@@ -149,7 +150,7 @@ export function resolveInCollection(address, elements, options) {
149
150
  * path still matches. Callers decide thresholds and whether to re-anchor.
150
151
  */
151
152
  export function reconcileInGraph(address, root) {
152
- const addressSegments = address.path !== undefined ? splitPath(address.path) : null;
153
+ const addressSegments = address.path !== undefined ? splitNodePath(address.path).map(segment => segment.name) : null;
153
154
  if (!address.name && !addressSegments?.length)
154
155
  return [];
155
156
  const targetName = address.name ?? addressSegments[addressSegments.length - 1];
@@ -208,7 +209,8 @@ function walkGltfNamePath(json, segments) {
208
209
  let candidates = scenes.flatMap(s => (Array.isArray(s?.nodes) ? s.nodes : []));
209
210
  let match = null;
210
211
  for (const segment of segments) {
211
- match = candidates.find(i => ((nodes[i]?.name ?? "") === segment)) ?? null;
212
+ const matches = [...new Set(candidates)].filter(i => (nodes[i]?.name ?? "") === segment.name);
213
+ match = segment.occurrence !== undefined ? matches[segment.occurrence] ?? null : matches.length === 1 ? matches[0] : null;
212
214
  if (match === null)
213
215
  return null;
214
216
  const children = nodes[match]?.children;
package/package.json CHANGED
@@ -1,11 +1,17 @@
1
1
  {
2
2
  "name": "@needle-tools/addressing",
3
- "version": "0.3.4",
3
+ "version": "0.4.0",
4
4
  "description": "Persistent addressing for Needle assets: deterministic needle-id computation, #needle anchors, and address resolution over glTF JSON and live scene graphs.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
7
7
  "types": "./src/index.ts",
8
8
  "exports": {
9
+ "./document/schema.json": "./src/document/schema.json",
10
+ "./document": {
11
+ "types": "./src/document/index.ts",
12
+ "node": "./dist/document/index.js",
13
+ "default": "./src/document/index.ts"
14
+ },
9
15
  ".": {
10
16
  "types": "./src/index.ts",
11
17
  "node": "./dist/index.js",
@@ -28,6 +34,8 @@
28
34
  "url": "https://needle.tools"
29
35
  },
30
36
  "devDependencies": {
37
+ "three": "^0.185.0",
38
+ "@types/three": "^0.185.0",
31
39
  "typescript": "^5.6.0",
32
40
  "vitest": "^3.0.0"
33
41
  },
package/src/address.ts CHANGED
@@ -26,6 +26,7 @@ export type NeedleAddress = {
26
26
  * {@link splitPath}. Empty SEGMENTS are load-bearing (positional walk over
27
27
  * unnamed nodes), so `""` is a valid one-segment path — test `!== undefined`,
28
28
  * never truthiness. ABSENT means "no path rung".
29
+ * A `~2n` suffix selects the zero-based occurrence among same-named siblings.
29
30
  */
30
31
  path?: string;
31
32
  };
@@ -82,7 +83,7 @@ export function createGltfElementAddress(json: GltfJson, type: StampedElementTyp
82
83
  if (name) address.name = name;
83
84
  if (type === "node") {
84
85
  const segments = gltfNodeNamePath(json, index);
85
- if (segments) address.path = joinPath(segments);
86
+ if (segments) address.path = segments.join("/");
86
87
  }
87
88
  return address;
88
89
  }
@@ -99,10 +100,31 @@ export function createObjectAddress(root: GraphNode, target: GraphNode): NeedleA
99
100
  if (anchor) address.anchor = anchor;
100
101
  // empty name dropped — see createGltfElementAddress; path segments stay verbatim
101
102
  if (target.name) address.name = target.name;
102
- address.path = joinPath(nodes.map(n => n.name ?? ""));
103
+ let parent = root;
104
+ address.path = nodes.map(node => {
105
+ const siblings = parent.children ?? [];
106
+ parent = node;
107
+ return nodePathSegment(node.name ?? "", siblings, node);
108
+ }).join("/");
103
109
  return address;
104
110
  }
105
111
 
112
+ /** `~2n` selects a same-named sibling; literal tildes were already escaped as `~0`. */
113
+ function nodePathSegment(name: string, siblings: readonly GraphNode[], node: GraphNode): string {
114
+ const matches = siblings.filter(sibling => (sibling.name ?? "") === name);
115
+ const escaped = encodePathSegment(name);
116
+ return matches.length > 1 ? `${escaped}~2${matches.indexOf(node)}` : escaped;
117
+ }
118
+
119
+ export function splitNodePath(path: string): Array<{ name: string; occurrence?: number }> {
120
+ return path.split("/").map(segment => {
121
+ const qualifier = /~2(0|[1-9][0-9]*)$/.exec(segment);
122
+ return qualifier
123
+ ? { name: decodePathSegment(segment.slice(0, qualifier.index)), occurrence: Number(qualifier[1]) }
124
+ : { name: decodePathSegment(segment) };
125
+ });
126
+ }
127
+
106
128
  export function collectionKeyForType(type: string): string {
107
129
  // glTF collection keys are the pluralized type names; only "mesh" is irregular
108
130
  return type === "mesh" ? "meshes" : `${type}s`;
@@ -123,7 +145,10 @@ function gltfNodeNamePath(json: GltfJson, nodeIndex: number): string[] | null {
123
145
  while (current !== undefined) {
124
146
  if (seen.has(current)) return null; // malformed (cyclic) input
125
147
  seen.add(current);
126
- path.unshift(typeof nodes[current]?.name === "string" ? nodes[current].name : "");
148
+ const parent = parents.get(current);
149
+ const siblingIndices: number[] = parent !== undefined ? nodes[parent]?.children ?? []
150
+ : [...new Set((Array.isArray(json.scenes) ? json.scenes : []).flatMap(scene => scene.nodes ?? []))];
151
+ path.unshift(nodePathSegment(typeof nodes[current]?.name === "string" ? nodes[current].name : "", siblingIndices.map(i => nodes[i]), nodes[current]));
127
152
  current = parents.get(current);
128
153
  }
129
154
  return path;