@scenoco-three/compiler 0.1.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.
@@ -0,0 +1,308 @@
1
+ import { isScalarType, isSpatial, Registry, getNodeDef, getAttachmentDef, TRANSFORM_ATTRS, } from '@scenoco-three/core/internal';
2
+ import { parseScene } from './SceneParser.js';
3
+ import { validatePrefab, validateScene } from './Validator.js';
4
+ import { expandPrefabs } from './prefab.js';
5
+ import { codecs } from './types.js';
6
+ /** Full pipeline: parse → expand prefabs → validate → compile. */
7
+ export function compileSceneXml(xml, options = {}) {
8
+ const parsed = parseScene(xml);
9
+ if (!parsed.ok)
10
+ return { ok: false, diagnostics: parsed.diagnostics };
11
+ // Expand <PrefabInstance src> elements inline before validation, so the
12
+ // validator and compiler see ordinary nodes (the bundle format is prefab-agnostic).
13
+ const expansion = [];
14
+ expandPrefabs(parsed.root, options.path ?? '', options, expansion);
15
+ if (expansion.length > 0)
16
+ return { ok: false, diagnostics: sortDiagnostics(expansion) };
17
+ const diagnostics = validateScene(parsed.root);
18
+ if (diagnostics.length > 0)
19
+ return { ok: false, diagnostics };
20
+ const result = compileTree(parsed.root, options);
21
+ if (result.diagnostics.length > 0)
22
+ return { ok: false, diagnostics: result.diagnostics };
23
+ return { ok: true, scene: result.scene };
24
+ }
25
+ /**
26
+ * Compile a `.prefab.xml` file into a standalone bundle for **dynamic
27
+ * instantiation** at runtime (`engine.instantiate`). The compiled root is the
28
+ * prefab's single node; ids stay local (each runtime instance is its own
29
+ * subtree, and internal references are already resolved to indices).
30
+ */
31
+ export function compilePrefabXml(xml, options = {}) {
32
+ const parsed = parseScene(xml);
33
+ if (!parsed.ok)
34
+ return { ok: false, diagnostics: parsed.diagnostics };
35
+ const expansion = [];
36
+ expandPrefabs(parsed.root, options.path ?? '', options, expansion);
37
+ if (expansion.length > 0)
38
+ return { ok: false, diagnostics: sortDiagnostics(expansion) };
39
+ const diagnostics = validatePrefab(parsed.root);
40
+ if (diagnostics.length > 0)
41
+ return { ok: false, diagnostics };
42
+ const prefabRoot = parsed.root.children.find((c) => isSpatial(c.tag));
43
+ const result = compileTree(prefabRoot, options);
44
+ if (result.diagnostics.length > 0)
45
+ return { ok: false, diagnostics: result.diagnostics };
46
+ return { ok: true, scene: result.scene };
47
+ }
48
+ function sortDiagnostics(diagnostics) {
49
+ return [...diagnostics].sort((a, b) => (a.file ?? '').localeCompare(b.file ?? '') || a.line - b.line || a.column - b.column);
50
+ }
51
+ /** Compile an already-validated tree, resolving referenced resource files. */
52
+ function compileTree(root, options) {
53
+ const scene = { root: 0, nodes: [], attachments: [], components: [] };
54
+ const diagnostics = [];
55
+ const nodeIdToIndex = new Map();
56
+ const componentIdToIndex = new Map();
57
+ const componentsOnNode = new Map();
58
+ const pending = [];
59
+ // One content-addressed pool so identical attachments (geometry/material/…) are shared.
60
+ const attachmentPool = new Map();
61
+ // Prefab files referenced by `prefab` props, deduped by source path.
62
+ const prefabPool = new Map();
63
+ const internAttachment = (asset) => {
64
+ const key = `${asset.tag}|${JSON.stringify(asset.attrs)}`;
65
+ const existing = attachmentPool.get(key);
66
+ if (existing !== undefined)
67
+ return existing;
68
+ const index = scene.attachments.length;
69
+ scene.attachments.push(asset);
70
+ attachmentPool.set(key, index);
71
+ return index;
72
+ };
73
+ /**
74
+ * Resolve a `prefab="…"` file, compile it to a standalone prefab CompiledScene,
75
+ * and intern it into the scene's prefabs[] pool. Returns its index, or
76
+ * undefined (with a diagnostic) if it can't be resolved/compiled.
77
+ */
78
+ const internPrefab = (src, el) => {
79
+ const cached = prefabPool.get(src);
80
+ if (cached !== undefined)
81
+ return cached;
82
+ const push = (code, message, file) => diagnostics.push({ line: el.line, column: el.column, code, message, ...(file ? { file } : {}) });
83
+ if (!options.resolve) {
84
+ push('no-resolver', `<${el.tag}> uses prefab="${src}" but no resource resolver is configured`);
85
+ return undefined;
86
+ }
87
+ const file = options.resolve(src, options.path ?? '');
88
+ if (!file) {
89
+ push('resource-not-found', `Cannot resolve prefab="${src}"`);
90
+ return undefined;
91
+ }
92
+ const result = compilePrefabXml(file.contents, { path: file.path, resolve: options.resolve });
93
+ if (!result.ok) {
94
+ for (const d of result.diagnostics)
95
+ diagnostics.push({ ...d, file: d.file ?? file.path });
96
+ return undefined;
97
+ }
98
+ const prefabs = (scene.prefabs ??= []);
99
+ const index = prefabs.length;
100
+ prefabs.push(result.scene);
101
+ prefabPool.set(src, index);
102
+ return index;
103
+ };
104
+ const buildNode = (el, parent) => {
105
+ const def = getNodeDef(el.tag);
106
+ const index = scene.nodes.length;
107
+ const id = el.attributes.get('id');
108
+ const node = {
109
+ tag: el.tag,
110
+ ...(id !== undefined ? { id } : {}),
111
+ parent,
112
+ children: [],
113
+ transform: compileTransform(el),
114
+ attrs: compileAttrs(el, def.attrs),
115
+ attachments: [],
116
+ components: [],
117
+ };
118
+ scene.nodes.push(node);
119
+ if (id !== undefined)
120
+ nodeIdToIndex.set(id, index);
121
+ for (const child of el.children) {
122
+ const attDef = getAttachmentDef(child.tag);
123
+ if (attDef) {
124
+ const attrs = resolveAttachmentAttrs(child, attDef.attrs, options, diagnostics);
125
+ node.attachments.push(internAttachment({ tag: child.tag, attrs }));
126
+ }
127
+ else if (child.tag === 'Components') {
128
+ buildComponents(child, index, node);
129
+ }
130
+ else if (getNodeDef(child.tag)) {
131
+ node.children.push(buildNode(child, index));
132
+ }
133
+ }
134
+ return index;
135
+ };
136
+ const buildComponents = (block, nodeIndex, node) => {
137
+ const targets = componentsOnNode.get(nodeIndex) ?? [];
138
+ componentsOnNode.set(nodeIndex, targets);
139
+ for (const el of block.children) {
140
+ const entry = Registry.getComponent(el.tag);
141
+ if (!entry)
142
+ continue; // validated; unreachable
143
+ const componentIndex = scene.components.length;
144
+ const id = el.attributes.get('id');
145
+ const compiled = {
146
+ type: el.tag,
147
+ ...(id !== undefined ? { id } : {}),
148
+ node: nodeIndex,
149
+ props: {},
150
+ };
151
+ scene.components.push(compiled);
152
+ node.components.push(componentIndex);
153
+ targets.push({ ctor: entry.ctor, componentIndex });
154
+ if (id !== undefined)
155
+ componentIdToIndex.set(id, componentIndex);
156
+ collectComponentProps(el, entry.meta.properties, componentIndex, compiled, pending, internPrefab);
157
+ }
158
+ };
159
+ buildNode(root, -1);
160
+ // Second pass: resolve references now that every node and component is indexed.
161
+ for (const ref of pending) {
162
+ const resolved = resolveRef(ref, nodeIdToIndex, componentIdToIndex, componentsOnNode);
163
+ if (resolved)
164
+ scene.components[ref.componentIndex].props[ref.attr] = resolved;
165
+ }
166
+ return { scene, diagnostics };
167
+ }
168
+ /**
169
+ * Compute an attachment's attributes, pulling defaults from a `src=` resource
170
+ * file when present (inline attributes override the file) — e.g. a shared
171
+ * `.material.xml`. Diagnostics for a missing/invalid file point at that file.
172
+ */
173
+ function resolveAttachmentAttrs(el, attrs, options, diagnostics) {
174
+ const src = el.attributes.get('src');
175
+ if (src === undefined)
176
+ return compileAttrs(el, attrs);
177
+ const push = (d) => diagnostics.push({ line: el.line, column: el.column, ...d });
178
+ if (!options.resolve) {
179
+ push({ code: 'no-resolver', message: `<${el.tag}> uses src="${src}" but no resource resolver is configured` });
180
+ return compileAttrs(el, attrs);
181
+ }
182
+ const file = options.resolve(src, options.path ?? '');
183
+ if (!file) {
184
+ push({ code: 'resource-not-found', message: `Cannot resolve src="${src}"` });
185
+ return compileAttrs(el, attrs);
186
+ }
187
+ const parsed = parseScene(file.contents);
188
+ if (!parsed.ok) {
189
+ for (const d of parsed.diagnostics)
190
+ diagnostics.push({ ...d, file: file.path });
191
+ return compileAttrs(el, attrs);
192
+ }
193
+ const fileRoot = parsed.root;
194
+ if (fileRoot.tag !== el.tag) {
195
+ push({ code: 'resource-tag-mismatch', message: `src="${src}" defines a <${fileRoot.tag}>, but it is used as a <${el.tag}>` });
196
+ return compileAttrs(el, attrs);
197
+ }
198
+ // File attributes are the base; inline attributes (except src) override them.
199
+ const merged = new Map();
200
+ for (const [k, v] of fileRoot.attributes)
201
+ if (k in attrs)
202
+ merged.set(k, v);
203
+ for (const [k, v] of el.attributes)
204
+ if (k !== 'src' && k in attrs)
205
+ merged.set(k, v);
206
+ // Validate the file's own values so a bad resource reports against its file.
207
+ for (const [k, v] of fileRoot.attributes) {
208
+ const meta = attrs[k];
209
+ if (!meta) {
210
+ diagnostics.push({ line: fileRoot.line, column: fileRoot.column, file: file.path, code: 'unknown-attr', message: `<${fileRoot.tag}> has no attribute "${k}"` });
211
+ }
212
+ else if (!codecs[meta.type].parse(v).ok) {
213
+ diagnostics.push({ line: fileRoot.line, column: fileRoot.column, file: file.path, code: 'bad-value', message: `<${fileRoot.tag}> attribute "${k}": ${codecs[meta.type].parse(v).message}` });
214
+ }
215
+ }
216
+ const out = {};
217
+ for (const [key, meta] of Object.entries(attrs)) {
218
+ out[key] = scalarValue(meta.type, merged.get(key), meta.default);
219
+ }
220
+ return out;
221
+ }
222
+ function compileTransform(el) {
223
+ const t = (key) => {
224
+ const meta = TRANSFORM_ATTRS[key];
225
+ return scalarValue(meta.type, el.attributes.get(key), meta.default);
226
+ };
227
+ return {
228
+ position: t('position'),
229
+ rotation: t('rotation'),
230
+ scale: t('scale'),
231
+ visible: t('visible'),
232
+ };
233
+ }
234
+ function compileAttrs(el, attrs) {
235
+ const out = {};
236
+ for (const [key, meta] of Object.entries(attrs)) {
237
+ out[key] = scalarValue(meta.type, el.attributes.get(key), meta.default);
238
+ }
239
+ return out;
240
+ }
241
+ function collectComponentProps(el, props, componentIndex, compiled, pending, internPrefab) {
242
+ for (const [attr, raw] of el.attributes) {
243
+ if (attr === 'id')
244
+ continue;
245
+ const meta = props.get(attr);
246
+ if (!meta)
247
+ continue; // validated
248
+ if (isScalarType(meta.type)) {
249
+ compiled.props[attr] = codecParse(meta.type, raw);
250
+ }
251
+ else if (meta.type === 'prefab') {
252
+ // A prefab prop value is a file path (like src=), not a `#id`. Resolve and
253
+ // embed it at build time; the runtime hands the component a PrefabRef.
254
+ const src = raw.trim();
255
+ if (src !== '') {
256
+ const index = internPrefab(src, el);
257
+ if (index !== undefined)
258
+ compiled.props[attr] = { prefab: index };
259
+ }
260
+ }
261
+ else {
262
+ // Reference value is `#id`; store the bare id (validated upstream).
263
+ const value = raw.trim().replace(/^#/, '');
264
+ if (value !== '')
265
+ pending.push({ componentIndex, attr, value, declared: meta.type });
266
+ }
267
+ }
268
+ }
269
+ function resolveRef(ref, nodeIdToIndex, componentIdToIndex, componentsOnNode) {
270
+ if (ref.declared === 'node') {
271
+ const idx = nodeIdToIndex.get(ref.value);
272
+ return idx === undefined ? undefined : { node: idx };
273
+ }
274
+ const byId = componentIdToIndex.get(ref.value);
275
+ if (byId !== undefined)
276
+ return { component: byId };
277
+ const nodeIndex = nodeIdToIndex.get(ref.value);
278
+ if (nodeIndex === undefined)
279
+ return undefined;
280
+ const declared = ref.declared;
281
+ const match = (componentsOnNode.get(nodeIndex) ?? []).find((c) => c.ctor === declared || c.ctor.prototype instanceof declared);
282
+ return match ? { component: match.componentIndex } : undefined;
283
+ }
284
+ const codecParse = (type, raw) => {
285
+ const r = codecs[type].parse(raw);
286
+ return r.ok ? r.value : zeroFor(type);
287
+ };
288
+ function scalarValue(type, raw, fallback) {
289
+ if (raw === undefined)
290
+ return fallback ?? zeroFor(type);
291
+ const r = codecs[type].parse(raw);
292
+ return r.ok ? r.value : (fallback ?? zeroFor(type));
293
+ }
294
+ function zeroFor(type) {
295
+ switch (type) {
296
+ case 'bool':
297
+ return false;
298
+ case 'string':
299
+ return '';
300
+ case 'vec2':
301
+ return [0, 0];
302
+ case 'vec3':
303
+ case 'euler':
304
+ return [0, 0, 0];
305
+ default:
306
+ return 0;
307
+ }
308
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * A single problem found while parsing, validating, or compiling a scene.
3
+ * Diagnostics carry a source position so agents driving the engine with plain
4
+ * file tools (grep, line edits) can jump straight to the offending line.
5
+ */
6
+ export interface SceDiagnostic {
7
+ /** 1-based line in the source XML. */
8
+ line: number;
9
+ /** 1-based column in the source XML. */
10
+ column: number;
11
+ /** Stable machine-readable category, e.g. "unknown-tag". */
12
+ code: string;
13
+ /** Human- and LLM-actionable description: what's wrong and how to fix it. */
14
+ message: string;
15
+ /** Set when the problem is in a referenced file (a resource or prefab), not the scene. */
16
+ file?: string;
17
+ }
18
+ /** tsc-style `file:line:col` rendering, e.g. `scene.xml:12:6 unknown-tag ...`. */
19
+ export declare function formatDiagnostic(d: SceDiagnostic, file?: string): string;
20
+ /** Levenshtein-nearest candidate within a small edit distance, for "did you mean" hints. */
21
+ export declare function nearestName(input: string, candidates: readonly string[]): string | undefined;
@@ -0,0 +1,38 @@
1
+ /** tsc-style `file:line:col` rendering, e.g. `scene.xml:12:6 unknown-tag ...`. */
2
+ export function formatDiagnostic(d, file = '<scene>') {
3
+ return `${d.file ?? file}:${d.line}:${d.column} ${d.code} ${d.message}`;
4
+ }
5
+ /** Levenshtein-nearest candidate within a small edit distance, for "did you mean" hints. */
6
+ export function nearestName(input, candidates) {
7
+ let best;
8
+ let bestDist = Infinity;
9
+ for (const c of candidates) {
10
+ const d = editDistance(input, c);
11
+ if (d < bestDist) {
12
+ bestDist = d;
13
+ best = c;
14
+ }
15
+ }
16
+ // Only suggest when the typo is plausibly a typo, not an unrelated word.
17
+ const threshold = Math.max(2, Math.floor(input.length / 3));
18
+ return best !== undefined && bestDist <= threshold ? best : undefined;
19
+ }
20
+ function editDistance(a, b) {
21
+ const m = a.length;
22
+ const n = b.length;
23
+ if (m === 0)
24
+ return n;
25
+ if (n === 0)
26
+ return m;
27
+ let prev = Array.from({ length: n + 1 }, (_, i) => i);
28
+ let curr = new Array(n + 1);
29
+ for (let i = 1; i <= m; i++) {
30
+ curr[0] = i;
31
+ for (let j = 1; j <= n; j++) {
32
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
33
+ curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
34
+ }
35
+ [prev, curr] = [curr, prev];
36
+ }
37
+ return prev[n];
38
+ }
@@ -0,0 +1,30 @@
1
+ import type { CompileOptions } from './compile.js';
2
+ import type { SceDiagnostic } from './diagnostics.js';
3
+ import { type SceElement } from './SceneParser.js';
4
+ /**
5
+ * Prefab expansion — a pre-pass on the parsed element tree.
6
+ *
7
+ * A prefab *file* has a `<Prefab>` root wrapping one node; a *scene* places an
8
+ * instance of it with `<PrefabInstance src="x.prefab.xml" id="t1" …/>` (the two
9
+ * tags are distinct so neither is ambiguous). Each `<PrefabInstance>` is
10
+ * replaced, at compile time, by the prefab file's node subtree:
11
+ *
12
+ * - the prefab file is parsed, recursively expanded, and validated standalone
13
+ * (prefabs are self-contained — that's what makes them dynamically loadable);
14
+ * - internal ids are namespaced into the instance scope (`osc` → `t1/osc`,
15
+ * composing for nested prefabs: `t1/inner/osc`) and internal `#refs` are
16
+ * rewritten to match; the prefab root's own id becomes the instance id;
17
+ * - instance transform attributes override the prefab root's (file = defaults,
18
+ * inline overrides — same rule as material `src=`);
19
+ * - `<Override target="#osc" amplitude="2"/>` children patch any internal
20
+ * node/component by its prefab-local id;
21
+ * - a `<Components>` child adds extra components to the instance root.
22
+ *
23
+ * Nesting: a prefab file may itself contain `<PrefabInstance>` children, which
24
+ * are expanded depth-first (and scoped: `t1/inner/osc`), so prefabs compose.
25
+ *
26
+ * Because expansion happens before validation, the validator and compiler see
27
+ * ordinary nodes — the bundle format is untouched, and the scene can address
28
+ * instance internals (`target="#t1/osc"`) like any other id.
29
+ */
30
+ export declare function expandPrefabs(root: SceElement, referrer: string, options: CompileOptions, diagnostics: SceDiagnostic[], stack?: string[]): void;
@@ -0,0 +1,255 @@
1
+ import { isScalarType, isSpatial, getNodeDef, Registry, TRANSFORM_ATTRS, } from '@scenoco-three/core/internal';
2
+ import { parseScene } from './SceneParser.js';
3
+ import { codecs } from './types.js';
4
+ import { validatePrefab } from './Validator.js';
5
+ /**
6
+ * Prefab expansion — a pre-pass on the parsed element tree.
7
+ *
8
+ * A prefab *file* has a `<Prefab>` root wrapping one node; a *scene* places an
9
+ * instance of it with `<PrefabInstance src="x.prefab.xml" id="t1" …/>` (the two
10
+ * tags are distinct so neither is ambiguous). Each `<PrefabInstance>` is
11
+ * replaced, at compile time, by the prefab file's node subtree:
12
+ *
13
+ * - the prefab file is parsed, recursively expanded, and validated standalone
14
+ * (prefabs are self-contained — that's what makes them dynamically loadable);
15
+ * - internal ids are namespaced into the instance scope (`osc` → `t1/osc`,
16
+ * composing for nested prefabs: `t1/inner/osc`) and internal `#refs` are
17
+ * rewritten to match; the prefab root's own id becomes the instance id;
18
+ * - instance transform attributes override the prefab root's (file = defaults,
19
+ * inline overrides — same rule as material `src=`);
20
+ * - `<Override target="#osc" amplitude="2"/>` children patch any internal
21
+ * node/component by its prefab-local id;
22
+ * - a `<Components>` child adds extra components to the instance root.
23
+ *
24
+ * Nesting: a prefab file may itself contain `<PrefabInstance>` children, which
25
+ * are expanded depth-first (and scoped: `t1/inner/osc`), so prefabs compose.
26
+ *
27
+ * Because expansion happens before validation, the validator and compiler see
28
+ * ordinary nodes — the bundle format is untouched, and the scene can address
29
+ * instance internals (`target="#t1/osc"`) like any other id.
30
+ */
31
+ export function expandPrefabs(root, referrer, options, diagnostics, stack = []) {
32
+ for (let i = 0; i < root.children.length; i++) {
33
+ const child = root.children[i];
34
+ if (child.tag === 'PrefabInstance') {
35
+ if (!child.attributes.has('src')) {
36
+ diagnostics.push({ line: child.line, column: child.column, code: 'prefab-missing-src', message: `<PrefabInstance> requires a src="…prefab.xml" attribute`, ...(child.file ? { file: child.file } : {}) });
37
+ continue;
38
+ }
39
+ const expanded = expandInstance(child, referrer, options, diagnostics, stack);
40
+ if (expanded) {
41
+ expanded.parent = root;
42
+ root.children[i] = expanded;
43
+ }
44
+ continue;
45
+ }
46
+ expandPrefabs(child, referrer, options, diagnostics, stack);
47
+ }
48
+ }
49
+ function expandInstance(instance, referrer, options, diagnostics, stack) {
50
+ const fail = (code, message, el = instance) => void diagnostics.push({ line: el.line, column: el.column, code, message, ...(el.file ? { file: el.file } : {}) });
51
+ const src = instance.attributes.get('src');
52
+ const instanceId = instance.attributes.get('id');
53
+ if (instanceId === undefined || instanceId === '') {
54
+ fail('prefab-missing-id', `<PrefabInstance src="${src}"> requires an id — instance internals are addressed as "#<id>/<inner>"`);
55
+ return null;
56
+ }
57
+ if (!options.resolve) {
58
+ fail('no-resolver', `<PrefabInstance> uses src="${src}" but no resource resolver is configured`);
59
+ return null;
60
+ }
61
+ const file = options.resolve(src, referrer);
62
+ if (!file) {
63
+ fail('prefab-not-found', `Cannot resolve src="${src}"`);
64
+ return null;
65
+ }
66
+ if (stack.includes(file.path)) {
67
+ fail('prefab-cycle', `Prefab cycle: ${[...stack, file.path].join(' → ')}`);
68
+ return null;
69
+ }
70
+ const parsed = parseScene(file.contents);
71
+ if (!parsed.ok) {
72
+ diagnostics.push(...parsed.diagnostics.map((d) => ({ ...d, file: d.file ?? file.path })));
73
+ return null;
74
+ }
75
+ // Depth-first: expand the prefab's own instances, then validate it standalone.
76
+ expandPrefabs(parsed.root, file.path, options, diagnostics, [...stack, file.path]);
77
+ const prefabDiags = validatePrefab(parsed.root);
78
+ if (prefabDiags.length > 0) {
79
+ diagnostics.push(...prefabDiags.map((d) => ({ ...d, file: d.file ?? file.path })));
80
+ return null;
81
+ }
82
+ const prefabRoot = parsed.root.children.find((c) => isSpatial(c.tag));
83
+ const clone = cloneSubtree(prefabRoot, file.path, null);
84
+ // Scope ids into the instance: root id → instance id, inner `x` → `id/x`.
85
+ const rename = buildRenameMap(clone, instanceId);
86
+ applyRenames(clone, rename);
87
+ // Instance transform attributes override the prefab root's.
88
+ for (const key of Object.keys(TRANSFORM_ATTRS)) {
89
+ const value = instance.attributes.get(key);
90
+ if (value === undefined)
91
+ continue;
92
+ const meta = TRANSFORM_ATTRS[key];
93
+ if (!codecs[meta.type].parse(value).ok) {
94
+ fail('bad-value', `<PrefabInstance> attribute "${key}": ${codecs[meta.type].parse(value).message}`);
95
+ }
96
+ clone.attributes.set(key, value);
97
+ }
98
+ for (const attr of instance.attributes.keys()) {
99
+ if (attr === 'src' || attr === 'id' || attr in TRANSFORM_ATTRS || isNamespaceAttr(attr))
100
+ continue;
101
+ fail('unknown-attr', `<PrefabInstance> has no attribute "${attr}" (allowed: src, id, ${Object.keys(TRANSFORM_ATTRS).join(', ')})`);
102
+ }
103
+ // Instance children: <Override target="#inner" …/> patches and an optional
104
+ // <Components> block appended to the instance root.
105
+ for (const child of instance.children) {
106
+ if (child.tag === 'Override') {
107
+ applyOverride(child, clone, rename, instanceId, fail);
108
+ }
109
+ else if (child.tag === 'Components') {
110
+ let block = clone.children.find((c) => c.tag === 'Components');
111
+ if (!block) {
112
+ block = { tag: 'Components', attributes: new Map(), children: [], parent: clone, line: child.line, column: child.column };
113
+ clone.children.push(block);
114
+ }
115
+ for (const comp of child.children) {
116
+ comp.parent = block;
117
+ block.children.push(comp);
118
+ }
119
+ }
120
+ else {
121
+ fail('unexpected-child', `<PrefabInstance> may only contain <Override> and <Components> (found <${child.tag}>)`, child);
122
+ }
123
+ }
124
+ return clone;
125
+ }
126
+ // ---- subtree mechanics ------------------------------------------------------
127
+ function cloneSubtree(el, file, parent) {
128
+ const clone = {
129
+ tag: el.tag,
130
+ attributes: new Map(el.attributes),
131
+ children: [],
132
+ parent,
133
+ line: el.line,
134
+ column: el.column,
135
+ file: el.file ?? file,
136
+ };
137
+ clone.children = el.children.map((c) => cloneSubtree(c, file, clone));
138
+ return clone;
139
+ }
140
+ /** old prefab-local id → scoped id. The root's own id maps to the instance id. */
141
+ function buildRenameMap(clonedRoot, instanceId) {
142
+ const rename = new Map();
143
+ const rootId = clonedRoot.attributes.get('id');
144
+ if (rootId !== undefined)
145
+ rename.set(rootId, instanceId);
146
+ const walk = (el) => {
147
+ if (el !== clonedRoot) {
148
+ const id = el.attributes.get('id');
149
+ if (id !== undefined && idCarrier(el))
150
+ rename.set(id, `${instanceId}/${id}`);
151
+ }
152
+ for (const c of el.children)
153
+ walk(c);
154
+ };
155
+ walk(clonedRoot);
156
+ // The root always carries the instance id, even if it had none in the file.
157
+ clonedRoot.attributes.set('id', instanceId);
158
+ return rename;
159
+ }
160
+ function applyRenames(root, rename) {
161
+ const walk = (el) => {
162
+ if (el !== root && idCarrier(el)) {
163
+ const id = el.attributes.get('id');
164
+ const renamed = id !== undefined ? rename.get(id) : undefined;
165
+ if (renamed !== undefined)
166
+ el.attributes.set('id', renamed);
167
+ }
168
+ // Rewrite reference values on component elements (`#old` → `#scoped`).
169
+ if (el.parent?.tag === 'Components') {
170
+ const entry = Registry.getComponent(el.tag);
171
+ if (entry) {
172
+ for (const [attr, value] of el.attributes) {
173
+ const meta = entry.meta.properties.get(attr);
174
+ if (!meta || isScalarType(meta.type) || !value.startsWith('#'))
175
+ continue;
176
+ const renamed = rename.get(value.slice(1).trim());
177
+ if (renamed !== undefined)
178
+ el.attributes.set(attr, `#${renamed}`);
179
+ }
180
+ }
181
+ }
182
+ for (const c of el.children)
183
+ walk(c);
184
+ };
185
+ walk(root);
186
+ }
187
+ /** Elements whose `id` participates in the scene namespace (nodes + components). */
188
+ function idCarrier(el) {
189
+ return isSpatial(el.tag) || el.parent?.tag === 'Components';
190
+ }
191
+ // ---- <Override> -------------------------------------------------------------
192
+ function applyOverride(override, clonedRoot, rename, instanceId, fail) {
193
+ const target = override.attributes.get('target');
194
+ if (target === undefined || !target.startsWith('#')) {
195
+ fail('ref-missing-hash', `<Override> needs target="#<prefab-local id>" (e.g. target="#osc")`, override);
196
+ return;
197
+ }
198
+ const localId = target.slice(1).trim();
199
+ const scopedId = rename.get(localId) ?? (localId === instanceId ? instanceId : undefined);
200
+ const found = scopedId !== undefined ? findById(clonedRoot, scopedId) : undefined;
201
+ if (!found) {
202
+ const known = [...rename.keys()].join(', ') || '(none)';
203
+ fail('override-unknown-target', `<Override> target "#${localId}" does not exist in the prefab. Ids declared there: ${known}`, override);
204
+ return;
205
+ }
206
+ // Validate override values against the target's schema *here*, so errors
207
+ // point at the <Override> the author wrote, not into the prefab file.
208
+ const schema = attrSchema(found);
209
+ for (const [attr, value] of override.attributes) {
210
+ if (attr === 'target')
211
+ continue;
212
+ const type = schema.get(attr);
213
+ if (type === undefined) {
214
+ fail('unknown-attr', `<Override>: <${found.tag}> has no attribute "${attr}"`, override);
215
+ continue;
216
+ }
217
+ if (type !== null && !codecs[type].parse(value).ok) {
218
+ fail('bad-value', `<Override> "${attr}": ${codecs[type].parse(value).message}`, override);
219
+ continue;
220
+ }
221
+ found.attributes.set(attr, value);
222
+ }
223
+ }
224
+ function findById(root, id) {
225
+ if (root.attributes.get('id') === id)
226
+ return root;
227
+ for (const c of root.children) {
228
+ const found = findById(c, id);
229
+ if (found)
230
+ return found;
231
+ }
232
+ return undefined;
233
+ }
234
+ /** attr → scalar type for value checking; `null` marks reference props (any #id). */
235
+ function attrSchema(el) {
236
+ const out = new Map();
237
+ if (el.parent?.tag === 'Components') {
238
+ const entry = Registry.getComponent(el.tag);
239
+ if (entry) {
240
+ for (const [name, meta] of entry.meta.properties) {
241
+ out.set(name, isScalarType(meta.type) ? meta.type : null);
242
+ }
243
+ }
244
+ return out;
245
+ }
246
+ const def = getNodeDef(el.tag);
247
+ if (def) {
248
+ for (const [name, meta] of Object.entries({ ...TRANSFORM_ATTRS, ...def.attrs }))
249
+ out.set(name, meta.type);
250
+ }
251
+ return out;
252
+ }
253
+ function isNamespaceAttr(name) {
254
+ return name === 'xmlns' || name.startsWith('xmlns:') || name.startsWith('xsi:');
255
+ }
@@ -0,0 +1,14 @@
1
+ import { type CompiledScene } from '@scenoco-three/core/internal';
2
+ /**
3
+ * Serialize a CompiledScene back to canonical, re-compilable XML — the inverse
4
+ * of the compiler (minus validation). Used to write programmatic/agent edits
5
+ * back to file form.
6
+ *
7
+ * The output is the **flat** form: prefab instances and material `src=` files
8
+ * have already been expanded/inlined by the compiler, so they appear as ordinary
9
+ * nodes and inline materials. Only non-default attributes are emitted. A
10
+ * reference (`{ node|component: index }`) is written as `#id`; if its target had
11
+ * no id, a stable unique one is synthesized so the reference survives the round
12
+ * trip.
13
+ */
14
+ export declare function serializeScene(scene: CompiledScene): string;