@es3d/es3d 1.0.0 → 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.
Files changed (79) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +86 -86
  3. package/docs/SPEC.md +417 -417
  4. package/package.json +1 -1
  5. package/scripts/es3d-cli.mjs +377 -377
  6. package/src/cache/geometry-store.js +392 -392
  7. package/src/cache/mesh-bundle.js +123 -123
  8. package/src/cache/sha256.js +73 -73
  9. package/src/format/ast.js +238 -238
  10. package/src/format/colors.js +93 -93
  11. package/src/format/compact-parser.js +171 -171
  12. package/src/format/compact-tokenizer.js +165 -165
  13. package/src/format/errors.js +247 -247
  14. package/src/format/json.js +55 -55
  15. package/src/format/limits.js +14 -14
  16. package/src/format/parse.js +92 -92
  17. package/src/format/schema.js +617 -617
  18. package/src/format/serializer.js +333 -333
  19. package/src/format/validator.js +330 -330
  20. package/src/format/value-error.js +12 -12
  21. package/src/format/value-format.js +314 -314
  22. package/src/format/values.js +459 -459
  23. package/src/format/xml-parser.js +136 -136
  24. package/src/format/xml-tokenizer.js +237 -237
  25. package/src/geometry/contour.js +86 -86
  26. package/src/geometry/extrude.js +175 -175
  27. package/src/geometry/gear.js +85 -85
  28. package/src/geometry/geometry-cache.js +159 -159
  29. package/src/geometry/lod-error.js +178 -178
  30. package/src/geometry/mesh.js +458 -458
  31. package/src/geometry/primitives.js +299 -299
  32. package/src/geometry/registry.js +645 -645
  33. package/src/geometry/revolve.js +136 -136
  34. package/src/geometry/sweep.js +441 -441
  35. package/src/index.js +47 -47
  36. package/src/io/compression.js +67 -67
  37. package/src/io/convert.js +236 -236
  38. package/src/io/download.js +13 -13
  39. package/src/io/external-scene.js +127 -127
  40. package/src/io/file-loader.js +28 -28
  41. package/src/io/gltf.js +630 -630
  42. package/src/io/mesh-import.js +104 -104
  43. package/src/io/obj.js +352 -352
  44. package/src/io/step-brep.js +439 -439
  45. package/src/io/step-exact.js +1149 -1149
  46. package/src/io/step.js +1418 -1418
  47. package/src/io/stl.js +193 -193
  48. package/src/io/svg-import.js +787 -787
  49. package/src/material/materials.js +154 -154
  50. package/src/math/mat4.js +475 -475
  51. package/src/math/random.js +30 -30
  52. package/src/math/trig.js +65 -65
  53. package/src/math/vec3.js +81 -81
  54. package/src/optimize/optimizer.js +579 -579
  55. package/src/path/path-flatten.js +322 -322
  56. package/src/path/path-parser.js +332 -332
  57. package/src/path/shapes.js +129 -129
  58. package/src/path/triangulate.js +517 -517
  59. package/src/pipeline.js +74 -74
  60. package/src/render/camera.js +168 -168
  61. package/src/render/frame-builder.js +362 -362
  62. package/src/render/geometry-pool.js +149 -149
  63. package/src/render/lighting.js +149 -149
  64. package/src/render/orbit-controls.js +237 -237
  65. package/src/render/renderer.js +258 -249
  66. package/src/render/scroll-timeline.js +104 -104
  67. package/src/render/shaders/glsl.js +285 -285
  68. package/src/render/shaders/wgsl.js +252 -252
  69. package/src/render/stage.js +364 -352
  70. package/src/render/texture-cache.js +40 -40
  71. package/src/render/textures.js +223 -223
  72. package/src/render/webgl2/webgl2-backend.js +586 -532
  73. package/src/render/webgpu/webgpu-backend.js +482 -480
  74. package/src/scene/flatten.js +754 -754
  75. package/src/scene/picking.js +161 -161
  76. package/src/scene/scene-graph.js +320 -320
  77. package/src/transform/transform.js +211 -211
  78. package/src/wasm/kernels-binary.js +3 -3
  79. package/src/wasm/kernels.js +385 -385
@@ -1,459 +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
- }
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
+ }