@es3d/es3d 0.0.0-stage → 1.0.1
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 +201 -0
- package/README.md +85 -2
- package/docs/REFERENCE.md +581 -0
- package/docs/SPEC.md +417 -0
- package/package.json +50 -4
- package/scripts/es3d-cli.mjs +377 -0
- package/src/cache/geometry-store.js +392 -0
- package/src/cache/mesh-bundle.js +123 -0
- package/src/cache/sha256.js +73 -0
- package/src/format/ast.js +238 -0
- package/src/format/colors.js +93 -0
- package/src/format/compact-parser.js +171 -0
- package/src/format/compact-tokenizer.js +165 -0
- package/src/format/errors.js +247 -0
- package/src/format/json.js +55 -0
- package/src/format/limits.js +14 -0
- package/src/format/parse.js +92 -0
- package/src/format/schema.js +617 -0
- package/src/format/serializer.js +333 -0
- package/src/format/validator.js +330 -0
- package/src/format/value-error.js +12 -0
- package/src/format/value-format.js +314 -0
- package/src/format/values.js +459 -0
- package/src/format/xml-parser.js +136 -0
- package/src/format/xml-tokenizer.js +237 -0
- package/src/geometry/contour.js +86 -0
- package/src/geometry/extrude.js +175 -0
- package/src/geometry/gear.js +85 -0
- package/src/geometry/geometry-cache.js +159 -0
- package/src/geometry/lod-error.js +178 -0
- package/src/geometry/mesh.js +458 -0
- package/src/geometry/primitives.js +299 -0
- package/src/geometry/registry.js +645 -0
- package/src/geometry/revolve.js +136 -0
- package/src/geometry/sweep.js +441 -0
- package/src/index.js +47 -0
- package/src/io/compression.js +67 -0
- package/src/io/convert.js +236 -0
- package/src/io/download.js +13 -0
- package/src/io/external-scene.js +127 -0
- package/src/io/file-loader.js +28 -0
- package/src/io/gltf.js +630 -0
- package/src/io/mesh-import.js +104 -0
- package/src/io/obj.js +352 -0
- package/src/io/step-brep.js +439 -0
- package/src/io/step-exact.js +1149 -0
- package/src/io/step.js +1418 -0
- package/src/io/stl.js +193 -0
- package/src/io/svg-import.js +787 -0
- package/src/material/materials.js +154 -0
- package/src/math/mat4.js +475 -0
- package/src/math/random.js +30 -0
- package/src/math/trig.js +65 -0
- package/src/math/vec3.js +81 -0
- package/src/optimize/optimizer.js +579 -0
- package/src/path/path-flatten.js +322 -0
- package/src/path/path-parser.js +332 -0
- package/src/path/shapes.js +129 -0
- package/src/path/triangulate.js +517 -0
- package/src/pipeline.js +74 -0
- package/src/render/camera.js +168 -0
- package/src/render/frame-builder.js +362 -0
- package/src/render/geometry-pool.js +149 -0
- package/src/render/lighting.js +149 -0
- package/src/render/orbit-controls.js +237 -0
- package/src/render/renderer.js +258 -0
- package/src/render/scroll-timeline.js +104 -0
- package/src/render/shaders/glsl.js +285 -0
- package/src/render/shaders/wgsl.js +252 -0
- package/src/render/stage.js +364 -0
- package/src/render/texture-cache.js +40 -0
- package/src/render/textures.js +223 -0
- package/src/render/webgl2/webgl2-backend.js +586 -0
- package/src/render/webgpu/webgpu-backend.js +482 -0
- package/src/scene/animation.js +171 -0
- package/src/scene/camera-constraints.js +117 -0
- package/src/scene/flatten.js +754 -0
- package/src/scene/picking.js +161 -0
- package/src/scene/scene-graph.js +320 -0
- package/src/transform/transform.js +211 -0
- package/src/wasm/kernels-binary.js +3 -0
- package/src/wasm/kernels.js +385 -0
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
// Typed attribute value parsers.
|
|
2
|
+
//
|
|
3
|
+
// Attribute values are kept as raw strings in the AST; the validator converts
|
|
4
|
+
// them with the parsers below according to the schema. Every parser either
|
|
5
|
+
// returns a value or throws a ValueError whose offset points into the raw
|
|
6
|
+
// string, which the validator maps back to a document line/column.
|
|
7
|
+
|
|
8
|
+
import { ValueError } from './value-error.js';
|
|
9
|
+
import { parseColor, NAMED_COLORS } from './colors.js';
|
|
10
|
+
import { suggest } from './errors.js';
|
|
11
|
+
import { parsePathData, PathError, readNumber, numberValue } from '../path/path-parser.js';
|
|
12
|
+
import { parseTransform } from '../transform/transform.js';
|
|
13
|
+
import { readNumbersWasm } from '../wasm/kernels.js';
|
|
14
|
+
|
|
15
|
+
function isWs(c) {
|
|
16
|
+
return c === 32 || c === 9 || c === 10 || c === 13;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function describe(raw) {
|
|
20
|
+
const s = raw.length > 24 ? raw.slice(0, 24) + '…' : raw;
|
|
21
|
+
return `'${s}'`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Parse a single number (strict). */
|
|
25
|
+
export function parseNumber(raw) {
|
|
26
|
+
let i = 0;
|
|
27
|
+
const n = raw.length;
|
|
28
|
+
while (i < n && isWs(raw.charCodeAt(i))) i++;
|
|
29
|
+
if (i >= n) throw new ValueError('Expected a number but the value is empty.', 0, 0);
|
|
30
|
+
const end = readNumber(raw, i);
|
|
31
|
+
if (end < 0) throw new ValueError(`Expected a number but found ${describe(raw.trim())}.`, i, Math.max(1, raw.trim().length));
|
|
32
|
+
const value = numberValue;
|
|
33
|
+
let j = end;
|
|
34
|
+
while (j < n && isWs(raw.charCodeAt(j))) j++;
|
|
35
|
+
if (j < n) {
|
|
36
|
+
if (raw[j] === ',' || isWs(raw.charCodeAt(end))) throw new ValueError(`Expected a single number but found a list ${describe(raw.trim())}.`, j, 1);
|
|
37
|
+
throw new ValueError(`Unexpected '${raw[j]}' after number.`, j, 1);
|
|
38
|
+
}
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// lists at least this long (mesh data) are read by the WebAssembly kernel when it is available
|
|
43
|
+
const WASM_LIST = 1024;
|
|
44
|
+
const wasmList = (raw, as) => (raw.length >= WASM_LIST ? readNumbersWasm(raw, as) : null);
|
|
45
|
+
|
|
46
|
+
// numbers of the list being read; short lists reuse this buffer
|
|
47
|
+
let scratch = new Float64Array(64);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Read a list of numbers separated by whitespace and/or single commas.
|
|
51
|
+
* Returns a view of the values, valid until the next call.
|
|
52
|
+
*/
|
|
53
|
+
function readNumberList(raw) {
|
|
54
|
+
const n = raw.length;
|
|
55
|
+
let buf = scratch;
|
|
56
|
+
let count = 0;
|
|
57
|
+
let i = 0;
|
|
58
|
+
while (i < n && isWs(raw.charCodeAt(i))) i++;
|
|
59
|
+
while (i < n) {
|
|
60
|
+
const end = readNumber(raw, i);
|
|
61
|
+
if (end < 0) {
|
|
62
|
+
let k = i;
|
|
63
|
+
while (k < n && !isWs(raw.charCodeAt(k)) && raw[k] !== ',') k++;
|
|
64
|
+
throw new ValueError(`Expected a number but found ${describe(raw.slice(i, k) || raw[i])}.`, i, Math.max(1, k - i));
|
|
65
|
+
}
|
|
66
|
+
if (count === buf.length) {
|
|
67
|
+
const grown = new Float64Array(buf.length * 2);
|
|
68
|
+
grown.set(buf);
|
|
69
|
+
buf = grown;
|
|
70
|
+
}
|
|
71
|
+
buf[count++] = numberValue;
|
|
72
|
+
i = end;
|
|
73
|
+
let sep = false;
|
|
74
|
+
while (i < n && isWs(raw.charCodeAt(i))) {
|
|
75
|
+
i++;
|
|
76
|
+
sep = true;
|
|
77
|
+
}
|
|
78
|
+
if (i < n && raw.charCodeAt(i) === 44) {
|
|
79
|
+
i++;
|
|
80
|
+
sep = true;
|
|
81
|
+
while (i < n && isWs(raw.charCodeAt(i))) i++;
|
|
82
|
+
if (i >= n) throw new ValueError('Trailing comma in number list.', i - 1, 1);
|
|
83
|
+
}
|
|
84
|
+
if (i < n && !sep) throw new ValueError(`Expected a separator (space or comma) but found '${raw[i]}'.`, i, 1);
|
|
85
|
+
}
|
|
86
|
+
// keep a grown buffer for the next list unless it is mesh-sized
|
|
87
|
+
if (buf.length <= 4096) scratch = buf;
|
|
88
|
+
return buf.subarray(0, count);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Parse a list of numbers separated by whitespace and/or single commas.
|
|
93
|
+
* Pushes into `out` (array or growable) and returns it.
|
|
94
|
+
*/
|
|
95
|
+
export function parseNumberList(raw, out = []) {
|
|
96
|
+
const list = readNumberList(raw);
|
|
97
|
+
for (let k = 0; k < list.length; k++) out.push(list[k]);
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function parseInteger(raw) {
|
|
102
|
+
const v = parseNumber(raw);
|
|
103
|
+
if (!Number.isInteger(v)) throw new ValueError(`Expected a whole number but found ${describe(raw.trim())}.`, 0, raw.length);
|
|
104
|
+
return v;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function parseBool(raw) {
|
|
108
|
+
const s = raw.trim();
|
|
109
|
+
if (s === 'true') return true;
|
|
110
|
+
if (s === 'false') return false;
|
|
111
|
+
throw new ValueError(`Expected 'true' or 'false' but found ${describe(s)}.`, 0, raw.length, {
|
|
112
|
+
suggestion: suggest(s, ['true', 'false']) || null,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function vec(raw, count, name) {
|
|
117
|
+
const list = parseNumberList(raw);
|
|
118
|
+
if (list.length !== count) {
|
|
119
|
+
throw new ValueError(`${name} needs ${count} numbers (e.g. ${Array.from({ length: count }, (_, i) => i + 1).join(',')}) but ${list.length} ${list.length === 1 ? 'was' : 'were'} given.`, 0, raw.length);
|
|
120
|
+
}
|
|
121
|
+
return list;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const ID_RE = /^[A-Za-z_][A-Za-z0-9_.:-]*$/;
|
|
125
|
+
|
|
126
|
+
export function parseId(raw) {
|
|
127
|
+
const s = raw.trim();
|
|
128
|
+
if (!ID_RE.test(s)) {
|
|
129
|
+
throw new ValueError(`Invalid id ${describe(s)}: ids start with a letter or '_' and contain letters, digits, '_', '-', '.' or ':'.`, 0, raw.length);
|
|
130
|
+
}
|
|
131
|
+
return s;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function parseRef(raw) {
|
|
135
|
+
let s = raw.trim();
|
|
136
|
+
if (s.startsWith('url(#') && s.endsWith(')')) s = s.slice(5, -1);
|
|
137
|
+
if (s[0] === '#') s = s.slice(1);
|
|
138
|
+
return parseId(s);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** "a" or "a..b" */
|
|
142
|
+
export function parseRange(raw) {
|
|
143
|
+
const s = raw.trim();
|
|
144
|
+
const k = s.indexOf('..');
|
|
145
|
+
if (k < 0) {
|
|
146
|
+
const v = parseNumber(s);
|
|
147
|
+
return [v, v];
|
|
148
|
+
}
|
|
149
|
+
const a = parseNumber(s.slice(0, k));
|
|
150
|
+
let b;
|
|
151
|
+
try {
|
|
152
|
+
b = parseNumber(s.slice(k + 2));
|
|
153
|
+
} catch (e) {
|
|
154
|
+
throw new ValueError(e.message, k + 2 + e.offset, e.length);
|
|
155
|
+
}
|
|
156
|
+
if (b < a) throw new ValueError(`Range ${describe(s)} is reversed: the lower bound must come first.`, 0, raw.length);
|
|
157
|
+
return [a, b];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Per-axis ranges "x,y,z" where each part is a number or a range; or a single (uniform) range. */
|
|
161
|
+
export function parseRange3(raw, allowUniform) {
|
|
162
|
+
const parts = raw.split(',');
|
|
163
|
+
if (parts.length === 1) {
|
|
164
|
+
if (!allowUniform) throw new ValueError('Expected three comma-separated components, e.g. 0,0..360,0.', 0, raw.length);
|
|
165
|
+
return { uniform: true, ranges: [parseRange(parts[0])] };
|
|
166
|
+
}
|
|
167
|
+
if (parts.length !== 3) throw new ValueError(`Expected three comma-separated components but found ${parts.length}.`, 0, raw.length);
|
|
168
|
+
const ranges = [];
|
|
169
|
+
let off = 0;
|
|
170
|
+
for (const p of parts) {
|
|
171
|
+
try {
|
|
172
|
+
ranges.push(parseRange(p));
|
|
173
|
+
} catch (e) {
|
|
174
|
+
throw new ValueError(e.message, off + (e.offset || 0), e.length || p.length);
|
|
175
|
+
}
|
|
176
|
+
off += p.length + 1;
|
|
177
|
+
}
|
|
178
|
+
return { uniform: false, ranges };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function parseIdList(raw) {
|
|
182
|
+
const ids = raw.split(/[\s,]+/).filter(Boolean);
|
|
183
|
+
if (!ids.length) throw new ValueError('Expected at least one id.', 0, raw.length);
|
|
184
|
+
let off = 0;
|
|
185
|
+
return ids.map((id) => {
|
|
186
|
+
const at = raw.indexOf(id, off);
|
|
187
|
+
off = at + id.length;
|
|
188
|
+
try {
|
|
189
|
+
return parseRef(id);
|
|
190
|
+
} catch (e) {
|
|
191
|
+
throw new ValueError(e.message, at, id.length);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function parseColorValue(raw) {
|
|
197
|
+
const c = parseColor(raw);
|
|
198
|
+
if (c) return c;
|
|
199
|
+
const s = raw.trim();
|
|
200
|
+
let sug = null;
|
|
201
|
+
if (/^[a-z]+$/i.test(s)) sug = suggest(s.toLowerCase(), [...NAMED_COLORS.keys()]);
|
|
202
|
+
throw new ValueError(`Invalid color ${describe(s)}.`, 0, raw.length, {
|
|
203
|
+
suggestion: sug,
|
|
204
|
+
hint: 'Colors are written #rgb, #rrggbb, #rrggbbaa, rgb(r,g,b), rgba(r,g,b,a) or as a CSS color name.',
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Seconds: "4", "4s", "250ms" */
|
|
209
|
+
export function parseDuration(raw) {
|
|
210
|
+
const s = raw.trim();
|
|
211
|
+
let scale = 1;
|
|
212
|
+
let body = s;
|
|
213
|
+
if (s.endsWith('ms')) {
|
|
214
|
+
scale = 0.001;
|
|
215
|
+
body = s.slice(0, -2);
|
|
216
|
+
} else if (s.endsWith('s')) {
|
|
217
|
+
body = s.slice(0, -1);
|
|
218
|
+
}
|
|
219
|
+
const v = parseNumber(body);
|
|
220
|
+
return v * scale;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Function-call syntax used by textures and helix(): name(arg, arg, ...) */
|
|
224
|
+
export function parseCall(raw) {
|
|
225
|
+
const s = raw.trim();
|
|
226
|
+
const m = /^([A-Za-z][A-Za-z0-9_-]*)\s*\(([\s\S]*)\)$/.exec(s);
|
|
227
|
+
if (!m) return null;
|
|
228
|
+
const argStr = m[2].trim();
|
|
229
|
+
const args = argStr ? argStr.split(/\s*,\s*/) : [];
|
|
230
|
+
return { name: m[1], args, argOffset: raw.indexOf('(') + 1 };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export const TEXTURE_FUNCTIONS = {
|
|
234
|
+
checker: [['count', 'number', 8], ['color1', 'color', '#ffffff'], ['color2', 'color', '#1a1a1a']],
|
|
235
|
+
grid: [['count', 'number', 8], ['width', 'number', 0.06], ['line', 'color', '#222222'], ['background', 'color', '#ffffff']],
|
|
236
|
+
stripes: [['count', 'number', 8], ['color1', 'color', '#ffffff'], ['color2', 'color', '#1a1a1a']],
|
|
237
|
+
noise: [['scale', 'number', 8], ['color1', 'color', '#ffffff'], ['color2', 'color', '#000000'], ['seed', 'number', 1]],
|
|
238
|
+
bricks: [['count', 'number', 6], ['brick', 'color', '#b5543a'], ['mortar', 'color', '#d8d0c4']],
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
export const NORMAL_FUNCTIONS = {
|
|
242
|
+
bumps: [['count', 'number', 8], ['strength', 'number', 1]],
|
|
243
|
+
tiles: [['count', 'number', 4], ['strength', 'number', 1], ['gap', 'number', 0.06]],
|
|
244
|
+
noise: [['scale', 'number', 8], ['strength', 'number', 1], ['seed', 'number', 1]],
|
|
245
|
+
waves: [['count', 'number', 6], ['strength', 'number', 1]],
|
|
246
|
+
bricks: [['count', 'number', 6], ['strength', 'number', 1]],
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
function parseTextureSpec(raw, table, label) {
|
|
250
|
+
const call = parseCall(raw);
|
|
251
|
+
if (call) {
|
|
252
|
+
const spec = table[call.name];
|
|
253
|
+
if (!spec) {
|
|
254
|
+
throw new ValueError(`Unknown procedural ${label} '${call.name}'.`, 0, call.name.length, {
|
|
255
|
+
suggestion: suggest(call.name, Object.keys(table)),
|
|
256
|
+
hint: `Available: ${Object.keys(table).map((k) => k + '()').join(', ')}. Any other value is treated as an image URL.`,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
if (call.args.length > spec.length) {
|
|
260
|
+
throw new ValueError(`${call.name}() takes at most ${spec.length} arguments but ${call.args.length} were given.`, 0, raw.length);
|
|
261
|
+
}
|
|
262
|
+
const params = {};
|
|
263
|
+
spec.forEach(([name, type, def], i) => {
|
|
264
|
+
const a = call.args[i];
|
|
265
|
+
const v = a === undefined || a === '' ? def : a;
|
|
266
|
+
try {
|
|
267
|
+
params[name] = type === 'number' ? parseNumber(String(v)) : parseColorValue(String(v));
|
|
268
|
+
} catch (e) {
|
|
269
|
+
throw new ValueError(`${call.name}(): argument '${name}': ${e.message}`, call.argOffset, raw.length - call.argOffset - 1);
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
return { kind: 'procedural', fn: call.name, params, key: raw.trim() };
|
|
273
|
+
}
|
|
274
|
+
const url = raw.trim();
|
|
275
|
+
if (!url) throw new ValueError('Expected an image URL or a procedural texture such as checker(8).', 0, 0);
|
|
276
|
+
if (/\s/.test(url) && !url.startsWith('data:')) throw new ValueError('Image URLs must not contain spaces (escape them as %20).', url.search(/\s/), 1);
|
|
277
|
+
return { kind: 'url', url, key: url };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Sweep spine: helix(r,pitch,turns[,segmentsPerTurn]) | 3D path data | point list */
|
|
281
|
+
export function parseSpine(raw) {
|
|
282
|
+
const s = raw.trim();
|
|
283
|
+
const call = parseCall(s);
|
|
284
|
+
if (call) {
|
|
285
|
+
if (call.name !== 'helix') {
|
|
286
|
+
throw new ValueError(`Unknown spine function '${call.name}'.`, 0, call.name.length, { suggestion: suggest(call.name, ['helix']) });
|
|
287
|
+
}
|
|
288
|
+
if (call.args.length < 3 || call.args.length > 4) {
|
|
289
|
+
throw new ValueError('helix() takes (radius, pitch, turns[, segmentsPerTurn]).', 0, s.length);
|
|
290
|
+
}
|
|
291
|
+
const nums = call.args.map((a) => parseNumber(a));
|
|
292
|
+
if (nums[0] <= 0) throw new ValueError('helix(): radius must be positive.', call.argOffset, call.args[0].length);
|
|
293
|
+
if (nums[2] <= 0) throw new ValueError('helix(): turns must be positive.', call.argOffset, s.length - call.argOffset);
|
|
294
|
+
if (nums[3] !== undefined && (!Number.isInteger(nums[3]) || nums[3] < 3)) throw new ValueError('helix(): segmentsPerTurn must be an integer of at least 3.', call.argOffset, s.length - call.argOffset);
|
|
295
|
+
return { kind: 'helix', radius: nums[0], pitch: nums[1], turns: nums[2], segmentsPerTurn: nums[3] || 0 };
|
|
296
|
+
}
|
|
297
|
+
if (/^[Mm]/.test(s)) {
|
|
298
|
+
try {
|
|
299
|
+
const p = parsePathData(raw, 3);
|
|
300
|
+
if (!p.segments.length) throw new ValueError('Empty 3D path.', 0, raw.length);
|
|
301
|
+
return { kind: 'path', segments: p.segments };
|
|
302
|
+
} catch (e) {
|
|
303
|
+
if (e instanceof PathError) throw new ValueError(e.message, e.offset, e.length, { code: e.code });
|
|
304
|
+
throw e;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
const pts = parseNumberList(raw);
|
|
308
|
+
if (pts.length % 3 !== 0) throw new ValueError(`Point lists need 3 numbers per point; found ${pts.length} numbers.`, 0, raw.length);
|
|
309
|
+
if (pts.length < 6) throw new ValueError('A spine needs at least 2 points.', 0, raw.length);
|
|
310
|
+
return { kind: 'points', points: Float64Array.from(pts) };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Key values of an animation: number lists separated by semicolons, e.g. "0,1,0; 2,1,0". */
|
|
314
|
+
export function parseKeyframes(raw) {
|
|
315
|
+
const out = [];
|
|
316
|
+
let start = 0;
|
|
317
|
+
for (const part of raw.split(';')) {
|
|
318
|
+
if (part.trim() === '') {
|
|
319
|
+
// a trailing semicolon is harmless; an empty key value in the middle is not
|
|
320
|
+
if (start + part.length < raw.length || !out.length) throw new ValueError('Empty key value between semicolons.', start, Math.max(1, part.length));
|
|
321
|
+
} else {
|
|
322
|
+
try {
|
|
323
|
+
out.push(parseNumberList(part));
|
|
324
|
+
} catch (e) {
|
|
325
|
+
if (e instanceof ValueError) throw new ValueError(e.message, start + e.offset, e.length, { suggestion: e.suggestion, hint: e.hint });
|
|
326
|
+
throw e;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
start += part.length + 1;
|
|
330
|
+
}
|
|
331
|
+
return out;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Parse a raw value according to a schema attribute spec.
|
|
336
|
+
* Returns the typed value; throws ValueError.
|
|
337
|
+
*/
|
|
338
|
+
export function parseTyped(spec, raw) {
|
|
339
|
+
switch (spec.type) {
|
|
340
|
+
case 'number':
|
|
341
|
+
case 'angle':
|
|
342
|
+
return parseNumber(raw);
|
|
343
|
+
case 'int':
|
|
344
|
+
return parseInteger(raw);
|
|
345
|
+
case 'bool':
|
|
346
|
+
return parseBool(raw);
|
|
347
|
+
case 'string':
|
|
348
|
+
return raw;
|
|
349
|
+
case 'id':
|
|
350
|
+
return parseId(raw);
|
|
351
|
+
case 'ref':
|
|
352
|
+
return parseRef(raw);
|
|
353
|
+
case 'vec2':
|
|
354
|
+
return vec(raw, 2, 'A 2D vector');
|
|
355
|
+
case 'vec3':
|
|
356
|
+
return vec(raw, 3, 'A 3D vector');
|
|
357
|
+
case 'vec4':
|
|
358
|
+
return vec(raw, 4, 'A 4D vector');
|
|
359
|
+
case 'size2': {
|
|
360
|
+
const l = parseNumberList(raw);
|
|
361
|
+
if (l.length === 1) return [l[0], l[0]];
|
|
362
|
+
if (l.length === 2) return l;
|
|
363
|
+
throw new ValueError(`Expected 1 or 2 numbers but found ${l.length}.`, 0, raw.length);
|
|
364
|
+
}
|
|
365
|
+
case 'size3':
|
|
366
|
+
case 'scale3': {
|
|
367
|
+
const l = parseNumberList(raw);
|
|
368
|
+
if (l.length === 1) return [l[0], l[0], l[0]];
|
|
369
|
+
if (l.length === 3) return l;
|
|
370
|
+
throw new ValueError(`Expected 1 or 3 numbers but found ${l.length}.`, 0, raw.length);
|
|
371
|
+
}
|
|
372
|
+
case 'count2': {
|
|
373
|
+
const l = parseNumberList(raw);
|
|
374
|
+
if (l.length !== 2 || !l.every((v) => Number.isInteger(v) && v >= 1)) {
|
|
375
|
+
throw new ValueError('Expected two positive whole numbers, e.g. 10,10.', 0, raw.length);
|
|
376
|
+
}
|
|
377
|
+
return l;
|
|
378
|
+
}
|
|
379
|
+
case 'numbers':
|
|
380
|
+
return parseNumberList(raw);
|
|
381
|
+
case 'color':
|
|
382
|
+
return parseColorValue(raw);
|
|
383
|
+
case 'enum': {
|
|
384
|
+
const s = raw.trim();
|
|
385
|
+
if (spec.values.includes(s)) return s;
|
|
386
|
+
throw new ValueError(`Invalid value ${describe(s)}. Expected one of: ${spec.values.join(', ')}.`, 0, raw.length, {
|
|
387
|
+
suggestion: suggest(s, spec.values),
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
case 'transform':
|
|
391
|
+
return parseTransform(raw);
|
|
392
|
+
case 'path2d':
|
|
393
|
+
try {
|
|
394
|
+
const p = parsePathData(raw, 2);
|
|
395
|
+
if (!p.segments.length) throw new ValueError('Empty path data.', 0, raw.length);
|
|
396
|
+
return { segments: p.segments, subpaths: p.subpaths };
|
|
397
|
+
} catch (e) {
|
|
398
|
+
if (e instanceof PathError) throw new ValueError(e.message, e.offset, e.length, { code: e.code });
|
|
399
|
+
throw e;
|
|
400
|
+
}
|
|
401
|
+
case 'spine':
|
|
402
|
+
return parseSpine(raw);
|
|
403
|
+
case 'points2':
|
|
404
|
+
case 'points3': {
|
|
405
|
+
const dim = spec.type === 'points2' ? 2 : 3;
|
|
406
|
+
const w = wasmList(raw, 'f64');
|
|
407
|
+
const l = w || readNumberList(raw);
|
|
408
|
+
if (l.length % dim !== 0) throw new ValueError(`Point lists need ${dim} numbers per point; found ${l.length} numbers.`, 0, raw.length);
|
|
409
|
+
return w || l.slice();
|
|
410
|
+
}
|
|
411
|
+
case 'floats': {
|
|
412
|
+
const w = wasmList(raw, 'f32');
|
|
413
|
+
if (w) return w;
|
|
414
|
+
const l = readNumberList(raw);
|
|
415
|
+
const out = new Float32Array(l.length);
|
|
416
|
+
out.set(l);
|
|
417
|
+
return out;
|
|
418
|
+
}
|
|
419
|
+
case 'indices': {
|
|
420
|
+
const w = wasmList(raw, 'index');
|
|
421
|
+
if (w) return w;
|
|
422
|
+
const l = readNumberList(raw);
|
|
423
|
+
const out = new Uint32Array(l.length);
|
|
424
|
+
for (let i = 0; i < l.length; i++) {
|
|
425
|
+
const v = l[i];
|
|
426
|
+
if (!Number.isInteger(v) || v < 0) throw new ValueError(`Indices must be non-negative whole numbers; found ${v} at position ${i}.`, 0, raw.length);
|
|
427
|
+
out[i] = v;
|
|
428
|
+
}
|
|
429
|
+
return out;
|
|
430
|
+
}
|
|
431
|
+
case 'range':
|
|
432
|
+
return parseRange(raw);
|
|
433
|
+
case 'range3':
|
|
434
|
+
return parseRange3(raw, spec.uniform !== false);
|
|
435
|
+
case 'idlist':
|
|
436
|
+
return parseIdList(raw);
|
|
437
|
+
case 'texture':
|
|
438
|
+
return parseTextureSpec(raw, TEXTURE_FUNCTIONS, 'texture');
|
|
439
|
+
case 'normalTexture':
|
|
440
|
+
return parseTextureSpec(raw, NORMAL_FUNCTIONS, 'normal map');
|
|
441
|
+
case 'duration':
|
|
442
|
+
return parseDuration(raw);
|
|
443
|
+
case 'repeatCount': {
|
|
444
|
+
const s = raw.trim();
|
|
445
|
+
if (s === 'indefinite') return Infinity;
|
|
446
|
+
const v = parseNumber(s);
|
|
447
|
+
if (v <= 0) throw new ValueError("repeat must be positive or 'indefinite'.", 0, raw.length);
|
|
448
|
+
return v;
|
|
449
|
+
}
|
|
450
|
+
case 'animValue':
|
|
451
|
+
return parseNumberList(raw);
|
|
452
|
+
case 'keyframes':
|
|
453
|
+
return parseKeyframes(raw);
|
|
454
|
+
case 'text':
|
|
455
|
+
return raw;
|
|
456
|
+
default:
|
|
457
|
+
throw new Error(`Unknown schema type '${spec.type}'`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Parser for the ES3D markup syntax: tokens -> AST (Document of Elements).
|
|
2
|
+
|
|
3
|
+
import { Attr, Comment, Document, Element } from './ast.js';
|
|
4
|
+
import { Diagnostic, ES3DError, LineIndex } from './errors.js';
|
|
5
|
+
import { Tok, XmlTokenizer, decodeEntities } from './xml-tokenizer.js';
|
|
6
|
+
|
|
7
|
+
function isBlank(s) {
|
|
8
|
+
for (let i = 0; i < s.length; i++) {
|
|
9
|
+
const c = s.charCodeAt(i);
|
|
10
|
+
if (c !== 32 && c !== 10 && c !== 9 && c !== 13) return false;
|
|
11
|
+
}
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function parseXml(text, lineIndex = new LineIndex(text)) {
|
|
16
|
+
const tz = new XmlTokenizer(text, lineIndex);
|
|
17
|
+
const fail = (code, message, offset, length = 1, extra = {}) => {
|
|
18
|
+
const d = new Diagnostic({ code, message, offset, length, ...extra });
|
|
19
|
+
d.locate(lineIndex);
|
|
20
|
+
throw new ES3DError([d], lineIndex);
|
|
21
|
+
};
|
|
22
|
+
const where = (offset) => {
|
|
23
|
+
const p = lineIndex.position(offset);
|
|
24
|
+
return `line ${p.line}, column ${p.column}`;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const stack = [];
|
|
28
|
+
let root = null;
|
|
29
|
+
const leading = [];
|
|
30
|
+
const trailing = [];
|
|
31
|
+
|
|
32
|
+
for (;;) {
|
|
33
|
+
const t = tz.next();
|
|
34
|
+
switch (t.type) {
|
|
35
|
+
case Tok.EOF: {
|
|
36
|
+
if (stack.length) {
|
|
37
|
+
const open = stack[stack.length - 1];
|
|
38
|
+
fail('E_UNCLOSED_ELEMENT', `Unclosed element <${open.name}> (opened at ${where(open.start)}).`, open.start, open.nameEnd - open.start, {
|
|
39
|
+
hint: `Add </${open.name}> or write <${open.name} ... /> for an element without children.`,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
if (!root) fail('E_EMPTY_DOCUMENT', 'Empty document: expected an <es3d> root element.', t.start, 0);
|
|
43
|
+
const doc = new Document({ root, syntax: 'xml', source: text, leading, trailing });
|
|
44
|
+
doc.lineIndex = lineIndex;
|
|
45
|
+
return doc;
|
|
46
|
+
}
|
|
47
|
+
case Tok.PI:
|
|
48
|
+
if (root || stack.length) fail('E_SYNTAX', 'Processing instructions (<?...?>) are only allowed before the root element.', t.start, 2);
|
|
49
|
+
break;
|
|
50
|
+
case Tok.DOCTYPE:
|
|
51
|
+
if (root || stack.length) fail('E_SYNTAX', 'A DOCTYPE is only allowed before the root element.', t.start, 2);
|
|
52
|
+
break;
|
|
53
|
+
case Tok.COMMENT: {
|
|
54
|
+
const c = new Comment(t.text, t.start, t.end);
|
|
55
|
+
if (stack.length) stack[stack.length - 1].appendChild(c);
|
|
56
|
+
else if (root) trailing.push(c);
|
|
57
|
+
else leading.push(c);
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
case Tok.TEXT:
|
|
61
|
+
case Tok.CDATA: {
|
|
62
|
+
const raw = t.type === Tok.TEXT ? t.raw : t.text;
|
|
63
|
+
if (isBlank(raw)) break;
|
|
64
|
+
if (!stack.length) {
|
|
65
|
+
let k = t.start;
|
|
66
|
+
while (k < t.end && /\s/.test(text[k])) k++;
|
|
67
|
+
fail('E_TEXT_OUTSIDE_ROOT', 'Text is not allowed outside the root element.', k, 1);
|
|
68
|
+
}
|
|
69
|
+
const el = stack[stack.length - 1];
|
|
70
|
+
const value = t.type === Tok.TEXT ? decodeEntities(raw, t.start, (code, msg, off, len) => fail(code, msg, off, len)) : raw;
|
|
71
|
+
if (el.text === null) {
|
|
72
|
+
el.text = value;
|
|
73
|
+
el.textStart = t.start;
|
|
74
|
+
} else {
|
|
75
|
+
el.text += value;
|
|
76
|
+
}
|
|
77
|
+
el.textEnd = t.end;
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
case Tok.OPEN: {
|
|
81
|
+
if (!stack.length && root) {
|
|
82
|
+
fail('E_MULTIPLE_ROOTS', `Only one root element is allowed; found a second root <${t.name}>.`, t.start, t.nameEnd - t.start);
|
|
83
|
+
}
|
|
84
|
+
const el = new Element(t.name, t.start);
|
|
85
|
+
el.nameStart = t.nameStart;
|
|
86
|
+
el.nameEnd = t.nameEnd;
|
|
87
|
+
let a = tz.next();
|
|
88
|
+
while (a.type === Tok.ATTR) {
|
|
89
|
+
for (const prev of el.attrs) {
|
|
90
|
+
if (prev.name === a.name) {
|
|
91
|
+
fail('E_DUPLICATE_ATTRIBUTE', `Duplicate attribute '${a.name}' on <${el.name}>.`, a.nameStart, a.nameEnd - a.nameStart);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const attr = new Attr(a.name, a.value, a.nameStart, a.nameEnd, a.valueStart, a.valueEnd, a.quote);
|
|
95
|
+
attr.decoded = a.decoded;
|
|
96
|
+
el.attrs.push(attr);
|
|
97
|
+
a = tz.next();
|
|
98
|
+
}
|
|
99
|
+
// a is OPEN_END (the tokenizer throws on anything else)
|
|
100
|
+
el.openEnd = a.end;
|
|
101
|
+
if (stack.length) stack[stack.length - 1].appendChild(el);
|
|
102
|
+
else root = el;
|
|
103
|
+
if (a.selfClosing) {
|
|
104
|
+
el.selfClosing = true;
|
|
105
|
+
el.end = a.end;
|
|
106
|
+
} else {
|
|
107
|
+
el.selfClosing = false;
|
|
108
|
+
stack.push(el);
|
|
109
|
+
}
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
case Tok.CLOSE: {
|
|
113
|
+
if (!stack.length) {
|
|
114
|
+
fail('E_UNEXPECTED_CLOSE', `Unexpected closing tag </${t.name}>: no element is open.`, t.start, t.end - t.start);
|
|
115
|
+
}
|
|
116
|
+
const open = stack[stack.length - 1];
|
|
117
|
+
if (open.name !== t.name) {
|
|
118
|
+
const deeper = stack.findIndex((e) => e.name === t.name);
|
|
119
|
+
fail(
|
|
120
|
+
'E_MISMATCHED_TAG',
|
|
121
|
+
`Mismatched closing tag: expected </${open.name}> (opened at ${where(open.start)}) but found </${t.name}>.`,
|
|
122
|
+
t.start,
|
|
123
|
+
t.end - t.start,
|
|
124
|
+
deeper >= 0 ? { hint: `<${open.name}> is not closed before </${t.name}>.` } : {},
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
stack.pop();
|
|
128
|
+
open.closeStart = t.start;
|
|
129
|
+
open.end = t.end;
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
default:
|
|
133
|
+
fail('E_SYNTAX', 'Unexpected token.', t.start, 1);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|