@multiplekex/shallot 0.1.12 → 0.2.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 (62) hide show
  1. package/package.json +3 -4
  2. package/src/core/builder.ts +71 -32
  3. package/src/core/component.ts +25 -11
  4. package/src/core/index.ts +14 -13
  5. package/src/core/math.ts +135 -0
  6. package/src/core/runtime.ts +0 -1
  7. package/src/core/state.ts +9 -68
  8. package/src/core/xml.ts +381 -265
  9. package/src/editor/format.ts +5 -0
  10. package/src/editor/index.ts +101 -0
  11. package/src/extras/arrows/index.ts +28 -69
  12. package/src/extras/gradient/index.ts +36 -52
  13. package/src/extras/lines/index.ts +51 -122
  14. package/src/extras/orbit/index.ts +40 -15
  15. package/src/extras/text/font.ts +546 -0
  16. package/src/extras/text/index.ts +158 -204
  17. package/src/extras/text/sdf.ts +429 -0
  18. package/src/standard/activity/index.ts +172 -0
  19. package/src/standard/compute/graph.ts +23 -23
  20. package/src/standard/compute/index.ts +76 -61
  21. package/src/standard/defaults.ts +8 -5
  22. package/src/standard/index.ts +1 -0
  23. package/src/standard/input/index.ts +30 -19
  24. package/src/standard/loading/index.ts +18 -13
  25. package/src/standard/render/bvh/blas.ts +752 -0
  26. package/src/standard/render/bvh/radix.ts +476 -0
  27. package/src/standard/render/bvh/structs.ts +167 -0
  28. package/src/standard/render/bvh/tlas.ts +886 -0
  29. package/src/standard/render/bvh/traverse.ts +467 -0
  30. package/src/standard/render/camera.ts +302 -27
  31. package/src/standard/render/data.ts +93 -0
  32. package/src/standard/render/depth.ts +117 -0
  33. package/src/standard/render/forward/index.ts +259 -0
  34. package/src/standard/render/forward/raster.ts +228 -0
  35. package/src/standard/render/index.ts +443 -70
  36. package/src/standard/render/indirect.ts +40 -0
  37. package/src/standard/render/instance.ts +214 -0
  38. package/src/standard/render/intersection.ts +72 -0
  39. package/src/standard/render/light.ts +16 -16
  40. package/src/standard/render/mesh/index.ts +67 -75
  41. package/src/standard/render/mesh/unified.ts +96 -0
  42. package/src/standard/render/{transparent.ts → overlay.ts} +14 -15
  43. package/src/standard/render/pass.ts +10 -4
  44. package/src/standard/render/postprocess.ts +142 -64
  45. package/src/standard/render/ray.ts +61 -0
  46. package/src/standard/render/scene.ts +38 -164
  47. package/src/standard/render/shaders.ts +484 -0
  48. package/src/standard/render/surface/compile.ts +3 -10
  49. package/src/standard/render/surface/index.ts +60 -30
  50. package/src/standard/render/surface/noise.ts +45 -0
  51. package/src/standard/render/surface/structs.ts +60 -19
  52. package/src/standard/render/surface/wgsl.ts +573 -0
  53. package/src/standard/render/triangle.ts +84 -0
  54. package/src/standard/transforms/index.ts +4 -6
  55. package/src/standard/tween/index.ts +10 -1
  56. package/src/standard/tween/sequence.ts +24 -16
  57. package/src/standard/tween/tween.ts +67 -16
  58. package/src/core/types.ts +0 -37
  59. package/src/standard/compute/inspect.ts +0 -201
  60. package/src/standard/compute/pass.ts +0 -23
  61. package/src/standard/compute/timing.ts +0 -139
  62. package/src/standard/render/forward.ts +0 -273
package/src/core/xml.ts CHANGED
@@ -1,10 +1,12 @@
1
- import { XMLParser } from "fast-xml-parser";
2
1
  import { addComponent, Pair } from "bitecs";
3
2
  import type { State } from "./state";
4
3
  import { getRegisteredComponent, type ComponentLike, type RegisteredComponent } from "./component";
5
4
  import { getRelationDef, ChildOf } from "./relation";
6
5
  import { toKebabCase, toCamelCase } from "./strings";
7
6
 
7
+ const INDENT = " ";
8
+ const MAX_LINE = 100;
9
+
8
10
  function levenshtein(a: string, b: string): number {
9
11
  if (a.length === 0) return b.length;
10
12
  if (b.length === 0) return a.length;
@@ -61,221 +63,253 @@ function findClosestMatch(input: string, candidates: string[]): string | null {
61
63
  return bestMatch;
62
64
  }
63
65
 
64
- export interface ParsedElement {
65
- readonly tag: string;
66
- readonly attrs: Readonly<Record<string, string>>;
67
- readonly children: readonly ParsedElement[];
66
+ export interface Node {
67
+ id?: string;
68
+ attrs: Attr[];
69
+ children: Node[];
70
+ comments?: string[];
71
+ blankBefore?: boolean;
68
72
  }
69
73
 
70
- export interface EntityRef {
71
- readonly attrName: string;
72
- readonly targetName: string;
74
+ export interface Attr {
75
+ name: string;
76
+ value: string;
73
77
  }
74
78
 
75
- export interface EntityDef {
76
- readonly id?: string;
77
- readonly components: readonly ComponentDef[];
78
- readonly children: readonly EntityDef[];
79
- readonly entityRefs: readonly EntityRef[];
79
+ export interface Ref {
80
+ attr: string;
81
+ target: string;
80
82
  }
81
83
 
82
- export interface ComponentDef {
83
- readonly def: RegisteredComponent;
84
- readonly attrs: Record<string, string>;
84
+ export interface ParseError {
85
+ message: string;
86
+ path?: string;
85
87
  }
86
88
 
87
- export interface ParseResult {
88
- readonly entities: readonly EntityDef[];
89
- readonly errors: readonly ParseError[];
90
- readonly warnings: readonly string[];
89
+ interface PendingFieldRef {
90
+ eid: number;
91
+ component: ComponentLike;
92
+ field: string;
93
+ targetName: string;
91
94
  }
92
95
 
93
- export interface ParseError {
94
- readonly message: string;
95
- readonly path?: string;
96
+ interface Token {
97
+ type: "comment" | "open" | "close" | "blank";
98
+ value: string;
99
+ selfClosing?: boolean;
100
+ attrs?: Record<string, string>;
101
+ tagName?: string;
96
102
  }
97
103
 
98
- export interface PendingFieldRef {
99
- readonly eid: number;
100
- readonly component: ComponentLike;
101
- readonly field: string;
102
- readonly targetName: string;
103
- }
104
+ function tokenize(xml: string): Token[] {
105
+ const tokens: Token[] = [];
106
+ const regex = /<!--[\s\S]*?-->|<\/?\s*(\w+)[^>]*\/?>/g;
107
+ let lastIndex = 0;
108
+ let match: RegExpExecArray | null;
104
109
 
105
- export function parseXml(xml: string): ParseResult {
106
- const errors: ParseError[] = [];
107
- const warnings: string[] = [];
108
-
109
- const parser = new XMLParser({
110
- ignoreAttributes: false,
111
- attributeNamePrefix: "",
112
- preserveOrder: true,
113
- trimValues: true,
114
- allowBooleanAttributes: true,
115
- });
116
-
117
- let raw: unknown;
118
- try {
119
- raw = parser.parse(xml, { allowBooleanAttributes: true });
120
- } catch (e) {
121
- return {
122
- entities: [],
123
- errors: [{ message: `xml parse error: ${(e as Error).message}` }],
124
- warnings: [],
125
- };
126
- }
127
-
128
- const elements = normalizeRaw(raw);
129
- const entities: EntityDef[] = [];
130
-
131
- for (const el of elements) {
132
- if (el.tag === "scene") {
133
- for (const child of el.children) {
134
- const result = parseEntityElement(child, errors, warnings);
135
- if (result) entities.push(result);
136
- }
137
- } else if (el.tag === "a") {
138
- const result = parseEntityElement(el, errors, warnings);
139
- if (result) entities.push(result);
140
- } else if (el.tag.toLowerCase() === "scene" || el.tag.toLowerCase() === "world") {
141
- errors.push({ message: `Invalid tag "${el.tag}". Use lowercase <scene>` });
110
+ while ((match = regex.exec(xml)) !== null) {
111
+ const before = xml.slice(lastIndex, match.index);
112
+ if (/\n\s*\n/.test(before)) {
113
+ tokens.push({ type: "blank", value: "" });
114
+ }
115
+ lastIndex = match.index + match[0].length;
116
+
117
+ const tag = match[0];
118
+
119
+ if (tag.startsWith("<!--")) {
120
+ const content = tag.slice(4, -3).trim();
121
+ tokens.push({ type: "comment", value: content });
122
+ } else if (tag.startsWith("</")) {
123
+ const tagName = tag.match(/<\/\s*(\w+)/)?.[1] ?? "";
124
+ tokens.push({ type: "close", value: tag, tagName });
125
+ } else {
126
+ const selfClosing = tag.endsWith("/>");
127
+ const tagMatch = tag.match(/<\s*(\w+)/);
128
+ const tagName = tagMatch?.[1] ?? "";
129
+ const attrs = parseTagAttrs(tag);
130
+ tokens.push({ type: "open", value: tag, selfClosing, tagName, attrs });
142
131
  }
143
132
  }
144
133
 
145
- return { entities, errors, warnings };
134
+ return tokens;
146
135
  }
147
136
 
148
- function isEntityRef(value: string): boolean {
149
- return value.startsWith("@") && value.length > 1;
137
+ function parseTagAttrs(tag: string): Record<string, string> {
138
+ const attrs: Record<string, string> = {};
139
+ const attrRegex = /([^\s=<>\/]+)(?:\s*=\s*"([^"]*)")?/g;
140
+ const inner = tag.replace(/^<\s*\w+/, "").replace(/\/?>$/, "");
141
+ let match: RegExpExecArray | null;
142
+
143
+ while ((match = attrRegex.exec(inner)) !== null) {
144
+ const name = match[1];
145
+ const value = match[2] ?? "";
146
+ attrs[name] = value;
147
+ }
148
+
149
+ return attrs;
150
150
  }
151
151
 
152
- function parseEntityElement(
153
- el: ParsedElement,
154
- errors: ParseError[],
155
- warnings: string[]
156
- ): EntityDef | null {
157
- if (el.tag !== "a") {
158
- if (el.tag.toLowerCase() === "a") {
159
- errors.push({ message: `Invalid tag "${el.tag}". Use lowercase <a>` });
152
+ export function parse(xml: string): Node[] {
153
+ const unclosedMatch = xml.match(/<[^>]*$/);
154
+ if (unclosedMatch) {
155
+ throw new Error(`xml parse error: Unclosed tag at end of document`);
156
+ }
157
+
158
+ const tokens = tokenize(xml);
159
+
160
+ for (const token of tokens) {
161
+ if (token.type === "open" && token.tagName !== "scene" && token.tagName !== "a") {
162
+ const tagName = token.tagName ?? "unknown";
163
+ if (tagName.toLowerCase() === "a" || tagName.toLowerCase() === "scene") {
164
+ continue;
165
+ }
166
+ throw new Error(`xml parse error: Unknown tag <${tagName}>`);
160
167
  }
161
- return null;
162
168
  }
163
169
 
164
- const components: ComponentDef[] = [];
165
- const children: EntityDef[] = [];
166
- const entityRefs: EntityRef[] = [];
167
- const shorthands: Array<{ name: string; value: string }> = [];
168
- let entityId: string | undefined;
170
+ const nodes: Node[] = [];
171
+ const errors: ParseError[] = [];
172
+
173
+ let i = 0;
174
+ let pendingComments: string[] = [];
175
+ let pendingBlank = false;
169
176
 
170
- for (const [attrName, attrValue] of Object.entries(el.attrs)) {
171
- if (attrName === "id") {
172
- entityId = attrValue;
177
+ while (i < tokens.length) {
178
+ const token = tokens[i];
179
+
180
+ if (token.type === "blank") {
181
+ pendingBlank = true;
182
+ i++;
173
183
  continue;
174
184
  }
175
185
 
176
- if (typeof attrValue === "string" && isEntityRef(attrValue)) {
177
- entityRefs.push({
178
- attrName,
179
- targetName: attrValue.slice(1),
180
- });
186
+ if (token.type === "comment") {
187
+ pendingComments.push(token.value);
188
+ i++;
181
189
  continue;
182
190
  }
183
191
 
184
- const registered = getRegisteredComponent(attrName);
185
- if (registered) {
186
- const attrs: Record<string, string> = {};
187
- if (typeof attrValue === "string" && attrValue !== "") {
188
- attrs["_value"] = attrValue;
192
+ if (token.type === "open" && token.tagName === "scene") {
193
+ pendingComments = [];
194
+ pendingBlank = false;
195
+ i++;
196
+ continue;
197
+ }
198
+
199
+ if (token.type === "close" && token.tagName === "scene") {
200
+ i++;
201
+ continue;
202
+ }
203
+
204
+ if (token.type === "open" && token.tagName === "a") {
205
+ const result = parseNodeFromTokens(tokens, i, errors);
206
+ if (result.node) {
207
+ result.node.comments = pendingComments.length > 0 ? pendingComments : undefined;
208
+ result.node.blankBefore = pendingBlank || undefined;
209
+ nodes.push(result.node);
189
210
  }
190
- components.push({ def: registered, attrs });
211
+ pendingComments = [];
212
+ pendingBlank = false;
213
+ i = result.nextIndex;
191
214
  continue;
192
215
  }
193
216
 
194
- if (typeof attrValue === "string" && attrValue !== "") {
195
- shorthands.push({ name: attrName, value: attrValue });
217
+ if (token.type === "open" && token.tagName?.toLowerCase() === "scene") {
218
+ throw new Error(`Invalid tag "${token.tagName}". Use lowercase <scene>`);
196
219
  }
220
+
221
+ if (
222
+ token.type === "open" &&
223
+ token.tagName?.toLowerCase() === "a" &&
224
+ token.tagName !== "a"
225
+ ) {
226
+ throw new Error(`Invalid tag "${token.tagName}". Use lowercase <a>`);
227
+ }
228
+
229
+ i++;
197
230
  }
198
231
 
199
- for (const { name, value } of shorthands) {
200
- const camel = toCamelCase(name);
201
- let matched = false;
232
+ if (errors.length > 0) {
233
+ throw new Error(errors.map((e) => e.message).join("\n"));
234
+ }
202
235
 
203
- for (const comp of components) {
204
- const component = comp.def.component;
205
- const hasField = camel in component || `${camel}X` in component;
236
+ return nodes;
237
+ }
206
238
 
207
- if (hasField) {
208
- matched = true;
209
- if (!comp.attrs._value) {
210
- comp.attrs[name] = value;
211
- }
212
- }
213
- }
239
+ function parseNodeFromTokens(
240
+ tokens: Token[],
241
+ startIndex: number,
242
+ errors: ParseError[]
243
+ ): { node: Node | null; nextIndex: number } {
244
+ const token = tokens[startIndex];
214
245
 
215
- if (!matched) {
216
- const id = entityId ? ` (${entityId})` : "";
217
- warnings.push(`shorthand "${name}" matches no declared component${id}`);
246
+ if (token.type !== "open" || token.tagName !== "a") {
247
+ if (token.tagName?.toLowerCase() === "a") {
248
+ errors.push({ message: `Invalid tag "${token.tagName}". Use lowercase <a>` });
218
249
  }
250
+ return { node: null, nextIndex: startIndex + 1 };
219
251
  }
220
252
 
221
- for (const child of el.children) {
222
- if (child.tag === "a") {
223
- const childEntity = parseEntityElement(child, errors, warnings);
224
- if (childEntity) children.push(childEntity);
225
- } else if (child.tag.toLowerCase() === "a") {
226
- errors.push({ message: `Invalid tag "${child.tag}". Use lowercase <a>` });
253
+ const rawAttrs = token.attrs ?? {};
254
+ const attrs: Attr[] = [];
255
+ let nodeId: string | undefined;
256
+
257
+ for (const [attrName, attrValue] of Object.entries(rawAttrs)) {
258
+ if (attrName === "id") {
259
+ nodeId = attrValue;
227
260
  } else {
228
- errors.push({ message: `Only <a> children allowed, found <${child.tag}>` });
261
+ attrs.push({ name: attrName, value: attrValue });
229
262
  }
230
263
  }
231
264
 
232
- return { id: entityId, components, children, entityRefs };
233
- }
265
+ const children: Node[] = [];
266
+ let i = startIndex + 1;
234
267
 
235
- interface RawElement {
236
- [key: string]: unknown;
237
- ":@"?: Record<string, string>;
238
- }
268
+ if (!token.selfClosing) {
269
+ let pendingComments: string[] = [];
270
+ let pendingBlank = false;
239
271
 
240
- function normalizeRaw(raw: unknown): ParsedElement[] {
241
- if (!Array.isArray(raw)) return [];
272
+ while (i < tokens.length) {
273
+ const childToken = tokens[i];
242
274
 
243
- const result: ParsedElement[] = [];
244
-
245
- for (const item of raw as RawElement[]) {
246
- if (typeof item !== "object" || item === null) continue;
275
+ if (childToken.type === "blank") {
276
+ pendingBlank = true;
277
+ i++;
278
+ continue;
279
+ }
247
280
 
248
- for (const [key, value] of Object.entries(item)) {
249
- if (key === ":@") continue;
281
+ if (childToken.type === "comment") {
282
+ pendingComments.push(childToken.value);
283
+ i++;
284
+ continue;
285
+ }
250
286
 
251
- const rawAttrs = item[":@"] ?? {};
252
- const attrs: Record<string, string> = {};
287
+ if (childToken.type === "close" && childToken.tagName === "a") {
288
+ i++;
289
+ break;
290
+ }
253
291
 
254
- for (const [attrKey, attrVal] of Object.entries(rawAttrs)) {
255
- if (typeof attrVal === "string") {
256
- attrs[attrKey] = attrVal;
257
- } else if (attrVal === true || attrVal === "") {
258
- attrs[attrKey] = "";
292
+ if (childToken.type === "open" && childToken.tagName === "a") {
293
+ const result = parseNodeFromTokens(tokens, i, errors);
294
+ if (result.node) {
295
+ result.node.comments = pendingComments.length > 0 ? pendingComments : undefined;
296
+ result.node.blankBefore = pendingBlank || undefined;
297
+ children.push(result.node);
259
298
  }
299
+ pendingComments = [];
300
+ pendingBlank = false;
301
+ i = result.nextIndex;
302
+ continue;
260
303
  }
261
304
 
262
- const children = normalizeRaw(value as unknown);
263
-
264
- result.push({
265
- tag: key,
266
- attrs,
267
- children,
268
- });
305
+ i++;
269
306
  }
270
307
  }
271
308
 
272
- return result;
273
- }
274
-
275
- export interface LoadResult {
276
- readonly entities: Map<string, number>;
277
- readonly roots: readonly number[];
278
- readonly errors: readonly ParseError[];
309
+ return {
310
+ node: { id: nodeId, attrs, children },
311
+ nextIndex: i,
312
+ };
279
313
  }
280
314
 
281
315
  export interface PostLoadContext {
@@ -295,109 +329,115 @@ export function unregisterPostLoadHook(hook: PostLoadHook): void {
295
329
  if (idx !== -1) postLoadHooks.splice(idx, 1);
296
330
  }
297
331
 
298
- class LoadParseContext implements PostLoadContext {
299
- private readonly nameToEntity = new Map<string, number>();
300
-
301
- getEntityByName(name: string): number | null {
302
- return this.nameToEntity.get(name) ?? null;
303
- }
304
-
305
- setName(name: string, eid: number): void {
306
- this.nameToEntity.set(name, eid);
307
- }
308
-
309
- getEntityMap(): Map<string, number> {
310
- return new Map(this.nameToEntity);
311
- }
312
- }
313
-
314
- export function loadScene(state: State, xml: string): LoadResult {
315
- const parseResult = parseXml(xml);
316
- return instantiateScene(state, parseResult.entities, parseResult.errors);
317
- }
318
-
319
- export async function loadSceneFile(
320
- state: State,
321
- path: string,
322
- readFile: (path: string) => Promise<string>
323
- ): Promise<LoadResult> {
324
- const xml = await readFile(path);
325
- return loadScene(state, xml);
326
- }
327
-
328
332
  interface QueuedEntity {
329
- readonly def: EntityDef;
330
- readonly eid: number;
331
- readonly parent?: number;
333
+ node: Node;
334
+ eid: number;
335
+ parent?: number;
332
336
  }
333
337
 
334
- function instantiateScene(
335
- state: State,
336
- entityDefs: readonly EntityDef[],
337
- parseErrors: readonly ParseError[]
338
- ): LoadResult {
339
- const context = new LoadParseContext();
340
- const errors: ParseError[] = [...parseErrors];
338
+ export function load(nodes: Node[], state: State): Map<Node, number> {
339
+ const nameToEntity = new Map<string, number>();
340
+ const nodeToEntity = new Map<Node, number>();
341
+ const errors: ParseError[] = [];
341
342
  const queue: QueuedEntity[] = [];
342
- const roots: number[] = [];
343
343
  const pendingFieldRefs: PendingFieldRef[] = [];
344
344
 
345
- for (const entityDef of entityDefs) {
346
- const eid = createEntityTree(state, entityDef, context, undefined, queue);
347
- roots.push(eid);
345
+ for (const node of nodes) {
346
+ createEntityTree(state, node, nameToEntity, nodeToEntity, undefined, queue);
348
347
  }
349
348
 
350
- for (const { def, eid, parent } of queue) {
349
+ for (const { node, eid, parent } of queue) {
351
350
  if (parent !== undefined) {
352
351
  addComponent(state.world, eid, Pair(ChildOf.relation, parent));
353
352
  }
354
353
 
355
- for (const ref of def.entityRefs) {
356
- applyRelation(state, eid, ref, context, errors);
354
+ const { componentAttrs, refs, unknown } = categorizeAttrs(node.attrs);
355
+
356
+ for (const attr of unknown) {
357
+ const nodeId = node.id ? ` (id="${node.id}")` : "";
358
+ errors.push({ message: `Unknown component "${attr.name}"${nodeId}` });
357
359
  }
358
360
 
359
- for (const compDef of def.components) {
360
- applyComponent(state, eid, compDef, context, errors, pendingFieldRefs);
361
+ for (const ref of refs) {
362
+ applyRelation(state, eid, ref, nameToEntity, errors);
363
+ }
364
+
365
+ for (const attr of componentAttrs) {
366
+ applyComponent(state, eid, attr, errors, pendingFieldRefs);
361
367
  }
362
368
  }
363
369
 
364
370
  for (const ref of pendingFieldRefs) {
365
- const targetEid = context.getEntityByName(ref.targetName);
366
- if (targetEid === null) {
371
+ const targetEid = nameToEntity.get(ref.targetName);
372
+ if (targetEid === undefined) {
367
373
  errors.push({ message: `Unknown entity: "@${ref.targetName}"` });
368
374
  continue;
369
375
  }
370
376
  setFieldValue(ref.component, ref.field, ref.eid, targetEid);
371
377
  }
372
378
 
379
+ const context: PostLoadContext = {
380
+ getEntityByName: (name) => nameToEntity.get(name) ?? null,
381
+ };
373
382
  for (const hook of postLoadHooks) {
374
383
  hook(state, context);
375
384
  }
376
385
 
377
- return {
378
- entities: context.getEntityMap(),
379
- roots,
380
- errors,
381
- };
386
+ if (errors.length > 0) {
387
+ throw new Error(errors.map((e) => e.message).join("\n"));
388
+ }
389
+
390
+ return nodeToEntity;
391
+ }
392
+
393
+ interface CategorizedAttrs {
394
+ componentAttrs: { name: string; value: string; def: RegisteredComponent }[];
395
+ refs: Ref[];
396
+ unknown: { name: string; value: string }[];
397
+ }
398
+
399
+ function categorizeAttrs(attrs: Attr[]): CategorizedAttrs {
400
+ const componentAttrs: { name: string; value: string; def: RegisteredComponent }[] = [];
401
+ const refs: Ref[] = [];
402
+ const unknown: { name: string; value: string }[] = [];
403
+
404
+ for (const attr of attrs) {
405
+ if (attr.value.startsWith("@") && attr.value.length > 1) {
406
+ refs.push({ attr: attr.name, target: attr.value.slice(1) });
407
+ continue;
408
+ }
409
+
410
+ const registered = getRegisteredComponent(attr.name);
411
+ if (registered) {
412
+ componentAttrs.push({ name: attr.name, value: attr.value, def: registered });
413
+ continue;
414
+ }
415
+
416
+ unknown.push({ name: attr.name, value: attr.value });
417
+ }
418
+
419
+ return { componentAttrs, refs, unknown };
382
420
  }
383
421
 
384
422
  function createEntityTree(
385
423
  state: State,
386
- def: EntityDef,
387
- context: LoadParseContext,
424
+ node: Node,
425
+ nameToEntity: Map<string, number>,
426
+ nodeToEntity: Map<Node, number>,
388
427
  parent: number | undefined,
389
428
  queue: QueuedEntity[]
390
429
  ): number {
391
430
  const eid = state.addEntity();
392
431
 
393
- if (def.id) {
394
- context.setName(def.id, eid);
432
+ if (node.id) {
433
+ nameToEntity.set(node.id, eid);
395
434
  }
435
+ nodeToEntity.set(node, eid);
396
436
 
397
- queue.push({ def, eid, parent });
437
+ queue.push({ node, eid, parent });
398
438
 
399
- for (const childDef of def.children) {
400
- createEntityTree(state, childDef, context, eid, queue);
439
+ for (const child of node.children) {
440
+ createEntityTree(state, child, nameToEntity, nodeToEntity, eid, queue);
401
441
  }
402
442
 
403
443
  return eid;
@@ -406,19 +446,19 @@ function createEntityTree(
406
446
  function applyRelation(
407
447
  state: State,
408
448
  eid: number,
409
- ref: EntityRef,
410
- context: LoadParseContext,
449
+ ref: Ref,
450
+ nameToEntity: Map<string, number>,
411
451
  errors: ParseError[]
412
452
  ): void {
413
- const relationDef = getRelationDef(ref.attrName);
453
+ const relationDef = getRelationDef(ref.attr);
414
454
  if (!relationDef) {
415
- errors.push({ message: `Unknown relation: "${ref.attrName}"` });
455
+ errors.push({ message: `Unknown relation: "${ref.attr}"` });
416
456
  return;
417
457
  }
418
458
 
419
- const targetEid = context.getEntityByName(ref.targetName);
420
- if (targetEid === null) {
421
- errors.push({ message: `Unknown entity: "@${ref.targetName}"` });
459
+ const targetEid = nameToEntity.get(ref.target);
460
+ if (targetEid === undefined) {
461
+ errors.push({ message: `Unknown entity: "@${ref.target}"` });
422
462
  return;
423
463
  }
424
464
 
@@ -428,28 +468,32 @@ function applyRelation(
428
468
  function applyComponent(
429
469
  state: State,
430
470
  eid: number,
431
- compDef: ComponentDef,
432
- context: LoadParseContext,
471
+ attr: { name: string; value: string; def: RegisteredComponent },
433
472
  errors: ParseError[],
434
473
  pendingFieldRefs: PendingFieldRef[]
435
474
  ): void {
436
- const { def, attrs } = compDef;
475
+ const { def, value } = attr;
437
476
  const { component, name, traits } = def;
438
477
 
439
478
  state.addComponent(eid, component as never);
440
479
 
441
480
  const defaults = traits?.defaults?.() ?? {};
442
- for (const [field, value] of Object.entries(defaults)) {
443
- setFieldValue(component, field, eid, value as number);
481
+ for (const [field, val] of Object.entries(defaults)) {
482
+ setFieldValue(component, field, eid, val as number);
483
+ }
484
+
485
+ const props: Record<string, string> = {};
486
+ if (value !== "") {
487
+ props["_value"] = value;
444
488
  }
445
489
 
446
490
  let values: Record<string, number>;
447
- let entityRefs: FieldEntityRef[] = [];
491
+ let entityRefs: { field: string; targetName: string }[] = [];
448
492
 
449
493
  if (traits?.adapter) {
450
- values = traits.adapter(attrs, eid);
494
+ values = traits.adapter(props, eid);
451
495
  } else {
452
- const result = parseAttrs(def, attrs);
496
+ const result = parseAttrs(def, props);
453
497
  values = result.values;
454
498
  entityRefs = result.entityRefs;
455
499
  for (const err of result.errors) {
@@ -457,8 +501,8 @@ function applyComponent(
457
501
  }
458
502
  }
459
503
 
460
- for (const [field, value] of Object.entries(values)) {
461
- setFieldValue(component, field, eid, value);
504
+ for (const [field, val] of Object.entries(values)) {
505
+ setFieldValue(component, field, eid, val);
462
506
  }
463
507
 
464
508
  for (const ref of entityRefs) {
@@ -471,39 +515,40 @@ function applyComponent(
471
515
  }
472
516
  }
473
517
 
474
- interface ParseAttrsResult {
518
+ function parseAttrs(
519
+ def: RegisteredComponent,
520
+ props: Record<string, string>
521
+ ): {
475
522
  values: Record<string, number>;
476
- entityRefs: FieldEntityRef[];
523
+ entityRefs: { field: string; targetName: string }[];
477
524
  errors: string[];
478
- }
479
-
480
- function parseAttrs(def: RegisteredComponent, attrs: Record<string, string>): ParseAttrsResult {
525
+ } {
481
526
  const allValues: Record<string, number> = {};
482
- const allEntityRefs: FieldEntityRef[] = [];
527
+ const allEntityRefs: { field: string; targetName: string }[] = [];
483
528
  const allErrors: string[] = [];
484
529
 
485
- if (attrs._value) {
486
- if (isCSSAttrSyntax(attrs._value)) {
487
- const result = parsePropertyString(def.name, attrs._value, def.component);
530
+ if (props._value) {
531
+ if (isCSSAttrSyntax(props._value)) {
532
+ const result = parsePropertyString(def.name, props._value, def.component);
488
533
  Object.assign(allValues, result.values);
489
534
  allEntityRefs.push(...result.entityRefs);
490
535
  allErrors.push(...result.errors);
491
536
  }
492
537
  }
493
538
 
494
- for (const [attrName, attrValue] of Object.entries(attrs)) {
495
- if (attrName === "_value") continue;
496
- if (!attrValue) continue;
539
+ for (const [propName, propValue] of Object.entries(props)) {
540
+ if (propName === "_value") continue;
541
+ if (!propValue) continue;
497
542
 
498
- if (isCSSAttrSyntax(attrValue)) {
499
- const result = parsePropertyString(def.name, attrValue, def.component);
543
+ if (isCSSAttrSyntax(propValue)) {
544
+ const result = parsePropertyString(def.name, propValue, def.component);
500
545
  Object.assign(allValues, result.values);
501
546
  allEntityRefs.push(...result.entityRefs);
502
547
  allErrors.push(...result.errors);
503
548
  } else {
504
549
  const result = parsePropertyString(
505
550
  def.name,
506
- `${attrName}: ${attrValue}`,
551
+ `${propName}: ${propValue}`,
507
552
  def.component
508
553
  );
509
554
  Object.assign(allValues, result.values);
@@ -522,17 +567,6 @@ function setFieldValue(component: ComponentLike, field: string, eid: number, val
522
567
  }
523
568
  }
524
569
 
525
- interface FieldEntityRef {
526
- readonly field: string;
527
- readonly targetName: string;
528
- }
529
-
530
- interface PropertyParseResult {
531
- readonly values: Record<string, number>;
532
- readonly entityRefs: readonly FieldEntityRef[];
533
- readonly errors: readonly string[];
534
- }
535
-
536
570
  function detectVec2(component: ComponentLike, base: string): boolean {
537
571
  return `${base}X` in component && `${base}Y` in component;
538
572
  }
@@ -597,9 +631,13 @@ function parsePropertyString(
597
631
  componentName: string,
598
632
  propertyString: string,
599
633
  component: ComponentLike
600
- ): PropertyParseResult {
634
+ ): {
635
+ values: Record<string, number>;
636
+ entityRefs: { field: string; targetName: string }[];
637
+ errors: string[];
638
+ } {
601
639
  const values: Record<string, number> = {};
602
- const entityRefs: FieldEntityRef[] = [];
640
+ const entityRefs: { field: string; targetName: string }[] = [];
603
641
  const errors: string[] = [];
604
642
 
605
643
  const properties = splitProperties(propertyString);
@@ -724,3 +762,81 @@ function parsePropertyString(
724
762
  function isCSSAttrSyntax(value: string): boolean {
725
763
  return value.includes(":") && (value.includes(";") || /^[\w-]+\s*:/.test(value));
726
764
  }
765
+
766
+ export function serialize(nodes: Node[]): string {
767
+ const lines: string[] = ["<scene>"];
768
+ let isFirst = true;
769
+
770
+ for (const node of nodes) {
771
+ serializeNode(node, lines, 1, isFirst);
772
+ isFirst = false;
773
+ }
774
+
775
+ lines.push("</scene>");
776
+ return lines.join("\n");
777
+ }
778
+
779
+ function serializeNode(node: Node, lines: string[], depth: number, isFirst: boolean): void {
780
+ const indent = INDENT.repeat(depth);
781
+
782
+ if (node.blankBefore && !isFirst) {
783
+ lines.push("");
784
+ }
785
+
786
+ if (node.comments) {
787
+ for (const comment of node.comments) {
788
+ lines.push(`${indent}<!-- ${comment} -->`);
789
+ }
790
+ }
791
+
792
+ const attrParts = buildAttrParts(node);
793
+ const singleLine = `${indent}<a${attrParts.join("")}${node.children.length === 0 ? " />" : ">"}`;
794
+
795
+ if (singleLine.length <= MAX_LINE) {
796
+ lines.push(singleLine);
797
+ } else {
798
+ lines.push(`${indent}<a`);
799
+ const attrIndent = INDENT.repeat(depth + 1);
800
+ for (const part of attrParts) {
801
+ lines.push(`${attrIndent}${part.trim()}`);
802
+ }
803
+ lines.push(`${indent}${node.children.length === 0 ? "/>" : ">"}`);
804
+ }
805
+
806
+ if (node.children.length > 0) {
807
+ let childIsFirst = true;
808
+ for (const child of node.children) {
809
+ serializeNode(child, lines, depth + 1, childIsFirst);
810
+ childIsFirst = false;
811
+ }
812
+ lines.push(`${indent}</a>`);
813
+ }
814
+ }
815
+
816
+ function buildAttrParts(node: Node): string[] {
817
+ const parts: string[] = [];
818
+
819
+ if (node.id) {
820
+ parts.push(` id="${escapeAttr(node.id)}"`);
821
+ }
822
+
823
+ const sortedAttrs = [...node.attrs].sort((a, b) => a.name.localeCompare(b.name));
824
+
825
+ for (const attr of sortedAttrs) {
826
+ if (attr.value === "") {
827
+ parts.push(` ${attr.name}`);
828
+ } else {
829
+ parts.push(` ${attr.name}="${escapeAttr(attr.value)}"`);
830
+ }
831
+ }
832
+
833
+ return parts;
834
+ }
835
+
836
+ function escapeAttr(str: string): string {
837
+ return str
838
+ .replace(/&/g, "&amp;")
839
+ .replace(/"/g, "&quot;")
840
+ .replace(/</g, "&lt;")
841
+ .replace(/>/g, "&gt;");
842
+ }