partforge 0.6.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -15
- package/bin/cli.js +82 -58
- package/docs/AUTHORING-PARTS.md +98 -26
- package/package.json +1 -1
- package/src/app-faceted-vase.js +10 -0
- package/src/faceted-vase-worker.js +3 -0
- package/src/framework/app.css +26 -0
- package/src/framework/assembly.js +6 -9
- package/src/framework/download.js +23 -0
- package/src/framework/geometry/feature-attribution.js +102 -0
- package/src/framework/geometry/helix-tube.js +10 -20
- package/src/framework/geometry/kernel-front.js +37 -0
- package/src/framework/geometry/kernel.js +56 -10
- package/src/framework/geometry/loft.js +79 -0
- package/src/framework/geometry/manifold-backend.js +67 -25
- package/src/framework/geometry/mesh-build.js +53 -0
- package/src/framework/geometry/occt-backend.js +117 -106
- package/src/framework/geometry/occt-repair.js +83 -0
- package/src/framework/geometry/polygon.js +89 -0
- package/src/framework/geometry/probe.js +37 -30
- package/src/framework/geometry/profile.js +96 -0
- package/src/framework/geometry/solid-sugar.js +32 -5
- package/src/framework/geometry/sweep.js +151 -0
- package/src/framework/geometry-service.js +4 -6
- package/src/framework/jobs.js +40 -18
- package/src/framework/mesh-cache.js +41 -0
- package/src/framework/mount.js +103 -240
- package/src/framework/param-deps.js +9 -18
- package/src/framework/pick-request/server.js +10 -0
- package/src/framework/regen-loop.js +45 -0
- package/src/framework/selection/format.js +2 -6
- package/src/framework/selection/hover.js +128 -0
- package/src/framework/selection/index.js +3 -0
- package/src/framework/selection/pick-toggle.js +34 -0
- package/src/framework/selection/pick.js +7 -30
- package/src/framework/selection/raycast.js +43 -0
- package/src/framework/selection/resolve.js +3 -8
- package/src/framework/status-ui.js +18 -0
- package/src/framework/view-state.js +11 -1
- package/src/framework/view-tabs.js +33 -0
- package/src/framework/viewer-controls.js +48 -0
- package/src/framework/viewer.js +9 -9
- package/src/framework/worker.js +12 -20
- package/src/parts/faceted-vase.js +75 -0
- package/src/parts/filleted-box.js +1 -1
- package/src/parts/planter.js +4 -3
- package/src/testing/build.js +3 -6
- package/src/testing/manifold.js +11 -0
- package/src/testing.js +1 -0
- package/src/framework/geometry/fuzzy-cut.js +0 -32
|
@@ -4,116 +4,71 @@
|
|
|
4
4
|
import { toEdgeFinder } from "./edge-selector.js";
|
|
5
5
|
import { toFaceFinder } from "./face-selector.js";
|
|
6
6
|
import { addSugar } from "./solid-sugar.js";
|
|
7
|
+
import { finishKernel } from "./kernel-front.js";
|
|
8
|
+
import { createOcctRepair } from "./occt-repair.js";
|
|
9
|
+
import { classifyFaceGroups } from "./feature-attribution.js";
|
|
10
|
+
import { resolveRings } from "./loft.js";
|
|
11
|
+
import { resolveSweepStations } from "./sweep.js";
|
|
12
|
+
import { normalizeProfile } from "./profile.js";
|
|
7
13
|
const MESH = { preview: { tolerance: 0.1, angularTolerance: 0.5 }, print: { tolerance: 0.01, angularTolerance: 0.1 } };
|
|
8
14
|
|
|
9
15
|
export function createOcctKernel(replicad) {
|
|
10
16
|
const { makeCylinder, makeBox, makeCircle, makeHelix, assembleWire, genericSweep,
|
|
11
|
-
makeCompound, loft, draw, exportSTEP, measureVolume, makeSphere } = replicad;
|
|
17
|
+
makeCompound, loft, draw, exportSTEP, measureVolume, makeSphere, makeLine, Plane } = replicad;
|
|
12
18
|
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
// two triangles. (A coarse mesh is enough — this is a topology check.)
|
|
17
|
-
const isClosedSolid = (shape) => {
|
|
18
|
-
const m = shape.mesh({ tolerance: 0.3, angularTolerance: 1.0 });
|
|
19
|
-
const P = m.vertices, T = m.triangles;
|
|
20
|
-
const id = new Map();
|
|
21
|
-
const vid = (i) => {
|
|
22
|
-
const key = Math.round(P[i * 3] * 32) + "," + Math.round(P[i * 3 + 1] * 32) + "," + Math.round(P[i * 3 + 2] * 32);
|
|
23
|
-
let d = id.get(key); if (d === undefined) { d = id.size; id.set(key, d); } return d;
|
|
24
|
-
};
|
|
25
|
-
const edges = new Map();
|
|
26
|
-
for (let t = 0; t < T.length / 3; t++) {
|
|
27
|
-
const a = vid(T[t * 3]), b = vid(T[t * 3 + 1]), c = vid(T[t * 3 + 2]);
|
|
28
|
-
for (const [x, y] of [[a, b], [b, c], [c, a]]) { const e = x < y ? x * 1e7 + y : y * 1e7 + x; edges.set(e, (edges.get(e) || 0) + 1); }
|
|
29
|
-
}
|
|
30
|
-
for (const n of edges.values()) if (n !== 2) return false;
|
|
31
|
-
return true;
|
|
32
|
-
};
|
|
19
|
+
// Fillet/chamfer/shell failure recovery (skip-on-failure, chamfer binary search) —
|
|
20
|
+
// see occt-repair.js for the policies and why they differ per op.
|
|
21
|
+
const { validChamfer, safeOp } = createOcctRepair(measureVolume);
|
|
33
22
|
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
// WASM heap doesn't grow across regenerates.
|
|
40
|
-
const validChamfer = (shape, finderFn, distance) => {
|
|
41
|
-
if (!(distance > 0)) return shape.clone();
|
|
42
|
-
const tryAt = (d) => {
|
|
43
|
-
const probe = shape.clone();
|
|
44
|
-
let res;
|
|
45
|
-
try { res = probe.chamfer(d, finderFn); } catch { return null; } // probe consumed by the op
|
|
46
|
-
if (measureVolume(res) > 0 && isClosedSolid(res)) return res;
|
|
47
|
-
res.delete?.();
|
|
48
|
-
return null;
|
|
49
|
-
};
|
|
50
|
-
let best = tryAt(distance);
|
|
51
|
-
if (best) return best; // requested distance is valid
|
|
52
|
-
let lo = 0, hi = distance, bestD = 0;
|
|
53
|
-
for (let i = 0; i < 6; i++) {
|
|
54
|
-
const mid = (lo + hi) / 2;
|
|
55
|
-
const res = tryAt(mid);
|
|
56
|
-
if (res) { best?.delete?.(); best = res; bestD = mid; lo = mid; } else hi = mid;
|
|
57
|
-
}
|
|
58
|
-
if (best) { console.info(`partforge: chamfer ${distance} reduced to ${bestD.toFixed(2)} (largest valid for this geometry)`); return best; }
|
|
59
|
-
return shape.clone(); // nothing valid — skip the chamfer
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
// Native fillet/chamfer can throw or yield an empty solid for out-of-range radii
|
|
63
|
-
// or awkward edge interactions — and OCCT's failures aren't monotonic in the
|
|
64
|
-
// radius (e.g. a radius that equals an adjacent fillet's can fail while larger
|
|
65
|
-
// ones succeed). Rather than letting the whole part vanish, attempt the op on a
|
|
66
|
-
// clone and fall back to the original shape (feature skipped) on a throw or empty
|
|
67
|
-
// result, with a console warning so it's discoverable.
|
|
68
|
-
const safeOp = (shape, op, label) => {
|
|
69
|
-
const backup = shape.clone();
|
|
70
|
-
try {
|
|
71
|
-
const result = op(shape);
|
|
72
|
-
if (measureVolume(result) > 0) { backup.delete?.(); return result; }
|
|
73
|
-
result.delete?.();
|
|
74
|
-
console.warn(`partforge: ${label} produced an empty solid — feature skipped (radius out of range?)`);
|
|
75
|
-
} catch (e) {
|
|
76
|
-
console.warn(`partforge: ${label} failed (${e?.message || e}) — feature skipped`);
|
|
77
|
-
}
|
|
78
|
-
return backup;
|
|
79
|
-
};
|
|
23
|
+
// Feature labels: each entry snapshots the labeled solid's geometry at the moment
|
|
24
|
+
// the label applies; transforms move the snapshots along, booleans merge the two
|
|
25
|
+
// sides' lists. At toMesh() time result faces are classified against the snapshots.
|
|
26
|
+
const cloneLabels = (ls) => ls.map((l) => ({ label: l.label, snapshot: l.snapshot.clone() }));
|
|
27
|
+
const mapLabels = (ls, f) => ls.map((l) => ({ label: l.label, snapshot: f(l.snapshot.clone()) }));
|
|
80
28
|
|
|
81
|
-
const wrap = (shape) => addSugar({
|
|
29
|
+
const wrap = (shape, labels = []) => addSugar({
|
|
82
30
|
_s: shape,
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
31
|
+
_labels: labels,
|
|
32
|
+
label: (name) => wrap(shape, [...labels, { label: name, snapshot: shape.clone() }]),
|
|
33
|
+
cut: (t) => wrap(shape.cut(t._s), [...cloneLabels(labels), ...cloneLabels(t._labels ?? [])]),
|
|
34
|
+
cutAll: (tools) => wrap(
|
|
35
|
+
shape.cut(makeCompound(tools.map((t) => t._s))),
|
|
36
|
+
[...cloneLabels(labels), ...tools.flatMap((t) => cloneLabels(t._labels ?? []))]
|
|
37
|
+
),
|
|
38
|
+
intersect: (t) => wrap(shape.intersect(t._s), [...cloneLabels(labels), ...cloneLabels(t._labels ?? [])]),
|
|
39
|
+
clone: () => wrap(shape.clone(), cloneLabels(labels)),
|
|
86
40
|
boundingBox: () => {
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
return {
|
|
90
|
-
min: [...min], max: [...max], center: [...bb.center],
|
|
91
|
-
size: [max[0] - min[0], max[1] - min[1], max[2] - min[2]],
|
|
92
|
-
};
|
|
93
|
-
},
|
|
94
|
-
translate: (v) => wrap(shape.translate(v)),
|
|
95
|
-
rotate: (deg, center, axis) => wrap(shape.rotate(deg, center, axis)),
|
|
96
|
-
mirror: (plane) => wrap(shape.mirror(plane)),
|
|
97
|
-
scale: (factor, center = [0, 0, 0]) => {
|
|
98
|
-
if (!(factor > 0)) throw new Error("scale: factor must be > 0");
|
|
99
|
-
return wrap(shape.scale(factor, center));
|
|
41
|
+
const [min, max] = shape.boundingBox.bounds; // addSugar derives center/size
|
|
42
|
+
return { min: [...min], max: [...max] };
|
|
100
43
|
},
|
|
44
|
+
translate: (v) => wrap(shape.translate(v), mapLabels(labels, (s) => s.translate(v))),
|
|
45
|
+
rotate: (deg, center, axis) => wrap(shape.rotate(deg, center, axis), mapLabels(labels, (s) => s.rotate(deg, center, axis))),
|
|
46
|
+
mirror: (plane) => wrap(shape.mirror(plane), mapLabels(labels, (s) => s.mirror(plane))),
|
|
47
|
+
scale: (factor, center) => wrap(shape.scale(factor, center), mapLabels(labels, (s) => s.scale(factor, center))), // validated/defaulted by addSugar
|
|
101
48
|
toMesh: ({ quality = "preview" } = {}) => {
|
|
102
49
|
const m = shape.mesh(MESH[quality]);
|
|
103
|
-
|
|
50
|
+
const out = {
|
|
104
51
|
positions: Float32Array.from(m.vertices),
|
|
105
52
|
normals: new Float32Array(0), // let the main thread crease (matches prior look)
|
|
106
53
|
indices: Uint32Array.from(m.triangles),
|
|
107
54
|
triangles: m.triangles.length / 3,
|
|
108
55
|
};
|
|
56
|
+
if (labels.length) {
|
|
57
|
+
const soups = labels.map((l) => {
|
|
58
|
+
const lm = l.snapshot.clone().mesh(MESH.preview); // clone: mesh() must not disturb the kept snapshot
|
|
59
|
+
return { label: l.label, vertices: lm.vertices, triangles: lm.triangles };
|
|
60
|
+
});
|
|
61
|
+
Object.assign(out, classifyFaceGroups(m, soups));
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
109
64
|
},
|
|
110
65
|
toSTL: ({ quality = "print" } = {}) => shape.blobSTL(MESH[quality]).arrayBuffer(),
|
|
111
|
-
fillet: (radius, selector) => wrap(safeOp(shape, (sh) => sh.fillet(radius, toEdgeFinder(selector)), `fillet(${radius})`)),
|
|
112
|
-
chamfer: (distance, selector) => wrap(validChamfer(shape, toEdgeFinder(selector), distance)),
|
|
66
|
+
fillet: (radius, selector) => wrap(safeOp(shape, (sh) => sh.fillet(radius, toEdgeFinder(selector)), `fillet(${radius})`), cloneLabels(labels)),
|
|
67
|
+
chamfer: (distance, selector) => wrap(validChamfer(shape, toEdgeFinder(selector), distance), cloneLabels(labels)),
|
|
113
68
|
shell: (thickness, openFaces) => {
|
|
114
69
|
if (openFaces == null) throw new Error("shell: openFaces is required (a fully closed hollow is not supported)");
|
|
115
70
|
// replicad shells inward with a positive thickness in this version, keeping outer dimensions.
|
|
116
|
-
return wrap(safeOp(shape, (sh) => sh.shell(thickness, toFaceFinder(openFaces)), `shell(${thickness})`));
|
|
71
|
+
return wrap(safeOp(shape, (sh) => sh.shell(thickness, toFaceFinder(openFaces)), `shell(${thickness})`), cloneLabels(labels));
|
|
117
72
|
},
|
|
118
73
|
volume: () => measureVolume(shape),
|
|
119
74
|
toIndexedMesh: () => {
|
|
@@ -131,12 +86,25 @@ export function createOcctKernel(replicad) {
|
|
|
131
86
|
return wrap(loft([w1, w2]));
|
|
132
87
|
};
|
|
133
88
|
|
|
134
|
-
//
|
|
89
|
+
// Draw a closed Drawing from a Contour: a legacy 2-D point list (all straight edges,
|
|
90
|
+
// the former polyDrawing) OR an ArcContour whose { to, via } segments become true
|
|
91
|
+
// OCCT arc edges via threePointsArcTo — so a rounded corner survives to STEP as a
|
|
92
|
+
// real CIRCLE B-rep entity, not a fan of LINEs. close() joins the last point back to
|
|
93
|
+
// the start with a straight edge (mirrors the implied ArcContour closure).
|
|
94
|
+
const contourDrawing = (contour) => {
|
|
95
|
+
if (Array.isArray(contour)) {
|
|
96
|
+
let pen = draw(contour[0]);
|
|
97
|
+
for (let i = 1; i < contour.length; i++) pen = pen.lineTo(contour[i]);
|
|
98
|
+
return pen.close();
|
|
99
|
+
}
|
|
100
|
+
let pen = draw(contour.start);
|
|
101
|
+
for (const seg of contour.segments) pen = seg.via ? pen.threePointsArcTo(seg.to, seg.via) : pen.lineTo(seg.to);
|
|
102
|
+
return pen.close();
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// extrude a 2-D polygon from z=0 (arguments validated by the kernel front)
|
|
135
106
|
const prism = (pts, h, { twist = 0, scaleTop = 1 } = {}) => {
|
|
136
|
-
|
|
137
|
-
let pen = draw(pts[0]);
|
|
138
|
-
for (let i = 1; i < pts.length; i++) pen = pen.lineTo(pts[i]);
|
|
139
|
-
const sketch = pen.close().sketchOnPlane("XY");
|
|
107
|
+
const sketch = contourDrawing(pts).sketchOnPlane("XY");
|
|
140
108
|
if (twist === 0 && scaleTop === 1) return wrap(sketch.extrude(h));
|
|
141
109
|
const cfg = {};
|
|
142
110
|
if (twist !== 0) cfg.twistAngle = twist;
|
|
@@ -145,12 +113,54 @@ export function createOcctKernel(replicad) {
|
|
|
145
113
|
};
|
|
146
114
|
|
|
147
115
|
// revolve a lathe profile [[r,z],…] around the Z axis (degrees defaults to 360)
|
|
148
|
-
const revolve = (pts, { degrees = 360 } = {}) =>
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
116
|
+
const revolve = (pts, { degrees = 360 } = {}) =>
|
|
117
|
+
wrap(contourDrawing(pts).sketchOnPlane("XZ").revolve([0, 0, 1], { angle: degrees }));
|
|
118
|
+
|
|
119
|
+
// extrude a polygon-with-holes region from z=0: cut each hole Drawing out of the outer
|
|
120
|
+
// Drawing (winding-agnostic 2-D boolean), sketch it, then extrude (twist/taper via cfg).
|
|
121
|
+
const extrude = (profile, h, { twist = 0, scaleTop = 1 } = {}) => {
|
|
122
|
+
const { outer, holes } = normalizeProfile(profile);
|
|
123
|
+
let region = contourDrawing(outer);
|
|
124
|
+
for (const hole of holes) region = region.cut(contourDrawing(hole));
|
|
125
|
+
const sketch = region.sketchOnPlane("XY");
|
|
126
|
+
if (twist === 0 && scaleTop === 1) return wrap(sketch.extrude(h));
|
|
127
|
+
const cfg = {};
|
|
128
|
+
if (twist !== 0) cfg.twistAngle = twist;
|
|
129
|
+
if (scaleTop !== 1) cfg.extrusionProfile = { profile: "linear", endFactor: scaleTop };
|
|
130
|
+
return wrap(sketch.extrude(h, cfg));
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// ring loft: each ring becomes a closed polygon wire placed at its z (native loft closes
|
|
134
|
+
// the ends for closed wires). closed:true loops are Manifold-only (replicad loft is open).
|
|
135
|
+
const loftOp = (rings, { ruled = true, closed = false } = {}) => {
|
|
136
|
+
if (closed) throw new Error("loft: closed:true loops are only supported on the Manifold backend");
|
|
137
|
+
const wires = resolveRings(rings).map(({ pts2d, z }) => contourDrawing(pts2d).sketchOnPlane("XY", z).wire);
|
|
138
|
+
return wrap(loft(wires, { ruled }));
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// Sweep a 2-D profile along a 3-D polyline path. DEFAULT (§3A recipe): loft the SAME
|
|
142
|
+
// 3-D stations resolveSweepStations() hands the Manifold backend, as ruled polygon wires
|
|
143
|
+
// — so the two backends produce identical elbow geometry by construction (the loft-parity
|
|
144
|
+
// mechanism, not a tolerance). smooth:true switches to the OCCT-native genericSweep along
|
|
145
|
+
// a spline spine for an exact swept B-rep (STEP-exact / preview-faceted, parity waived —
|
|
146
|
+
// the same contract loft ships for ruled:false). closed:true loops are Manifold-only.
|
|
147
|
+
const sweepSmooth = (profile2D, path3D, cornerRadius) => {
|
|
148
|
+
const edges = [];
|
|
149
|
+
for (let i = 0; i < path3D.length - 1; i++) edges.push(makeLine(path3D[i], path3D[i + 1]));
|
|
150
|
+
const spine = assembleWire(edges);
|
|
151
|
+
const t0 = [path3D[1][0] - path3D[0][0], path3D[1][1] - path3D[0][1], path3D[1][2] - path3D[0][2]];
|
|
152
|
+
const profileWire = contourDrawing(profile2D).sketchOnPlane(new Plane(path3D[0], null, t0)).wire;
|
|
153
|
+
return wrap(genericSweep(profileWire, spine, {
|
|
154
|
+
transitionMode: cornerRadius > 0 ? "round" : "right", // sharp miter analogue vs rounded joint
|
|
155
|
+
forceProfileSpineOthogonality: true,
|
|
156
|
+
}));
|
|
157
|
+
};
|
|
158
|
+
const sweep = (profile2D, path3D, { closed = false, cornerRadius = 0, ruled = true, smooth = false } = {}) => {
|
|
159
|
+
if (closed) throw new Error("sweep: closed:true loops are only supported on the Manifold backend");
|
|
160
|
+
if (smooth) return sweepSmooth(profile2D, path3D, cornerRadius);
|
|
161
|
+
const { stations } = resolveSweepStations(profile2D, path3D, { closed, cornerRadius });
|
|
162
|
+
const wires = stations.map((ring) => assembleWire(ring.map((p, i) => makeLine(p, ring[(i + 1) % ring.length]))));
|
|
163
|
+
return wrap(loft(wires, { ruled }));
|
|
154
164
|
};
|
|
155
165
|
|
|
156
166
|
// circle profile swept along a helix (frenet)
|
|
@@ -162,13 +172,14 @@ export function createOcctKernel(replicad) {
|
|
|
162
172
|
return wrap(genericSweep(profile, spine, { frenet: true }));
|
|
163
173
|
};
|
|
164
174
|
|
|
165
|
-
return {
|
|
166
|
-
cylinder,
|
|
167
|
-
|
|
168
|
-
cylinder(od / 2, od / 2, h).cut(cylinder(bore / 2, bore / 2, h + 4).translate([0, 0, -2])),
|
|
169
|
-
box: (min, max) => wrap(makeBox(min, max)), prism, revolve, helixSweptTube,
|
|
175
|
+
return finishKernel({
|
|
176
|
+
cylinder, // boredCylinder: the kernel front's default composition is exactly right here
|
|
177
|
+
box: (min, max) => wrap(makeBox(min, max)), prism, extrude, revolve, loft: loftOp, sweep, helixSweptTube,
|
|
170
178
|
sphere: (r) => wrap(makeSphere(r)),
|
|
171
|
-
union: (solids) => wrap(
|
|
179
|
+
union: (solids) => wrap(
|
|
180
|
+
solids.map((s) => s._s).reduce((a, b) => a.fuse(b)),
|
|
181
|
+
solids.flatMap((s) => cloneLabels(s._labels ?? []))
|
|
182
|
+
),
|
|
172
183
|
toSTEP: (named) => exportSTEP(named.map(({ name, solid }) => ({ name, shape: solid._s }))).arrayBuffer(),
|
|
173
|
-
};
|
|
184
|
+
});
|
|
174
185
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Failure recovery for native OCCT features (fillet/chamfer/shell), extracted from
|
|
2
|
+
// occt-backend.js so the backend reads as a clean contract mapping and the pure
|
|
3
|
+
// mesh-topology logic is unit-testable without booting OCCT.
|
|
4
|
+
//
|
|
5
|
+
// Two deliberately different rescue policies:
|
|
6
|
+
// - chamfer → validChamfer: try the requested distance, and if it breaks the
|
|
7
|
+
// solid, binary-search the largest distance that doesn't. An over-large chamfer
|
|
8
|
+
// fails by over-running its faces, which is (near enough) monotonic in the
|
|
9
|
+
// distance — so bisection is sound.
|
|
10
|
+
// - fillet (and shell) → safeOp: attempt once, skip the feature on failure.
|
|
11
|
+
// OCCT fillet failures are NOT monotonic in the radius (a radius equal to an
|
|
12
|
+
// adjacent fillet's can fail while larger ones succeed), so a binary search
|
|
13
|
+
// would converge on garbage; skipping keeps the part alive and warns.
|
|
14
|
+
|
|
15
|
+
// Is a shape a closed solid? A broken chamfer (one that over-ran and consumed a face)
|
|
16
|
+
// meshes to an OPEN surface; a valid one is closed. OCCT meshes each face separately,
|
|
17
|
+
// so weld vertices by position, then a closed solid has every edge shared by exactly
|
|
18
|
+
// two triangles. (A coarse mesh is enough — this is a topology check.)
|
|
19
|
+
export const isClosedSolid = (shape) => {
|
|
20
|
+
const m = shape.mesh({ tolerance: 0.3, angularTolerance: 1.0 });
|
|
21
|
+
const P = m.vertices, T = m.triangles;
|
|
22
|
+
const id = new Map();
|
|
23
|
+
const vid = (i) => {
|
|
24
|
+
const key = Math.round(P[i * 3] * 32) + "," + Math.round(P[i * 3 + 1] * 32) + "," + Math.round(P[i * 3 + 2] * 32);
|
|
25
|
+
let d = id.get(key); if (d === undefined) { d = id.size; id.set(key, d); } return d;
|
|
26
|
+
};
|
|
27
|
+
const edges = new Map();
|
|
28
|
+
for (let t = 0; t < T.length / 3; t++) {
|
|
29
|
+
const a = vid(T[t * 3]), b = vid(T[t * 3 + 1]), c = vid(T[t * 3 + 2]);
|
|
30
|
+
for (const [x, y] of [[a, b], [b, c], [c, a]]) { const e = x < y ? x * 1e7 + y : y * 1e7 + x; edges.set(e, (edges.get(e) || 0) + 1); }
|
|
31
|
+
}
|
|
32
|
+
for (const n of edges.values()) if (n !== 2) return false;
|
|
33
|
+
return true;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function createOcctRepair(measureVolume) {
|
|
37
|
+
// The true maximum chamfer for an edge depends on local angles and adjacent features,
|
|
38
|
+
// which is hard to predict analytically (and OCCT exposes no max-radius query). So
|
|
39
|
+
// VALIDATE the result instead of guessing: try the requested distance, and if it makes
|
|
40
|
+
// a closed solid, use it (valid large chamfers — e.g. on a pill — go through). If not,
|
|
41
|
+
// binary-search the largest distance that does. Discarded attempts are freed so OCCT's
|
|
42
|
+
// WASM heap doesn't grow across regenerates.
|
|
43
|
+
const validChamfer = (shape, finderFn, distance) => {
|
|
44
|
+
if (!(distance > 0)) return shape.clone();
|
|
45
|
+
const tryAt = (d) => {
|
|
46
|
+
const probe = shape.clone();
|
|
47
|
+
let res;
|
|
48
|
+
try { res = probe.chamfer(d, finderFn); } catch { return null; } // probe consumed by the op
|
|
49
|
+
if (measureVolume(res) > 0 && isClosedSolid(res)) return res;
|
|
50
|
+
res.delete?.();
|
|
51
|
+
return null;
|
|
52
|
+
};
|
|
53
|
+
let best = tryAt(distance);
|
|
54
|
+
if (best) return best; // requested distance is valid
|
|
55
|
+
let lo = 0, hi = distance, bestD = 0;
|
|
56
|
+
for (let i = 0; i < 6; i++) {
|
|
57
|
+
const mid = (lo + hi) / 2;
|
|
58
|
+
const res = tryAt(mid);
|
|
59
|
+
if (res) { best?.delete?.(); best = res; bestD = mid; lo = mid; } else hi = mid;
|
|
60
|
+
}
|
|
61
|
+
if (best) { console.info(`partforge: chamfer ${distance} reduced to ${bestD.toFixed(2)} (largest valid for this geometry)`); return best; }
|
|
62
|
+
return shape.clone(); // nothing valid — skip the chamfer
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// Native fillet/shell can throw or yield an empty solid for out-of-range radii
|
|
66
|
+
// or awkward edge interactions. Rather than letting the whole part vanish, attempt
|
|
67
|
+
// the op on a clone and fall back to the original shape (feature skipped) on a
|
|
68
|
+
// throw or empty result, with a console warning so it's discoverable.
|
|
69
|
+
const safeOp = (shape, op, label) => {
|
|
70
|
+
const backup = shape.clone();
|
|
71
|
+
try {
|
|
72
|
+
const result = op(shape);
|
|
73
|
+
if (measureVolume(result) > 0) { backup.delete?.(); return result; }
|
|
74
|
+
result.delete?.();
|
|
75
|
+
console.warn(`partforge: ${label} produced an empty solid — feature skipped (radius out of range?)`);
|
|
76
|
+
} catch (e) {
|
|
77
|
+
console.warn(`partforge: ${label} failed (${e?.message || e}) — feature skipped`);
|
|
78
|
+
}
|
|
79
|
+
return backup;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
return { validChamfer, safeOp };
|
|
83
|
+
}
|
|
@@ -97,6 +97,95 @@ export function ringSectorPolygon(innerR, outerR, arcDeg, segs = 32) {
|
|
|
97
97
|
return pts;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
// Per-corner rounding geometry, shared verbatim by filletPolygon (which tessellates it)
|
|
101
|
+
// and roundedProfile (which emits it as a symbolic arc). Given a corner p0→p1→p2 and a
|
|
102
|
+
// requested radius r, returns the incoming/outgoing tangent points `a`/`b`, the arc centre
|
|
103
|
+
// `c`, the clamped radius `rr`, the short sweep `dA`, and the incoming start angle `a0` —
|
|
104
|
+
// or null for a corner that must stay sharp (zero-length edge or a straight/180° corner).
|
|
105
|
+
// The per-corner clamp (t ≤ min(l0,l2)/2) keeps neighbouring arcs from overlapping.
|
|
106
|
+
// Extracting this means the two consumers can never diverge on clamping/winding.
|
|
107
|
+
export function cornerArc(p0, p1, p2, r) {
|
|
108
|
+
let v0 = [p0[0] - p1[0], p0[1] - p1[1]], v2 = [p2[0] - p1[0], p2[1] - p1[1]];
|
|
109
|
+
const l0 = Math.hypot(v0[0], v0[1]), l2 = Math.hypot(v2[0], v2[1]);
|
|
110
|
+
if (l0 < 1e-9 || l2 < 1e-9) return null; // zero-length edge → sharp
|
|
111
|
+
v0 = [v0[0] / l0, v0[1] / l0]; v2 = [v2[0] / l2, v2[1] / l2];
|
|
112
|
+
const cosA = Math.max(-1, Math.min(1, v0[0] * v2[0] + v0[1] * v2[1]));
|
|
113
|
+
const half = Math.acos(cosA) / 2; // half the corner's interior angle
|
|
114
|
+
let bis = [v0[0] + v2[0], v0[1] + v2[1]];
|
|
115
|
+
const bl = Math.hypot(bis[0], bis[1]);
|
|
116
|
+
if (half < 1e-6 || bl < 1e-9) return null; // straight (180°) corner → sharp
|
|
117
|
+
bis = [bis[0] / bl, bis[1] / bl];
|
|
118
|
+
let rr = r, t = r / Math.tan(half); // tangent setback along each edge
|
|
119
|
+
const tmax = Math.min(l0, l2) / 2; // clamp: never past an edge midpoint
|
|
120
|
+
if (t > tmax) { t = tmax; rr = t * Math.tan(half); }
|
|
121
|
+
const a = [p1[0] + v0[0] * t, p1[1] + v0[1] * t]; // tangent point on the incoming edge
|
|
122
|
+
const b = [p1[0] + v2[0] * t, p1[1] + v2[1] * t]; // tangent point on the outgoing edge
|
|
123
|
+
const c = [p1[0] + bis[0] * (rr / Math.sin(half)), p1[1] + bis[1] * (rr / Math.sin(half))]; // arc center
|
|
124
|
+
const a0 = Math.atan2(a[1] - c[1], a[0] - c[0]);
|
|
125
|
+
let dA = Math.atan2(b[1] - c[1], b[0] - c[0]) - a0; // sweep the SHORT arc from a to b
|
|
126
|
+
while (dA <= -Math.PI) dA += 2 * Math.PI;
|
|
127
|
+
while (dA > Math.PI) dA -= 2 * Math.PI;
|
|
128
|
+
return { a, b, c, rr, dA, a0 };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Round every corner of a CCW polygon: each vertex is replaced by a tangent circular
|
|
132
|
+
// arc of radius r, tessellated with `segs` segments per corner (default 8, matching
|
|
133
|
+
// roundedRectPolygon). Returns a plain [[x,y],…] point list usable by prism/extrude/loft
|
|
134
|
+
// on BOTH kernels by construction. Corners are CLAMPED per-corner: r is reduced so an
|
|
135
|
+
// arc's tangent points never pass the midpoint of either adjacent edge, so neighbouring
|
|
136
|
+
// rounded corners can never overlap (pass a very large r to fully round every corner).
|
|
137
|
+
// Intended for convex CCW outlines (brackets, gussets, pads, knob/star profiles); a
|
|
138
|
+
// reflex corner is still rounded but its arc is placed on the angle bisector.
|
|
139
|
+
// NOTE: bakes each arc into `segs` straight facets, so STEP export of a filletPolygon
|
|
140
|
+
// part has faceted (LINE) corners; for mathematically-true CIRCLE corners in STEP use
|
|
141
|
+
// roundedProfile, which carries the arc symbolically to both backends.
|
|
142
|
+
export function filletPolygon(points, r, { segs = 8 } = {}) {
|
|
143
|
+
const n = points.length;
|
|
144
|
+
if (n < 3) throw new Error("filletPolygon: need at least 3 points");
|
|
145
|
+
if (!(r > 0)) throw new Error("filletPolygon: r must be > 0");
|
|
146
|
+
const out = [];
|
|
147
|
+
for (let i = 0; i < n; i++) {
|
|
148
|
+
const arc = cornerArc(points[(i - 1 + n) % n], points[i], points[(i + 1) % n], r);
|
|
149
|
+
if (!arc) { out.push([points[i][0], points[i][1]]); continue; } // sharp corner
|
|
150
|
+
const { c, rr, dA, a0 } = arc;
|
|
151
|
+
for (let s = 0; s <= segs; s++) {
|
|
152
|
+
const ang = a0 + dA * (s / segs);
|
|
153
|
+
out.push([c[0] + rr * Math.cos(ang), c[1] + rr * Math.sin(ang)]);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Arc-aware sibling of filletPolygon: rounds the corners of a CCW polygon with the SAME
|
|
160
|
+
// tangent/centre/sweep math (via cornerArc), but instead of tessellating each arc into
|
|
161
|
+
// line facets it emits a canonical ArcContour { start, segments:[{to}|{to,via}], arc:true }
|
|
162
|
+
// that carries the arc SYMBOLICALLY. Feed it to prism/extrude (not loft yet): OCCT builds a
|
|
163
|
+
// true CIRCLE B-rep edge (exact STEP fillets) while Manifold tessellates the same spec, so
|
|
164
|
+
// both kernels agree by construction. `r` is a scalar (every corner) or a per-corner array
|
|
165
|
+
// r[] (length === points.length; a 0 or a degenerate corner stays sharp — a plain line).
|
|
166
|
+
export function roundedProfile(points, r) {
|
|
167
|
+
const n = points.length;
|
|
168
|
+
if (n < 3) throw new Error("roundedProfile: need at least 3 points");
|
|
169
|
+
const radii = Array.isArray(r) ? r : null;
|
|
170
|
+
if (radii && radii.length !== n)
|
|
171
|
+
throw new Error("roundedProfile: r[] length must match points length");
|
|
172
|
+
if (!radii && !(r >= 0)) throw new Error("roundedProfile: r must be ≥ 0 (or a per-corner r[]); 0 keeps every corner sharp");
|
|
173
|
+
const segments = [];
|
|
174
|
+
let start = null;
|
|
175
|
+
const lineTo = (p) => { if (start === null) start = [p[0], p[1]]; else segments.push({ to: [p[0], p[1]] }); };
|
|
176
|
+
for (let i = 0; i < n; i++) {
|
|
177
|
+
const p1 = points[i];
|
|
178
|
+
const ri = radii ? radii[i] : r;
|
|
179
|
+
const arc = ri > 0 ? cornerArc(points[(i - 1 + n) % n], p1, points[(i + 1) % n], ri) : null;
|
|
180
|
+
if (!arc) { lineTo(p1); continue; } // sharp / degenerate corner → plain vertex
|
|
181
|
+
const { a, b, c, rr, dA, a0 } = arc;
|
|
182
|
+
lineTo(a); // straight run into the incoming tangent point
|
|
183
|
+
const mid = a0 + dA / 2; // arc midpoint (three-point via — sign/winding-free)
|
|
184
|
+
segments.push({ to: [b[0], b[1]], via: [c[0] + rr * Math.cos(mid), c[1] + rr * Math.sin(mid)] });
|
|
185
|
+
}
|
|
186
|
+
return { start, segments, arc: true };
|
|
187
|
+
}
|
|
188
|
+
|
|
100
189
|
const PATTERN_AXIS = { X: [1, 0, 0], Y: [0, 1, 0], Z: [0, 0, 1] };
|
|
101
190
|
|
|
102
191
|
// `count` copies of `solid` translated by i*step ([dx,dy,dz]) for i in 0..count-1.
|
|
@@ -1,41 +1,48 @@
|
|
|
1
1
|
// Geometry-free backend detection. A probe kernel records every op a part's
|
|
2
2
|
// build() invokes (returning chainable no-op proxies, dummy values for queries);
|
|
3
|
-
// if an OCCT-only op was used, the part needs the OCCT backend.
|
|
4
|
-
|
|
3
|
+
// if an OCCT-only op was used, the part needs the OCCT backend. The op list lives
|
|
4
|
+
// in kernel.js — the same list generates the Manifold backend's throwing stubs.
|
|
5
|
+
import { OCCT_ONLY_OPS } from "./kernel.js";
|
|
6
|
+
|
|
7
|
+
const OCCT_ONLY = new Set(OCCT_ONLY_OPS);
|
|
5
8
|
|
|
6
9
|
export function createProbeKernel() {
|
|
7
10
|
const used = new Set();
|
|
8
11
|
const note = (name) => used.add(name);
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
volume() { note("volume"); return 1; },
|
|
23
|
-
toMesh() { note("toMesh"); return { positions: new Float32Array(9), normals: new Float32Array(9), triangles: 1, edges: new Float32Array(0) }; },
|
|
24
|
-
toSTL() { note("toSTL"); return new ArrayBuffer(0); },
|
|
25
|
-
toIndexedMesh() { note("toIndexedMesh"); return { positions: new Float32Array(9), indices: new Uint32Array(3) }; },
|
|
12
|
+
|
|
13
|
+
// Catch-all proxies: any method records its name and returns the chainable solid
|
|
14
|
+
// proxy, EXCEPT the queries below, which return realistic dummy values the build may
|
|
15
|
+
// read. Using a Proxy (rather than a hand-listed allowlist) means new kernel/solid
|
|
16
|
+
// methods never have to be mirrored here — the probe can't drift out of sync with the
|
|
17
|
+
// real backends. (That drift previously broke the panel's relevance dimming/hiding when
|
|
18
|
+
// the build-step vocabulary was added but not taught to the probe.)
|
|
19
|
+
const solidQueries = {
|
|
20
|
+
boundingBox: () => ({ min: [0, 0, 0], max: [1, 1, 1], center: [0.5, 0.5, 0.5], size: [1, 1, 1] }),
|
|
21
|
+
volume: () => 1,
|
|
22
|
+
toMesh: () => ({ positions: new Float32Array(9), normals: new Float32Array(9), triangles: 1, edges: new Float32Array(0) }),
|
|
23
|
+
toSTL: () => new ArrayBuffer(0),
|
|
24
|
+
toIndexedMesh: () => ({ positions: new Float32Array(9), indices: new Uint32Array(3) }),
|
|
26
25
|
};
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
sphere() { note("sphere"); return proxy; },
|
|
31
|
-
box() { note("box"); return proxy; },
|
|
32
|
-
prism() { note("prism"); return proxy; },
|
|
33
|
-
revolve() { note("revolve"); return proxy; },
|
|
34
|
-
helixSweptTube() { note("helixSweptTube"); return proxy; },
|
|
35
|
-
union() { note("union"); return proxy; },
|
|
36
|
-
toSTEP() { note("toSTEP"); return Promise.resolve(new ArrayBuffer(0)); },
|
|
37
|
-
cleanup() {},
|
|
26
|
+
const kernelQueries = {
|
|
27
|
+
toSTEP: () => Promise.resolve(new ArrayBuffer(0)),
|
|
28
|
+
cleanup: () => {},
|
|
38
29
|
};
|
|
30
|
+
|
|
31
|
+
// `ignore` keeps the proxy from masquerading as a thenable/internal handle: symbols,
|
|
32
|
+
// `then` (so it's never await-unwrapped), and `_`-prefixed internals resolve to
|
|
33
|
+
// undefined rather than a chainable op.
|
|
34
|
+
const ignore = (key) => typeof key !== "string" || key === "then" || key[0] === "_";
|
|
35
|
+
|
|
36
|
+
const opProxy = (queries) => new Proxy({}, {
|
|
37
|
+
get(_t, key) {
|
|
38
|
+
if (ignore(key)) return undefined;
|
|
39
|
+
if (key in queries) return queries[key];
|
|
40
|
+
return (..._args) => { note(key); return proxy; };
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const proxy = opProxy(solidQueries); // a solid handle: every op chains back to itself
|
|
45
|
+
const kernel = opProxy(kernelQueries); // factory ops (cylinder/box/prism/…) return a solid
|
|
39
46
|
return { kernel, used };
|
|
40
47
|
}
|
|
41
48
|
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Backend-shared 2-D region normalization + tessellation for extrude()/prism(). A contour
|
|
2
|
+
// is EITHER a bare points array (legacy, all straight edges) OR a canonical ArcContour
|
|
3
|
+
// { start:[x,y], segments:[{to}|{to,via}], arc:true } carrying true circular arcs (from
|
|
4
|
+
// roundedProfile). normalizeProfile validates the polymorphic { outer, holes } envelope
|
|
5
|
+
// (bare array = outer only), preserving each contour's shape; tessellateProfile turns the
|
|
6
|
+
// arcs into point rings for the Manifold (mesh) path. The OCCT path consumes the same
|
|
7
|
+
// ArcContour directly (contourDrawing → threePointsArcTo) for true CIRCLE B-rep edges.
|
|
8
|
+
// Legacy point-array contours take the exact former path byte-for-byte — no cache-busting.
|
|
9
|
+
|
|
10
|
+
// An ArcContour is a non-array object carrying arcs symbolically.
|
|
11
|
+
export function isArcContour(c) {
|
|
12
|
+
return !!c && typeof c === "object" && !Array.isArray(c) && (c.arc === true || Array.isArray(c.segments));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function validateContour(c, role) {
|
|
16
|
+
if (isArcContour(c)) {
|
|
17
|
+
if (!Array.isArray(c.start) || c.start.length < 2)
|
|
18
|
+
throw new Error(`extrude: ${role} arc contour needs a start [x,y]`);
|
|
19
|
+
if (!Array.isArray(c.segments) || c.segments.length < 1)
|
|
20
|
+
throw new Error(`extrude: ${role} arc contour needs ≥1 segment`);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (!Array.isArray(c) || c.length < 3) throw new Error(`extrude: ${role} needs ≥3 points`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function normalizeProfile(profile) {
|
|
27
|
+
let outer, holes;
|
|
28
|
+
if (Array.isArray(profile) || isArcContour(profile)) { outer = profile; holes = []; }
|
|
29
|
+
else if (profile && typeof profile === "object") { outer = profile.outer; holes = profile.holes ?? []; }
|
|
30
|
+
else throw new Error("extrude: profile must be [[x,y],…], an arc contour, or { outer, holes? }");
|
|
31
|
+
// Preserve the historical, test-pinned wording for the legacy point-array path.
|
|
32
|
+
if (isArcContour(outer)) validateContour(outer, "outer contour");
|
|
33
|
+
else if (!Array.isArray(outer) || outer.length < 3) throw new Error("extrude: outer contour needs ≥3 points");
|
|
34
|
+
if (!Array.isArray(holes)) throw new Error("extrude: holes must be an array of contours");
|
|
35
|
+
for (const hole of holes) {
|
|
36
|
+
if (isArcContour(hole)) validateContour(hole, "hole arc contour");
|
|
37
|
+
else if (!Array.isArray(hole) || hole.length < 3) throw new Error("extrude: each hole needs ≥3 points");
|
|
38
|
+
}
|
|
39
|
+
return { outer, holes };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Sample the circular arc through (p0, via, p1) — the three-point form roundedProfile
|
|
43
|
+
// emits — into a point list p1…pN (EXCLUDING the start p0, which the ring already holds;
|
|
44
|
+
// the last point is exactly p1). The circle is recovered from the circumcircle of the
|
|
45
|
+
// three points; the sweep direction is the one whose arc actually passes through `via`
|
|
46
|
+
// (sign-free, winding-free). Facet count scales with the sweep's fraction of the kernel's
|
|
47
|
+
// full-circle resolution `segs`, matching the piePolygon/circleProfile convention, so an
|
|
48
|
+
// arc and a circleProfile of equal radius facet identically. A degenerate (collinear)
|
|
49
|
+
// triple falls back to a single straight segment to p1 — the same "plain line" the OCCT
|
|
50
|
+
// side gets when roundedProfile emits no `via`.
|
|
51
|
+
export function sampleArc(p0, via, p1, segs) {
|
|
52
|
+
const [ax, ay] = p0, [bx, by] = via, [cx, cy] = p1;
|
|
53
|
+
const d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
|
|
54
|
+
if (Math.abs(d) < 1e-12) return [[cx, cy]]; // collinear → straight line
|
|
55
|
+
const sa = ax * ax + ay * ay, sb = bx * bx + by * by, sc = cx * cx + cy * cy;
|
|
56
|
+
const ux = (sa * (by - cy) + sb * (cy - ay) + sc * (ay - by)) / d;
|
|
57
|
+
const uy = (sa * (cx - bx) + sb * (ax - cx) + sc * (bx - ax)) / d;
|
|
58
|
+
const rr = Math.hypot(ax - ux, ay - uy);
|
|
59
|
+
const a0 = Math.atan2(ay - uy, ax - ux);
|
|
60
|
+
const av = Math.atan2(by - uy, bx - ux);
|
|
61
|
+
const a1 = Math.atan2(cy - uy, cx - ux);
|
|
62
|
+
const twoPi = 2 * Math.PI;
|
|
63
|
+
const ccw = (x) => { let v = x % twoPi; if (v < 0) v += twoPi; return v; };
|
|
64
|
+
const dCCW = ccw(a1 - a0), vCCW = ccw(av - a0);
|
|
65
|
+
const dA = vCCW <= dCCW ? dCCW : dCCW - twoPi; // pick the sweep containing `via`
|
|
66
|
+
const steps = Math.max(2, Math.ceil((segs * Math.abs(dA)) / twoPi));
|
|
67
|
+
const out = [];
|
|
68
|
+
for (let s = 1; s <= steps; s++) {
|
|
69
|
+
const ang = a0 + dA * (s / steps);
|
|
70
|
+
out.push([ux + rr * Math.cos(ang), uy + rr * Math.sin(ang)]);
|
|
71
|
+
}
|
|
72
|
+
out[out.length - 1] = [cx, cy]; // pin the exact endpoint
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Tessellate a single contour into a CCW point ring. A legacy array is returned unchanged
|
|
77
|
+
// (identical to the former path); an ArcContour is walked start→segment→segment, lines
|
|
78
|
+
// pushing their `to` and arcs pushing their sampled points.
|
|
79
|
+
export function tessellateContour(contour, segs) {
|
|
80
|
+
if (Array.isArray(contour)) return contour;
|
|
81
|
+
const ring = [[contour.start[0], contour.start[1]]];
|
|
82
|
+
let prev = contour.start;
|
|
83
|
+
for (const seg of contour.segments) {
|
|
84
|
+
if (seg.via) for (const p of sampleArc(prev, seg.via, seg.to, segs)) ring.push(p);
|
|
85
|
+
else ring.push([seg.to[0], seg.to[1]]);
|
|
86
|
+
prev = seg.to;
|
|
87
|
+
}
|
|
88
|
+
return ring;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Normalize + tessellate a whole region to { outer:[[x,y],…], holes:[[[x,y],…],…] } of
|
|
92
|
+
// point rings, ready for CrossSection.ofPolygons on the Manifold path.
|
|
93
|
+
export function tessellateProfile(profile, segs) {
|
|
94
|
+
const { outer, holes } = normalizeProfile(profile);
|
|
95
|
+
return { outer: tessellateContour(outer, segs), holes: holes.map((hl) => tessellateContour(hl, segs)) };
|
|
96
|
+
}
|