@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,645 +1,645 @@
1
- // Geometry registry: turns shape elements into geometry descriptors.
2
- //
3
- // A descriptor captures everything needed to (lazily) produce triangles:
4
- // key cache key built only from shape-defining parameters; elements
5
- // that differ only in size share the same mesh (see `unit`)
6
- // unit matrix from the canonical mesh space into element space
7
- // lods number of tessellation levels (1 = no level of detail)
8
- // kind 'triangles' | 'lines'
9
- // doubleSided render back faces (flat or open surfaces)
10
- // bounds conservative canonical bounds, known without tessellating
11
- // generate(lod, quality, report) -> Mesh
12
- // identity (set here, built-in shapes only) the key with the full text of
13
- // every input it hashes: what the persistent geometry cache
14
- // names its records by, so that two shapes whose short keys
15
- // collide can never share a cached mesh
16
- //
17
- // New element types can be added with registerGeometry(); this is the
18
- // extension point that keeps the core format small.
19
-
20
- import * as mat4 from '../math/mat4.js';
21
- import { valueOf } from '../format/schema.js';
22
- import { pathControlBounds } from '../path/path-parser.js';
23
- import { flattenPath, circleContour } from '../path/path-flatten.js';
24
- import { buildRegions } from '../path/shapes.js';
25
- import { unitBox, roundedBox, unitSphere, unitCylinder, unitCapsule, unitPlane, unitTorus, lineMesh, lodSegments } from './primitives.js';
26
- import {
27
- sphereDeviation, cylinderDeviation, capsuleDeviation, torusDeviation, roundedBoxDeviation,
28
- gearDeviation, pathFlattenDeviation, bevelDeviation, circleSagitta,
29
- } from './lod-error.js';
30
- import { flatMesh, extrudeMesh } from './extrude.js';
31
- import { revolveMesh } from './revolve.js';
32
- import { sweepMesh, spinePolylines, catmullRom } from './sweep.js';
33
- import { gearOutline } from './gear.js';
34
- import { Mesh, computeNormals } from './mesh.js';
35
-
36
- export const LOD_COUNT = 4;
37
-
38
- function lodList(fn) {
39
- const a = new Array(LOD_COUNT);
40
- for (let lod = 0; lod < LOD_COUNT; lod++) a[lod] = fn(lod);
41
- return a;
42
- }
43
-
44
- function segsAt(base, min, fixed) {
45
- return fixed ? [fixed] : lodList((lod) => lodSegments(base, lod, 1, min));
46
- }
47
-
48
- const registry = new Map();
49
- // shapes defined in this module; filled at the end of it. A replacement
50
- // registered later loses the identity (its key may hash anything)
51
- let builtins = null;
52
-
53
- export function registerGeometry(name, describe) {
54
- registry.set(name, describe);
55
- if (builtins) builtins.delete(name);
56
- }
57
-
58
- export function hasGeometry(name) {
59
- return registry.has(name);
60
- }
61
-
62
- // raw inputs hashed into the key of the descriptor being built, or null
63
- // when it cannot have an identity (keyed by a hash of mesh data)
64
- let inputs = null;
65
-
66
- /** @returns {object|null} descriptor */
67
- export function describeShape(el, ctx) {
68
- const fn = registry.get(el.name);
69
- if (!fn) return null;
70
- inputs = builtins && builtins.has(el.name) ? [] : null;
71
- try {
72
- const desc = fn(el, ctx);
73
- if (desc && inputs) desc.identity = [desc.key, ...inputs].map((t) => `${t.length}:${t}`).join('');
74
- return desc;
75
- } finally {
76
- inputs = null;
77
- }
78
- }
79
-
80
- const box3 = (minX, minY, minZ, maxX, maxY, maxZ) => ({ min: [minX, minY, minZ], max: [maxX, maxY, maxZ] });
81
- const S = (x, y, z) => mat4.fromScaling(mat4.createF64(), x, y, z);
82
-
83
- function hashString(s) {
84
- if (inputs) inputs.push(s);
85
- // FNV-1a, two lanes for a 64-bit-ish key
86
- let h1 = 0x811c9dc5, h2 = 0x01000193;
87
- for (let i = 0; i < s.length; i++) {
88
- const c = s.charCodeAt(i);
89
- h1 = Math.imul(h1 ^ c, 0x01000193);
90
- h2 = Math.imul(h2 ^ c, 0x5bd1e995);
91
- }
92
- return (h1 >>> 0).toString(36) + (h2 >>> 0).toString(36) + ':' + s.length;
93
- }
94
-
95
- /** Hash of a typed array's contents (two 32-bit lanes + length). */
96
- function hashTyped(a) {
97
- // mesh data: hashing all of it for a persistent name would cost more than
98
- // the mesh does to build, so such shapes have no identity
99
- inputs = null;
100
- if (!a) return '';
101
- const w = new Uint32Array(a.buffer, a.byteOffset, a.byteLength >> 2);
102
- let h1 = 0x9747b28c, h2 = 0x01000193;
103
- for (let i = 0; i < w.length; i++) {
104
- let k = Math.imul(w[i], 0xcc9e2d51);
105
- k = Math.imul((k << 15) | (k >>> 17), 0x1b873593);
106
- h1 ^= k;
107
- h1 = (Math.imul((h1 << 13) | (h1 >>> 19), 5) + 0xe6546b64) | 0;
108
- h2 = Math.imul(h2 ^ w[i], 0x85ebca6b);
109
- h2 ^= h2 >>> 13;
110
- }
111
- return (h1 >>> 0).toString(36) + (h2 >>> 0).toString(36) + ':' + a.length;
112
- }
113
-
114
- function rawOf(el, name) {
115
- const a = el.attrIndex && el.attrIndex[name];
116
- return a ? a.value.trim() : '';
117
- }
118
-
119
- /** Path data of a path-consuming element (inline d or referenced <path>). */
120
- function pathSource(el) {
121
- if (el.values.d) return { path: el.values.d, raw: rawOf(el, 'd'), fillRule: valueOf(el, 'fillRule') };
122
- const t = el.pathTarget;
123
- if (t && t.values && t.values.d) {
124
- const rule = el.values.fillRule !== undefined ? el.values.fillRule : valueOf(t, 'fillRule');
125
- return { path: t.values.d, raw: rawOf(t, 'd'), fillRule: rule };
126
- }
127
- return null;
128
- }
129
-
130
- function pathDiag(segments) {
131
- const b = pathControlBounds(segments);
132
- return Math.hypot(b.maxX - b.minX, b.maxY - b.minY) || 1;
133
- }
134
-
135
- function pathTolerance(segments, lod, quality) {
136
- return pathFlattenDeviation(pathDiag(segments), lod) / quality;
137
- }
138
-
139
- function subpathsOpen(segments) {
140
- let open = false;
141
- let inSub = false;
142
- let closed = false;
143
- for (const s of segments) {
144
- if (s.c === 'M') {
145
- if (inSub && !closed) open = true;
146
- inSub = true;
147
- closed = false;
148
- } else if (s.c === 'Z') closed = true;
149
- }
150
- if (inSub && !closed) open = true;
151
- return open;
152
- }
153
-
154
- function regionsFor(src, lod, quality, report) {
155
- const contours = flattenPath(src.path.segments, { tolerance: pathTolerance(src.path.segments, lod, quality) });
156
- for (const c of contours) {
157
- if (c.degenerate && report) report('warning', 'W_DEGENERATE_CONTOUR', 'A subpath has fewer than three distinct points and encloses no area.');
158
- }
159
- const shape = buildRegions(contours, src.fillRule);
160
- if (!shape.regions.length && report) report('warning', 'W_EMPTY_FILL', 'The path encloses no area; nothing is filled.');
161
- return shape;
162
- }
163
-
164
- // --- primitives --------------------------------------------------------------
165
-
166
- registerGeometry('box', (el) => {
167
- const size = valueOf(el, 'size');
168
- const r = valueOf(el, 'radius');
169
- if (r > 0) {
170
- const fixed = el.values.segments;
171
- return {
172
- key: `rbox:${size.join(',')}:${r}:${fixed || ''}`,
173
- unit: null,
174
- lods: fixed ? 1 : LOD_COUNT,
175
- kind: 'triangles',
176
- doubleSided: false,
177
- bounds: box3(-size[0] / 2, -size[1] / 2, -size[2] / 2, size[0] / 2, size[1] / 2, size[2] / 2),
178
- lodDeviation: segsAt(4, 1, fixed).map((n) => roundedBoxDeviation(r, n)),
179
- generate: (lod, q) => roundedBox(size[0], size[1], size[2], r, fixed || lodSegments(4, lod, q, 1)),
180
- };
181
- }
182
- return {
183
- key: 'box',
184
- unit: S(size[0], size[1], size[2]),
185
- lods: 1,
186
- kind: 'triangles',
187
- doubleSided: false,
188
- bounds: box3(-0.5, -0.5, -0.5, 0.5, 0.5, 0.5),
189
- lodDeviation: [0],
190
- generate: () => unitBox(),
191
- };
192
- });
193
-
194
- registerGeometry('sphere', (el) => {
195
- const r = valueOf(el, 'radius');
196
- const fixed = el.values.segments;
197
- return {
198
- key: `sphere:${fixed || ''}`,
199
- unit: S(r, r, r),
200
- lods: fixed ? 1 : LOD_COUNT,
201
- kind: 'triangles',
202
- doubleSided: false,
203
- bounds: box3(-1, -1, -1, 1, 1, 1),
204
- lodDeviation: segsAt(48, 8, fixed).map(sphereDeviation),
205
- generate: (lod, q) => unitSphere(fixed || lodSegments(48, lod, q, 8)),
206
- };
207
- });
208
-
209
- function cylinderLike(el, top) {
210
- const r = valueOf(el, 'radius');
211
- const h = valueOf(el, 'height');
212
- const caps = valueOf(el, 'caps');
213
- const fixed = el.values.segments;
214
- const t = top / r;
215
- const m = Math.max(1, t);
216
- return {
217
- key: `cyl:${t}:${caps}:${fixed || ''}`,
218
- unit: S(r, h, r),
219
- lods: fixed ? 1 : LOD_COUNT,
220
- kind: 'triangles',
221
- doubleSided: !caps,
222
- bounds: box3(-m, -0.5, -m, m, 0.5, m),
223
- lodDeviation: segsAt(48, 8, fixed).map((n) => cylinderDeviation(t, n)),
224
- generate: (lod, q) => unitCylinder(t, fixed || lodSegments(48, lod, q, 8), caps),
225
- };
226
- }
227
-
228
- registerGeometry('cylinder', (el) => {
229
- const r = valueOf(el, 'radius');
230
- const top = el.values.radiusTop !== undefined ? el.values.radiusTop : r;
231
- return cylinderLike(el, top);
232
- });
233
-
234
- registerGeometry('cone', (el) => cylinderLike(el, 0));
235
-
236
- registerGeometry('capsule', (el) => {
237
- const r = valueOf(el, 'radius');
238
- const h = valueOf(el, 'height');
239
- const L = Math.max(0, h / r - 2);
240
- const fixed = el.values.segments;
241
- return {
242
- key: `capsule:${L}:${fixed || ''}`,
243
- unit: S(r, r, r),
244
- lods: fixed ? 1 : LOD_COUNT,
245
- kind: 'triangles',
246
- doubleSided: false,
247
- bounds: box3(-1, -(L / 2 + 1), -1, 1, L / 2 + 1, 1),
248
- lodDeviation: segsAt(32, 8, fixed).map(capsuleDeviation),
249
- generate: (lod, q) => unitCapsule(L, fixed || lodSegments(32, lod, q, 8)),
250
- };
251
- });
252
-
253
- registerGeometry('plane', (el) => {
254
- const size = valueOf(el, 'size');
255
- return {
256
- key: 'plane',
257
- unit: S(size[0], 1, size[1]),
258
- lods: 1,
259
- kind: 'triangles',
260
- doubleSided: true,
261
- bounds: box3(-0.5, 0, -0.5, 0.5, 0, 0.5),
262
- lodDeviation: [0],
263
- generate: () => unitPlane(),
264
- };
265
- });
266
-
267
- registerGeometry('torus', (el) => {
268
- const R = valueOf(el, 'radius');
269
- const tube = valueOf(el, 'tube');
270
- const arc = valueOf(el, 'arc');
271
- const t = tube / R;
272
- const fs = el.values.segments;
273
- const ft = el.values.tubeSegments;
274
- return {
275
- key: `torus:${t}:${arc}:${fs || ''}:${ft || ''}`,
276
- unit: S(R, R, R),
277
- lods: fs && ft ? 1 : LOD_COUNT,
278
- kind: 'triangles',
279
- doubleSided: false,
280
- bounds: box3(-(1 + t), -t, -(1 + t), 1 + t, t, 1 + t),
281
- lodDeviation: (fs && ft ? [0] : lodList((lod) => 0)).map((_, lod) => torusDeviation(t, arc, fs || lodSegments(64, lod, 1, 8), ft || lodSegments(24, lod, 1, 6))),
282
- generate: (lod, q) => unitTorus(t, arc, fs || lodSegments(64, lod, q, 8), ft || lodSegments(24, lod, q, 6)),
283
- };
284
- });
285
-
286
- // --- curves --------------------------------------------------------------------
287
-
288
- function pointsBounds(pts, dim, pad = 0) {
289
- const b = box3(Infinity, Infinity, Infinity, -Infinity, -Infinity, -Infinity);
290
- for (let i = 0; i < pts.length; i += dim) {
291
- for (let k = 0; k < 3; k++) {
292
- const v = k < dim ? pts[i + k] : 0;
293
- if (v < b.min[k]) b.min[k] = v;
294
- if (v > b.max[k]) b.max[k] = v;
295
- }
296
- }
297
- for (let k = 0; k < 3; k++) {
298
- b.min[k] -= pad;
299
- b.max[k] += pad;
300
- }
301
- return b;
302
- }
303
-
304
- registerGeometry('line', (el) => {
305
- const a = el.values.from, b = el.values.to;
306
- const r = valueOf(el, 'radius');
307
- if (r > 0) {
308
- // a tube is the shared unit cylinder, oriented and scaled along the segment
309
- const d = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
310
- const len = Math.hypot(d[0], d[1], d[2]);
311
- const y = [d[0] / len, d[1] / len, d[2] / len];
312
- const ref = Math.abs(y[1]) < 0.99 ? [0, 1, 0] : [1, 0, 0];
313
- let x = [ref[1] * y[2] - ref[2] * y[1], ref[2] * y[0] - ref[0] * y[2], ref[0] * y[1] - ref[1] * y[0]];
314
- const xl = Math.hypot(...x);
315
- x = x.map((v) => v / xl);
316
- const z = [x[1] * y[2] - x[2] * y[1], x[2] * y[0] - x[0] * y[2], x[0] * y[1] - x[1] * y[0]];
317
- // columns: x*r, dir*len, z*r (right handed since z = x cross y), translation = midpoint
318
- const m = mat4.createF64();
319
- m.set([x[0] * r, x[1] * r, x[2] * r, 0, y[0] * len, y[1] * len, y[2] * len, 0, z[0] * r, z[1] * r, z[2] * r, 0, (a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2, 1]);
320
- const fixed = el.values.segments;
321
- return {
322
- key: `cyl:1:true:${fixed || ''}`,
323
- unit: m,
324
- lods: fixed ? 1 : LOD_COUNT,
325
- kind: 'triangles',
326
- doubleSided: false,
327
- bounds: box3(-1, -0.5, -1, 1, 0.5, 1),
328
- lodDeviation: segsAt(48, 8, fixed).map((n) => cylinderDeviation(1, n)),
329
- generate: (lod, q) => unitCylinder(1, fixed || lodSegments(48, lod, q, 8), true),
330
- };
331
- }
332
- return {
333
- key: `line:${a.join(',')}:${b.join(',')}`,
334
- unit: null,
335
- lods: 1,
336
- kind: 'lines',
337
- doubleSided: false,
338
- bounds: pointsBounds([...a, ...b], 3),
339
- lodDeviation: [0],
340
- generate: () => lineMesh([...a, ...b], false),
341
- };
342
- });
343
-
344
- registerGeometry('polyline', (el) => {
345
- const r = valueOf(el, 'radius');
346
- const closed = valueOf(el, 'closed');
347
- const smooth = valueOf(el, 'smooth');
348
- const fixed = el.values.segments;
349
- const pts = el.values.points;
350
- const d = el.values.d;
351
- const keyBase = pts ? `pts:${hashString(rawOf(el, 'points'))}` : `d:${hashString(rawOf(el, 'd'))}`;
352
- const polylines = (lod, q) => {
353
- if (pts) {
354
- let p = pts;
355
- if (smooth) p = catmullRom(pts, closed, lodSegments(12, lod, q, 2));
356
- return [{ points: p, closed }];
357
- }
358
- const tol = pathTolerance(d.segments, lod, q);
359
- return flattenPath(d.segments, { tolerance: tol }).map((c) => {
360
- const n = c.points.length / 2;
361
- const p3 = new Float64Array(n * 3);
362
- for (let i = 0; i < n; i++) {
363
- p3[i * 3] = c.points[i * 2];
364
- p3[i * 3 + 1] = c.points[i * 2 + 1];
365
- }
366
- return { points: p3, closed: c.closed || closed };
367
- });
368
- };
369
- let bounds;
370
- if (pts) {
371
- const raw = pointsBounds(pts, 3);
372
- const diag = Math.hypot(raw.max[0] - raw.min[0], raw.max[1] - raw.min[1], raw.max[2] - raw.min[2]);
373
- // splines may overshoot the control points slightly
374
- bounds = pointsBounds(pts, 3, r + (smooth ? 0.1 * diag : 0));
375
- } else {
376
- const cb = pathControlBounds(d.segments);
377
- bounds = box3(cb.minX - r, cb.minY - r, -r, cb.maxX + r, cb.maxY + r, r);
378
- }
379
- if (r > 0) {
380
- return {
381
- key: `tube:${keyBase}:${r}:${closed}:${smooth}:${fixed || ''}`,
382
- unit: null,
383
- lods: LOD_COUNT,
384
- kind: 'triangles',
385
- doubleSided: false,
386
- bounds,
387
- lodDeviation: lodList((lod) => {
388
- const spine = pts
389
- ? Math.hypot(bounds.max[0] - bounds.min[0], bounds.max[1] - bounds.min[1], bounds.max[2] - bounds.min[2]) * (smooth ? 0.05 / lodSegments(12, lod, 1, 2) : 0)
390
- : pathFlattenDeviation(pathDiag(d.segments), lod);
391
- return spine + r * circleSagitta(fixed || lodSegments(24, lod, 1, 6));
392
- }),
393
- generate: (lod, q, report) => sweepMesh({ kind: 'circle', radius: r, segments: fixed || lodSegments(24, lod, q, 6) }, polylines(lod, q), { caps: true }, report).mesh,
394
- };
395
- }
396
- return {
397
- key: `polyline:${keyBase}:${closed}:${smooth}`,
398
- unit: null,
399
- lods: smooth || d ? LOD_COUNT : 1,
400
- kind: 'lines',
401
- doubleSided: false,
402
- bounds,
403
- lodDeviation: smooth || d
404
- ? lodList((lod) => (pts
405
- ? Math.hypot(bounds.max[0] - bounds.min[0], bounds.max[1] - bounds.min[1], bounds.max[2] - bounds.min[2]) * (smooth ? 0.05 / lodSegments(12, lod, 1, 2) : 0)
406
- : pathFlattenDeviation(pathDiag(d.segments), lod)))
407
- : [0],
408
- generate: (lod, q) => {
409
- const list = polylines(lod, q);
410
- if (list.length === 1) return lineMesh(list[0].points, list[0].closed);
411
- // several subpaths: concatenate
412
- const meshes = list.map((l) => lineMesh(l.points, l.closed));
413
- let vc = 0, ic = 0;
414
- for (const m of meshes) {
415
- vc += m.vertexCount;
416
- ic += m.indices.length;
417
- }
418
- const positions = new Float32Array(vc * 3);
419
- const indices = new Uint32Array(ic);
420
- let vo = 0, io = 0;
421
- for (const m of meshes) {
422
- positions.set(m.positions, vo * 3);
423
- for (let i = 0; i < m.indices.length; i++) indices[io + i] = m.indices[i] + vo;
424
- vo += m.vertexCount;
425
- io += m.indices.length;
426
- }
427
- return new Mesh({ positions, indices, kind: 'lines' });
428
- },
429
- };
430
- });
431
-
432
- // --- flat shapes -------------------------------------------------------------------
433
-
434
- registerGeometry('polygon', (el) => {
435
- const pts = el.values.points;
436
- const contour = { points: Float64Array.from(pts), smooth: new Uint8Array(pts.length / 2), closed: true };
437
- return {
438
- key: `polygon:${hashString(rawOf(el, 'points'))}`,
439
- unit: null,
440
- lods: 1,
441
- kind: 'triangles',
442
- doubleSided: true,
443
- bounds: pointsBounds(pts, 2),
444
- lodDeviation: [0],
445
- generate: (lod, q, report) => flatMesh(buildRegions([contour], 'nonzero').regions, report),
446
- };
447
- });
448
-
449
- registerGeometry('path', (el) => {
450
- const src = pathSource(el);
451
- if (!src) return null;
452
- const cb = pathControlBounds(src.path.segments);
453
- return {
454
- key: `path:${hashString(src.raw)}:${src.fillRule}`,
455
- unit: null,
456
- lods: LOD_COUNT,
457
- kind: 'triangles',
458
- doubleSided: true,
459
- bounds: box3(cb.minX, cb.minY, 0, cb.maxX, cb.maxY, 0),
460
- lodDeviation: lodList((lod) => pathFlattenDeviation(pathDiag(src.path.segments), lod)),
461
- generate: (lod, q, report) => flatMesh(regionsFor(src, lod, q, report).regions, report),
462
- };
463
- });
464
-
465
- registerGeometry('extrude', (el) => {
466
- const src = pathSource(el);
467
- if (!src) return null;
468
- const depth = valueOf(el, 'depth');
469
- const bevel = valueOf(el, 'bevel');
470
- const bevelSegments = valueOf(el, 'bevelSegments');
471
- const center = valueOf(el, 'center');
472
- const cb = pathControlBounds(src.path.segments);
473
- const z0 = center ? -depth / 2 : 0;
474
- return {
475
- key: `extrude:${hashString(src.raw)}:${src.fillRule}:${depth}:${bevel}:${bevelSegments}:${center}`,
476
- unit: null,
477
- lods: LOD_COUNT,
478
- kind: 'triangles',
479
- doubleSided: false,
480
- bounds: box3(cb.minX, cb.minY, z0, cb.maxX, cb.maxY, z0 + depth),
481
- lodDeviation: lodList((lod) => pathFlattenDeviation(pathDiag(src.path.segments), lod) + bevelDeviation(bevel, Math.max(1, Math.round(bevelSegments / (lod >= 2 ? 2 : 1))))),
482
- generate: (lod, q, report) =>
483
- extrudeMesh(regionsFor(src, lod, q, report), { depth, bevel, bevelSegments: Math.max(1, Math.round(bevelSegments / (lod >= 2 ? 2 : 1))), center }, report),
484
- };
485
- });
486
-
487
- registerGeometry('revolve', (el) => {
488
- const src = pathSource(el);
489
- if (!src) return null;
490
- const angle = valueOf(el, 'angle');
491
- const fixed = el.values.segments;
492
- const cb = pathControlBounds(src.path.segments);
493
- const R = Math.max(Math.abs(cb.maxX), Math.abs(cb.minX));
494
- return {
495
- key: `revolve:${hashString(src.raw)}:${angle}:${fixed || ''}`,
496
- unit: null,
497
- lods: LOD_COUNT,
498
- kind: 'triangles',
499
- doubleSided: subpathsOpen(src.path.segments),
500
- bounds: box3(-R, cb.minY, -R, R, cb.maxY, R),
501
- lodDeviation: lodList((lod) => {
502
- const segs = fixed || Math.max(3, Math.round(lodSegments(48, lod, 1, 8) * Math.min(1, angle / 360) + (angle < 360 ? 1 : 0)));
503
- return pathFlattenDeviation(pathDiag(src.path.segments), lod) + R * circleSagitta(segs);
504
- }),
505
- generate: (lod, q, report) => {
506
- const contours = flattenPath(src.path.segments, { tolerance: pathTolerance(src.path.segments, lod, q) });
507
- const segs = fixed || Math.max(3, Math.round(lodSegments(48, lod, q, 8) * Math.min(1, angle / 360) + (angle < 360 ? 1 : 0)));
508
- return revolveMesh(contours, { angle, segments: segs }, report).mesh;
509
- },
510
- };
511
- });
512
-
513
- function spineBounds(spine) {
514
- if (spine.kind === 'helix') return box3(-spine.radius, Math.min(0, spine.pitch * spine.turns), -spine.radius, spine.radius, Math.max(0, spine.pitch * spine.turns), spine.radius);
515
- if (spine.kind === 'points') {
516
- const b = pointsBounds(spine.points, 3);
517
- const pad = 0.15 * Math.hypot(b.max[0] - b.min[0], b.max[1] - b.min[1], b.max[2] - b.min[2]);
518
- return pointsBounds(spine.points, 3, pad);
519
- }
520
- const b = box3(Infinity, Infinity, Infinity, -Infinity, -Infinity, -Infinity);
521
- for (const s of spine.segments) {
522
- if (s.c === 'Z') continue;
523
- const pts = [[s.x, s.y, s.z]];
524
- if (s.c === 'C') pts.push([s.x1, s.y1, s.z1], [s.x2, s.y2, s.z2]);
525
- if (s.c === 'Q') pts.push([s.x1, s.y1, s.z1]);
526
- for (const p of pts) for (let k = 0; k < 3; k++) {
527
- b.min[k] = Math.min(b.min[k], p[k] || 0);
528
- b.max[k] = Math.max(b.max[k], p[k] || 0);
529
- }
530
- }
531
- return b;
532
- }
533
-
534
- registerGeometry('sweep', (el) => {
535
- const spine = el.values.along;
536
- const radius = el.values.radius;
537
- const src = radius === undefined ? pathSource(el) : null;
538
- if (radius === undefined && !src) return null;
539
- const smooth = valueOf(el, 'smooth');
540
- const twist = valueOf(el, 'twist');
541
- const taper = valueOf(el, 'taper');
542
- const caps = valueOf(el, 'caps');
543
- const fixed = el.values.segments;
544
- let profileR = radius;
545
- if (src) {
546
- const cb = pathControlBounds(src.path.segments);
547
- profileR = Math.max(Math.abs(cb.minX), Math.abs(cb.maxX), Math.abs(cb.minY), Math.abs(cb.maxY));
548
- }
549
- const sb = spineBounds(spine);
550
- const pad = profileR * Math.max(1, taper) * 1.5;
551
- const spineOpen = spine.kind === 'helix' || (spine.kind === 'path' ? subpathsOpen(spine.segments) : !(spine.points.length > 6 && spine.points[0] === spine.points[spine.points.length - 3] && spine.points[1] === spine.points[spine.points.length - 2] && spine.points[2] === spine.points[spine.points.length - 1]));
552
- const spineSize = Math.hypot(sb.max[0] - sb.min[0], sb.max[1] - sb.min[1], sb.max[2] - sb.min[2]) || 1;
553
- return {
554
- key: `sweep:${src ? hashString(src.raw) + ':' + src.fillRule : 'r' + radius}:${hashString(rawOf(el, 'along'))}:${smooth}:${twist}:${taper}:${caps}:${fixed || ''}`,
555
- unit: null,
556
- lods: LOD_COUNT,
557
- kind: 'triangles',
558
- doubleSided: !caps && spineOpen,
559
- bounds: box3(sb.min[0] - pad, sb.min[1] - pad, sb.min[2] - pad, sb.max[0] + pad, sb.max[1] + pad, sb.max[2] + pad),
560
- lodDeviation: lodList((lod) => {
561
- const profileErr = src
562
- ? pathFlattenDeviation(pathDiag(src.path.segments), lod)
563
- : (radius || 0) * circleSagitta(fixed || lodSegments(24, lod, 1, 6));
564
- return pathFlattenDeviation(spineSize, lod) + profileErr;
565
- }),
566
- generate: (lod, q, report) => {
567
- const spines = spinePolylines(spine, {
568
- tolerance: pathFlattenDeviation(spineSize, lod) / q,
569
- segmentsPerTurn: lodSegments(48, lod, q, 8),
570
- smooth,
571
- subdivisions: lodSegments(12, lod, q, 2),
572
- });
573
- const profile = src
574
- ? { kind: 'shape', ...regionsFor(src, lod, q, report) }
575
- : { kind: 'circle', radius, segments: fixed || lodSegments(24, lod, q, 6) };
576
- return sweepMesh(profile, spines, { twist, taper, caps }, report).mesh;
577
- },
578
- };
579
- });
580
-
581
- registerGeometry('gear', (el) => {
582
- const teeth = valueOf(el, 'teeth');
583
- const m = valueOf(el, 'module');
584
- const pa = valueOf(el, 'pressureAngle');
585
- const depth = valueOf(el, 'depth') / m;
586
- const bore = valueOf(el, 'bore') / m;
587
- const bevel = valueOf(el, 'bevel') / m;
588
- const center = valueOf(el, 'center');
589
- const ra = teeth / 2 + 1;
590
- const z0 = center ? -depth / 2 : 0;
591
- return {
592
- key: `gear:${teeth}:${pa}:${depth}:${bore}:${bevel}:${center}`,
593
- unit: S(m, m, m),
594
- lods: LOD_COUNT,
595
- kind: 'triangles',
596
- doubleSided: false,
597
- bounds: box3(-ra, -ra, z0, ra, ra, z0 + depth),
598
- lodDeviation: lodList((lod) => gearDeviation(teeth, lodSegments(8, lod, 1, 2)) + bevelDeviation(bevel, lod >= 2 ? 1 : 2)),
599
- generate: (lod, q, report) => {
600
- const flank = lodSegments(8, lod, q, 2);
601
- const arc = lodSegments(4, lod, q, 2);
602
- const g = gearOutline(teeth, pa, flank, arc);
603
- const outer = { ...g.outline, isWall: true };
604
- const holes = [];
605
- if (bore > 0) {
606
- const c = circleContour(bore, lodSegments(48, lod, q, 8));
607
- // holes are clockwise
608
- const n = c.points.length / 2;
609
- const rev = new Float64Array(c.points.length);
610
- for (let i = 0; i < n; i++) {
611
- rev[i * 2] = c.points[(n - 1 - i) * 2];
612
- rev[i * 2 + 1] = c.points[(n - 1 - i) * 2 + 1];
613
- }
614
- holes.push({ points: rev, smooth: c.smooth, closed: true, isWall: true });
615
- }
616
- const shape = { regions: [{ outer, holes }], walls: [outer, ...holes] };
617
- return extrudeMesh(shape, { depth, bevel, bevelSegments: lod >= 2 ? 1 : 2, center }, report);
618
- },
619
- };
620
- });
621
-
622
- registerGeometry('mesh', (el) => {
623
- const positions = el.values.positions;
624
- const indices = el.values.indices || Uint32Array.from({ length: positions.length / 3 }, (_, i) => i);
625
- const normals = el.values.normals;
626
- const uvs = el.values.uvs;
627
- const smooth = valueOf(el, 'smooth');
628
- // keyed by value, not spelling: "1.0" and "1" describe the same mesh
629
- const key = `mesh:${hashTyped(positions)}:${hashTyped(el.values.indices)}:${hashTyped(normals)}:${hashTyped(uvs)}:${smooth}`;
630
- return {
631
- key,
632
- unit: null,
633
- lods: 1,
634
- kind: 'triangles',
635
- doubleSided: false,
636
- bounds: pointsBounds(positions, 3),
637
- lodDeviation: [0],
638
- generate: () => {
639
- if (normals) return new Mesh({ positions: Float32Array.from(positions), normals: Float32Array.from(normals), uvs: uvs ? Float32Array.from(uvs) : null, indices: Uint32Array.from(indices) });
640
- return computeNormals(positions, indices, smooth, uvs);
641
- },
642
- };
643
- });
644
-
645
- builtins = new Set(registry.keys());
1
+ // Geometry registry: turns shape elements into geometry descriptors.
2
+ //
3
+ // A descriptor captures everything needed to (lazily) produce triangles:
4
+ // key cache key built only from shape-defining parameters; elements
5
+ // that differ only in size share the same mesh (see `unit`)
6
+ // unit matrix from the canonical mesh space into element space
7
+ // lods number of tessellation levels (1 = no level of detail)
8
+ // kind 'triangles' | 'lines'
9
+ // doubleSided render back faces (flat or open surfaces)
10
+ // bounds conservative canonical bounds, known without tessellating
11
+ // generate(lod, quality, report) -> Mesh
12
+ // identity (set here, built-in shapes only) the key with the full text of
13
+ // every input it hashes: what the persistent geometry cache
14
+ // names its records by, so that two shapes whose short keys
15
+ // collide can never share a cached mesh
16
+ //
17
+ // New element types can be added with registerGeometry(); this is the
18
+ // extension point that keeps the core format small.
19
+
20
+ import * as mat4 from '../math/mat4.js';
21
+ import { valueOf } from '../format/schema.js';
22
+ import { pathControlBounds } from '../path/path-parser.js';
23
+ import { flattenPath, circleContour } from '../path/path-flatten.js';
24
+ import { buildRegions } from '../path/shapes.js';
25
+ import { unitBox, roundedBox, unitSphere, unitCylinder, unitCapsule, unitPlane, unitTorus, lineMesh, lodSegments } from './primitives.js';
26
+ import {
27
+ sphereDeviation, cylinderDeviation, capsuleDeviation, torusDeviation, roundedBoxDeviation,
28
+ gearDeviation, pathFlattenDeviation, bevelDeviation, circleSagitta,
29
+ } from './lod-error.js';
30
+ import { flatMesh, extrudeMesh } from './extrude.js';
31
+ import { revolveMesh } from './revolve.js';
32
+ import { sweepMesh, spinePolylines, catmullRom } from './sweep.js';
33
+ import { gearOutline } from './gear.js';
34
+ import { Mesh, computeNormals } from './mesh.js';
35
+
36
+ export const LOD_COUNT = 4;
37
+
38
+ function lodList(fn) {
39
+ const a = new Array(LOD_COUNT);
40
+ for (let lod = 0; lod < LOD_COUNT; lod++) a[lod] = fn(lod);
41
+ return a;
42
+ }
43
+
44
+ function segsAt(base, min, fixed) {
45
+ return fixed ? [fixed] : lodList((lod) => lodSegments(base, lod, 1, min));
46
+ }
47
+
48
+ const registry = new Map();
49
+ // shapes defined in this module; filled at the end of it. A replacement
50
+ // registered later loses the identity (its key may hash anything)
51
+ let builtins = null;
52
+
53
+ export function registerGeometry(name, describe) {
54
+ registry.set(name, describe);
55
+ if (builtins) builtins.delete(name);
56
+ }
57
+
58
+ export function hasGeometry(name) {
59
+ return registry.has(name);
60
+ }
61
+
62
+ // raw inputs hashed into the key of the descriptor being built, or null
63
+ // when it cannot have an identity (keyed by a hash of mesh data)
64
+ let inputs = null;
65
+
66
+ /** @returns {object|null} descriptor */
67
+ export function describeShape(el, ctx) {
68
+ const fn = registry.get(el.name);
69
+ if (!fn) return null;
70
+ inputs = builtins && builtins.has(el.name) ? [] : null;
71
+ try {
72
+ const desc = fn(el, ctx);
73
+ if (desc && inputs) desc.identity = [desc.key, ...inputs].map((t) => `${t.length}:${t}`).join('');
74
+ return desc;
75
+ } finally {
76
+ inputs = null;
77
+ }
78
+ }
79
+
80
+ const box3 = (minX, minY, minZ, maxX, maxY, maxZ) => ({ min: [minX, minY, minZ], max: [maxX, maxY, maxZ] });
81
+ const S = (x, y, z) => mat4.fromScaling(mat4.createF64(), x, y, z);
82
+
83
+ function hashString(s) {
84
+ if (inputs) inputs.push(s);
85
+ // FNV-1a, two lanes for a 64-bit-ish key
86
+ let h1 = 0x811c9dc5, h2 = 0x01000193;
87
+ for (let i = 0; i < s.length; i++) {
88
+ const c = s.charCodeAt(i);
89
+ h1 = Math.imul(h1 ^ c, 0x01000193);
90
+ h2 = Math.imul(h2 ^ c, 0x5bd1e995);
91
+ }
92
+ return (h1 >>> 0).toString(36) + (h2 >>> 0).toString(36) + ':' + s.length;
93
+ }
94
+
95
+ /** Hash of a typed array's contents (two 32-bit lanes + length). */
96
+ function hashTyped(a) {
97
+ // mesh data: hashing all of it for a persistent name would cost more than
98
+ // the mesh does to build, so such shapes have no identity
99
+ inputs = null;
100
+ if (!a) return '';
101
+ const w = new Uint32Array(a.buffer, a.byteOffset, a.byteLength >> 2);
102
+ let h1 = 0x9747b28c, h2 = 0x01000193;
103
+ for (let i = 0; i < w.length; i++) {
104
+ let k = Math.imul(w[i], 0xcc9e2d51);
105
+ k = Math.imul((k << 15) | (k >>> 17), 0x1b873593);
106
+ h1 ^= k;
107
+ h1 = (Math.imul((h1 << 13) | (h1 >>> 19), 5) + 0xe6546b64) | 0;
108
+ h2 = Math.imul(h2 ^ w[i], 0x85ebca6b);
109
+ h2 ^= h2 >>> 13;
110
+ }
111
+ return (h1 >>> 0).toString(36) + (h2 >>> 0).toString(36) + ':' + a.length;
112
+ }
113
+
114
+ function rawOf(el, name) {
115
+ const a = el.attrIndex && el.attrIndex[name];
116
+ return a ? a.value.trim() : '';
117
+ }
118
+
119
+ /** Path data of a path-consuming element (inline d or referenced <path>). */
120
+ function pathSource(el) {
121
+ if (el.values.d) return { path: el.values.d, raw: rawOf(el, 'd'), fillRule: valueOf(el, 'fillRule') };
122
+ const t = el.pathTarget;
123
+ if (t && t.values && t.values.d) {
124
+ const rule = el.values.fillRule !== undefined ? el.values.fillRule : valueOf(t, 'fillRule');
125
+ return { path: t.values.d, raw: rawOf(t, 'd'), fillRule: rule };
126
+ }
127
+ return null;
128
+ }
129
+
130
+ function pathDiag(segments) {
131
+ const b = pathControlBounds(segments);
132
+ return Math.hypot(b.maxX - b.minX, b.maxY - b.minY) || 1;
133
+ }
134
+
135
+ function pathTolerance(segments, lod, quality) {
136
+ return pathFlattenDeviation(pathDiag(segments), lod) / quality;
137
+ }
138
+
139
+ function subpathsOpen(segments) {
140
+ let open = false;
141
+ let inSub = false;
142
+ let closed = false;
143
+ for (const s of segments) {
144
+ if (s.c === 'M') {
145
+ if (inSub && !closed) open = true;
146
+ inSub = true;
147
+ closed = false;
148
+ } else if (s.c === 'Z') closed = true;
149
+ }
150
+ if (inSub && !closed) open = true;
151
+ return open;
152
+ }
153
+
154
+ function regionsFor(src, lod, quality, report) {
155
+ const contours = flattenPath(src.path.segments, { tolerance: pathTolerance(src.path.segments, lod, quality) });
156
+ for (const c of contours) {
157
+ if (c.degenerate && report) report('warning', 'W_DEGENERATE_CONTOUR', 'A subpath has fewer than three distinct points and encloses no area.');
158
+ }
159
+ const shape = buildRegions(contours, src.fillRule);
160
+ if (!shape.regions.length && report) report('warning', 'W_EMPTY_FILL', 'The path encloses no area; nothing is filled.');
161
+ return shape;
162
+ }
163
+
164
+ // --- primitives --------------------------------------------------------------
165
+
166
+ registerGeometry('box', (el) => {
167
+ const size = valueOf(el, 'size');
168
+ const r = valueOf(el, 'radius');
169
+ if (r > 0) {
170
+ const fixed = el.values.segments;
171
+ return {
172
+ key: `rbox:${size.join(',')}:${r}:${fixed || ''}`,
173
+ unit: null,
174
+ lods: fixed ? 1 : LOD_COUNT,
175
+ kind: 'triangles',
176
+ doubleSided: false,
177
+ bounds: box3(-size[0] / 2, -size[1] / 2, -size[2] / 2, size[0] / 2, size[1] / 2, size[2] / 2),
178
+ lodDeviation: segsAt(4, 1, fixed).map((n) => roundedBoxDeviation(r, n)),
179
+ generate: (lod, q) => roundedBox(size[0], size[1], size[2], r, fixed || lodSegments(4, lod, q, 1)),
180
+ };
181
+ }
182
+ return {
183
+ key: 'box',
184
+ unit: S(size[0], size[1], size[2]),
185
+ lods: 1,
186
+ kind: 'triangles',
187
+ doubleSided: false,
188
+ bounds: box3(-0.5, -0.5, -0.5, 0.5, 0.5, 0.5),
189
+ lodDeviation: [0],
190
+ generate: () => unitBox(),
191
+ };
192
+ });
193
+
194
+ registerGeometry('sphere', (el) => {
195
+ const r = valueOf(el, 'radius');
196
+ const fixed = el.values.segments;
197
+ return {
198
+ key: `sphere:${fixed || ''}`,
199
+ unit: S(r, r, r),
200
+ lods: fixed ? 1 : LOD_COUNT,
201
+ kind: 'triangles',
202
+ doubleSided: false,
203
+ bounds: box3(-1, -1, -1, 1, 1, 1),
204
+ lodDeviation: segsAt(48, 8, fixed).map(sphereDeviation),
205
+ generate: (lod, q) => unitSphere(fixed || lodSegments(48, lod, q, 8)),
206
+ };
207
+ });
208
+
209
+ function cylinderLike(el, top) {
210
+ const r = valueOf(el, 'radius');
211
+ const h = valueOf(el, 'height');
212
+ const caps = valueOf(el, 'caps');
213
+ const fixed = el.values.segments;
214
+ const t = top / r;
215
+ const m = Math.max(1, t);
216
+ return {
217
+ key: `cyl:${t}:${caps}:${fixed || ''}`,
218
+ unit: S(r, h, r),
219
+ lods: fixed ? 1 : LOD_COUNT,
220
+ kind: 'triangles',
221
+ doubleSided: !caps,
222
+ bounds: box3(-m, -0.5, -m, m, 0.5, m),
223
+ lodDeviation: segsAt(48, 8, fixed).map((n) => cylinderDeviation(t, n)),
224
+ generate: (lod, q) => unitCylinder(t, fixed || lodSegments(48, lod, q, 8), caps),
225
+ };
226
+ }
227
+
228
+ registerGeometry('cylinder', (el) => {
229
+ const r = valueOf(el, 'radius');
230
+ const top = el.values.radiusTop !== undefined ? el.values.radiusTop : r;
231
+ return cylinderLike(el, top);
232
+ });
233
+
234
+ registerGeometry('cone', (el) => cylinderLike(el, 0));
235
+
236
+ registerGeometry('capsule', (el) => {
237
+ const r = valueOf(el, 'radius');
238
+ const h = valueOf(el, 'height');
239
+ const L = Math.max(0, h / r - 2);
240
+ const fixed = el.values.segments;
241
+ return {
242
+ key: `capsule:${L}:${fixed || ''}`,
243
+ unit: S(r, r, r),
244
+ lods: fixed ? 1 : LOD_COUNT,
245
+ kind: 'triangles',
246
+ doubleSided: false,
247
+ bounds: box3(-1, -(L / 2 + 1), -1, 1, L / 2 + 1, 1),
248
+ lodDeviation: segsAt(32, 8, fixed).map(capsuleDeviation),
249
+ generate: (lod, q) => unitCapsule(L, fixed || lodSegments(32, lod, q, 8)),
250
+ };
251
+ });
252
+
253
+ registerGeometry('plane', (el) => {
254
+ const size = valueOf(el, 'size');
255
+ return {
256
+ key: 'plane',
257
+ unit: S(size[0], 1, size[1]),
258
+ lods: 1,
259
+ kind: 'triangles',
260
+ doubleSided: true,
261
+ bounds: box3(-0.5, 0, -0.5, 0.5, 0, 0.5),
262
+ lodDeviation: [0],
263
+ generate: () => unitPlane(),
264
+ };
265
+ });
266
+
267
+ registerGeometry('torus', (el) => {
268
+ const R = valueOf(el, 'radius');
269
+ const tube = valueOf(el, 'tube');
270
+ const arc = valueOf(el, 'arc');
271
+ const t = tube / R;
272
+ const fs = el.values.segments;
273
+ const ft = el.values.tubeSegments;
274
+ return {
275
+ key: `torus:${t}:${arc}:${fs || ''}:${ft || ''}`,
276
+ unit: S(R, R, R),
277
+ lods: fs && ft ? 1 : LOD_COUNT,
278
+ kind: 'triangles',
279
+ doubleSided: false,
280
+ bounds: box3(-(1 + t), -t, -(1 + t), 1 + t, t, 1 + t),
281
+ lodDeviation: (fs && ft ? [0] : lodList((lod) => 0)).map((_, lod) => torusDeviation(t, arc, fs || lodSegments(64, lod, 1, 8), ft || lodSegments(24, lod, 1, 6))),
282
+ generate: (lod, q) => unitTorus(t, arc, fs || lodSegments(64, lod, q, 8), ft || lodSegments(24, lod, q, 6)),
283
+ };
284
+ });
285
+
286
+ // --- curves --------------------------------------------------------------------
287
+
288
+ function pointsBounds(pts, dim, pad = 0) {
289
+ const b = box3(Infinity, Infinity, Infinity, -Infinity, -Infinity, -Infinity);
290
+ for (let i = 0; i < pts.length; i += dim) {
291
+ for (let k = 0; k < 3; k++) {
292
+ const v = k < dim ? pts[i + k] : 0;
293
+ if (v < b.min[k]) b.min[k] = v;
294
+ if (v > b.max[k]) b.max[k] = v;
295
+ }
296
+ }
297
+ for (let k = 0; k < 3; k++) {
298
+ b.min[k] -= pad;
299
+ b.max[k] += pad;
300
+ }
301
+ return b;
302
+ }
303
+
304
+ registerGeometry('line', (el) => {
305
+ const a = el.values.from, b = el.values.to;
306
+ const r = valueOf(el, 'radius');
307
+ if (r > 0) {
308
+ // a tube is the shared unit cylinder, oriented and scaled along the segment
309
+ const d = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
310
+ const len = Math.hypot(d[0], d[1], d[2]);
311
+ const y = [d[0] / len, d[1] / len, d[2] / len];
312
+ const ref = Math.abs(y[1]) < 0.99 ? [0, 1, 0] : [1, 0, 0];
313
+ let x = [ref[1] * y[2] - ref[2] * y[1], ref[2] * y[0] - ref[0] * y[2], ref[0] * y[1] - ref[1] * y[0]];
314
+ const xl = Math.hypot(...x);
315
+ x = x.map((v) => v / xl);
316
+ const z = [x[1] * y[2] - x[2] * y[1], x[2] * y[0] - x[0] * y[2], x[0] * y[1] - x[1] * y[0]];
317
+ // columns: x*r, dir*len, z*r (right handed since z = x cross y), translation = midpoint
318
+ const m = mat4.createF64();
319
+ m.set([x[0] * r, x[1] * r, x[2] * r, 0, y[0] * len, y[1] * len, y[2] * len, 0, z[0] * r, z[1] * r, z[2] * r, 0, (a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2, 1]);
320
+ const fixed = el.values.segments;
321
+ return {
322
+ key: `cyl:1:true:${fixed || ''}`,
323
+ unit: m,
324
+ lods: fixed ? 1 : LOD_COUNT,
325
+ kind: 'triangles',
326
+ doubleSided: false,
327
+ bounds: box3(-1, -0.5, -1, 1, 0.5, 1),
328
+ lodDeviation: segsAt(48, 8, fixed).map((n) => cylinderDeviation(1, n)),
329
+ generate: (lod, q) => unitCylinder(1, fixed || lodSegments(48, lod, q, 8), true),
330
+ };
331
+ }
332
+ return {
333
+ key: `line:${a.join(',')}:${b.join(',')}`,
334
+ unit: null,
335
+ lods: 1,
336
+ kind: 'lines',
337
+ doubleSided: false,
338
+ bounds: pointsBounds([...a, ...b], 3),
339
+ lodDeviation: [0],
340
+ generate: () => lineMesh([...a, ...b], false),
341
+ };
342
+ });
343
+
344
+ registerGeometry('polyline', (el) => {
345
+ const r = valueOf(el, 'radius');
346
+ const closed = valueOf(el, 'closed');
347
+ const smooth = valueOf(el, 'smooth');
348
+ const fixed = el.values.segments;
349
+ const pts = el.values.points;
350
+ const d = el.values.d;
351
+ const keyBase = pts ? `pts:${hashString(rawOf(el, 'points'))}` : `d:${hashString(rawOf(el, 'd'))}`;
352
+ const polylines = (lod, q) => {
353
+ if (pts) {
354
+ let p = pts;
355
+ if (smooth) p = catmullRom(pts, closed, lodSegments(12, lod, q, 2));
356
+ return [{ points: p, closed }];
357
+ }
358
+ const tol = pathTolerance(d.segments, lod, q);
359
+ return flattenPath(d.segments, { tolerance: tol }).map((c) => {
360
+ const n = c.points.length / 2;
361
+ const p3 = new Float64Array(n * 3);
362
+ for (let i = 0; i < n; i++) {
363
+ p3[i * 3] = c.points[i * 2];
364
+ p3[i * 3 + 1] = c.points[i * 2 + 1];
365
+ }
366
+ return { points: p3, closed: c.closed || closed };
367
+ });
368
+ };
369
+ let bounds;
370
+ if (pts) {
371
+ const raw = pointsBounds(pts, 3);
372
+ const diag = Math.hypot(raw.max[0] - raw.min[0], raw.max[1] - raw.min[1], raw.max[2] - raw.min[2]);
373
+ // splines may overshoot the control points slightly
374
+ bounds = pointsBounds(pts, 3, r + (smooth ? 0.1 * diag : 0));
375
+ } else {
376
+ const cb = pathControlBounds(d.segments);
377
+ bounds = box3(cb.minX - r, cb.minY - r, -r, cb.maxX + r, cb.maxY + r, r);
378
+ }
379
+ if (r > 0) {
380
+ return {
381
+ key: `tube:${keyBase}:${r}:${closed}:${smooth}:${fixed || ''}`,
382
+ unit: null,
383
+ lods: LOD_COUNT,
384
+ kind: 'triangles',
385
+ doubleSided: false,
386
+ bounds,
387
+ lodDeviation: lodList((lod) => {
388
+ const spine = pts
389
+ ? Math.hypot(bounds.max[0] - bounds.min[0], bounds.max[1] - bounds.min[1], bounds.max[2] - bounds.min[2]) * (smooth ? 0.05 / lodSegments(12, lod, 1, 2) : 0)
390
+ : pathFlattenDeviation(pathDiag(d.segments), lod);
391
+ return spine + r * circleSagitta(fixed || lodSegments(24, lod, 1, 6));
392
+ }),
393
+ generate: (lod, q, report) => sweepMesh({ kind: 'circle', radius: r, segments: fixed || lodSegments(24, lod, q, 6) }, polylines(lod, q), { caps: true }, report).mesh,
394
+ };
395
+ }
396
+ return {
397
+ key: `polyline:${keyBase}:${closed}:${smooth}`,
398
+ unit: null,
399
+ lods: smooth || d ? LOD_COUNT : 1,
400
+ kind: 'lines',
401
+ doubleSided: false,
402
+ bounds,
403
+ lodDeviation: smooth || d
404
+ ? lodList((lod) => (pts
405
+ ? Math.hypot(bounds.max[0] - bounds.min[0], bounds.max[1] - bounds.min[1], bounds.max[2] - bounds.min[2]) * (smooth ? 0.05 / lodSegments(12, lod, 1, 2) : 0)
406
+ : pathFlattenDeviation(pathDiag(d.segments), lod)))
407
+ : [0],
408
+ generate: (lod, q) => {
409
+ const list = polylines(lod, q);
410
+ if (list.length === 1) return lineMesh(list[0].points, list[0].closed);
411
+ // several subpaths: concatenate
412
+ const meshes = list.map((l) => lineMesh(l.points, l.closed));
413
+ let vc = 0, ic = 0;
414
+ for (const m of meshes) {
415
+ vc += m.vertexCount;
416
+ ic += m.indices.length;
417
+ }
418
+ const positions = new Float32Array(vc * 3);
419
+ const indices = new Uint32Array(ic);
420
+ let vo = 0, io = 0;
421
+ for (const m of meshes) {
422
+ positions.set(m.positions, vo * 3);
423
+ for (let i = 0; i < m.indices.length; i++) indices[io + i] = m.indices[i] + vo;
424
+ vo += m.vertexCount;
425
+ io += m.indices.length;
426
+ }
427
+ return new Mesh({ positions, indices, kind: 'lines' });
428
+ },
429
+ };
430
+ });
431
+
432
+ // --- flat shapes -------------------------------------------------------------------
433
+
434
+ registerGeometry('polygon', (el) => {
435
+ const pts = el.values.points;
436
+ const contour = { points: Float64Array.from(pts), smooth: new Uint8Array(pts.length / 2), closed: true };
437
+ return {
438
+ key: `polygon:${hashString(rawOf(el, 'points'))}`,
439
+ unit: null,
440
+ lods: 1,
441
+ kind: 'triangles',
442
+ doubleSided: true,
443
+ bounds: pointsBounds(pts, 2),
444
+ lodDeviation: [0],
445
+ generate: (lod, q, report) => flatMesh(buildRegions([contour], 'nonzero').regions, report),
446
+ };
447
+ });
448
+
449
+ registerGeometry('path', (el) => {
450
+ const src = pathSource(el);
451
+ if (!src) return null;
452
+ const cb = pathControlBounds(src.path.segments);
453
+ return {
454
+ key: `path:${hashString(src.raw)}:${src.fillRule}`,
455
+ unit: null,
456
+ lods: LOD_COUNT,
457
+ kind: 'triangles',
458
+ doubleSided: true,
459
+ bounds: box3(cb.minX, cb.minY, 0, cb.maxX, cb.maxY, 0),
460
+ lodDeviation: lodList((lod) => pathFlattenDeviation(pathDiag(src.path.segments), lod)),
461
+ generate: (lod, q, report) => flatMesh(regionsFor(src, lod, q, report).regions, report),
462
+ };
463
+ });
464
+
465
+ registerGeometry('extrude', (el) => {
466
+ const src = pathSource(el);
467
+ if (!src) return null;
468
+ const depth = valueOf(el, 'depth');
469
+ const bevel = valueOf(el, 'bevel');
470
+ const bevelSegments = valueOf(el, 'bevelSegments');
471
+ const center = valueOf(el, 'center');
472
+ const cb = pathControlBounds(src.path.segments);
473
+ const z0 = center ? -depth / 2 : 0;
474
+ return {
475
+ key: `extrude:${hashString(src.raw)}:${src.fillRule}:${depth}:${bevel}:${bevelSegments}:${center}`,
476
+ unit: null,
477
+ lods: LOD_COUNT,
478
+ kind: 'triangles',
479
+ doubleSided: false,
480
+ bounds: box3(cb.minX, cb.minY, z0, cb.maxX, cb.maxY, z0 + depth),
481
+ lodDeviation: lodList((lod) => pathFlattenDeviation(pathDiag(src.path.segments), lod) + bevelDeviation(bevel, Math.max(1, Math.round(bevelSegments / (lod >= 2 ? 2 : 1))))),
482
+ generate: (lod, q, report) =>
483
+ extrudeMesh(regionsFor(src, lod, q, report), { depth, bevel, bevelSegments: Math.max(1, Math.round(bevelSegments / (lod >= 2 ? 2 : 1))), center }, report),
484
+ };
485
+ });
486
+
487
+ registerGeometry('revolve', (el) => {
488
+ const src = pathSource(el);
489
+ if (!src) return null;
490
+ const angle = valueOf(el, 'angle');
491
+ const fixed = el.values.segments;
492
+ const cb = pathControlBounds(src.path.segments);
493
+ const R = Math.max(Math.abs(cb.maxX), Math.abs(cb.minX));
494
+ return {
495
+ key: `revolve:${hashString(src.raw)}:${angle}:${fixed || ''}`,
496
+ unit: null,
497
+ lods: LOD_COUNT,
498
+ kind: 'triangles',
499
+ doubleSided: subpathsOpen(src.path.segments),
500
+ bounds: box3(-R, cb.minY, -R, R, cb.maxY, R),
501
+ lodDeviation: lodList((lod) => {
502
+ const segs = fixed || Math.max(3, Math.round(lodSegments(48, lod, 1, 8) * Math.min(1, angle / 360) + (angle < 360 ? 1 : 0)));
503
+ return pathFlattenDeviation(pathDiag(src.path.segments), lod) + R * circleSagitta(segs);
504
+ }),
505
+ generate: (lod, q, report) => {
506
+ const contours = flattenPath(src.path.segments, { tolerance: pathTolerance(src.path.segments, lod, q) });
507
+ const segs = fixed || Math.max(3, Math.round(lodSegments(48, lod, q, 8) * Math.min(1, angle / 360) + (angle < 360 ? 1 : 0)));
508
+ return revolveMesh(contours, { angle, segments: segs }, report).mesh;
509
+ },
510
+ };
511
+ });
512
+
513
+ function spineBounds(spine) {
514
+ if (spine.kind === 'helix') return box3(-spine.radius, Math.min(0, spine.pitch * spine.turns), -spine.radius, spine.radius, Math.max(0, spine.pitch * spine.turns), spine.radius);
515
+ if (spine.kind === 'points') {
516
+ const b = pointsBounds(spine.points, 3);
517
+ const pad = 0.15 * Math.hypot(b.max[0] - b.min[0], b.max[1] - b.min[1], b.max[2] - b.min[2]);
518
+ return pointsBounds(spine.points, 3, pad);
519
+ }
520
+ const b = box3(Infinity, Infinity, Infinity, -Infinity, -Infinity, -Infinity);
521
+ for (const s of spine.segments) {
522
+ if (s.c === 'Z') continue;
523
+ const pts = [[s.x, s.y, s.z]];
524
+ if (s.c === 'C') pts.push([s.x1, s.y1, s.z1], [s.x2, s.y2, s.z2]);
525
+ if (s.c === 'Q') pts.push([s.x1, s.y1, s.z1]);
526
+ for (const p of pts) for (let k = 0; k < 3; k++) {
527
+ b.min[k] = Math.min(b.min[k], p[k] || 0);
528
+ b.max[k] = Math.max(b.max[k], p[k] || 0);
529
+ }
530
+ }
531
+ return b;
532
+ }
533
+
534
+ registerGeometry('sweep', (el) => {
535
+ const spine = el.values.along;
536
+ const radius = el.values.radius;
537
+ const src = radius === undefined ? pathSource(el) : null;
538
+ if (radius === undefined && !src) return null;
539
+ const smooth = valueOf(el, 'smooth');
540
+ const twist = valueOf(el, 'twist');
541
+ const taper = valueOf(el, 'taper');
542
+ const caps = valueOf(el, 'caps');
543
+ const fixed = el.values.segments;
544
+ let profileR = radius;
545
+ if (src) {
546
+ const cb = pathControlBounds(src.path.segments);
547
+ profileR = Math.max(Math.abs(cb.minX), Math.abs(cb.maxX), Math.abs(cb.minY), Math.abs(cb.maxY));
548
+ }
549
+ const sb = spineBounds(spine);
550
+ const pad = profileR * Math.max(1, taper) * 1.5;
551
+ const spineOpen = spine.kind === 'helix' || (spine.kind === 'path' ? subpathsOpen(spine.segments) : !(spine.points.length > 6 && spine.points[0] === spine.points[spine.points.length - 3] && spine.points[1] === spine.points[spine.points.length - 2] && spine.points[2] === spine.points[spine.points.length - 1]));
552
+ const spineSize = Math.hypot(sb.max[0] - sb.min[0], sb.max[1] - sb.min[1], sb.max[2] - sb.min[2]) || 1;
553
+ return {
554
+ key: `sweep:${src ? hashString(src.raw) + ':' + src.fillRule : 'r' + radius}:${hashString(rawOf(el, 'along'))}:${smooth}:${twist}:${taper}:${caps}:${fixed || ''}`,
555
+ unit: null,
556
+ lods: LOD_COUNT,
557
+ kind: 'triangles',
558
+ doubleSided: !caps && spineOpen,
559
+ bounds: box3(sb.min[0] - pad, sb.min[1] - pad, sb.min[2] - pad, sb.max[0] + pad, sb.max[1] + pad, sb.max[2] + pad),
560
+ lodDeviation: lodList((lod) => {
561
+ const profileErr = src
562
+ ? pathFlattenDeviation(pathDiag(src.path.segments), lod)
563
+ : (radius || 0) * circleSagitta(fixed || lodSegments(24, lod, 1, 6));
564
+ return pathFlattenDeviation(spineSize, lod) + profileErr;
565
+ }),
566
+ generate: (lod, q, report) => {
567
+ const spines = spinePolylines(spine, {
568
+ tolerance: pathFlattenDeviation(spineSize, lod) / q,
569
+ segmentsPerTurn: lodSegments(48, lod, q, 8),
570
+ smooth,
571
+ subdivisions: lodSegments(12, lod, q, 2),
572
+ });
573
+ const profile = src
574
+ ? { kind: 'shape', ...regionsFor(src, lod, q, report) }
575
+ : { kind: 'circle', radius, segments: fixed || lodSegments(24, lod, q, 6) };
576
+ return sweepMesh(profile, spines, { twist, taper, caps }, report).mesh;
577
+ },
578
+ };
579
+ });
580
+
581
+ registerGeometry('gear', (el) => {
582
+ const teeth = valueOf(el, 'teeth');
583
+ const m = valueOf(el, 'module');
584
+ const pa = valueOf(el, 'pressureAngle');
585
+ const depth = valueOf(el, 'depth') / m;
586
+ const bore = valueOf(el, 'bore') / m;
587
+ const bevel = valueOf(el, 'bevel') / m;
588
+ const center = valueOf(el, 'center');
589
+ const ra = teeth / 2 + 1;
590
+ const z0 = center ? -depth / 2 : 0;
591
+ return {
592
+ key: `gear:${teeth}:${pa}:${depth}:${bore}:${bevel}:${center}`,
593
+ unit: S(m, m, m),
594
+ lods: LOD_COUNT,
595
+ kind: 'triangles',
596
+ doubleSided: false,
597
+ bounds: box3(-ra, -ra, z0, ra, ra, z0 + depth),
598
+ lodDeviation: lodList((lod) => gearDeviation(teeth, lodSegments(8, lod, 1, 2)) + bevelDeviation(bevel, lod >= 2 ? 1 : 2)),
599
+ generate: (lod, q, report) => {
600
+ const flank = lodSegments(8, lod, q, 2);
601
+ const arc = lodSegments(4, lod, q, 2);
602
+ const g = gearOutline(teeth, pa, flank, arc);
603
+ const outer = { ...g.outline, isWall: true };
604
+ const holes = [];
605
+ if (bore > 0) {
606
+ const c = circleContour(bore, lodSegments(48, lod, q, 8));
607
+ // holes are clockwise
608
+ const n = c.points.length / 2;
609
+ const rev = new Float64Array(c.points.length);
610
+ for (let i = 0; i < n; i++) {
611
+ rev[i * 2] = c.points[(n - 1 - i) * 2];
612
+ rev[i * 2 + 1] = c.points[(n - 1 - i) * 2 + 1];
613
+ }
614
+ holes.push({ points: rev, smooth: c.smooth, closed: true, isWall: true });
615
+ }
616
+ const shape = { regions: [{ outer, holes }], walls: [outer, ...holes] };
617
+ return extrudeMesh(shape, { depth, bevel, bevelSegments: lod >= 2 ? 1 : 2, center }, report);
618
+ },
619
+ };
620
+ });
621
+
622
+ registerGeometry('mesh', (el) => {
623
+ const positions = el.values.positions;
624
+ const indices = el.values.indices || Uint32Array.from({ length: positions.length / 3 }, (_, i) => i);
625
+ const normals = el.values.normals;
626
+ const uvs = el.values.uvs;
627
+ const smooth = valueOf(el, 'smooth');
628
+ // keyed by value, not spelling: "1.0" and "1" describe the same mesh
629
+ const key = `mesh:${hashTyped(positions)}:${hashTyped(el.values.indices)}:${hashTyped(normals)}:${hashTyped(uvs)}:${smooth}`;
630
+ return {
631
+ key,
632
+ unit: null,
633
+ lods: 1,
634
+ kind: 'triangles',
635
+ doubleSided: false,
636
+ bounds: pointsBounds(positions, 3),
637
+ lodDeviation: [0],
638
+ generate: () => {
639
+ if (normals) return new Mesh({ positions: Float32Array.from(positions), normals: Float32Array.from(normals), uvs: uvs ? Float32Array.from(uvs) : null, indices: Uint32Array.from(indices) });
640
+ return computeNormals(positions, indices, smooth, uvs);
641
+ },
642
+ };
643
+ });
644
+
645
+ builtins = new Set(registry.keys());