@es3d/es3d 1.0.0 → 1.0.2

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 (80) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +95 -86
  3. package/docs/SPEC.md +426 -417
  4. package/package.json +1 -1
  5. package/scripts/es3d-cli.mjs +390 -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 +132 -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-pack.js +1461 -0
  42. package/src/io/gltf.js +630 -630
  43. package/src/io/mesh-import.js +104 -104
  44. package/src/io/obj.js +352 -352
  45. package/src/io/step-brep.js +439 -439
  46. package/src/io/step-exact.js +1149 -1149
  47. package/src/io/step.js +1418 -1418
  48. package/src/io/stl.js +193 -193
  49. package/src/io/svg-import.js +787 -787
  50. package/src/material/materials.js +154 -154
  51. package/src/math/mat4.js +475 -475
  52. package/src/math/random.js +30 -30
  53. package/src/math/trig.js +65 -65
  54. package/src/math/vec3.js +81 -81
  55. package/src/optimize/optimizer.js +604 -579
  56. package/src/path/path-flatten.js +322 -322
  57. package/src/path/path-parser.js +332 -332
  58. package/src/path/shapes.js +129 -129
  59. package/src/path/triangulate.js +517 -517
  60. package/src/pipeline.js +74 -74
  61. package/src/render/camera.js +168 -168
  62. package/src/render/frame-builder.js +362 -362
  63. package/src/render/geometry-pool.js +149 -149
  64. package/src/render/lighting.js +149 -149
  65. package/src/render/orbit-controls.js +237 -237
  66. package/src/render/renderer.js +258 -249
  67. package/src/render/scroll-timeline.js +104 -104
  68. package/src/render/shaders/glsl.js +285 -285
  69. package/src/render/shaders/wgsl.js +252 -252
  70. package/src/render/stage.js +365 -352
  71. package/src/render/texture-cache.js +40 -40
  72. package/src/render/textures.js +223 -223
  73. package/src/render/webgl2/webgl2-backend.js +586 -532
  74. package/src/render/webgpu/webgpu-backend.js +482 -480
  75. package/src/scene/flatten.js +754 -754
  76. package/src/scene/picking.js +161 -161
  77. package/src/scene/scene-graph.js +320 -320
  78. package/src/transform/transform.js +211 -211
  79. package/src/wasm/kernels-binary.js +3 -3
  80. 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());