@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.
- package/LICENSE +21 -0
- package/README.md +29 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +373 -0
- package/dist/cli/templates.d.ts +11 -0
- package/dist/cli/templates.js +401 -0
- package/dist/components.d.ts +43 -0
- package/dist/components.js +181 -0
- package/dist/dsl/SceneParser.d.ts +30 -0
- package/dist/dsl/SceneParser.js +59 -0
- package/dist/dsl/Validator.d.ts +15 -0
- package/dist/dsl/Validator.js +331 -0
- package/dist/dsl/bundle.d.ts +9 -0
- package/dist/dsl/bundle.js +151 -0
- package/dist/dsl/compile.d.ts +36 -0
- package/dist/dsl/compile.js +308 -0
- package/dist/dsl/diagnostics.d.ts +21 -0
- package/dist/dsl/diagnostics.js +38 -0
- package/dist/dsl/prefab.d.ts +30 -0
- package/dist/dsl/prefab.js +255 -0
- package/dist/dsl/serialize.d.ts +14 -0
- package/dist/dsl/serialize.js +157 -0
- package/dist/dsl/types.d.ts +45 -0
- package/dist/dsl/types.js +120 -0
- package/dist/exporters/docs.d.ts +16 -0
- package/dist/exporters/docs.js +190 -0
- package/dist/exporters/xsd.d.ts +13 -0
- package/dist/exporters/xsd.js +199 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +23 -0
- package/dist/watch.d.ts +33 -0
- package/dist/watch.js +112 -0
- package/package.json +35 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { SaxesParser } from 'saxes';
|
|
2
|
+
/**
|
|
3
|
+
* Parse a scene XML string into a position-aware element tree. Returns XML
|
|
4
|
+
* syntax errors (malformed markup) as diagnostics; semantic validation against
|
|
5
|
+
* the schema is a separate pass (see Validator).
|
|
6
|
+
*/
|
|
7
|
+
export function parseScene(xml) {
|
|
8
|
+
const parser = new SaxesParser({ fileName: 'scene' });
|
|
9
|
+
const diagnostics = [];
|
|
10
|
+
let root = null;
|
|
11
|
+
const stack = [];
|
|
12
|
+
parser.on('opentagstart', (tag) => {
|
|
13
|
+
const el = {
|
|
14
|
+
tag: tag.name,
|
|
15
|
+
attributes: new Map(),
|
|
16
|
+
children: [],
|
|
17
|
+
parent: stack[stack.length - 1] ?? null,
|
|
18
|
+
// saxes columns are 0-based; present 1-based to match editor gutters.
|
|
19
|
+
line: parser.line,
|
|
20
|
+
column: parser.column + 1,
|
|
21
|
+
};
|
|
22
|
+
if (el.parent)
|
|
23
|
+
el.parent.children.push(el);
|
|
24
|
+
else
|
|
25
|
+
root ??= el;
|
|
26
|
+
stack.push(el);
|
|
27
|
+
});
|
|
28
|
+
parser.on('attribute', (attr) => {
|
|
29
|
+
const top = stack[stack.length - 1];
|
|
30
|
+
if (top)
|
|
31
|
+
top.attributes.set(attr.name, attr.value);
|
|
32
|
+
});
|
|
33
|
+
parser.on('closetag', () => {
|
|
34
|
+
stack.pop();
|
|
35
|
+
});
|
|
36
|
+
parser.on('error', (e) => {
|
|
37
|
+
diagnostics.push({
|
|
38
|
+
line: parser.line,
|
|
39
|
+
column: parser.column + 1,
|
|
40
|
+
code: 'xml-syntax',
|
|
41
|
+
message: e.message,
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
try {
|
|
45
|
+
parser.write(xml).close();
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// saxes throws after emitting the 'error' event; the diagnostic is already recorded.
|
|
49
|
+
}
|
|
50
|
+
if (diagnostics.length > 0)
|
|
51
|
+
return { ok: false, diagnostics };
|
|
52
|
+
if (!root) {
|
|
53
|
+
return {
|
|
54
|
+
ok: false,
|
|
55
|
+
diagnostics: [{ line: 1, column: 1, code: 'empty-document', message: 'No root element found' }],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return { ok: true, root };
|
|
59
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type SceDiagnostic } from './diagnostics.js';
|
|
2
|
+
import type { SceElement } from './SceneParser.js';
|
|
3
|
+
/**
|
|
4
|
+
* Validates a parsed scene tree against the node and component registries,
|
|
5
|
+
* collecting *every* problem (never fail-fast) with a source position and an
|
|
6
|
+
* LLM-actionable message. This is the heart of the edit→check→fix loop: the
|
|
7
|
+
* `scenoco validate` CLI is a thin wrapper over this.
|
|
8
|
+
*/
|
|
9
|
+
export declare function validateScene(root: SceElement): SceDiagnostic[];
|
|
10
|
+
/**
|
|
11
|
+
* Validates a prefab file: a `<Prefab>` root wrapping exactly one spatial node.
|
|
12
|
+
* Prefabs must be **self-contained** — every `#ref` resolves within the file —
|
|
13
|
+
* which is what makes them instantiable at runtime without a surrounding scene.
|
|
14
|
+
*/
|
|
15
|
+
export declare function validatePrefab(root: SceElement): SceDiagnostic[];
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { isScalarType, Registry, getNodeDef, getAttachmentDef, allAttachmentDefs, attachmentAcceptsNode, attachmentTags, isSpatial, nodeTags, TRANSFORM_ATTRS, } from '@scenoco-three/core/internal';
|
|
2
|
+
import { nearestName } from './diagnostics.js';
|
|
3
|
+
import { codecs } from './types.js';
|
|
4
|
+
/**
|
|
5
|
+
* Validates a parsed scene tree against the node and component registries,
|
|
6
|
+
* collecting *every* problem (never fail-fast) with a source position and an
|
|
7
|
+
* LLM-actionable message. This is the heart of the edit→check→fix loop: the
|
|
8
|
+
* `scenoco validate` CLI is a thin wrapper over this.
|
|
9
|
+
*/
|
|
10
|
+
export function validateScene(root) {
|
|
11
|
+
const ctx = new ValidationContext();
|
|
12
|
+
if (root.tag !== 'Scene') {
|
|
13
|
+
ctx.error(root, 'bad-root', `Root element must be <Scene>, got <${root.tag}>`);
|
|
14
|
+
// Still walk it as best we can, so attribute/child errors surface too.
|
|
15
|
+
}
|
|
16
|
+
ctx.walk(root);
|
|
17
|
+
ctx.resolveReferences();
|
|
18
|
+
ctx.diagnostics.sort((a, b) => a.line - b.line || a.column - b.column);
|
|
19
|
+
return ctx.diagnostics;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Validates a prefab file: a `<Prefab>` root wrapping exactly one spatial node.
|
|
23
|
+
* Prefabs must be **self-contained** — every `#ref` resolves within the file —
|
|
24
|
+
* which is what makes them instantiable at runtime without a surrounding scene.
|
|
25
|
+
*/
|
|
26
|
+
export function validatePrefab(root) {
|
|
27
|
+
const ctx = new ValidationContext();
|
|
28
|
+
if (root.tag !== 'Prefab') {
|
|
29
|
+
ctx.error(root, 'bad-root', `A prefab file's root element must be <Prefab>, got <${root.tag}>`);
|
|
30
|
+
}
|
|
31
|
+
for (const attr of root.attributes.keys()) {
|
|
32
|
+
ctx.error(root, 'unexpected-attr', `A prefab file's <Prefab> root takes no attributes (found "${attr}")`);
|
|
33
|
+
}
|
|
34
|
+
const spatial = root.children.filter((c) => isSpatial(c.tag));
|
|
35
|
+
if (root.children.length !== 1 || spatial.length !== 1) {
|
|
36
|
+
ctx.error(root, 'bad-prefab-root', `<Prefab> must contain exactly one node (e.g. a <Mesh> or <Group>); found ${root.children.length}`);
|
|
37
|
+
}
|
|
38
|
+
for (const child of root.children)
|
|
39
|
+
ctx.walk(child);
|
|
40
|
+
ctx.resolveReferences();
|
|
41
|
+
ctx.diagnostics.sort((a, b) => a.line - b.line || a.column - b.column);
|
|
42
|
+
return ctx.diagnostics;
|
|
43
|
+
}
|
|
44
|
+
class ValidationContext {
|
|
45
|
+
diagnostics = [];
|
|
46
|
+
// One scene-wide id namespace shared by nodes and components, so a `#id`
|
|
47
|
+
// reference resolves unambiguously to exactly one element.
|
|
48
|
+
ids = new Map();
|
|
49
|
+
componentsByNode = new Map();
|
|
50
|
+
pendingRefs = [];
|
|
51
|
+
error(el, code, message) {
|
|
52
|
+
// Elements spliced in from another file (prefab expansion) carry their
|
|
53
|
+
// origin so the fix-it loop points at the right file.
|
|
54
|
+
this.diagnostics.push({ line: el.line, column: el.column, code, message, ...(el.file ? { file: el.file } : {}) });
|
|
55
|
+
}
|
|
56
|
+
walk(el) {
|
|
57
|
+
const def = getNodeDef(el.tag);
|
|
58
|
+
if (!def) {
|
|
59
|
+
// Unknown at this position. Components are handled by their parent; if we
|
|
60
|
+
// reach an unregistered, non-Components tag here it's a genuine unknown.
|
|
61
|
+
if (el.tag !== 'Components') {
|
|
62
|
+
const suggestion = nearestName(el.tag, [...nodeTags(), ...attachmentTags(), 'Components']);
|
|
63
|
+
this.error(el, 'unknown-tag', `Unknown element <${el.tag}>${suggestion ? `. Did you mean <${suggestion}>?` : ''}`);
|
|
64
|
+
}
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (def.kind === 'scene' && el.parent !== null) {
|
|
68
|
+
this.error(el, 'nested-scene', '<Scene> may only be the root element');
|
|
69
|
+
}
|
|
70
|
+
this.validateNodeAttributes(el, def);
|
|
71
|
+
this.validateChildren(el, def);
|
|
72
|
+
}
|
|
73
|
+
validateChildren(el, def) {
|
|
74
|
+
let componentsCount = 0;
|
|
75
|
+
// group → first attachment tag seen on this node (for uniqueness diagnostics).
|
|
76
|
+
const groupFirst = new Map();
|
|
77
|
+
for (const child of el.children) {
|
|
78
|
+
const attDef = getAttachmentDef(child.tag);
|
|
79
|
+
if (attDef) {
|
|
80
|
+
this.validateAttachment(el, def, child, attDef, groupFirst);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (child.tag === 'Components') {
|
|
84
|
+
if (!def.children.components) {
|
|
85
|
+
this.error(child, 'misplaced-components', `<Components> is not allowed inside <${el.tag}>`);
|
|
86
|
+
}
|
|
87
|
+
else if (++componentsCount > 1) {
|
|
88
|
+
this.error(child, 'duplicate-components', `<${el.tag}> may have only one <Components> block`);
|
|
89
|
+
}
|
|
90
|
+
this.validateComponentsBlock(el, child);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const childDef = getNodeDef(child.tag);
|
|
94
|
+
if (childDef) {
|
|
95
|
+
if (!def.children.object3d) {
|
|
96
|
+
this.error(child, 'misplaced-node', `<${child.tag}> is not allowed inside <${el.tag}>`);
|
|
97
|
+
}
|
|
98
|
+
this.walk(child);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (Registry.getComponent(child.tag)) {
|
|
102
|
+
this.error(child, 'component-outside-block', `<${child.tag}> is a component and must be placed inside a <Components> block`);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
// Truly unknown tag.
|
|
106
|
+
this.walk(child);
|
|
107
|
+
}
|
|
108
|
+
// Required attachment groups (e.g. a <Mesh> needs a geometry).
|
|
109
|
+
for (const [group, tags] of requiredGroupsFor(def)) {
|
|
110
|
+
if (!groupFirst.has(group)) {
|
|
111
|
+
this.error(el, 'missing-required-attachment', `<${el.tag}> requires a ${group} (e.g. <${tags[0]} />)`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** One attachment rule: target-compatible? group cardinality ok? attrs valid? */
|
|
116
|
+
validateAttachment(host, hostDef, child, attDef, groupFirst) {
|
|
117
|
+
if (!attachmentAcceptsNode(attDef, hostDef)) {
|
|
118
|
+
this.error(child, 'attachment-wrong-target', `<${child.tag}> cannot attach to <${host.tag}>${targetHint(attDef)}`);
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
const prev = groupFirst.get(attDef.group);
|
|
122
|
+
if (prev !== undefined && attDef.unique) {
|
|
123
|
+
this.error(child, 'duplicate-attachment', `<${host.tag}> may have only one ${attDef.group} (already has <${prev}>)`);
|
|
124
|
+
}
|
|
125
|
+
else if (prev === undefined) {
|
|
126
|
+
groupFirst.set(attDef.group, child.tag);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
this.validateAttachmentAttributes(child, attDef);
|
|
130
|
+
this.rejectChildElements(child);
|
|
131
|
+
}
|
|
132
|
+
validateComponentsBlock(host, block) {
|
|
133
|
+
if (block.attributes.size > 0) {
|
|
134
|
+
this.error(block, 'unexpected-attr', `<Components> takes no attributes`);
|
|
135
|
+
}
|
|
136
|
+
const hostComponents = this.componentsByNode.get(host) ?? [];
|
|
137
|
+
this.componentsByNode.set(host, hostComponents);
|
|
138
|
+
for (const child of block.children) {
|
|
139
|
+
const entry = Registry.getComponent(child.tag);
|
|
140
|
+
if (!entry) {
|
|
141
|
+
if (getNodeDef(child.tag)) {
|
|
142
|
+
this.error(child, 'not-a-component', `<${child.tag}> is not a component; <Components> may only contain components`);
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
const suggestion = nearestName(child.tag, Registry.componentNames());
|
|
146
|
+
this.error(child, 'unknown-component', `Unknown component <${child.tag}>${suggestion ? `. Did you mean <${suggestion}>?` : ''}`);
|
|
147
|
+
}
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
this.validateComponentAttributes(child, entry.meta.properties);
|
|
151
|
+
hostComponents.push(entry.ctor);
|
|
152
|
+
this.rejectChildElements(child);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/** Validate a node element's transform + type-specific attributes. */
|
|
156
|
+
validateNodeAttributes(el, def) {
|
|
157
|
+
const allowed = { ...TRANSFORM_ATTRS, ...def.attrs };
|
|
158
|
+
for (const [attr, raw] of el.attributes) {
|
|
159
|
+
if (isNamespaceAttr(attr))
|
|
160
|
+
continue; // xmlns / xsi schema hints for IDEs
|
|
161
|
+
if (attr === 'id') {
|
|
162
|
+
this.registerId(el, raw, 'node');
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const meta = allowed[attr];
|
|
166
|
+
if (!meta) {
|
|
167
|
+
const suggestion = nearestName(attr, [...Object.keys(allowed), 'id']);
|
|
168
|
+
this.error(el, 'unknown-attr', `<${el.tag}> has no attribute "${attr}"${suggestion ? `. Did you mean "${suggestion}"?` : ''}`);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
this.parseScalarAttr(el, attr, raw, meta.type);
|
|
172
|
+
}
|
|
173
|
+
for (const [attr, meta] of Object.entries(allowed)) {
|
|
174
|
+
if (meta.required && !el.attributes.has(attr)) {
|
|
175
|
+
this.error(el, 'missing-attr', `<${el.tag}> is missing required attribute "${attr}"`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/** Validate an attachment element's attributes (no transform/id; `src` allowed). */
|
|
180
|
+
validateAttachmentAttributes(el, def) {
|
|
181
|
+
for (const [attr, raw] of el.attributes) {
|
|
182
|
+
if (isNamespaceAttr(attr))
|
|
183
|
+
continue;
|
|
184
|
+
// An attachment may pull its defaults from a resource file (e.g. a shared
|
|
185
|
+
// .material.xml); the file itself is resolved and checked at compile time.
|
|
186
|
+
if (attr === 'src')
|
|
187
|
+
continue;
|
|
188
|
+
const meta = def.attrs[attr];
|
|
189
|
+
if (!meta) {
|
|
190
|
+
const suggestion = nearestName(attr, [...Object.keys(def.attrs), 'src']);
|
|
191
|
+
this.error(el, 'unknown-attr', `<${el.tag}> has no attribute "${attr}"${suggestion ? `. Did you mean "${suggestion}"?` : ''}`);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
this.parseScalarAttr(el, attr, raw, meta.type);
|
|
195
|
+
}
|
|
196
|
+
for (const [attr, meta] of Object.entries(def.attrs)) {
|
|
197
|
+
if (meta.required && !el.attributes.has(attr)) {
|
|
198
|
+
this.error(el, 'missing-attr', `<${el.tag}> is missing required attribute "${attr}"`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/** Validate a component element. */
|
|
203
|
+
validateComponentAttributes(el, props) {
|
|
204
|
+
for (const [attr, raw] of el.attributes) {
|
|
205
|
+
if (isNamespaceAttr(attr))
|
|
206
|
+
continue;
|
|
207
|
+
if (attr === 'id') {
|
|
208
|
+
this.registerId(el, raw, 'component');
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const meta = props.get(attr);
|
|
212
|
+
if (!meta) {
|
|
213
|
+
const suggestion = nearestName(attr, [...props.keys(), 'id']);
|
|
214
|
+
this.error(el, 'unknown-attr', `<${el.tag}> has no property "${attr}"${suggestion ? `. Did you mean "${suggestion}"?` : ''}`);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (isScalarType(meta.type)) {
|
|
218
|
+
this.parseScalarAttr(el, attr, raw, meta.type);
|
|
219
|
+
}
|
|
220
|
+
else if (meta.type === 'prefab') {
|
|
221
|
+
// A prefab prop is a `.prefab.xml` file path (no `#`); the file itself is
|
|
222
|
+
// resolved, compiled, and checked at compile time (with its own diagnostics).
|
|
223
|
+
this.validatePrefabAttr(el, attr, raw);
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
this.recordReference(el, attr, raw, meta.type);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
for (const [attr, meta] of props) {
|
|
230
|
+
if (meta.required && !el.attributes.has(attr)) {
|
|
231
|
+
this.error(el, 'missing-attr', `<${el.tag}> is missing required property "${attr}"`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/** Record a `#id` reference for resolution once the whole tree is known. */
|
|
236
|
+
recordReference(el, attr, raw, declared) {
|
|
237
|
+
const value = raw.trim();
|
|
238
|
+
if (value === '')
|
|
239
|
+
return; // optional, left unset
|
|
240
|
+
if (!value.startsWith('#')) {
|
|
241
|
+
this.error(el, 'ref-missing-hash', `<${el.tag}> "${attr}" must reference an id with a leading "#" (e.g. "#turbine")`);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
this.pendingRefs.push({ element: el, attr, value: value.slice(1), declared });
|
|
245
|
+
}
|
|
246
|
+
/** A `prefab` prop must name a file; `#id` here is almost always a mistake. */
|
|
247
|
+
validatePrefabAttr(el, attr, raw) {
|
|
248
|
+
const value = raw.trim();
|
|
249
|
+
if (value === '')
|
|
250
|
+
return; // optional, left unset
|
|
251
|
+
if (value.startsWith('#')) {
|
|
252
|
+
this.error(el, 'bad-value', `<${el.tag}> "${attr}" is a prefab file path (e.g. "enemy.prefab.xml"), not an id reference — drop the leading "#"`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
parseScalarAttr(el, attr, raw, type) {
|
|
256
|
+
const result = codecs[type].parse(raw);
|
|
257
|
+
if (!result.ok) {
|
|
258
|
+
this.error(el, 'bad-value', `<${el.tag}> attribute "${attr}": ${result.message}`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
rejectChildElements(el) {
|
|
262
|
+
for (const child of el.children) {
|
|
263
|
+
this.error(child, 'unexpected-child', `<${el.tag}> may not contain child elements (found <${child.tag}>)`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
registerId(el, raw, kind) {
|
|
267
|
+
// `/` is allowed in ids — references are literal `#id` lookups, so a scoped
|
|
268
|
+
// id like `t1/osc` (from prefab expansion, or authored) is unambiguous.
|
|
269
|
+
const existing = this.ids.get(raw);
|
|
270
|
+
if (existing) {
|
|
271
|
+
this.error(el, 'duplicate-id', `Duplicate id "${raw}" (first used at line ${existing.el.line})`);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
this.ids.set(raw, { el, kind });
|
|
275
|
+
}
|
|
276
|
+
resolveReferences() {
|
|
277
|
+
for (const ref of this.pendingRefs) {
|
|
278
|
+
const entry = this.ids.get(ref.value);
|
|
279
|
+
if (!entry) {
|
|
280
|
+
this.error(ref.element, 'unresolved-ref', `<${ref.element.tag}> "${ref.attr}" references "#${ref.value}", which has no matching id`);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (ref.declared === 'node') {
|
|
284
|
+
if (entry.kind !== 'node') {
|
|
285
|
+
this.error(ref.element, 'ref-type-mismatch', `<${ref.element.tag}> "${ref.attr}" expects a node, but "#${ref.value}" is a component`);
|
|
286
|
+
}
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
this.resolveComponentRef(ref, ref.declared, entry);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
resolveComponentRef(ref, declared, entry) {
|
|
293
|
+
if (entry.kind === 'component') {
|
|
294
|
+
const compEntry = Registry.getComponent(entry.el.tag);
|
|
295
|
+
if (compEntry && !isAssignable(compEntry.ctor, declared)) {
|
|
296
|
+
this.error(ref.element, 'ref-type-mismatch', `<${ref.element.tag}> "${ref.attr}" expects a ${declared.name}, but "#${ref.value}" is a ${entry.el.tag}`);
|
|
297
|
+
}
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
// The id names a node: resolve to a component of the declared type on it.
|
|
301
|
+
const matches = (this.componentsByNode.get(entry.el) ?? []).filter((c) => isAssignable(c, declared));
|
|
302
|
+
if (matches.length === 0) {
|
|
303
|
+
this.error(ref.element, 'unresolved-ref', `<${ref.element.tag}> "${ref.attr}" expects a ${declared.name} on node "#${ref.value}", but that node has none`);
|
|
304
|
+
}
|
|
305
|
+
else if (matches.length > 1) {
|
|
306
|
+
this.error(ref.element, 'ambiguous-ref', `<${ref.element.tag}> "${ref.attr}": node "#${ref.value}" has multiple ${declared.name} components — give the target component its own id and reference that`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
function isAssignable(actual, declared) {
|
|
311
|
+
return actual === declared || actual.prototype instanceof declared;
|
|
312
|
+
}
|
|
313
|
+
/** Required attachment groups for a node (e.g. a <Mesh> requires a 'geometry'). */
|
|
314
|
+
function requiredGroupsFor(node) {
|
|
315
|
+
const groups = new Map();
|
|
316
|
+
for (const d of allAttachmentDefs()) {
|
|
317
|
+
if (!d.required || !attachmentAcceptsNode(d, node))
|
|
318
|
+
continue;
|
|
319
|
+
(groups.get(d.group) ?? groups.set(d.group, []).get(d.group)).push(d.tag);
|
|
320
|
+
}
|
|
321
|
+
return groups;
|
|
322
|
+
}
|
|
323
|
+
/** A fix-it hint listing the node tags an attachment can attach to. */
|
|
324
|
+
function targetHint(attDef) {
|
|
325
|
+
const tags = nodeTags().filter((t) => attachmentAcceptsNode(attDef, getNodeDef(t)));
|
|
326
|
+
return tags.length > 0 ? ` (it attaches to ${tags.map((t) => `<${t}>`).join(', ')})` : '';
|
|
327
|
+
}
|
|
328
|
+
/** XML namespace bookkeeping (xmlns, xsi:*) — used to wire an XSD, ignored by us. */
|
|
329
|
+
function isNamespaceAttr(name) {
|
|
330
|
+
return name === 'xmlns' || name.startsWith('xmlns:') || name.startsWith('xsi:');
|
|
331
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type Bundle, type BundleKind, type CompiledScene } from '@scenoco-three/core/internal';
|
|
2
|
+
/**
|
|
3
|
+
* Encode a CompiledScene into the optimized bundle (string pool + default
|
|
4
|
+
* elision + TRS special-case + bitwise-NOT refs). Build-time only; the runtime
|
|
5
|
+
* decoder reverses it. See bundle-format.ts for the wire layout.
|
|
6
|
+
*/
|
|
7
|
+
export declare function bundleScene(compiled: CompiledScene, kind?: BundleKind): Bundle;
|
|
8
|
+
/** Encode a prefab CompiledScene for runtime `engine.instantiate`. */
|
|
9
|
+
export declare function bundlePrefab(compiled: CompiledScene): Bundle;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { isScalarType, BUNDLE_VERSION, ComponentFlag, NodeFlag, componentPropEntries, nodeAttrEntries, attachmentAttrEntries, valueEquals, } from '@scenoco-three/core/internal';
|
|
2
|
+
/**
|
|
3
|
+
* Encode a CompiledScene into the optimized bundle (string pool + default
|
|
4
|
+
* elision + TRS special-case + bitwise-NOT refs). Build-time only; the runtime
|
|
5
|
+
* decoder reverses it. See bundle-format.ts for the wire layout.
|
|
6
|
+
*/
|
|
7
|
+
export function bundleScene(compiled, kind = 'scene') {
|
|
8
|
+
const pool = buildStringPool(compiled);
|
|
9
|
+
const intern = (s) => pool.index.get(s);
|
|
10
|
+
const attachments = compiled.attachments.map((a) => encodeTag(a, intern));
|
|
11
|
+
const components = compiled.components.map((c) => encodeComponent(c, intern));
|
|
12
|
+
const nodes = compiled.nodes.map((n) => encodeNode(n, intern));
|
|
13
|
+
// Embedded prefabs are self-contained sub-bundles (their own string pool), so
|
|
14
|
+
// they nest cleanly; the slot is appended only when the scene uses one.
|
|
15
|
+
const asset = compiled.prefabs?.length
|
|
16
|
+
? [nodes, attachments, components, compiled.root, compiled.prefabs.map((p) => bundlePrefab(p))]
|
|
17
|
+
: [nodes, attachments, components, compiled.root];
|
|
18
|
+
// The `kind` slot is appended only for prefabs, so scene bundles stay at 3 elements.
|
|
19
|
+
return kind === 'prefab' ? [BUNDLE_VERSION, pool.strings, asset, 'prefab'] : [BUNDLE_VERSION, pool.strings, asset];
|
|
20
|
+
}
|
|
21
|
+
/** Encode a prefab CompiledScene for runtime `engine.instantiate`. */
|
|
22
|
+
export function bundlePrefab(compiled) {
|
|
23
|
+
return bundleScene(compiled, 'prefab');
|
|
24
|
+
}
|
|
25
|
+
// ---- string pool ----------------------------------------------------------
|
|
26
|
+
function buildStringPool(compiled) {
|
|
27
|
+
const counts = new Map();
|
|
28
|
+
const add = (s) => {
|
|
29
|
+
if (s !== undefined)
|
|
30
|
+
counts.set(s, (counts.get(s) ?? 0) + 1);
|
|
31
|
+
};
|
|
32
|
+
const addStringAttrs = (entries, attrs) => {
|
|
33
|
+
for (const [name, meta] of entries) {
|
|
34
|
+
if (meta.type === 'string')
|
|
35
|
+
add(attrs[name]);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
for (const n of compiled.nodes) {
|
|
39
|
+
add(n.tag);
|
|
40
|
+
add(n.id);
|
|
41
|
+
addStringAttrs(nodeAttrEntries(n.tag), n.attrs);
|
|
42
|
+
}
|
|
43
|
+
for (const a of compiled.attachments) {
|
|
44
|
+
add(a.tag);
|
|
45
|
+
addStringAttrs(attachmentAttrEntries(a.tag), a.attrs);
|
|
46
|
+
}
|
|
47
|
+
for (const c of compiled.components) {
|
|
48
|
+
add(c.type);
|
|
49
|
+
add(c.id);
|
|
50
|
+
for (const [name, meta] of componentPropEntries(c.type)) {
|
|
51
|
+
if (meta.type === 'string' && name in c.props)
|
|
52
|
+
add(c.props[name]);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// Most-used strings first → smallest indices (cheapest in any varint codec).
|
|
56
|
+
// Tie-break alphabetically so the bundle is deterministic.
|
|
57
|
+
const strings = [...counts.entries()]
|
|
58
|
+
.sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1))
|
|
59
|
+
.map(([s]) => s);
|
|
60
|
+
return { strings, index: new Map(strings.map((s, i) => [s, i])) };
|
|
61
|
+
}
|
|
62
|
+
// ---- records --------------------------------------------------------------
|
|
63
|
+
function encodeTag(asset, intern) {
|
|
64
|
+
const { mask, values } = encodeAttrs(attachmentAttrEntries(asset.tag), asset.attrs, intern);
|
|
65
|
+
return [intern(asset.tag), mask, ...values];
|
|
66
|
+
}
|
|
67
|
+
function encodeNode(n, intern) {
|
|
68
|
+
const { mask, values } = encodeAttrs(nodeAttrEntries(n.tag), n.attrs, intern);
|
|
69
|
+
let flags = 0;
|
|
70
|
+
const extras = [];
|
|
71
|
+
if (n.id !== undefined) {
|
|
72
|
+
flags |= NodeFlag.Id;
|
|
73
|
+
extras.push(intern(n.id));
|
|
74
|
+
}
|
|
75
|
+
const trs = encodeTransform(n.transform);
|
|
76
|
+
if (trs) {
|
|
77
|
+
flags |= NodeFlag.Transform;
|
|
78
|
+
extras.push(trs);
|
|
79
|
+
}
|
|
80
|
+
if (n.attachments.length > 0) {
|
|
81
|
+
flags |= NodeFlag.Attachments;
|
|
82
|
+
extras.push(n.attachments);
|
|
83
|
+
}
|
|
84
|
+
if (n.components.length > 0) {
|
|
85
|
+
flags |= NodeFlag.Components;
|
|
86
|
+
extras.push(n.components);
|
|
87
|
+
}
|
|
88
|
+
if (n.children.length > 0) {
|
|
89
|
+
flags |= NodeFlag.Children;
|
|
90
|
+
extras.push(n.children);
|
|
91
|
+
}
|
|
92
|
+
return [intern(n.tag), flags, mask, ...values, ...extras];
|
|
93
|
+
}
|
|
94
|
+
function encodeComponent(c, intern) {
|
|
95
|
+
let mask = 0;
|
|
96
|
+
const values = [];
|
|
97
|
+
let bit = 0;
|
|
98
|
+
for (const [name, meta] of componentPropEntries(c.type)) {
|
|
99
|
+
if (name in c.props) {
|
|
100
|
+
mask |= 1 << bit;
|
|
101
|
+
values.push(encodePropValue(c.props[name], meta.type, intern));
|
|
102
|
+
}
|
|
103
|
+
bit++;
|
|
104
|
+
}
|
|
105
|
+
let flags = 0;
|
|
106
|
+
const out = [intern(c.type), 0, mask, ...values];
|
|
107
|
+
if (c.id !== undefined) {
|
|
108
|
+
flags |= ComponentFlag.Id;
|
|
109
|
+
out.push(intern(c.id));
|
|
110
|
+
}
|
|
111
|
+
out[1] = flags;
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
// ---- value helpers --------------------------------------------------------
|
|
115
|
+
function encodeAttrs(entries, attrs, intern) {
|
|
116
|
+
let mask = 0;
|
|
117
|
+
const values = [];
|
|
118
|
+
let bit = 0;
|
|
119
|
+
for (const [name, meta] of entries) {
|
|
120
|
+
const value = attrs[name];
|
|
121
|
+
if (value !== undefined && !valueEquals(value, meta.default)) {
|
|
122
|
+
mask |= 1 << bit;
|
|
123
|
+
values.push(meta.type === 'string' ? intern(value) : value);
|
|
124
|
+
}
|
|
125
|
+
bit++;
|
|
126
|
+
}
|
|
127
|
+
return { mask, values };
|
|
128
|
+
}
|
|
129
|
+
/** Reference props use bitwise-NOT: component → `i`, node → `~i`. */
|
|
130
|
+
function encodePropValue(value, type, intern) {
|
|
131
|
+
// A prefab prop stores a plain prefabs[] index (the registered type, not the
|
|
132
|
+
// sign, tells the decoder it is a prefab — no collision with node/component).
|
|
133
|
+
if (type === 'prefab')
|
|
134
|
+
return value.prefab;
|
|
135
|
+
if (!isScalarType(type)) {
|
|
136
|
+
const ref = value;
|
|
137
|
+
return 'component' in ref ? ref.component : ~ref.node;
|
|
138
|
+
}
|
|
139
|
+
if (type === 'string')
|
|
140
|
+
return intern(value);
|
|
141
|
+
return value;
|
|
142
|
+
}
|
|
143
|
+
function encodeTransform(t) {
|
|
144
|
+
const isIdentity = t.position.every((v) => v === 0) &&
|
|
145
|
+
t.rotation.every((v) => v === 0) &&
|
|
146
|
+
t.scale.every((v) => v === 1) &&
|
|
147
|
+
t.visible;
|
|
148
|
+
if (isIdentity)
|
|
149
|
+
return null;
|
|
150
|
+
return [...t.position, ...t.rotation, ...t.scale, t.visible ? 1 : 0];
|
|
151
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type CompiledScene } from '@scenoco-three/core/internal';
|
|
2
|
+
import type { SceDiagnostic } from './diagnostics.js';
|
|
3
|
+
export type { CompiledScene, CompiledNode, CompiledAsset, CompiledComponent, CompiledTransform, CompiledRef, CompiledValue, } from '@scenoco-three/core/internal';
|
|
4
|
+
export type CompileResult = {
|
|
5
|
+
ok: true;
|
|
6
|
+
scene: CompiledScene;
|
|
7
|
+
} | {
|
|
8
|
+
ok: false;
|
|
9
|
+
diagnostics: SceDiagnostic[];
|
|
10
|
+
};
|
|
11
|
+
/** A referenced file loaded by a resolver: its canonical path and contents. */
|
|
12
|
+
export interface ResolvedFile {
|
|
13
|
+
path: string;
|
|
14
|
+
contents: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Loads a file referenced by `src=` (a material/prefab resource), resolving
|
|
18
|
+
* `request` relative to `referrer`. Returns null if it can't be found. The CLI
|
|
19
|
+
* supplies a filesystem resolver; a browser host can read from a bundled map.
|
|
20
|
+
*/
|
|
21
|
+
export type ResourceResolver = (request: string, referrer: string) => ResolvedFile | null;
|
|
22
|
+
export interface CompileOptions {
|
|
23
|
+
/** Path of the scene being compiled — the referrer for relative `src=` paths. */
|
|
24
|
+
path?: string;
|
|
25
|
+
/** Required for scenes that use `src=` to pull in resource files. */
|
|
26
|
+
resolve?: ResourceResolver;
|
|
27
|
+
}
|
|
28
|
+
/** Full pipeline: parse → expand prefabs → validate → compile. */
|
|
29
|
+
export declare function compileSceneXml(xml: string, options?: CompileOptions): CompileResult;
|
|
30
|
+
/**
|
|
31
|
+
* Compile a `.prefab.xml` file into a standalone bundle for **dynamic
|
|
32
|
+
* instantiation** at runtime (`engine.instantiate`). The compiled root is the
|
|
33
|
+
* prefab's single node; ids stay local (each runtime instance is its own
|
|
34
|
+
* subtree, and internal references are already resolved to indices).
|
|
35
|
+
*/
|
|
36
|
+
export declare function compilePrefabXml(xml: string, options?: CompileOptions): CompileResult;
|