partforge 0.66.0 → 0.67.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.
@@ -2195,8 +2195,10 @@ STEP export always builds on OCCT, so a filleted part gets exact B-rep blends in
2195
2195
  STEP even though it previews (and STL/3MF-exports) from the mesh blend — the two agree
2196
2196
  to within tessellation on the supported edge classes, with one visible exception:
2197
2197
  orthogonal box-style corners where three filleted edges meet get a true sphere-octant
2198
- cap, but other blend junctions are mitred on the mesh where OCCT builds a vertex
2199
- blend.
2198
+ cap, and a planar rim's own corners blend by turn direction (salient corners steer
2199
+ the band around a small arc; reflex/inside corners round into an arc of the blend
2200
+ radius about the vertex — the rolling-ball pivot), but other blend junctions are
2201
+ mitred on the mesh where OCCT builds a vertex blend.
2200
2202
 
2201
2203
  If the mesh backend hits an edge class it can't blend, it signals `NEEDS_OCCT` and the
2202
2204
  framework reroutes **just that sub-part** to OCCT for those exact parameters —
@@ -2241,15 +2243,19 @@ melt away, holes narrower than `2r` seal shut. That makes it ideal for
2241
2243
  "soften this whole organic part" and wrong for parts where a specific edge
2242
2244
  must stay sharp (use `fillet` with a selector for that).
2243
2245
 
2244
- **Cost warning: roundAll is morphological (three Minkowski passes) and its
2245
- runtime grows steeply with the solid's triangle count.** On a simple bracket it
2246
- is interactive; on a complex outline it is not measured 30+ seconds on a
2247
- text-outline extrusion whose `fillet({ r, edges: { inPlane: "XY", at: h } })`
2248
- takes well under a second. **Never use roundAll just to round the top or bottom
2249
- rim of an extrusion**that is exactly what the `inPlane` fillet selector is
2250
- for, at a tiny fraction of the cost. Reach for roundAll only when the design
2251
- genuinely needs every edge softened at once and the solid is geometrically
2252
- simple.
2246
+ **Cost note.** On a Z-aligned extrusion (constant cross-section a plate, a
2247
+ text backing, any straight-sided prism) roundAll takes a fast path: the ball
2248
+ morphology is computed as a 2-D close-open of the cross-section plus rim
2249
+ fillets, so even a complex text-outline backing rounds in well under a second.
2250
+ Everything else pays the full morphology (three Minkowski passes), whose
2251
+ runtime grows steeply with triangle count a rotated or lofted solid of a few
2252
+ thousand triangles can take tens of seconds. When only a specific edge needs
2253
+ rounding, `fillet` with a selector (e.g. `{ inPlane: "XY", at: h }` for a rim)
2254
+ says what you mean and is always the cheap, predictable choice; reach for
2255
+ roundAll when the design genuinely calls for every edge softened at once. At
2256
+ the fast path's corners the plan silhouette follows the rim fillet's corner
2257
+ rounding (radius ≈ 1.05–1.25·r rather than exactly r) — the same corner
2258
+ treatment fillet itself applies.
2253
2259
 
2254
2260
  Rules of thumb:
2255
2261
 
@@ -60,10 +60,15 @@ Its coverage and tolerance band are part of the contract:
60
60
  guards, all ≤ ~1e-3 mm). Volumes agree with the B-rep result to ~0.1% on covered
61
61
  edge classes. **Corners:** where exactly three selected straight convex chains meet
62
62
  at a mutually orthogonal vertex (a box corner), the fillet caps it with the
63
- rolling-ball sphere octant; every other junction of blended chains is a **mitre**
64
- (the blend surfaces intersect), where the B-rep class builds a kernel-specific
65
- vertex blend instead parity at such corners is approximate, like `roundedBox`'s
66
- documented corner carve-out.
63
+ rolling-ball sphere octant. A two-chain corner in a common face plane (a rim
64
+ corner) blends by turn direction: SALIENT corners steer the band around a small
65
+ arc (the silhouette rounds by about the blend radius inside the band), and REFLEX
66
+ corners get the rolling-ball pivot — the face's blend boundary rounds into an arc
67
+ of the blend radius about the vertex, exactly what a ball rolling into an inside
68
+ corner leaves. Every other junction of blended chains is a **mitre** (the blend
69
+ surfaces intersect), where the B-rep class builds a kernel-specific vertex blend
70
+ instead — parity at such corners is approximate, like `roundedBox`'s documented
71
+ corner carve-out.
67
72
  - **New surfaces:** like the B-rep op, a mesh blend produces new surfaces — feature
68
73
  labels upstream of the call do not survive through it (attribution uses the
69
74
  fallback path), and the result shades with the default SMOOTH policy.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.66.0",
3
+ "version": "0.67.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -15,7 +15,7 @@ import { meshToStl } from "./mesh-stl.js";
15
15
  import { creasedNormals } from "./creased-normals.js";
16
16
  import { loftShadingPolicy, SMOOTH, BLEND } from "./shading-policy.js";
17
17
  import { meshFillet, meshChamfer, UnsupportedEdgeError } from "./mesh-fillet.js";
18
- import { meshRoundAll } from "./mesh-roundall.js";
18
+ import { meshRoundAll, prismSection, roundAllSegs } from "./mesh-roundall.js";
19
19
  import { KernelCapabilityError } from "./errors.js";
20
20
 
21
21
  const PLANE_NORMAL = { XY: [0, 0, 1], XZ: [0, 1, 0], YZ: [1, 0, 0] };
@@ -168,6 +168,66 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
168
168
  }
169
169
  };
170
170
 
171
+ // roundAll prism fast path. Ball close-then-open via native Minkowski is
172
+ // seconds-per-thousand-triangles (a text-outline backing measured 30+ s), but on
173
+ // a Z-prism the SAME morphology decomposes: the 2-D close-open of the
174
+ // cross-section (Clipper2 offsets +r, -2r, +r) supplies the wall melting and
175
+ // hole sealing, and a selector-free fillet of the re-extruded section at r
176
+ // supplies every rounded surface — vertical edges, both rims, and the corner
177
+ // treatments. Returns a raw (tracked) manifold, or null to
178
+ // keep the reference morphology — the fast path may only ever SUBSTITUTE for
179
+ // it: any doubt (not a prism, plate too thin, everything melted, a fillet
180
+ // refusal) falls back rather than widening or narrowing what roundAll accepts,
181
+ // and roundAll must never surface NEEDS_OCCT (it is its own reference
182
+ // implementation), which is why the whole attempt is fenced by a bare catch.
183
+ const prismRoundAllFast = (m, mHash, r) => {
184
+ let sect = null;
185
+ try {
186
+ sect = prismSection(wasm, m);
187
+ if (!sect) return null;
188
+ const { cs, z0, h: height } = sect;
189
+ // the erosion consumes the whole plate below 2r, and just above it the two
190
+ // rim bands graze each other — both belong to the reference morphology
191
+ if (!(height > 2 * r * 1.05)) return null;
192
+ // MITER joins on all three offsets, deliberately: the true morphology mints
193
+ // outline corners of radius exactly r, and a rim fillet of radius r over an
194
+ // r-radius corner pinches its top tangent contour to a point — the planar
195
+ // sweep refuses, structurally. Miter keeps every corner SHARP instead, and
196
+ // the selector-free fillet below performs ALL the rounding: convex vertical
197
+ // edges (cutters at r — the same solid the round join would have produced),
198
+ // concave verticals (fillers), both rims, and the corner treatments. Melt
199
+ // and seal thresholds stay exact on straight stretches; corner-local
200
+ // thresholds and silhouettes differ from the ball morphology by the rim
201
+ // fillet's own corner tolerance (~0.25·r) — the documented corner trade.
202
+ let cur = null;
203
+ try {
204
+ for (const delta of [r, -2 * r, r]) {
205
+ const next = (cur ?? cs).offset(delta, "Miter", 2);
206
+ const cleaned = next.simplify(1e-6);
207
+ next.delete?.();
208
+ cur?.delete?.();
209
+ cur = cleaned;
210
+ }
211
+ if (!(cur.area() > 0)) return null; // everything melted — reference path owns the empty result
212
+ let base = T(Manifold.extrude(cur, height));
213
+ if (z0 !== 0) base = T(base.translate([0, 0, z0]));
214
+ const wrapped = wrap(base, h("roundAllPrismBase", mHash, r, quality));
215
+ // selector-free: every sharp edge of the mitered prism gets its radius here
216
+ const filleted = wrapped.fillet(r);
217
+ // decouple from the fillet cache's pin: cached() will pin the object this
218
+ // returns under the roundAll hash, and one WASM object must never sit
219
+ // under two cache entries (double-dispose on eviction)
220
+ return T(filleted._m.asOriginal());
221
+ } finally {
222
+ cur?.delete?.();
223
+ }
224
+ } catch {
225
+ return null;
226
+ } finally {
227
+ sect?.cs?.delete?.();
228
+ }
229
+ };
230
+
171
231
  const wrap = (m, hash) => addSugar({
172
232
  _m: m,
173
233
  _hash: hash,
@@ -190,7 +250,8 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
190
250
  // per-quality kernel, so it can never collide across tiers (the OCCT twin
191
251
  // key omits it for the same reason); it just spells out that the ball
192
252
  // tessellation, and so the result, is tier-dependent.
193
- return cached(h("roundAll", hash, r, quality), () => T(meshRoundAll(wasm, m, r, quality)));
253
+ return cached(h("roundAll", hash, r, quality), () =>
254
+ prismRoundAllFast(m, hash, r) ?? T(meshRoundAll(wasm, m, r, quality)));
194
255
  },
195
256
  // batch difference: first minus the union of the rest, evaluated as one boolean
196
257
  // tree — no materialized intermediate union (the unionRaw memory note applies)
@@ -21,10 +21,14 @@
21
21
  // block shaves the sharp corner down to band depth, and the band sweeps around with
22
22
  // no crease (see roundSalientCorners). The silhouette inside the band rounds by about
23
23
  // the blend radius at such corners; the flat shelf the horn leaves at the band's base
24
- // is the deliberate residue. Corners keep their mitre today's and OCCT's behavior,
25
- // a real crease the feature-line overlay honestly draws — when they are REFLEX (the
26
- // ball cannot reach in), or too tight to host the setback (glyph-scale features
27
- // smaller than ~3× the magnitude), or when three or more chains meet (the spherical
24
+ // is the deliberate residue. A REFLEX corner in a common face plane gets the
25
+ // rolling-ball PIVOT (reflexPivotAt/reflexPivotTool): the ball swings about the
26
+ // corner's face-normal axis, touching the face and the vertical corner edge, and the
27
+ // face's blend boundary rounds into an arc of radius r about the vertex — without it
28
+ // the flush-ended neighbor tools leave a wedge of the original rim uncut and the face
29
+ // keeps a point AT the corner (the label-part "artifacts" bug). Corners keep their
30
+ // mitre only when too tight to host the salient setback (glyph-scale features smaller
31
+ // than ~3× the magnitude), or when three or more chains meet (the spherical
28
32
  // cornerPatches below own the orthogonal three-chain case).
29
33
  //
30
34
  // Known limits (documented, not bugs): radius feasibility is the caller's job (clamp
@@ -758,7 +762,9 @@ function planarTool(k, chain, magnitude, mode, segs, pSegs = segs) {
758
762
  // viewer's line threshold and draw across the band. A salient split corner with room
759
763
  // for the setback gets a corner ARC (cornerArcAt — the same rounded-corner treatment
760
764
  // apply() gives two-chain junctions), the adjoining stretches trimmed to its tangent
761
- // points; reflex or too-tight splits keep the overshoot mitre.
765
+ // points; a reflex split gets the rolling-ball PIVOT (reflexPivotTool without it
766
+ // the flush stretch ends leave the corner wedge uncut and the face keeps its point);
767
+ // too-tight salient splits keep the overshoot mitre.
762
768
  const reach = magnitude * 1.5;
763
769
  const breaks = [];
764
770
  for (let i = closed ? 0 : 1; i < (closed ? m : m - 1); i++) {
@@ -773,6 +779,7 @@ function planarTool(k, chain, magnitude, mode, segs, pSegs = segs) {
773
779
  // polyline to the ADJACENT break (or chain end) — a single tessellation segment says
774
780
  // nothing about the room a whole smooth stretch offers.
775
781
  const cornerArcs = new Map(); // break vertex index → { arc, t }
782
+ const pivots = []; // reflex break vertices → rolling-ball pivots
776
783
  if (convex && breaks.length) {
777
784
  const segSum = (from, to) => {
778
785
  let sum = 0;
@@ -789,7 +796,9 @@ function planarTool(k, chain, magnitude, mode, segs, pSegs = segs) {
789
796
  : (j + 1 < breaks.length ? breaks[j + 1] : m - 1);
790
797
  const got = cornerArcAt(at(i), faceN, scl(segDir[iIn], -1), segDir[i],
791
798
  wallNs[iIn], wallNs[i], segSum(prevB, i), segSum(i, nextB), magnitude);
792
- if (got) cornerArcs.set(i, got);
799
+ if (got) { cornerArcs.set(i, got); continue; }
800
+ const piv = reflexPivotAt(at(i), faceN, scl(segDir[iIn], -1), segDir[i], wallNs[iIn], wallNs[i]);
801
+ if (piv) pivots.push(piv);
793
802
  }
794
803
  }
795
804
 
@@ -860,6 +869,7 @@ function planarTool(k, chain, magnitude, mode, segs, pSegs = segs) {
860
869
  if (len(sub(got.vertex, got.arc.O)) - got.arc.R > 0.02 * magnitude)
861
870
  tools.push(cornerHornTool(k, got, magnitude, segs));
862
871
  }
872
+ for (const piv of pivots) tools.push(reflexPivotTool(k, piv, magnitude, mode, segs, pSegs));
863
873
  return tools;
864
874
  } catch (e) {
865
875
  throw new UnsupportedEdgeError(`planar sweep: ${e.message}`);
@@ -883,10 +893,11 @@ function planarTool(k, chain, magnitude, mode, segs, pSegs = segs) {
883
893
  // sharper than CORNER_ROUND_MIN_TURN; gentler corners keep their exact silhouette (a
884
894
  // mitre under the viewer\'s 35° line threshold draws nothing).
885
895
  //
886
- // REFLEX corners keep the mitre: the ball genuinely cannot reach into the corner, the
887
- // crease there is real geometry, and rounding the path would ADD material. A corner
888
- // whose neighbors are too short to host the setback (tight glyph features) falls back
889
- // to the mitre too the fallback is today\'s behavior, never a failure.
896
+ // REFLEX corners take the rolling-ball PIVOT instead (reflexPivotAt below): steering
897
+ // the band path around a reflex corner would ADD material, but the ball itself swings
898
+ // about the corner touching the face and the vertical corner edge see the reflex
899
+ // pivot section. A salient corner whose neighbors are too short to host the setback
900
+ // (tight glyph features) falls back to the mitre — that fallback is never a failure.
890
901
  // Corners gentler than this keep their mitre: the two blends there differ by less than
891
902
  // the mitre turn everywhere, far under the viewer's 35° line threshold, and the shallow
892
903
  // overlap sliver stays too flat for simplify() to fold into visible creases. Measured:
@@ -989,6 +1000,95 @@ function cornerHornTool(k, { vertex, f, arc }, magnitude, segs) {
989
1000
  return tool.translate(O);
990
1001
  }
991
1002
 
1003
+ // ---------------------------------------------------------------------------
1004
+ // Reflex pivots. At a REFLEX corner the neighboring blend tools end flush against the
1005
+ // planes through the vertex (their only overshoot is the 0.05·r anti-graze allowance),
1006
+ // so the wedge of rim material between those end planes — azimuthal extent = the turn
1007
+ // angle — survives both cutters and the face keeps a point essentially AT the vertex.
1008
+ // The rolling ball does not stop there: it pivots about the corner, touching the face
1009
+ // and the vertical corner edge, its center swinging on an arc of radius r about the
1010
+ // face-normal axis through the vertex. The envelope is a horn-torus patch — the
1011
+ // revolve, about that axis, of the blend cross-section running from the axis point
1012
+ // r below the face to the face tangency at radius r — and the face's blend boundary
1013
+ // becomes an arc of radius r about the vertex (the "rounded inside curve"). The pivot
1014
+ // cross-section at each span end coincides exactly with the neighbor tool's own
1015
+ // cross-section at the vertex (same circle, same plane), so the handover is G1; the
1016
+ // small angular overshoot below only re-cuts band the neighbors already cut.
1017
+
1018
+ // Pivot descriptor at one vertex, or null (salient — cornerArcAt's case — or a turn
1019
+ // too gentle to matter). Same argument convention as cornerArcAt: tin1/tin2 point
1020
+ // from the vertex INTO each side, wall1/wall2 are the sides' outward wall normals.
1021
+ function reflexPivotAt(vertex, f, tin1, tin2, wall1, wall2) {
1022
+ const tIn = scl(tin1, -1), tOut = tin2;
1023
+ const turn = Math.acos(clamp1(dot(tIn, tOut)));
1024
+ if (turn < CORNER_ROUND_MIN_TURN) return null; // wedge sagitta sub-micron: keep today's behavior
1025
+ const turnS = dot(cross(tIn, tOut), f);
1026
+ const matLeft = dot(wall1, cross(tIn, f)) > 0;
1027
+ if ((turnS > 0) === matLeft) return null; // salient: roundSalientCorners' territory
1028
+ // inward in-plane wall normals bound the uncut wedge; the pivot sweeps between them
1029
+ const proj = (wl) => { const p = sub(scl(wl, -1), scl(f, -dot(wl, f))); const l = len(p) || 1; return scl(p, 1 / l); };
1030
+ let u0 = proj(wall1), uE = proj(wall2);
1031
+ const span = Math.acos(clamp1(dot(u0, uE)));
1032
+ if (!(span > 1e-4)) return null;
1033
+ if (dot(cross(u0, uE), f) < 0) [u0, uE] = [uE, u0]; // azimuth increases u0 → uE about +f
1034
+ return { vertex, f, u0, span };
1035
+ }
1036
+
1037
+ // The pivot cutter: revolve of the blend cross-section about the face-normal axis
1038
+ // through the vertex. Cross-section in (ρ, z) with z along f (face at z = 0): the
1039
+ // fillet arc runs from the axis point (0, −r) to the face tangency (r, 0) on the
1040
+ // circle centered (r, −r) — extended past the tangency by revolveTool's arc-tail
1041
+ // recipe, because the blend meets the face tangentially there and a tessellated
1042
+ // tangency grazes — then closes above the face so the corner column goes with it.
1043
+ // A chamfer takes the chord instead (a cone), which meets face and edge transversally
1044
+ // and needs no tail. Interior arc vertices use profile2D's area-exact radius.
1045
+ function reflexPivotTool(k, { vertex, f, u0, span }, magnitude, mode, segs, pSegs) {
1046
+ const r = magnitude;
1047
+ const delta = 0.02 * r;
1048
+ let poly;
1049
+ if (mode === "chamfer") {
1050
+ poly = [[0, -r], [r, 0], [r + delta, delta], [0, delta]];
1051
+ } else {
1052
+ const sag = Math.min(2e-4, 0.02 * r);
1053
+ const ext = Math.min(0.4, Math.max(0.01, Math.acos(Math.max(-1, 1 - sag / r))));
1054
+ const arcSpan = Math.PI / 2 + ext;
1055
+ const nArc = Math.max(2, Math.ceil((arcSpan / (2 * Math.PI)) * pSegs));
1056
+ const th = arcSpan / nArc;
1057
+ const rEq = r * Math.sqrt(th / Math.sin(th));
1058
+ poly = [];
1059
+ for (let i = 0; i <= nArc; i++) {
1060
+ const a = Math.PI - arcSpan * (i / nArc); // 180° (axis) → past the face tangency
1061
+ const ri = i === 0 || i === nArc ? r : rEq;
1062
+ poly.push([r + ri * Math.cos(a), -r + ri * Math.sin(a)]);
1063
+ }
1064
+ poly.push([poly[poly.length - 1][0], delta], [0, delta]);
1065
+ }
1066
+ let area = 0;
1067
+ for (let i = 0; i < poly.length; i++) {
1068
+ const [x1, y1] = poly[i], [x2, y2] = poly[(i + 1) % poly.length];
1069
+ area += x1 * y2 - x2 * y1;
1070
+ }
1071
+ if (area < 0) poly = poly.slice().reverse();
1072
+ // angular overshoot past both span ends: crosses the walls (tangent contacts at the
1073
+ // ends) decisively, re-cutting only band the flush-ended neighbors already cut
1074
+ const ovAng = 0.05;
1075
+ const degrees = ((span + 2 * ovAng) * 180) / Math.PI;
1076
+ let tool = k.revolve(poly, { degrees, segs: cornerArcSegs(segs, 0, magnitude) });
1077
+ // pose: Z → f, then twist the revolve's start azimuth (+X) onto u0 backed off by ovAng
1078
+ const v0 = cross(f, u0);
1079
+ const startDir = add(scl(u0, Math.cos(-ovAng)), scl(v0, Math.sin(-ovAng)));
1080
+ const axisRaw = cross([0, 0, 1], f);
1081
+ const s = len(axisRaw);
1082
+ let axis = null, theta = 0;
1083
+ if (s > 1e-9) { axis = scl(axisRaw, 1 / s); theta = Math.atan2(s, f[2]); }
1084
+ else if (f[2] < 0) { axis = [1, 0, 0]; theta = Math.PI; }
1085
+ if (axis) tool = tool.rotateAbout({ axis, deg: (theta * 180) / Math.PI });
1086
+ const xImage = axis ? rotVec([1, 0, 0], axis, theta) : [1, 0, 0];
1087
+ const twist = Math.atan2(dot(f, cross(xImage, startDir)), dot(xImage, startDir));
1088
+ if (Math.abs(twist) > 1e-9) tool = tool.rotateAbout({ axis: f, deg: (twist * 180) / Math.PI });
1089
+ return tool.translate(vertex);
1090
+ }
1091
+
992
1092
  function chainEndInfo(ch, end) {
993
1093
  if (ch.kind === "line") {
994
1094
  return end === "start"
@@ -1003,9 +1103,10 @@ function chainEndInfo(ch, end) {
1003
1103
  : { v: pts[m - 1], tin: norm(sub(pts[m - 2], pts[m - 1])), flanks: [ch.faceN, ch.wallNs[ch.wallNs.length - 1]], len: plen };
1004
1104
  }
1005
1105
 
1006
- // Corner arc for two chain ends meeting at one vertex, or null (no common face plane,
1007
- // gentle turn, reflex corner, or no room for the setback).
1008
- function cornerArcBetween(E1, E2, magnitude) {
1106
+ // Corner treatment for two chain ends meeting at one vertex: {arc} for a salient
1107
+ // corner with room for the setback, {pivot} for a reflex corner, or null (no common
1108
+ // face plane, gentle turn, or a too-tight salient corner keeping its mitre).
1109
+ function cornerBlendBetween(E1, E2, magnitude) {
1009
1110
  let f = null, wall1 = null, wall2 = null;
1010
1111
  for (const c1 of E1.flanks) {
1011
1112
  for (const c2 of E2.flanks) {
@@ -1018,7 +1119,10 @@ function cornerArcBetween(E1, E2, magnitude) {
1018
1119
  }
1019
1120
  }
1020
1121
  if (!f) return null;
1021
- return cornerArcAt(E1.v, f, E1.tin, E2.tin, wall1, wall2, E1.len, E2.len, magnitude);
1122
+ const corner = cornerArcAt(E1.v, f, E1.tin, E2.tin, wall1, wall2, E1.len, E2.len, magnitude);
1123
+ if (corner) return { corner };
1124
+ const pivot = reflexPivotAt(E1.v, f, E1.tin, E2.tin, wall1, wall2);
1125
+ return pivot ? { pivot } : null;
1022
1126
  }
1023
1127
 
1024
1128
  // Convert a FACE-PLANE arc chain to the equivalent planar chain, or return null when
@@ -1082,8 +1186,9 @@ function trimChain(ch, tStart, tEnd) {
1082
1186
  return { ...ch, points: pts, wallNs: walls, closed: false };
1083
1187
  }
1084
1188
 
1085
- // Round the salient two-chain corners of a selection: returns the effective chain list
1086
- // (trimmed neighbors substituted in place) plus the synthetic corner-arc chains.
1189
+ // Blend the two-chain corners of a selection: salient corners round (trimmed neighbors
1190
+ // substituted in place, plus the synthetic corner-arc chains and their horns); reflex
1191
+ // corners get rolling-ball pivots (neighbors stay flush — the pivot owns the wedge).
1087
1192
  function roundSalientCorners(selected, magnitude) {
1088
1193
  const keyOf = (p) => `${Math.round(p[0] * WELD)},${Math.round(p[1] * WELD)},${Math.round(p[2] * WELD)}`;
1089
1194
  const ends = new Map();
@@ -1096,7 +1201,7 @@ function roundSalientCorners(selected, magnitude) {
1096
1201
  (ends.get(kk) ?? ends.set(kk, []).get(kk)).push({ ch, end, info });
1097
1202
  }
1098
1203
  }
1099
- const arcs = [], horns = [], trims = new Map();
1204
+ const arcs = [], horns = [], pivots = [], trims = new Map();
1100
1205
  const addTrim = (ch, end, t) => {
1101
1206
  const cur = trims.get(ch) ?? { start: 0, end: 0 };
1102
1207
  cur[end] = t;
@@ -1104,8 +1209,10 @@ function roundSalientCorners(selected, magnitude) {
1104
1209
  };
1105
1210
  for (const list of ends.values()) {
1106
1211
  if (list.length !== 2 || (list[0].ch === list[1].ch && list[0].end === list[1].end)) continue;
1107
- const got = cornerArcBetween(list[0].info, list[1].info, magnitude);
1108
- if (!got) continue;
1212
+ const blend = cornerBlendBetween(list[0].info, list[1].info, magnitude);
1213
+ if (!blend) continue;
1214
+ if (blend.pivot) { pivots.push(blend.pivot); continue; }
1215
+ const got = blend.corner;
1109
1216
  arcs.push(got.arc);
1110
1217
  // a gentle corner's horn is a sliver — depth ρ·(1/cos(turn/2) − 1), microns at
1111
1218
  // small turns — not worth a cutter (and thin cutters are their own noise source)
@@ -1114,14 +1221,14 @@ function roundSalientCorners(selected, magnitude) {
1114
1221
  addTrim(list[0].ch, list[0].end, got.t);
1115
1222
  addTrim(list[1].ch, list[1].end, got.t);
1116
1223
  }
1117
- if (!arcs.length) return { chains: selected, arcs, horns };
1224
+ if (!arcs.length) return { chains: selected, arcs, horns, pivots };
1118
1225
  const chains = [];
1119
1226
  for (const ch of selected) {
1120
1227
  const tr = trims.get(ch);
1121
1228
  const eff = tr ? trimChain(ch, tr.start, tr.end) : ch;
1122
1229
  if (eff) chains.push(eff);
1123
1230
  }
1124
- return { chains, arcs, horns };
1231
+ return { chains, arcs, horns, pivots };
1125
1232
  }
1126
1233
 
1127
1234
  // ---------------------------------------------------------------------------
@@ -1199,7 +1306,7 @@ function apply(k, solid, mode, magnitude, { edges, segs = DEFAULT_SEGS, sharpDeg
1199
1306
  // converted arc joins its planar neighbors — chainEdges' own stitch pass ran before
1200
1307
  // these chains were planar, so their junctions are still open here.
1201
1308
  const planarized = stitchPlanarChains(selected.map((ch) => planarizeArc(ch) ?? ch));
1202
- const { chains: effective, arcs, horns } = roundSalientCorners(planarized, magnitude);
1309
+ const { chains: effective, arcs, horns, pivots } = roundSalientCorners(planarized, magnitude);
1203
1310
  const pSegs = blendSegs(segs, magnitude);
1204
1311
  const toolsFor = (ch) =>
1205
1312
  ch.kind === "planar"
@@ -1207,6 +1314,7 @@ function apply(k, solid, mode, magnitude, { edges, segs = DEFAULT_SEGS, sharpDeg
1207
1314
  : [(ch.kind === "arc" ? revolveTool : prismTool)(k, ch, magnitude, mode, segs, pSegs)];
1208
1315
  const cutters = [...effective, ...arcs].filter((ch) => ch.convex).flatMap(toolsFor);
1209
1316
  cutters.push(...horns.map((h) => cornerHornTool(k, h, magnitude, segs)));
1317
+ cutters.push(...pivots.map((p) => reflexPivotTool(k, p, magnitude, mode, segs, pSegs)));
1210
1318
  const fillers = effective.filter((ch) => !ch.convex).flatMap(toolsFor);
1211
1319
  if (mode === "fillet") cutters.push(...cornerPatches(k, effective, magnitude, segs));
1212
1320
  let out = solid;
@@ -81,3 +81,46 @@ export function meshRoundAll(wasm, m, r, quality) {
81
81
  sph2R.delete?.();
82
82
  }
83
83
  }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Prism detection for the fast path (manifold-backend.js). The Minkowski chain
87
+ // above is seconds-per-thousand-triangles, but roundAll's expensive real-world
88
+ // inputs are almost always Z-prisms (text backings, plates, extruded outlines) —
89
+ // and on a prism the ball morphology decomposes exactly into the 2-D disk
90
+ // morphology of the cross-section plus rim fillets, all of which are fast. This
91
+ // function answers "is m a Z-prism, and what is its constant cross-section?"
92
+ //
93
+ // Detection is deliberately behavioral, not structural: three slices must have
94
+ // equal area AND vanishing symmetric difference (a sheared prism has equal-area
95
+ // TRANSLATED sections — the subtract catches it), and the solid's volume must
96
+ // equal section × height (a bulge parked between the slice planes would pass the
97
+ // slice checks alone). Any failure returns null and the caller keeps the
98
+ // reference morphology — the fast path may only ever substitute, never widen.
99
+ //
100
+ // On success the returned CrossSection is the CALLER's to delete.
101
+ export function prismSection(wasm, m, relTol = 1e-4) {
102
+ const bb = m.boundingBox();
103
+ const z0 = bb.min[2], h = bb.max[2] - z0;
104
+ if (!(h > 0)) return null;
105
+ const volume = m.volume();
106
+ if (!(volume > 0)) return null;
107
+ const slices = [0.25, 0.5, 0.75].map((t) => m.slice(z0 + t * h));
108
+ try {
109
+ const area = slices[1].area();
110
+ if (!(area > 0)) return null;
111
+ for (const s of slices) if (Math.abs(s.area() - area) > relTol * area) return null;
112
+ for (const s of [slices[0], slices[2]]) {
113
+ const d1 = slices[1].subtract(s), d2 = s.subtract(slices[1]);
114
+ const diff = d1.area() + d2.area();
115
+ d1.delete?.();
116
+ d2.delete?.();
117
+ if (diff > relTol * area) return null;
118
+ }
119
+ if (Math.abs(volume - area * h) > 10 * relTol * area * h) return null;
120
+ const cs = slices[1];
121
+ slices[1] = null; // ownership moves to the caller
122
+ return { cs, z0, h };
123
+ } finally {
124
+ for (const s of slices) s?.delete?.();
125
+ }
126
+ }