@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.
- package/package.json +3 -4
- package/src/core/builder.ts +71 -32
- package/src/core/component.ts +25 -11
- package/src/core/index.ts +14 -13
- package/src/core/math.ts +135 -0
- package/src/core/runtime.ts +0 -1
- package/src/core/state.ts +9 -68
- package/src/core/xml.ts +381 -265
- package/src/editor/format.ts +5 -0
- package/src/editor/index.ts +101 -0
- package/src/extras/arrows/index.ts +28 -69
- package/src/extras/gradient/index.ts +36 -52
- package/src/extras/lines/index.ts +51 -122
- package/src/extras/orbit/index.ts +40 -15
- package/src/extras/text/font.ts +546 -0
- package/src/extras/text/index.ts +158 -204
- package/src/extras/text/sdf.ts +429 -0
- package/src/standard/activity/index.ts +172 -0
- package/src/standard/compute/graph.ts +23 -23
- package/src/standard/compute/index.ts +76 -61
- package/src/standard/defaults.ts +8 -5
- package/src/standard/index.ts +1 -0
- package/src/standard/input/index.ts +30 -19
- package/src/standard/loading/index.ts +18 -13
- package/src/standard/render/bvh/blas.ts +752 -0
- package/src/standard/render/bvh/radix.ts +476 -0
- package/src/standard/render/bvh/structs.ts +167 -0
- package/src/standard/render/bvh/tlas.ts +886 -0
- package/src/standard/render/bvh/traverse.ts +467 -0
- package/src/standard/render/camera.ts +302 -27
- package/src/standard/render/data.ts +93 -0
- package/src/standard/render/depth.ts +117 -0
- package/src/standard/render/forward/index.ts +259 -0
- package/src/standard/render/forward/raster.ts +228 -0
- package/src/standard/render/index.ts +443 -70
- package/src/standard/render/indirect.ts +40 -0
- package/src/standard/render/instance.ts +214 -0
- package/src/standard/render/intersection.ts +72 -0
- package/src/standard/render/light.ts +16 -16
- package/src/standard/render/mesh/index.ts +67 -75
- package/src/standard/render/mesh/unified.ts +96 -0
- package/src/standard/render/{transparent.ts → overlay.ts} +14 -15
- package/src/standard/render/pass.ts +10 -4
- package/src/standard/render/postprocess.ts +142 -64
- package/src/standard/render/ray.ts +61 -0
- package/src/standard/render/scene.ts +38 -164
- package/src/standard/render/shaders.ts +484 -0
- package/src/standard/render/surface/compile.ts +3 -10
- package/src/standard/render/surface/index.ts +60 -30
- package/src/standard/render/surface/noise.ts +45 -0
- package/src/standard/render/surface/structs.ts +60 -19
- package/src/standard/render/surface/wgsl.ts +573 -0
- package/src/standard/render/triangle.ts +84 -0
- package/src/standard/transforms/index.ts +4 -6
- package/src/standard/tween/index.ts +10 -1
- package/src/standard/tween/sequence.ts +24 -16
- package/src/standard/tween/tween.ts +67 -16
- package/src/core/types.ts +0 -37
- package/src/standard/compute/inspect.ts +0 -201
- package/src/standard/compute/pass.ts +0 -23
- package/src/standard/compute/timing.ts +0 -139
- 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
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
74
|
+
export interface Attr {
|
|
75
|
+
name: string;
|
|
76
|
+
value: string;
|
|
73
77
|
}
|
|
74
78
|
|
|
75
|
-
export interface
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
83
|
-
|
|
84
|
-
|
|
84
|
+
export interface ParseError {
|
|
85
|
+
message: string;
|
|
86
|
+
path?: string;
|
|
85
87
|
}
|
|
86
88
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
interface PendingFieldRef {
|
|
90
|
+
eid: number;
|
|
91
|
+
component: ComponentLike;
|
|
92
|
+
field: string;
|
|
93
|
+
targetName: string;
|
|
91
94
|
}
|
|
92
95
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
134
|
+
return tokens;
|
|
146
135
|
}
|
|
147
136
|
|
|
148
|
-
function
|
|
149
|
-
|
|
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
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
let
|
|
170
|
+
const nodes: Node[] = [];
|
|
171
|
+
const errors: ParseError[] = [];
|
|
172
|
+
|
|
173
|
+
let i = 0;
|
|
174
|
+
let pendingComments: string[] = [];
|
|
175
|
+
let pendingBlank = false;
|
|
169
176
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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 (
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
211
|
+
pendingComments = [];
|
|
212
|
+
pendingBlank = false;
|
|
213
|
+
i = result.nextIndex;
|
|
191
214
|
continue;
|
|
192
215
|
}
|
|
193
216
|
|
|
194
|
-
if (
|
|
195
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
232
|
+
if (errors.length > 0) {
|
|
233
|
+
throw new Error(errors.map((e) => e.message).join("\n"));
|
|
234
|
+
}
|
|
202
235
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
const hasField = camel in component || `${camel}X` in component;
|
|
236
|
+
return nodes;
|
|
237
|
+
}
|
|
206
238
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
261
|
+
attrs.push({ name: attrName, value: attrValue });
|
|
229
262
|
}
|
|
230
263
|
}
|
|
231
264
|
|
|
232
|
-
|
|
233
|
-
|
|
265
|
+
const children: Node[] = [];
|
|
266
|
+
let i = startIndex + 1;
|
|
234
267
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
}
|
|
268
|
+
if (!token.selfClosing) {
|
|
269
|
+
let pendingComments: string[] = [];
|
|
270
|
+
let pendingBlank = false;
|
|
239
271
|
|
|
240
|
-
|
|
241
|
-
|
|
272
|
+
while (i < tokens.length) {
|
|
273
|
+
const childToken = tokens[i];
|
|
242
274
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
275
|
+
if (childToken.type === "blank") {
|
|
276
|
+
pendingBlank = true;
|
|
277
|
+
i++;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
247
280
|
|
|
248
|
-
|
|
249
|
-
|
|
281
|
+
if (childToken.type === "comment") {
|
|
282
|
+
pendingComments.push(childToken.value);
|
|
283
|
+
i++;
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
250
286
|
|
|
251
|
-
|
|
252
|
-
|
|
287
|
+
if (childToken.type === "close" && childToken.tagName === "a") {
|
|
288
|
+
i++;
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
253
291
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
result.push({
|
|
265
|
-
tag: key,
|
|
266
|
-
attrs,
|
|
267
|
-
children,
|
|
268
|
-
});
|
|
305
|
+
i++;
|
|
269
306
|
}
|
|
270
307
|
}
|
|
271
308
|
|
|
272
|
-
return
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
333
|
+
node: Node;
|
|
334
|
+
eid: number;
|
|
335
|
+
parent?: number;
|
|
332
336
|
}
|
|
333
337
|
|
|
334
|
-
function
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
|
346
|
-
|
|
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 {
|
|
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
|
-
|
|
356
|
-
|
|
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
|
|
360
|
-
|
|
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 =
|
|
366
|
-
if (targetEid ===
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
387
|
-
|
|
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 (
|
|
394
|
-
|
|
432
|
+
if (node.id) {
|
|
433
|
+
nameToEntity.set(node.id, eid);
|
|
395
434
|
}
|
|
435
|
+
nodeToEntity.set(node, eid);
|
|
396
436
|
|
|
397
|
-
queue.push({
|
|
437
|
+
queue.push({ node, eid, parent });
|
|
398
438
|
|
|
399
|
-
for (const
|
|
400
|
-
createEntityTree(state,
|
|
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:
|
|
410
|
-
|
|
449
|
+
ref: Ref,
|
|
450
|
+
nameToEntity: Map<string, number>,
|
|
411
451
|
errors: ParseError[]
|
|
412
452
|
): void {
|
|
413
|
-
const relationDef = getRelationDef(ref.
|
|
453
|
+
const relationDef = getRelationDef(ref.attr);
|
|
414
454
|
if (!relationDef) {
|
|
415
|
-
errors.push({ message: `Unknown relation: "${ref.
|
|
455
|
+
errors.push({ message: `Unknown relation: "${ref.attr}"` });
|
|
416
456
|
return;
|
|
417
457
|
}
|
|
418
458
|
|
|
419
|
-
const targetEid =
|
|
420
|
-
if (targetEid ===
|
|
421
|
-
errors.push({ message: `Unknown entity: "@${ref.
|
|
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
|
-
|
|
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,
|
|
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,
|
|
443
|
-
setFieldValue(component, field, eid,
|
|
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:
|
|
491
|
+
let entityRefs: { field: string; targetName: string }[] = [];
|
|
448
492
|
|
|
449
493
|
if (traits?.adapter) {
|
|
450
|
-
values = traits.adapter(
|
|
494
|
+
values = traits.adapter(props, eid);
|
|
451
495
|
} else {
|
|
452
|
-
const result = parseAttrs(def,
|
|
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,
|
|
461
|
-
setFieldValue(component, field, eid,
|
|
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
|
-
|
|
518
|
+
function parseAttrs(
|
|
519
|
+
def: RegisteredComponent,
|
|
520
|
+
props: Record<string, string>
|
|
521
|
+
): {
|
|
475
522
|
values: Record<string, number>;
|
|
476
|
-
entityRefs:
|
|
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:
|
|
527
|
+
const allEntityRefs: { field: string; targetName: string }[] = [];
|
|
483
528
|
const allErrors: string[] = [];
|
|
484
529
|
|
|
485
|
-
if (
|
|
486
|
-
if (isCSSAttrSyntax(
|
|
487
|
-
const result = parsePropertyString(def.name,
|
|
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 [
|
|
495
|
-
if (
|
|
496
|
-
if (!
|
|
539
|
+
for (const [propName, propValue] of Object.entries(props)) {
|
|
540
|
+
if (propName === "_value") continue;
|
|
541
|
+
if (!propValue) continue;
|
|
497
542
|
|
|
498
|
-
if (isCSSAttrSyntax(
|
|
499
|
-
const result = parsePropertyString(def.name,
|
|
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
|
-
`${
|
|
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
|
-
):
|
|
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:
|
|
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, "&")
|
|
839
|
+
.replace(/"/g, """)
|
|
840
|
+
.replace(/</g, "<")
|
|
841
|
+
.replace(/>/g, ">");
|
|
842
|
+
}
|