@es3d/es3d 1.0.2 → 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,252 @@
1
+ // OpenSCAD script. The language is documented in the OpenSCAD User Manual
2
+ // (the 2021.01 manual, and the same primitives in current builds).
3
+ //
4
+ // OpenSCAD is Z-up. The file's root `rotate([90, 0, 0])` maps ES3D's Y-up
5
+ // coordinates onto Z-up: (x, y, z) becomes (x, −z, y). Everything inside that
6
+ // rotation is written in ES3D coordinates.
7
+ //
8
+ // Primitives: cube(size, center=true) is centred (the default centre is false,
9
+ // which would put the box in the first octant). sphere(r). cylinder is along
10
+ // Z; r1 is the bottom and r2 the top. `rotate([-90, 0, 0])` maps that Z axis
11
+ // onto ES3D Y, and the bottom (negative Z, r1) onto negative Y, so r1 is the
12
+ // ES3D bottom radius. OpenSCAD solids are closed: a cylinder with caps off is
13
+ // still capped, and the notes say so. There is no capsule primitive; it is a
14
+ // cylinder plus two spheres. There is no zero-thickness solid: a plane is a
15
+ // 2D square, and the notes say so.
16
+ //
17
+ // linear_extrude runs along +Z, which is ES3D's extrude direction inside the
18
+ // root rotation. rotate_extrude revolves around Z; a further −90° about X puts
19
+ // the result around Y. polygon paths are the outline then the holes.
20
+ // polyhedron faces are listed clockwise when viewed from outside, so ES3D's
21
+ // counter-clockwise triangles are reversed.
22
+ //
23
+ // multmatrix takes a row-major 4×4 with the translation in the last column,
24
+ // the same layout as a column-vector matrix written by rows.
25
+ //
26
+ // Reuse is a module. A repeat whose step is a pure translation, a pure
27
+ // rotation about the origin, or a pure positive scale is a for-loop. Anything
28
+ // else is baked. Scatter is baked too, except a plain grid: OpenSCAD has no
29
+ // bitwise operators, so it cannot run the mulberry32 placement.
30
+
31
+ import { n, trsOf, axisAngle, matrixRows } from './text-encode.js';
32
+ import { repeatCopies, scatterCopies, paint, surfaceMesh } from './export-model.js';
33
+
34
+ function indent(text, spaces = 2) {
35
+ const pad = ' '.repeat(spaces);
36
+ return text.split('\n').map((line) => (line ? pad + line : line)).join('\n');
37
+ }
38
+
39
+ function vec(v) {
40
+ return `[${v.map(n).join(', ')}]`;
41
+ }
42
+
43
+ /**
44
+ * @returns {{text: string, notes: string[]}}
45
+ */
46
+ export function exportOpenSCAD(model) {
47
+ const notes = model.notes.slice();
48
+ const tally = new Map();
49
+ const add = (key, k = 1) => tally.set(key, (tally.get(key) || 0) + k);
50
+ const modules = [];
51
+ const names = new Map();
52
+ let seq = 0;
53
+
54
+ const moduleFor = (solid, doubleSided) => {
55
+ const key = `${solid.key}:${doubleSided ? 1 : 0}`;
56
+ if (names.has(key)) return names.get(key);
57
+ const id = `g${seq++}`;
58
+ names.set(key, id);
59
+ modules.push(`module ${id}() {\n${indent(solidBody(solid, doubleSided))}\n}`);
60
+ return id;
61
+ };
62
+
63
+ const solidBody = (solid, doubleSided) => {
64
+ if (solid.kind === 'box') {
65
+ const s = solid.size;
66
+ return `cube([${n(s[0])}, ${n(s[1])}, ${n(s[2])}], center=true);`;
67
+ }
68
+ if (solid.kind === 'sphere') return `sphere(r=${n(solid.radius)}, $fn=${solid.segments});`;
69
+ if (solid.kind === 'cylinder' || solid.kind === 'cone' || solid.kind === 'frustum') {
70
+ if (solid.caps === false) add('caps');
71
+ const r1 = solid.kind === 'cone' ? solid.radius : solid.radius;
72
+ const r2 = solid.kind === 'cone' ? 0 : (solid.radiusTop !== undefined ? solid.radiusTop : solid.radius);
73
+ return `rotate([-90, 0, 0]) cylinder(h=${n(solid.height)}, r1=${n(r1)}, r2=${n(r2)}, center=true, $fn=${solid.segments});`;
74
+ }
75
+ if (solid.kind === 'capsule') {
76
+ add('capsule');
77
+ const r = solid.radius, h = solid.height, mid = Math.max(0, h - 2 * r), y = h / 2 - r;
78
+ return `rotate([-90, 0, 0]) {\n cylinder(h=${n(mid)}, r=${n(r)}, center=true, $fn=${solid.segments});\n translate([0, 0, ${n(y)}]) sphere(r=${n(r)}, $fn=${solid.segments});\n translate([0, 0, ${n(-y)}]) sphere(r=${n(r)}, $fn=${solid.segments});\n}`;
79
+ }
80
+ if (solid.kind === 'plane') {
81
+ add('plane');
82
+ return `rotate([-90, 0, 0]) square([${n(solid.width)}, ${n(solid.depth)}], center=true);`;
83
+ }
84
+ if (solid.kind === 'torus') {
85
+ return `rotate([-90, 0, 0]) rotate_extrude($fn=${solid.segments}) translate([${n(solid.radius)}, 0]) circle(r=${n(solid.tube)}, $fn=${solid.tubeSegments});`;
86
+ }
87
+ if (solid.kind === 'extrude') {
88
+ return `linear_extrude(height=${n(solid.depth)}, center=${solid.center ? 'true' : 'false'})\n${indent(polygon(solid.regions))}`;
89
+ }
90
+ if (solid.kind === 'revolve') {
91
+ add('revolve');
92
+ const pts = [];
93
+ for (let i = 0; i < solid.points.length; i += 2) pts.push(`[${n(solid.points[i])}, ${n(solid.points[i + 1])}]`);
94
+ return `rotate([-90, 0, 0]) rotate_extrude(angle=${n(Math.min(solid.angle, 360))}, $fn=${solid.segments}) polygon([${pts.join(', ')}]);`;
95
+ }
96
+ if (solid.kind === 'surface') {
97
+ add('surface');
98
+ const mesh = surfaceMesh(solid.regions);
99
+ return polyhedron(mesh);
100
+ }
101
+ return `// ${solid.kind}`;
102
+ };
103
+
104
+ const call = (node) => {
105
+ if (!node) return '';
106
+ if (node.kind === 'group') {
107
+ const inner = node.children.map(call).filter(Boolean);
108
+ return wrap(node.matrix, block(inner));
109
+ }
110
+ if (node.kind === 'repeat') return repeat(node);
111
+ if (node.kind === 'scatter') return scatter(node);
112
+ const id = node.solid.kind === 'mesh' || node.solid.kind === 'lines'
113
+ ? meshModule(node.solid)
114
+ : moduleFor(node.solid, node.doubleSided);
115
+ const color = model.materials[node.material];
116
+ const c = color.baseColorSrgb;
117
+ const painted = `color([${n(c[0])}, ${n(c[1])}, ${n(c[2])}, ${n(color.opacity)}]) ${id}();`;
118
+ return wrap(node.matrix, painted);
119
+ };
120
+
121
+ const meshModule = (mesh) => {
122
+ const key = mesh.key;
123
+ if (names.has(key)) return names.get(key);
124
+ const id = `g${seq++}`;
125
+ names.set(key, id);
126
+ if (mesh.kind === 'lines') {
127
+ add('lines');
128
+ modules.push(`module ${id}() { /* hairline omitted */ }`);
129
+ return id;
130
+ }
131
+ modules.push(`module ${id}() {\n${indent(polyhedron(mesh))}\n}`);
132
+ return id;
133
+ };
134
+
135
+ const repeat = (node) => {
136
+ const step = trsOf(node.step);
137
+ const inner = block(node.children.map(call).filter(Boolean));
138
+ if (step && step.noRot && step.noScale) {
139
+ add('loop');
140
+ const body = `for (i = [0:${node.count - 1}])\n${indent(`translate(${vec(step.translation)} * i)\n${indent(inner)}`)}`;
141
+ return wrap(node.matrix, body);
142
+ }
143
+ if (step && step.rotationOnly && step.noTrans) {
144
+ const a = axisAngle(step.rotation);
145
+ if (a.angle > 1e-8) {
146
+ add('loop');
147
+ const body = `for (i = [0:${node.count - 1}])\n${indent(`rotate(a=${n(a.angle * 180 / Math.PI)} * i, v=${vec(a.axis)})\n${indent(inner)}`)}`;
148
+ return wrap(node.matrix, body);
149
+ }
150
+ }
151
+ if (step && step.noRot && step.noTrans && step.scale.every((v) => v > 0)) {
152
+ add('loop');
153
+ const body = `for (i = [0:${node.count - 1}])\n${indent(`scale([pow(${n(step.scale[0])}, i), pow(${n(step.scale[1])}, i), pow(${n(step.scale[2])}, i)])\n${indent(inner)}`)}`;
154
+ return wrap(node.matrix, body);
155
+ }
156
+ add('baked', node.count);
157
+ const copies = repeatCopies(node).map((m) => wrap(m, inner));
158
+ return wrap(node.matrix, block(copies));
159
+ };
160
+
161
+ const scatter = (node) => {
162
+ const p = node.params;
163
+ const plain = p.grid && !(p.jitter > 0) && !p.randomScale && !p.randomRotation && !p.randomMaterial;
164
+ const inner = block(node.children.map(call).filter(Boolean));
165
+ if (plain) {
166
+ add('grid');
167
+ const nx = p.grid[0], nz = p.grid[1];
168
+ const body = `for (ix = [0:${nx - 1}], iz = [0:${nz - 1}])\n${indent(`translate([(${n(p.spacing[0])}) * (ix - ${n((nx - 1) / 2)}), 0, (${n(p.spacing[1])}) * (iz - ${n((nz - 1) / 2)})])\n${indent(inner)}`)}`;
169
+ return wrap(node.matrix, body);
170
+ }
171
+ add('scatter', p.count);
172
+ const copies = scatterCopies(node).map((copy) => {
173
+ const kids = copy.material ? node.children.map((c) => paint(c, copy.material, null, model.resolve)) : node.children;
174
+ return wrap(copy.matrix, block(kids.map(call).filter(Boolean)));
175
+ });
176
+ return wrap(node.matrix, block(copies));
177
+ };
178
+
179
+ const body = call(model.root) || 'cube(0);';
180
+ if (tally.get('caps')) notes.push(`${tally.get('caps').toLocaleString('en-US')} open cylinder${tally.get('caps') === 1 ? '' : 's'} written closed (OpenSCAD solids have caps).`);
181
+ if (tally.get('capsule')) notes.push(`${tally.get('capsule').toLocaleString('en-US')} capsule${tally.get('capsule') === 1 ? '' : 's'} written as a cylinder and two spheres.`);
182
+ if (tally.get('plane')) notes.push(`${tally.get('plane').toLocaleString('en-US')} plane${tally.get('plane') === 1 ? '' : 's'} written as a 2D square (OpenSCAD has no zero-thickness solid).`);
183
+ if (tally.get('surface')) notes.push(`${tally.get('surface').toLocaleString('en-US')} flat surface${tally.get('surface') === 1 ? '' : 's'} written as a polyhedron.`);
184
+ if (tally.get('revolve')) notes.push(`${tally.get('revolve').toLocaleString('en-US')} revolve${tally.get('revolve') === 1 ? '' : 's'} written with rotate_extrude.`);
185
+ if (tally.get('loop')) notes.push(`${tally.get('loop').toLocaleString('en-US')} repeat${tally.get('loop') === 1 ? '' : 's'} written as a for-loop.`);
186
+ if (tally.get('baked')) notes.push(`${tally.get('baked').toLocaleString('en-US')} repeated copies baked (the step is not a pure translation, rotation or scale).`);
187
+ if (tally.get('grid')) notes.push(`${tally.get('grid').toLocaleString('en-US')} plain grid${tally.get('grid') === 1 ? '' : 's'} written as a for-loop.`);
188
+ if (tally.get('scatter')) notes.push(`${tally.get('scatter').toLocaleString('en-US')} scattered copies baked (OpenSCAD cannot run the mulberry32 placement).`);
189
+ if (tally.get('lines')) notes.push(`${tally.get('lines').toLocaleString('en-US')} hairline${tally.get('lines') === 1 ? '' : 's'} omitted (OpenSCAD has no line primitive).`);
190
+ if (model.lights.length) notes.push(`${model.lights.length.toLocaleString('en-US')} light${model.lights.length === 1 ? '' : 's'} omitted (OpenSCAD describes solids).`);
191
+ if (model.cameras.length) notes.push(`${model.cameras.length.toLocaleString('en-US')} camera${model.cameras.length === 1 ? '' : 's'} omitted.`);
192
+
193
+ const text = `// ES3D scene, OpenSCAD 2021.01 primitives.
194
+ // OpenSCAD is Z-up. rotate([90, 0, 0]) maps ES3D Y-up onto Z-up.
195
+ ${modules.join('\n\n')}
196
+ rotate([90, 0, 0])
197
+ ${body}
198
+ `;
199
+ return { text, notes };
200
+ }
201
+
202
+ function block(lines) {
203
+ const items = lines.filter(Boolean);
204
+ if (!items.length) return 'cube(0);';
205
+ if (items.length === 1) return items[0];
206
+ return `union() {\n${items.map((l) => indent(l)).join('\n')}\n}`;
207
+ }
208
+
209
+ function wrap(matrix, inner) {
210
+ if (!matrix || !inner) return inner || '';
211
+ const t = trsOf(matrix);
212
+ if (!t) {
213
+ const rows = matrixRows(matrix).map((r) => `[${r.map(n).join(', ')}]`).join(', ');
214
+ return `multmatrix([${rows}])\n${indent(inner)}`;
215
+ }
216
+ if (t.identity) return inner;
217
+ let s = inner;
218
+ if (!t.noScale) s = `scale(${vec(t.scale)})\n${indent(s)}`;
219
+ if (!t.noRot) {
220
+ const a = axisAngle(t.rotation);
221
+ s = `rotate(a=${n((a.angle * 180) / Math.PI)}, v=${vec(a.axis)})\n${indent(s)}`;
222
+ }
223
+ if (!t.noTrans) s = `translate(${vec(t.translation)})\n${indent(s)}`;
224
+ return s;
225
+ }
226
+
227
+ function polygon(regions) {
228
+ const pts = [];
229
+ const paths = [];
230
+ const addLoop = (loop) => {
231
+ const path = [];
232
+ const nPts = loop.length / 2;
233
+ for (let i = 0; i < nPts; i++) {
234
+ path.push(pts.length);
235
+ pts.push(`[${n(loop[i * 2])}, ${n(loop[i * 2 + 1])}]`);
236
+ }
237
+ paths.push(`[${path.join(', ')}]`);
238
+ };
239
+ for (const region of regions) {
240
+ addLoop(region.outer);
241
+ for (const hole of region.holes) addLoop(hole);
242
+ }
243
+ return `polygon(points=[${pts.join(', ')}], paths=[${paths.join(', ')}]);`;
244
+ }
245
+
246
+ function polyhedron(mesh) {
247
+ const pts = [];
248
+ for (let i = 0; i < mesh.positions.length; i += 3) pts.push(`[${n(mesh.positions[i])}, ${n(mesh.positions[i + 1])}, ${n(mesh.positions[i + 2])}]`);
249
+ const faces = [];
250
+ for (let i = 0; i < mesh.indices.length; i += 3) faces.push(`[${mesh.indices[i]}, ${mesh.indices[i + 2]}, ${mesh.indices[i + 1]}]`);
251
+ return `polyhedron(points=[${pts.join(', ')}], faces=[${faces.join(', ')}], convexity=8);`;
252
+ }
@@ -0,0 +1,87 @@
1
+ // Shared number and transform formatting for the text exporters.
2
+ //
3
+ // Coordinates use fixed() so the same scene always produces the same bytes.
4
+ // Six digits is enough to round-trip the placements these formats store
5
+ // (a degree of rotation, a scale, a coordinate) without trailing noise.
6
+
7
+ import * as mat4 from '../math/mat4.js';
8
+ import { fixed } from './external-scene.js';
9
+
10
+ export const DIGITS = 6;
11
+
12
+ const I = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
13
+
14
+ /** Fixed-point number, trimmed. Non-finite values become 0. */
15
+ export function n(v) {
16
+ return fixed(Number.isFinite(v) ? v : 0, DIGITS);
17
+ }
18
+
19
+ /** Numbers wrapped onto several lines. `per` values per line. */
20
+ export function numList(values, per = 12) {
21
+ const parts = [];
22
+ for (let i = 0; i < values.length; i++) {
23
+ if (i) parts.push(i % per === 0 ? '\n ' : ' ');
24
+ parts.push(n(values[i]));
25
+ }
26
+ return parts.join('');
27
+ }
28
+
29
+ /**
30
+ * Translation, quaternion [x, y, z, w] and scale of an affine matrix.
31
+ * Null when the matrix shears, which TRS cannot represent.
32
+ * `identity` is the 4x4 identity. `plain` is a pure translation.
33
+ */
34
+ export function trsOf(m) {
35
+ if (!m || mat4.isIdentity(m, 1e-9)) {
36
+ return { translation: [0, 0, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1], identity: true, plain: true, scaleOnly: true };
37
+ }
38
+ const d = mat4.decompose(m, 1e-6);
39
+ if (!d) return null;
40
+ const translation = d.translation;
41
+ const rotation = d.rotation;
42
+ const scale = d.scale;
43
+ const plain = Math.hypot(rotation[0], rotation[1], rotation[2]) < 1e-8 && scale.every((v) => Math.abs(v - 1) < 1e-8);
44
+ const noRot = Math.hypot(rotation[0], rotation[1], rotation[2]) < 1e-8;
45
+ const noScale = scale.every((v) => Math.abs(v - 1) < 1e-8);
46
+ const noTrans = translation.every((v) => Math.abs(v) < 1e-8);
47
+ return { translation, rotation, scale, identity: plain && noTrans, plain, scaleOnly: noRot, rotationOnly: noTrans && noScale, noRot, noScale, noTrans };
48
+ }
49
+
50
+ /** Axis (unit) and angle in radians of a quaternion [x, y, z, w]. */
51
+ export function axisAngle(q) {
52
+ let x = q[0], y = q[1], z = q[2], w = q[3];
53
+ if (w < 0) { x = -x; y = -y; z = -z; w = -w; }
54
+ const angle = 2 * Math.acos(Math.min(1, Math.max(-1, w)));
55
+ const s = Math.sin(angle / 2);
56
+ if (Math.abs(s) < 1e-8 || angle < 1e-8) return { axis: [0, 1, 0], angle: 0 };
57
+ return { axis: [x / s, y / s, z / s], angle };
58
+ }
59
+
60
+ /**
61
+ * Rows of a column-vector matrix (translation in the last column).
62
+ * OpenSCAD multmatrix uses this layout.
63
+ */
64
+ export function matrixRows(m) {
65
+ const a = m || I;
66
+ const rows = [];
67
+ for (let r = 0; r < 4; r++) rows.push([a[r], a[r + 4], a[r + 8], a[r + 12]]);
68
+ return rows;
69
+ }
70
+
71
+ /**
72
+ * Rows of the transpose. OpenUSD applies row vectors (p' = p · M), so a
73
+ * translation lives on the last row. See usd.js.
74
+ */
75
+ export function usdRows(m) {
76
+ const a = m || I;
77
+ const rows = [];
78
+ for (let c = 0; c < 4; c++) rows.push([a[c * 4], a[c * 4 + 1], a[c * 4 + 2], a[c * 4 + 3]]);
79
+ return rows;
80
+ }
81
+
82
+ /** Metres per one authoring unit, for the ES3D unit tokens. */
83
+ export const METRES_PER_UNIT = { m: 1, cm: 0.01, mm: 0.001, km: 1000, in: 0.0254, ft: 0.3048 };
84
+
85
+ export function metresPerUnit(unit) {
86
+ return METRES_PER_UNIT[unit] ?? 1;
87
+ }