@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,617 +1,617 @@
1
- // The ES3D v1 schema: elements, attributes, value types, defaults, aliases and
2
- // cross-attribute rules. The validator, the serializer (short aliases), the
3
- // inspector (editable fields) and the specification generator all read this table.
4
- //
5
- // Design rule: every primitive's default size fits in the unit cube centred at
6
- // the origin, so <box/>, <sphere/>, <cylinder/> ... are all visible and comparable.
7
-
8
- import { MAX_COPIES } from './limits.js';
9
-
10
- const ident = {
11
- id: { type: 'id', desc: 'Unique identifier, referenced by <use ref>, material="…" and path="…".' },
12
- name: { type: 'string', desc: 'Human readable label shown in tools.' },
13
- };
14
-
15
- const placement = {
16
- transform: { type: 'transform', alias: 't', desc: 'SVG-style transform list; applied outside position/rotation/scale.' },
17
- position: { type: 'vec3', alias: 'p', default: [0, 0, 0], desc: 'Translation x,y,z.' },
18
- rotation: { type: 'vec3', alias: 'rot', default: [0, 0, 0], desc: 'Euler angles in degrees, applied about X, then Y, then Z.' },
19
- scale: { type: 'scale3', alias: 'sc', default: [1, 1, 1], desc: 'Uniform scale or x,y,z.' },
20
- visible: { type: 'bool', default: true, desc: 'When false the element and its children are not rendered.' },
21
- };
22
-
23
- const style = {
24
- material: { type: 'ref', ref: 'material', alias: 'm', desc: 'Material id. Inherited by descendants, like SVG fill.' },
25
- color: { type: 'color', alias: 'c', desc: 'Base colour override. Inherited by descendants.' },
26
- };
27
-
28
- const node = {
29
- ...ident,
30
- ...placement,
31
- ...style,
32
- collide: { type: 'bool', default: true, desc: 'When false the camera may pass through the element and its children (see <controls collide>).' },
33
- };
34
-
35
- const pathSource = {
36
- d: { type: 'path2d', desc: 'SVG path data in the local XY plane (y up): M L H V C S Q T A Z.' },
37
- path: { type: 'ref', ref: 'path', desc: 'Id of a <path> element whose d is used.' },
38
- fillRule: { type: 'enum', values: ['nonzero', 'evenodd'], default: 'nonzero', desc: 'How nested contours become holes (SVG fill-rule).' },
39
- };
40
-
41
- const positive = { exclusiveMin: 0 };
42
-
43
- function requirePathSource(v, err, el, given = (k) => v[k] !== undefined) {
44
- if (!given('d') && !given('path')) err(null, "Missing path data: give either d=\"…\" or path=\"id\".", 'E_MISSING_ATTRIBUTE');
45
- else if (given('d') && given('path')) err('path', "Give either d or path, not both.", 'E_CONFLICTING_ATTRIBUTES');
46
- }
47
-
48
- /** Children categories */
49
- export const SHAPES = [
50
- 'box', 'sphere', 'cylinder', 'cone', 'capsule', 'plane', 'torus',
51
- 'line', 'polyline', 'polygon', 'path', 'extrude', 'revolve', 'sweep', 'gear', 'mesh',
52
- ];
53
- export const CONTAINERS = ['group', 'repeat', 'scatter', 'lod'];
54
- export const SCENE_NODES = [...SHAPES, ...CONTAINERS, 'use', 'light', 'camera', 'material'];
55
-
56
- export const ELEMENTS = {
57
- es3d: {
58
- category: 'root',
59
- children: ['title', 'desc', 'defs', 'scene', 'environment', 'controls', ...SCENE_NODES],
60
- desc: 'Root element of an ES3D document.',
61
- attrs: {
62
- version: { type: 'string', required: true, desc: 'Format version; this implementation reads version 1.' },
63
- unit: { type: 'enum', values: ['m', 'cm', 'mm', 'km', 'in', 'ft'], default: 'm', desc: 'Length unit (informative).' },
64
- xmlns: { type: 'string', desc: 'Optional. Ignored if present. A namespace is not required.' },
65
- name: ident.name,
66
- },
67
- check(v, err) {
68
- if (v.version !== undefined && !/^1(\.\d+)?$/.test(String(v.version).trim())) {
69
- err('version', `Unsupported ES3D version '${v.version}'. This reader supports version 1.`, 'E_VERSION');
70
- }
71
- },
72
- },
73
- title: { category: 'meta', children: 'text', desc: 'Document title.', attrs: {} },
74
- desc: { category: 'meta', children: 'text', desc: 'Free text description.', attrs: {} },
75
- defs: {
76
- category: 'defs',
77
- children: [...SCENE_NODES],
78
- desc: 'Definitions that are not rendered directly; referenced with <use ref>.',
79
- attrs: {},
80
- },
81
- scene: {
82
- category: 'container',
83
- children: [...SCENE_NODES, 'environment', 'controls'],
84
- desc: 'Optional wrapper for scene content; behaves like a group.',
85
- attrs: { ...node },
86
- },
87
- environment: {
88
- category: 'meta',
89
- children: [],
90
- desc: 'Background and ambient (hemisphere) lighting.',
91
- attrs: {
92
- background: { type: 'color', default: [0.09, 0.1, 0.12, 1], desc: 'Clear colour.' },
93
- sky: { type: 'color', default: [0.8, 0.86, 0.95, 1], desc: 'Ambient light colour from above.' },
94
- ground: { type: 'color', default: [0.25, 0.22, 0.2, 1], desc: 'Ambient light colour from below.' },
95
- intensity: { type: 'number', min: 0, default: 0.5, desc: 'Ambient / environment intensity.' },
96
- exposure: { type: 'number', exclusiveMin: 0, default: 1, desc: 'Tone mapping exposure.' },
97
- },
98
- },
99
- controls: {
100
- category: 'meta',
101
- children: [],
102
- desc: 'How a viewer lets people move the camera: orbit limits, collision with the scene, or a camera driven by page scroll.',
103
- attrs: {
104
- mode: { type: 'enum', values: ['orbit', 'scroll', 'none'], default: 'orbit', desc: 'orbit: drag to orbit, pan and zoom. scroll: the first camera follows its scroll-timeline animations and dragging is off. none: a fixed view.' },
105
- zoom: { type: 'bool', default: true, desc: 'Allow zooming (orbit mode).' },
106
- pan: { type: 'bool', default: true, desc: 'Allow panning (orbit mode).' },
107
- minDistance: { type: 'number', ...positive, desc: 'Closest the camera may get to its target.' },
108
- maxDistance: { type: 'number', ...positive, desc: 'Farthest the camera may get from its target.' },
109
- minPolar: { type: 'angle', min: 0, max: 180, default: 0, desc: 'Smallest angle between the view from the target and straight up, in degrees (0 = from directly above).' },
110
- maxPolar: { type: 'angle', min: 0, max: 180, default: 180, desc: 'Largest angle from straight up, in degrees (90 = never below the target).' },
111
- targetMin: { type: 'vec3', desc: 'Lower corner of the box the orbit target must stay in (limits panning).' },
112
- targetMax: { type: 'vec3', desc: 'Upper corner of the box the orbit target must stay in.' },
113
- collide: { type: 'bool', default: false, desc: 'Stop the camera at surfaces instead of letting it pass into objects. Elements with collide="false" are ignored.' },
114
- },
115
- check(v, err, el, given = (k) => v[k] !== undefined) {
116
- if (v.minDistance !== undefined && v.maxDistance !== undefined && v.maxDistance < v.minDistance) err('maxDistance', 'maxDistance must not be smaller than minDistance.', 'E_RANGE');
117
- if (v.minPolar !== undefined && v.maxPolar !== undefined && v.maxPolar < v.minPolar) err('maxPolar', 'maxPolar must not be smaller than minPolar.', 'E_RANGE');
118
- if (given('targetMin') !== given('targetMax')) err(given('targetMin') ? 'targetMin' : 'targetMax', 'Give both targetMin and targetMax.', 'E_MISSING_ATTRIBUTE');
119
- if (v.targetMin && v.targetMax && v.targetMin.some((x, k) => x > v.targetMax[k])) err('targetMax', 'targetMax must not be smaller than targetMin on any axis.', 'E_RANGE');
120
- },
121
- },
122
- material: {
123
- category: 'resource',
124
- children: [],
125
- desc: 'Minimal physically based material (metallic/roughness).',
126
- attrs: {
127
- ...ident,
128
- color: { type: 'color', alias: 'c', default: [0.8, 0.8, 0.8, 1], desc: 'Base colour (sRGB).' },
129
- metallic: { type: 'number', min: 0, max: 1, default: 0, desc: '0 = dielectric, 1 = metal.' },
130
- roughness: { type: 'number', min: 0, max: 1, default: 0.5, desc: '0 = mirror, 1 = fully rough.' },
131
- opacity: { type: 'number', min: 0, max: 1, default: 1, desc: 'Alpha; values below 1 are blended.' },
132
- emissive: { type: 'color', default: [0, 0, 0, 1], desc: 'Emitted colour.' },
133
- emissiveIntensity: { type: 'number', min: 0, default: 1, desc: 'Multiplier for emissive.' },
134
- map: { type: 'texture', desc: 'Base colour texture: image URL or checker(), grid(), stripes(), noise(), bricks().' },
135
- normalMap: { type: 'normalTexture', desc: 'Normal map: image URL or bumps(), tiles(), noise(), waves(), bricks().' },
136
- normalScale: { type: 'number', default: 1, desc: 'Normal map strength.' },
137
- uvScale: { type: 'vec2', default: [1, 1], desc: 'Texture repeat in u and v.' },
138
- doubleSided: { type: 'bool', default: false, desc: 'Render back faces too.' },
139
- unlit: { type: 'bool', default: false, desc: 'Ignore lighting; output the base colour.' },
140
- occlusionMap: { type: 'texture', desc: 'Occlusion texture. The red channel darkens indirect light. Image URL or a procedural texture.' },
141
- occlusionStrength: { type: 'number', min: 0, max: 1, default: 1, desc: 'Strength of occlusionMap. 0 disables it, 1 uses the texture as stored.' },
142
- metallicRoughnessMap: { type: 'texture', desc: 'Packed texture: green multiplies roughness, blue multiplies metallic.' },
143
- emissiveMap: { type: 'texture', desc: 'sRGB texture multiplied with emissive.' },
144
- alphaMode: { type: 'enum', values: ['auto', 'opaque', 'mask', 'blend'], default: 'auto', desc: 'auto blends when opacity is below 1 and is opaque otherwise. mask discards fragments below alphaCutoff. blend uses opacity as coverage. opaque ignores alpha.' },
145
- alphaCutoff: { type: 'number', min: 0, max: 1, default: 0.5, desc: 'Mask threshold.' },
146
- },
147
- },
148
- group: { category: 'container', children: [...SCENE_NODES, 'animate'], desc: 'Groups children under a common transform and style.', attrs: { ...node } },
149
- use: {
150
- category: 'instance',
151
- children: ['animate'],
152
- desc: 'Instantiates the element with the given id (like SVG <use>).',
153
- attrs: {
154
- ...node,
155
- ref: { type: 'ref', ref: 'node', required: true, alias: 'href', desc: 'Id of the element to instantiate.' },
156
- },
157
- },
158
- repeat: {
159
- category: 'container',
160
- children: [...SCENE_NODES, 'animate'],
161
- desc: 'Repeats its children count times; copy i is transformed by step applied i times.',
162
- attrs: {
163
- ...node,
164
- count: { type: 'int', alias: 'n', required: true, min: 0, max: MAX_COPIES, desc: 'Number of copies.' },
165
- step: { type: 'transform', required: true, desc: 'Transform applied cumulatively between copies.' },
166
- },
167
- },
168
- scatter: {
169
- category: 'container',
170
- children: [...SCENE_NODES, 'animate'],
171
- desc: 'Deterministic pseudo-random or grid placement of its children (seeded mulberry32).',
172
- attrs: {
173
- ...node,
174
- count: { type: 'int', alias: 'n', min: 0, max: MAX_COPIES, desc: 'Number of random copies (random layout).' },
175
- grid: { type: 'count2', desc: 'Grid layout nx,nz (instead of count).' },
176
- spacing: { type: 'vec2', default: [1, 1], desc: 'Grid cell size x,z.' },
177
- area: { type: 'size2', desc: 'Random layout: rectangle width,depth centred at the origin (XZ plane).' },
178
- radius: { type: 'number', ...positive, desc: 'Random layout: disc radius (XZ plane).' },
179
- seed: { type: 'int', default: 1, min: 0, desc: 'Random seed.' },
180
- jitter: { type: 'number', min: 0, max: 1, default: 0, desc: 'Grid layout: random offset as a fraction of the cell.' },
181
- randomScale: { type: 'range3', desc: 'Per-copy scale: a range (uniform, e.g. 0.8..1.2) or x,y,z ranges.' },
182
- randomRotation: { type: 'range3', uniform: false, desc: 'Per-copy Euler ranges in degrees, e.g. 0,0..360,0.' },
183
- randomMaterial: { type: 'idlist', ref: 'material', desc: 'Per-copy material chosen from this list of ids.' },
184
- },
185
- check(v, err, el, given = (k) => v[k] !== undefined) {
186
- const hasGrid = given('grid');
187
- const hasCount = given('count');
188
- if (hasGrid && hasCount) err('grid', 'Give either count (random layout) or grid (grid layout), not both.', 'E_CONFLICTING_ATTRIBUTES');
189
- else if (!hasGrid && !hasCount) err(null, 'scatter needs count="…" (random layout) or grid="nx,nz" (grid layout).', 'E_MISSING_ATTRIBUTE');
190
- if (hasCount && !given('area') && !given('radius')) err(null, 'Random scatter needs area="w,d" or radius="r".', 'E_MISSING_ATTRIBUTE');
191
- if (given('area') && given('radius')) err('radius', 'Give either area or radius, not both.', 'E_CONFLICTING_ATTRIBUTES');
192
- if (v.area && (v.area[0] < 0 || v.area[1] < 0)) err('area', 'area must not be negative.', 'E_RANGE');
193
- if (v.randomScale && v.randomScale.ranges.some((r) => r[0] < 0)) err('randomScale', 'Scale ranges must not be negative.', 'E_RANGE');
194
- },
195
- },
196
- lod: {
197
- category: 'container',
198
- children: [...SCENE_NODES, 'animate'],
199
- desc: 'Level of detail switch: child i is shown while the camera distance is below distances[i].',
200
- attrs: {
201
- ...node,
202
- distances: { type: 'numbers', required: true, desc: 'Increasing switch distances; one fewer than the number of children.' },
203
- },
204
- check(v, err, el) {
205
- const d = v.distances;
206
- if (!d) return;
207
- for (let i = 1; i < d.length; i++) if (d[i] <= d[i - 1]) err('distances', 'distances must be strictly increasing.', 'E_RANGE');
208
- if (d.some((x) => x <= 0)) err('distances', 'distances must be positive.', 'E_RANGE');
209
- const kids = el.children.filter((c) => c.type === 'element' && c.name !== 'animate').length;
210
- if (kids !== d.length + 1) err('distances', `lod has ${kids} children but ${d.length} distances; it needs exactly one distance fewer than children.`, 'E_LOD_CHILDREN');
211
- },
212
- },
213
- animate: {
214
- category: 'anim',
215
- children: [],
216
- desc: 'Animates a transform of its parent (position, rotation, scale; target and fov of a camera) over time or over page scroll.',
217
- attrs: {
218
- attr: { type: 'enum', values: ['position', 'rotation', 'scale', 'target', 'fov'], required: true, desc: 'Animated attribute. target and fov apply to <camera> only.' },
219
- from: { type: 'animValue', desc: 'Start value (defaults to the parent value).' },
220
- to: { type: 'animValue', desc: 'End value.' },
221
- values: { type: 'keyframes', desc: 'Key values separated by semicolons, e.g. 0,1,0; 2,1,0; 2,3,0 (instead of from/to).' },
222
- keyTimes: { type: 'numbers', desc: 'Progress (0 to 1) at which each of values is reached; evenly spaced when omitted.' },
223
- along: { type: 'spine', desc: 'Path to move along at constant speed (position and target): x,y,z points, 3D path data or helix().' },
224
- smooth: { type: 'bool', default: false, desc: 'Pass through values or along points on a smooth (centripetal Catmull-Rom) curve.' },
225
- dur: { type: 'duration', desc: 'Duration of one cycle, e.g. 4s or 500ms (required unless timeline="scroll").' },
226
- begin: { type: 'duration', default: 0, min: 0, desc: 'Delay before the animation starts.' },
227
- repeat: { type: 'repeatCount', default: Infinity, desc: "Number of cycles or 'indefinite'." },
228
- alternate: { type: 'bool', default: false, desc: 'Play every second cycle backwards.' },
229
- easing: { type: 'enum', values: ['linear', 'ease-in', 'ease-out', 'ease-in-out'], default: 'linear', desc: 'Timing function.' },
230
- timeline: { type: 'enum', values: ['time', 'scroll'], default: 'time', desc: 'time: plays by the clock. scroll: progress follows page scroll from 0 to 1 (dur, begin, repeat and alternate are not used).' },
231
- },
232
- check(v, err, el, given = (k) => v[k] !== undefined) {
233
- const sources = ['to', 'values', 'along'].filter((k) => given(k));
234
- if (!sources.length) err(null, 'animate needs to="…", values="…" or along="…".', 'E_MISSING_ATTRIBUTE');
235
- else if (sources.length > 1) err(sources[1], `Give only one of to, values and along (found ${sources.join(' and ')}).`, 'E_CONFLICTING_ATTRIBUTES');
236
- if (given('from') && (given('values') || given('along'))) err('from', 'from is only used with to.', 'E_CONFLICTING_ATTRIBUTES');
237
- const scroll = v.timeline === 'scroll';
238
- if (!scroll && !given('dur')) err(null, "animate requires the attribute 'dur' (or timeline=\"scroll\").", 'E_MISSING_ATTRIBUTE');
239
- if (v.dur !== undefined && v.dur <= 0) err('dur', 'dur must be positive.', 'E_RANGE');
240
- const parent = el && el.parent;
241
- if ((v.attr === 'target' || v.attr === 'fov') && parent && parent.name !== 'camera') {
242
- err('attr', `attr="${v.attr}" can only animate a <camera>.`, 'E_INVALID_VALUE');
243
- }
244
- if (v.attr === 'target' && parent && parent.name === 'camera' && parent.attrIndex && parent.attrIndex.rotation) {
245
- err('attr', 'This camera is oriented by rotation; give it a target instead to animate the target.', 'E_CONFLICTING_ATTRIBUTES');
246
- }
247
- if (given('along') && v.attr && v.attr !== 'position' && v.attr !== 'target') err('along', 'along can only move position or target.', 'E_INVALID_VALUE');
248
- const n = v.attr === 'scale' ? [1, 3] : v.attr === 'fov' ? [1, 1] : [3, 3];
249
- const need = `${v.attr || 'value'} animation values need ${n[0] === n[1] ? n[0] : '1 or 3'} number${n[1] === 1 ? '' : 's'}`;
250
- for (const k of ['from', 'to']) {
251
- if (v[k] !== undefined && !n.includes(v[k].length)) err(k, `${need}.`, 'E_TYPE');
252
- }
253
- if (v.values) {
254
- if (v.values.length < 2) err('values', 'values needs at least 2 key values.', 'E_RANGE');
255
- const bad = v.values.findIndex((x) => !n.includes(x.length));
256
- if (bad >= 0) err('values', `${need}; key value ${bad + 1} has ${v.values[bad].length}.`, 'E_TYPE');
257
- }
258
- if (given('keyTimes') && !given('values')) err('keyTimes', 'keyTimes needs values.', 'E_MISSING_ATTRIBUTE');
259
- const kt = v.keyTimes;
260
- if (kt && v.values) {
261
- if (kt.length !== v.values.length) err('keyTimes', `keyTimes has ${kt.length} entries but values has ${v.values.length}.`, 'E_RANGE');
262
- else if (kt[0] !== 0 || kt[kt.length - 1] !== 1) err('keyTimes', 'keyTimes must start at 0 and end at 1.', 'E_RANGE');
263
- else if (kt.some((t, i) => i > 0 && t < kt[i - 1])) err('keyTimes', 'keyTimes must not decrease.', 'E_RANGE');
264
- }
265
- },
266
- },
267
- camera: {
268
- category: 'camera',
269
- children: ['animate'],
270
- desc: 'Perspective or orthographic camera. The first camera is the initial view.',
271
- attrs: {
272
- ...ident,
273
- transform: placement.transform,
274
- position: { type: 'vec3', alias: 'p', default: [0, 0, 10], desc: 'Eye position.' },
275
- rotation: { type: 'vec3', alias: 'rot', desc: 'Euler orientation (used when no target is given; the camera looks down -Z).' },
276
- target: { type: 'vec3', desc: 'Point the camera looks at.' },
277
- up: { type: 'vec3', default: [0, 1, 0], desc: 'Up vector.' },
278
- type: { type: 'enum', values: ['perspective', 'orthographic'], default: 'perspective', desc: 'Projection.' },
279
- fov: { type: 'angle', min: 1, max: 179, default: 45, desc: 'Vertical field of view in degrees (perspective).' },
280
- height: { type: 'number', ...positive, default: 10, desc: 'Visible height (orthographic).' },
281
- near: { type: 'number', ...positive, desc: 'Near clip distance (automatic when omitted).' },
282
- far: { type: 'number', ...positive, desc: 'Far clip distance (automatic when omitted).' },
283
- aspect: { type: 'number', ...positive, default: 1, desc: 'Width / height of the view the author framed (e.g. 1.778 for 16:9). fov is the vertical angle at this aspect ratio.' },
284
- fit: { type: 'enum', values: ['contain', 'cover', 'width', 'height'], default: 'contain', desc: 'How the framed view adapts to other screens: contain keeps all of it visible, cover fills the screen, width and height keep that extent fixed.' },
285
- },
286
- check(v, err) {
287
- if (v.near !== undefined && v.far !== undefined && v.far <= v.near) err('far', 'far must be greater than near.', 'E_RANGE');
288
- if (v.target !== undefined && v.rotation !== undefined) err('rotation', 'Give either target or rotation, not both.', 'E_CONFLICTING_ATTRIBUTES');
289
- },
290
- },
291
- light: {
292
- category: 'light',
293
- children: ['animate'],
294
- desc: 'Directional, point, spot, ambient or hemisphere light.',
295
- attrs: {
296
- ...ident,
297
- transform: placement.transform,
298
- position: { type: 'vec3', alias: 'p', default: [0, 0, 0], desc: 'Light position (point/spot).' },
299
- rotation: placement.rotation,
300
- visible: placement.visible,
301
- type: { type: 'enum', values: ['directional', 'point', 'spot', 'ambient', 'hemisphere'], required: true, desc: 'Light kind.' },
302
- color: { type: 'color', alias: 'c', default: [1, 1, 1, 1], desc: 'Light colour.' },
303
- intensity: { type: 'number', min: 0, default: 1, desc: 'Intensity multiplier.' },
304
- direction: { type: 'vec3', alias: 'dir', desc: 'Direction the light travels (directional/spot).' },
305
- target: { type: 'vec3', desc: 'Point the light aims at (alternative to direction).' },
306
- range: { type: 'number', min: 0, default: 0, desc: 'Point/spot cut-off distance (0 = unlimited).' },
307
- angle: { type: 'angle', min: 1, max: 179, default: 45, desc: 'Spot cone angle in degrees.' },
308
- penumbra: { type: 'number', min: 0, max: 1, default: 0.2, desc: 'Spot edge softness.' },
309
- groundColor: { type: 'color', default: [0.25, 0.22, 0.2, 1], desc: 'Hemisphere light colour from below.' },
310
- },
311
- check(v, err) {
312
- if (v.direction && Math.hypot(...v.direction) < 1e-12) err('direction', 'direction must not be the zero vector.', 'E_RANGE');
313
- if (v.direction !== undefined && v.target !== undefined) err('target', 'Give either direction or target, not both.', 'E_CONFLICTING_ATTRIBUTES');
314
- },
315
- },
316
-
317
- // ---- shapes -------------------------------------------------------------
318
- box: {
319
- category: 'shape',
320
- children: ['animate'],
321
- desc: 'Axis aligned box centred at the origin, optionally with rounded edges.',
322
- attrs: {
323
- ...node,
324
- size: { type: 'size3', alias: 's', default: [1, 1, 1], desc: 'Edge lengths (one value for a cube).' },
325
- radius: { type: 'number', alias: 'r', min: 0, default: 0, desc: 'Edge rounding radius.' },
326
- segments: { type: 'int', min: 1, max: 32, desc: 'Rounding segments (automatic when omitted).' },
327
- },
328
- check(v, err) {
329
- if (v.size && v.size.some((x) => x <= 0)) err('size', 'size must be positive.', 'E_RANGE');
330
- if (v.size && v.radius > 0 && v.radius * 2 > Math.min(...v.size) + 1e-12) err('radius', `radius ${v.radius} is larger than half the smallest edge (${Math.min(...v.size) / 2}).`, 'E_RANGE');
331
- },
332
- },
333
- sphere: {
334
- category: 'shape',
335
- children: ['animate'],
336
- desc: 'Sphere centred at the origin.',
337
- attrs: {
338
- ...node,
339
- radius: { type: 'number', alias: 'r', ...positive, default: 0.5, desc: 'Radius.' },
340
- segments: { type: 'int', min: 3, max: 512, desc: 'Fixed tessellation (automatic level of detail when omitted).' },
341
- },
342
- },
343
- cylinder: {
344
- category: 'shape',
345
- children: ['animate'],
346
- desc: 'Cylinder (or truncated cone) along Y, centred at the origin.',
347
- attrs: {
348
- ...node,
349
- radius: { type: 'number', alias: 'r', ...positive, default: 0.5, desc: 'Bottom radius (and top radius unless radiusTop is set).' },
350
- radiusTop: { type: 'number', min: 0, desc: 'Top radius.' },
351
- height: { type: 'number', alias: 'h', ...positive, default: 1, desc: 'Height.' },
352
- segments: { type: 'int', min: 3, max: 512, desc: 'Fixed number of sides, e.g. 6 for a hexagonal prism.' },
353
- caps: { type: 'bool', default: true, desc: 'Close the ends.' },
354
- },
355
- },
356
- cone: {
357
- category: 'shape',
358
- children: ['animate'],
359
- desc: 'Cone along Y with its base at -height/2 and apex at +height/2.',
360
- attrs: {
361
- ...node,
362
- radius: { type: 'number', alias: 'r', ...positive, default: 0.5, desc: 'Base radius.' },
363
- height: { type: 'number', alias: 'h', ...positive, default: 1, desc: 'Height.' },
364
- segments: { type: 'int', min: 3, max: 512, desc: 'Fixed number of sides.' },
365
- caps: { type: 'bool', default: true, desc: 'Close the base.' },
366
- },
367
- },
368
- capsule: {
369
- category: 'shape',
370
- children: ['animate'],
371
- desc: 'Capsule along Y; height is the total height including both caps.',
372
- attrs: {
373
- ...node,
374
- radius: { type: 'number', alias: 'r', ...positive, default: 0.25, desc: 'Radius.' },
375
- height: { type: 'number', alias: 'h', ...positive, default: 1, desc: 'Total height (at least 2 × radius).' },
376
- segments: { type: 'int', min: 3, max: 512, desc: 'Fixed tessellation.' },
377
- },
378
- check(v, err) {
379
- const r = v.radius ?? 0.25;
380
- const h = v.height ?? 1;
381
- if (h < 2 * r - 1e-12) err('height', `capsule height (${h}) must be at least twice the radius (${2 * r}).`, 'E_RANGE');
382
- },
383
- },
384
- plane: {
385
- category: 'shape',
386
- children: ['animate'],
387
- desc: 'Flat rectangle in the XZ plane facing +Y (rendered double sided).',
388
- attrs: {
389
- ...node,
390
- size: { type: 'size2', alias: 's', default: [1, 1], desc: 'Width (x) and depth (z).' },
391
- },
392
- check(v, err) {
393
- if (v.size && v.size.some((x) => x <= 0)) err('size', 'size must be positive.', 'E_RANGE');
394
- },
395
- },
396
- torus: {
397
- category: 'shape',
398
- children: ['animate'],
399
- desc: 'Torus around the Y axis.',
400
- attrs: {
401
- ...node,
402
- radius: { type: 'number', alias: 'r', ...positive, default: 0.35, desc: 'Distance from the centre to the tube centre.' },
403
- tube: { type: 'number', ...positive, default: 0.15, desc: 'Tube radius.' },
404
- arc: { type: 'angle', exclusiveMin: 0, max: 360, default: 360, desc: 'Swept angle in degrees (partial torus / pipe elbow).' },
405
- segments: { type: 'int', min: 3, max: 1024, desc: 'Fixed segments around the ring.' },
406
- tubeSegments: { type: 'int', min: 3, max: 512, desc: 'Fixed segments around the tube.' },
407
- },
408
- },
409
- line: {
410
- category: 'shape',
411
- children: ['animate'],
412
- desc: 'Straight line segment; rendered as a tube when radius > 0.',
413
- attrs: {
414
- ...node,
415
- from: { type: 'vec3', required: true, desc: 'Start point.' },
416
- to: { type: 'vec3', required: true, desc: 'End point.' },
417
- radius: { type: 'number', alias: 'r', min: 0, default: 0, desc: 'Tube radius (0 = hairline).' },
418
- segments: { type: 'int', min: 3, max: 256, desc: 'Tube sides.' },
419
- },
420
- check(v, err) {
421
- if (v.from && v.to && Math.hypot(v.to[0] - v.from[0], v.to[1] - v.from[1], v.to[2] - v.from[2]) < 1e-12) {
422
- err('to', 'A line needs two distinct points.', 'E_DEGENERATE');
423
- }
424
- },
425
- },
426
- polyline: {
427
- category: 'shape',
428
- children: ['animate'],
429
- desc: '3D polyline or 2D path outline; rendered as a tube when radius > 0.',
430
- attrs: {
431
- ...node,
432
- points: { type: 'points3', desc: 'x,y,z x,y,z …' },
433
- d: { type: 'path2d', desc: 'Outline of 2D path data in the XY plane (alternative to points).' },
434
- closed: { type: 'bool', default: false, desc: 'Connect the last point to the first.' },
435
- smooth: { type: 'bool', default: false, desc: 'Interpolate the points with a Catmull-Rom spline.' },
436
- radius: { type: 'number', alias: 'r', min: 0, default: 0, desc: 'Tube radius (0 = hairline).' },
437
- segments: { type: 'int', min: 3, max: 256, desc: 'Tube sides.' },
438
- },
439
- check(v, err, el, given = (k) => v[k] !== undefined) {
440
- if (!given('points') && !given('d')) err(null, 'polyline needs points="…" or d="…".', 'E_MISSING_ATTRIBUTE');
441
- if (given('points') && given('d')) err('d', 'Give either points or d, not both.', 'E_CONFLICTING_ATTRIBUTES');
442
- if (v.points && v.points.length < 6) err('points', 'A polyline needs at least 2 points.', 'E_DEGENERATE');
443
- },
444
- },
445
- polygon: {
446
- category: 'shape',
447
- children: ['animate'],
448
- desc: 'Filled flat polygon in the XY plane (rendered double sided).',
449
- attrs: {
450
- ...node,
451
- points: { type: 'points2', required: true, desc: 'x,y x,y … (at least 3 points).' },
452
- },
453
- check(v, err) {
454
- if (v.points && v.points.length < 6) err('points', 'A polygon needs at least 3 points.', 'E_DEGENERATE');
455
- },
456
- },
457
- path: {
458
- category: 'shape',
459
- children: ['animate'],
460
- desc: 'Filled flat 2D path in the XY plane (rendered double sided). In <defs> it is a reusable outline.',
461
- attrs: { ...node, ...pathSource },
462
- check: requirePathSource,
463
- },
464
- extrude: {
465
- category: 'shape',
466
- children: ['animate'],
467
- desc: 'Extrudes a 2D path along +Z, optionally with a rounded bevel.',
468
- attrs: {
469
- ...node,
470
- ...pathSource,
471
- depth: { type: 'number', ...positive, default: 1, desc: 'Extrusion depth along Z.' },
472
- bevel: { type: 'number', min: 0, default: 0, desc: 'Bevel radius on the front and back edges.' },
473
- bevelSegments: { type: 'int', min: 1, max: 16, default: 3, desc: 'Segments per bevel.' },
474
- center: { type: 'bool', default: false, desc: 'Centre the extrusion on z = 0.' },
475
- },
476
- check(v, err, el, given) {
477
- requirePathSource(v, err, el, given);
478
- const depth = v.depth ?? 1;
479
- if (v.bevel && v.bevel * 2 > depth + 1e-12) err('bevel', `bevel (${v.bevel}) must be at most half the depth (${depth / 2}).`, 'E_RANGE');
480
- },
481
- },
482
- revolve: {
483
- category: 'shape',
484
- children: ['animate'],
485
- desc: 'Revolves (lathes) a 2D profile around the Y axis; x is the distance from the axis.',
486
- attrs: {
487
- ...node,
488
- ...pathSource,
489
- angle: { type: 'angle', exclusiveMin: 0, max: 360, default: 360, desc: 'Revolution angle in degrees.' },
490
- segments: { type: 'int', min: 3, max: 1024, desc: 'Fixed number of radial segments.' },
491
- },
492
- check: requirePathSource,
493
- },
494
- sweep: {
495
- category: 'shape',
496
- children: ['animate'],
497
- desc: 'Sweeps a profile (circle or 2D path) along a 3D spine with rotation minimising frames.',
498
- attrs: {
499
- ...node,
500
- ...pathSource,
501
- radius: { type: 'number', alias: 'r', ...positive, desc: 'Circular profile radius (alternative to d/path).' },
502
- along: { type: 'spine', required: true, desc: 'Spine: x,y,z points, 3D path data (M L C S Q T Z) or helix(r,pitch,turns).' },
503
- smooth: { type: 'bool', default: false, desc: 'Interpolate spine points with a Catmull-Rom spline.' },
504
- twist: { type: 'angle', default: 0, desc: 'Profile rotation in degrees from start to end.' },
505
- taper: { type: 'number', min: 0, default: 1, desc: 'Profile scale at the end (1 = constant).' },
506
- caps: { type: 'bool', default: true, desc: 'Close open ends.' },
507
- segments: { type: 'int', min: 3, max: 256, desc: 'Circle profile sides.' },
508
- },
509
- check(v, err, el, given = (k) => v[k] !== undefined) {
510
- const hasPath = given('d') || given('path');
511
- if (!hasPath && !given('radius')) err(null, 'sweep needs a profile: radius="…" or d="…" / path="…".', 'E_MISSING_ATTRIBUTE');
512
- if (hasPath && given('radius')) err('radius', 'Give either radius or a path profile, not both.', 'E_CONFLICTING_ATTRIBUTES');
513
- if (given('d') && given('path')) err('path', 'Give either d or path, not both.', 'E_CONFLICTING_ATTRIBUTES');
514
- },
515
- },
516
- gear: {
517
- category: 'shape',
518
- children: ['animate'],
519
- desc: 'Involute spur gear in the XY plane, extruded along Z.',
520
- attrs: {
521
- ...node,
522
- teeth: { type: 'int', min: 5, max: 400, default: 24, desc: 'Number of teeth.' },
523
- module: { type: 'number', ...positive, default: 0.04, desc: 'Module: pitch diameter / teeth.' },
524
- pressureAngle: { type: 'angle', min: 10, max: 35, default: 20, desc: 'Pressure angle in degrees.' },
525
- depth: { type: 'number', ...positive, default: 0.2, desc: 'Face width (extrusion depth).' },
526
- bore: { type: 'number', min: 0, default: 0, desc: 'Radius of the centre hole.' },
527
- bevel: { type: 'number', min: 0, default: 0, desc: 'Edge bevel radius.' },
528
- center: { type: 'bool', default: true, desc: 'Centre the gear on z = 0.' },
529
- },
530
- check(v, err) {
531
- const teeth = v.teeth ?? 24;
532
- const m = v.module ?? 0.04;
533
- const root = (m * teeth) / 2 - 1.25 * m;
534
- if (v.bore && v.bore >= root * 0.95) err('bore', `bore (${v.bore}) must be smaller than the root radius (${(root * 0.95).toFixed(4)}).`, 'E_RANGE');
535
- const depth = v.depth ?? 0.2;
536
- if (v.bevel && v.bevel * 2 > depth + 1e-12) err('bevel', `bevel must be at most half the depth (${depth / 2}).`, 'E_RANGE');
537
- },
538
- },
539
- mesh: {
540
- category: 'shape',
541
- children: ['animate'],
542
- desc: 'Explicit indexed triangle mesh (escape hatch for data that is not procedural).',
543
- attrs: {
544
- ...node,
545
- positions: { type: 'floats', required: true, desc: 'x y z for every vertex.' },
546
- indices: { type: 'indices', desc: 'Triangle vertex indices (3 per triangle); omitted = non-indexed triangles.' },
547
- normals: { type: 'floats', desc: 'Optional x y z normal per vertex (computed when omitted).' },
548
- uvs: { type: 'floats', desc: 'Optional u v per vertex.' },
549
- smooth: { type: 'angle', min: 0, max: 180, default: 30, desc: 'Crease angle for computed normals (0 = flat).' },
550
- },
551
- check(v, err) {
552
- const p = v.positions;
553
- if (!p) return;
554
- if (p.length % 3 !== 0) err('positions', `positions needs 3 numbers per vertex; found ${p.length} numbers.`, 'E_MESH');
555
- const nv = Math.floor(p.length / 3);
556
- if (nv < 3) err('positions', 'A mesh needs at least 3 vertices.', 'E_MESH');
557
- if (v.indices) {
558
- if (v.indices.length % 3 !== 0) err('indices', `indices must contain 3 entries per triangle; found ${v.indices.length}.`, 'E_MESH');
559
- for (let i = 0; i < v.indices.length; i++) {
560
- if (v.indices[i] >= nv) {
561
- err('indices', `Index ${v.indices[i]} at position ${i} is out of range (the mesh has ${nv} vertices).`, 'E_MESH');
562
- break;
563
- }
564
- }
565
- } else if (nv % 3 !== 0) err('positions', 'Without indices the vertex count must be a multiple of 3.', 'E_MESH');
566
- if (v.normals && v.normals.length !== p.length) err('normals', `normals must have one x y z per vertex (${p.length} numbers); found ${v.normals.length}.`, 'E_MESH');
567
- if (v.uvs && v.uvs.length !== nv * 2) err('uvs', `uvs must have one u v per vertex (${nv * 2} numbers); found ${v.uvs.length}.`, 'E_MESH');
568
- for (let i = 0; i < p.length; i++) {
569
- if (!Number.isFinite(p[i])) {
570
- err('positions', `Non-finite coordinate at position ${i}.`, 'E_MESH');
571
- break;
572
- }
573
- }
574
- },
575
- },
576
- };
577
-
578
- /** Elements accepted as the root (v3d is a legacy alias). */
579
- export const ROOT_ELEMENTS = ['es3d', 'v3d'];
580
-
581
- // alias tables --------------------------------------------------------------
582
- const aliasCache = new Map();
583
-
584
- /** Map alias -> canonical attribute name for an element. */
585
- export function aliasesFor(elementName) {
586
- let m = aliasCache.get(elementName);
587
- if (m) return m;
588
- m = new Map();
589
- const spec = ELEMENTS[elementName === 'v3d' ? 'es3d' : elementName];
590
- if (spec) {
591
- for (const [name, a] of Object.entries(spec.attrs)) {
592
- m.set(name, name);
593
- if (a.alias) m.set(a.alias, name);
594
- }
595
- }
596
- aliasCache.set(elementName, m);
597
- return m;
598
- }
599
-
600
- export function elementSpec(name) {
601
- return ELEMENTS[name === 'v3d' ? 'es3d' : name] || null;
602
- }
603
-
604
- export function attrSpec(elementName, canonical) {
605
- const e = elementSpec(elementName);
606
- return e ? e.attrs[canonical] || null : null;
607
- }
608
-
609
- /** Typed value of an attribute, falling back to the schema default. */
610
- export function valueOf(el, name) {
611
- const v = el.values ? el.values[name] : undefined;
612
- if (v !== undefined) return v;
613
- const a = attrSpec(el.name, name);
614
- return a ? a.default : undefined;
615
- }
616
-
617
- export const ALL_ELEMENT_NAMES = Object.keys(ELEMENTS);
1
+ // The ES3D v1 schema: elements, attributes, value types, defaults, aliases and
2
+ // cross-attribute rules. The validator, the serializer (short aliases), the
3
+ // inspector (editable fields) and the specification generator all read this table.
4
+ //
5
+ // Design rule: every primitive's default size fits in the unit cube centred at
6
+ // the origin, so <box/>, <sphere/>, <cylinder/> ... are all visible and comparable.
7
+
8
+ import { MAX_COPIES } from './limits.js';
9
+
10
+ const ident = {
11
+ id: { type: 'id', desc: 'Unique identifier, referenced by <use ref>, material="…" and path="…".' },
12
+ name: { type: 'string', desc: 'Human readable label shown in tools.' },
13
+ };
14
+
15
+ const placement = {
16
+ transform: { type: 'transform', alias: 't', desc: 'SVG-style transform list; applied outside position/rotation/scale.' },
17
+ position: { type: 'vec3', alias: 'p', default: [0, 0, 0], desc: 'Translation x,y,z.' },
18
+ rotation: { type: 'vec3', alias: 'rot', default: [0, 0, 0], desc: 'Euler angles in degrees, applied about X, then Y, then Z.' },
19
+ scale: { type: 'scale3', alias: 'sc', default: [1, 1, 1], desc: 'Uniform scale or x,y,z.' },
20
+ visible: { type: 'bool', default: true, desc: 'When false the element and its children are not rendered.' },
21
+ };
22
+
23
+ const style = {
24
+ material: { type: 'ref', ref: 'material', alias: 'm', desc: 'Material id. Inherited by descendants, like SVG fill.' },
25
+ color: { type: 'color', alias: 'c', desc: 'Base colour override. Inherited by descendants.' },
26
+ };
27
+
28
+ const node = {
29
+ ...ident,
30
+ ...placement,
31
+ ...style,
32
+ collide: { type: 'bool', default: true, desc: 'When false the camera may pass through the element and its children (see <controls collide>).' },
33
+ };
34
+
35
+ const pathSource = {
36
+ d: { type: 'path2d', desc: 'SVG path data in the local XY plane (y up): M L H V C S Q T A Z.' },
37
+ path: { type: 'ref', ref: 'path', desc: 'Id of a <path> element whose d is used.' },
38
+ fillRule: { type: 'enum', values: ['nonzero', 'evenodd'], default: 'nonzero', desc: 'How nested contours become holes (SVG fill-rule).' },
39
+ };
40
+
41
+ const positive = { exclusiveMin: 0 };
42
+
43
+ function requirePathSource(v, err, el, given = (k) => v[k] !== undefined) {
44
+ if (!given('d') && !given('path')) err(null, "Missing path data: give either d=\"…\" or path=\"id\".", 'E_MISSING_ATTRIBUTE');
45
+ else if (given('d') && given('path')) err('path', "Give either d or path, not both.", 'E_CONFLICTING_ATTRIBUTES');
46
+ }
47
+
48
+ /** Children categories */
49
+ export const SHAPES = [
50
+ 'box', 'sphere', 'cylinder', 'cone', 'capsule', 'plane', 'torus',
51
+ 'line', 'polyline', 'polygon', 'path', 'extrude', 'revolve', 'sweep', 'gear', 'mesh',
52
+ ];
53
+ export const CONTAINERS = ['group', 'repeat', 'scatter', 'lod'];
54
+ export const SCENE_NODES = [...SHAPES, ...CONTAINERS, 'use', 'light', 'camera', 'material'];
55
+
56
+ export const ELEMENTS = {
57
+ es3d: {
58
+ category: 'root',
59
+ children: ['title', 'desc', 'defs', 'scene', 'environment', 'controls', ...SCENE_NODES],
60
+ desc: 'Root element of an ES3D document.',
61
+ attrs: {
62
+ version: { type: 'string', required: true, desc: 'Format version; this implementation reads version 1.' },
63
+ unit: { type: 'enum', values: ['m', 'cm', 'mm', 'km', 'in', 'ft'], default: 'm', desc: 'Length unit (informative).' },
64
+ xmlns: { type: 'string', desc: 'Optional. Ignored if present. A namespace is not required.' },
65
+ name: ident.name,
66
+ },
67
+ check(v, err) {
68
+ if (v.version !== undefined && !/^1(\.\d+)?$/.test(String(v.version).trim())) {
69
+ err('version', `Unsupported ES3D version '${v.version}'. This reader supports version 1.`, 'E_VERSION');
70
+ }
71
+ },
72
+ },
73
+ title: { category: 'meta', children: 'text', desc: 'Document title.', attrs: {} },
74
+ desc: { category: 'meta', children: 'text', desc: 'Free text description.', attrs: {} },
75
+ defs: {
76
+ category: 'defs',
77
+ children: [...SCENE_NODES],
78
+ desc: 'Definitions that are not rendered directly; referenced with <use ref>.',
79
+ attrs: {},
80
+ },
81
+ scene: {
82
+ category: 'container',
83
+ children: [...SCENE_NODES, 'environment', 'controls'],
84
+ desc: 'Optional wrapper for scene content; behaves like a group.',
85
+ attrs: { ...node },
86
+ },
87
+ environment: {
88
+ category: 'meta',
89
+ children: [],
90
+ desc: 'Background and ambient (hemisphere) lighting.',
91
+ attrs: {
92
+ background: { type: 'color', default: [0.09, 0.1, 0.12, 1], desc: 'Clear colour.' },
93
+ sky: { type: 'color', default: [0.8, 0.86, 0.95, 1], desc: 'Ambient light colour from above.' },
94
+ ground: { type: 'color', default: [0.25, 0.22, 0.2, 1], desc: 'Ambient light colour from below.' },
95
+ intensity: { type: 'number', min: 0, default: 0.5, desc: 'Ambient / environment intensity.' },
96
+ exposure: { type: 'number', exclusiveMin: 0, default: 1, desc: 'Tone mapping exposure.' },
97
+ },
98
+ },
99
+ controls: {
100
+ category: 'meta',
101
+ children: [],
102
+ desc: 'How a viewer lets people move the camera: orbit limits, collision with the scene, or a camera driven by page scroll.',
103
+ attrs: {
104
+ mode: { type: 'enum', values: ['orbit', 'scroll', 'none'], default: 'orbit', desc: 'orbit: drag to orbit, pan and zoom. scroll: the first camera follows its scroll-timeline animations and dragging is off. none: a fixed view.' },
105
+ zoom: { type: 'bool', default: true, desc: 'Allow zooming (orbit mode).' },
106
+ pan: { type: 'bool', default: true, desc: 'Allow panning (orbit mode).' },
107
+ minDistance: { type: 'number', ...positive, desc: 'Closest the camera may get to its target.' },
108
+ maxDistance: { type: 'number', ...positive, desc: 'Farthest the camera may get from its target.' },
109
+ minPolar: { type: 'angle', min: 0, max: 180, default: 0, desc: 'Smallest angle between the view from the target and straight up, in degrees (0 = from directly above).' },
110
+ maxPolar: { type: 'angle', min: 0, max: 180, default: 180, desc: 'Largest angle from straight up, in degrees (90 = never below the target).' },
111
+ targetMin: { type: 'vec3', desc: 'Lower corner of the box the orbit target must stay in (limits panning).' },
112
+ targetMax: { type: 'vec3', desc: 'Upper corner of the box the orbit target must stay in.' },
113
+ collide: { type: 'bool', default: false, desc: 'Stop the camera at surfaces instead of letting it pass into objects. Elements with collide="false" are ignored.' },
114
+ },
115
+ check(v, err, el, given = (k) => v[k] !== undefined) {
116
+ if (v.minDistance !== undefined && v.maxDistance !== undefined && v.maxDistance < v.minDistance) err('maxDistance', 'maxDistance must not be smaller than minDistance.', 'E_RANGE');
117
+ if (v.minPolar !== undefined && v.maxPolar !== undefined && v.maxPolar < v.minPolar) err('maxPolar', 'maxPolar must not be smaller than minPolar.', 'E_RANGE');
118
+ if (given('targetMin') !== given('targetMax')) err(given('targetMin') ? 'targetMin' : 'targetMax', 'Give both targetMin and targetMax.', 'E_MISSING_ATTRIBUTE');
119
+ if (v.targetMin && v.targetMax && v.targetMin.some((x, k) => x > v.targetMax[k])) err('targetMax', 'targetMax must not be smaller than targetMin on any axis.', 'E_RANGE');
120
+ },
121
+ },
122
+ material: {
123
+ category: 'resource',
124
+ children: [],
125
+ desc: 'Minimal physically based material (metallic/roughness).',
126
+ attrs: {
127
+ ...ident,
128
+ color: { type: 'color', alias: 'c', default: [0.8, 0.8, 0.8, 1], desc: 'Base colour (sRGB).' },
129
+ metallic: { type: 'number', min: 0, max: 1, default: 0, desc: '0 = dielectric, 1 = metal.' },
130
+ roughness: { type: 'number', min: 0, max: 1, default: 0.5, desc: '0 = mirror, 1 = fully rough.' },
131
+ opacity: { type: 'number', min: 0, max: 1, default: 1, desc: 'Alpha; values below 1 are blended.' },
132
+ emissive: { type: 'color', default: [0, 0, 0, 1], desc: 'Emitted colour.' },
133
+ emissiveIntensity: { type: 'number', min: 0, default: 1, desc: 'Multiplier for emissive.' },
134
+ map: { type: 'texture', desc: 'Base colour texture: image URL or checker(), grid(), stripes(), noise(), bricks().' },
135
+ normalMap: { type: 'normalTexture', desc: 'Normal map: image URL or bumps(), tiles(), noise(), waves(), bricks().' },
136
+ normalScale: { type: 'number', default: 1, desc: 'Normal map strength.' },
137
+ uvScale: { type: 'vec2', default: [1, 1], desc: 'Texture repeat in u and v.' },
138
+ doubleSided: { type: 'bool', default: false, desc: 'Render back faces too.' },
139
+ unlit: { type: 'bool', default: false, desc: 'Ignore lighting; output the base colour.' },
140
+ occlusionMap: { type: 'texture', desc: 'Occlusion texture. The red channel darkens indirect light. Image URL or a procedural texture.' },
141
+ occlusionStrength: { type: 'number', min: 0, max: 1, default: 1, desc: 'Strength of occlusionMap. 0 disables it, 1 uses the texture as stored.' },
142
+ metallicRoughnessMap: { type: 'texture', desc: 'Packed texture: green multiplies roughness, blue multiplies metallic.' },
143
+ emissiveMap: { type: 'texture', desc: 'sRGB texture multiplied with emissive.' },
144
+ alphaMode: { type: 'enum', values: ['auto', 'opaque', 'mask', 'blend'], default: 'auto', desc: 'auto blends when opacity is below 1 and is opaque otherwise. mask discards fragments below alphaCutoff. blend uses opacity as coverage. opaque ignores alpha.' },
145
+ alphaCutoff: { type: 'number', min: 0, max: 1, default: 0.5, desc: 'Mask threshold.' },
146
+ },
147
+ },
148
+ group: { category: 'container', children: [...SCENE_NODES, 'animate'], desc: 'Groups children under a common transform and style.', attrs: { ...node } },
149
+ use: {
150
+ category: 'instance',
151
+ children: ['animate'],
152
+ desc: 'Instantiates the element with the given id (like SVG <use>).',
153
+ attrs: {
154
+ ...node,
155
+ ref: { type: 'ref', ref: 'node', required: true, alias: 'href', desc: 'Id of the element to instantiate.' },
156
+ },
157
+ },
158
+ repeat: {
159
+ category: 'container',
160
+ children: [...SCENE_NODES, 'animate'],
161
+ desc: 'Repeats its children count times; copy i is transformed by step applied i times.',
162
+ attrs: {
163
+ ...node,
164
+ count: { type: 'int', alias: 'n', required: true, min: 0, max: MAX_COPIES, desc: 'Number of copies.' },
165
+ step: { type: 'transform', required: true, desc: 'Transform applied cumulatively between copies.' },
166
+ },
167
+ },
168
+ scatter: {
169
+ category: 'container',
170
+ children: [...SCENE_NODES, 'animate'],
171
+ desc: 'Deterministic pseudo-random or grid placement of its children (seeded mulberry32).',
172
+ attrs: {
173
+ ...node,
174
+ count: { type: 'int', alias: 'n', min: 0, max: MAX_COPIES, desc: 'Number of random copies (random layout).' },
175
+ grid: { type: 'count2', desc: 'Grid layout nx,nz (instead of count).' },
176
+ spacing: { type: 'vec2', default: [1, 1], desc: 'Grid cell size x,z.' },
177
+ area: { type: 'size2', desc: 'Random layout: rectangle width,depth centred at the origin (XZ plane).' },
178
+ radius: { type: 'number', ...positive, desc: 'Random layout: disc radius (XZ plane).' },
179
+ seed: { type: 'int', default: 1, min: 0, desc: 'Random seed.' },
180
+ jitter: { type: 'number', min: 0, max: 1, default: 0, desc: 'Grid layout: random offset as a fraction of the cell.' },
181
+ randomScale: { type: 'range3', desc: 'Per-copy scale: a range (uniform, e.g. 0.8..1.2) or x,y,z ranges.' },
182
+ randomRotation: { type: 'range3', uniform: false, desc: 'Per-copy Euler ranges in degrees, e.g. 0,0..360,0.' },
183
+ randomMaterial: { type: 'idlist', ref: 'material', desc: 'Per-copy material chosen from this list of ids.' },
184
+ },
185
+ check(v, err, el, given = (k) => v[k] !== undefined) {
186
+ const hasGrid = given('grid');
187
+ const hasCount = given('count');
188
+ if (hasGrid && hasCount) err('grid', 'Give either count (random layout) or grid (grid layout), not both.', 'E_CONFLICTING_ATTRIBUTES');
189
+ else if (!hasGrid && !hasCount) err(null, 'scatter needs count="…" (random layout) or grid="nx,nz" (grid layout).', 'E_MISSING_ATTRIBUTE');
190
+ if (hasCount && !given('area') && !given('radius')) err(null, 'Random scatter needs area="w,d" or radius="r".', 'E_MISSING_ATTRIBUTE');
191
+ if (given('area') && given('radius')) err('radius', 'Give either area or radius, not both.', 'E_CONFLICTING_ATTRIBUTES');
192
+ if (v.area && (v.area[0] < 0 || v.area[1] < 0)) err('area', 'area must not be negative.', 'E_RANGE');
193
+ if (v.randomScale && v.randomScale.ranges.some((r) => r[0] < 0)) err('randomScale', 'Scale ranges must not be negative.', 'E_RANGE');
194
+ },
195
+ },
196
+ lod: {
197
+ category: 'container',
198
+ children: [...SCENE_NODES, 'animate'],
199
+ desc: 'Level of detail switch: child i is shown while the camera distance is below distances[i].',
200
+ attrs: {
201
+ ...node,
202
+ distances: { type: 'numbers', required: true, desc: 'Increasing switch distances; one fewer than the number of children.' },
203
+ },
204
+ check(v, err, el) {
205
+ const d = v.distances;
206
+ if (!d) return;
207
+ for (let i = 1; i < d.length; i++) if (d[i] <= d[i - 1]) err('distances', 'distances must be strictly increasing.', 'E_RANGE');
208
+ if (d.some((x) => x <= 0)) err('distances', 'distances must be positive.', 'E_RANGE');
209
+ const kids = el.children.filter((c) => c.type === 'element' && c.name !== 'animate').length;
210
+ if (kids !== d.length + 1) err('distances', `lod has ${kids} children but ${d.length} distances; it needs exactly one distance fewer than children.`, 'E_LOD_CHILDREN');
211
+ },
212
+ },
213
+ animate: {
214
+ category: 'anim',
215
+ children: [],
216
+ desc: 'Animates a transform of its parent (position, rotation, scale; target and fov of a camera) over time or over page scroll.',
217
+ attrs: {
218
+ attr: { type: 'enum', values: ['position', 'rotation', 'scale', 'target', 'fov'], required: true, desc: 'Animated attribute. target and fov apply to <camera> only.' },
219
+ from: { type: 'animValue', desc: 'Start value (defaults to the parent value).' },
220
+ to: { type: 'animValue', desc: 'End value.' },
221
+ values: { type: 'keyframes', desc: 'Key values separated by semicolons, e.g. 0,1,0; 2,1,0; 2,3,0 (instead of from/to).' },
222
+ keyTimes: { type: 'numbers', desc: 'Progress (0 to 1) at which each of values is reached; evenly spaced when omitted.' },
223
+ along: { type: 'spine', desc: 'Path to move along at constant speed (position and target): x,y,z points, 3D path data or helix().' },
224
+ smooth: { type: 'bool', default: false, desc: 'Pass through values or along points on a smooth (centripetal Catmull-Rom) curve.' },
225
+ dur: { type: 'duration', desc: 'Duration of one cycle, e.g. 4s or 500ms (required unless timeline="scroll").' },
226
+ begin: { type: 'duration', default: 0, min: 0, desc: 'Delay before the animation starts.' },
227
+ repeat: { type: 'repeatCount', default: Infinity, desc: "Number of cycles or 'indefinite'." },
228
+ alternate: { type: 'bool', default: false, desc: 'Play every second cycle backwards.' },
229
+ easing: { type: 'enum', values: ['linear', 'ease-in', 'ease-out', 'ease-in-out'], default: 'linear', desc: 'Timing function.' },
230
+ timeline: { type: 'enum', values: ['time', 'scroll'], default: 'time', desc: 'time: plays by the clock. scroll: progress follows page scroll from 0 to 1 (dur, begin, repeat and alternate are not used).' },
231
+ },
232
+ check(v, err, el, given = (k) => v[k] !== undefined) {
233
+ const sources = ['to', 'values', 'along'].filter((k) => given(k));
234
+ if (!sources.length) err(null, 'animate needs to="…", values="…" or along="…".', 'E_MISSING_ATTRIBUTE');
235
+ else if (sources.length > 1) err(sources[1], `Give only one of to, values and along (found ${sources.join(' and ')}).`, 'E_CONFLICTING_ATTRIBUTES');
236
+ if (given('from') && (given('values') || given('along'))) err('from', 'from is only used with to.', 'E_CONFLICTING_ATTRIBUTES');
237
+ const scroll = v.timeline === 'scroll';
238
+ if (!scroll && !given('dur')) err(null, "animate requires the attribute 'dur' (or timeline=\"scroll\").", 'E_MISSING_ATTRIBUTE');
239
+ if (v.dur !== undefined && v.dur <= 0) err('dur', 'dur must be positive.', 'E_RANGE');
240
+ const parent = el && el.parent;
241
+ if ((v.attr === 'target' || v.attr === 'fov') && parent && parent.name !== 'camera') {
242
+ err('attr', `attr="${v.attr}" can only animate a <camera>.`, 'E_INVALID_VALUE');
243
+ }
244
+ if (v.attr === 'target' && parent && parent.name === 'camera' && parent.attrIndex && parent.attrIndex.rotation) {
245
+ err('attr', 'This camera is oriented by rotation; give it a target instead to animate the target.', 'E_CONFLICTING_ATTRIBUTES');
246
+ }
247
+ if (given('along') && v.attr && v.attr !== 'position' && v.attr !== 'target') err('along', 'along can only move position or target.', 'E_INVALID_VALUE');
248
+ const n = v.attr === 'scale' ? [1, 3] : v.attr === 'fov' ? [1, 1] : [3, 3];
249
+ const need = `${v.attr || 'value'} animation values need ${n[0] === n[1] ? n[0] : '1 or 3'} number${n[1] === 1 ? '' : 's'}`;
250
+ for (const k of ['from', 'to']) {
251
+ if (v[k] !== undefined && !n.includes(v[k].length)) err(k, `${need}.`, 'E_TYPE');
252
+ }
253
+ if (v.values) {
254
+ if (v.values.length < 2) err('values', 'values needs at least 2 key values.', 'E_RANGE');
255
+ const bad = v.values.findIndex((x) => !n.includes(x.length));
256
+ if (bad >= 0) err('values', `${need}; key value ${bad + 1} has ${v.values[bad].length}.`, 'E_TYPE');
257
+ }
258
+ if (given('keyTimes') && !given('values')) err('keyTimes', 'keyTimes needs values.', 'E_MISSING_ATTRIBUTE');
259
+ const kt = v.keyTimes;
260
+ if (kt && v.values) {
261
+ if (kt.length !== v.values.length) err('keyTimes', `keyTimes has ${kt.length} entries but values has ${v.values.length}.`, 'E_RANGE');
262
+ else if (kt[0] !== 0 || kt[kt.length - 1] !== 1) err('keyTimes', 'keyTimes must start at 0 and end at 1.', 'E_RANGE');
263
+ else if (kt.some((t, i) => i > 0 && t < kt[i - 1])) err('keyTimes', 'keyTimes must not decrease.', 'E_RANGE');
264
+ }
265
+ },
266
+ },
267
+ camera: {
268
+ category: 'camera',
269
+ children: ['animate'],
270
+ desc: 'Perspective or orthographic camera. The first camera is the initial view.',
271
+ attrs: {
272
+ ...ident,
273
+ transform: placement.transform,
274
+ position: { type: 'vec3', alias: 'p', default: [0, 0, 10], desc: 'Eye position.' },
275
+ rotation: { type: 'vec3', alias: 'rot', desc: 'Euler orientation (used when no target is given; the camera looks down -Z).' },
276
+ target: { type: 'vec3', desc: 'Point the camera looks at.' },
277
+ up: { type: 'vec3', default: [0, 1, 0], desc: 'Up vector.' },
278
+ type: { type: 'enum', values: ['perspective', 'orthographic'], default: 'perspective', desc: 'Projection.' },
279
+ fov: { type: 'angle', min: 1, max: 179, default: 45, desc: 'Vertical field of view in degrees (perspective).' },
280
+ height: { type: 'number', ...positive, default: 10, desc: 'Visible height (orthographic).' },
281
+ near: { type: 'number', ...positive, desc: 'Near clip distance (automatic when omitted).' },
282
+ far: { type: 'number', ...positive, desc: 'Far clip distance (automatic when omitted).' },
283
+ aspect: { type: 'number', ...positive, default: 1, desc: 'Width / height of the view the author framed (e.g. 1.778 for 16:9). fov is the vertical angle at this aspect ratio.' },
284
+ fit: { type: 'enum', values: ['contain', 'cover', 'width', 'height'], default: 'contain', desc: 'How the framed view adapts to other screens: contain keeps all of it visible, cover fills the screen, width and height keep that extent fixed.' },
285
+ },
286
+ check(v, err) {
287
+ if (v.near !== undefined && v.far !== undefined && v.far <= v.near) err('far', 'far must be greater than near.', 'E_RANGE');
288
+ if (v.target !== undefined && v.rotation !== undefined) err('rotation', 'Give either target or rotation, not both.', 'E_CONFLICTING_ATTRIBUTES');
289
+ },
290
+ },
291
+ light: {
292
+ category: 'light',
293
+ children: ['animate'],
294
+ desc: 'Directional, point, spot, ambient or hemisphere light.',
295
+ attrs: {
296
+ ...ident,
297
+ transform: placement.transform,
298
+ position: { type: 'vec3', alias: 'p', default: [0, 0, 0], desc: 'Light position (point/spot).' },
299
+ rotation: placement.rotation,
300
+ visible: placement.visible,
301
+ type: { type: 'enum', values: ['directional', 'point', 'spot', 'ambient', 'hemisphere'], required: true, desc: 'Light kind.' },
302
+ color: { type: 'color', alias: 'c', default: [1, 1, 1, 1], desc: 'Light colour.' },
303
+ intensity: { type: 'number', min: 0, default: 1, desc: 'Intensity multiplier.' },
304
+ direction: { type: 'vec3', alias: 'dir', desc: 'Direction the light travels (directional/spot).' },
305
+ target: { type: 'vec3', desc: 'Point the light aims at (alternative to direction).' },
306
+ range: { type: 'number', min: 0, default: 0, desc: 'Point/spot cut-off distance (0 = unlimited).' },
307
+ angle: { type: 'angle', min: 1, max: 179, default: 45, desc: 'Spot cone angle in degrees.' },
308
+ penumbra: { type: 'number', min: 0, max: 1, default: 0.2, desc: 'Spot edge softness.' },
309
+ groundColor: { type: 'color', default: [0.25, 0.22, 0.2, 1], desc: 'Hemisphere light colour from below.' },
310
+ },
311
+ check(v, err) {
312
+ if (v.direction && Math.hypot(...v.direction) < 1e-12) err('direction', 'direction must not be the zero vector.', 'E_RANGE');
313
+ if (v.direction !== undefined && v.target !== undefined) err('target', 'Give either direction or target, not both.', 'E_CONFLICTING_ATTRIBUTES');
314
+ },
315
+ },
316
+
317
+ // ---- shapes -------------------------------------------------------------
318
+ box: {
319
+ category: 'shape',
320
+ children: ['animate'],
321
+ desc: 'Axis aligned box centred at the origin, optionally with rounded edges.',
322
+ attrs: {
323
+ ...node,
324
+ size: { type: 'size3', alias: 's', default: [1, 1, 1], desc: 'Edge lengths (one value for a cube).' },
325
+ radius: { type: 'number', alias: 'r', min: 0, default: 0, desc: 'Edge rounding radius.' },
326
+ segments: { type: 'int', min: 1, max: 32, desc: 'Rounding segments (automatic when omitted).' },
327
+ },
328
+ check(v, err) {
329
+ if (v.size && v.size.some((x) => x <= 0)) err('size', 'size must be positive.', 'E_RANGE');
330
+ if (v.size && v.radius > 0 && v.radius * 2 > Math.min(...v.size) + 1e-12) err('radius', `radius ${v.radius} is larger than half the smallest edge (${Math.min(...v.size) / 2}).`, 'E_RANGE');
331
+ },
332
+ },
333
+ sphere: {
334
+ category: 'shape',
335
+ children: ['animate'],
336
+ desc: 'Sphere centred at the origin.',
337
+ attrs: {
338
+ ...node,
339
+ radius: { type: 'number', alias: 'r', ...positive, default: 0.5, desc: 'Radius.' },
340
+ segments: { type: 'int', min: 3, max: 512, desc: 'Fixed tessellation (automatic level of detail when omitted).' },
341
+ },
342
+ },
343
+ cylinder: {
344
+ category: 'shape',
345
+ children: ['animate'],
346
+ desc: 'Cylinder (or truncated cone) along Y, centred at the origin.',
347
+ attrs: {
348
+ ...node,
349
+ radius: { type: 'number', alias: 'r', ...positive, default: 0.5, desc: 'Bottom radius (and top radius unless radiusTop is set).' },
350
+ radiusTop: { type: 'number', min: 0, desc: 'Top radius.' },
351
+ height: { type: 'number', alias: 'h', ...positive, default: 1, desc: 'Height.' },
352
+ segments: { type: 'int', min: 3, max: 512, desc: 'Fixed number of sides, e.g. 6 for a hexagonal prism.' },
353
+ caps: { type: 'bool', default: true, desc: 'Close the ends.' },
354
+ },
355
+ },
356
+ cone: {
357
+ category: 'shape',
358
+ children: ['animate'],
359
+ desc: 'Cone along Y with its base at -height/2 and apex at +height/2.',
360
+ attrs: {
361
+ ...node,
362
+ radius: { type: 'number', alias: 'r', ...positive, default: 0.5, desc: 'Base radius.' },
363
+ height: { type: 'number', alias: 'h', ...positive, default: 1, desc: 'Height.' },
364
+ segments: { type: 'int', min: 3, max: 512, desc: 'Fixed number of sides.' },
365
+ caps: { type: 'bool', default: true, desc: 'Close the base.' },
366
+ },
367
+ },
368
+ capsule: {
369
+ category: 'shape',
370
+ children: ['animate'],
371
+ desc: 'Capsule along Y; height is the total height including both caps.',
372
+ attrs: {
373
+ ...node,
374
+ radius: { type: 'number', alias: 'r', ...positive, default: 0.25, desc: 'Radius.' },
375
+ height: { type: 'number', alias: 'h', ...positive, default: 1, desc: 'Total height (at least 2 × radius).' },
376
+ segments: { type: 'int', min: 3, max: 512, desc: 'Fixed tessellation.' },
377
+ },
378
+ check(v, err) {
379
+ const r = v.radius ?? 0.25;
380
+ const h = v.height ?? 1;
381
+ if (h < 2 * r - 1e-12) err('height', `capsule height (${h}) must be at least twice the radius (${2 * r}).`, 'E_RANGE');
382
+ },
383
+ },
384
+ plane: {
385
+ category: 'shape',
386
+ children: ['animate'],
387
+ desc: 'Flat rectangle in the XZ plane facing +Y (rendered double sided).',
388
+ attrs: {
389
+ ...node,
390
+ size: { type: 'size2', alias: 's', default: [1, 1], desc: 'Width (x) and depth (z).' },
391
+ },
392
+ check(v, err) {
393
+ if (v.size && v.size.some((x) => x <= 0)) err('size', 'size must be positive.', 'E_RANGE');
394
+ },
395
+ },
396
+ torus: {
397
+ category: 'shape',
398
+ children: ['animate'],
399
+ desc: 'Torus around the Y axis.',
400
+ attrs: {
401
+ ...node,
402
+ radius: { type: 'number', alias: 'r', ...positive, default: 0.35, desc: 'Distance from the centre to the tube centre.' },
403
+ tube: { type: 'number', ...positive, default: 0.15, desc: 'Tube radius.' },
404
+ arc: { type: 'angle', exclusiveMin: 0, max: 360, default: 360, desc: 'Swept angle in degrees (partial torus / pipe elbow).' },
405
+ segments: { type: 'int', min: 3, max: 1024, desc: 'Fixed segments around the ring.' },
406
+ tubeSegments: { type: 'int', min: 3, max: 512, desc: 'Fixed segments around the tube.' },
407
+ },
408
+ },
409
+ line: {
410
+ category: 'shape',
411
+ children: ['animate'],
412
+ desc: 'Straight line segment; rendered as a tube when radius > 0.',
413
+ attrs: {
414
+ ...node,
415
+ from: { type: 'vec3', required: true, desc: 'Start point.' },
416
+ to: { type: 'vec3', required: true, desc: 'End point.' },
417
+ radius: { type: 'number', alias: 'r', min: 0, default: 0, desc: 'Tube radius (0 = hairline).' },
418
+ segments: { type: 'int', min: 3, max: 256, desc: 'Tube sides.' },
419
+ },
420
+ check(v, err) {
421
+ if (v.from && v.to && Math.hypot(v.to[0] - v.from[0], v.to[1] - v.from[1], v.to[2] - v.from[2]) < 1e-12) {
422
+ err('to', 'A line needs two distinct points.', 'E_DEGENERATE');
423
+ }
424
+ },
425
+ },
426
+ polyline: {
427
+ category: 'shape',
428
+ children: ['animate'],
429
+ desc: '3D polyline or 2D path outline; rendered as a tube when radius > 0.',
430
+ attrs: {
431
+ ...node,
432
+ points: { type: 'points3', desc: 'x,y,z x,y,z …' },
433
+ d: { type: 'path2d', desc: 'Outline of 2D path data in the XY plane (alternative to points).' },
434
+ closed: { type: 'bool', default: false, desc: 'Connect the last point to the first.' },
435
+ smooth: { type: 'bool', default: false, desc: 'Interpolate the points with a Catmull-Rom spline.' },
436
+ radius: { type: 'number', alias: 'r', min: 0, default: 0, desc: 'Tube radius (0 = hairline).' },
437
+ segments: { type: 'int', min: 3, max: 256, desc: 'Tube sides.' },
438
+ },
439
+ check(v, err, el, given = (k) => v[k] !== undefined) {
440
+ if (!given('points') && !given('d')) err(null, 'polyline needs points="…" or d="…".', 'E_MISSING_ATTRIBUTE');
441
+ if (given('points') && given('d')) err('d', 'Give either points or d, not both.', 'E_CONFLICTING_ATTRIBUTES');
442
+ if (v.points && v.points.length < 6) err('points', 'A polyline needs at least 2 points.', 'E_DEGENERATE');
443
+ },
444
+ },
445
+ polygon: {
446
+ category: 'shape',
447
+ children: ['animate'],
448
+ desc: 'Filled flat polygon in the XY plane (rendered double sided).',
449
+ attrs: {
450
+ ...node,
451
+ points: { type: 'points2', required: true, desc: 'x,y x,y … (at least 3 points).' },
452
+ },
453
+ check(v, err) {
454
+ if (v.points && v.points.length < 6) err('points', 'A polygon needs at least 3 points.', 'E_DEGENERATE');
455
+ },
456
+ },
457
+ path: {
458
+ category: 'shape',
459
+ children: ['animate'],
460
+ desc: 'Filled flat 2D path in the XY plane (rendered double sided). In <defs> it is a reusable outline.',
461
+ attrs: { ...node, ...pathSource },
462
+ check: requirePathSource,
463
+ },
464
+ extrude: {
465
+ category: 'shape',
466
+ children: ['animate'],
467
+ desc: 'Extrudes a 2D path along +Z, optionally with a rounded bevel.',
468
+ attrs: {
469
+ ...node,
470
+ ...pathSource,
471
+ depth: { type: 'number', ...positive, default: 1, desc: 'Extrusion depth along Z.' },
472
+ bevel: { type: 'number', min: 0, default: 0, desc: 'Bevel radius on the front and back edges.' },
473
+ bevelSegments: { type: 'int', min: 1, max: 16, default: 3, desc: 'Segments per bevel.' },
474
+ center: { type: 'bool', default: false, desc: 'Centre the extrusion on z = 0.' },
475
+ },
476
+ check(v, err, el, given) {
477
+ requirePathSource(v, err, el, given);
478
+ const depth = v.depth ?? 1;
479
+ if (v.bevel && v.bevel * 2 > depth + 1e-12) err('bevel', `bevel (${v.bevel}) must be at most half the depth (${depth / 2}).`, 'E_RANGE');
480
+ },
481
+ },
482
+ revolve: {
483
+ category: 'shape',
484
+ children: ['animate'],
485
+ desc: 'Revolves (lathes) a 2D profile around the Y axis; x is the distance from the axis.',
486
+ attrs: {
487
+ ...node,
488
+ ...pathSource,
489
+ angle: { type: 'angle', exclusiveMin: 0, max: 360, default: 360, desc: 'Revolution angle in degrees.' },
490
+ segments: { type: 'int', min: 3, max: 1024, desc: 'Fixed number of radial segments.' },
491
+ },
492
+ check: requirePathSource,
493
+ },
494
+ sweep: {
495
+ category: 'shape',
496
+ children: ['animate'],
497
+ desc: 'Sweeps a profile (circle or 2D path) along a 3D spine with rotation minimising frames.',
498
+ attrs: {
499
+ ...node,
500
+ ...pathSource,
501
+ radius: { type: 'number', alias: 'r', ...positive, desc: 'Circular profile radius (alternative to d/path).' },
502
+ along: { type: 'spine', required: true, desc: 'Spine: x,y,z points, 3D path data (M L C S Q T Z) or helix(r,pitch,turns).' },
503
+ smooth: { type: 'bool', default: false, desc: 'Interpolate spine points with a Catmull-Rom spline.' },
504
+ twist: { type: 'angle', default: 0, desc: 'Profile rotation in degrees from start to end.' },
505
+ taper: { type: 'number', min: 0, default: 1, desc: 'Profile scale at the end (1 = constant).' },
506
+ caps: { type: 'bool', default: true, desc: 'Close open ends.' },
507
+ segments: { type: 'int', min: 3, max: 256, desc: 'Circle profile sides.' },
508
+ },
509
+ check(v, err, el, given = (k) => v[k] !== undefined) {
510
+ const hasPath = given('d') || given('path');
511
+ if (!hasPath && !given('radius')) err(null, 'sweep needs a profile: radius="…" or d="…" / path="…".', 'E_MISSING_ATTRIBUTE');
512
+ if (hasPath && given('radius')) err('radius', 'Give either radius or a path profile, not both.', 'E_CONFLICTING_ATTRIBUTES');
513
+ if (given('d') && given('path')) err('path', 'Give either d or path, not both.', 'E_CONFLICTING_ATTRIBUTES');
514
+ },
515
+ },
516
+ gear: {
517
+ category: 'shape',
518
+ children: ['animate'],
519
+ desc: 'Involute spur gear in the XY plane, extruded along Z.',
520
+ attrs: {
521
+ ...node,
522
+ teeth: { type: 'int', min: 5, max: 400, default: 24, desc: 'Number of teeth.' },
523
+ module: { type: 'number', ...positive, default: 0.04, desc: 'Module: pitch diameter / teeth.' },
524
+ pressureAngle: { type: 'angle', min: 10, max: 35, default: 20, desc: 'Pressure angle in degrees.' },
525
+ depth: { type: 'number', ...positive, default: 0.2, desc: 'Face width (extrusion depth).' },
526
+ bore: { type: 'number', min: 0, default: 0, desc: 'Radius of the centre hole.' },
527
+ bevel: { type: 'number', min: 0, default: 0, desc: 'Edge bevel radius.' },
528
+ center: { type: 'bool', default: true, desc: 'Centre the gear on z = 0.' },
529
+ },
530
+ check(v, err) {
531
+ const teeth = v.teeth ?? 24;
532
+ const m = v.module ?? 0.04;
533
+ const root = (m * teeth) / 2 - 1.25 * m;
534
+ if (v.bore && v.bore >= root * 0.95) err('bore', `bore (${v.bore}) must be smaller than the root radius (${(root * 0.95).toFixed(4)}).`, 'E_RANGE');
535
+ const depth = v.depth ?? 0.2;
536
+ if (v.bevel && v.bevel * 2 > depth + 1e-12) err('bevel', `bevel must be at most half the depth (${depth / 2}).`, 'E_RANGE');
537
+ },
538
+ },
539
+ mesh: {
540
+ category: 'shape',
541
+ children: ['animate'],
542
+ desc: 'Explicit indexed triangle mesh (escape hatch for data that is not procedural).',
543
+ attrs: {
544
+ ...node,
545
+ positions: { type: 'floats', required: true, desc: 'x y z for every vertex.' },
546
+ indices: { type: 'indices', desc: 'Triangle vertex indices (3 per triangle); omitted = non-indexed triangles.' },
547
+ normals: { type: 'floats', desc: 'Optional x y z normal per vertex (computed when omitted).' },
548
+ uvs: { type: 'floats', desc: 'Optional u v per vertex.' },
549
+ smooth: { type: 'angle', min: 0, max: 180, default: 30, desc: 'Crease angle for computed normals (0 = flat).' },
550
+ },
551
+ check(v, err) {
552
+ const p = v.positions;
553
+ if (!p) return;
554
+ if (p.length % 3 !== 0) err('positions', `positions needs 3 numbers per vertex; found ${p.length} numbers.`, 'E_MESH');
555
+ const nv = Math.floor(p.length / 3);
556
+ if (nv < 3) err('positions', 'A mesh needs at least 3 vertices.', 'E_MESH');
557
+ if (v.indices) {
558
+ if (v.indices.length % 3 !== 0) err('indices', `indices must contain 3 entries per triangle; found ${v.indices.length}.`, 'E_MESH');
559
+ for (let i = 0; i < v.indices.length; i++) {
560
+ if (v.indices[i] >= nv) {
561
+ err('indices', `Index ${v.indices[i]} at position ${i} is out of range (the mesh has ${nv} vertices).`, 'E_MESH');
562
+ break;
563
+ }
564
+ }
565
+ } else if (nv % 3 !== 0) err('positions', 'Without indices the vertex count must be a multiple of 3.', 'E_MESH');
566
+ if (v.normals && v.normals.length !== p.length) err('normals', `normals must have one x y z per vertex (${p.length} numbers); found ${v.normals.length}.`, 'E_MESH');
567
+ if (v.uvs && v.uvs.length !== nv * 2) err('uvs', `uvs must have one u v per vertex (${nv * 2} numbers); found ${v.uvs.length}.`, 'E_MESH');
568
+ for (let i = 0; i < p.length; i++) {
569
+ if (!Number.isFinite(p[i])) {
570
+ err('positions', `Non-finite coordinate at position ${i}.`, 'E_MESH');
571
+ break;
572
+ }
573
+ }
574
+ },
575
+ },
576
+ };
577
+
578
+ /** Elements accepted as the root (v3d is a legacy alias). */
579
+ export const ROOT_ELEMENTS = ['es3d', 'v3d'];
580
+
581
+ // alias tables --------------------------------------------------------------
582
+ const aliasCache = new Map();
583
+
584
+ /** Map alias -> canonical attribute name for an element. */
585
+ export function aliasesFor(elementName) {
586
+ let m = aliasCache.get(elementName);
587
+ if (m) return m;
588
+ m = new Map();
589
+ const spec = ELEMENTS[elementName === 'v3d' ? 'es3d' : elementName];
590
+ if (spec) {
591
+ for (const [name, a] of Object.entries(spec.attrs)) {
592
+ m.set(name, name);
593
+ if (a.alias) m.set(a.alias, name);
594
+ }
595
+ }
596
+ aliasCache.set(elementName, m);
597
+ return m;
598
+ }
599
+
600
+ export function elementSpec(name) {
601
+ return ELEMENTS[name === 'v3d' ? 'es3d' : name] || null;
602
+ }
603
+
604
+ export function attrSpec(elementName, canonical) {
605
+ const e = elementSpec(elementName);
606
+ return e ? e.attrs[canonical] || null : null;
607
+ }
608
+
609
+ /** Typed value of an attribute, falling back to the schema default. */
610
+ export function valueOf(el, name) {
611
+ const v = el.values ? el.values[name] : undefined;
612
+ if (v !== undefined) return v;
613
+ const a = attrSpec(el.name, name);
614
+ return a ? a.default : undefined;
615
+ }
616
+
617
+ export const ALL_ELEMENT_NAMES = Object.keys(ELEMENTS);