@es3d/es3d 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -201
- package/README.md +86 -86
- package/docs/SPEC.md +417 -417
- package/package.json +1 -1
- package/scripts/es3d-cli.mjs +377 -377
- package/src/cache/geometry-store.js +392 -392
- package/src/cache/mesh-bundle.js +123 -123
- package/src/cache/sha256.js +73 -73
- package/src/format/ast.js +238 -238
- package/src/format/colors.js +93 -93
- package/src/format/compact-parser.js +171 -171
- package/src/format/compact-tokenizer.js +165 -165
- package/src/format/errors.js +247 -247
- package/src/format/json.js +55 -55
- package/src/format/limits.js +14 -14
- package/src/format/parse.js +92 -92
- package/src/format/schema.js +617 -617
- package/src/format/serializer.js +333 -333
- package/src/format/validator.js +330 -330
- package/src/format/value-error.js +12 -12
- package/src/format/value-format.js +314 -314
- package/src/format/values.js +459 -459
- package/src/format/xml-parser.js +136 -136
- package/src/format/xml-tokenizer.js +237 -237
- package/src/geometry/contour.js +86 -86
- package/src/geometry/extrude.js +175 -175
- package/src/geometry/gear.js +85 -85
- package/src/geometry/geometry-cache.js +159 -159
- package/src/geometry/lod-error.js +178 -178
- package/src/geometry/mesh.js +458 -458
- package/src/geometry/primitives.js +299 -299
- package/src/geometry/registry.js +645 -645
- package/src/geometry/revolve.js +136 -136
- package/src/geometry/sweep.js +441 -441
- package/src/index.js +47 -47
- package/src/io/compression.js +67 -67
- package/src/io/convert.js +236 -236
- package/src/io/download.js +13 -13
- package/src/io/external-scene.js +127 -127
- package/src/io/file-loader.js +28 -28
- package/src/io/gltf.js +630 -630
- package/src/io/mesh-import.js +104 -104
- package/src/io/obj.js +352 -352
- package/src/io/step-brep.js +439 -439
- package/src/io/step-exact.js +1149 -1149
- package/src/io/step.js +1418 -1418
- package/src/io/stl.js +193 -193
- package/src/io/svg-import.js +787 -787
- package/src/material/materials.js +154 -154
- package/src/math/mat4.js +475 -475
- package/src/math/random.js +30 -30
- package/src/math/trig.js +65 -65
- package/src/math/vec3.js +81 -81
- package/src/optimize/optimizer.js +579 -579
- package/src/path/path-flatten.js +322 -322
- package/src/path/path-parser.js +332 -332
- package/src/path/shapes.js +129 -129
- package/src/path/triangulate.js +517 -517
- package/src/pipeline.js +74 -74
- package/src/render/camera.js +168 -168
- package/src/render/frame-builder.js +362 -362
- package/src/render/geometry-pool.js +149 -149
- package/src/render/lighting.js +149 -149
- package/src/render/orbit-controls.js +237 -237
- package/src/render/renderer.js +258 -249
- package/src/render/scroll-timeline.js +104 -104
- package/src/render/shaders/glsl.js +285 -285
- package/src/render/shaders/wgsl.js +252 -252
- package/src/render/stage.js +364 -352
- package/src/render/texture-cache.js +40 -40
- package/src/render/textures.js +223 -223
- package/src/render/webgl2/webgl2-backend.js +586 -532
- package/src/render/webgpu/webgpu-backend.js +482 -480
- package/src/scene/flatten.js +754 -754
- package/src/scene/picking.js +161 -161
- package/src/scene/scene-graph.js +320 -320
- package/src/transform/transform.js +211 -211
- package/src/wasm/kernels-binary.js +3 -3
- package/src/wasm/kernels.js +385 -385
package/src/io/step-exact.js
CHANGED
|
@@ -1,1149 +1,1149 @@
|
|
|
1
|
-
// STEP solids to procedural ES3D, from their exact geometry (step-brep.js).
|
|
2
|
-
//
|
|
3
|
-
// A CAD part is a boundary of faces. Each shell is written as the most
|
|
4
|
-
// direct ES3D it can be:
|
|
5
|
-
//
|
|
6
|
-
// 1. one shape when the whole shell is one: a box or an <extrude> (a prism:
|
|
7
|
-
// two matching flat caps and straight walls, with holes, arcs, ellipses
|
|
8
|
-
// and splines in the outline), a <cylinder>, <cone>, <sphere>, <torus>
|
|
9
|
-
// or a <revolve> (a turned part: every face a surface of revolution
|
|
10
|
-
// about one axis, or a flat ring across it);
|
|
11
|
-
// 2. otherwise face by face: a flat face is a <path> (lines, circular and
|
|
12
|
-
// elliptical arcs, Bézier curves, holes as subpaths); a cylindrical,
|
|
13
|
-
// conical, spherical or toroidal face bounded along its own circles and
|
|
14
|
-
// meridians (a fillet, a hole wall, a rounded corner) is a <revolve> of
|
|
15
|
-
// its meridian through the angle it spans;
|
|
16
|
-
// 3. a face that ES3D v1 cannot describe exactly (a free-form NURBS
|
|
17
|
-
// surface, or a curved face cut along a curve that is not one of its
|
|
18
|
-
// circles or meridians, such as the rim of a cross hole) is tessellated
|
|
19
|
-
// from its exact surface into a <mesh>, and counted in the note.
|
|
20
|
-
//
|
|
21
|
-
// Coordinates stay in the file's unit and frame. A Z-up file is turned Y-up
|
|
22
|
-
// by a rotation on the group that holds it, so every number is the CAD
|
|
23
|
-
// tool's own.
|
|
24
|
-
|
|
25
|
-
import { parseSTEP, colourMap, ownerIndex, inheritedColour, productName } from './step.js';
|
|
26
|
-
import { readBrep, evalSpline, evalSplineSurface } from './step-brep.js';
|
|
27
|
-
import { h, Document, Comment } from '../format/ast.js';
|
|
28
|
-
import { serialize } from '../format/serializer.js';
|
|
29
|
-
import { formatNumber } from '../format/value-format.js';
|
|
30
|
-
import { colorToHex } from '../format/colors.js';
|
|
31
|
-
import { matrixToEuler } from '../transform/transform.js';
|
|
32
|
-
import * as mat4 from '../math/mat4.js';
|
|
33
|
-
|
|
34
|
-
const TAU = Math.PI * 2;
|
|
35
|
-
const DEG = 180 / Math.PI;
|
|
36
|
-
const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
|
37
|
-
const add = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
|
|
38
|
-
const mul = (a, s) => [a[0] * s, a[1] * s, a[2] * s];
|
|
39
|
-
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
|
40
|
-
const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
|
|
41
|
-
const len = (a) => Math.hypot(a[0], a[1], a[2]);
|
|
42
|
-
const unit = (a) => mul(a, 1 / (len(a) || 1));
|
|
43
|
-
const neg = (a) => [-a[0], -a[1], -a[2]];
|
|
44
|
-
/** angle in (0, 2π] (a whole turn stays a whole turn) */
|
|
45
|
-
const turn = (a) => {
|
|
46
|
-
const m = a - TAU * Math.floor(a / TAU);
|
|
47
|
-
return m <= 1e-12 ? TAU : m;
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
const UNIT = { millimetres: 'mm', centimetres: 'cm', metres: 'm', kilometres: 'km', inch: 'in', inches: 'in', foot: 'ft', feet: 'ft' };
|
|
51
|
-
|
|
52
|
-
// --- placement ---------------------------------------------------------------------------
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* position and rotation of a frame (X, Y, Z axes at origin), and the frame a
|
|
56
|
-
* reader builds back from the rounded angles, for measuring local coordinates.
|
|
57
|
-
*/
|
|
58
|
-
function placement(origin, X, Y, Z, fmt) {
|
|
59
|
-
const m = [X[0], X[1], X[2], 0, Y[0], Y[1], Y[2], 0, Z[0], Z[1], Z[2], 0, 0, 0, 0, 1];
|
|
60
|
-
const e = matrixToEuler(m);
|
|
61
|
-
const rot = e.rotation.map((a) => {
|
|
62
|
-
const r = Number(formatNumber(a, 6));
|
|
63
|
-
return r === -180 ? 180 : r === 0 ? 0 : r;
|
|
64
|
-
});
|
|
65
|
-
const R = mat4.fromEulerDeg(mat4.createF64(), rot[0], rot[1], rot[2]);
|
|
66
|
-
const attrs = {};
|
|
67
|
-
if (origin.some((v) => v !== 0)) attrs.position = origin.map(fmt).join(',');
|
|
68
|
-
if (rot.some((v) => v !== 0)) attrs.rotation = rot.map((v) => formatNumber(v)).join(',');
|
|
69
|
-
return { attrs, frame: { o: origin, x: [R[0], R[1], R[2]], y: [R[4], R[5], R[6]], z: [R[8], R[9], R[10]] } };
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const localOf = (frame) => (p) => {
|
|
73
|
-
const d = sub(p, frame.o);
|
|
74
|
-
return [dot(d, frame.x), dot(d, frame.y)];
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
// --- edges as 2D path data ----------------------------------------------------------------
|
|
78
|
-
|
|
79
|
-
/** Insert knots until every interior knot is full: the Bézier segments of a polynomial spline. */
|
|
80
|
-
function bezierSegments(c) {
|
|
81
|
-
const p = c.degree;
|
|
82
|
-
let P = c.points.map((q) => q.slice());
|
|
83
|
-
let U = c.knots.slice();
|
|
84
|
-
const interior = [...new Set(U.slice(p + 1, U.length - p - 1))];
|
|
85
|
-
for (const t of interior) {
|
|
86
|
-
let mult = U.filter((u) => Math.abs(u - t) < 1e-14).length;
|
|
87
|
-
while (mult < p) {
|
|
88
|
-
const k = U.findIndex((u) => u > t) - 1;
|
|
89
|
-
const Q = [];
|
|
90
|
-
for (let i = 0; i < P.length + 1; i++) {
|
|
91
|
-
if (i <= k - p) Q.push(P[i]);
|
|
92
|
-
else if (i > k) Q.push(P[i - 1]);
|
|
93
|
-
else {
|
|
94
|
-
const a = (t - U[i]) / (U[i + p] - U[i]);
|
|
95
|
-
Q.push(add(mul(P[i - 1], 1 - a), mul(P[i], a)));
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
P = Q;
|
|
99
|
-
U.splice(k + 1, 0, t);
|
|
100
|
-
mult++;
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
const segs = [];
|
|
104
|
-
for (let i = 0; i + p < P.length; i += p) segs.push(P.slice(i, i + p + 1));
|
|
105
|
-
return segs;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const polynomial = (c) => !c.weights || c.weights.every((w) => Math.abs(w - c.weights[0]) < 1e-12 * Math.abs(c.weights[0]));
|
|
109
|
-
|
|
110
|
-
/** Points along an edge, start to end, for curves without an exact form. */
|
|
111
|
-
function sampleEdge(e, n = 24) {
|
|
112
|
-
const c = e.curve;
|
|
113
|
-
if (c.kind === 'line') return [e.start, e.end];
|
|
114
|
-
if (c.kind === 'polyline') return e.forward ? c.points : c.points.slice().reverse();
|
|
115
|
-
if (c.kind === 'circle' || c.kind === 'ellipse') {
|
|
116
|
-
const a = c.kind === 'circle' ? c.radius : c.a;
|
|
117
|
-
const b = c.kind === 'circle' ? c.radius : c.b;
|
|
118
|
-
const f = c.frame;
|
|
119
|
-
const param = (p) => {
|
|
120
|
-
const d = sub(p, f.origin);
|
|
121
|
-
return Math.atan2(dot(d, f.y) / b, dot(d, f.x) / a);
|
|
122
|
-
};
|
|
123
|
-
const t0 = param(e.start);
|
|
124
|
-
const t1 = param(e.end);
|
|
125
|
-
const closed = len(sub(e.start, e.end)) < 1e-9 * (a + b);
|
|
126
|
-
const span = closed ? (e.forward ? TAU : -TAU) : e.forward ? turn(t1 - t0) : -turn(t0 - t1);
|
|
127
|
-
const m = Math.max(n, Math.ceil(Math.abs(span) / (Math.PI / 36)));
|
|
128
|
-
const out = [];
|
|
129
|
-
for (let i = 0; i <= m; i++) {
|
|
130
|
-
const t = t0 + (span * i) / m;
|
|
131
|
-
out.push(add(f.origin, add(mul(f.x, a * Math.cos(t)), mul(f.y, b * Math.sin(t)))));
|
|
132
|
-
}
|
|
133
|
-
out[0] = e.start;
|
|
134
|
-
out[m] = e.end;
|
|
135
|
-
return out;
|
|
136
|
-
}
|
|
137
|
-
if (c.kind === 'bspline') {
|
|
138
|
-
const t0 = c.knots[c.degree];
|
|
139
|
-
const t1 = c.knots[c.knots.length - c.degree - 1];
|
|
140
|
-
const m = Math.max(n, c.points.length * 6);
|
|
141
|
-
const out = [];
|
|
142
|
-
for (let i = 0; i <= m; i++) out.push(evalSpline(c, t0 + ((t1 - t0) * i) / m));
|
|
143
|
-
if (!e.forward) out.reverse();
|
|
144
|
-
out[0] = e.start;
|
|
145
|
-
out[m] = e.end;
|
|
146
|
-
return out;
|
|
147
|
-
}
|
|
148
|
-
return null;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* Path data for one edge of a flat face, from the current point (the edge's
|
|
153
|
-
* start) to its end. `exact` is false when the curve was sampled.
|
|
154
|
-
*/
|
|
155
|
-
function edgeData(e, frame, L, fmt, tol) {
|
|
156
|
-
const c = e.curve;
|
|
157
|
-
const P = (p) => `${fmt(p[0])},${fmt(p[1])}`;
|
|
158
|
-
const end = L(e.end);
|
|
159
|
-
if (c.kind === 'line') return { d: `L${P(end)}`, exact: true };
|
|
160
|
-
if (c.kind === 'polyline') return { d: (e.forward ? c.points.slice(1) : c.points.slice(0, -1).reverse()).map((p) => `L${P(L(p))}`).join(''), exact: true };
|
|
161
|
-
if ((c.kind === 'circle' || c.kind === 'ellipse') && Math.abs(Math.abs(dot(c.frame.z, frame.z)) - 1) < 1e-9) {
|
|
162
|
-
const a = c.kind === 'circle' ? c.radius : c.a;
|
|
163
|
-
const b = c.kind === 'circle' ? c.radius : c.b;
|
|
164
|
-
const f = c.frame;
|
|
165
|
-
const ccw = e.forward === dot(f.z, frame.z) > 0;
|
|
166
|
-
const param = (p) => {
|
|
167
|
-
const d = sub(p, f.origin);
|
|
168
|
-
return Math.atan2(dot(d, f.y) / b, dot(d, f.x) / a);
|
|
169
|
-
};
|
|
170
|
-
const t0 = param(e.start);
|
|
171
|
-
const rot = c.kind === 'circle' ? 0 : Math.atan2(dot(f.x, frame.y), dot(f.x, frame.x)) * DEG;
|
|
172
|
-
const arc = (to, large) => `A${fmt(a)},${fmt(b)} ${c.kind === 'circle' ? 0 : formatNumber(rot, 6)} ${large ? 1 : 0},${ccw ? 1 : 0} ${P(to)}`;
|
|
173
|
-
if (len(sub(e.start, e.end)) <= tol) {
|
|
174
|
-
// a whole circle: two half turns
|
|
175
|
-
const tm = t0 + Math.PI;
|
|
176
|
-
const mid = add(f.origin, add(mul(f.x, a * Math.cos(tm)), mul(f.y, b * Math.sin(tm))));
|
|
177
|
-
return { d: arc(L(mid), false) + arc(end, false), exact: true };
|
|
178
|
-
}
|
|
179
|
-
const t1 = param(e.end);
|
|
180
|
-
const span = ccw === (dot(f.z, frame.z) > 0) ? turn(t1 - t0) : turn(t0 - t1);
|
|
181
|
-
return { d: arc(end, span > Math.PI + 1e-12), exact: true };
|
|
182
|
-
}
|
|
183
|
-
if (c.kind === 'bspline' && polynomial(c) && c.degree <= 3) {
|
|
184
|
-
let segs = bezierSegments(c);
|
|
185
|
-
if (!e.forward) segs = segs.reverse().map((s) => s.slice().reverse());
|
|
186
|
-
const cmd = c.degree === 1 ? 'L' : c.degree === 2 ? 'Q' : 'C';
|
|
187
|
-
return { d: segs.map((s) => cmd + s.slice(1).map((p) => P(L(p))).join(' ')).join(''), exact: true };
|
|
188
|
-
}
|
|
189
|
-
const pts = sampleEdge(e);
|
|
190
|
-
if (!pts) return null;
|
|
191
|
-
return { d: pts.slice(1).map((p) => `L${P(L(p))}`).join(''), exact: false };
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/** A flat face as a <path> in its plane. */
|
|
195
|
-
function planarFace(face, fmt, tol) {
|
|
196
|
-
const s = face.surface.frame;
|
|
197
|
-
const z = face.sense ? s.z : neg(s.z);
|
|
198
|
-
const { attrs, frame } = placement(s.origin, s.x, cross(z, s.x), z, fmt);
|
|
199
|
-
const L = localOf(frame);
|
|
200
|
-
const subpaths = [];
|
|
201
|
-
let exact = true;
|
|
202
|
-
for (const loop of face.loops) {
|
|
203
|
-
if (!loop.edges.length) continue;
|
|
204
|
-
let d = `M${fmt(L(loop.edges[0].start)[0])},${fmt(L(loop.edges[0].start)[1])}`;
|
|
205
|
-
for (const e of loop.edges) {
|
|
206
|
-
const seg = edgeData(e, frame, L, fmt, tol);
|
|
207
|
-
if (!seg) return null;
|
|
208
|
-
d += seg.d;
|
|
209
|
-
exact = exact && seg.exact;
|
|
210
|
-
}
|
|
211
|
-
subpaths.push(`${d}Z`);
|
|
212
|
-
}
|
|
213
|
-
if (!subpaths.length) return null;
|
|
214
|
-
attrs.d = subpaths.join(' ');
|
|
215
|
-
if (subpaths.length > 1) attrs.fillRule = 'evenodd';
|
|
216
|
-
return { el: h('path', attrs), exact };
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
// --- faces of revolution ---------------------------------------------------------------------
|
|
220
|
-
|
|
221
|
-
/** Where a point is on a surface of revolution: azimuth u, and v (height, or latitude on spheres and tori). */
|
|
222
|
-
function revolutionCoords(s) {
|
|
223
|
-
const f = s.frame;
|
|
224
|
-
return (p) => {
|
|
225
|
-
const d = sub(p, f.origin);
|
|
226
|
-
const hgt = dot(d, f.z);
|
|
227
|
-
const x = dot(d, f.x);
|
|
228
|
-
const y = dot(d, f.y);
|
|
229
|
-
const rho = Math.hypot(x, y);
|
|
230
|
-
const u = Math.atan2(y, x);
|
|
231
|
-
if (s.kind === 'sphere') return { u, v: Math.atan2(hgt, rho), rho, h: hgt };
|
|
232
|
-
if (s.kind === 'torus') return { u, v: Math.atan2(hgt, rho - s.major), rho, h: hgt };
|
|
233
|
-
return { u, v: hgt, rho, h: hgt };
|
|
234
|
-
};
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/** A point along an edge at fraction t (0 start, 1 end). */
|
|
238
|
-
function edgeAt(e, t) {
|
|
239
|
-
const pts = sampleEdge(e, 8);
|
|
240
|
-
if (!pts) return null;
|
|
241
|
-
const x = t * (pts.length - 1);
|
|
242
|
-
const i = Math.min(pts.length - 2, Math.floor(x));
|
|
243
|
-
const f = x - i;
|
|
244
|
-
return add(mul(pts[i], 1 - f), mul(pts[i + 1], f));
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
/** The CCW interval from a to b that holds probe: [start, span]. */
|
|
248
|
-
function intervalHolding(a, b, probe) {
|
|
249
|
-
const s1 = turn(b - a);
|
|
250
|
-
return turn(probe - a) < s1 ? [a, s1] : [b, turn(a - b)];
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
/**
|
|
254
|
-
* A face of a cylinder, cone, sphere or torus bounded only along its own
|
|
255
|
-
* circles (parallels) and meridians: the ranges of u and v it covers.
|
|
256
|
-
* Null when an edge runs any other way.
|
|
257
|
-
*/
|
|
258
|
-
function revolutionPatch(face, tol) {
|
|
259
|
-
const s = face.surface;
|
|
260
|
-
const f = s.frame;
|
|
261
|
-
const at = revolutionCoords(s);
|
|
262
|
-
const parallels = [];
|
|
263
|
-
const meridians = [];
|
|
264
|
-
let seamU = false;
|
|
265
|
-
let seamV = false;
|
|
266
|
-
const poles = [];
|
|
267
|
-
const curves = new Map();
|
|
268
|
-
for (const loop of face.loops) {
|
|
269
|
-
if (loop.vertex) poles.push(at(loop.vertex));
|
|
270
|
-
for (const e of loop.edges) {
|
|
271
|
-
const c = e.curve;
|
|
272
|
-
const key = c.kind === 'circle' ? `${c.frame.origin.map((x) => x.toFixed(9))}:${c.radius.toFixed(9)}` : null;
|
|
273
|
-
if (len(sub(e.start, e.end)) <= tol && c.kind === 'line') continue;
|
|
274
|
-
if (c.kind === 'circle' && Math.abs(Math.abs(dot(c.frame.z, f.z)) - 1) < 1e-7 && at(c.frame.origin).rho <= tol * 10) {
|
|
275
|
-
const closed = len(sub(e.start, e.end)) <= tol;
|
|
276
|
-
const ccw = e.forward === dot(c.frame.z, f.z) > 0;
|
|
277
|
-
const u0 = at(e.start).u;
|
|
278
|
-
const u1 = at(e.end).u;
|
|
279
|
-
parallels.push({ e, v: at(edgeAt(e, 0.5)).v, closed, interval: closed ? null : ccw ? [u0, turn(u1 - u0)] : [u1, turn(u0 - u1)] });
|
|
280
|
-
if (c.seam || (key && curves.has(key) && closed)) seamV = true;
|
|
281
|
-
if (key) curves.set(key, true);
|
|
282
|
-
continue;
|
|
283
|
-
}
|
|
284
|
-
const a = at(e.start);
|
|
285
|
-
const b = at(e.end);
|
|
286
|
-
const m = at(edgeAt(e, 0.5));
|
|
287
|
-
const sameU = (p, q) => Math.abs(Math.sin(p.u - q.u)) < 1e-7 && Math.cos(p.u - q.u) > 0;
|
|
288
|
-
const onAxis = (p) => p.rho <= tol * 10;
|
|
289
|
-
const meridianLine = c.kind === 'line' && (s.kind === 'cylinder' || s.kind === 'cone') && (sameU(a, b) || onAxis(a) || onAxis(b)) && (onAxis(m) || sameU(m, onAxis(a) ? b : a));
|
|
290
|
-
const meridianArc = c.kind === 'circle' && (s.kind === 'sphere' || s.kind === 'torus') && Math.abs(dot(c.frame.z, f.z)) < 1e-7 && (onAxis(a) || onAxis(b) || sameU(a, b)) && (onAxis(m) || sameU(m, onAxis(a) ? b : a));
|
|
291
|
-
if (meridianLine || meridianArc) {
|
|
292
|
-
meridians.push({ e, u: onAxis(a) ? b.u : a.u, a, b, m });
|
|
293
|
-
if (c.seam) seamU = true;
|
|
294
|
-
continue;
|
|
295
|
-
}
|
|
296
|
-
return null;
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
// two meridians at one azimuth walked both ways are the seam of a whole turn
|
|
300
|
-
for (let i = 0; i < meridians.length; i++) {
|
|
301
|
-
for (let j = i + 1; j < meridians.length; j++) {
|
|
302
|
-
if (Math.abs(Math.sin(meridians[i].u - meridians[j].u)) < 1e-7 && Math.cos(meridians[i].u - meridians[j].u) > 0) seamU = true;
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
// azimuth range
|
|
307
|
-
let u0 = 0;
|
|
308
|
-
let uSpan = TAU;
|
|
309
|
-
if (!seamU && !parallels.some((p) => p.closed)) {
|
|
310
|
-
const open = parallels.filter((p) => p.interval);
|
|
311
|
-
if (open.length) [u0, uSpan] = open.reduce((best, p) => (p.interval[1] > best[1] ? p.interval : best), open[0].interval);
|
|
312
|
-
else if (meridians.length >= 2) {
|
|
313
|
-
const us = [...new Set(meridians.map((m) => Number(m.u.toFixed(9))))];
|
|
314
|
-
if (us.length !== 2) return null;
|
|
315
|
-
const probe = interiorProbe(face, meridians[0].e, at);
|
|
316
|
-
if (probe === null) return null;
|
|
317
|
-
[u0, uSpan] = intervalHolding(us[0], us[1], probe.u);
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
// range across: heights on cylinders and cones, latitudes on spheres and tori
|
|
322
|
-
let v0;
|
|
323
|
-
let v1;
|
|
324
|
-
if (s.kind === 'torus') {
|
|
325
|
-
const arcs = meridians.filter((m) => !m.e.curve.seam);
|
|
326
|
-
if (seamV || (!arcs.length && parallels.length < 2)) [v0, v1] = [-Math.PI, Math.PI];
|
|
327
|
-
else if (arcs.length) {
|
|
328
|
-
const m = arcs[0];
|
|
329
|
-
const [start, span] = intervalHolding(m.a.v, m.b.v, m.m.v);
|
|
330
|
-
[v0, v1] = [start, start + span];
|
|
331
|
-
} else {
|
|
332
|
-
const vs = [...new Set(parallels.map((p) => Number(p.v.toFixed(9))))];
|
|
333
|
-
if (vs.length !== 2) return null;
|
|
334
|
-
const probe = interiorProbe(face, parallels[0].e, at);
|
|
335
|
-
if (probe === null) return null;
|
|
336
|
-
const [start, span] = intervalHolding(vs[0], vs[1], probe.v);
|
|
337
|
-
[v0, v1] = [start, start + span];
|
|
338
|
-
}
|
|
339
|
-
} else {
|
|
340
|
-
const vs = [];
|
|
341
|
-
for (const p of parallels) vs.push(p.v);
|
|
342
|
-
for (const m of meridians) vs.push(m.a.v, m.b.v);
|
|
343
|
-
for (const p of poles) vs.push(p.v);
|
|
344
|
-
if (s.kind === 'sphere' && !vs.length) vs.push(-Math.PI / 2, Math.PI / 2);
|
|
345
|
-
if (s.kind === 'sphere' && poles.length === 1 && !parallels.length && !meridians.length) vs.push(-poles[0].v);
|
|
346
|
-
if (vs.length < 2) return null;
|
|
347
|
-
v0 = Math.min(...vs);
|
|
348
|
-
v1 = Math.max(...vs);
|
|
349
|
-
if (v1 - v0 <= tol) return null;
|
|
350
|
-
}
|
|
351
|
-
return { u0, uSpan, v0, v1 };
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
/** Coordinates of a point just inside the face, beside the middle of edge e. */
|
|
355
|
-
function interiorProbe(face, e, at) {
|
|
356
|
-
const p0 = edgeAt(e, 0.45);
|
|
357
|
-
const p1 = edgeAt(e, 0.55);
|
|
358
|
-
const m = edgeAt(e, 0.5);
|
|
359
|
-
if (!p0 || !p1 || !m) return null;
|
|
360
|
-
const s = face.surface;
|
|
361
|
-
const f = s.frame;
|
|
362
|
-
// the outward normal at m
|
|
363
|
-
const d = sub(m, f.origin);
|
|
364
|
-
const radial = unit(sub(d, mul(f.z, dot(d, f.z))));
|
|
365
|
-
let n;
|
|
366
|
-
if (s.kind === 'cylinder') n = radial;
|
|
367
|
-
else if (s.kind === 'cone') n = unit(sub(mul(radial, Math.cos(s.semiAngle)), mul(f.z, Math.sin(s.semiAngle))));
|
|
368
|
-
else if (s.kind === 'sphere') n = unit(d);
|
|
369
|
-
else n = unit(sub(d, mul(radial, s.major)));
|
|
370
|
-
if (!face.sense) n = neg(n);
|
|
371
|
-
const t = unit(sub(p1, p0));
|
|
372
|
-
const inward = unit(cross(n, t));
|
|
373
|
-
const step = Math.max(len(sub(p1, p0)), 1e-9);
|
|
374
|
-
return at(add(m, mul(inward, step)));
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
/** The profile of a patch in (radius, height) about its axis: a line or an arc. */
|
|
378
|
-
function patchProfile(s, v0, v1) {
|
|
379
|
-
if (s.kind === 'cylinder') return { kind: 'line', a: [s.radius, v0], b: [s.radius, v1] };
|
|
380
|
-
if (s.kind === 'cone') {
|
|
381
|
-
const r = (v) => Math.max(0, s.radius + v * Math.tan(s.semiAngle));
|
|
382
|
-
return { kind: 'line', a: [r(v0), v0], b: [r(v1), v1] };
|
|
383
|
-
}
|
|
384
|
-
const c = s.kind === 'sphere' ? [0, 0] : [s.major, 0];
|
|
385
|
-
const r = s.kind === 'sphere' ? s.radius : s.minor;
|
|
386
|
-
const pt = (v) => [c[0] + r * Math.cos(v), c[1] + r * Math.sin(v)];
|
|
387
|
-
return { kind: 'arc', centre: c, radius: r, a: pt(v0), b: pt(v1), span: v1 - v0, v0 };
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
function profileData(p, fmt, move = true) {
|
|
391
|
-
const P = (q) => `${fmt(Math.max(0, q[0]))},${fmt(q[1])}`;
|
|
392
|
-
const head = move ? `M${P(p.a)}` : '';
|
|
393
|
-
if (p.kind === 'line') return `${head}L${P(p.b)}`;
|
|
394
|
-
const arc = (to, large) => `A${fmt(p.radius)},${fmt(p.radius)} 0 ${large ? 1 : 0},1 ${P(to)}`;
|
|
395
|
-
if (p.span >= TAU - 1e-9) {
|
|
396
|
-
const mid = [p.centre[0] + p.radius * Math.cos(p.v0 + Math.PI), p.centre[1] + p.radius * Math.sin(p.v0 + Math.PI)];
|
|
397
|
-
return `${head}${arc(mid, false)}${arc(p.b, false)}`;
|
|
398
|
-
}
|
|
399
|
-
return `${head}${arc(p.b, p.span > Math.PI + 1e-12)}`;
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
/** A face of revolution as an open <revolve> through the angle it spans. */
|
|
403
|
-
function revolveFace(face, fmt, tol) {
|
|
404
|
-
const patch = revolutionPatch(face, tol);
|
|
405
|
-
if (!patch) return null;
|
|
406
|
-
const s = face.surface;
|
|
407
|
-
const f = s.frame;
|
|
408
|
-
const X = add(mul(f.x, Math.cos(patch.u0)), mul(f.y, Math.sin(patch.u0)));
|
|
409
|
-
const { attrs } = placement(f.origin, X, f.z, cross(X, f.z), fmt);
|
|
410
|
-
attrs.d = profileData(patchProfile(s, patch.v0, patch.v1), fmt);
|
|
411
|
-
if (patch.uSpan < TAU - 1e-9) attrs.angle = formatNumber(patch.uSpan * DEG, 6);
|
|
412
|
-
return { el: h('revolve', attrs), exact: true };
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
// --- whole solids ----------------------------------------------------------------------------
|
|
416
|
-
|
|
417
|
-
const parallelDir = (a, b) => Math.abs(Math.abs(dot(a, b)) - 1) < 1e-7;
|
|
418
|
-
const REVOLUTION = new Set(['cylinder', 'cone', 'sphere', 'torus']);
|
|
419
|
-
|
|
420
|
-
/** A shell that is one sphere or one torus. */
|
|
421
|
-
function primitiveSolid(shell, fmt, tol) {
|
|
422
|
-
if (shell.faces.length !== 1) return null;
|
|
423
|
-
const face = shell.faces[0];
|
|
424
|
-
const s = face.surface;
|
|
425
|
-
if (s.kind !== 'sphere' && s.kind !== 'torus') return null;
|
|
426
|
-
const patch = revolutionPatch(face, tol);
|
|
427
|
-
if (!patch || patch.uSpan < TAU - 1e-9) return null;
|
|
428
|
-
if (s.kind === 'sphere') {
|
|
429
|
-
if (patch.v1 - patch.v0 < Math.PI - 1e-7) return null;
|
|
430
|
-
const attrs = { radius: fmt(s.radius) };
|
|
431
|
-
if (s.frame.origin.some((v) => v !== 0)) attrs.position = s.frame.origin.map(fmt).join(',');
|
|
432
|
-
return h('sphere', attrs);
|
|
433
|
-
}
|
|
434
|
-
if (patch.v1 - patch.v0 < TAU - 1e-7) return null;
|
|
435
|
-
const { attrs } = placement(s.frame.origin, s.frame.x, s.frame.z, cross(s.frame.x, s.frame.z), fmt);
|
|
436
|
-
return h('torus', { ...attrs, radius: fmt(s.major), tube: fmt(s.minor) });
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
/**
|
|
440
|
-
* A turned part: every face a whole turn of a surface of revolution about one
|
|
441
|
-
* axis, or a flat ring or disc across it. Written as a cylinder, a cone or a
|
|
442
|
-
* <revolve> of the profile the faces make in (radius, height).
|
|
443
|
-
*/
|
|
444
|
-
function revolvedSolid(shell, fmt, tol) {
|
|
445
|
-
const axisFace = shell.faces.find((fc) => REVOLUTION.has(fc.surface.kind));
|
|
446
|
-
if (!axisFace) return null;
|
|
447
|
-
const O = axisFace.surface.frame.origin;
|
|
448
|
-
// the axis points up (or along the first axis it has a part of), so the profile reads bottom to top
|
|
449
|
-
let Z = axisFace.surface.frame.z;
|
|
450
|
-
const lead = Math.abs(Z[2]) > 1e-9 ? Z[2] : Math.abs(Z[1]) > 1e-9 ? Z[1] : Z[0];
|
|
451
|
-
if (lead < 0) Z = neg(Z);
|
|
452
|
-
const onAxis = (p) => len(cross(sub(p, O), Z)) <= tol * 10;
|
|
453
|
-
const H = (p) => dot(sub(p, O), Z);
|
|
454
|
-
const segments = [];
|
|
455
|
-
for (const face of shell.faces) {
|
|
456
|
-
const s = face.surface;
|
|
457
|
-
if (REVOLUTION.has(s.kind)) {
|
|
458
|
-
if (!parallelDir(s.frame.z, Z) || !onAxis(s.frame.origin)) return null;
|
|
459
|
-
const patch = revolutionPatch(face, tol);
|
|
460
|
-
if (!patch || patch.uSpan < TAU - 1e-9) return null;
|
|
461
|
-
const p = patchProfile(s, patch.v0, patch.v1);
|
|
462
|
-
// the face's own axis may point the other way or start elsewhere
|
|
463
|
-
const flip = dot(s.frame.z, Z) < 0 ? -1 : 1;
|
|
464
|
-
const shift = H(s.frame.origin);
|
|
465
|
-
const map = (q) => [q[0], shift + flip * q[1]];
|
|
466
|
-
if (p.kind === 'line') segments.push({ kind: 'line', a: map(p.a), b: map(p.b) });
|
|
467
|
-
else segments.push({ kind: 'arc', centre: map(p.centre), radius: p.radius, a: map(p.a), b: map(p.b), ccw: flip > 0, span: p.span });
|
|
468
|
-
} else if (s.kind === 'plane') {
|
|
469
|
-
if (!parallelDir(s.frame.z, Z)) return null;
|
|
470
|
-
const radii = [];
|
|
471
|
-
for (const loop of face.loops) {
|
|
472
|
-
for (const e of loop.edges) {
|
|
473
|
-
if (e.curve.kind !== 'circle' || !parallelDir(e.curve.frame.z, Z) || !onAxis(e.curve.frame.origin)) return null;
|
|
474
|
-
radii.push(e.curve.radius);
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
if (!radii.length) return null;
|
|
478
|
-
const hgt = H(s.frame.origin);
|
|
479
|
-
segments.push({ kind: 'line', a: [radii.length > 1 ? Math.min(...radii) : 0, hgt], b: [Math.max(...radii), hgt] });
|
|
480
|
-
} else return null;
|
|
481
|
-
}
|
|
482
|
-
// chain the pieces into one profile
|
|
483
|
-
const same = (p, q) => Math.hypot(p[0] - q[0], p[1] - q[1]) <= tol * 10;
|
|
484
|
-
const left = segments.slice();
|
|
485
|
-
const start = left.findIndex((sg) => sg.a[0] <= tol * 10 || sg.b[0] <= tol * 10);
|
|
486
|
-
const chain = [];
|
|
487
|
-
let cur = left.splice(start >= 0 ? start : 0, 1)[0];
|
|
488
|
-
if (cur.b[0] <= tol * 10 && !(cur.a[0] <= tol * 10)) cur = reverseSegment(cur);
|
|
489
|
-
chain.push(cur);
|
|
490
|
-
while (left.length) {
|
|
491
|
-
const end = chain[chain.length - 1].b;
|
|
492
|
-
const i = left.findIndex((sg) => same(sg.a, end) || same(sg.b, end));
|
|
493
|
-
if (i < 0) return null;
|
|
494
|
-
let next = left.splice(i, 1)[0];
|
|
495
|
-
if (!same(next.a, end)) next = reverseSegment(next);
|
|
496
|
-
chain.push(next);
|
|
497
|
-
}
|
|
498
|
-
const first = chain[0].a;
|
|
499
|
-
const last = chain[chain.length - 1].b;
|
|
500
|
-
const touchesAxis = first[0] <= tol * 10 && last[0] <= tol * 10;
|
|
501
|
-
if (!touchesAxis && !same(first, last)) return null;
|
|
502
|
-
// a frame on the axis at the lowest height
|
|
503
|
-
const hs = chain.flatMap((sg) => [sg.a[1], sg.b[1]]);
|
|
504
|
-
const base = Math.min(...hs);
|
|
505
|
-
const origin = add(O, mul(Z, base));
|
|
506
|
-
const X = unit(Math.abs(Z[0]) < 0.9 ? cross([0, 1, 0], Z) : cross(Z, [0, 0, 1]));
|
|
507
|
-
const lines = chain.every((sg) => sg.kind === 'line');
|
|
508
|
-
const rs = chain.map((sg) => [sg.a, sg.b]).flat();
|
|
509
|
-
// cylinder: disc, wall, disc; cone and frustum likewise
|
|
510
|
-
if (lines && touchesAxis && chain.length === 3) {
|
|
511
|
-
const [d0, wall, d1] = chain;
|
|
512
|
-
if (Math.abs(d0.a[1] - d0.b[1]) <= tol && Math.abs(d1.a[1] - d1.b[1]) <= tol) {
|
|
513
|
-
const r0 = wall.a[0];
|
|
514
|
-
const r1 = wall.b[0];
|
|
515
|
-
const lowFirst = wall.a[1] < wall.b[1];
|
|
516
|
-
const height = Math.abs(wall.b[1] - wall.a[1]);
|
|
517
|
-
const centre = add(origin, mul(Z, height / 2));
|
|
518
|
-
const { attrs } = placement(centre, X, Z, cross(X, Z), fmt);
|
|
519
|
-
const [bottom, top] = lowFirst ? [r0, r1] : [r1, r0];
|
|
520
|
-
if (Math.abs(bottom - top) <= tol) return { el: h('cylinder', { ...attrs, radius: fmt(bottom), height: fmt(height) }), kind: 'cylinder' };
|
|
521
|
-
return { el: h('cylinder', { ...attrs, radius: fmt(bottom), radiusTop: fmt(top), height: fmt(height) }), kind: 'cylinder' };
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
if (lines && touchesAxis && chain.length === 2) {
|
|
525
|
-
const flat = chain.find((sg) => Math.abs(sg.a[1] - sg.b[1]) <= tol);
|
|
526
|
-
const slope = chain.find((sg) => sg !== flat);
|
|
527
|
-
if (flat && slope) {
|
|
528
|
-
const height = Math.abs(slope.a[1] - slope.b[1]);
|
|
529
|
-
const apexUp = Math.max(slope.a[1], slope.b[1]) > flat.a[1];
|
|
530
|
-
const centre = add(origin, mul(Z, height / 2));
|
|
531
|
-
const Y = apexUp ? Z : neg(Z);
|
|
532
|
-
const { attrs } = placement(centre, X, Y, cross(X, Y), fmt);
|
|
533
|
-
return { el: h('cone', { ...attrs, radius: fmt(Math.max(...rs.map((p) => p[0]))), height: fmt(height) }), kind: 'cone' };
|
|
534
|
-
}
|
|
535
|
-
}
|
|
536
|
-
const { attrs } = placement(origin, X, Z, cross(X, Z), fmt);
|
|
537
|
-
const shifted = chain.map((sg) => ({ ...sg, a: [sg.a[0], sg.a[1] - base], b: [sg.b[0], sg.b[1] - base], centre: sg.centre && [sg.centre[0], sg.centre[1] - base] }));
|
|
538
|
-
let d = '';
|
|
539
|
-
shifted.forEach((sg, i) => {
|
|
540
|
-
d += i === 0 ? `M${fmt(Math.max(0, sg.a[0]))},${fmt(sg.a[1])}` : '';
|
|
541
|
-
if (sg.kind === 'line') d += `L${fmt(Math.max(0, sg.b[0]))},${fmt(sg.b[1])}`;
|
|
542
|
-
else d += `A${fmt(sg.radius)},${fmt(sg.radius)} 0 ${Math.abs(sg.span) > Math.PI + 1e-12 ? 1 : 0},${sg.ccw ? 1 : 0} ${fmt(Math.max(0, sg.b[0]))},${fmt(sg.b[1])}`;
|
|
543
|
-
});
|
|
544
|
-
if (!touchesAxis) d += 'Z';
|
|
545
|
-
return { el: h('revolve', { ...attrs, d }), kind: 'revolve' };
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
function reverseSegment(sg) {
|
|
549
|
-
return sg.kind === 'line' ? { ...sg, a: sg.b, b: sg.a } : { ...sg, a: sg.b, b: sg.a, ccw: !sg.ccw };
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
/**
|
|
553
|
-
* A prism: two flat caps with the same outline, the second the first moved
|
|
554
|
-
* along their normal, and walls that run straight between them. Written as
|
|
555
|
-
* a box when the outline is a rectangle, else an <extrude> of the outline.
|
|
556
|
-
*/
|
|
557
|
-
function prismSolid(shell, fmt, tol) {
|
|
558
|
-
const planes = shell.faces.filter((fc) => fc.surface.kind === 'plane');
|
|
559
|
-
for (const cap of planes) {
|
|
560
|
-
const n = cap.sense ? cap.surface.frame.z : neg(cap.surface.frame.z);
|
|
561
|
-
const d = neg(n);
|
|
562
|
-
const caps = planes.filter((fc) => parallelDir(fc.surface.frame.z, d));
|
|
563
|
-
if (caps.length !== 2) continue;
|
|
564
|
-
const other = caps.find((fc) => fc !== cap);
|
|
565
|
-
const depth = dot(sub(other.surface.frame.origin, cap.surface.frame.origin), d);
|
|
566
|
-
if (depth <= tol) continue;
|
|
567
|
-
const H = (p) => dot(sub(p, cap.surface.frame.origin), d);
|
|
568
|
-
let ok = true;
|
|
569
|
-
for (const fc of shell.faces) {
|
|
570
|
-
if (fc === cap || fc === other) continue;
|
|
571
|
-
const s = fc.surface;
|
|
572
|
-
const wall = (s.kind === 'plane' && Math.abs(dot(s.frame.z, d)) < 1e-7) || ((s.kind === 'cylinder') && parallelDir(s.frame.z, d)) || (s.kind === 'extrusion' && parallelDir(s.dir, d));
|
|
573
|
-
if (!wall) {
|
|
574
|
-
ok = false;
|
|
575
|
-
break;
|
|
576
|
-
}
|
|
577
|
-
for (const loop of fc.loops) {
|
|
578
|
-
for (const e of loop.edges) {
|
|
579
|
-
const h0 = H(e.start);
|
|
580
|
-
const h1 = H(e.end);
|
|
581
|
-
const across = e.curve.kind === 'line' && parallelDir(e.curve.dir, d);
|
|
582
|
-
const inCap = (Math.abs(h0) <= tol && Math.abs(h1) <= tol) || (Math.abs(h0 - depth) <= tol && Math.abs(h1 - depth) <= tol);
|
|
583
|
-
if (!across && !inCap) ok = false;
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
if (!ok) break;
|
|
587
|
-
}
|
|
588
|
-
if (!ok) continue;
|
|
589
|
-
// the far cap is the near one moved by the depth
|
|
590
|
-
const corners = (fc) => fc.loops.flatMap((l) => l.edges.map((e) => e.start));
|
|
591
|
-
const near = corners(cap);
|
|
592
|
-
const far = corners(other);
|
|
593
|
-
if (near.length !== far.length || !far.every((p) => near.some((q) => len(sub(add(q, mul(d, depth)), p)) <= tol * 10))) continue;
|
|
594
|
-
// the outline in a frame on the near cap whose +Z points into the solid
|
|
595
|
-
const s = cap.surface.frame;
|
|
596
|
-
const xAxis = s.x;
|
|
597
|
-
const { attrs, frame } = placement(s.origin, xAxis, cross(d, xAxis), d, fmt);
|
|
598
|
-
const L = localOf(frame);
|
|
599
|
-
const loops = cap.loops.filter((l) => l.edges.length);
|
|
600
|
-
const rect = loops.length === 1 && loops[0].edges.length === 4 && loops[0].edges.every((e) => e.curve.kind === 'line') && loops[0].edges.every((e, i, a) => Math.abs(dot(unit(sub(e.end, e.start)), unit(sub(a[(i + 1) % 4].end, a[(i + 1) % 4].start)))) < 1e-7);
|
|
601
|
-
if (rect) {
|
|
602
|
-
const p = loops[0].edges.map((e) => e.start);
|
|
603
|
-
const ex = unit(sub(p[1], p[0]));
|
|
604
|
-
const ey = cross(d, ex);
|
|
605
|
-
const centre = add(mul(add(p[0], p[2]), 0.5), mul(d, depth / 2));
|
|
606
|
-
const pl = placement(centre, ex, ey, d, fmt);
|
|
607
|
-
return { el: h('box', { ...pl.attrs, size: [len(sub(p[1], p[0])), len(sub(p[3], p[0])), depth].map(fmt).join(',') }), kind: 'box' };
|
|
608
|
-
}
|
|
609
|
-
const subpaths = [];
|
|
610
|
-
let exact = true;
|
|
611
|
-
for (const loop of loops) {
|
|
612
|
-
let dd = `M${fmt(L(loop.edges[0].start)[0])},${fmt(L(loop.edges[0].start)[1])}`;
|
|
613
|
-
for (const e of loop.edges) {
|
|
614
|
-
const seg = edgeData(e, frame, L, fmt, tol);
|
|
615
|
-
if (!seg) return null;
|
|
616
|
-
dd += seg.d;
|
|
617
|
-
exact = exact && seg.exact;
|
|
618
|
-
}
|
|
619
|
-
subpaths.push(`${dd}Z`);
|
|
620
|
-
}
|
|
621
|
-
const out = { ...attrs, d: subpaths.join(' '), depth: fmt(depth) };
|
|
622
|
-
if (subpaths.length > 1) out.fillRule = 'evenodd';
|
|
623
|
-
return { el: h('extrude', out), kind: 'extrude', exact };
|
|
624
|
-
}
|
|
625
|
-
return null;
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
// --- faces ES3D cannot describe: tessellated from the exact surface ------------------------------
|
|
629
|
-
|
|
630
|
-
/** Parameters of a surface point, and the point of parameters, for tessellation. */
|
|
631
|
-
function parametrisation(s) {
|
|
632
|
-
const f = s.frame;
|
|
633
|
-
if (s.kind === 'plane') {
|
|
634
|
-
return { uv: (p) => [dot(sub(p, f.origin), f.x), dot(sub(p, f.origin), f.y)], xyz: ([u, v]) => add(f.origin, add(mul(f.x, u), mul(f.y, v))), periodic: [false, false] };
|
|
635
|
-
}
|
|
636
|
-
if (REVOLUTION.has(s.kind)) {
|
|
637
|
-
const at = revolutionCoords(s);
|
|
638
|
-
return {
|
|
639
|
-
uv: (p) => {
|
|
640
|
-
const c = at(p);
|
|
641
|
-
return [c.u, c.v];
|
|
642
|
-
},
|
|
643
|
-
xyz: ([u, v]) => {
|
|
644
|
-
const dir = add(mul(f.x, Math.cos(u)), mul(f.y, Math.sin(u)));
|
|
645
|
-
if (s.kind === 'cylinder') return add(f.origin, add(mul(f.z, v), mul(dir, s.radius)));
|
|
646
|
-
if (s.kind === 'cone') return add(f.origin, add(mul(f.z, v), mul(dir, s.radius + v * Math.tan(s.semiAngle))));
|
|
647
|
-
if (s.kind === 'sphere') return add(f.origin, add(mul(f.z, s.radius * Math.sin(v)), mul(dir, s.radius * Math.cos(v))));
|
|
648
|
-
return add(f.origin, add(mul(f.z, s.minor * Math.sin(v)), mul(dir, s.major + s.minor * Math.cos(v))));
|
|
649
|
-
},
|
|
650
|
-
periodic: [true, s.kind === 'torus'],
|
|
651
|
-
};
|
|
652
|
-
}
|
|
653
|
-
if (s.kind === 'bspline') {
|
|
654
|
-
const u0 = s.U[s.du];
|
|
655
|
-
const u1 = s.U[s.U.length - s.du - 1];
|
|
656
|
-
const v0 = s.V[s.dv];
|
|
657
|
-
const v1 = s.V[s.V.length - s.dv - 1];
|
|
658
|
-
const xyz = ([u, v]) => evalSplineSurface(s, Math.min(u1, Math.max(u0, u)), Math.min(v1, Math.max(v0, v)));
|
|
659
|
-
const G = 16;
|
|
660
|
-
const grid = [];
|
|
661
|
-
for (let i = 0; i <= G; i++) for (let j = 0; j <= G; j++) {
|
|
662
|
-
const uv = [u0 + ((u1 - u0) * i) / G, v0 + ((v1 - v0) * j) / G];
|
|
663
|
-
grid.push([uv, xyz(uv)]);
|
|
664
|
-
}
|
|
665
|
-
// nearest grid point, then Gauss-Newton on the distance
|
|
666
|
-
const uv = (p) => {
|
|
667
|
-
let best = grid[0];
|
|
668
|
-
for (const g of grid) if (len(sub(g[1], p)) < len(sub(best[1], p))) best = g;
|
|
669
|
-
let [u, v] = best[0];
|
|
670
|
-
const hu = (u1 - u0) * 1e-6;
|
|
671
|
-
const hv = (v1 - v0) * 1e-6;
|
|
672
|
-
for (let k = 0; k < 12; k++) {
|
|
673
|
-
const q = xyz([u, v]);
|
|
674
|
-
const r = sub(p, q);
|
|
675
|
-
const su = mul(sub(xyz([u + hu, v]), q), 1 / hu);
|
|
676
|
-
const sv = mul(sub(xyz([u, v + hv]), q), 1 / hv);
|
|
677
|
-
const a = dot(su, su), b = dot(su, sv), c = dot(sv, sv);
|
|
678
|
-
const det = a * c - b * b;
|
|
679
|
-
if (Math.abs(det) < 1e-30) break;
|
|
680
|
-
const du = (c * dot(su, r) - b * dot(sv, r)) / det;
|
|
681
|
-
const dv = (a * dot(sv, r) - b * dot(su, r)) / det;
|
|
682
|
-
u = Math.min(u1, Math.max(u0, u + du));
|
|
683
|
-
v = Math.min(v1, Math.max(v0, v + dv));
|
|
684
|
-
if (Math.abs(du) < hu && Math.abs(dv) < hv) break;
|
|
685
|
-
}
|
|
686
|
-
return [u, v];
|
|
687
|
-
};
|
|
688
|
-
return { uv, xyz, periodic: [false, false] };
|
|
689
|
-
}
|
|
690
|
-
return null;
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
function area2(p) {
|
|
694
|
-
let a = 0;
|
|
695
|
-
for (let i = 0; i < p.length; i++) {
|
|
696
|
-
const q = p[(i + 1) % p.length];
|
|
697
|
-
a += p[i][0] * q[1] - q[0] * p[i][1];
|
|
698
|
-
}
|
|
699
|
-
return a / 2;
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
/** One outline from an outer ring and holes, joined by bridges (for ear clipping). */
|
|
703
|
-
function bridgeHoles(outer, holes) {
|
|
704
|
-
let ring = outer.slice();
|
|
705
|
-
const sorted = holes.slice().sort((a, b) => Math.max(...b.map((p) => p.uv[0])) - Math.max(...a.map((p) => p.uv[0])));
|
|
706
|
-
for (const hole of sorted) {
|
|
707
|
-
let hi = 0;
|
|
708
|
-
for (let i = 1; i < hole.length; i++) if (hole[i].uv[0] > hole[hi].uv[0]) hi = i;
|
|
709
|
-
const hp = hole[hi].uv;
|
|
710
|
-
let best = -1;
|
|
711
|
-
let bestD = Infinity;
|
|
712
|
-
for (let i = 0; i < ring.length; i++) {
|
|
713
|
-
const q = ring[i].uv;
|
|
714
|
-
if (q[0] < hp[0]) continue;
|
|
715
|
-
const dd = Math.hypot(q[0] - hp[0], q[1] - hp[1]);
|
|
716
|
-
if (dd < bestD && !crossesRing(hp, q, ring, hole)) {
|
|
717
|
-
bestD = dd;
|
|
718
|
-
best = i;
|
|
719
|
-
}
|
|
720
|
-
}
|
|
721
|
-
if (best < 0) {
|
|
722
|
-
for (let i = 0; i < ring.length; i++) {
|
|
723
|
-
const q = ring[i].uv;
|
|
724
|
-
const dd = Math.hypot(q[0] - hp[0], q[1] - hp[1]);
|
|
725
|
-
if (dd < bestD) {
|
|
726
|
-
bestD = dd;
|
|
727
|
-
best = i;
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
}
|
|
731
|
-
const holeRun = [...hole.slice(hi), ...hole.slice(0, hi + 1)];
|
|
732
|
-
ring = [...ring.slice(0, best + 1), ...holeRun, ...ring.slice(best)];
|
|
733
|
-
}
|
|
734
|
-
return ring;
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
function crossesRing(a, b, ring, hole) {
|
|
738
|
-
const segs = [ring, hole];
|
|
739
|
-
for (const r of segs) {
|
|
740
|
-
for (let i = 0; i < r.length; i++) {
|
|
741
|
-
const p = r[i].uv;
|
|
742
|
-
const q = r[(i + 1) % r.length].uv;
|
|
743
|
-
if ((p === a || q === a || p === b || q === b)) continue;
|
|
744
|
-
const o = (x, y, z) => (y[0] - x[0]) * (z[1] - x[1]) - (y[1] - x[1]) * (z[0] - x[0]);
|
|
745
|
-
const d1 = o(a, b, p), d2 = o(a, b, q), d3 = o(p, q, a), d4 = o(p, q, b);
|
|
746
|
-
if (d1 * d2 < 0 && d3 * d4 < 0) return true;
|
|
747
|
-
}
|
|
748
|
-
}
|
|
749
|
-
return false;
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
/** Ear clipping in (u, v), cutting at the shortest 3D diagonal; returns index triples into ring. */
|
|
753
|
-
function clipEars(ring) {
|
|
754
|
-
const n = ring.length;
|
|
755
|
-
let idx = ring.map((_, i) => i);
|
|
756
|
-
if (area2(ring.map((p) => p.uv)) < 0) idx.reverse();
|
|
757
|
-
const tris = [];
|
|
758
|
-
const o = (a, b, c) => (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
|
|
759
|
-
const inside = (p, a, b, c) => o(a, b, p) > 0 && o(b, c, p) > 0 && o(c, a, p) > 0;
|
|
760
|
-
let guard = n * n;
|
|
761
|
-
while (idx.length > 3 && guard-- > 0) {
|
|
762
|
-
let best = -1;
|
|
763
|
-
let bestLen = Infinity;
|
|
764
|
-
for (let k = 0; k < idx.length; k++) {
|
|
765
|
-
const a = idx[(k + idx.length - 1) % idx.length], b = idx[k], c = idx[(k + 1) % idx.length];
|
|
766
|
-
if (o(ring[a].uv, ring[b].uv, ring[c].uv) <= 0) continue;
|
|
767
|
-
if (idx.some((m) => m !== a && m !== b && m !== c && inside(ring[m].uv, ring[a].uv, ring[b].uv, ring[c].uv))) continue;
|
|
768
|
-
const l = len(sub(ring[a].p, ring[c].p));
|
|
769
|
-
if (l < bestLen) {
|
|
770
|
-
bestLen = l;
|
|
771
|
-
best = k;
|
|
772
|
-
}
|
|
773
|
-
}
|
|
774
|
-
if (best < 0) break;
|
|
775
|
-
const m = idx.length;
|
|
776
|
-
tris.push([idx[(best + m - 1) % m], idx[best], idx[(best + 1) % m]]);
|
|
777
|
-
idx.splice(best, 1);
|
|
778
|
-
}
|
|
779
|
-
for (let k = 1; k + 1 < idx.length; k++) tris.push([idx[0], idx[k], idx[k + 1]]);
|
|
780
|
-
return tris;
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
/** Cells of the parameter grid for a surface: few along straight directions, more where it curves. */
|
|
784
|
-
function gridSteps(s, box, chord) {
|
|
785
|
-
const angleStep = (r) => Math.min(Math.PI / 12, 2 * Math.acos(Math.max(-1, 1 - chord / Math.max(r, chord))));
|
|
786
|
-
const du = box.max[0] - box.min[0];
|
|
787
|
-
const dv = box.max[1] - box.min[1];
|
|
788
|
-
const n = (span, step) => Math.max(1, Math.min(720, Math.ceil(span / step)));
|
|
789
|
-
if (s.kind === 'cylinder') return [n(du, angleStep(s.radius)), 1];
|
|
790
|
-
if (s.kind === 'cone') {
|
|
791
|
-
const r = Math.max(Math.abs(s.radius + box.min[1] * Math.tan(s.semiAngle)), Math.abs(s.radius + box.max[1] * Math.tan(s.semiAngle)));
|
|
792
|
-
return [n(du, angleStep(r)), 1];
|
|
793
|
-
}
|
|
794
|
-
if (s.kind === 'sphere') return [n(du, angleStep(s.radius)), n(dv, angleStep(s.radius))];
|
|
795
|
-
if (s.kind === 'torus') return [n(du, angleStep(s.major + s.minor)), n(dv, angleStep(s.minor))];
|
|
796
|
-
if (s.kind === 'bspline') return [Math.max(4, (s.U.length - 2 * s.du - 1) * 6), Math.max(4, (s.V.length - 2 * s.dv - 1) * 6)];
|
|
797
|
-
return [1, 1];
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
/** The part of a polygon inside the cell [u0, u1] × [v0, v1] (Sutherland–Hodgman). */
|
|
801
|
-
function clipToCell(poly, u0, u1, v0, v1) {
|
|
802
|
-
let out = poly;
|
|
803
|
-
const edges = [
|
|
804
|
-
[(q) => q[0] >= u0, 0, u0],
|
|
805
|
-
[(q) => q[0] <= u1, 0, u1],
|
|
806
|
-
[(q) => q[1] >= v0, 1, v0],
|
|
807
|
-
[(q) => q[1] <= v1, 1, v1],
|
|
808
|
-
];
|
|
809
|
-
for (const [inside, k, c] of edges) {
|
|
810
|
-
if (!out.length) break;
|
|
811
|
-
const next = [];
|
|
812
|
-
for (let i = 0; i < out.length; i++) {
|
|
813
|
-
const a = out[i];
|
|
814
|
-
const b = out[(i + 1) % out.length];
|
|
815
|
-
const ia = inside(a);
|
|
816
|
-
const ib = inside(b);
|
|
817
|
-
if (ia) next.push(a);
|
|
818
|
-
if (ia !== ib) {
|
|
819
|
-
const t = (c - a[k]) / (b[k] - a[k]);
|
|
820
|
-
const q = [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
|
|
821
|
-
q[k] = c;
|
|
822
|
-
next.push(q);
|
|
823
|
-
}
|
|
824
|
-
}
|
|
825
|
-
out = next;
|
|
826
|
-
}
|
|
827
|
-
return out;
|
|
828
|
-
}
|
|
829
|
-
|
|
830
|
-
/**
|
|
831
|
-
* The simple regions of a clipped cell. Clipping an outline with bridged
|
|
832
|
-
* holes can leave pieces joined by zero-width corridors and holes joined to
|
|
833
|
-
* their outline; the outline is cut where it touches itself, the loops
|
|
834
|
-
* without area dropped, and each hole bridged again into the loop around it.
|
|
835
|
-
* Returns rings of {uv, p} ready for ear clipping.
|
|
836
|
-
*/
|
|
837
|
-
function cellRegions(cell, su, sv) {
|
|
838
|
-
const key = (q) => `${Math.round(q[0] / su)},${Math.round(q[1] / sv)}`;
|
|
839
|
-
const loops = [];
|
|
840
|
-
const stack = [];
|
|
841
|
-
const at = new Map();
|
|
842
|
-
for (const q of cell) {
|
|
843
|
-
const k = key(q);
|
|
844
|
-
if (at.has(k)) {
|
|
845
|
-
const from = at.get(k);
|
|
846
|
-
const loop = stack.splice(from + 1);
|
|
847
|
-
for (const r of loop) at.delete(key(r));
|
|
848
|
-
loops.push([stack[from], ...loop]);
|
|
849
|
-
continue;
|
|
850
|
-
}
|
|
851
|
-
at.set(k, stack.length);
|
|
852
|
-
stack.push(q);
|
|
853
|
-
}
|
|
854
|
-
if (stack.length) loops.push(stack);
|
|
855
|
-
const tiny = su * sv * 1e3;
|
|
856
|
-
const kept = loops.filter((l) => l.length >= 3 && Math.abs(area2(l)) > tiny);
|
|
857
|
-
const outers = kept.filter((l) => area2(l) > 0);
|
|
858
|
-
const holes = kept.filter((l) => area2(l) < 0);
|
|
859
|
-
const inside = (q, ring) => {
|
|
860
|
-
let c = false;
|
|
861
|
-
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
|
862
|
-
const a = ring[i];
|
|
863
|
-
const b = ring[j];
|
|
864
|
-
if (a[1] > q[1] !== b[1] > q[1] && q[0] < ((b[0] - a[0]) * (q[1] - a[1])) / (b[1] - a[1]) + a[0]) c = !c;
|
|
865
|
-
}
|
|
866
|
-
return c;
|
|
867
|
-
};
|
|
868
|
-
return outers.map((outer) => {
|
|
869
|
-
const mine = holes.filter((hole) => inside(hole[0], outer));
|
|
870
|
-
const ring = (l) => l.map((uv) => ({ uv, p: [uv[0], uv[1], 0] }));
|
|
871
|
-
return mine.length ? bridgeHoles(ring(outer), mine.map(ring)) : ring(outer);
|
|
872
|
-
});
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
/**
|
|
876
|
-
* Triangles of a face from its exact surface: its loops are sampled and
|
|
877
|
-
* mapped to the surface's parameters, the region they bound is cut along a
|
|
878
|
-
* grid fine enough for the surface's curvature, and each cell is
|
|
879
|
-
* triangulated there and mapped back onto the surface.
|
|
880
|
-
*/
|
|
881
|
-
function tessellateFace(face, chord) {
|
|
882
|
-
const par = parametrisation(face.surface);
|
|
883
|
-
if (!par) return null;
|
|
884
|
-
const rings = [];
|
|
885
|
-
for (const loop of face.loops) {
|
|
886
|
-
if (!loop.edges.length) continue;
|
|
887
|
-
const ring = [];
|
|
888
|
-
let prev = null;
|
|
889
|
-
for (const e of loop.edges) {
|
|
890
|
-
const pts = sampleEdge(e, 16);
|
|
891
|
-
if (!pts) return null;
|
|
892
|
-
for (let i = 0; i < pts.length - 1; i++) {
|
|
893
|
-
const uv = par.uv(pts[i]);
|
|
894
|
-
if (prev) {
|
|
895
|
-
// stay on the side of the seam the loop is on
|
|
896
|
-
for (let k = 0; k < 2; k++) {
|
|
897
|
-
if (!par.periodic[k]) continue;
|
|
898
|
-
while (uv[k] - prev[k] > Math.PI) uv[k] -= TAU;
|
|
899
|
-
while (uv[k] - prev[k] < -Math.PI) uv[k] += TAU;
|
|
900
|
-
}
|
|
901
|
-
}
|
|
902
|
-
ring.push({ uv, p: pts[i] });
|
|
903
|
-
prev = uv;
|
|
904
|
-
}
|
|
905
|
-
}
|
|
906
|
-
if (ring.length >= 3) rings.push(ring);
|
|
907
|
-
}
|
|
908
|
-
if (!rings.length) return null;
|
|
909
|
-
rings.sort((a, b) => Math.abs(area2(b.map((q) => q.uv))) - Math.abs(area2(a.map((q) => q.uv))));
|
|
910
|
-
const outer = rings[0];
|
|
911
|
-
if (area2(outer.map((q) => q.uv)) < 0) outer.reverse();
|
|
912
|
-
const holes = rings.slice(1).map((r) => (area2(r.map((q) => q.uv)) > 0 ? r.slice().reverse() : r));
|
|
913
|
-
// a hole unwrapped a whole turn away from the outline is moved inside it
|
|
914
|
-
for (let k = 0; k < 2; k++) {
|
|
915
|
-
if (!par.periodic[k]) continue;
|
|
916
|
-
const lo = Math.min(...outer.map((q) => q.uv[k]));
|
|
917
|
-
const hi = Math.max(...outer.map((q) => q.uv[k]));
|
|
918
|
-
for (const hole of holes) {
|
|
919
|
-
const mid = hole.reduce((sum, q) => sum + q.uv[k], 0) / hole.length;
|
|
920
|
-
const shift = mid < lo ? Math.ceil((lo - mid) / TAU) * TAU : mid > hi ? -Math.ceil((mid - hi) / TAU) * TAU : 0;
|
|
921
|
-
if (shift) for (const q of hole) q.uv = k === 0 ? [q.uv[0] + shift, q.uv[1]] : [q.uv[0], q.uv[1] + shift];
|
|
922
|
-
}
|
|
923
|
-
}
|
|
924
|
-
const poly = (holes.length ? bridgeHoles(outer, holes) : outer).map((q) => q.uv);
|
|
925
|
-
const box = { min: [Infinity, Infinity], max: [-Infinity, -Infinity] };
|
|
926
|
-
for (const q of poly) {
|
|
927
|
-
for (let k = 0; k < 2; k++) {
|
|
928
|
-
box.min[k] = Math.min(box.min[k], q[k]);
|
|
929
|
-
box.max[k] = Math.max(box.max[k], q[k]);
|
|
930
|
-
}
|
|
931
|
-
}
|
|
932
|
-
const [nu, nv] = gridSteps(face.surface, box, chord);
|
|
933
|
-
const positions = [];
|
|
934
|
-
const indices = [];
|
|
935
|
-
const ids = new Map();
|
|
936
|
-
const su = (box.max[0] - box.min[0]) * 1e-9 || 1e-12;
|
|
937
|
-
const sv = (box.max[1] - box.min[1]) * 1e-9 || 1e-12;
|
|
938
|
-
const uvs = [];
|
|
939
|
-
const vert = (uv) => {
|
|
940
|
-
const key = `${Math.round(uv[0] / su)},${Math.round(uv[1] / sv)}`;
|
|
941
|
-
if (!ids.has(key)) {
|
|
942
|
-
uvs.push(uv);
|
|
943
|
-
ids.set(key, positions.push(par.xyz(uv)) - 1);
|
|
944
|
-
}
|
|
945
|
-
return ids.get(key);
|
|
946
|
-
};
|
|
947
|
-
for (let i = 0; i < nu; i++) {
|
|
948
|
-
const u0 = box.min[0] + ((box.max[0] - box.min[0]) * i) / nu;
|
|
949
|
-
const u1 = box.min[0] + ((box.max[0] - box.min[0]) * (i + 1)) / nu;
|
|
950
|
-
for (let j = 0; j < nv; j++) {
|
|
951
|
-
const v0 = box.min[1] + ((box.max[1] - box.min[1]) * j) / nv;
|
|
952
|
-
const v1 = box.min[1] + ((box.max[1] - box.min[1]) * (j + 1)) / nv;
|
|
953
|
-
const cell = clipToCell(poly, u0, u1, v0, v1);
|
|
954
|
-
if (cell.length < 3 || Math.abs(area2(cell)) <= su * sv) continue;
|
|
955
|
-
for (const ring of cellRegions(cell, su, sv)) {
|
|
956
|
-
for (const q of ring) q.p = par.xyz(q.uv);
|
|
957
|
-
for (const [a, b, c] of clipEars(ring)) {
|
|
958
|
-
const t = [vert(ring[a].uv), vert(ring[b].uv), vert(ring[c].uv)];
|
|
959
|
-
if (t[0] === t[1] || t[1] === t[2] || t[0] === t[2]) continue;
|
|
960
|
-
// counter-clockwise in (u, v) faces along the surface normal; the face may face the other way
|
|
961
|
-
if (face.sense) indices.push(t[0], t[1], t[2]);
|
|
962
|
-
else indices.push(t[0], t[2], t[1]);
|
|
963
|
-
}
|
|
964
|
-
}
|
|
965
|
-
}
|
|
966
|
-
}
|
|
967
|
-
if (!indices.length) return null;
|
|
968
|
-
return { positions, indices: closeTJunctions(indices, uvs, positions, par, su, sv) };
|
|
969
|
-
}
|
|
970
|
-
|
|
971
|
-
/**
|
|
972
|
-
* Neighbouring cells can meet with a vertex of one lying on an edge of the
|
|
973
|
-
* other, which shows as pinholes. Triangles without area go, and a triangle
|
|
974
|
-
* with vertices on its edges is fanned from its centre through them.
|
|
975
|
-
*/
|
|
976
|
-
function closeTJunctions(indices, uvs, positions, par, su, sv) {
|
|
977
|
-
// a lookup grid of 64 × 64 cells over the face's parameters
|
|
978
|
-
let lo = [Infinity, Infinity];
|
|
979
|
-
let hi = [-Infinity, -Infinity];
|
|
980
|
-
for (const q of uvs) {
|
|
981
|
-
lo = [Math.min(lo[0], q[0]), Math.min(lo[1], q[1])];
|
|
982
|
-
hi = [Math.max(hi[0], q[0]), Math.max(hi[1], q[1])];
|
|
983
|
-
}
|
|
984
|
-
const cu = (hi[0] - lo[0]) / 64 || 1;
|
|
985
|
-
const cv = (hi[1] - lo[1]) / 64 || 1;
|
|
986
|
-
const grid = new Map();
|
|
987
|
-
uvs.forEach((q, i) => {
|
|
988
|
-
const k = `${Math.floor((q[0] - lo[0]) / cu)},${Math.floor((q[1] - lo[1]) / cv)}`;
|
|
989
|
-
if (!grid.has(k)) grid.set(k, []);
|
|
990
|
-
grid.get(k).push(i);
|
|
991
|
-
});
|
|
992
|
-
const eps = Math.hypot(su, sv) * 1e3;
|
|
993
|
-
const onEdge = (a, b) => {
|
|
994
|
-
const A = uvs[a];
|
|
995
|
-
const B = uvs[b];
|
|
996
|
-
const d = [B[0] - A[0], B[1] - A[1]];
|
|
997
|
-
const L2 = d[0] * d[0] + d[1] * d[1];
|
|
998
|
-
const found = [];
|
|
999
|
-
const [i0, i1] = [Math.floor((Math.min(A[0], B[0]) - lo[0]) / cu), Math.floor((Math.max(A[0], B[0]) - lo[0]) / cu)];
|
|
1000
|
-
const [j0, j1] = [Math.floor((Math.min(A[1], B[1]) - lo[1]) / cv), Math.floor((Math.max(A[1], B[1]) - lo[1]) / cv)];
|
|
1001
|
-
for (let i = i0; i <= i1; i++) {
|
|
1002
|
-
for (let j = j0; j <= j1; j++) {
|
|
1003
|
-
for (const v of grid.get(`${i},${j}`) || []) {
|
|
1004
|
-
if (v === a || v === b) continue;
|
|
1005
|
-
const P = uvs[v];
|
|
1006
|
-
const t = ((P[0] - A[0]) * d[0] + (P[1] - A[1]) * d[1]) / L2;
|
|
1007
|
-
if (t <= 1e-9 || t >= 1 - 1e-9) continue;
|
|
1008
|
-
if (Math.abs((P[0] - A[0]) * d[1] - (P[1] - A[1]) * d[0]) / Math.sqrt(L2) > eps) continue;
|
|
1009
|
-
found.push([t, v]);
|
|
1010
|
-
}
|
|
1011
|
-
}
|
|
1012
|
-
}
|
|
1013
|
-
return found.sort((x, y) => x[0] - y[0]).map(([, v]) => v);
|
|
1014
|
-
};
|
|
1015
|
-
const out = [];
|
|
1016
|
-
for (let k = 0; k < indices.length; k += 3) {
|
|
1017
|
-
const t = [indices[k], indices[k + 1], indices[k + 2]];
|
|
1018
|
-
const [A, B, C] = t.map((i) => uvs[i]);
|
|
1019
|
-
const area = (B[0] - A[0]) * (C[1] - A[1]) - (B[1] - A[1]) * (C[0] - A[0]);
|
|
1020
|
-
if (Math.abs(area) <= su * sv) continue;
|
|
1021
|
-
const ring = [];
|
|
1022
|
-
for (let e = 0; e < 3; e++) {
|
|
1023
|
-
for (const v of [t[e], ...onEdge(t[e], t[(e + 1) % 3])]) if (!ring.includes(v)) ring.push(v);
|
|
1024
|
-
}
|
|
1025
|
-
if (ring.length === 3) {
|
|
1026
|
-
out.push(...t);
|
|
1027
|
-
continue;
|
|
1028
|
-
}
|
|
1029
|
-
const mid = [(A[0] + B[0] + C[0]) / 3, (A[1] + B[1] + C[1]) / 3];
|
|
1030
|
-
uvs.push(mid);
|
|
1031
|
-
const c = positions.push(par.xyz(mid)) - 1;
|
|
1032
|
-
for (let i = 0; i < ring.length; i++) out.push(c, ring[i], ring[(i + 1) % ring.length]);
|
|
1033
|
-
}
|
|
1034
|
-
return out;
|
|
1035
|
-
}
|
|
1036
|
-
|
|
1037
|
-
// --- the document --------------------------------------------------------------------------
|
|
1038
|
-
|
|
1039
|
-
function digitsFor(extent) {
|
|
1040
|
-
return Math.min(12, Math.max(0, Math.ceil(-Math.log10(Math.max(extent, 1e-9) * 1e-7))));
|
|
1041
|
-
}
|
|
1042
|
-
|
|
1043
|
-
/**
|
|
1044
|
-
* Convert a STEP file with a boundary representation to procedural ES3D.
|
|
1045
|
-
* @param {string|Uint8Array} input
|
|
1046
|
-
* @param {{up?: 'z'|'y', name?: string}} [opts]
|
|
1047
|
-
* @returns {{text: string, note: string, stats: {shells: number, faces: number, exact: number, tessellated: number, shapes: object}}|null}
|
|
1048
|
-
* null when the file has no B-rep faces (a tessellated STEP file)
|
|
1049
|
-
*/
|
|
1050
|
-
export function stepToExact(input, { up = 'z', name = '' } = {}) {
|
|
1051
|
-
const doc = parseSTEP(input);
|
|
1052
|
-
const { shells, tol } = readBrep(doc.entities);
|
|
1053
|
-
if (!shells.length || shells.every((s) => !s.faces.length)) return null;
|
|
1054
|
-
let extent = 0;
|
|
1055
|
-
for (const sh of shells) for (const fc of sh.faces) for (const l of fc.loops) for (const e of l.edges) for (const p of [e.start, e.end]) extent = Math.max(extent, len(p));
|
|
1056
|
-
const digits = digitsFor(Math.max(extent * 2, 1e-6));
|
|
1057
|
-
const fmt = (v) => formatNumber(v, digits);
|
|
1058
|
-
|
|
1059
|
-
const colours = colourMap(doc.entities);
|
|
1060
|
-
const owners = ownerIndex(doc.entities);
|
|
1061
|
-
const materials = new Map();
|
|
1062
|
-
const materialFor = (rgba) => {
|
|
1063
|
-
const hex = colorToHex(rgba || [0.78, 0.8, 0.82, 1]);
|
|
1064
|
-
if (!materials.has(hex)) materials.set(hex, `c${hex.slice(1, 7)}`);
|
|
1065
|
-
return materials.get(hex);
|
|
1066
|
-
};
|
|
1067
|
-
|
|
1068
|
-
const stats = { shells: shells.length, faces: 0, exact: 0, tessellated: 0, approximate: 0, skipped: 0, shapes: {} };
|
|
1069
|
-
const count = (k) => (stats.shapes[k] = (stats.shapes[k] || 0) + 1);
|
|
1070
|
-
const groups = [];
|
|
1071
|
-
for (const shell of shells) {
|
|
1072
|
-
const shellMat = materialFor(inheritedColour(colours, owners, [shell.id]));
|
|
1073
|
-
const g = h('group', { material: shellMat });
|
|
1074
|
-
if (shell.name) g.setAttr('name', shell.name);
|
|
1075
|
-
stats.faces += shell.faces.length;
|
|
1076
|
-
const whole = primitiveSolid(shell, fmt, tol);
|
|
1077
|
-
const solid = whole ? { el: whole, kind: whole.name } : shell.closed ? revolvedSolid(shell, fmt, tol) || prismSolid(shell, fmt, tol) : null;
|
|
1078
|
-
if (solid) {
|
|
1079
|
-
g.appendChild(solid.el);
|
|
1080
|
-
count(solid.kind);
|
|
1081
|
-
if (solid.exact === false) stats.approximate += shell.faces.length;
|
|
1082
|
-
else stats.exact += shell.faces.length;
|
|
1083
|
-
groups.push(g);
|
|
1084
|
-
continue;
|
|
1085
|
-
}
|
|
1086
|
-
const mesh = { positions: [], indices: [] };
|
|
1087
|
-
for (const face of shell.faces) {
|
|
1088
|
-
const faceColour = colours.get(face.id);
|
|
1089
|
-
const k = face.surface.kind;
|
|
1090
|
-
let out = null;
|
|
1091
|
-
if (k === 'plane') out = planarFace(face, fmt, tol);
|
|
1092
|
-
else if (REVOLUTION.has(k)) out = revolveFace(face, fmt, tol);
|
|
1093
|
-
if (out) {
|
|
1094
|
-
if (faceColour) out.el.setAttr('material', materialFor(faceColour));
|
|
1095
|
-
g.appendChild(out.el);
|
|
1096
|
-
count(out.el.name);
|
|
1097
|
-
if (out.exact) stats.exact++;
|
|
1098
|
-
else stats.approximate++;
|
|
1099
|
-
continue;
|
|
1100
|
-
}
|
|
1101
|
-
const t = tessellateFace(face, extent * 5e-4);
|
|
1102
|
-
if (!t) {
|
|
1103
|
-
stats.skipped++;
|
|
1104
|
-
continue;
|
|
1105
|
-
}
|
|
1106
|
-
const base = mesh.positions.length;
|
|
1107
|
-
for (const p of t.positions) mesh.positions.push(p);
|
|
1108
|
-
for (const i of t.indices) mesh.indices.push(base + i);
|
|
1109
|
-
stats.tessellated++;
|
|
1110
|
-
}
|
|
1111
|
-
if (mesh.indices.length) {
|
|
1112
|
-
g.appendChild(
|
|
1113
|
-
h('mesh', {
|
|
1114
|
-
positions: mesh.positions.map((p) => p.map(fmt).join(' ')).join(' '),
|
|
1115
|
-
indices: mesh.indices.join(' '),
|
|
1116
|
-
smooth: '30',
|
|
1117
|
-
}),
|
|
1118
|
-
);
|
|
1119
|
-
count('mesh');
|
|
1120
|
-
}
|
|
1121
|
-
groups.push(g);
|
|
1122
|
-
}
|
|
1123
|
-
|
|
1124
|
-
// nothing in the file could be read as a surface (unbounded faces): the triangle path says why
|
|
1125
|
-
if (stats.skipped === stats.faces) return null;
|
|
1126
|
-
// CAD kernels name an unnamed part after its kind; the file name says more
|
|
1127
|
-
const product = productName(doc.entities);
|
|
1128
|
-
const title = (product && !/^(compound|solid|shell|body|part|unnamed)\s*\d*$/i.test(product.trim()) ? product : '') || String(name).replace(/\.(step|stp|p21)$/i, '') || product || 'step';
|
|
1129
|
-
const rootAttrs = { version: '1' };
|
|
1130
|
-
if (UNIT[doc.unit]) rootAttrs.unit = UNIT[doc.unit];
|
|
1131
|
-
const root = h('es3d', rootAttrs, h('title', {}, title));
|
|
1132
|
-
for (const [hex, id] of materials) root.appendChild(h('material', { id, color: hex }));
|
|
1133
|
-
const holder = up === 'z' ? root.appendChild(h('group', { name: 'z-up', rotation: '-90,0,0' })) : root;
|
|
1134
|
-
for (const g of groups) holder.appendChild(g);
|
|
1135
|
-
const label = { box: ['box', 'boxes'], extrude: ['extrusion', 'extrusions'], revolve: ['revolve', 'revolves'], path: ['flat face', 'flat faces'], mesh: ['mesh', 'meshes'], torus: ['torus', 'tori'] };
|
|
1136
|
-
const summary = Object.entries(stats.shapes)
|
|
1137
|
-
.map(([k, n]) => `${n} ${(label[k] || [k, `${k}s`])[n === 1 ? 0 : 1]}`)
|
|
1138
|
-
.join(', ');
|
|
1139
|
-
const exactNote = stats.tessellated
|
|
1140
|
-
? `${stats.exact + stats.approximate} of ${stats.faces} faces are ES3D shapes; ${stats.tessellated} face${stats.tessellated === 1 ? ' is a free-form or cross-cut surface ES3D cannot describe, so it is' : 's are free-form or cross-cut surfaces ES3D cannot describe, so they are'} tessellated from the exact surface into a mesh.`
|
|
1141
|
-
: `All ${stats.faces} faces are ES3D shapes${stats.approximate ? ` (${stats.approximate} with edges sampled: rational splines)` : ''}; nothing is a mesh.`;
|
|
1142
|
-
const comment = new Comment(` Converted from STEP: ${summary}. `);
|
|
1143
|
-
const text = serialize(new Document({ root, syntax: 'xml', leading: [comment] }), { pretty: true, comments: true });
|
|
1144
|
-
const skippedNote = stats.skipped ? ` ${stats.skipped} face${stats.skipped === 1 ? ' has' : 's have'} no surface or bounds that can be read and ${stats.skipped === 1 ? 'was' : 'were'} left out.` : '';
|
|
1145
|
-
const unitNote = doc.unit ? ` Coordinates are in ${doc.unit} and were not rescaled.` : ' The file does not name a length unit; coordinates were used as written.';
|
|
1146
|
-
return { text, note: `STEP B-rep: ${summary}. ${exactNote}${skippedNote}${unitNote}${up === 'z' ? ' Turned from Z-up to Y-up by the outer group.' : ''}`, stats };
|
|
1147
|
-
}
|
|
1148
|
-
|
|
1149
|
-
|
|
1
|
+
// STEP solids to procedural ES3D, from their exact geometry (step-brep.js).
|
|
2
|
+
//
|
|
3
|
+
// A CAD part is a boundary of faces. Each shell is written as the most
|
|
4
|
+
// direct ES3D it can be:
|
|
5
|
+
//
|
|
6
|
+
// 1. one shape when the whole shell is one: a box or an <extrude> (a prism:
|
|
7
|
+
// two matching flat caps and straight walls, with holes, arcs, ellipses
|
|
8
|
+
// and splines in the outline), a <cylinder>, <cone>, <sphere>, <torus>
|
|
9
|
+
// or a <revolve> (a turned part: every face a surface of revolution
|
|
10
|
+
// about one axis, or a flat ring across it);
|
|
11
|
+
// 2. otherwise face by face: a flat face is a <path> (lines, circular and
|
|
12
|
+
// elliptical arcs, Bézier curves, holes as subpaths); a cylindrical,
|
|
13
|
+
// conical, spherical or toroidal face bounded along its own circles and
|
|
14
|
+
// meridians (a fillet, a hole wall, a rounded corner) is a <revolve> of
|
|
15
|
+
// its meridian through the angle it spans;
|
|
16
|
+
// 3. a face that ES3D v1 cannot describe exactly (a free-form NURBS
|
|
17
|
+
// surface, or a curved face cut along a curve that is not one of its
|
|
18
|
+
// circles or meridians, such as the rim of a cross hole) is tessellated
|
|
19
|
+
// from its exact surface into a <mesh>, and counted in the note.
|
|
20
|
+
//
|
|
21
|
+
// Coordinates stay in the file's unit and frame. A Z-up file is turned Y-up
|
|
22
|
+
// by a rotation on the group that holds it, so every number is the CAD
|
|
23
|
+
// tool's own.
|
|
24
|
+
|
|
25
|
+
import { parseSTEP, colourMap, ownerIndex, inheritedColour, productName } from './step.js';
|
|
26
|
+
import { readBrep, evalSpline, evalSplineSurface } from './step-brep.js';
|
|
27
|
+
import { h, Document, Comment } from '../format/ast.js';
|
|
28
|
+
import { serialize } from '../format/serializer.js';
|
|
29
|
+
import { formatNumber } from '../format/value-format.js';
|
|
30
|
+
import { colorToHex } from '../format/colors.js';
|
|
31
|
+
import { matrixToEuler } from '../transform/transform.js';
|
|
32
|
+
import * as mat4 from '../math/mat4.js';
|
|
33
|
+
|
|
34
|
+
const TAU = Math.PI * 2;
|
|
35
|
+
const DEG = 180 / Math.PI;
|
|
36
|
+
const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
|
37
|
+
const add = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
|
|
38
|
+
const mul = (a, s) => [a[0] * s, a[1] * s, a[2] * s];
|
|
39
|
+
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
|
40
|
+
const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
|
|
41
|
+
const len = (a) => Math.hypot(a[0], a[1], a[2]);
|
|
42
|
+
const unit = (a) => mul(a, 1 / (len(a) || 1));
|
|
43
|
+
const neg = (a) => [-a[0], -a[1], -a[2]];
|
|
44
|
+
/** angle in (0, 2π] (a whole turn stays a whole turn) */
|
|
45
|
+
const turn = (a) => {
|
|
46
|
+
const m = a - TAU * Math.floor(a / TAU);
|
|
47
|
+
return m <= 1e-12 ? TAU : m;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const UNIT = { millimetres: 'mm', centimetres: 'cm', metres: 'm', kilometres: 'km', inch: 'in', inches: 'in', foot: 'ft', feet: 'ft' };
|
|
51
|
+
|
|
52
|
+
// --- placement ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* position and rotation of a frame (X, Y, Z axes at origin), and the frame a
|
|
56
|
+
* reader builds back from the rounded angles, for measuring local coordinates.
|
|
57
|
+
*/
|
|
58
|
+
function placement(origin, X, Y, Z, fmt) {
|
|
59
|
+
const m = [X[0], X[1], X[2], 0, Y[0], Y[1], Y[2], 0, Z[0], Z[1], Z[2], 0, 0, 0, 0, 1];
|
|
60
|
+
const e = matrixToEuler(m);
|
|
61
|
+
const rot = e.rotation.map((a) => {
|
|
62
|
+
const r = Number(formatNumber(a, 6));
|
|
63
|
+
return r === -180 ? 180 : r === 0 ? 0 : r;
|
|
64
|
+
});
|
|
65
|
+
const R = mat4.fromEulerDeg(mat4.createF64(), rot[0], rot[1], rot[2]);
|
|
66
|
+
const attrs = {};
|
|
67
|
+
if (origin.some((v) => v !== 0)) attrs.position = origin.map(fmt).join(',');
|
|
68
|
+
if (rot.some((v) => v !== 0)) attrs.rotation = rot.map((v) => formatNumber(v)).join(',');
|
|
69
|
+
return { attrs, frame: { o: origin, x: [R[0], R[1], R[2]], y: [R[4], R[5], R[6]], z: [R[8], R[9], R[10]] } };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const localOf = (frame) => (p) => {
|
|
73
|
+
const d = sub(p, frame.o);
|
|
74
|
+
return [dot(d, frame.x), dot(d, frame.y)];
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// --- edges as 2D path data ----------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
/** Insert knots until every interior knot is full: the Bézier segments of a polynomial spline. */
|
|
80
|
+
function bezierSegments(c) {
|
|
81
|
+
const p = c.degree;
|
|
82
|
+
let P = c.points.map((q) => q.slice());
|
|
83
|
+
let U = c.knots.slice();
|
|
84
|
+
const interior = [...new Set(U.slice(p + 1, U.length - p - 1))];
|
|
85
|
+
for (const t of interior) {
|
|
86
|
+
let mult = U.filter((u) => Math.abs(u - t) < 1e-14).length;
|
|
87
|
+
while (mult < p) {
|
|
88
|
+
const k = U.findIndex((u) => u > t) - 1;
|
|
89
|
+
const Q = [];
|
|
90
|
+
for (let i = 0; i < P.length + 1; i++) {
|
|
91
|
+
if (i <= k - p) Q.push(P[i]);
|
|
92
|
+
else if (i > k) Q.push(P[i - 1]);
|
|
93
|
+
else {
|
|
94
|
+
const a = (t - U[i]) / (U[i + p] - U[i]);
|
|
95
|
+
Q.push(add(mul(P[i - 1], 1 - a), mul(P[i], a)));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
P = Q;
|
|
99
|
+
U.splice(k + 1, 0, t);
|
|
100
|
+
mult++;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const segs = [];
|
|
104
|
+
for (let i = 0; i + p < P.length; i += p) segs.push(P.slice(i, i + p + 1));
|
|
105
|
+
return segs;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const polynomial = (c) => !c.weights || c.weights.every((w) => Math.abs(w - c.weights[0]) < 1e-12 * Math.abs(c.weights[0]));
|
|
109
|
+
|
|
110
|
+
/** Points along an edge, start to end, for curves without an exact form. */
|
|
111
|
+
function sampleEdge(e, n = 24) {
|
|
112
|
+
const c = e.curve;
|
|
113
|
+
if (c.kind === 'line') return [e.start, e.end];
|
|
114
|
+
if (c.kind === 'polyline') return e.forward ? c.points : c.points.slice().reverse();
|
|
115
|
+
if (c.kind === 'circle' || c.kind === 'ellipse') {
|
|
116
|
+
const a = c.kind === 'circle' ? c.radius : c.a;
|
|
117
|
+
const b = c.kind === 'circle' ? c.radius : c.b;
|
|
118
|
+
const f = c.frame;
|
|
119
|
+
const param = (p) => {
|
|
120
|
+
const d = sub(p, f.origin);
|
|
121
|
+
return Math.atan2(dot(d, f.y) / b, dot(d, f.x) / a);
|
|
122
|
+
};
|
|
123
|
+
const t0 = param(e.start);
|
|
124
|
+
const t1 = param(e.end);
|
|
125
|
+
const closed = len(sub(e.start, e.end)) < 1e-9 * (a + b);
|
|
126
|
+
const span = closed ? (e.forward ? TAU : -TAU) : e.forward ? turn(t1 - t0) : -turn(t0 - t1);
|
|
127
|
+
const m = Math.max(n, Math.ceil(Math.abs(span) / (Math.PI / 36)));
|
|
128
|
+
const out = [];
|
|
129
|
+
for (let i = 0; i <= m; i++) {
|
|
130
|
+
const t = t0 + (span * i) / m;
|
|
131
|
+
out.push(add(f.origin, add(mul(f.x, a * Math.cos(t)), mul(f.y, b * Math.sin(t)))));
|
|
132
|
+
}
|
|
133
|
+
out[0] = e.start;
|
|
134
|
+
out[m] = e.end;
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
if (c.kind === 'bspline') {
|
|
138
|
+
const t0 = c.knots[c.degree];
|
|
139
|
+
const t1 = c.knots[c.knots.length - c.degree - 1];
|
|
140
|
+
const m = Math.max(n, c.points.length * 6);
|
|
141
|
+
const out = [];
|
|
142
|
+
for (let i = 0; i <= m; i++) out.push(evalSpline(c, t0 + ((t1 - t0) * i) / m));
|
|
143
|
+
if (!e.forward) out.reverse();
|
|
144
|
+
out[0] = e.start;
|
|
145
|
+
out[m] = e.end;
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Path data for one edge of a flat face, from the current point (the edge's
|
|
153
|
+
* start) to its end. `exact` is false when the curve was sampled.
|
|
154
|
+
*/
|
|
155
|
+
function edgeData(e, frame, L, fmt, tol) {
|
|
156
|
+
const c = e.curve;
|
|
157
|
+
const P = (p) => `${fmt(p[0])},${fmt(p[1])}`;
|
|
158
|
+
const end = L(e.end);
|
|
159
|
+
if (c.kind === 'line') return { d: `L${P(end)}`, exact: true };
|
|
160
|
+
if (c.kind === 'polyline') return { d: (e.forward ? c.points.slice(1) : c.points.slice(0, -1).reverse()).map((p) => `L${P(L(p))}`).join(''), exact: true };
|
|
161
|
+
if ((c.kind === 'circle' || c.kind === 'ellipse') && Math.abs(Math.abs(dot(c.frame.z, frame.z)) - 1) < 1e-9) {
|
|
162
|
+
const a = c.kind === 'circle' ? c.radius : c.a;
|
|
163
|
+
const b = c.kind === 'circle' ? c.radius : c.b;
|
|
164
|
+
const f = c.frame;
|
|
165
|
+
const ccw = e.forward === dot(f.z, frame.z) > 0;
|
|
166
|
+
const param = (p) => {
|
|
167
|
+
const d = sub(p, f.origin);
|
|
168
|
+
return Math.atan2(dot(d, f.y) / b, dot(d, f.x) / a);
|
|
169
|
+
};
|
|
170
|
+
const t0 = param(e.start);
|
|
171
|
+
const rot = c.kind === 'circle' ? 0 : Math.atan2(dot(f.x, frame.y), dot(f.x, frame.x)) * DEG;
|
|
172
|
+
const arc = (to, large) => `A${fmt(a)},${fmt(b)} ${c.kind === 'circle' ? 0 : formatNumber(rot, 6)} ${large ? 1 : 0},${ccw ? 1 : 0} ${P(to)}`;
|
|
173
|
+
if (len(sub(e.start, e.end)) <= tol) {
|
|
174
|
+
// a whole circle: two half turns
|
|
175
|
+
const tm = t0 + Math.PI;
|
|
176
|
+
const mid = add(f.origin, add(mul(f.x, a * Math.cos(tm)), mul(f.y, b * Math.sin(tm))));
|
|
177
|
+
return { d: arc(L(mid), false) + arc(end, false), exact: true };
|
|
178
|
+
}
|
|
179
|
+
const t1 = param(e.end);
|
|
180
|
+
const span = ccw === (dot(f.z, frame.z) > 0) ? turn(t1 - t0) : turn(t0 - t1);
|
|
181
|
+
return { d: arc(end, span > Math.PI + 1e-12), exact: true };
|
|
182
|
+
}
|
|
183
|
+
if (c.kind === 'bspline' && polynomial(c) && c.degree <= 3) {
|
|
184
|
+
let segs = bezierSegments(c);
|
|
185
|
+
if (!e.forward) segs = segs.reverse().map((s) => s.slice().reverse());
|
|
186
|
+
const cmd = c.degree === 1 ? 'L' : c.degree === 2 ? 'Q' : 'C';
|
|
187
|
+
return { d: segs.map((s) => cmd + s.slice(1).map((p) => P(L(p))).join(' ')).join(''), exact: true };
|
|
188
|
+
}
|
|
189
|
+
const pts = sampleEdge(e);
|
|
190
|
+
if (!pts) return null;
|
|
191
|
+
return { d: pts.slice(1).map((p) => `L${P(L(p))}`).join(''), exact: false };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** A flat face as a <path> in its plane. */
|
|
195
|
+
function planarFace(face, fmt, tol) {
|
|
196
|
+
const s = face.surface.frame;
|
|
197
|
+
const z = face.sense ? s.z : neg(s.z);
|
|
198
|
+
const { attrs, frame } = placement(s.origin, s.x, cross(z, s.x), z, fmt);
|
|
199
|
+
const L = localOf(frame);
|
|
200
|
+
const subpaths = [];
|
|
201
|
+
let exact = true;
|
|
202
|
+
for (const loop of face.loops) {
|
|
203
|
+
if (!loop.edges.length) continue;
|
|
204
|
+
let d = `M${fmt(L(loop.edges[0].start)[0])},${fmt(L(loop.edges[0].start)[1])}`;
|
|
205
|
+
for (const e of loop.edges) {
|
|
206
|
+
const seg = edgeData(e, frame, L, fmt, tol);
|
|
207
|
+
if (!seg) return null;
|
|
208
|
+
d += seg.d;
|
|
209
|
+
exact = exact && seg.exact;
|
|
210
|
+
}
|
|
211
|
+
subpaths.push(`${d}Z`);
|
|
212
|
+
}
|
|
213
|
+
if (!subpaths.length) return null;
|
|
214
|
+
attrs.d = subpaths.join(' ');
|
|
215
|
+
if (subpaths.length > 1) attrs.fillRule = 'evenodd';
|
|
216
|
+
return { el: h('path', attrs), exact };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// --- faces of revolution ---------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
/** Where a point is on a surface of revolution: azimuth u, and v (height, or latitude on spheres and tori). */
|
|
222
|
+
function revolutionCoords(s) {
|
|
223
|
+
const f = s.frame;
|
|
224
|
+
return (p) => {
|
|
225
|
+
const d = sub(p, f.origin);
|
|
226
|
+
const hgt = dot(d, f.z);
|
|
227
|
+
const x = dot(d, f.x);
|
|
228
|
+
const y = dot(d, f.y);
|
|
229
|
+
const rho = Math.hypot(x, y);
|
|
230
|
+
const u = Math.atan2(y, x);
|
|
231
|
+
if (s.kind === 'sphere') return { u, v: Math.atan2(hgt, rho), rho, h: hgt };
|
|
232
|
+
if (s.kind === 'torus') return { u, v: Math.atan2(hgt, rho - s.major), rho, h: hgt };
|
|
233
|
+
return { u, v: hgt, rho, h: hgt };
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** A point along an edge at fraction t (0 start, 1 end). */
|
|
238
|
+
function edgeAt(e, t) {
|
|
239
|
+
const pts = sampleEdge(e, 8);
|
|
240
|
+
if (!pts) return null;
|
|
241
|
+
const x = t * (pts.length - 1);
|
|
242
|
+
const i = Math.min(pts.length - 2, Math.floor(x));
|
|
243
|
+
const f = x - i;
|
|
244
|
+
return add(mul(pts[i], 1 - f), mul(pts[i + 1], f));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** The CCW interval from a to b that holds probe: [start, span]. */
|
|
248
|
+
function intervalHolding(a, b, probe) {
|
|
249
|
+
const s1 = turn(b - a);
|
|
250
|
+
return turn(probe - a) < s1 ? [a, s1] : [b, turn(a - b)];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* A face of a cylinder, cone, sphere or torus bounded only along its own
|
|
255
|
+
* circles (parallels) and meridians: the ranges of u and v it covers.
|
|
256
|
+
* Null when an edge runs any other way.
|
|
257
|
+
*/
|
|
258
|
+
function revolutionPatch(face, tol) {
|
|
259
|
+
const s = face.surface;
|
|
260
|
+
const f = s.frame;
|
|
261
|
+
const at = revolutionCoords(s);
|
|
262
|
+
const parallels = [];
|
|
263
|
+
const meridians = [];
|
|
264
|
+
let seamU = false;
|
|
265
|
+
let seamV = false;
|
|
266
|
+
const poles = [];
|
|
267
|
+
const curves = new Map();
|
|
268
|
+
for (const loop of face.loops) {
|
|
269
|
+
if (loop.vertex) poles.push(at(loop.vertex));
|
|
270
|
+
for (const e of loop.edges) {
|
|
271
|
+
const c = e.curve;
|
|
272
|
+
const key = c.kind === 'circle' ? `${c.frame.origin.map((x) => x.toFixed(9))}:${c.radius.toFixed(9)}` : null;
|
|
273
|
+
if (len(sub(e.start, e.end)) <= tol && c.kind === 'line') continue;
|
|
274
|
+
if (c.kind === 'circle' && Math.abs(Math.abs(dot(c.frame.z, f.z)) - 1) < 1e-7 && at(c.frame.origin).rho <= tol * 10) {
|
|
275
|
+
const closed = len(sub(e.start, e.end)) <= tol;
|
|
276
|
+
const ccw = e.forward === dot(c.frame.z, f.z) > 0;
|
|
277
|
+
const u0 = at(e.start).u;
|
|
278
|
+
const u1 = at(e.end).u;
|
|
279
|
+
parallels.push({ e, v: at(edgeAt(e, 0.5)).v, closed, interval: closed ? null : ccw ? [u0, turn(u1 - u0)] : [u1, turn(u0 - u1)] });
|
|
280
|
+
if (c.seam || (key && curves.has(key) && closed)) seamV = true;
|
|
281
|
+
if (key) curves.set(key, true);
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
const a = at(e.start);
|
|
285
|
+
const b = at(e.end);
|
|
286
|
+
const m = at(edgeAt(e, 0.5));
|
|
287
|
+
const sameU = (p, q) => Math.abs(Math.sin(p.u - q.u)) < 1e-7 && Math.cos(p.u - q.u) > 0;
|
|
288
|
+
const onAxis = (p) => p.rho <= tol * 10;
|
|
289
|
+
const meridianLine = c.kind === 'line' && (s.kind === 'cylinder' || s.kind === 'cone') && (sameU(a, b) || onAxis(a) || onAxis(b)) && (onAxis(m) || sameU(m, onAxis(a) ? b : a));
|
|
290
|
+
const meridianArc = c.kind === 'circle' && (s.kind === 'sphere' || s.kind === 'torus') && Math.abs(dot(c.frame.z, f.z)) < 1e-7 && (onAxis(a) || onAxis(b) || sameU(a, b)) && (onAxis(m) || sameU(m, onAxis(a) ? b : a));
|
|
291
|
+
if (meridianLine || meridianArc) {
|
|
292
|
+
meridians.push({ e, u: onAxis(a) ? b.u : a.u, a, b, m });
|
|
293
|
+
if (c.seam) seamU = true;
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// two meridians at one azimuth walked both ways are the seam of a whole turn
|
|
300
|
+
for (let i = 0; i < meridians.length; i++) {
|
|
301
|
+
for (let j = i + 1; j < meridians.length; j++) {
|
|
302
|
+
if (Math.abs(Math.sin(meridians[i].u - meridians[j].u)) < 1e-7 && Math.cos(meridians[i].u - meridians[j].u) > 0) seamU = true;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// azimuth range
|
|
307
|
+
let u0 = 0;
|
|
308
|
+
let uSpan = TAU;
|
|
309
|
+
if (!seamU && !parallels.some((p) => p.closed)) {
|
|
310
|
+
const open = parallels.filter((p) => p.interval);
|
|
311
|
+
if (open.length) [u0, uSpan] = open.reduce((best, p) => (p.interval[1] > best[1] ? p.interval : best), open[0].interval);
|
|
312
|
+
else if (meridians.length >= 2) {
|
|
313
|
+
const us = [...new Set(meridians.map((m) => Number(m.u.toFixed(9))))];
|
|
314
|
+
if (us.length !== 2) return null;
|
|
315
|
+
const probe = interiorProbe(face, meridians[0].e, at);
|
|
316
|
+
if (probe === null) return null;
|
|
317
|
+
[u0, uSpan] = intervalHolding(us[0], us[1], probe.u);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// range across: heights on cylinders and cones, latitudes on spheres and tori
|
|
322
|
+
let v0;
|
|
323
|
+
let v1;
|
|
324
|
+
if (s.kind === 'torus') {
|
|
325
|
+
const arcs = meridians.filter((m) => !m.e.curve.seam);
|
|
326
|
+
if (seamV || (!arcs.length && parallels.length < 2)) [v0, v1] = [-Math.PI, Math.PI];
|
|
327
|
+
else if (arcs.length) {
|
|
328
|
+
const m = arcs[0];
|
|
329
|
+
const [start, span] = intervalHolding(m.a.v, m.b.v, m.m.v);
|
|
330
|
+
[v0, v1] = [start, start + span];
|
|
331
|
+
} else {
|
|
332
|
+
const vs = [...new Set(parallels.map((p) => Number(p.v.toFixed(9))))];
|
|
333
|
+
if (vs.length !== 2) return null;
|
|
334
|
+
const probe = interiorProbe(face, parallels[0].e, at);
|
|
335
|
+
if (probe === null) return null;
|
|
336
|
+
const [start, span] = intervalHolding(vs[0], vs[1], probe.v);
|
|
337
|
+
[v0, v1] = [start, start + span];
|
|
338
|
+
}
|
|
339
|
+
} else {
|
|
340
|
+
const vs = [];
|
|
341
|
+
for (const p of parallels) vs.push(p.v);
|
|
342
|
+
for (const m of meridians) vs.push(m.a.v, m.b.v);
|
|
343
|
+
for (const p of poles) vs.push(p.v);
|
|
344
|
+
if (s.kind === 'sphere' && !vs.length) vs.push(-Math.PI / 2, Math.PI / 2);
|
|
345
|
+
if (s.kind === 'sphere' && poles.length === 1 && !parallels.length && !meridians.length) vs.push(-poles[0].v);
|
|
346
|
+
if (vs.length < 2) return null;
|
|
347
|
+
v0 = Math.min(...vs);
|
|
348
|
+
v1 = Math.max(...vs);
|
|
349
|
+
if (v1 - v0 <= tol) return null;
|
|
350
|
+
}
|
|
351
|
+
return { u0, uSpan, v0, v1 };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Coordinates of a point just inside the face, beside the middle of edge e. */
|
|
355
|
+
function interiorProbe(face, e, at) {
|
|
356
|
+
const p0 = edgeAt(e, 0.45);
|
|
357
|
+
const p1 = edgeAt(e, 0.55);
|
|
358
|
+
const m = edgeAt(e, 0.5);
|
|
359
|
+
if (!p0 || !p1 || !m) return null;
|
|
360
|
+
const s = face.surface;
|
|
361
|
+
const f = s.frame;
|
|
362
|
+
// the outward normal at m
|
|
363
|
+
const d = sub(m, f.origin);
|
|
364
|
+
const radial = unit(sub(d, mul(f.z, dot(d, f.z))));
|
|
365
|
+
let n;
|
|
366
|
+
if (s.kind === 'cylinder') n = radial;
|
|
367
|
+
else if (s.kind === 'cone') n = unit(sub(mul(radial, Math.cos(s.semiAngle)), mul(f.z, Math.sin(s.semiAngle))));
|
|
368
|
+
else if (s.kind === 'sphere') n = unit(d);
|
|
369
|
+
else n = unit(sub(d, mul(radial, s.major)));
|
|
370
|
+
if (!face.sense) n = neg(n);
|
|
371
|
+
const t = unit(sub(p1, p0));
|
|
372
|
+
const inward = unit(cross(n, t));
|
|
373
|
+
const step = Math.max(len(sub(p1, p0)), 1e-9);
|
|
374
|
+
return at(add(m, mul(inward, step)));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** The profile of a patch in (radius, height) about its axis: a line or an arc. */
|
|
378
|
+
function patchProfile(s, v0, v1) {
|
|
379
|
+
if (s.kind === 'cylinder') return { kind: 'line', a: [s.radius, v0], b: [s.radius, v1] };
|
|
380
|
+
if (s.kind === 'cone') {
|
|
381
|
+
const r = (v) => Math.max(0, s.radius + v * Math.tan(s.semiAngle));
|
|
382
|
+
return { kind: 'line', a: [r(v0), v0], b: [r(v1), v1] };
|
|
383
|
+
}
|
|
384
|
+
const c = s.kind === 'sphere' ? [0, 0] : [s.major, 0];
|
|
385
|
+
const r = s.kind === 'sphere' ? s.radius : s.minor;
|
|
386
|
+
const pt = (v) => [c[0] + r * Math.cos(v), c[1] + r * Math.sin(v)];
|
|
387
|
+
return { kind: 'arc', centre: c, radius: r, a: pt(v0), b: pt(v1), span: v1 - v0, v0 };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function profileData(p, fmt, move = true) {
|
|
391
|
+
const P = (q) => `${fmt(Math.max(0, q[0]))},${fmt(q[1])}`;
|
|
392
|
+
const head = move ? `M${P(p.a)}` : '';
|
|
393
|
+
if (p.kind === 'line') return `${head}L${P(p.b)}`;
|
|
394
|
+
const arc = (to, large) => `A${fmt(p.radius)},${fmt(p.radius)} 0 ${large ? 1 : 0},1 ${P(to)}`;
|
|
395
|
+
if (p.span >= TAU - 1e-9) {
|
|
396
|
+
const mid = [p.centre[0] + p.radius * Math.cos(p.v0 + Math.PI), p.centre[1] + p.radius * Math.sin(p.v0 + Math.PI)];
|
|
397
|
+
return `${head}${arc(mid, false)}${arc(p.b, false)}`;
|
|
398
|
+
}
|
|
399
|
+
return `${head}${arc(p.b, p.span > Math.PI + 1e-12)}`;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** A face of revolution as an open <revolve> through the angle it spans. */
|
|
403
|
+
function revolveFace(face, fmt, tol) {
|
|
404
|
+
const patch = revolutionPatch(face, tol);
|
|
405
|
+
if (!patch) return null;
|
|
406
|
+
const s = face.surface;
|
|
407
|
+
const f = s.frame;
|
|
408
|
+
const X = add(mul(f.x, Math.cos(patch.u0)), mul(f.y, Math.sin(patch.u0)));
|
|
409
|
+
const { attrs } = placement(f.origin, X, f.z, cross(X, f.z), fmt);
|
|
410
|
+
attrs.d = profileData(patchProfile(s, patch.v0, patch.v1), fmt);
|
|
411
|
+
if (patch.uSpan < TAU - 1e-9) attrs.angle = formatNumber(patch.uSpan * DEG, 6);
|
|
412
|
+
return { el: h('revolve', attrs), exact: true };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// --- whole solids ----------------------------------------------------------------------------
|
|
416
|
+
|
|
417
|
+
const parallelDir = (a, b) => Math.abs(Math.abs(dot(a, b)) - 1) < 1e-7;
|
|
418
|
+
const REVOLUTION = new Set(['cylinder', 'cone', 'sphere', 'torus']);
|
|
419
|
+
|
|
420
|
+
/** A shell that is one sphere or one torus. */
|
|
421
|
+
function primitiveSolid(shell, fmt, tol) {
|
|
422
|
+
if (shell.faces.length !== 1) return null;
|
|
423
|
+
const face = shell.faces[0];
|
|
424
|
+
const s = face.surface;
|
|
425
|
+
if (s.kind !== 'sphere' && s.kind !== 'torus') return null;
|
|
426
|
+
const patch = revolutionPatch(face, tol);
|
|
427
|
+
if (!patch || patch.uSpan < TAU - 1e-9) return null;
|
|
428
|
+
if (s.kind === 'sphere') {
|
|
429
|
+
if (patch.v1 - patch.v0 < Math.PI - 1e-7) return null;
|
|
430
|
+
const attrs = { radius: fmt(s.radius) };
|
|
431
|
+
if (s.frame.origin.some((v) => v !== 0)) attrs.position = s.frame.origin.map(fmt).join(',');
|
|
432
|
+
return h('sphere', attrs);
|
|
433
|
+
}
|
|
434
|
+
if (patch.v1 - patch.v0 < TAU - 1e-7) return null;
|
|
435
|
+
const { attrs } = placement(s.frame.origin, s.frame.x, s.frame.z, cross(s.frame.x, s.frame.z), fmt);
|
|
436
|
+
return h('torus', { ...attrs, radius: fmt(s.major), tube: fmt(s.minor) });
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* A turned part: every face a whole turn of a surface of revolution about one
|
|
441
|
+
* axis, or a flat ring or disc across it. Written as a cylinder, a cone or a
|
|
442
|
+
* <revolve> of the profile the faces make in (radius, height).
|
|
443
|
+
*/
|
|
444
|
+
function revolvedSolid(shell, fmt, tol) {
|
|
445
|
+
const axisFace = shell.faces.find((fc) => REVOLUTION.has(fc.surface.kind));
|
|
446
|
+
if (!axisFace) return null;
|
|
447
|
+
const O = axisFace.surface.frame.origin;
|
|
448
|
+
// the axis points up (or along the first axis it has a part of), so the profile reads bottom to top
|
|
449
|
+
let Z = axisFace.surface.frame.z;
|
|
450
|
+
const lead = Math.abs(Z[2]) > 1e-9 ? Z[2] : Math.abs(Z[1]) > 1e-9 ? Z[1] : Z[0];
|
|
451
|
+
if (lead < 0) Z = neg(Z);
|
|
452
|
+
const onAxis = (p) => len(cross(sub(p, O), Z)) <= tol * 10;
|
|
453
|
+
const H = (p) => dot(sub(p, O), Z);
|
|
454
|
+
const segments = [];
|
|
455
|
+
for (const face of shell.faces) {
|
|
456
|
+
const s = face.surface;
|
|
457
|
+
if (REVOLUTION.has(s.kind)) {
|
|
458
|
+
if (!parallelDir(s.frame.z, Z) || !onAxis(s.frame.origin)) return null;
|
|
459
|
+
const patch = revolutionPatch(face, tol);
|
|
460
|
+
if (!patch || patch.uSpan < TAU - 1e-9) return null;
|
|
461
|
+
const p = patchProfile(s, patch.v0, patch.v1);
|
|
462
|
+
// the face's own axis may point the other way or start elsewhere
|
|
463
|
+
const flip = dot(s.frame.z, Z) < 0 ? -1 : 1;
|
|
464
|
+
const shift = H(s.frame.origin);
|
|
465
|
+
const map = (q) => [q[0], shift + flip * q[1]];
|
|
466
|
+
if (p.kind === 'line') segments.push({ kind: 'line', a: map(p.a), b: map(p.b) });
|
|
467
|
+
else segments.push({ kind: 'arc', centre: map(p.centre), radius: p.radius, a: map(p.a), b: map(p.b), ccw: flip > 0, span: p.span });
|
|
468
|
+
} else if (s.kind === 'plane') {
|
|
469
|
+
if (!parallelDir(s.frame.z, Z)) return null;
|
|
470
|
+
const radii = [];
|
|
471
|
+
for (const loop of face.loops) {
|
|
472
|
+
for (const e of loop.edges) {
|
|
473
|
+
if (e.curve.kind !== 'circle' || !parallelDir(e.curve.frame.z, Z) || !onAxis(e.curve.frame.origin)) return null;
|
|
474
|
+
radii.push(e.curve.radius);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
if (!radii.length) return null;
|
|
478
|
+
const hgt = H(s.frame.origin);
|
|
479
|
+
segments.push({ kind: 'line', a: [radii.length > 1 ? Math.min(...radii) : 0, hgt], b: [Math.max(...radii), hgt] });
|
|
480
|
+
} else return null;
|
|
481
|
+
}
|
|
482
|
+
// chain the pieces into one profile
|
|
483
|
+
const same = (p, q) => Math.hypot(p[0] - q[0], p[1] - q[1]) <= tol * 10;
|
|
484
|
+
const left = segments.slice();
|
|
485
|
+
const start = left.findIndex((sg) => sg.a[0] <= tol * 10 || sg.b[0] <= tol * 10);
|
|
486
|
+
const chain = [];
|
|
487
|
+
let cur = left.splice(start >= 0 ? start : 0, 1)[0];
|
|
488
|
+
if (cur.b[0] <= tol * 10 && !(cur.a[0] <= tol * 10)) cur = reverseSegment(cur);
|
|
489
|
+
chain.push(cur);
|
|
490
|
+
while (left.length) {
|
|
491
|
+
const end = chain[chain.length - 1].b;
|
|
492
|
+
const i = left.findIndex((sg) => same(sg.a, end) || same(sg.b, end));
|
|
493
|
+
if (i < 0) return null;
|
|
494
|
+
let next = left.splice(i, 1)[0];
|
|
495
|
+
if (!same(next.a, end)) next = reverseSegment(next);
|
|
496
|
+
chain.push(next);
|
|
497
|
+
}
|
|
498
|
+
const first = chain[0].a;
|
|
499
|
+
const last = chain[chain.length - 1].b;
|
|
500
|
+
const touchesAxis = first[0] <= tol * 10 && last[0] <= tol * 10;
|
|
501
|
+
if (!touchesAxis && !same(first, last)) return null;
|
|
502
|
+
// a frame on the axis at the lowest height
|
|
503
|
+
const hs = chain.flatMap((sg) => [sg.a[1], sg.b[1]]);
|
|
504
|
+
const base = Math.min(...hs);
|
|
505
|
+
const origin = add(O, mul(Z, base));
|
|
506
|
+
const X = unit(Math.abs(Z[0]) < 0.9 ? cross([0, 1, 0], Z) : cross(Z, [0, 0, 1]));
|
|
507
|
+
const lines = chain.every((sg) => sg.kind === 'line');
|
|
508
|
+
const rs = chain.map((sg) => [sg.a, sg.b]).flat();
|
|
509
|
+
// cylinder: disc, wall, disc; cone and frustum likewise
|
|
510
|
+
if (lines && touchesAxis && chain.length === 3) {
|
|
511
|
+
const [d0, wall, d1] = chain;
|
|
512
|
+
if (Math.abs(d0.a[1] - d0.b[1]) <= tol && Math.abs(d1.a[1] - d1.b[1]) <= tol) {
|
|
513
|
+
const r0 = wall.a[0];
|
|
514
|
+
const r1 = wall.b[0];
|
|
515
|
+
const lowFirst = wall.a[1] < wall.b[1];
|
|
516
|
+
const height = Math.abs(wall.b[1] - wall.a[1]);
|
|
517
|
+
const centre = add(origin, mul(Z, height / 2));
|
|
518
|
+
const { attrs } = placement(centre, X, Z, cross(X, Z), fmt);
|
|
519
|
+
const [bottom, top] = lowFirst ? [r0, r1] : [r1, r0];
|
|
520
|
+
if (Math.abs(bottom - top) <= tol) return { el: h('cylinder', { ...attrs, radius: fmt(bottom), height: fmt(height) }), kind: 'cylinder' };
|
|
521
|
+
return { el: h('cylinder', { ...attrs, radius: fmt(bottom), radiusTop: fmt(top), height: fmt(height) }), kind: 'cylinder' };
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
if (lines && touchesAxis && chain.length === 2) {
|
|
525
|
+
const flat = chain.find((sg) => Math.abs(sg.a[1] - sg.b[1]) <= tol);
|
|
526
|
+
const slope = chain.find((sg) => sg !== flat);
|
|
527
|
+
if (flat && slope) {
|
|
528
|
+
const height = Math.abs(slope.a[1] - slope.b[1]);
|
|
529
|
+
const apexUp = Math.max(slope.a[1], slope.b[1]) > flat.a[1];
|
|
530
|
+
const centre = add(origin, mul(Z, height / 2));
|
|
531
|
+
const Y = apexUp ? Z : neg(Z);
|
|
532
|
+
const { attrs } = placement(centre, X, Y, cross(X, Y), fmt);
|
|
533
|
+
return { el: h('cone', { ...attrs, radius: fmt(Math.max(...rs.map((p) => p[0]))), height: fmt(height) }), kind: 'cone' };
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
const { attrs } = placement(origin, X, Z, cross(X, Z), fmt);
|
|
537
|
+
const shifted = chain.map((sg) => ({ ...sg, a: [sg.a[0], sg.a[1] - base], b: [sg.b[0], sg.b[1] - base], centre: sg.centre && [sg.centre[0], sg.centre[1] - base] }));
|
|
538
|
+
let d = '';
|
|
539
|
+
shifted.forEach((sg, i) => {
|
|
540
|
+
d += i === 0 ? `M${fmt(Math.max(0, sg.a[0]))},${fmt(sg.a[1])}` : '';
|
|
541
|
+
if (sg.kind === 'line') d += `L${fmt(Math.max(0, sg.b[0]))},${fmt(sg.b[1])}`;
|
|
542
|
+
else d += `A${fmt(sg.radius)},${fmt(sg.radius)} 0 ${Math.abs(sg.span) > Math.PI + 1e-12 ? 1 : 0},${sg.ccw ? 1 : 0} ${fmt(Math.max(0, sg.b[0]))},${fmt(sg.b[1])}`;
|
|
543
|
+
});
|
|
544
|
+
if (!touchesAxis) d += 'Z';
|
|
545
|
+
return { el: h('revolve', { ...attrs, d }), kind: 'revolve' };
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function reverseSegment(sg) {
|
|
549
|
+
return sg.kind === 'line' ? { ...sg, a: sg.b, b: sg.a } : { ...sg, a: sg.b, b: sg.a, ccw: !sg.ccw };
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* A prism: two flat caps with the same outline, the second the first moved
|
|
554
|
+
* along their normal, and walls that run straight between them. Written as
|
|
555
|
+
* a box when the outline is a rectangle, else an <extrude> of the outline.
|
|
556
|
+
*/
|
|
557
|
+
function prismSolid(shell, fmt, tol) {
|
|
558
|
+
const planes = shell.faces.filter((fc) => fc.surface.kind === 'plane');
|
|
559
|
+
for (const cap of planes) {
|
|
560
|
+
const n = cap.sense ? cap.surface.frame.z : neg(cap.surface.frame.z);
|
|
561
|
+
const d = neg(n);
|
|
562
|
+
const caps = planes.filter((fc) => parallelDir(fc.surface.frame.z, d));
|
|
563
|
+
if (caps.length !== 2) continue;
|
|
564
|
+
const other = caps.find((fc) => fc !== cap);
|
|
565
|
+
const depth = dot(sub(other.surface.frame.origin, cap.surface.frame.origin), d);
|
|
566
|
+
if (depth <= tol) continue;
|
|
567
|
+
const H = (p) => dot(sub(p, cap.surface.frame.origin), d);
|
|
568
|
+
let ok = true;
|
|
569
|
+
for (const fc of shell.faces) {
|
|
570
|
+
if (fc === cap || fc === other) continue;
|
|
571
|
+
const s = fc.surface;
|
|
572
|
+
const wall = (s.kind === 'plane' && Math.abs(dot(s.frame.z, d)) < 1e-7) || ((s.kind === 'cylinder') && parallelDir(s.frame.z, d)) || (s.kind === 'extrusion' && parallelDir(s.dir, d));
|
|
573
|
+
if (!wall) {
|
|
574
|
+
ok = false;
|
|
575
|
+
break;
|
|
576
|
+
}
|
|
577
|
+
for (const loop of fc.loops) {
|
|
578
|
+
for (const e of loop.edges) {
|
|
579
|
+
const h0 = H(e.start);
|
|
580
|
+
const h1 = H(e.end);
|
|
581
|
+
const across = e.curve.kind === 'line' && parallelDir(e.curve.dir, d);
|
|
582
|
+
const inCap = (Math.abs(h0) <= tol && Math.abs(h1) <= tol) || (Math.abs(h0 - depth) <= tol && Math.abs(h1 - depth) <= tol);
|
|
583
|
+
if (!across && !inCap) ok = false;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
if (!ok) break;
|
|
587
|
+
}
|
|
588
|
+
if (!ok) continue;
|
|
589
|
+
// the far cap is the near one moved by the depth
|
|
590
|
+
const corners = (fc) => fc.loops.flatMap((l) => l.edges.map((e) => e.start));
|
|
591
|
+
const near = corners(cap);
|
|
592
|
+
const far = corners(other);
|
|
593
|
+
if (near.length !== far.length || !far.every((p) => near.some((q) => len(sub(add(q, mul(d, depth)), p)) <= tol * 10))) continue;
|
|
594
|
+
// the outline in a frame on the near cap whose +Z points into the solid
|
|
595
|
+
const s = cap.surface.frame;
|
|
596
|
+
const xAxis = s.x;
|
|
597
|
+
const { attrs, frame } = placement(s.origin, xAxis, cross(d, xAxis), d, fmt);
|
|
598
|
+
const L = localOf(frame);
|
|
599
|
+
const loops = cap.loops.filter((l) => l.edges.length);
|
|
600
|
+
const rect = loops.length === 1 && loops[0].edges.length === 4 && loops[0].edges.every((e) => e.curve.kind === 'line') && loops[0].edges.every((e, i, a) => Math.abs(dot(unit(sub(e.end, e.start)), unit(sub(a[(i + 1) % 4].end, a[(i + 1) % 4].start)))) < 1e-7);
|
|
601
|
+
if (rect) {
|
|
602
|
+
const p = loops[0].edges.map((e) => e.start);
|
|
603
|
+
const ex = unit(sub(p[1], p[0]));
|
|
604
|
+
const ey = cross(d, ex);
|
|
605
|
+
const centre = add(mul(add(p[0], p[2]), 0.5), mul(d, depth / 2));
|
|
606
|
+
const pl = placement(centre, ex, ey, d, fmt);
|
|
607
|
+
return { el: h('box', { ...pl.attrs, size: [len(sub(p[1], p[0])), len(sub(p[3], p[0])), depth].map(fmt).join(',') }), kind: 'box' };
|
|
608
|
+
}
|
|
609
|
+
const subpaths = [];
|
|
610
|
+
let exact = true;
|
|
611
|
+
for (const loop of loops) {
|
|
612
|
+
let dd = `M${fmt(L(loop.edges[0].start)[0])},${fmt(L(loop.edges[0].start)[1])}`;
|
|
613
|
+
for (const e of loop.edges) {
|
|
614
|
+
const seg = edgeData(e, frame, L, fmt, tol);
|
|
615
|
+
if (!seg) return null;
|
|
616
|
+
dd += seg.d;
|
|
617
|
+
exact = exact && seg.exact;
|
|
618
|
+
}
|
|
619
|
+
subpaths.push(`${dd}Z`);
|
|
620
|
+
}
|
|
621
|
+
const out = { ...attrs, d: subpaths.join(' '), depth: fmt(depth) };
|
|
622
|
+
if (subpaths.length > 1) out.fillRule = 'evenodd';
|
|
623
|
+
return { el: h('extrude', out), kind: 'extrude', exact };
|
|
624
|
+
}
|
|
625
|
+
return null;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// --- faces ES3D cannot describe: tessellated from the exact surface ------------------------------
|
|
629
|
+
|
|
630
|
+
/** Parameters of a surface point, and the point of parameters, for tessellation. */
|
|
631
|
+
function parametrisation(s) {
|
|
632
|
+
const f = s.frame;
|
|
633
|
+
if (s.kind === 'plane') {
|
|
634
|
+
return { uv: (p) => [dot(sub(p, f.origin), f.x), dot(sub(p, f.origin), f.y)], xyz: ([u, v]) => add(f.origin, add(mul(f.x, u), mul(f.y, v))), periodic: [false, false] };
|
|
635
|
+
}
|
|
636
|
+
if (REVOLUTION.has(s.kind)) {
|
|
637
|
+
const at = revolutionCoords(s);
|
|
638
|
+
return {
|
|
639
|
+
uv: (p) => {
|
|
640
|
+
const c = at(p);
|
|
641
|
+
return [c.u, c.v];
|
|
642
|
+
},
|
|
643
|
+
xyz: ([u, v]) => {
|
|
644
|
+
const dir = add(mul(f.x, Math.cos(u)), mul(f.y, Math.sin(u)));
|
|
645
|
+
if (s.kind === 'cylinder') return add(f.origin, add(mul(f.z, v), mul(dir, s.radius)));
|
|
646
|
+
if (s.kind === 'cone') return add(f.origin, add(mul(f.z, v), mul(dir, s.radius + v * Math.tan(s.semiAngle))));
|
|
647
|
+
if (s.kind === 'sphere') return add(f.origin, add(mul(f.z, s.radius * Math.sin(v)), mul(dir, s.radius * Math.cos(v))));
|
|
648
|
+
return add(f.origin, add(mul(f.z, s.minor * Math.sin(v)), mul(dir, s.major + s.minor * Math.cos(v))));
|
|
649
|
+
},
|
|
650
|
+
periodic: [true, s.kind === 'torus'],
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
if (s.kind === 'bspline') {
|
|
654
|
+
const u0 = s.U[s.du];
|
|
655
|
+
const u1 = s.U[s.U.length - s.du - 1];
|
|
656
|
+
const v0 = s.V[s.dv];
|
|
657
|
+
const v1 = s.V[s.V.length - s.dv - 1];
|
|
658
|
+
const xyz = ([u, v]) => evalSplineSurface(s, Math.min(u1, Math.max(u0, u)), Math.min(v1, Math.max(v0, v)));
|
|
659
|
+
const G = 16;
|
|
660
|
+
const grid = [];
|
|
661
|
+
for (let i = 0; i <= G; i++) for (let j = 0; j <= G; j++) {
|
|
662
|
+
const uv = [u0 + ((u1 - u0) * i) / G, v0 + ((v1 - v0) * j) / G];
|
|
663
|
+
grid.push([uv, xyz(uv)]);
|
|
664
|
+
}
|
|
665
|
+
// nearest grid point, then Gauss-Newton on the distance
|
|
666
|
+
const uv = (p) => {
|
|
667
|
+
let best = grid[0];
|
|
668
|
+
for (const g of grid) if (len(sub(g[1], p)) < len(sub(best[1], p))) best = g;
|
|
669
|
+
let [u, v] = best[0];
|
|
670
|
+
const hu = (u1 - u0) * 1e-6;
|
|
671
|
+
const hv = (v1 - v0) * 1e-6;
|
|
672
|
+
for (let k = 0; k < 12; k++) {
|
|
673
|
+
const q = xyz([u, v]);
|
|
674
|
+
const r = sub(p, q);
|
|
675
|
+
const su = mul(sub(xyz([u + hu, v]), q), 1 / hu);
|
|
676
|
+
const sv = mul(sub(xyz([u, v + hv]), q), 1 / hv);
|
|
677
|
+
const a = dot(su, su), b = dot(su, sv), c = dot(sv, sv);
|
|
678
|
+
const det = a * c - b * b;
|
|
679
|
+
if (Math.abs(det) < 1e-30) break;
|
|
680
|
+
const du = (c * dot(su, r) - b * dot(sv, r)) / det;
|
|
681
|
+
const dv = (a * dot(sv, r) - b * dot(su, r)) / det;
|
|
682
|
+
u = Math.min(u1, Math.max(u0, u + du));
|
|
683
|
+
v = Math.min(v1, Math.max(v0, v + dv));
|
|
684
|
+
if (Math.abs(du) < hu && Math.abs(dv) < hv) break;
|
|
685
|
+
}
|
|
686
|
+
return [u, v];
|
|
687
|
+
};
|
|
688
|
+
return { uv, xyz, periodic: [false, false] };
|
|
689
|
+
}
|
|
690
|
+
return null;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function area2(p) {
|
|
694
|
+
let a = 0;
|
|
695
|
+
for (let i = 0; i < p.length; i++) {
|
|
696
|
+
const q = p[(i + 1) % p.length];
|
|
697
|
+
a += p[i][0] * q[1] - q[0] * p[i][1];
|
|
698
|
+
}
|
|
699
|
+
return a / 2;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** One outline from an outer ring and holes, joined by bridges (for ear clipping). */
|
|
703
|
+
function bridgeHoles(outer, holes) {
|
|
704
|
+
let ring = outer.slice();
|
|
705
|
+
const sorted = holes.slice().sort((a, b) => Math.max(...b.map((p) => p.uv[0])) - Math.max(...a.map((p) => p.uv[0])));
|
|
706
|
+
for (const hole of sorted) {
|
|
707
|
+
let hi = 0;
|
|
708
|
+
for (let i = 1; i < hole.length; i++) if (hole[i].uv[0] > hole[hi].uv[0]) hi = i;
|
|
709
|
+
const hp = hole[hi].uv;
|
|
710
|
+
let best = -1;
|
|
711
|
+
let bestD = Infinity;
|
|
712
|
+
for (let i = 0; i < ring.length; i++) {
|
|
713
|
+
const q = ring[i].uv;
|
|
714
|
+
if (q[0] < hp[0]) continue;
|
|
715
|
+
const dd = Math.hypot(q[0] - hp[0], q[1] - hp[1]);
|
|
716
|
+
if (dd < bestD && !crossesRing(hp, q, ring, hole)) {
|
|
717
|
+
bestD = dd;
|
|
718
|
+
best = i;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
if (best < 0) {
|
|
722
|
+
for (let i = 0; i < ring.length; i++) {
|
|
723
|
+
const q = ring[i].uv;
|
|
724
|
+
const dd = Math.hypot(q[0] - hp[0], q[1] - hp[1]);
|
|
725
|
+
if (dd < bestD) {
|
|
726
|
+
bestD = dd;
|
|
727
|
+
best = i;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
const holeRun = [...hole.slice(hi), ...hole.slice(0, hi + 1)];
|
|
732
|
+
ring = [...ring.slice(0, best + 1), ...holeRun, ...ring.slice(best)];
|
|
733
|
+
}
|
|
734
|
+
return ring;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function crossesRing(a, b, ring, hole) {
|
|
738
|
+
const segs = [ring, hole];
|
|
739
|
+
for (const r of segs) {
|
|
740
|
+
for (let i = 0; i < r.length; i++) {
|
|
741
|
+
const p = r[i].uv;
|
|
742
|
+
const q = r[(i + 1) % r.length].uv;
|
|
743
|
+
if ((p === a || q === a || p === b || q === b)) continue;
|
|
744
|
+
const o = (x, y, z) => (y[0] - x[0]) * (z[1] - x[1]) - (y[1] - x[1]) * (z[0] - x[0]);
|
|
745
|
+
const d1 = o(a, b, p), d2 = o(a, b, q), d3 = o(p, q, a), d4 = o(p, q, b);
|
|
746
|
+
if (d1 * d2 < 0 && d3 * d4 < 0) return true;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
return false;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/** Ear clipping in (u, v), cutting at the shortest 3D diagonal; returns index triples into ring. */
|
|
753
|
+
function clipEars(ring) {
|
|
754
|
+
const n = ring.length;
|
|
755
|
+
let idx = ring.map((_, i) => i);
|
|
756
|
+
if (area2(ring.map((p) => p.uv)) < 0) idx.reverse();
|
|
757
|
+
const tris = [];
|
|
758
|
+
const o = (a, b, c) => (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
|
|
759
|
+
const inside = (p, a, b, c) => o(a, b, p) > 0 && o(b, c, p) > 0 && o(c, a, p) > 0;
|
|
760
|
+
let guard = n * n;
|
|
761
|
+
while (idx.length > 3 && guard-- > 0) {
|
|
762
|
+
let best = -1;
|
|
763
|
+
let bestLen = Infinity;
|
|
764
|
+
for (let k = 0; k < idx.length; k++) {
|
|
765
|
+
const a = idx[(k + idx.length - 1) % idx.length], b = idx[k], c = idx[(k + 1) % idx.length];
|
|
766
|
+
if (o(ring[a].uv, ring[b].uv, ring[c].uv) <= 0) continue;
|
|
767
|
+
if (idx.some((m) => m !== a && m !== b && m !== c && inside(ring[m].uv, ring[a].uv, ring[b].uv, ring[c].uv))) continue;
|
|
768
|
+
const l = len(sub(ring[a].p, ring[c].p));
|
|
769
|
+
if (l < bestLen) {
|
|
770
|
+
bestLen = l;
|
|
771
|
+
best = k;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
if (best < 0) break;
|
|
775
|
+
const m = idx.length;
|
|
776
|
+
tris.push([idx[(best + m - 1) % m], idx[best], idx[(best + 1) % m]]);
|
|
777
|
+
idx.splice(best, 1);
|
|
778
|
+
}
|
|
779
|
+
for (let k = 1; k + 1 < idx.length; k++) tris.push([idx[0], idx[k], idx[k + 1]]);
|
|
780
|
+
return tris;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/** Cells of the parameter grid for a surface: few along straight directions, more where it curves. */
|
|
784
|
+
function gridSteps(s, box, chord) {
|
|
785
|
+
const angleStep = (r) => Math.min(Math.PI / 12, 2 * Math.acos(Math.max(-1, 1 - chord / Math.max(r, chord))));
|
|
786
|
+
const du = box.max[0] - box.min[0];
|
|
787
|
+
const dv = box.max[1] - box.min[1];
|
|
788
|
+
const n = (span, step) => Math.max(1, Math.min(720, Math.ceil(span / step)));
|
|
789
|
+
if (s.kind === 'cylinder') return [n(du, angleStep(s.radius)), 1];
|
|
790
|
+
if (s.kind === 'cone') {
|
|
791
|
+
const r = Math.max(Math.abs(s.radius + box.min[1] * Math.tan(s.semiAngle)), Math.abs(s.radius + box.max[1] * Math.tan(s.semiAngle)));
|
|
792
|
+
return [n(du, angleStep(r)), 1];
|
|
793
|
+
}
|
|
794
|
+
if (s.kind === 'sphere') return [n(du, angleStep(s.radius)), n(dv, angleStep(s.radius))];
|
|
795
|
+
if (s.kind === 'torus') return [n(du, angleStep(s.major + s.minor)), n(dv, angleStep(s.minor))];
|
|
796
|
+
if (s.kind === 'bspline') return [Math.max(4, (s.U.length - 2 * s.du - 1) * 6), Math.max(4, (s.V.length - 2 * s.dv - 1) * 6)];
|
|
797
|
+
return [1, 1];
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** The part of a polygon inside the cell [u0, u1] × [v0, v1] (Sutherland–Hodgman). */
|
|
801
|
+
function clipToCell(poly, u0, u1, v0, v1) {
|
|
802
|
+
let out = poly;
|
|
803
|
+
const edges = [
|
|
804
|
+
[(q) => q[0] >= u0, 0, u0],
|
|
805
|
+
[(q) => q[0] <= u1, 0, u1],
|
|
806
|
+
[(q) => q[1] >= v0, 1, v0],
|
|
807
|
+
[(q) => q[1] <= v1, 1, v1],
|
|
808
|
+
];
|
|
809
|
+
for (const [inside, k, c] of edges) {
|
|
810
|
+
if (!out.length) break;
|
|
811
|
+
const next = [];
|
|
812
|
+
for (let i = 0; i < out.length; i++) {
|
|
813
|
+
const a = out[i];
|
|
814
|
+
const b = out[(i + 1) % out.length];
|
|
815
|
+
const ia = inside(a);
|
|
816
|
+
const ib = inside(b);
|
|
817
|
+
if (ia) next.push(a);
|
|
818
|
+
if (ia !== ib) {
|
|
819
|
+
const t = (c - a[k]) / (b[k] - a[k]);
|
|
820
|
+
const q = [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
|
|
821
|
+
q[k] = c;
|
|
822
|
+
next.push(q);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
out = next;
|
|
826
|
+
}
|
|
827
|
+
return out;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* The simple regions of a clipped cell. Clipping an outline with bridged
|
|
832
|
+
* holes can leave pieces joined by zero-width corridors and holes joined to
|
|
833
|
+
* their outline; the outline is cut where it touches itself, the loops
|
|
834
|
+
* without area dropped, and each hole bridged again into the loop around it.
|
|
835
|
+
* Returns rings of {uv, p} ready for ear clipping.
|
|
836
|
+
*/
|
|
837
|
+
function cellRegions(cell, su, sv) {
|
|
838
|
+
const key = (q) => `${Math.round(q[0] / su)},${Math.round(q[1] / sv)}`;
|
|
839
|
+
const loops = [];
|
|
840
|
+
const stack = [];
|
|
841
|
+
const at = new Map();
|
|
842
|
+
for (const q of cell) {
|
|
843
|
+
const k = key(q);
|
|
844
|
+
if (at.has(k)) {
|
|
845
|
+
const from = at.get(k);
|
|
846
|
+
const loop = stack.splice(from + 1);
|
|
847
|
+
for (const r of loop) at.delete(key(r));
|
|
848
|
+
loops.push([stack[from], ...loop]);
|
|
849
|
+
continue;
|
|
850
|
+
}
|
|
851
|
+
at.set(k, stack.length);
|
|
852
|
+
stack.push(q);
|
|
853
|
+
}
|
|
854
|
+
if (stack.length) loops.push(stack);
|
|
855
|
+
const tiny = su * sv * 1e3;
|
|
856
|
+
const kept = loops.filter((l) => l.length >= 3 && Math.abs(area2(l)) > tiny);
|
|
857
|
+
const outers = kept.filter((l) => area2(l) > 0);
|
|
858
|
+
const holes = kept.filter((l) => area2(l) < 0);
|
|
859
|
+
const inside = (q, ring) => {
|
|
860
|
+
let c = false;
|
|
861
|
+
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
|
862
|
+
const a = ring[i];
|
|
863
|
+
const b = ring[j];
|
|
864
|
+
if (a[1] > q[1] !== b[1] > q[1] && q[0] < ((b[0] - a[0]) * (q[1] - a[1])) / (b[1] - a[1]) + a[0]) c = !c;
|
|
865
|
+
}
|
|
866
|
+
return c;
|
|
867
|
+
};
|
|
868
|
+
return outers.map((outer) => {
|
|
869
|
+
const mine = holes.filter((hole) => inside(hole[0], outer));
|
|
870
|
+
const ring = (l) => l.map((uv) => ({ uv, p: [uv[0], uv[1], 0] }));
|
|
871
|
+
return mine.length ? bridgeHoles(ring(outer), mine.map(ring)) : ring(outer);
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* Triangles of a face from its exact surface: its loops are sampled and
|
|
877
|
+
* mapped to the surface's parameters, the region they bound is cut along a
|
|
878
|
+
* grid fine enough for the surface's curvature, and each cell is
|
|
879
|
+
* triangulated there and mapped back onto the surface.
|
|
880
|
+
*/
|
|
881
|
+
function tessellateFace(face, chord) {
|
|
882
|
+
const par = parametrisation(face.surface);
|
|
883
|
+
if (!par) return null;
|
|
884
|
+
const rings = [];
|
|
885
|
+
for (const loop of face.loops) {
|
|
886
|
+
if (!loop.edges.length) continue;
|
|
887
|
+
const ring = [];
|
|
888
|
+
let prev = null;
|
|
889
|
+
for (const e of loop.edges) {
|
|
890
|
+
const pts = sampleEdge(e, 16);
|
|
891
|
+
if (!pts) return null;
|
|
892
|
+
for (let i = 0; i < pts.length - 1; i++) {
|
|
893
|
+
const uv = par.uv(pts[i]);
|
|
894
|
+
if (prev) {
|
|
895
|
+
// stay on the side of the seam the loop is on
|
|
896
|
+
for (let k = 0; k < 2; k++) {
|
|
897
|
+
if (!par.periodic[k]) continue;
|
|
898
|
+
while (uv[k] - prev[k] > Math.PI) uv[k] -= TAU;
|
|
899
|
+
while (uv[k] - prev[k] < -Math.PI) uv[k] += TAU;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
ring.push({ uv, p: pts[i] });
|
|
903
|
+
prev = uv;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
if (ring.length >= 3) rings.push(ring);
|
|
907
|
+
}
|
|
908
|
+
if (!rings.length) return null;
|
|
909
|
+
rings.sort((a, b) => Math.abs(area2(b.map((q) => q.uv))) - Math.abs(area2(a.map((q) => q.uv))));
|
|
910
|
+
const outer = rings[0];
|
|
911
|
+
if (area2(outer.map((q) => q.uv)) < 0) outer.reverse();
|
|
912
|
+
const holes = rings.slice(1).map((r) => (area2(r.map((q) => q.uv)) > 0 ? r.slice().reverse() : r));
|
|
913
|
+
// a hole unwrapped a whole turn away from the outline is moved inside it
|
|
914
|
+
for (let k = 0; k < 2; k++) {
|
|
915
|
+
if (!par.periodic[k]) continue;
|
|
916
|
+
const lo = Math.min(...outer.map((q) => q.uv[k]));
|
|
917
|
+
const hi = Math.max(...outer.map((q) => q.uv[k]));
|
|
918
|
+
for (const hole of holes) {
|
|
919
|
+
const mid = hole.reduce((sum, q) => sum + q.uv[k], 0) / hole.length;
|
|
920
|
+
const shift = mid < lo ? Math.ceil((lo - mid) / TAU) * TAU : mid > hi ? -Math.ceil((mid - hi) / TAU) * TAU : 0;
|
|
921
|
+
if (shift) for (const q of hole) q.uv = k === 0 ? [q.uv[0] + shift, q.uv[1]] : [q.uv[0], q.uv[1] + shift];
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
const poly = (holes.length ? bridgeHoles(outer, holes) : outer).map((q) => q.uv);
|
|
925
|
+
const box = { min: [Infinity, Infinity], max: [-Infinity, -Infinity] };
|
|
926
|
+
for (const q of poly) {
|
|
927
|
+
for (let k = 0; k < 2; k++) {
|
|
928
|
+
box.min[k] = Math.min(box.min[k], q[k]);
|
|
929
|
+
box.max[k] = Math.max(box.max[k], q[k]);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
const [nu, nv] = gridSteps(face.surface, box, chord);
|
|
933
|
+
const positions = [];
|
|
934
|
+
const indices = [];
|
|
935
|
+
const ids = new Map();
|
|
936
|
+
const su = (box.max[0] - box.min[0]) * 1e-9 || 1e-12;
|
|
937
|
+
const sv = (box.max[1] - box.min[1]) * 1e-9 || 1e-12;
|
|
938
|
+
const uvs = [];
|
|
939
|
+
const vert = (uv) => {
|
|
940
|
+
const key = `${Math.round(uv[0] / su)},${Math.round(uv[1] / sv)}`;
|
|
941
|
+
if (!ids.has(key)) {
|
|
942
|
+
uvs.push(uv);
|
|
943
|
+
ids.set(key, positions.push(par.xyz(uv)) - 1);
|
|
944
|
+
}
|
|
945
|
+
return ids.get(key);
|
|
946
|
+
};
|
|
947
|
+
for (let i = 0; i < nu; i++) {
|
|
948
|
+
const u0 = box.min[0] + ((box.max[0] - box.min[0]) * i) / nu;
|
|
949
|
+
const u1 = box.min[0] + ((box.max[0] - box.min[0]) * (i + 1)) / nu;
|
|
950
|
+
for (let j = 0; j < nv; j++) {
|
|
951
|
+
const v0 = box.min[1] + ((box.max[1] - box.min[1]) * j) / nv;
|
|
952
|
+
const v1 = box.min[1] + ((box.max[1] - box.min[1]) * (j + 1)) / nv;
|
|
953
|
+
const cell = clipToCell(poly, u0, u1, v0, v1);
|
|
954
|
+
if (cell.length < 3 || Math.abs(area2(cell)) <= su * sv) continue;
|
|
955
|
+
for (const ring of cellRegions(cell, su, sv)) {
|
|
956
|
+
for (const q of ring) q.p = par.xyz(q.uv);
|
|
957
|
+
for (const [a, b, c] of clipEars(ring)) {
|
|
958
|
+
const t = [vert(ring[a].uv), vert(ring[b].uv), vert(ring[c].uv)];
|
|
959
|
+
if (t[0] === t[1] || t[1] === t[2] || t[0] === t[2]) continue;
|
|
960
|
+
// counter-clockwise in (u, v) faces along the surface normal; the face may face the other way
|
|
961
|
+
if (face.sense) indices.push(t[0], t[1], t[2]);
|
|
962
|
+
else indices.push(t[0], t[2], t[1]);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
if (!indices.length) return null;
|
|
968
|
+
return { positions, indices: closeTJunctions(indices, uvs, positions, par, su, sv) };
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* Neighbouring cells can meet with a vertex of one lying on an edge of the
|
|
973
|
+
* other, which shows as pinholes. Triangles without area go, and a triangle
|
|
974
|
+
* with vertices on its edges is fanned from its centre through them.
|
|
975
|
+
*/
|
|
976
|
+
function closeTJunctions(indices, uvs, positions, par, su, sv) {
|
|
977
|
+
// a lookup grid of 64 × 64 cells over the face's parameters
|
|
978
|
+
let lo = [Infinity, Infinity];
|
|
979
|
+
let hi = [-Infinity, -Infinity];
|
|
980
|
+
for (const q of uvs) {
|
|
981
|
+
lo = [Math.min(lo[0], q[0]), Math.min(lo[1], q[1])];
|
|
982
|
+
hi = [Math.max(hi[0], q[0]), Math.max(hi[1], q[1])];
|
|
983
|
+
}
|
|
984
|
+
const cu = (hi[0] - lo[0]) / 64 || 1;
|
|
985
|
+
const cv = (hi[1] - lo[1]) / 64 || 1;
|
|
986
|
+
const grid = new Map();
|
|
987
|
+
uvs.forEach((q, i) => {
|
|
988
|
+
const k = `${Math.floor((q[0] - lo[0]) / cu)},${Math.floor((q[1] - lo[1]) / cv)}`;
|
|
989
|
+
if (!grid.has(k)) grid.set(k, []);
|
|
990
|
+
grid.get(k).push(i);
|
|
991
|
+
});
|
|
992
|
+
const eps = Math.hypot(su, sv) * 1e3;
|
|
993
|
+
const onEdge = (a, b) => {
|
|
994
|
+
const A = uvs[a];
|
|
995
|
+
const B = uvs[b];
|
|
996
|
+
const d = [B[0] - A[0], B[1] - A[1]];
|
|
997
|
+
const L2 = d[0] * d[0] + d[1] * d[1];
|
|
998
|
+
const found = [];
|
|
999
|
+
const [i0, i1] = [Math.floor((Math.min(A[0], B[0]) - lo[0]) / cu), Math.floor((Math.max(A[0], B[0]) - lo[0]) / cu)];
|
|
1000
|
+
const [j0, j1] = [Math.floor((Math.min(A[1], B[1]) - lo[1]) / cv), Math.floor((Math.max(A[1], B[1]) - lo[1]) / cv)];
|
|
1001
|
+
for (let i = i0; i <= i1; i++) {
|
|
1002
|
+
for (let j = j0; j <= j1; j++) {
|
|
1003
|
+
for (const v of grid.get(`${i},${j}`) || []) {
|
|
1004
|
+
if (v === a || v === b) continue;
|
|
1005
|
+
const P = uvs[v];
|
|
1006
|
+
const t = ((P[0] - A[0]) * d[0] + (P[1] - A[1]) * d[1]) / L2;
|
|
1007
|
+
if (t <= 1e-9 || t >= 1 - 1e-9) continue;
|
|
1008
|
+
if (Math.abs((P[0] - A[0]) * d[1] - (P[1] - A[1]) * d[0]) / Math.sqrt(L2) > eps) continue;
|
|
1009
|
+
found.push([t, v]);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
return found.sort((x, y) => x[0] - y[0]).map(([, v]) => v);
|
|
1014
|
+
};
|
|
1015
|
+
const out = [];
|
|
1016
|
+
for (let k = 0; k < indices.length; k += 3) {
|
|
1017
|
+
const t = [indices[k], indices[k + 1], indices[k + 2]];
|
|
1018
|
+
const [A, B, C] = t.map((i) => uvs[i]);
|
|
1019
|
+
const area = (B[0] - A[0]) * (C[1] - A[1]) - (B[1] - A[1]) * (C[0] - A[0]);
|
|
1020
|
+
if (Math.abs(area) <= su * sv) continue;
|
|
1021
|
+
const ring = [];
|
|
1022
|
+
for (let e = 0; e < 3; e++) {
|
|
1023
|
+
for (const v of [t[e], ...onEdge(t[e], t[(e + 1) % 3])]) if (!ring.includes(v)) ring.push(v);
|
|
1024
|
+
}
|
|
1025
|
+
if (ring.length === 3) {
|
|
1026
|
+
out.push(...t);
|
|
1027
|
+
continue;
|
|
1028
|
+
}
|
|
1029
|
+
const mid = [(A[0] + B[0] + C[0]) / 3, (A[1] + B[1] + C[1]) / 3];
|
|
1030
|
+
uvs.push(mid);
|
|
1031
|
+
const c = positions.push(par.xyz(mid)) - 1;
|
|
1032
|
+
for (let i = 0; i < ring.length; i++) out.push(c, ring[i], ring[(i + 1) % ring.length]);
|
|
1033
|
+
}
|
|
1034
|
+
return out;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// --- the document --------------------------------------------------------------------------
|
|
1038
|
+
|
|
1039
|
+
function digitsFor(extent) {
|
|
1040
|
+
return Math.min(12, Math.max(0, Math.ceil(-Math.log10(Math.max(extent, 1e-9) * 1e-7))));
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* Convert a STEP file with a boundary representation to procedural ES3D.
|
|
1045
|
+
* @param {string|Uint8Array} input
|
|
1046
|
+
* @param {{up?: 'z'|'y', name?: string}} [opts]
|
|
1047
|
+
* @returns {{text: string, note: string, stats: {shells: number, faces: number, exact: number, tessellated: number, shapes: object}}|null}
|
|
1048
|
+
* null when the file has no B-rep faces (a tessellated STEP file)
|
|
1049
|
+
*/
|
|
1050
|
+
export function stepToExact(input, { up = 'z', name = '' } = {}) {
|
|
1051
|
+
const doc = parseSTEP(input);
|
|
1052
|
+
const { shells, tol } = readBrep(doc.entities);
|
|
1053
|
+
if (!shells.length || shells.every((s) => !s.faces.length)) return null;
|
|
1054
|
+
let extent = 0;
|
|
1055
|
+
for (const sh of shells) for (const fc of sh.faces) for (const l of fc.loops) for (const e of l.edges) for (const p of [e.start, e.end]) extent = Math.max(extent, len(p));
|
|
1056
|
+
const digits = digitsFor(Math.max(extent * 2, 1e-6));
|
|
1057
|
+
const fmt = (v) => formatNumber(v, digits);
|
|
1058
|
+
|
|
1059
|
+
const colours = colourMap(doc.entities);
|
|
1060
|
+
const owners = ownerIndex(doc.entities);
|
|
1061
|
+
const materials = new Map();
|
|
1062
|
+
const materialFor = (rgba) => {
|
|
1063
|
+
const hex = colorToHex(rgba || [0.78, 0.8, 0.82, 1]);
|
|
1064
|
+
if (!materials.has(hex)) materials.set(hex, `c${hex.slice(1, 7)}`);
|
|
1065
|
+
return materials.get(hex);
|
|
1066
|
+
};
|
|
1067
|
+
|
|
1068
|
+
const stats = { shells: shells.length, faces: 0, exact: 0, tessellated: 0, approximate: 0, skipped: 0, shapes: {} };
|
|
1069
|
+
const count = (k) => (stats.shapes[k] = (stats.shapes[k] || 0) + 1);
|
|
1070
|
+
const groups = [];
|
|
1071
|
+
for (const shell of shells) {
|
|
1072
|
+
const shellMat = materialFor(inheritedColour(colours, owners, [shell.id]));
|
|
1073
|
+
const g = h('group', { material: shellMat });
|
|
1074
|
+
if (shell.name) g.setAttr('name', shell.name);
|
|
1075
|
+
stats.faces += shell.faces.length;
|
|
1076
|
+
const whole = primitiveSolid(shell, fmt, tol);
|
|
1077
|
+
const solid = whole ? { el: whole, kind: whole.name } : shell.closed ? revolvedSolid(shell, fmt, tol) || prismSolid(shell, fmt, tol) : null;
|
|
1078
|
+
if (solid) {
|
|
1079
|
+
g.appendChild(solid.el);
|
|
1080
|
+
count(solid.kind);
|
|
1081
|
+
if (solid.exact === false) stats.approximate += shell.faces.length;
|
|
1082
|
+
else stats.exact += shell.faces.length;
|
|
1083
|
+
groups.push(g);
|
|
1084
|
+
continue;
|
|
1085
|
+
}
|
|
1086
|
+
const mesh = { positions: [], indices: [] };
|
|
1087
|
+
for (const face of shell.faces) {
|
|
1088
|
+
const faceColour = colours.get(face.id);
|
|
1089
|
+
const k = face.surface.kind;
|
|
1090
|
+
let out = null;
|
|
1091
|
+
if (k === 'plane') out = planarFace(face, fmt, tol);
|
|
1092
|
+
else if (REVOLUTION.has(k)) out = revolveFace(face, fmt, tol);
|
|
1093
|
+
if (out) {
|
|
1094
|
+
if (faceColour) out.el.setAttr('material', materialFor(faceColour));
|
|
1095
|
+
g.appendChild(out.el);
|
|
1096
|
+
count(out.el.name);
|
|
1097
|
+
if (out.exact) stats.exact++;
|
|
1098
|
+
else stats.approximate++;
|
|
1099
|
+
continue;
|
|
1100
|
+
}
|
|
1101
|
+
const t = tessellateFace(face, extent * 5e-4);
|
|
1102
|
+
if (!t) {
|
|
1103
|
+
stats.skipped++;
|
|
1104
|
+
continue;
|
|
1105
|
+
}
|
|
1106
|
+
const base = mesh.positions.length;
|
|
1107
|
+
for (const p of t.positions) mesh.positions.push(p);
|
|
1108
|
+
for (const i of t.indices) mesh.indices.push(base + i);
|
|
1109
|
+
stats.tessellated++;
|
|
1110
|
+
}
|
|
1111
|
+
if (mesh.indices.length) {
|
|
1112
|
+
g.appendChild(
|
|
1113
|
+
h('mesh', {
|
|
1114
|
+
positions: mesh.positions.map((p) => p.map(fmt).join(' ')).join(' '),
|
|
1115
|
+
indices: mesh.indices.join(' '),
|
|
1116
|
+
smooth: '30',
|
|
1117
|
+
}),
|
|
1118
|
+
);
|
|
1119
|
+
count('mesh');
|
|
1120
|
+
}
|
|
1121
|
+
groups.push(g);
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// nothing in the file could be read as a surface (unbounded faces): the triangle path says why
|
|
1125
|
+
if (stats.skipped === stats.faces) return null;
|
|
1126
|
+
// CAD kernels name an unnamed part after its kind; the file name says more
|
|
1127
|
+
const product = productName(doc.entities);
|
|
1128
|
+
const title = (product && !/^(compound|solid|shell|body|part|unnamed)\s*\d*$/i.test(product.trim()) ? product : '') || String(name).replace(/\.(step|stp|p21)$/i, '') || product || 'step';
|
|
1129
|
+
const rootAttrs = { version: '1' };
|
|
1130
|
+
if (UNIT[doc.unit]) rootAttrs.unit = UNIT[doc.unit];
|
|
1131
|
+
const root = h('es3d', rootAttrs, h('title', {}, title));
|
|
1132
|
+
for (const [hex, id] of materials) root.appendChild(h('material', { id, color: hex }));
|
|
1133
|
+
const holder = up === 'z' ? root.appendChild(h('group', { name: 'z-up', rotation: '-90,0,0' })) : root;
|
|
1134
|
+
for (const g of groups) holder.appendChild(g);
|
|
1135
|
+
const label = { box: ['box', 'boxes'], extrude: ['extrusion', 'extrusions'], revolve: ['revolve', 'revolves'], path: ['flat face', 'flat faces'], mesh: ['mesh', 'meshes'], torus: ['torus', 'tori'] };
|
|
1136
|
+
const summary = Object.entries(stats.shapes)
|
|
1137
|
+
.map(([k, n]) => `${n} ${(label[k] || [k, `${k}s`])[n === 1 ? 0 : 1]}`)
|
|
1138
|
+
.join(', ');
|
|
1139
|
+
const exactNote = stats.tessellated
|
|
1140
|
+
? `${stats.exact + stats.approximate} of ${stats.faces} faces are ES3D shapes; ${stats.tessellated} face${stats.tessellated === 1 ? ' is a free-form or cross-cut surface ES3D cannot describe, so it is' : 's are free-form or cross-cut surfaces ES3D cannot describe, so they are'} tessellated from the exact surface into a mesh.`
|
|
1141
|
+
: `All ${stats.faces} faces are ES3D shapes${stats.approximate ? ` (${stats.approximate} with edges sampled: rational splines)` : ''}; nothing is a mesh.`;
|
|
1142
|
+
const comment = new Comment(` Converted from STEP: ${summary}. `);
|
|
1143
|
+
const text = serialize(new Document({ root, syntax: 'xml', leading: [comment] }), { pretty: true, comments: true });
|
|
1144
|
+
const skippedNote = stats.skipped ? ` ${stats.skipped} face${stats.skipped === 1 ? ' has' : 's have'} no surface or bounds that can be read and ${stats.skipped === 1 ? 'was' : 'were'} left out.` : '';
|
|
1145
|
+
const unitNote = doc.unit ? ` Coordinates are in ${doc.unit} and were not rescaled.` : ' The file does not name a length unit; coordinates were used as written.';
|
|
1146
|
+
return { text, note: `STEP B-rep: ${summary}. ${exactNote}${skippedNote}${unitNote}${up === 'z' ? ' Turned from Z-up to Y-up by the outer group.' : ''}`, stats };
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
|