@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
package/Readme.md CHANGED
@@ -6,6 +6,62 @@ Persistent addressing for glTF assets and three.js scene graphs: deterministic
6
6
  Zero runtime dependencies, no three.js import (scene graphs are accessed structurally),
7
7
  usable in browsers, node, and workers.
8
8
 
9
+ ## Project documents
10
+
11
+ `@needle-tools/addressing/document` exports the document types, evaluator,
12
+ single-def/over application, optional inspection index, and schema validator.
13
+ The JSON Schema is available at `@needle-tools/addressing/document/schema.json`.
14
+ The document format is unchanged by this package split.
15
+
16
+ The shared writer exports `writePropertyOver`, `captureDocumentValue`,
17
+ `documentOverKey`, and `mergeDocumentOvers`. These encode property edits, retain
18
+ addresses, remove net-undo edits, and merge saved overs. Hosts still supply node
19
+ reference encoding, live-target checks, asset assembly, and component serialization.
20
+ Non-finite numbers and unsupported values produce a writer issue instead of being
21
+ silently changed by JSON serialization.
22
+
23
+ `DocumentAssetAssembly` registers deduplicated asset sources and shared material
24
+ or HDR entities in the writer's asset/reference tables. Material assignments reuse
25
+ saved entity identities by source and material ID, even when asset keys change.
26
+ Hosts supply the captured entity IDs and resolve asset ownership; the assembly
27
+ contains no engine or editor dependencies.
28
+
29
+ The evaluator accepts a `DocumentRuntime` host. It owns asset caching, reference
30
+ placement, derived glTF IDs, target resolution, defs, property and parent overs,
31
+ entity sharing, and issue reporting. The host supplies loading, object creation,
32
+ cloning, and lifecycle scheduling. Needle Engine provides its own adapter;
33
+ plain three.js can use the same evaluator:
34
+
35
+ ```ts
36
+ import { Color, Object3D, Scene } from "three";
37
+ import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
38
+ import { evaluateProjectDocument, type DocumentRuntime } from "@needle-tools/addressing/document";
39
+
40
+ const scene = new Scene();
41
+ const runtime: DocumentRuntime = {
42
+ scene,
43
+ renderer, // your existing renderer, for $renderer opinions
44
+ createNode: () => new Object3D(),
45
+ createColor: value => new Color().fromArray(value),
46
+ parse: (data, src) => new GLTFLoader().parseAsync(data, new URL(".", new URL(src, documentUrl)).href),
47
+ instantiate: node => (node as Object3D).clone(),
48
+ // Plain objects have no component lifecycle or template registrations.
49
+ parkTemplate() {},
50
+ activateInstance() {},
51
+ disposeTemplate() {},
52
+ schedule: work => work(),
53
+ loadEnvironment, // your existing HDR/PMREM loader: (url, options) => Promise<Texture | null>
54
+ };
55
+ const result = await evaluateProjectDocument(runtime, document, { baseUrl: documentUrl });
56
+ await result.completed;
57
+ ```
58
+
59
+ Supply `createDefContext` and the component access hooks if the host supports
60
+ components. Without them, component defs are reported as unsupported; node defs
61
+ and ordinary three.js properties still work. A skeletal or component-aware host
62
+ must supply its matching clone implementation. The default addressing entry point
63
+ does not import the document evaluator or any three.js runtime.
64
+
9
65
  ## Ids
10
66
 
11
67
  Every glTF root-collection element (nodes, scenes, animations, cameras, materials,
@@ -34,6 +90,12 @@ An address captures every fallback at creation time, while the target is known
34
90
  1. `id` — exact match (`extras` in glTF JSON, `userData` in live scene graphs)
35
91
  2. `anchor` — survives asset updates
36
92
  3. `path` — best-effort structural match
93
+
94
+ Node paths qualify same-named siblings with `~2n` (zero-based occurrence):
95
+ `Cube~21` names the second `Cube`. Literal tildes remain escaped as `~0`.
96
+ Unqualified ambiguous paths fail to resolve rather than picking the first child.
97
+ These paths assume the original sibling order; use authored anchors or persistent
98
+ IDs when the source scene changes.
37
99
  4. **reconciliation** — never automatic: `reconcileInGraph` returns ranked candidates
38
100
  with confidence + reasons; the caller decides thresholds and whether to re-anchor.
39
101
 
package/dist/address.d.ts CHANGED
@@ -23,6 +23,7 @@ export type NeedleAddress = {
23
23
  * {@link splitPath}. Empty SEGMENTS are load-bearing (positional walk over
24
24
  * unnamed nodes), so `""` is a valid one-segment path — test `!== undefined`,
25
25
  * never truthiness. ABSENT means "no path rung".
26
+ * A `~2n` suffix selects the zero-based occurrence among same-named siblings.
26
27
  */
27
28
  path?: string;
28
29
  };
@@ -44,6 +45,10 @@ export type GraphNode = {
44
45
  export declare function createGltfElementAddress(json: GltfJson, type: StampedElementType | string, index: number): NeedleAddress | null;
45
46
  /** Build an address for a live scene-graph object under `root`. */
46
47
  export declare function createObjectAddress(root: GraphNode, target: GraphNode): NeedleAddress | null;
48
+ export declare function splitNodePath(path: string): Array<{
49
+ name: string;
50
+ occurrence?: number;
51
+ }>;
47
52
  export declare function collectionKeyForType(type: string): string;
48
53
  /** path from root (exclusive) to target (inclusive), or null if target is not under root */
49
54
  export declare function findPath(root: GraphNode, target: GraphNode): GraphNode[] | null;
package/dist/address.js CHANGED
@@ -45,7 +45,7 @@ export function createGltfElementAddress(json, type, index) {
45
45
  if (type === "node") {
46
46
  const segments = gltfNodeNamePath(json, index);
47
47
  if (segments)
48
- address.path = joinPath(segments);
48
+ address.path = segments.join("/");
49
49
  }
50
50
  return address;
51
51
  }
@@ -64,9 +64,28 @@ export function createObjectAddress(root, target) {
64
64
  // empty name dropped — see createGltfElementAddress; path segments stay verbatim
65
65
  if (target.name)
66
66
  address.name = target.name;
67
- address.path = joinPath(nodes.map(n => n.name ?? ""));
67
+ let parent = root;
68
+ address.path = nodes.map(node => {
69
+ const siblings = parent.children ?? [];
70
+ parent = node;
71
+ return nodePathSegment(node.name ?? "", siblings, node);
72
+ }).join("/");
68
73
  return address;
69
74
  }
75
+ /** `~2n` selects a same-named sibling; literal tildes were already escaped as `~0`. */
76
+ function nodePathSegment(name, siblings, node) {
77
+ const matches = siblings.filter(sibling => (sibling.name ?? "") === name);
78
+ const escaped = encodePathSegment(name);
79
+ return matches.length > 1 ? `${escaped}~2${matches.indexOf(node)}` : escaped;
80
+ }
81
+ export function splitNodePath(path) {
82
+ return path.split("/").map(segment => {
83
+ const qualifier = /~2(0|[1-9][0-9]*)$/.exec(segment);
84
+ return qualifier
85
+ ? { name: decodePathSegment(segment.slice(0, qualifier.index)), occurrence: Number(qualifier[1]) }
86
+ : { name: decodePathSegment(segment) };
87
+ });
88
+ }
70
89
  export function collectionKeyForType(type) {
71
90
  // glTF collection keys are the pluralized type names; only "mesh" is irregular
72
91
  return type === "mesh" ? "meshes" : `${type}s`;
@@ -90,7 +109,10 @@ function gltfNodeNamePath(json, nodeIndex) {
90
109
  if (seen.has(current))
91
110
  return null; // malformed (cyclic) input
92
111
  seen.add(current);
93
- path.unshift(typeof nodes[current]?.name === "string" ? nodes[current].name : "");
112
+ const parent = parents.get(current);
113
+ const siblingIndices = parent !== undefined ? nodes[parent]?.children ?? []
114
+ : [...new Set((Array.isArray(json.scenes) ? json.scenes : []).flatMap(scene => scene.nodes ?? []))];
115
+ path.unshift(nodePathSegment(typeof nodes[current]?.name === "string" ? nodes[current].name : "", siblingIndices.map(i => nodes[i]), nodes[current]));
94
116
  current = parents.get(current);
95
117
  }
96
118
  return path;
@@ -0,0 +1,27 @@
1
+ import type { ProjectDocument, ProjectDocumentAsset, ProjectDocumentReference } from "./types.js";
2
+ /** Asset keys and shared resource identities for one document assembly. */
3
+ export declare class DocumentAssetAssembly {
4
+ private readonly assets;
5
+ private readonly references;
6
+ private readonly assetBySrc;
7
+ private readonly referenceById;
8
+ private readonly materialBySource;
9
+ private readonly baseMaterialBySource;
10
+ constructor(assets: Record<string, ProjectDocumentAsset>, references: ProjectDocumentReference[], base?: ProjectDocument | null);
11
+ addAsset(input: {
12
+ src: string;
13
+ name?: string;
14
+ kind: string;
15
+ identity?: {
16
+ id: string;
17
+ provider?: string;
18
+ };
19
+ }): string;
20
+ materialEntity(asset: string, material: {
21
+ id: string;
22
+ name?: string;
23
+ }, entityId: string): string;
24
+ environmentEntity(asset: string, entityId: string): string;
25
+ private addReference;
26
+ private materialKey;
27
+ }
@@ -0,0 +1,75 @@
1
+ /** Asset keys and shared resource identities for one document assembly. */
2
+ export class DocumentAssetAssembly {
3
+ constructor(assets, references, base) {
4
+ this.assets = assets;
5
+ this.references = references;
6
+ this.assetBySrc = new Map();
7
+ this.referenceById = new Map();
8
+ this.materialBySource = new Map();
9
+ this.baseMaterialBySource = new Map();
10
+ for (const [key, asset] of Object.entries(assets))
11
+ this.assetBySrc.set(asset.src, key);
12
+ for (const reference of references) {
13
+ this.referenceById.set(reference.id, reference);
14
+ const key = this.materialKey(reference, assets);
15
+ if (key)
16
+ this.materialBySource.set(key, reference);
17
+ }
18
+ for (const reference of base?.references ?? []) {
19
+ const key = this.materialKey(reference, base?.assets ?? {});
20
+ if (key)
21
+ this.baseMaterialBySource.set(key, reference);
22
+ }
23
+ }
24
+ addAsset(input) {
25
+ const existing = this.assetBySrc.get(input.src);
26
+ if (existing !== undefined)
27
+ return existing;
28
+ const filename = input.src.split("?")[0].split("/").pop() || "material";
29
+ const dot = filename.lastIndexOf(".");
30
+ const stem = input.name || (dot > 0 ? filename.substring(0, dot) : filename);
31
+ let key = stem;
32
+ let n = 2;
33
+ while (key in this.assets)
34
+ key = `${stem}-${n++}`;
35
+ this.assets[key] = { src: input.src, kind: input.kind, ...(input.identity ? { asset: { ...input.identity } } : {}) };
36
+ this.assetBySrc.set(input.src, key);
37
+ return key;
38
+ }
39
+ materialEntity(asset, material, entityId) {
40
+ if (!this.assets[asset])
41
+ throw new Error(`Unknown document asset: ${asset}`);
42
+ const key = JSON.stringify([this.assets[asset].src, material.id]);
43
+ const existing = this.materialBySource.get(key);
44
+ if (existing)
45
+ return existing.id;
46
+ const base = this.baseMaterialBySource.get(key);
47
+ const reference = base ? { ...base, asset } : {
48
+ id: entityId, asset, address: { type: "material", id: material.id, ...(material.name ? { name: material.name } : {}) },
49
+ };
50
+ this.addReference(reference);
51
+ this.materialBySource.set(key, reference);
52
+ return reference.id;
53
+ }
54
+ environmentEntity(asset, entityId) {
55
+ if (!this.assets[asset])
56
+ throw new Error(`Unknown document asset: ${asset}`);
57
+ this.addReference({ id: entityId, asset, address: { type: "hdr" } });
58
+ return entityId;
59
+ }
60
+ addReference(reference) {
61
+ const existing = this.referenceById.get(reference.id);
62
+ if (existing) {
63
+ if (existing.asset !== reference.asset || existing.address?.type !== reference.address?.type || existing.address?.id !== reference.address?.id) {
64
+ throw new Error(`Document reference id '${reference.id}' already identifies another resource`);
65
+ }
66
+ return;
67
+ }
68
+ this.references.push(reference);
69
+ this.referenceById.set(reference.id, reference);
70
+ }
71
+ materialKey(reference, assets) {
72
+ return reference.address?.type === "material" && reference.address.id && assets[reference.asset]
73
+ ? JSON.stringify([assets[reference.asset].src, reference.address.id]) : null;
74
+ }
75
+ }
@@ -0,0 +1,9 @@
1
+ export interface DocumentCollectionSelectorOptions {
2
+ matchesComponentType?(component: object, name: string): boolean;
3
+ matchesComponentGuid?(component: object, guid: string): boolean;
4
+ deriveComponentGuid?(referenceId: string, fileGuid: string): string;
5
+ referenceId?: string;
6
+ report?(message: string): void;
7
+ }
8
+ /** Resolve an index or field qualifier; ambiguous identities never select an arbitrary member. */
9
+ export declare function resolveDocumentCollectionElement(collection: readonly unknown[], selector: string, options?: DocumentCollectionSelectorOptions): unknown;
@@ -0,0 +1,29 @@
1
+ /** Resolve an index or field qualifier; ambiguous identities never select an arbitrary member. */
2
+ export function resolveDocumentCollectionElement(collection, selector, options = {}) {
3
+ if (/^\d+$/.test(selector))
4
+ return collection[Number(selector)];
5
+ const qualifier = /^([A-Za-z_][A-Za-z0-9_-]*):([\s\S]*)$/.exec(selector);
6
+ if (!qualifier) {
7
+ options.report?.(`"${selector}" is neither an index nor a <field>:<value> qualifier`);
8
+ return undefined;
9
+ }
10
+ const [, field, value] = qualifier;
11
+ const elements = collection.filter((element) => element !== null && typeof element === "object");
12
+ let matches = elements.filter(element => {
13
+ if (field === "type")
14
+ return options.matchesComponentType?.(element, value) ?? element.constructor?.name === value;
15
+ if (field === "guid")
16
+ return options.matchesComponentGuid?.(element, value)
17
+ ?? (element.guid === value || element.needle_editor_guid === value);
18
+ return element[field] === value;
19
+ });
20
+ if (!matches.length && field === "guid" && options.referenceId && options.deriveComponentGuid) {
21
+ const derived = options.deriveComponentGuid(options.referenceId, value);
22
+ matches = elements.filter(element => element.guid === derived);
23
+ }
24
+ if (matches.length > 1) {
25
+ options.report?.(`"${selector}" matched ${matches.length} elements ? the document must name one unambiguously`);
26
+ return undefined;
27
+ }
28
+ return matches[0];
29
+ }
@@ -0,0 +1,72 @@
1
+ import { type NeedleAddress } from "../index.js";
2
+ import { type ProjectDocument, type ProjectDocumentTarget, type ProjectEvaluationIssue, type ProjectEvaluationResult, type EvaluateProjectDocumentOptions } from "./types.js";
3
+ import type { DocumentNode as Object3D, DocumentModel as Model, DocumentRuntime as Context, DocumentDefContext as SerializationContext } from "./runtime.js";
4
+ /** Shared asset/entity preparation; does not place nodes into the host scene. */
5
+ export declare function createDocumentAssetLoader(context: Pick<Context, "parse" | "parkTemplate" | "loadEnvironment">, document: ProjectDocument, options: EvaluateProjectDocumentOptions | undefined, issue: (arc: ProjectEvaluationIssue["arc"], message: string) => void): {
6
+ loadParsedAsset: (assetId: string, arc?: ProjectEvaluationIssue["arc"]) => Promise<Model | null>;
7
+ entities: Map<string, Promise<object | null>>;
8
+ };
9
+ /**
10
+ * Evaluate a project document into the given context. One-shot; never throws for
11
+ * content issues (they are collected in the result), only for a non-document input.
12
+ *
13
+ * Order (plans/project-format-v0.md): load + place references (document order) →
14
+ * mounts → [pre-setup phase:] asset component deserialization (the engine's own
15
+ * queue, FIFO) → defs → overs. Resolution is scoped to the target reference's
16
+ * subtree; misses are reported and skipped (exact resolution only in v0).
17
+ */
18
+ export declare function evaluateProjectDocument(context: Context, document: ProjectDocument, options?: EvaluateProjectDocumentOptions): Promise<ProjectEvaluationResult>;
19
+ export declare function splitPath(path: string): string[];
20
+ /**
21
+ * The serialization context ONE def application (or a whole document's worth)
22
+ * deserializes through: rooted at the COMPOSITION root so `{guid}` references in
23
+ * def data resolve across the whole composed result (cross-reference wiring).
24
+ * `baseUrl`: URL-string component fields (e.g. a tracked image's remote object) go
25
+ * through the EXISTING AssetReference.getOrCreate deserialization — it needs a
26
+ * source id to resolve document-relative urls against (no new loading path).
27
+ */
28
+ export declare function createDefSerializationContext(context: Context, root: Object3D, baseUrl?: string): SerializationContext;
29
+ /**
30
+ * Apply ONE def to the live composition — create/adopt the node, then
31
+ * instantiate/adopt its components. Exported as the single-arc primitive:
32
+ * document evaluation loops over it, and the op-bridge APPLICATOR applies a
33
+ * creation op's carried def through exactly this function (one implementation,
34
+ * plans/operation-bridge.md fixture 2).
35
+ */
36
+ export declare function applyDef(def: NonNullable<ProjectDocument["defs"]>[number], root: Object3D, references: Map<string, Object3D>, issue: (arc: "def", message: string) => void, serializationContext: SerializationContext): void;
37
+ /**
38
+ * The shared state a sequence of over applications rides — constructed once per
39
+ * document evaluation AND once per op-bridge apply session (the applicator,
40
+ * plans/operation-bridge.md fixture 2):
41
+ * - async entity chaining: entity-targeted overs and entity-ref values await the
42
+ * entity's instantiation; overs touching the same entity keep their order among
43
+ * themselves by chaining on the entity's tail promise. Non-async overs apply
44
+ * synchronously (pre-awake); `settled()` awaits the async remainder.
45
+ * - deactivated subtrees ("active": false → removeFromParent), per scope ref:
46
+ * materials are RESOURCES, not graph nodes — a material used only by a
47
+ * deactivated mesh must stay addressable (doc order legally deactivates first
48
+ * and patches after), so material resolution searches these detached roots too.
49
+ */
50
+ export type OverApplyState = {
51
+ /** Bind original-scene addresses before any rename, removal or reparent changes their paths. */
52
+ sceneTargets?: ReadonlyMap<ProjectDocumentTarget, Object3D | null>;
53
+ readonly entities: Map<string, Promise<object | null>>;
54
+ readonly deactivatedRoots: Map<string | undefined, Object3D[]>;
55
+ chainOnEntity(refId: string, work: (entity: object | null) => void): void;
56
+ settled(): Promise<void>;
57
+ };
58
+ export declare function createOverApplyState(entities: Map<string, Promise<object | null>>): OverApplyState;
59
+ /**
60
+ * Apply ONE over to the live composition — the single-arc primitive: document
61
+ * evaluation loops over it, and the op-bridge APPLICATOR applies a remote
62
+ * property/assign/deactivation op through exactly this function (one
63
+ * implementation, plans/operation-bridge.md fixture 2).
64
+ */
65
+ export declare function applyOver(context: Context, over: NonNullable<ProjectDocument["overs"]>[number], root: Object3D, references: Map<string, Object3D>, state: OverApplyState, issue: (arc: "over", message: string) => void): void;
66
+ /**
67
+ * Material entity resolution incl. MATERIAL-ONLY glbs (a promoted material
68
+ * library entry: materials but no scene/meshes). The loader never
69
+ * instantiates an unreferenced material, so a scene traversal finds nothing —
70
+ * pull the materials from the parser instead. Exported for tests.
71
+ */
72
+ export declare function resolveMaterialEntity(model: Model, address: NeedleAddress): Promise<object | null>;