@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,249 @@
1
+ import type { NeedleAddress } from "../address.js";
2
+ import type { DocumentNode, DocumentModel } from "./runtime.js";
3
+
4
+ /** the version discriminator key every project document must carry */
5
+ export const PROJECT_DOCUMENT_KEY = "needle:project";
6
+
7
+ export type ProjectDocumentAsset = {
8
+ /** where the asset lives — resolved against `EvaluateProjectDocumentOptions.baseUrl` */
9
+ src: string;
10
+ /**
11
+ * Optional PROVIDER IDENTITY for assets whose resources (textures, buffers) live at
12
+ * provider-specific urls that `src` alone can't reach (e.g. PolyHaven). The host recovers
13
+ * the provider's resolver from this identity at load time (see
14
+ * `EvaluateProjectDocumentOptions.resolveAssetUrl`). Self-contained assets omit it and
15
+ * load by `src`. The engine ignores it; only the host (editor) reads it.
16
+ */
17
+ asset?: { id: string; provider?: string };
18
+ /**
19
+ * Optional SEMANTIC KIND, decided at USE time by the writer ("model", "material",
20
+ * "hdr", "texture", "image", "video", "audio", "code", "text") — the same file can
21
+ * be a texture on a material slot or a plain image in UI, and downstream
22
+ * processing (compression at publish) follows the use, not the extension. The
23
+ * engine ignores it; only hosts/tooling read it.
24
+ */
25
+ kind?: string;
26
+ /**
27
+ * INFORMATIONAL: the server `ETag` observed when this document captured the
28
+ * asset — lets a future reader detect "the upstream file changed since".
29
+ * Never used for loading decisions; the engine ignores it.
30
+ */
31
+ etag?: string;
32
+ };
33
+
34
+ export type ProjectDocumentReference = {
35
+ /** project-local id of this reference — the resolution scope (or entity handle) for targets */
36
+ id: string;
37
+ /** key into the document's `assets` map */
38
+ asset: string;
39
+ /**
40
+ * ELEMENT REFERENCE discriminator: when present, this reference instantiates the
41
+ * addressed element INSIDE the asset (e.g. a material) into the composition's ENTITY
42
+ * POOL instead of placing a scene subtree. Entity references are first-class composed
43
+ * entities: overs can target them (`{ ref: <id>, path: "map/repeat" }`) and values can
44
+ * point at them (`{ ref: <id> }` — e.g. a mesh's material assignment). N consumers of
45
+ * the same entity share ONE runtime instance by construction (USD: a Material-prim
46
+ * reference; assignment ≈ material:binding).
47
+ */
48
+ address?: NeedleAddress;
49
+ /** @deprecated (2026-08-03, read-only shim — remove soon) replaced by parent OVERS
50
+ * (`{ target: { ref, path: "parent" }, value: { ref | "$scene", address?, index? } }`)
51
+ * days after shipping; only unreleased documents from that week carry this field.
52
+ * The writer no longer emits it; the evaluator still honors it at placement. */
53
+ parent?: { ref: string; address?: NeedleAddress };
54
+ };
55
+
56
+ /** A host-owned root that document opinions compose onto instead of instantiating. */
57
+ export type ProjectDocumentBinding = {
58
+ /** Document-local reference used by targets. `$`-prefixed ids are reserved. */
59
+ id: string;
60
+ /** The kind of host root required by this document. */
61
+ kind: "scene";
62
+ /** Stable structural identity used by the host to match this root before applying edits. */
63
+ signature: string;
64
+ /** Informational label for diagnostics only; never used as identity. */
65
+ name?: string;
66
+ /** Existing asset-reference ids whose instances belong to this host root. */
67
+ references?: string[];
68
+ };
69
+
70
+ export type ProjectDocumentTarget = {
71
+ /** which placed reference the address resolves WITHIN; omitted → the composition root */
72
+ ref?: string;
73
+ /** fat address of the element inside the reference (needle-id / anchor / path) */
74
+ address?: NeedleAddress;
75
+ /**
76
+ * Property path within the resolved element, "/"-separated.
77
+ *
78
+ * A segment landing on a COLLECTION may be a `<field>:<value>` qualifier instead of an
79
+ * index — `components/type:DragControls/enabled`, `components/guid:6f1f…/enabled` —
80
+ * so a document never depends on array order (`userData.components` reorders whenever
81
+ * a component is added or removed). A bare numeric segment on an array is still an
82
+ * index, but it is now an explicit opt-in rather than the only option.
83
+ * `components` is a synthesized segment on a node: the live layout
84
+ * (`userData.components`) stays an implementation detail.
85
+ *
86
+ * Reserved LAST segment: `active` — with `value: false` it removes whatever the rest
87
+ * of the path resolved to (the node itself, or a component) from the composed result.
88
+ */
89
+ path?: string;
90
+ };
91
+
92
+ export type ProjectDocumentDef = {
93
+ target: ProjectDocumentTarget;
94
+ /**
95
+ * When present, the def CREATES a new node (USD-proper: def defines prims) under the
96
+ * resolved target (composition root when the target is empty) and the components
97
+ * apply to IT — e.g. the runtime start camera. Without `create`, components apply to
98
+ * the resolved existing node.
99
+ */
100
+ create?: {
101
+ id?: string;
102
+ name?: string;
103
+ position?: number[];
104
+ quaternion?: number[];
105
+ scale?: number[];
106
+ /** Recreate a hierarchy duplicate from its stable source and seed. */
107
+ copy?: {
108
+ source: ProjectDocumentTarget;
109
+ seed: string;
110
+ reference?: boolean;
111
+ } & ({ batch: string; index: number } | { batch?: never; index?: never });
112
+ };
113
+ /** VERBATIM NEEDLE_components wire entries ({ name, guid, ...fields }) */
114
+ components?: Array<{ name: string } & Record<string, unknown>>;
115
+ };
116
+
117
+ export type ProjectDocumentOver = {
118
+ target: ProjectDocumentTarget;
119
+ value: unknown;
120
+ };
121
+
122
+ export type ProjectDocument = {
123
+ [PROJECT_DOCUMENT_KEY]: string;
124
+ /** layer metadata (USD's "layer metadata" concept — e.g. `name`); free-form */
125
+ metadata?: Record<string, unknown>;
126
+ /** named remote origins for `remote://<name>/<path>` srcs — the scheme
127
+ * routes, the AUTHORITY names the remote, this table supplies the base.
128
+ * Resolved per DOCUMENT (a nested document resolves with ITS table), so
129
+ * the document works wherever it travels. Semantics mirror the editor's
130
+ * remoteSrc.ts — keep the two in sync. */
131
+ remotes?: Record<string, string>;
132
+ assets?: Record<string, ProjectDocumentAsset>;
133
+ /** Required host-owned roots. The host resolves these before evaluation. */
134
+ bindings?: ProjectDocumentBinding[];
135
+ references?: ProjectDocumentReference[];
136
+ defs?: ProjectDocumentDef[];
137
+ overs?: ProjectDocumentOver[];
138
+ /** unknown arcs are ignored by evaluation and must be preserved by writers */
139
+ [arc: string]: unknown;
140
+ };
141
+
142
+ const REMOTE_SRC_SCHEME = "remote://";
143
+
144
+ /**
145
+ * Resolve a `remote://name/path` src via the document's remotes table
146
+ * (`overrides` win for known names — the host's per-environment repoint, e.g.
147
+ * the editor's dev proxy). Non-remote srcs pass through. An UNKNOWN remote
148
+ * name resolves to null — the caller reports it; guessing a base would load
149
+ * the wrong bytes silently. MIRRORS the editor's remoteSrc.ts resolveRemoteSrc.
150
+ */
151
+ export function resolveDocumentRemoteSrc(src: string, remotes: Record<string, string> | undefined, overrides?: Record<string, string>): string | null {
152
+ if (!src.startsWith(REMOTE_SRC_SCHEME)) return src;
153
+ const rest = src.substring(REMOTE_SRC_SCHEME.length);
154
+ const slash = rest.indexOf("/");
155
+ if (slash <= 0) return null;
156
+ const name = rest.substring(0, slash);
157
+ const path = rest.substring(slash); // keeps the leading "/"
158
+ const base = overrides?.[name] ?? remotes?.[name];
159
+ if (base === undefined) return null;
160
+ // "" base = host-relative; trim a trailing slash otherwise
161
+ return (base.endsWith("/") ? base.substring(0, base.length - 1) : base) + path;
162
+ }
163
+
164
+ /** structural check for the version discriminator — the opener/sniffing entry point */
165
+ export function isProjectDocument(value: unknown): value is ProjectDocument {
166
+ return !!value && typeof value === "object" && typeof (value as Record<string, unknown>)[PROJECT_DOCUMENT_KEY] === "string";
167
+ }
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // evaluation
171
+ // ---------------------------------------------------------------------------
172
+
173
+ export type ProjectEvaluationIssue = {
174
+ arc: "reference" | "def" | "over";
175
+ message: string;
176
+ };
177
+
178
+ /**
179
+ * Report evaluation issues to the console — in PRODUCTION too. Every issue
180
+ * means the document asked for something the runtime could not provide (a
181
+ * dropped def, an unresolved over, a failed asset), i.e. the loaded scene
182
+ * silently differs from what the author saved. The author debugs on the
183
+ * deployed page: gating this behind dev/?debugproject cost a full day of
184
+ * blind hunting when a deployed scene lost a component with a clean console
185
+ * (2026-09-01). A healthy document reports nothing.
186
+ */
187
+ export function reportProjectIssues(issues: readonly ProjectEvaluationIssue[], warn: (message: string) => void = console.warn): void {
188
+ for (const issue of issues) {
189
+ warn(`[needle:project] ${issue.arc}: ${issue.message}`);
190
+ }
191
+ }
192
+
193
+ export type ProjectEvaluationResult = {
194
+ /** the object all non-mounted references were added to */
195
+ root: DocumentNode;
196
+ /** Placed reference roots by reference id. Populated during the host's scheduled
197
+ * composition phase, after template deserialization; await `completed` first. */
198
+ references: Map<string, DocumentNode>;
199
+ /** per-reference Model views by reference id (`scene` is that reference's placed
200
+ * INSTANCE; parser etc. come from the shared template). Populated at `completed`,
201
+ * like `references`. */
202
+ models: Map<string, DocumentModel>;
203
+ /**
204
+ * misses and errors — an issue never aborts evaluation, the arc is skipped.
205
+ * Populated up to `completed` (defs/overs apply in the host's composition phase).
206
+ */
207
+ issues: ProjectEvaluationIssue[];
208
+ /**
209
+ * Resolves once defs/overs have been applied. Hosts schedule composition after
210
+ * asset deserialization and before component activation. Needle's adapter uses
211
+ * its pre-setup phase, requiring a context tick; a vanilla host can run immediately.
212
+ */
213
+ completed: Promise<void>;
214
+ };
215
+
216
+ export type EvaluateProjectDocumentOptions = {
217
+ /** Reuse existing roots as named resolution scopes. Bindings may supply external
218
+ * host roots that have no asset reference in the document. When supplied, every
219
+ * declared scene reference must also be bound; a missing binding is reported
220
+ * instead of loading another instance. */
221
+ referenceBindings?: ReadonlyMap<string, DocumentNode>;
222
+ /** where references are placed; defaults to `context.scene` */
223
+ parent?: DocumentNode;
224
+ /** resolves `assets.src` for the document — tests/tools provide in-memory data here */
225
+ resolveAsset?: (assetId: string, src: string) => Promise<ArrayBuffer | string>;
226
+ /** base for resolving relative `assets.src` urls with the default (fetch) resolver */
227
+ baseUrl?: string;
228
+ /** per-environment overrides for the document's `remotes` table (known
229
+ * names only — the editor maps needle-cloud to its dev proxy); the
230
+ * document's own table is the default */
231
+ remotes?: Record<string, string>;
232
+ /**
233
+ * Rewrites a resource url (texture / external buffer) encountered while parsing an
234
+ * asset — the asset service's `resolveUrl` re-entering at load time. Needed for provider
235
+ * assets (e.g. PolyHaven) whose textures live at urls that plain relative resolution
236
+ * against `assets.src` cannot reach. Passed to the host's parse operation;
237
+ * the editor opener supplies it from `IAssetsService` so this
238
+ * evaluator carries NO provider knowledge.
239
+ */
240
+ resolveAssetUrl?: (assetId: string, url: string) => string;
241
+ /**
242
+ * AGGREGATE download progress across the document's assets: `loaded` bytes summed
243
+ * over all asset downloads, `total` summed over the known Content-Lengths (0 while
244
+ * nothing reported a length). Fired per received chunk — the loader chain adapts it
245
+ * to the ProgressEvent shape the loading UI expects.
246
+ */
247
+ onProgress?: (loaded: number, total: number) => void;
248
+ };
249
+
@@ -0,0 +1,222 @@
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
+
20
+ /** a JSON Schema node — `true`/`false` schema forms are not used by our schema */
21
+ type SchemaNode = Record<string, unknown>;
22
+
23
+ export type SchemaValidationError = {
24
+ /** "/"-separated path to the offending value, relative to the document root */
25
+ readonly path: string;
26
+ readonly message: string;
27
+ };
28
+
29
+ /** keywords that carry no constraint — annotations, identity, and the definition bag */
30
+ const IGNORED_KEYWORDS = new Set(["$schema", "$id", "$comment", "title", "description", "default", "examples", "definitions"]);
31
+
32
+ /** keywords this interpreter implements; anything else in the schema is a hard error */
33
+ const SUPPORTED_KEYWORDS = new Set([
34
+ "$ref", "type", "enum", "const", "required", "properties", "additionalProperties",
35
+ "items", "minItems", "maxItems", "minLength", "allOf", "anyOf", "not",
36
+ ]);
37
+
38
+ /**
39
+ * Validate `value` against `schema`. Returns every error found (not just the first) —
40
+ * a writer regression usually shows up as several related failures and seeing them
41
+ * together is what makes the cause obvious.
42
+ *
43
+ * @throws if the schema uses a keyword this interpreter does not implement.
44
+ */
45
+ export function validateAgainstSchema(schema: SchemaNode, value: unknown): SchemaValidationError[] {
46
+ const errors: SchemaValidationError[] = [];
47
+ validateNode(schema, value, "", schema, errors);
48
+ return errors;
49
+ }
50
+
51
+ /** one-line-per-error rendering for assertion messages */
52
+ export function formatSchemaErrors(errors: readonly SchemaValidationError[]): string {
53
+ return errors.map(e => ` ${e.path || "<root>"}: ${e.message}`).join("\n");
54
+ }
55
+
56
+ function validateNode(schema: SchemaNode, value: unknown, path: string, root: SchemaNode, errors: SchemaValidationError[]): void {
57
+ for (const keyword of Object.keys(schema)) {
58
+ if (IGNORED_KEYWORDS.has(keyword) || SUPPORTED_KEYWORDS.has(keyword)) continue;
59
+ throw new Error(`projectDocumentValidation: unsupported JSON Schema keyword "${keyword}" at "${path || "<root>"}" — implement it before using it in the schema`);
60
+ }
61
+ assertWellFormed(schema, path);
62
+
63
+ if (typeof schema.$ref === "string") {
64
+ validateNode(resolveRef(schema.$ref, root), value, path, root, errors);
65
+ // sibling keywords next to $ref are ignored in draft-07 — our schema never mixes them
66
+ return;
67
+ }
68
+
69
+ if (schema.type !== undefined && !matchesType(schema.type, value)) {
70
+ errors.push({ path, message: `expected type ${JSON.stringify(schema.type)}, got ${describe(value)}` });
71
+ return; // every remaining keyword assumes the type held — reporting them too is noise
72
+ }
73
+
74
+ if (Array.isArray(schema.enum) && !schema.enum.some(candidate => deepEquals(candidate, value))) {
75
+ errors.push({ path, message: `expected one of ${JSON.stringify(schema.enum)}, got ${describe(value)}` });
76
+ }
77
+ if ("const" in schema && !deepEquals(schema.const, value)) {
78
+ errors.push({ path, message: `expected ${JSON.stringify(schema.const)}, got ${describe(value)}` });
79
+ }
80
+
81
+ if (typeof schema.minLength === "number" && typeof value === "string" && value.length < schema.minLength) {
82
+ errors.push({ path, message: `expected at least ${schema.minLength} character(s), got ${value.length}` });
83
+ }
84
+
85
+ if (Array.isArray(value)) {
86
+ if (typeof schema.minItems === "number" && value.length < schema.minItems) {
87
+ errors.push({ path, message: `expected at least ${schema.minItems} item(s), got ${value.length}` });
88
+ }
89
+ if (typeof schema.maxItems === "number" && value.length > schema.maxItems) {
90
+ errors.push({ path, message: `expected at most ${schema.maxItems} item(s), got ${value.length}` });
91
+ }
92
+ if (isSchema(schema.items)) {
93
+ for (let i = 0; i < value.length; i++) validateNode(schema.items, value[i], join(path, String(i)), root, errors);
94
+ }
95
+ }
96
+
97
+ if (isPlainObject(value)) {
98
+ if (Array.isArray(schema.required)) {
99
+ for (const key of schema.required) {
100
+ if (typeof key === "string" && !(key in value)) {
101
+ errors.push({ path, message: `missing required property "${key}"` });
102
+ }
103
+ }
104
+ }
105
+ const properties = isPlainObject(schema.properties) ? schema.properties : undefined;
106
+ if (properties) {
107
+ for (const [key, sub] of Object.entries(properties)) {
108
+ if (key in value && isSchema(sub)) validateNode(sub, value[key], join(path, key), root, errors);
109
+ }
110
+ }
111
+ const additional = schema.additionalProperties;
112
+ if (additional === false || isSchema(additional)) {
113
+ for (const [key, entry] of Object.entries(value)) {
114
+ if (properties && key in properties) continue;
115
+ if (additional === false) {
116
+ errors.push({ path: join(path, key), message: "unexpected property" });
117
+ }
118
+ else {
119
+ validateNode(additional as SchemaNode, entry, join(path, key), root, errors);
120
+ }
121
+ }
122
+ }
123
+ }
124
+
125
+ if (Array.isArray(schema.allOf)) {
126
+ for (const sub of schema.allOf) if (isSchema(sub)) validateNode(sub, value, path, root, errors);
127
+ }
128
+ if (Array.isArray(schema.anyOf)) {
129
+ const matched = schema.anyOf.some(sub => isSchema(sub) && validateAgainstSchemaIn(sub, value, root).length === 0);
130
+ if (!matched) errors.push({ path, message: "matched none of the allowed forms (anyOf)" });
131
+ }
132
+ if (isSchema(schema.not) && validateAgainstSchemaIn(schema.not, value, root).length === 0) {
133
+ // our schema uses `not: {}` to mark RETIRED fields — say so rather than leaking
134
+ // "must not match the empty schema" at the reader
135
+ const why = typeof schema.description === "string" ? schema.description : "value is not allowed here";
136
+ errors.push({ path, message: why });
137
+ }
138
+ }
139
+
140
+ /**
141
+ * A keyword whose VALUE has an unexpected shape would be skipped by the checks below —
142
+ * the same silent-pass hazard as an unknown keyword, so it is the same hard error.
143
+ */
144
+ function assertWellFormed(schema: SchemaNode, path: string): void {
145
+ const bad = (keyword: string, expected: string): never => {
146
+ throw new Error(`projectDocumentValidation: "${keyword}" at "${path || "<root>"}" must be ${expected}`);
147
+ };
148
+ if ("$ref" in schema && typeof schema.$ref !== "string") bad("$ref", "a string");
149
+ if ("type" in schema && typeof schema.type !== "string" && !Array.isArray(schema.type)) bad("type", "a string or array of strings");
150
+ if ("enum" in schema && !Array.isArray(schema.enum)) bad("enum", "an array");
151
+ if ("required" in schema && !Array.isArray(schema.required)) bad("required", "an array");
152
+ if ("properties" in schema && !isPlainObject(schema.properties)) bad("properties", "an object");
153
+ if ("items" in schema && !isPlainObject(schema.items)) bad("items", "a schema object (tuple form is not supported)");
154
+ if ("not" in schema && !isPlainObject(schema.not)) bad("not", "a schema object");
155
+ if ("allOf" in schema && !Array.isArray(schema.allOf)) bad("allOf", "an array");
156
+ if ("anyOf" in schema && !Array.isArray(schema.anyOf)) bad("anyOf", "an array");
157
+ if ("additionalProperties" in schema && typeof schema.additionalProperties !== "boolean" && !isPlainObject(schema.additionalProperties)) bad("additionalProperties", "a boolean or schema object");
158
+ for (const keyword of ["minItems", "maxItems", "minLength"]) {
159
+ if (keyword in schema && typeof schema[keyword] !== "number") bad(keyword, "a number");
160
+ }
161
+ }
162
+
163
+ /** sub-validation that keeps the same `$ref` root (used by anyOf/not) */
164
+ function validateAgainstSchemaIn(schema: SchemaNode, value: unknown, root: SchemaNode): SchemaValidationError[] {
165
+ const errors: SchemaValidationError[] = [];
166
+ validateNode(schema, value, "", root, errors);
167
+ return errors;
168
+ }
169
+
170
+ function resolveRef(ref: string, root: SchemaNode): SchemaNode {
171
+ if (!ref.startsWith("#/")) throw new Error(`projectDocumentValidation: only local "#/..." $refs are supported, got "${ref}"`);
172
+ let current: unknown = root;
173
+ for (const segment of ref.slice(2).split("/")) {
174
+ if (!isPlainObject(current)) throw new Error(`projectDocumentValidation: $ref "${ref}" does not resolve`);
175
+ current = current[decodeURIComponent(segment.replace(/~1/g, "/").replace(/~0/g, "~"))];
176
+ }
177
+ if (!isSchema(current)) throw new Error(`projectDocumentValidation: $ref "${ref}" does not resolve to a schema`);
178
+ return current;
179
+ }
180
+
181
+ function matchesType(type: unknown, value: unknown): boolean {
182
+ if (Array.isArray(type)) return type.some(t => matchesType(t, value));
183
+ switch (type) {
184
+ case "object": return isPlainObject(value);
185
+ case "array": return Array.isArray(value);
186
+ case "string": return typeof value === "string";
187
+ case "number": return typeof value === "number" && Number.isFinite(value);
188
+ case "integer": return typeof value === "number" && Number.isInteger(value);
189
+ case "boolean": return typeof value === "boolean";
190
+ case "null": return value === null;
191
+ default: throw new Error(`projectDocumentValidation: unsupported "type" value ${JSON.stringify(type)}`);
192
+ }
193
+ }
194
+
195
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
196
+ return typeof value === "object" && value !== null && !Array.isArray(value);
197
+ }
198
+
199
+ function isSchema(value: unknown): value is SchemaNode {
200
+ return isPlainObject(value);
201
+ }
202
+
203
+ function join(path: string, segment: string): string {
204
+ return path ? `${path}/${segment}` : segment;
205
+ }
206
+
207
+ function describe(value: unknown): string {
208
+ if (value === null) return "null";
209
+ if (Array.isArray(value)) return "array";
210
+ if (value === undefined) return "undefined";
211
+ return typeof value;
212
+ }
213
+
214
+ function deepEquals(a: unknown, b: unknown): boolean {
215
+ if (a === b) return true;
216
+ if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((v, i) => deepEquals(v, b[i]));
217
+ if (isPlainObject(a) && isPlainObject(b)) {
218
+ const keys = Object.keys(a);
219
+ return keys.length === Object.keys(b).length && keys.every(k => k in b && deepEquals(a[k], b[k]));
220
+ }
221
+ return false;
222
+ }
@@ -0,0 +1,112 @@
1
+ import type { NeedleAddress } from "../address.js";
2
+ import type { ProjectDocumentOver, ProjectDocumentTarget } from "./types.js";
3
+
4
+ export interface PropertyOverInput {
5
+ readonly target: ProjectDocumentTarget;
6
+ readonly value: unknown;
7
+ readonly previousValue: unknown;
8
+ readonly sequence?: number;
9
+ readonly alwaysWrite?: boolean;
10
+ }
11
+
12
+ export type PropertyOverResult =
13
+ | { over: ProjectDocumentOver; sequence: number }
14
+ | { reverted: ProjectDocumentOver }
15
+ | { issue: string };
16
+
17
+ /** Serialize one captured edit; adapters supply ownership and live-target checks. */
18
+ export function writePropertyOver(input: PropertyOverInput, encodeNode?: (object: object) => unknown): PropertyOverResult {
19
+ const value = captureDocumentValue(input.value, encodeNode);
20
+ if (value === undefined) return { issue: "value type is not expressible in the document yet" };
21
+ const base = captureDocumentValue(input.previousValue, encodeNode);
22
+ if (!input.alwaysWrite && base !== undefined && JSON.stringify(base) === JSON.stringify(value)) {
23
+ return { reverted: { target: input.target, value: base } };
24
+ }
25
+ return { over: { target: input.target, value }, sequence: input.sequence ?? 0 };
26
+ }
27
+
28
+ export function isDocumentReferenceValue(value: unknown): value is { ref: string; address?: NeedleAddress } {
29
+ if (!value || typeof value !== "object") return false;
30
+ const candidate = value as Record<string, unknown>;
31
+ if (typeof candidate.ref !== "string" || Object.keys(candidate).some(key => key !== "ref" && key !== "address")) return false;
32
+ const address = candidate.address;
33
+ return address === undefined || (!!address && typeof address === "object" && !Array.isArray(address)
34
+ && Object.entries(address).every(([key, entry]) => ["id", "anchor", "type", "name", "path"].includes(key) && typeof entry === "string"));
35
+ }
36
+
37
+ export function captureDocumentValue(value: unknown, encodeNode?: (object: object) => unknown): unknown {
38
+ if (value === null) return null;
39
+ if (isDocumentReferenceValue(value)) return { ref: value.ref, ...(value.address ? { address: { ...value.address } } : {}) };
40
+ switch (typeof value) {
41
+ case "number": return Number.isFinite(value) ? value : undefined;
42
+ case "string":
43
+ case "boolean":
44
+ return value;
45
+ case "object": {
46
+ // scene-object values (object slots) → NODE REFERENCES ({ref, address?});
47
+ // unattributable objects fall through to undefined = "not expressible"
48
+ if ((value as { isObject3D?: boolean }).isObject3D === true) {
49
+ return encodeNode?.(value);
50
+ }
51
+ const toArray = (value as { toArray?: unknown }).toArray;
52
+ if (typeof toArray === "function") {
53
+ const array = (value as { toArray(): unknown[] }).toArray();
54
+ if (Array.isArray(array)) {
55
+ // three value types are numeric arrays: Vector[234]/Quaternion/Color
56
+ // ([x,y,z(,w)]). Euler trails a string `order` — take the leading
57
+ // numeric run; the evaluator's Euler.fromArray([x,y,z]) keeps the
58
+ // node's own order. Reject anything with more than one trailing
59
+ // non-number (not a known value type).
60
+ const nums: number[] = [];
61
+ for (const v of array) { if (typeof v === "number" && Number.isFinite(v)) nums.push(v); else break; }
62
+ if (nums.length > 0 && (nums.length === array.length || (nums.length === array.length - 1 && typeof array[array.length - 1] === "string"))) return nums;
63
+ }
64
+ }
65
+ if (Array.isArray(value) && value.every(v => (typeof v === "number" && Number.isFinite(v)) || typeof v === "string" || typeof v === "boolean")) {
66
+ return [...value];
67
+ }
68
+ return undefined;
69
+ }
70
+ default:
71
+ return undefined;
72
+ }
73
+ }
74
+
75
+ export function documentOverKey(over: ProjectDocumentOver): string {
76
+ const t = over.target;
77
+ const addressKey = t.address?.id ?? t.address?.path ?? t.address?.name ?? "";
78
+ return `${t.ref ?? ""}::${addressKey}::${t.path ?? ""}`;
79
+ }
80
+
81
+ /**
82
+ * Merge base-document overs with freshly captured ones: a new over SUPERSEDES a base
83
+ * over on the same target (the user re-edited it); base overs whose reference is gone
84
+ * are dropped; everything else is preserved.
85
+ */
86
+ export function mergeDocumentOvers(baseOvers: readonly ProjectDocumentOver[], newOvers: readonly ProjectDocumentOver[], liveRefs: ReadonlySet<string>, contentRefIds: ReadonlySet<string>, alwaysSuperseded?: ReadonlySet<string>, skipped?: string[]): ProjectDocumentOver[] {
87
+ const superseded = new Set(newOvers.map(documentOverKey));
88
+ const result: ProjectDocumentOver[] = [];
89
+ for (const over of baseOvers) {
90
+ // parent overs of the REGENERATED classes never merge back (re-derived
91
+ // live every save): address-less = reference roots; addressed on a CONTENT
92
+ // ref = content children. Addressed parent overs on OTHER refs are
93
+ // ops-recorded asset moves — they PERSIST across sessions and supersede
94
+ // by key on re-move.
95
+ if (over.target.path === "parent" && (!over.target.address || (over.target.ref !== undefined && contentRefIds.has(over.target.ref)))) continue;
96
+ if (over.target.ref && !liveRefs.has(over.target.ref)) {
97
+ // reference removed — the over dies with it. EXPECTED after a reference
98
+ // delete, but SURFACED regardless ("Save Project never drops an edit
99
+ // silently"): an unexpected entry here is the symptom of an attribution
100
+ // bug (wrong ref id), and silence cost a debugging session once
101
+ // (plans/project-format-v0.md item 7b).
102
+ skipped?.push(`over ${over.target.path ?? "?"} on reference "${over.target.ref}" — reference no longer exists, over dropped`);
103
+ continue;
104
+ }
105
+ const key = documentOverKey(over);
106
+ if (superseded.has(key)) continue; // re-edited below
107
+ if (alwaysSuperseded?.has(key)) continue; // rebuilt-from-live paths (placement)
108
+ result.push(over);
109
+ }
110
+ result.push(...newOvers);
111
+ return result;
112
+ }
package/src/id.ts CHANGED
@@ -16,6 +16,11 @@ export const NEEDLE_ID_KEY = "needle-id";
16
16
 
17
17
  // #region Derivation
18
18
 
19
+ /** Stable instance identity, preserving the namespace used by Engine duplication. */
20
+ export function deriveInstanceId(scope: string, originalId: string): string {
21
+ return uuidv5(`${scope}:${originalId}`, "eff8ba80-635d-11ec-90d6-0242ac120003");
22
+ }
23
+
19
24
  /**
20
25
  * The name an id is derived FROM: the anchor marker removed (if any) and space/underscore
21
26
  * runs collapsed.
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { ANCHOR_MARKER, ANCHOR_MARKERS, parseAnchor, parseAnchorValue, type ParsedAnchor } from "./anchor.js";
2
2
  export {
3
3
  computeElementId,
4
+ deriveInstanceId,
4
5
  ensureIds,
5
6
  ensureNodeId,
6
7
  getElementId,
@@ -44,3 +45,5 @@ export {
44
45
  } from "./resolve.js";
45
46
  export { targetFromString, targetToString, type AddressTarget } from "./target.js";
46
47
  export { randomUuid, uuidv5, UUID_NAMESPACE_URL } from "./uuid.js";
48
+
49
+ export { attachNeedleIdsToLoadedGltf, type GltfAssociation, type AttachNeedleIdsResult } from "./loadedGltf.js";