@es3d/es3d 0.0.0-stage → 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 (82) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +85 -2
  3. package/docs/REFERENCE.md +581 -0
  4. package/docs/SPEC.md +417 -0
  5. package/package.json +50 -4
  6. package/scripts/es3d-cli.mjs +377 -0
  7. package/src/cache/geometry-store.js +392 -0
  8. package/src/cache/mesh-bundle.js +123 -0
  9. package/src/cache/sha256.js +73 -0
  10. package/src/format/ast.js +238 -0
  11. package/src/format/colors.js +93 -0
  12. package/src/format/compact-parser.js +171 -0
  13. package/src/format/compact-tokenizer.js +165 -0
  14. package/src/format/errors.js +247 -0
  15. package/src/format/json.js +55 -0
  16. package/src/format/limits.js +14 -0
  17. package/src/format/parse.js +92 -0
  18. package/src/format/schema.js +617 -0
  19. package/src/format/serializer.js +333 -0
  20. package/src/format/validator.js +330 -0
  21. package/src/format/value-error.js +12 -0
  22. package/src/format/value-format.js +314 -0
  23. package/src/format/values.js +459 -0
  24. package/src/format/xml-parser.js +136 -0
  25. package/src/format/xml-tokenizer.js +237 -0
  26. package/src/geometry/contour.js +86 -0
  27. package/src/geometry/extrude.js +175 -0
  28. package/src/geometry/gear.js +85 -0
  29. package/src/geometry/geometry-cache.js +159 -0
  30. package/src/geometry/lod-error.js +178 -0
  31. package/src/geometry/mesh.js +458 -0
  32. package/src/geometry/primitives.js +299 -0
  33. package/src/geometry/registry.js +645 -0
  34. package/src/geometry/revolve.js +136 -0
  35. package/src/geometry/sweep.js +441 -0
  36. package/src/index.js +47 -0
  37. package/src/io/compression.js +67 -0
  38. package/src/io/convert.js +236 -0
  39. package/src/io/download.js +13 -0
  40. package/src/io/external-scene.js +127 -0
  41. package/src/io/file-loader.js +28 -0
  42. package/src/io/gltf.js +630 -0
  43. package/src/io/mesh-import.js +104 -0
  44. package/src/io/obj.js +352 -0
  45. package/src/io/step-brep.js +439 -0
  46. package/src/io/step-exact.js +1149 -0
  47. package/src/io/step.js +1418 -0
  48. package/src/io/stl.js +193 -0
  49. package/src/io/svg-import.js +787 -0
  50. package/src/material/materials.js +154 -0
  51. package/src/math/mat4.js +475 -0
  52. package/src/math/random.js +30 -0
  53. package/src/math/trig.js +65 -0
  54. package/src/math/vec3.js +81 -0
  55. package/src/optimize/optimizer.js +579 -0
  56. package/src/path/path-flatten.js +322 -0
  57. package/src/path/path-parser.js +332 -0
  58. package/src/path/shapes.js +129 -0
  59. package/src/path/triangulate.js +517 -0
  60. package/src/pipeline.js +74 -0
  61. package/src/render/camera.js +168 -0
  62. package/src/render/frame-builder.js +362 -0
  63. package/src/render/geometry-pool.js +149 -0
  64. package/src/render/lighting.js +149 -0
  65. package/src/render/orbit-controls.js +237 -0
  66. package/src/render/renderer.js +258 -0
  67. package/src/render/scroll-timeline.js +104 -0
  68. package/src/render/shaders/glsl.js +285 -0
  69. package/src/render/shaders/wgsl.js +252 -0
  70. package/src/render/stage.js +364 -0
  71. package/src/render/texture-cache.js +40 -0
  72. package/src/render/textures.js +223 -0
  73. package/src/render/webgl2/webgl2-backend.js +586 -0
  74. package/src/render/webgpu/webgpu-backend.js +482 -0
  75. package/src/scene/animation.js +171 -0
  76. package/src/scene/camera-constraints.js +117 -0
  77. package/src/scene/flatten.js +754 -0
  78. package/src/scene/picking.js +161 -0
  79. package/src/scene/scene-graph.js +320 -0
  80. package/src/transform/transform.js +211 -0
  81. package/src/wasm/kernels-binary.js +3 -0
  82. package/src/wasm/kernels.js +385 -0
@@ -0,0 +1,237 @@
1
+ // Tokenizer for the ES3D markup syntax (an XML subset).
2
+ //
3
+ // A hand written, allocation-light scanner: the parser pulls tokens one at a
4
+ // time with next(). Every token carries character offsets so the parser and
5
+ // validator can report exact line/column positions and the editor can perform
6
+ // lossless text edits.
7
+
8
+ import { Diagnostic, ES3DError } from './errors.js';
9
+
10
+ export const Tok = Object.freeze({
11
+ OPEN: 'open', // <name
12
+ ATTR: 'attr', // name="value"
13
+ OPEN_END: 'openEnd', // > or />
14
+ CLOSE: 'close', // </name>
15
+ TEXT: 'text',
16
+ COMMENT: 'comment',
17
+ CDATA: 'cdata',
18
+ PI: 'pi', // <?xml ... ?>
19
+ DOCTYPE: 'doctype',
20
+ EOF: 'eof',
21
+ });
22
+
23
+ const LT = 60;
24
+ const GT = 62;
25
+ const SLASH = 47;
26
+ const EQ = 61;
27
+ const DQ = 34;
28
+ const SQ = 39;
29
+ const BANG = 33;
30
+ const QM = 63;
31
+ const AMP = 38;
32
+
33
+ function isSpace(c) {
34
+ return c === 32 || c === 10 || c === 9 || c === 13;
35
+ }
36
+
37
+ function isNameStart(c) {
38
+ return (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c === 95 || c === 58 || c > 127;
39
+ }
40
+
41
+ function isNameChar(c) {
42
+ return isNameStart(c) || (c >= 48 && c <= 57) || c === 45 || c === 46;
43
+ }
44
+
45
+ const NAMED_ENTITIES = { lt: '<', gt: '>', amp: '&', quot: '"', apos: "'" };
46
+
47
+ /** Decode XML entity references; reports bad references with exact offsets. */
48
+ export function decodeEntities(raw, baseOffset, fail) {
49
+ if (raw.indexOf('&') < 0) return raw;
50
+ let out = '';
51
+ let i = 0;
52
+ while (i < raw.length) {
53
+ const amp = raw.indexOf('&', i);
54
+ if (amp < 0) {
55
+ out += raw.slice(i);
56
+ break;
57
+ }
58
+ out += raw.slice(i, amp);
59
+ const semi = raw.indexOf(';', amp);
60
+ if (semi < 0 || semi - amp > 12) {
61
+ fail('E_BAD_ENTITY', "Unescaped '&'. Write '&amp;' for a literal ampersand.", baseOffset + amp, 1);
62
+ }
63
+ const name = raw.slice(amp + 1, semi);
64
+ let ch;
65
+ if (name[0] === '#') {
66
+ const code = name[1] === 'x' || name[1] === 'X' ? parseInt(name.slice(2), 16) : parseInt(name.slice(1), 10);
67
+ if (!Number.isFinite(code) || code < 0 || code > 0x10ffff) {
68
+ fail('E_BAD_ENTITY', `Invalid character reference '&${name};'.`, baseOffset + amp, semi - amp + 1);
69
+ }
70
+ ch = String.fromCodePoint(code);
71
+ } else {
72
+ ch = NAMED_ENTITIES[name];
73
+ if (ch === undefined) {
74
+ fail('E_BAD_ENTITY', `Unknown entity '&${name};'. Only &lt; &gt; &amp; &quot; &apos; and numeric references are allowed.`, baseOffset + amp, semi - amp + 1);
75
+ }
76
+ }
77
+ out += ch;
78
+ i = semi + 1;
79
+ }
80
+ return out;
81
+ }
82
+
83
+ export class XmlTokenizer {
84
+ constructor(text, lineIndex = null) {
85
+ this.s = text;
86
+ this.i = 0;
87
+ this.inTag = false;
88
+ this.lineIndex = lineIndex;
89
+ this.n = text.length;
90
+ // Skip a UTF-8 byte order mark.
91
+ if (text.charCodeAt(0) === 0xfeff) this.i = 1;
92
+ }
93
+
94
+ fail(code, message, offset, length = 1, extra = {}) {
95
+ const d = new Diagnostic({ code, message, offset, length, ...extra });
96
+ d.locate(this.lineIndex);
97
+ throw new ES3DError([d], this.lineIndex);
98
+ }
99
+
100
+ next() {
101
+ return this.inTag ? this.nextInTag() : this.nextContent();
102
+ }
103
+
104
+ nextContent() {
105
+ const s = this.s;
106
+ const start = this.i;
107
+ if (start >= this.n) return { type: Tok.EOF, start, end: start };
108
+ if (s.charCodeAt(start) !== LT) {
109
+ let end = s.indexOf('<', start);
110
+ if (end < 0) end = this.n;
111
+ this.i = end;
112
+ const raw = s.slice(start, end);
113
+ return { type: Tok.TEXT, raw, text: null, start, end };
114
+ }
115
+ const c1 = s.charCodeAt(start + 1);
116
+ if (c1 === BANG) {
117
+ if (s.startsWith('<!--', start)) {
118
+ const end = s.indexOf('-->', start + 4);
119
+ if (end < 0) this.fail('E_UNTERMINATED_COMMENT', "Unterminated comment: missing '-->'.", start, 4);
120
+ this.i = end + 3;
121
+ return { type: Tok.COMMENT, text: s.slice(start + 4, end), start, end: end + 3 };
122
+ }
123
+ if (s.startsWith('<![CDATA[', start)) {
124
+ const end = s.indexOf(']]>', start + 9);
125
+ if (end < 0) this.fail('E_UNTERMINATED_CDATA', "Unterminated CDATA section: missing ']]>'.", start, 9);
126
+ this.i = end + 3;
127
+ return { type: Tok.CDATA, text: s.slice(start + 9, end), start, end: end + 3 };
128
+ }
129
+ if (s.startsWith('<!DOCTYPE', start) || s.startsWith('<!doctype', start)) {
130
+ const end = s.indexOf('>', start);
131
+ if (end < 0) this.fail('E_UNTERMINATED_DOCTYPE', "Unterminated DOCTYPE: missing '>'.", start, 9);
132
+ this.i = end + 1;
133
+ return { type: Tok.DOCTYPE, start, end: end + 1 };
134
+ }
135
+ this.fail('E_SYNTAX', "Unexpected '<!'. Comments are written <!-- like this -->.", start, 2);
136
+ }
137
+ if (c1 === QM) {
138
+ const end = s.indexOf('?>', start + 2);
139
+ if (end < 0) this.fail('E_UNTERMINATED_PI', "Unterminated processing instruction: missing '?>'.", start, 2);
140
+ this.i = end + 2;
141
+ let j = start + 2;
142
+ while (j < end && isNameChar(s.charCodeAt(j))) j++;
143
+ return { type: Tok.PI, target: s.slice(start + 2, j), start, end: end + 2 };
144
+ }
145
+ if (c1 === SLASH) {
146
+ const nameStart = start + 2;
147
+ let j = nameStart;
148
+ if (!isNameStart(s.charCodeAt(j))) this.fail('E_SYNTAX', "Expected an element name after '</'.", j, 1);
149
+ while (j < this.n && isNameChar(s.charCodeAt(j))) j++;
150
+ const nameEnd = j;
151
+ while (j < this.n && isSpace(s.charCodeAt(j))) j++;
152
+ if (s.charCodeAt(j) !== GT) this.fail('E_SYNTAX', `Expected '>' to finish closing tag </${s.slice(nameStart, nameEnd)}>.`, j, 1);
153
+ this.i = j + 1;
154
+ return { type: Tok.CLOSE, name: s.slice(nameStart, nameEnd), start, end: j + 1, nameStart, nameEnd };
155
+ }
156
+ const nameStart = start + 1;
157
+ if (!isNameStart(c1)) {
158
+ this.fail('E_SYNTAX', "Unexpected '<'. Element names must start with a letter; write '&lt;' for a literal '<'.", start, 1);
159
+ }
160
+ let j = nameStart + 1;
161
+ while (j < this.n && isNameChar(s.charCodeAt(j))) j++;
162
+ this.i = j;
163
+ this.inTag = true;
164
+ return { type: Tok.OPEN, name: s.slice(nameStart, j), start, nameStart, nameEnd: j };
165
+ }
166
+
167
+ nextInTag() {
168
+ const s = this.s;
169
+ let i = this.i;
170
+ const hadSpace = i < this.n && isSpace(s.charCodeAt(i));
171
+ while (i < this.n && isSpace(s.charCodeAt(i))) i++;
172
+ if (i >= this.n) this.fail('E_UNEXPECTED_EOF', "Unexpected end of file inside a tag: missing '>'.", i, 0);
173
+ const c = s.charCodeAt(i);
174
+ if (c === GT) {
175
+ this.i = i + 1;
176
+ this.inTag = false;
177
+ return { type: Tok.OPEN_END, selfClosing: false, start: i, end: i + 1 };
178
+ }
179
+ if (c === SLASH) {
180
+ if (s.charCodeAt(i + 1) !== GT) this.fail('E_SYNTAX', "Expected '/>' to close the tag.", i, 1);
181
+ this.i = i + 2;
182
+ this.inTag = false;
183
+ return { type: Tok.OPEN_END, selfClosing: true, start: i, end: i + 2 };
184
+ }
185
+ if (!isNameStart(c)) {
186
+ if (c === DQ || c === SQ) this.fail('E_SYNTAX', 'Unexpected quoted value: an attribute name is missing.', i, 1);
187
+ if (c === LT) this.fail('E_SYNTAX', "Unexpected '<': the previous tag is missing its '>'.", i, 1);
188
+ this.fail('E_SYNTAX', `Unexpected character '${s[i]}' in tag.`, i, 1);
189
+ }
190
+ if (!hadSpace) this.fail('E_SYNTAX', 'Attributes must be separated by whitespace.', i, 1);
191
+ const nameStart = i;
192
+ while (i < this.n && isNameChar(s.charCodeAt(i))) i++;
193
+ const nameEnd = i;
194
+ const name = s.slice(nameStart, nameEnd);
195
+ while (i < this.n && isSpace(s.charCodeAt(i))) i++;
196
+ if (s.charCodeAt(i) !== EQ) {
197
+ this.fail('E_SYNTAX', `Attribute '${name}' has no value. Write ${name}="...".`, nameStart, nameEnd - nameStart);
198
+ }
199
+ i++;
200
+ while (i < this.n && isSpace(s.charCodeAt(i))) i++;
201
+ const q = s.charCodeAt(i);
202
+ if (q !== DQ && q !== SQ) {
203
+ this.fail('E_UNQUOTED_ATTRIBUTE', `The value of attribute '${name}' must be quoted.`, i, 1, { hint: `Write ${name}="value".` });
204
+ }
205
+ const valueStart = i + 1;
206
+ const close = s.indexOf(q === DQ ? '"' : "'", valueStart);
207
+ if (close < 0) {
208
+ this.fail('E_UNTERMINATED_ATTRIBUTE', `Unterminated value for attribute '${name}': missing closing quote.`, i, 1);
209
+ }
210
+ const lt = s.indexOf('<', valueStart);
211
+ if (lt >= 0 && lt < close) {
212
+ this.fail('E_UNTERMINATED_ATTRIBUTE', `Unterminated value for attribute '${name}': found '<' before the closing quote.`, i, 1);
213
+ }
214
+ this.i = close + 1;
215
+ const raw = s.slice(valueStart, close);
216
+ let value = raw;
217
+ let decoded = false;
218
+ if (raw.indexOf('&') >= 0) {
219
+ value = decodeEntities(raw, valueStart, (code, msg, off, len) => this.fail(code, msg, off, len));
220
+ decoded = true;
221
+ }
222
+ return {
223
+ type: Tok.ATTR,
224
+ name,
225
+ nameStart,
226
+ nameEnd,
227
+ value,
228
+ valueStart,
229
+ valueEnd: close,
230
+ quote: q === DQ ? '"' : "'",
231
+ decoded,
232
+ };
233
+ }
234
+ }
235
+
236
+ // keeps the tokenizer's hidden class alive between documents (see MeshBuilder.shapeKeeper)
237
+ XmlTokenizer.shapeKeeper = new XmlTokenizer('', null);
@@ -0,0 +1,86 @@
1
+ // Per-vertex data for 2D contours shared by the extrusion, sweep and revolve
2
+ // generators: edge normals, smoothed vertex normals, miter (inset) vectors and
3
+ // cumulative arc length. Contours are oriented with the filled side on the
4
+ // left, so the right-hand perpendicular of an edge is its outward normal.
5
+
6
+ const MAX_MITER = 4;
7
+
8
+ export function contourFrames(points, smooth, closed = true) {
9
+ const n = points.length / 2;
10
+ const edges = closed ? n : n - 1;
11
+ const edgeN = new Float64Array(edges * 2);
12
+ for (let i = 0; i < edges; i++) {
13
+ const j = (i + 1) % n;
14
+ const dx = points[j * 2] - points[i * 2];
15
+ const dy = points[j * 2 + 1] - points[i * 2 + 1];
16
+ const l = Math.hypot(dx, dy) || 1;
17
+ edgeN[i * 2] = dy / l;
18
+ edgeN[i * 2 + 1] = -dx / l;
19
+ }
20
+ const vertN = new Float64Array(n * 2);
21
+ const miter = new Float64Array(n * 2);
22
+ const corner = new Uint8Array(n);
23
+ for (let i = 0; i < n; i++) {
24
+ const hasPrev = closed || i > 0;
25
+ const hasNext = closed || i < n - 1;
26
+ const pe = hasPrev ? (i - 1 + edges) % edges : -1;
27
+ const ne = hasNext ? i % edges : -1;
28
+ let ax, ay, bx, by;
29
+ if (pe >= 0) {
30
+ ax = edgeN[pe * 2];
31
+ ay = edgeN[pe * 2 + 1];
32
+ }
33
+ if (ne >= 0) {
34
+ bx = edgeN[ne * 2];
35
+ by = edgeN[ne * 2 + 1];
36
+ }
37
+ if (pe < 0) {
38
+ ax = bx;
39
+ ay = by;
40
+ }
41
+ if (ne < 0) {
42
+ bx = ax;
43
+ by = ay;
44
+ }
45
+ let mx = ax + bx, my = ay + by;
46
+ let ml = Math.hypot(mx, my);
47
+ if (ml < 1e-9) {
48
+ mx = ax;
49
+ my = ay;
50
+ ml = 1;
51
+ }
52
+ mx /= ml;
53
+ my /= ml;
54
+ vertN[i * 2] = mx;
55
+ vertN[i * 2 + 1] = my;
56
+ const cosHalf = Math.max(mx * ax + my * ay, 1 / MAX_MITER);
57
+ miter[i * 2] = mx / cosHalf;
58
+ miter[i * 2 + 1] = my / cosHalf;
59
+ corner[i] = smooth && smooth[i] ? 0 : 1;
60
+ }
61
+ const arc = new Float64Array(n + 1);
62
+ for (let i = 1; i <= n; i++) {
63
+ if (!closed && i === n) {
64
+ arc[i] = arc[i - 1];
65
+ break;
66
+ }
67
+ const a = (i - 1) % n, b = i % n;
68
+ arc[i] = arc[i - 1] + Math.hypot(points[b * 2] - points[a * 2], points[b * 2 + 1] - points[a * 2 + 1]);
69
+ }
70
+ return { n, edges, edgeN, vertN, miter, corner, arc, length: arc[closed ? n : n - 1] };
71
+ }
72
+
73
+ /** Bounds of a set of 2D point arrays. */
74
+ export function bounds2(list) {
75
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
76
+ for (const pts of list) {
77
+ for (let i = 0; i < pts.length; i += 2) {
78
+ const x = pts[i], y = pts[i + 1];
79
+ if (x < minX) minX = x;
80
+ if (y < minY) minY = y;
81
+ if (x > maxX) maxX = x;
82
+ if (y > maxY) maxY = y;
83
+ }
84
+ }
85
+ return { minX, minY, maxX, maxY, size: Math.max(maxX - minX, maxY - minY) || 1 };
86
+ }
@@ -0,0 +1,175 @@
1
+ // Flat fills and extrusions of 2D regions.
2
+ //
3
+ // extrude: caps are the triangulated regions (front at z = depth facing +Z,
4
+ // back at z = 0 facing -Z); side walls follow the contours where the fill
5
+ // state changes. An optional quarter-circle bevel insets the caps by `bevel`
6
+ // so the silhouette of the 2D path is kept at the widest point.
7
+
8
+ import { Mesh, MeshBuilder } from './mesh.js';
9
+ import { triangulateRegion } from '../path/shapes.js';
10
+ import { contourFrames, bounds2 } from './contour.js';
11
+ import { extrudeWasm } from '../wasm/kernels.js';
12
+
13
+ function regionBounds(regions) {
14
+ const lists = [];
15
+ for (const r of regions) {
16
+ lists.push(r.outer.points);
17
+ for (const h of r.holes) lists.push(h.points);
18
+ }
19
+ return bounds2(lists);
20
+ }
21
+
22
+ /** Warn about a triangulation that does not cover its region (self-intersecting input). */
23
+ export function reportTriangulation(error, report) {
24
+ if (error > 0.01 && report) {
25
+ report('warning', 'W_SELF_INTERSECTION', `A filled contour appears to be self-intersecting; the triangulation covers ${(100 * (1 - error)).toFixed(1)}% of the expected area.`);
26
+ }
27
+ }
28
+
29
+ /** Triangulate regions once; reports malformed (self-intersecting) input. */
30
+ export function triangulateRegions(regions, report) {
31
+ const out = [];
32
+ for (const r of regions) {
33
+ const t = triangulateRegion(r);
34
+ reportTriangulation(t.error, report);
35
+ out.push({ region: r, ...t });
36
+ }
37
+ return out;
38
+ }
39
+
40
+ /** Flat double-sided fill in the XY plane (normal +Z). */
41
+ export function flatMesh(regions, report) {
42
+ const tris = triangulateRegions(regions, report);
43
+ let vertices = 0, indices = 0;
44
+ for (const t of tris) {
45
+ vertices += t.positions.length / 2;
46
+ indices += t.indices.length;
47
+ }
48
+ const b = new MeshBuilder('triangles', vertices, indices);
49
+ const bb = regionBounds(regions);
50
+ for (const t of tris) {
51
+ const base = b.vertexCount;
52
+ const p = t.positions;
53
+ for (let i = 0; i < p.length; i += 2) {
54
+ b.vertex(p[i], p[i + 1], 0, 0, 0, 1, (p[i] - bb.minX) / bb.size, (p[i + 1] - bb.minY) / bb.size);
55
+ }
56
+ const idx = t.indices;
57
+ for (let i = 0; i < idx.length; i += 3) b.tri(base + idx[i], base + idx[i + 1], base + idx[i + 2]);
58
+ }
59
+ return b.build();
60
+ }
61
+
62
+ /**
63
+ * Inset positions of a contour: p - a * miter (a = 0 for contours that are not walls).
64
+ */
65
+ function insetPoints(contour, amount) {
66
+ const pts = contour.points;
67
+ if (!amount || contour.isWall === false) return pts;
68
+ const f = contourFrames(pts, contour.smooth, true);
69
+ const out = new Float64Array(pts.length);
70
+ for (let i = 0; i < pts.length; i += 2) {
71
+ out[i] = pts[i] - f.miter[i] * amount;
72
+ out[i + 1] = pts[i + 1] - f.miter[i + 1] * amount;
73
+ }
74
+ return out;
75
+ }
76
+
77
+ /** Profile rings (back to front) for a straight or bevelled wall. */
78
+ function wallProfile(depth, bevel, segs) {
79
+ const rings = [];
80
+ if (!(bevel > 0)) {
81
+ rings.push({ inset: 0, z: 0, nr: 1, nz: 0 }, { inset: 0, z: depth, nr: 1, nz: 0 });
82
+ return rings;
83
+ }
84
+ for (let k = 0; k <= segs; k++) {
85
+ const phi = (k / segs) * (Math.PI / 2);
86
+ rings.push({ inset: bevel * (1 - Math.sin(phi)), z: bevel * (1 - Math.cos(phi)), nr: Math.sin(phi), nz: -Math.cos(phi) });
87
+ }
88
+ for (let k = segs; k >= 0; k--) {
89
+ const phi = (k / segs) * (Math.PI / 2);
90
+ const ring = { inset: bevel * (1 - Math.sin(phi)), z: depth - bevel * (1 - Math.cos(phi)), nr: Math.sin(phi), nz: Math.cos(phi) };
91
+ // the straight section starts where the back bevel ends
92
+ if (k === segs && Math.abs(ring.z - rings[rings.length - 1].z) < 1e-12) continue;
93
+ rings.push(ring);
94
+ }
95
+ return rings;
96
+ }
97
+
98
+ /**
99
+ * @param {{regions: object[], walls: object[]}} shape from buildRegions (contours carry `smooth` and `isWall`)
100
+ * @param {{depth:number, bevel?:number, bevelSegments?:number, center?:boolean}} o
101
+ */
102
+ export function extrudeMesh(shape, o, report) {
103
+ const depth = o.depth;
104
+ const bevel = Math.min(o.bevel || 0, depth / 2);
105
+ const segs = o.bevelSegments || 3;
106
+ const z0 = o.center ? -depth / 2 : 0;
107
+ const bb = regionBounds(shape.regions);
108
+ const S = bb.size;
109
+ const rings = wallProfile(depth, bevel, segs);
110
+ const w = extrudeWasm(shape, bevel, rings, z0, z0 + depth, bb.minX, bb.minY, S);
111
+ if (w) {
112
+ for (const e of w.errors) reportTriangulation(e, report);
113
+ return new Mesh({ positions: w.positions, normals: w.normals, uvs: w.uvs, indices: w.indices, kind: 'triangles' });
114
+ }
115
+
116
+ // caps: the inset contours are triangulated directly (re-using the original
117
+ // triangulation with moved vertices can flip long thin triangles)
118
+ const capRegions = shape.regions.map((r) => ({
119
+ outer: { ...r.outer, points: insetPoints(r.outer, bevel) },
120
+ holes: r.holes.map((h) => ({ ...h, points: insetPoints(h, bevel) })),
121
+ }));
122
+ const caps = triangulateRegions(capRegions, report);
123
+ const walls = shape.walls.map((c) => ({ pts: c.points, f: contourFrames(c.points, c.smooth, true) }));
124
+ // count first, so the mesh is allocated once at its final size
125
+ let vertices = 0, indices = 0;
126
+ for (const t of caps) {
127
+ vertices += t.positions.length;
128
+ indices += 2 * t.indices.length;
129
+ }
130
+ for (const { f } of walls) {
131
+ vertices += f.n * rings.length * 2;
132
+ indices += f.n * (rings.length - 1) * 6;
133
+ }
134
+ const b = new MeshBuilder('triangles', vertices, indices);
135
+ for (const t of caps) {
136
+ const flat = t.positions;
137
+ const idx = t.indices;
138
+ for (const [z, nz] of [[z0 + depth, 1], [z0, -1]]) {
139
+ const base = b.vertexCount;
140
+ for (let i = 0; i < flat.length; i += 2) {
141
+ b.vertex(flat[i], flat[i + 1], z, 0, 0, nz, (flat[i] - bb.minX) / S, (flat[i + 1] - bb.minY) / S);
142
+ }
143
+ for (let i = 0; i < idx.length; i += 3) {
144
+ if (nz > 0) b.tri(base + idx[i], base + idx[i + 1], base + idx[i + 2]);
145
+ else b.tri(base + idx[i], base + idx[i + 2], base + idx[i + 1]);
146
+ }
147
+ }
148
+ }
149
+
150
+ // walls
151
+ for (const { pts, f } of walls) {
152
+ const n = f.n;
153
+ for (let e = 0; e < n; e++) {
154
+ const i0 = e, i1 = (e + 1) % n;
155
+ // normal at each end of the edge: the edge's own at corners, else the vertex's
156
+ const n0x = f.corner[i0] ? f.edgeN[e * 2] : f.vertN[i0 * 2], n0y = f.corner[i0] ? f.edgeN[e * 2 + 1] : f.vertN[i0 * 2 + 1];
157
+ const n1x = f.corner[i1] ? f.edgeN[e * 2] : f.vertN[i1 * 2], n1y = f.corner[i1] ? f.edgeN[e * 2 + 1] : f.vertN[i1 * 2 + 1];
158
+ const u0 = f.arc[e] / S, u1 = f.arc[e + 1] / S;
159
+ let prevA = -1, prevB = -1;
160
+ for (const ring of rings) {
161
+ const ax = pts[i0 * 2] - f.miter[i0 * 2] * ring.inset;
162
+ const ay = pts[i0 * 2 + 1] - f.miter[i0 * 2 + 1] * ring.inset;
163
+ const bx = pts[i1 * 2] - f.miter[i1 * 2] * ring.inset;
164
+ const by = pts[i1 * 2 + 1] - f.miter[i1 * 2 + 1] * ring.inset;
165
+ const z = z0 + ring.z;
166
+ const a = b.vertex(ax, ay, z, n0x * ring.nr, n0y * ring.nr, ring.nz, u0, ring.z / S);
167
+ const c = b.vertex(bx, by, z, n1x * ring.nr, n1y * ring.nr, ring.nz, u1, ring.z / S);
168
+ if (prevA >= 0) b.quad(prevA, prevB, c, a);
169
+ prevA = a;
170
+ prevB = c;
171
+ }
172
+ }
173
+ }
174
+ return b.build();
175
+ }
@@ -0,0 +1,85 @@
1
+ // Involute spur gear outline (module 1); the descriptor scales it by the module.
2
+ //
3
+ // A gear is described by a handful of parameters instead of thousands of
4
+ // vertices: the outline is generated from the involute of the base circle,
5
+ // then handed to the regular extrusion, bevel included if you asked for one.
6
+ // A 2D outline goes in. The generic extrude operator does the rest.
7
+
8
+ import { gearOutlineWasm } from '../wasm/kernels.js';
9
+
10
+ const TAU = Math.PI * 2;
11
+
12
+ function inv(a) {
13
+ return Math.tan(a) - a;
14
+ }
15
+
16
+ /**
17
+ * @returns {{outline: {points: Float64Array, smooth: Uint8Array, closed: true}, outerRadius: number, rootRadius: number}}
18
+ */
19
+ export function gearOutline(teeth, pressureAngleDeg, flankSamples = 6, arcSamples = 3) {
20
+ const fast = gearOutlineWasm(teeth, pressureAngleDeg, flankSamples, arcSamples);
21
+ if (fast) return fast;
22
+ const N = teeth;
23
+ const alpha = (pressureAngleDeg * Math.PI) / 180;
24
+ const rp = N / 2;
25
+ const rb = rp * Math.cos(alpha);
26
+ let ra = rp + 1;
27
+ const rf = rp - 1.25;
28
+ const psi = Math.PI / (2 * N);
29
+ const invA = inv(alpha);
30
+ const half = (r) => {
31
+ // half tooth angle at radius r (r >= rb)
32
+ const ar = Math.acos(Math.min(1, rb / r));
33
+ return psi + invA - inv(ar);
34
+ };
35
+ // pointed teeth: shrink the tip radius until the tip keeps some width
36
+ if (half(ra) < psi * 0.15) {
37
+ let lo = Math.max(rb, rf), hi = ra;
38
+ for (let k = 0; k < 40; k++) {
39
+ const mid = (lo + hi) / 2;
40
+ if (half(mid) > psi * 0.15) lo = mid;
41
+ else hi = mid;
42
+ }
43
+ ra = lo;
44
+ }
45
+ const rStart = Math.max(rb, rf);
46
+ const pts = [];
47
+ const smooth = [];
48
+ const add = (r, a, s) => {
49
+ pts.push(r * Math.cos(a), r * Math.sin(a));
50
+ smooth.push(s ? 1 : 0);
51
+ };
52
+ const pitchAngle = TAU / N;
53
+ for (let k = 0; k < N; k++) {
54
+ const c = k * pitchAngle;
55
+ const leftRoot = c - half(rStart);
56
+ const rightRoot = c + half(rStart);
57
+ const prevRightRoot = c - pitchAngle + half(rStart);
58
+ // root arc from the previous tooth to this one
59
+ for (let i = 1; i < arcSamples; i++) {
60
+ add(rf, prevRightRoot + ((leftRoot - prevRightRoot) * i) / arcSamples, true);
61
+ }
62
+ // left flank: radial segment (when the root is inside the base circle) then involute
63
+ add(rf, leftRoot, false);
64
+ if (rf < rStart - 1e-9) add(rStart, leftRoot, false);
65
+ for (let i = 1; i <= flankSamples; i++) {
66
+ const r = rStart + ((ra - rStart) * i) / flankSamples;
67
+ add(r, c - half(r), i < flankSamples);
68
+ }
69
+ // tip arc
70
+ const tipL = c - half(ra), tipR = c + half(ra);
71
+ for (let i = 1; i < arcSamples; i++) add(ra, tipL + ((tipR - tipL) * i) / arcSamples, true);
72
+ // right flank going down
73
+ for (let i = flankSamples; i >= 1; i--) {
74
+ const r = rStart + ((ra - rStart) * i) / flankSamples;
75
+ add(r, c + half(r), i < flankSamples);
76
+ }
77
+ if (rf < rStart - 1e-9) add(rStart, rightRoot, false);
78
+ add(rf, rightRoot, false);
79
+ }
80
+ return {
81
+ outline: { points: Float64Array.from(pts), smooth: Uint8Array.from(smooth), closed: true },
82
+ outerRadius: ra,
83
+ rootRadius: rf,
84
+ };
85
+ }