@es3d/es3d 1.0.1 → 1.1.0

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.
@@ -0,0 +1,679 @@
1
+ // Scene description shared by the X3D, USD, OpenSCAD and three.js exporters.
2
+ //
3
+ // structure 'keep' walks the scene graph. A shape stays a primitive wherever
4
+ // the target has one; the size is the element's own size, taken from the
5
+ // schema defaults and from the canonical unit matrix in registry.js (the unit
6
+ // matrix is not applied a second time). A shape the target cannot say stays
7
+ // one mesh per geometry entry, at the requested tessellation level.
8
+ //
9
+ // structure 'flatten' walks the RenderScene, the same triangles as the glTF
10
+ // and OBJ exporters at that level, one mesh per geometry and one instance per
11
+ // drawn copy.
12
+ //
13
+ // <lod> keeps its first child, which is the most detailed alternative, matching
14
+ // exportableInstances(). <repeat> and <scatter> stay as placement rules; each
15
+ // writer expands them with the target's own reuse (a loop, a point instancer,
16
+ // or a copy per placement). Scatter placements are computed here, with the
17
+ // same mulberry32 and es3dSin the flattener uses, so a baked copy lands on the
18
+ // same point.
19
+
20
+ import * as mat4 from '../math/mat4.js';
21
+ import { valueOf } from '../format/schema.js';
22
+ import { lodSegments } from '../geometry/primitives.js';
23
+ import { gearOutline } from '../geometry/gear.js';
24
+ import { pathFlattenDeviation } from '../geometry/lod-error.js';
25
+ import { pathControlBounds } from '../path/path-parser.js';
26
+ import { flattenPath, circleContour } from '../path/path-flatten.js';
27
+ import { buildRegions, triangulateRegion } from '../path/shapes.js';
28
+ import { mulberry32 } from '../math/random.js';
29
+ import { es3dSin, es3dCos } from '../math/trig.js';
30
+ import { exportableInstances } from './external-scene.js';
31
+
32
+ const CURVE = new Set(['C', 'S', 'Q', 'T', 'A']);
33
+
34
+ function segs(el, base, min, lod) {
35
+ return el.values.segments || lodSegments(base, lod, 1, min);
36
+ }
37
+
38
+ function pathOf(el) {
39
+ if (el.values.d) return { path: el.values.d, fillRule: valueOf(el, 'fillRule') };
40
+ const t = el.pathTarget;
41
+ if (t && t.values && t.values.d) {
42
+ const fillRule = el.values.fillRule !== undefined ? el.values.fillRule : valueOf(t, 'fillRule');
43
+ return { path: t.values.d, fillRule };
44
+ }
45
+ return null;
46
+ }
47
+
48
+ function tolerance(segments, lod) {
49
+ const b = pathControlBounds(segments);
50
+ const diag = Math.hypot(b.maxX - b.minX, b.maxY - b.minY) || 1;
51
+ return pathFlattenDeviation(diag, lod);
52
+ }
53
+
54
+ function hasCurve(segments) {
55
+ return segments.some((s) => CURVE.has(s.c));
56
+ }
57
+
58
+ function pts2(typed) {
59
+ return Array.from(typed);
60
+ }
61
+
62
+ /** Stable key for a list of contours, so two different outlines never share a definition. */
63
+ function contourKey(regions) {
64
+ let h = 2166136261;
65
+ const mix = (v) => {
66
+ h ^= Math.round(v * 1e6) | 0;
67
+ h = Math.imul(h, 16777619);
68
+ };
69
+ for (const r of regions) {
70
+ for (const v of r.outer) mix(v);
71
+ mix(1);
72
+ for (const hole of r.holes) {
73
+ for (const v of hole) mix(v);
74
+ mix(2);
75
+ }
76
+ }
77
+ return (h >>> 0).toString(36);
78
+ }
79
+
80
+ function scalePts(typed, s) {
81
+ const o = new Array(typed.length);
82
+ for (let i = 0; i < typed.length; i++) o[i] = typed[i] * s;
83
+ return o;
84
+ }
85
+
86
+ /** One filled outline and its holes, in the XY plane. Outer is counter-clockwise. */
87
+ function regionsOf(src, lod, curves) {
88
+ if (hasCurve(src.path.segments)) curves.count++;
89
+ const contours = flattenPath(src.path.segments, { tolerance: tolerance(src.path.segments, lod) });
90
+ const shape = buildRegions(contours, src.fillRule);
91
+ if (!shape.regions.length) return null;
92
+ return shape.regions.map((r) => ({
93
+ outer: pts2(r.outer.points),
94
+ holes: r.holes.map((h) => pts2(h.points)),
95
+ }));
96
+ }
97
+
98
+ /**
99
+ * A primitive in element space, or null when the shape has to be a mesh.
100
+ * `unit` means the mesh (or the unit cylinder of a straight tube) still needs
101
+ * the canonical unit matrix from the registry.
102
+ */
103
+ function parametric(el, lod, curves) {
104
+ const name = el.name;
105
+ if (name === 'box') {
106
+ if (valueOf(el, 'radius') > 0) return null;
107
+ const size = valueOf(el, 'size');
108
+ return { solid: { kind: 'box', size: size.slice(), key: `box:${size.join('x')}` } };
109
+ }
110
+ if (name === 'sphere') {
111
+ const radius = valueOf(el, 'radius');
112
+ const segments = segs(el, 48, 8, lod);
113
+ return { solid: { kind: 'sphere', radius, segments, key: `sphere:${radius}:${segments}` } };
114
+ }
115
+ if (name === 'cylinder' || name === 'cone') {
116
+ const radius = valueOf(el, 'radius');
117
+ const height = valueOf(el, 'height');
118
+ const top = name === 'cone' ? 0 : (el.values.radiusTop !== undefined ? el.values.radiusTop : radius);
119
+ const caps = valueOf(el, 'caps');
120
+ const segments = segs(el, 48, 8, lod);
121
+ if (!(radius > 0) || !(height > 0)) return null;
122
+ if (top === 0) return { solid: { kind: 'cone', radius, height, caps, segments, key: `cone:${radius}:${height}:${caps}:${segments}` } };
123
+ if (Math.abs(top - radius) < 1e-12) {
124
+ return { solid: { kind: 'cylinder', radius, height, caps, segments, key: `cyl:${radius}:${height}:${caps}:${segments}` } };
125
+ }
126
+ return { solid: { kind: 'frustum', radius, radiusTop: top, height, caps, segments, key: `frustum:${radius}:${top}:${height}:${caps}:${segments}` } };
127
+ }
128
+ if (name === 'capsule') {
129
+ const radius = valueOf(el, 'radius');
130
+ const height = valueOf(el, 'height');
131
+ const segments = segs(el, 32, 8, lod);
132
+ return { solid: { kind: 'capsule', radius, height, segments, key: `capsule:${radius}:${height}:${segments}` } };
133
+ }
134
+ if (name === 'plane') {
135
+ const size = valueOf(el, 'size');
136
+ return { solid: { kind: 'plane', width: size[0], depth: size[1], key: `plane:${size[0]}x${size[1]}` } };
137
+ }
138
+ if (name === 'torus') {
139
+ const arc = valueOf(el, 'arc');
140
+ if (arc < 360 - 1e-6) return null;
141
+ const radius = valueOf(el, 'radius');
142
+ const tube = valueOf(el, 'tube');
143
+ const segments = el.values.segments || lodSegments(64, lod, 1, 8);
144
+ const tubeSegments = el.values.tubeSegments || lodSegments(24, lod, 1, 6);
145
+ return { solid: { kind: 'torus', radius, tube, segments, tubeSegments, key: `torus:${radius}:${tube}:${segments}:${tubeSegments}` } };
146
+ }
147
+ if (name === 'line' && valueOf(el, 'radius') > 0) {
148
+ const segments = segs(el, 48, 8, lod);
149
+ return { solid: { kind: 'cylinder', radius: 1, height: 1, caps: true, segments, key: `tube:${segments}` }, unit: true };
150
+ }
151
+ if (name === 'extrude') {
152
+ if (valueOf(el, 'bevel') > 0) return null;
153
+ const src = pathOf(el);
154
+ if (!src) return null;
155
+ const regions = regionsOf(src, lod, curves);
156
+ if (!regions) return null;
157
+ const depth = valueOf(el, 'depth');
158
+ const center = valueOf(el, 'center');
159
+ return { solid: { kind: 'extrude', depth, center, regions, key: `extrude:${depth}:${center}:${contourKey(regions)}` } };
160
+ }
161
+ if (name === 'polygon') {
162
+ const points = el.values.points;
163
+ if (!points || points.length < 6) return null;
164
+ const regions = [{ outer: pts2(points), holes: [] }];
165
+ return { solid: { kind: 'surface', regions, key: `poly:${contourKey(regions)}` } };
166
+ }
167
+ if (name === 'path') {
168
+ const src = pathOf(el);
169
+ if (!src) return null;
170
+ const regions = regionsOf(src, lod, curves);
171
+ if (!regions) return null;
172
+ return { solid: { kind: 'surface', regions, key: `path:${contourKey(regions)}` } };
173
+ }
174
+ if (name === 'revolve') {
175
+ const src = pathOf(el);
176
+ if (!src) return null;
177
+ if (hasCurve(src.path.segments)) curves.count++;
178
+ const contours = flattenPath(src.path.segments, { tolerance: tolerance(src.path.segments, lod) });
179
+ if (contours.length !== 1) return null;
180
+ const raw = contours[0].points;
181
+ for (let i = 0; i < raw.length; i += 2) if (raw[i] < -1e-6) return null;
182
+ const points = [];
183
+ for (let i = 0; i < raw.length; i += 2) points.push(Math.max(0, raw[i]), raw[i + 1]);
184
+ const closed = !!contours[0].closed;
185
+ const angle = valueOf(el, 'angle');
186
+ const segments = el.values.segments || Math.max(3, Math.round(lodSegments(48, lod, 1, 8) * Math.min(1, angle / 360) + (angle < 360 ? 1 : 0)));
187
+ return { solid: { kind: 'revolve', points, closed, angle, segments, doubleSided: angle < 360 - 1e-6 || !closed, key: `revolve:${angle}:${segments}:${closed}:${contourKey([{ outer: points, holes: [] }])}` } };
188
+ }
189
+ if (name === 'gear') {
190
+ if (valueOf(el, 'bevel') > 0) return null;
191
+ const teeth = valueOf(el, 'teeth');
192
+ const module = valueOf(el, 'module');
193
+ const pa = valueOf(el, 'pressureAngle');
194
+ const depth = valueOf(el, 'depth');
195
+ const bore = valueOf(el, 'bore');
196
+ const center = valueOf(el, 'center');
197
+ const flank = lodSegments(8, lod, 1, 2);
198
+ const arc = lodSegments(4, lod, 1, 2);
199
+ const g = gearOutline(teeth, pa, flank, arc);
200
+ const regions = [{ outer: scalePts(g.outline.points, module), holes: [] }];
201
+ if (bore > 0) {
202
+ const c = circleContour(bore / module, lodSegments(48, lod, 1, 8));
203
+ const n = c.points.length / 2;
204
+ const hole = new Array(n * 2);
205
+ for (let i = 0; i < n; i++) {
206
+ const j = n - 1 - i;
207
+ hole[i * 2] = c.points[j * 2] * module;
208
+ hole[i * 2 + 1] = c.points[j * 2 + 1] * module;
209
+ }
210
+ regions[0].holes.push(hole);
211
+ }
212
+ return { solid: { kind: 'extrude', depth, center, regions, key: `gear:${teeth}:${module}:${pa}:${depth}:${bore}:${center}:${contourKey(regions)}` } };
213
+ }
214
+ return null;
215
+ }
216
+
217
+ function meshSolid(node, meshOf, lod) {
218
+ const entry = node.geometry;
219
+ const mesh = meshOf(entry, Math.min(lod, entry.lods - 1));
220
+ const kind = mesh.kind === 'lines' ? 'lines' : 'mesh';
221
+ return {
222
+ solid: {
223
+ kind,
224
+ key: `g${entry.id}`,
225
+ positions: mesh.positions,
226
+ normals: mesh.normals,
227
+ indices: mesh.indices,
228
+ },
229
+ unit: true,
230
+ };
231
+ }
232
+
233
+ function composed(local, unit) {
234
+ if (!unit && !local) return null;
235
+ if (!unit) return mat4.isIdentity(local, 1e-12) ? null : new Float64Array(local);
236
+ if (!local || mat4.isIdentity(local, 1e-12)) return mat4.isIdentity(unit, 1e-12) ? null : new Float64Array(unit);
237
+ const out = mat4.createF64();
238
+ mat4.multiply(out, local, unit);
239
+ return mat4.isIdentity(out, 1e-12) ? null : out;
240
+ }
241
+
242
+ function group(matrix, children, own) {
243
+ if (!children.length) return null;
244
+ const style = own || NO_STYLE;
245
+ const styled = style.material != null || style.color != null;
246
+ const m = matrix && !mat4.isIdentity(matrix, 1e-12) ? new Float64Array(matrix) : null;
247
+ if (!m && children.length === 1 && !styled) return children[0];
248
+ return { kind: 'group', matrix: m, children, own: style };
249
+ }
250
+
251
+ const FALLBACK = {
252
+ box: 'rounded box',
253
+ extrude: 'bevelled extrusion',
254
+ sweep: 'sweep',
255
+ torus: 'partial torus',
256
+ polyline: 'tube along a path',
257
+ revolve: 'revolve',
258
+ gear: 'bevelled gear',
259
+ mesh: 'authored mesh',
260
+ line: 'line',
261
+ polygon: 'polygon',
262
+ path: 'filled path',
263
+ };
264
+
265
+ /**
266
+ * @param {{graph: object, scene: object, doc: object}} compiled
267
+ * @param {(entry: object, lod: number) => object} meshOf
268
+ * @param {{lod?: number, structure?: 'keep'|'flatten'}} [opts]
269
+ */
270
+ export function buildExportModel(compiled, meshOf, { lod = 0, structure = 'keep' } = {}) {
271
+ const level = Math.max(0, Math.min(3, lod | 0));
272
+ const { graph, scene, doc } = compiled;
273
+ const materials = graph.materials;
274
+ const notes = [];
275
+ const counts = new Map();
276
+ const count = (key, n = 1) => counts.set(key, (counts.get(key) || 0) + n);
277
+ const curves = { count: 0 };
278
+ let meshTriangles = 0;
279
+
280
+ const addMesh = (solid) => {
281
+ if (solid.kind === 'mesh') meshTriangles += solid.indices.length / 3;
282
+ else if (solid.kind === 'lines') meshTriangles += solid.indices.length / 2;
283
+ };
284
+
285
+ let root = null;
286
+ if (structure === 'flatten') {
287
+ const solids = new Map();
288
+ const children = [];
289
+ for (const i of exportableInstances(scene)) {
290
+ const gi = scene.geom[i];
291
+ let solid = solids.get(gi);
292
+ if (!solid) {
293
+ const entry = scene.geometries[gi];
294
+ const mesh = meshOf(entry, Math.min(level, entry.lods - 1));
295
+ solid = {
296
+ kind: mesh.kind === 'lines' ? 'lines' : 'mesh',
297
+ key: `g${entry.id}`,
298
+ positions: mesh.positions,
299
+ normals: mesh.normals,
300
+ indices: mesh.indices,
301
+ doubleSided: !!entry.doubleSided,
302
+ };
303
+ solids.set(gi, solid);
304
+ addMesh(solid);
305
+ }
306
+ const mi = scene.mat[i];
307
+ const matrix = new Float64Array(16);
308
+ const src = scene.matrices;
309
+ const o = i * 16;
310
+ for (let k = 0; k < 16; k++) matrix[k] = src[o + k];
311
+ const mp = materials.list[mi];
312
+ children.push({
313
+ kind: 'shape',
314
+ matrix: mat4.isIdentity(matrix, 1e-7) ? null : matrix,
315
+ solid,
316
+ material: mi,
317
+ inherits: false,
318
+ doubleSided: !!(solid.doubleSided || (mp && mp.doubleSided)),
319
+ });
320
+ }
321
+ root = children.length === 1 ? children[0] : { kind: 'group', matrix: null, children };
322
+ notes.push('Flattened to the triangles of the glTF and OBJ export at this level of detail, one mesh per geometry.');
323
+ } else {
324
+ const reasonOf = new Map();
325
+ const walk = (node, inMat, inCol) => {
326
+ if (!node || node.visible === false) {
327
+ if (node && node.visible === false) count('hidden');
328
+ return null;
329
+ }
330
+ if (node.anims && node.anims.length) count('animated');
331
+ if (node.kind === 'light' || node.kind === 'camera') return null;
332
+ const local = node.scriptLocal || node.local || null;
333
+ let mat = inMat;
334
+ let col = inCol;
335
+ if (node.material !== null && node.material !== undefined) {
336
+ mat = node.material;
337
+ col = null;
338
+ }
339
+ if (node.color !== null && node.color !== undefined) col = node.color;
340
+ const inherits = (node.material === null || node.material === undefined) && (node.color === null || node.color === undefined);
341
+
342
+ const own = styleOf(node);
343
+ if (node.kind === 'lod') {
344
+ if (node.children.length > 1) count('lod', node.children.length - 1);
345
+ const chosen = node.children[0] ? walk(node.children[0], mat, col) : null;
346
+ return group(local, chosen ? [chosen] : [], own);
347
+ }
348
+ if (node.kind === 'repeat') {
349
+ const children = node.children.map((c) => walk(c, mat, col)).filter(Boolean);
350
+ if (!children.length || !node.count) return null;
351
+ return group(local, [{ kind: 'repeat', count: node.count, step: new Float64Array(node.step), children, own: NO_STYLE }], own);
352
+ }
353
+ if (node.kind === 'scatter') {
354
+ const children = node.children.map((c) => walk(c, mat, col)).filter(Boolean);
355
+ if (!children.length || !node.params || !node.params.count) return null;
356
+ if (node.params.randomMaterial) {
357
+ for (const id of node.params.randomMaterial) materials.resolve(id, null);
358
+ }
359
+ return group(local, [{ kind: 'scatter', params: node.params, children, own: NO_STYLE }], own);
360
+ }
361
+ if (node.kind === 'use') {
362
+ const parts = [];
363
+ if (node.target) {
364
+ const t = walk(node.target, mat, col);
365
+ if (t) parts.push(t);
366
+ }
367
+ for (const c of node.children) {
368
+ const t = walk(c, mat, col);
369
+ if (t) parts.push(t);
370
+ }
371
+ return group(local, parts, own);
372
+ }
373
+ if (node.kind === 'shape') {
374
+ if (!node.geometry) return null;
375
+ let built = parametric(node.el, level, curves);
376
+ if (!built) {
377
+ const why = FALLBACK[node.el.name] || node.el.name;
378
+ reasonOf.set(why, (reasonOf.get(why) || 0) + 1);
379
+ built = meshSolid(node, meshOf, level);
380
+ addMesh(built.solid);
381
+ }
382
+ const mi = materials.resolve(mat, col);
383
+ const mp = materials.list[mi];
384
+ if (mp && (mp.map || mp.normalMap || mp.emissiveMap || mp.metallicRoughnessMap || mp.occlusionMap)) count('texture');
385
+ const open = built.solid.kind === 'plane' || built.solid.kind === 'surface' || built.solid.kind === 'lines'
386
+ || built.solid.doubleSided || ((built.solid.kind === 'cylinder' || built.solid.kind === 'cone' || built.solid.kind === 'frustum') && built.solid.caps === false);
387
+ return {
388
+ kind: 'shape',
389
+ matrix: built.unit ? composed(local, node.unit) : composed(local, null),
390
+ meshMatrix: composed(local, node.unit),
391
+ entry: node.geometry,
392
+ solid: built.solid,
393
+ material: mi,
394
+ own,
395
+ inherits,
396
+ doubleSided: !!(open || node.geometry.doubleSided || (mp && mp.doubleSided)),
397
+ };
398
+ }
399
+ const children = [];
400
+ for (const c of node.children) {
401
+ const t = walk(c, mat, col);
402
+ if (t) children.push(t);
403
+ }
404
+ return group(local, children, own);
405
+ };
406
+ root = walk(graph.root, null, null) || { kind: 'group', matrix: null, children: [] };
407
+ for (const [why, n] of reasonOf) {
408
+ notes.push(`${n.toLocaleString('en-US')} ${why}${n === 1 ? '' : 's'} tessellated: the target has no primitive for ${n === 1 ? 'it' : 'them'}.`);
409
+ }
410
+ if (curves.count) notes.push(`${curves.count.toLocaleString('en-US')} curved outline${curves.count === 1 ? '' : 's'} flattened to polygons at this level of detail.`);
411
+ }
412
+
413
+ if (counts.get('lod')) notes.push(`${counts.get('lod').toLocaleString('en-US')} level-of-detail alternative${counts.get('lod') === 1 ? '' : 's'} omitted; the most detailed child is written.`);
414
+ if (counts.get('animated')) notes.push(`${counts.get('animated').toLocaleString('en-US')} animated transform${counts.get('animated') === 1 ? '' : 's'} written at the rest pose.`);
415
+ if (counts.get('hidden')) notes.push(`${counts.get('hidden').toLocaleString('en-US')} hidden element${counts.get('hidden') === 1 ? '' : 's'} omitted.`);
416
+ if (counts.get('texture')) notes.push(`${counts.get('texture').toLocaleString('en-US')} material${counts.get('texture') === 1 ? '' : 's'} lost texture maps; the base colour is kept.`);
417
+
418
+ const unit = (doc && doc.root && valueOf(doc.root, 'unit')) || 'm';
419
+ const baked = new Map();
420
+ return {
421
+ structure: structure === 'flatten' ? 'flatten' : 'keep',
422
+ lod: level,
423
+ unit,
424
+ root,
425
+ materials: materials.list,
426
+ resolve: (id, color) => materials.resolve(id, color),
427
+ /** Triangles of a shape's geometry at this level, in the geometry's own space. */
428
+ bakedMesh(entry) {
429
+ if (!entry) return null;
430
+ let hit = baked.get(entry);
431
+ if (hit) return hit;
432
+ const mesh = meshOf(entry, Math.min(level, entry.lods - 1));
433
+ hit = {
434
+ kind: mesh.kind === 'lines' ? 'lines' : 'mesh',
435
+ key: `g${entry.id}`,
436
+ positions: mesh.positions,
437
+ normals: mesh.normals,
438
+ indices: mesh.indices,
439
+ };
440
+ baked.set(entry, hit);
441
+ return hit;
442
+ },
443
+ lights: scene.lights || [],
444
+ cameras: scene.cameras || [],
445
+ environment: graph.environment,
446
+ notes,
447
+ meshTriangles,
448
+ };
449
+ }
450
+
451
+ /** step^0, step^1, … in the same order and with the same multiply as the flattener. */
452
+ export function repeatCopies(node) {
453
+ const out = [];
454
+ const acc = mat4.createF64();
455
+ const tmp = mat4.createF64();
456
+ for (let i = 0; i < node.count; i++) {
457
+ out.push(new Float64Array(acc));
458
+ mat4.multiply(tmp, acc, node.step);
459
+ acc.set(tmp);
460
+ }
461
+ return out;
462
+ }
463
+
464
+ /**
465
+ * One matrix per scatter copy, in flattener order. `material` is a material id
466
+ * when the copy draws a random material, otherwise null. Paint the copy with
467
+ * {@link paint} so a descendant that sets its own material keeps it.
468
+ */
469
+ export function scatterCopies(node) {
470
+ const p = node.params;
471
+ const rand = mulberry32(p.seed);
472
+ const lerp = (r, t) => r[0] + (r[1] - r[0]) * t;
473
+ const nx = p.grid ? p.grid[0] : 0;
474
+ const nz = p.grid ? p.grid[1] : 0;
475
+ const pos = [0, 0, 0];
476
+ const rot = [0, 0, 0];
477
+ const scl = [1, 1, 1];
478
+ const copies = [];
479
+ for (let i = 0; i < p.count; i++) {
480
+ const u0 = rand(), u1 = rand(), u2 = rand(), u3 = rand(), u4 = rand(), u5 = rand(), u6 = rand(), u7 = rand(), u8 = rand();
481
+ pos[1] = 0;
482
+ rot[0] = rot[1] = rot[2] = 0;
483
+ scl[0] = scl[1] = scl[2] = 1;
484
+ if (p.grid) {
485
+ const ix = i % nx;
486
+ const iz = Math.floor(i / nx);
487
+ pos[0] = (ix - (nx - 1) / 2) * p.spacing[0] + (u0 - 0.5) * p.jitter * p.spacing[0];
488
+ pos[2] = (iz - (nz - 1) / 2) * p.spacing[1] + (u1 - 0.5) * p.jitter * p.spacing[1];
489
+ } else if (p.radius) {
490
+ const r = p.radius * Math.sqrt(u0);
491
+ const a = u1 * Math.PI * 2;
492
+ pos[0] = r * es3dCos(a);
493
+ pos[2] = r * es3dSin(a);
494
+ } else {
495
+ pos[0] = (u0 - 0.5) * p.area[0];
496
+ pos[2] = (u1 - 0.5) * p.area[1];
497
+ }
498
+ if (p.randomRotation) {
499
+ const rr = p.randomRotation.ranges;
500
+ rot[0] = lerp(rr[0], u2);
501
+ rot[1] = lerp(rr[1], u3);
502
+ rot[2] = lerp(rr[2], u4);
503
+ }
504
+ if (p.randomScale) {
505
+ const rs = p.randomScale.ranges;
506
+ if (p.randomScale.uniform) scl[0] = scl[1] = scl[2] = lerp(rs[0], u5);
507
+ else {
508
+ scl[0] = lerp(rs[0], u5);
509
+ scl[1] = lerp(rs[1], u6);
510
+ scl[2] = lerp(rs[2], u7);
511
+ }
512
+ }
513
+ const place = mat4.createF64();
514
+ mat4.compose(place, pos, rot, scl);
515
+ let material = null;
516
+ if (p.randomMaterial && p.randomMaterial.length) {
517
+ material = p.randomMaterial[Math.min(p.randomMaterial.length - 1, Math.floor(u8 * p.randomMaterial.length))];
518
+ }
519
+ copies.push({ matrix: place, material });
520
+ }
521
+ return copies;
522
+ }
523
+
524
+ /**
525
+ * A copy of `node` whose materials are resolved as the flattener would,
526
+ * starting from an inherited material id and colour. A node that sets its
527
+ * own material or colour replaces what it inherits.
528
+ * @param {(id: string|null, color: number[]|null) => number} resolve
529
+ */
530
+ export function paint(node, inMat, inCol, resolve) {
531
+ let mat = inMat;
532
+ let col = inCol;
533
+ const own = node.own || NO_STYLE;
534
+ if (own.material != null) {
535
+ mat = own.material;
536
+ col = null;
537
+ }
538
+ if (own.color != null) col = own.color;
539
+ if (node.kind === 'shape') return { ...node, material: resolve(mat, col) };
540
+ if (!node.children) return node;
541
+ return { ...node, children: node.children.map((c) => paint(c, mat, col, resolve)) };
542
+ }
543
+
544
+ /**
545
+ * Closed triangle mesh of a straight extrusion of XY regions along Z.
546
+ * Outer contours are counter-clockwise. The signed volume is positive.
547
+ */
548
+ export function extrudeMesh(regions, depth, center) {
549
+ const z0 = center ? -depth / 2 : 0;
550
+ const z1 = z0 + depth;
551
+ const positions = [];
552
+ const normals = [];
553
+ const indices = [];
554
+ const pushV = (x, y, z, nx, ny, nz) => {
555
+ positions.push(x, y, z);
556
+ normals.push(nx, ny, nz);
557
+ return (positions.length / 3) - 1;
558
+ };
559
+ for (const region of regions) {
560
+ const tri = triangulateRegion({
561
+ outer: { points: Float64Array.from(region.outer), smooth: new Uint8Array(region.outer.length / 2) },
562
+ holes: region.holes.map((h) => ({ points: Float64Array.from(h), smooth: new Uint8Array(h.length / 2) })),
563
+ });
564
+ if (!tri || !tri.indices || !tri.indices.length) continue;
565
+ const baseTop = positions.length / 3;
566
+ const pts = tri.positions;
567
+ for (let i = 0; i < pts.length; i += 2) pushV(pts[i], pts[i + 1], z1, 0, 0, 1);
568
+ for (let i = 0; i < tri.indices.length; i += 3) {
569
+ indices.push(baseTop + tri.indices[i], baseTop + tri.indices[i + 1], baseTop + tri.indices[i + 2]);
570
+ }
571
+ const baseBot = positions.length / 3;
572
+ for (let i = 0; i < pts.length; i += 2) pushV(pts[i], pts[i + 1], z0, 0, 0, -1);
573
+ for (let i = 0; i < tri.indices.length; i += 3) {
574
+ indices.push(baseBot + tri.indices[i], baseBot + tri.indices[i + 2], baseBot + tri.indices[i + 1]);
575
+ }
576
+ const wall = (loop) => {
577
+ const n = loop.length / 2;
578
+ for (let i = 0; i < n; i++) {
579
+ const j = (i + 1) % n;
580
+ const ax = loop[i * 2], ay = loop[i * 2 + 1];
581
+ const bx = loop[j * 2], by = loop[j * 2 + 1];
582
+ const vx = bx - ax, vy = by - ay;
583
+ const nl = Math.hypot(vy, vx) || 1;
584
+ const nx = vy / nl, ny = -vx / nl;
585
+ const a = pushV(ax, ay, z0, nx, ny, 0);
586
+ const b = pushV(bx, by, z0, nx, ny, 0);
587
+ const c = pushV(bx, by, z1, nx, ny, 0);
588
+ const d = pushV(ax, ay, z1, nx, ny, 0);
589
+ indices.push(a, b, c, a, c, d);
590
+ }
591
+ };
592
+ wall(region.outer);
593
+ for (const h of region.holes) wall(h);
594
+ }
595
+ return { positions: Float32Array.from(positions), normals: Float32Array.from(normals), indices: Uint32Array.from(indices) };
596
+ }
597
+
598
+ const NO_STYLE = { material: null, color: null };
599
+
600
+ function styleOf(node) {
601
+ return {
602
+ material: node.material != null ? node.material : null,
603
+ color: node.color != null ? node.color : null,
604
+ };
605
+ }
606
+
607
+ /**
608
+ * Closed mesh of a frustum along Y, base radius at −height/2, top radius at
609
+ * +height/2. `segments` is the number of sides. Positive signed volume.
610
+ */
611
+ export function frustumMesh(radius, radiusTop, height, segments, caps) {
612
+ const n = Math.max(3, segments | 0);
613
+ const y0 = -height / 2;
614
+ const y1 = height / 2;
615
+ const positions = [];
616
+ const normals = [];
617
+ const indices = [];
618
+ const pushV = (x, y, z, nx, ny, nz) => {
619
+ positions.push(x, y, z);
620
+ normals.push(nx, ny, nz);
621
+ return (positions.length / 3) - 1;
622
+ };
623
+ const ring = [];
624
+ for (let i = 0; i < n; i++) {
625
+ const a = (i / n) * Math.PI * 2;
626
+ ring.push(Math.cos(a), Math.sin(a));
627
+ }
628
+ const slope = radius - radiusTop;
629
+ const nl = Math.hypot(height, slope) || 1;
630
+ const side = [];
631
+ for (let i = 0; i < n; i++) {
632
+ const c = ring[i * 2], s = ring[i * 2 + 1];
633
+ const nx = (c * height) / nl;
634
+ const ny = slope / nl;
635
+ const nz = (s * height) / nl;
636
+ side.push(pushV(c * radius, y0, s * radius, nx, ny, nz));
637
+ side.push(pushV(c * radiusTop, y1, s * radiusTop, nx, ny, nz));
638
+ }
639
+ for (let i = 0; i < n; i++) {
640
+ const j = (i + 1) % n;
641
+ const a = side[i * 2], b = side[j * 2], c = side[j * 2 + 1], d = side[i * 2 + 1];
642
+ indices.push(a, b, c, a, c, d);
643
+ }
644
+ if (caps && radius > 1e-8) {
645
+ const hub = pushV(0, y0, 0, 0, -1, 0);
646
+ const rim = [];
647
+ for (let i = 0; i < n; i++) rim.push(pushV(ring[i * 2] * radius, y0, ring[i * 2 + 1] * radius, 0, -1, 0));
648
+ for (let i = 0; i < n; i++) indices.push(hub, rim[(i + 1) % n], rim[i]);
649
+ }
650
+ if (caps && radiusTop > 1e-8) {
651
+ const hub = pushV(0, y1, 0, 0, 1, 0);
652
+ const rim = [];
653
+ for (let i = 0; i < n; i++) rim.push(pushV(ring[i * 2] * radiusTop, y1, ring[i * 2 + 1] * radiusTop, 0, 1, 0));
654
+ for (let i = 0; i < n; i++) indices.push(hub, rim[i], rim[(i + 1) % n]);
655
+ }
656
+ return { positions: Float32Array.from(positions), normals: Float32Array.from(normals), indices: Uint32Array.from(indices) };
657
+ }
658
+
659
+ /** Flat XY regions as one double-sided triangle mesh in the XY plane. */
660
+ export function surfaceMesh(regions) {
661
+ const positions = [];
662
+ const normals = [];
663
+ const indices = [];
664
+ for (const region of regions) {
665
+ const tri = triangulateRegion({
666
+ outer: { points: Float64Array.from(region.outer), smooth: new Uint8Array(region.outer.length / 2) },
667
+ holes: region.holes.map((h) => ({ points: Float64Array.from(h), smooth: new Uint8Array(h.length / 2) })),
668
+ });
669
+ if (!tri || !tri.indices) continue;
670
+ const base = positions.length / 3;
671
+ const pts = tri.positions;
672
+ for (let i = 0; i < pts.length; i += 2) {
673
+ positions.push(pts[i], pts[i + 1], 0);
674
+ normals.push(0, 0, 1);
675
+ }
676
+ for (let i = 0; i < tri.indices.length; i++) indices.push(base + tri.indices[i]);
677
+ }
678
+ return { positions: Float32Array.from(positions), normals: Float32Array.from(normals), indices: Uint32Array.from(indices) };
679
+ }
@@ -1,6 +1,6 @@
1
1
  // File loading for ES3D Studio: detects the content type from magic bytes and
2
2
  // extension, transparently decompresses gzip, and converts foreign formats
3
- // (SVG, OBJ, STL, STEP, glTF) into ES3D text with the same importers as the
3
+ // (SVG, OBJ, STL, STEP, glTF, X3D, USDA, OpenSCAD) into ES3D text with the same importers as the
4
4
  // converter page, so they can be inspected and edited like any other document.
5
5
 
6
6
  import { isGzip, gunzip } from './compression.js';
@@ -23,6 +23,7 @@ export async function loadFile(file) {
23
23
  const kind = detectKind(name, bytes) || 'es3d';
24
24
  if (kind === 'es3d') return { name, kind, text: new TextDecoder('utf-8').decode(bytes), bytes, originalSize, note };
25
25
  const r = await importFiles([{ name, bytes }]);
26
- const warn = r.warnings.length ? ` (${r.warnings.join(' ')})` : '';
26
+ const said = [...r.warnings, ...r.notes];
27
+ const warn = said.length ? ` (${said.join(' ')})` : '';
27
28
  return { name, kind, bytes, originalSize, text: r.text, note: (note ? note + '; ' : '') + `converted from ${INPUT_KINDS[kind]} to ES3D${warn}` };
28
29
  }