partforge 0.36.0 → 0.37.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.
@@ -152,6 +152,9 @@ future contract v2 — but are not shown here; see `docs/KERNEL-CONTRACT.md`
152
152
  | `k.loft({ rings, ruled?, closed? })` | stack polygon cross-sections into a solid — ruled walls between consecutive rings, capped ends (both backends; `closed:true` capless loops are Manifold-only). `ruled:false` (smooth C2 blend) is honoured only by OCCT/STEP export; the Manifold preview always shows faceted straight walls |
153
153
  | `k.sweep({ profile, path, cornerRadius?, closed?, ruled?, smooth? })` | sweep a fixed 2-D profile along a 3-D polyline path — sharp mitered corners (or `cornerRadius` fillets), capped ends (both backends). `closed:true` capless loops and `smooth:true` (OCCT-native swept B-rep, STEP-exact / preview-faceted) are backend-specific, like loft's `closed`/`ruled:false`. `closed:true` loops must be **planar** — RMF frame-transport holonomy can seam-twist a non-planar closed loop where the last station rejoins the first, so only planar closed loops are supported/tested |
154
154
  | `k.sphere({ r\|d })` | sphere centred at the origin; bare `k.sphere(r)` also stays valid |
155
+ | `k.roundedBox({ size, center?, round })` | box with rounded edges — `round` = number (all edges) or `{ side?, top?, bottom? }` (vertical edges / rims); stays on Manifold (no OCCT routing, unlike `fillet`); `side` must be 0 or ≥ the rim radii (between clamps with a warning); with `side > 0`, `top + bottom` must be strictly `< h` |
156
+ | `k.roundedCylinder({ r\|d, h, center?, round })` | cylinder with rounded rims — `round` = number (both) or `{ top?, bottom? }`; `round: r` with `top+bottom = h` gives a sphere (capsule when `h > 2r`); one lathe revolve, curve-exact in STEP |
157
+ | `k.torus({ rMajor, rMinor })` | torus centered at the origin (tube centerline in z=0); `0 < rMinor < rMajor` |
155
158
  | `k.revolve({ profile, degrees? })` | revolve a lathe profile `[[r,z],…]` (r ≥ 0) around the Z axis (full or partial) |
156
159
  | `k.helixSweptTube({ pathR, profileR, pitch, turns, z0, lefthand })` | circle swept along a helix (e.g. a rope groove) |
157
160
  | `k.union(solids[])` | boolean union |
@@ -194,6 +197,9 @@ const tab = pathProfile([0, 0])
194
197
  .cubicTo([0, 8], [14, 16], [6, 16]) // curved top edge
195
198
  .close();
196
199
  k.extrude({ profile: tab, h: 3 });
200
+
201
+ // Rounded enclosure: soft vertical edges, a softer lid, a flat base.
202
+ const shell = k.roundedBox({ size: [60, 40, 22], round: { side: 4, top: 2, bottom: 0 } });
197
203
  ```
198
204
 
199
205
  2-D polygon helpers for `prism`/`extrude`/`loft`: `import { piePolygon, hexPolygon,
@@ -542,8 +548,10 @@ Pure helpers from `partforge/geometry` (no backend dependency):
542
548
  cut an inner cylinder from an outer one instead).
543
549
  `circleProfile(r, center?)` — a circle of radius `r` centered at `[cx,cy]` (default origin).
544
550
  Compose it for round solids: `k.prism({ points: circleProfile(r), h })` is a cylinder, and
545
- **a torus is `k.revolve({ profile: circleProfile(minorR, [majorR, 0]) })`** (with `majorR > minorR`)
546
- partforge has no `torus` primitive because it's just a revolved circle.
551
+ **use `k.torus({ rMajor, rMinor })` for a torus** it desugars to a revolve of
552
+ an arc-exact circle profile (`k.revolve({ profile: circleProfile(minorR,
553
+ [majorR, 0]) })` is the faceted hand-rolled equivalent; the primitive keeps
554
+ real TORUS faces in STEP).
547
555
 
548
556
  **Patterns** (return `Solid[]` — feed to `k.union(...)` for features or `s.cutAll(...)` for holes):
549
557
  `linearPattern(solid, count, [dx,dy,dz])`, `circularPattern(solid, count, { center, axis, angle, rotateCopies })`.
@@ -83,6 +83,24 @@ Variant literals under this entry: `extrude: unknown bevel option`, `extrude: be
83
83
  - **Cause:** Offsetting the rim by the bevel distance would pinch a narrow feature (a tooth land, a thin bar, a thin web beside a hole) shut, so the bevel deterministically backs off to the largest offset the outline can take — the same geometric limit OCCT's chamfer hits, resolved in pure JS instead of kernel re-runs.
84
84
  - **Fix:** Usually nothing — the reduced bevel is the correct maximum for the geometry. To silence it, clamp the bevel parameter below the printed value or widen the narrow feature.
85
85
 
86
+ ## roundedbox-rim-clamped
87
+
88
+ - **Symptom:** `roundedBox: round.top <n> clamped to round.side <m> (side must be 0 or ≥ rim radii; use side: 0 for a rim-only round-over)` in the console, and the built rim round-over is smaller than the `round.top`/`round.bottom` you passed.
89
+ - **Cause:** the middle regime `0 < side < rim` has no closed-form corner shared by both backends, so the rim radii clamp down to `side` (the footprint-defining radius never grows silently).
90
+ - **Fix:** either raise `round.side` to ≥ the rim radii (torus/sphere corners), or set `side: 0` exactly for a full-size rim-only round-over on sharp vertical edges.
91
+
92
+ ## roundedbox-strict-h
93
+
94
+ - **Symptom:** `roundedBox: with round.side > 0, round.top + round.bottom must be < h (the rim fillets would meet tangentially; reduce the rim radii slightly, or use side: 0 for a sharp-sided full-height round-over)` thrown from a build.
95
+ - **Cause:** with `round.side > 0`, the top and bottom rim fillets are separate features that need a straight wall band between them; `top + bottom == h` (or greater) leaves no band, so the fillets would meet tangentially — which the B-rep backend cannot build.
96
+ - **Fix:** reduce `round.top`/`round.bottom` slightly so their sum is strictly less than `h`, or set `round.side: 0` for a sharp-sided full-height round-over. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § roundedBox row.
97
+
98
+ ## roundedbox-fillet-skipped
99
+
100
+ - **Symptom:** `partforge: fillet(<r>) produced invalid geometry — feature skipped` (or `… produced an empty solid — feature skipped`) in the console during a `roundedBox` build, and the OCCT-exported rim is sharp where a round-over was requested.
101
+ - **Cause:** OCCT's native fillet cannot build the rim round-over at a degenerate boundary (e.g. a rim radius exactly equal to `round.side` on a stadium profile, `2·side == min(w, d)`) and would otherwise return invalid-but-nonempty geometry; the monotonicity/validity gate in `occt-repair.js`'s `safeOp` catches it and skips the feature rather than exporting invalid STEP.
102
+ - **Fix:** shrink the affected rim radius slightly below `round.side` (or below the degenerate boundary), or accept the sharp rim at that exact radius.
103
+
86
104
  ## boolean-not-watertight
87
105
 
88
106
  - **Symptom:** `NOT watertight ✗` from `partforge measure` (non-zero exit) after adding a boolean cut or union.
@@ -177,6 +177,9 @@ above. All ops return a `Solid`.
177
177
  | `cylinder({r\|d, h, center?})` · `cylinder({r1, r2, h, center?})` \| `{d1, d2, h}` | Cylinder along +Z from z = 0 (straight: exactly one of `r`/`d`); the cone form (`r1`/`r2` or `d1`/`d2` ends) gives a frustum. `center: true` centers on z = 0. |
178
178
  | `boredCylinder({od, h, bore})` | Compound: cylinder of diameter `od` with a through-bore `bore`. Semantically identical to the composition in `kernel-front.js`; a backend may override only for caching, never for different geometry. |
179
179
  | `sphere({r\|d})` | Sphere centered at the origin; bare `sphere(r)` stays valid. |
180
+ | `roundedCylinder({ r\|d, h, center?, round })` | Cylinder with rim round-overs (`round`: number = both rims, or `{ top?, bottom? }`), built as one lathe `revolve` of an arc-exact profile — real torus faces in STEP. Validation: radii ≥ 0, each ≤ r, top + bottom ≤ h. Options-only. |
181
+ | `torus({ rMajor, rMinor })` | Torus centered at the origin, tube centerline in the z = 0 plane; requires 0 < rMinor < rMajor. Curve-exact on B-rep backends. Options-only. |
182
+ | `roundedBox({ size, center?, round })` | Box with selectively rounded edges; `round`: number = every edge, or `{ side?, top?, bottom? }` (vertical edges / top rim / bottom rim). Corner semantics and the `0 < side < rim` clamp-with-warning rule are normative in the design spec and summarized under [Rounded primitives](#rounded-primitives). Options-only. |
180
183
  | `box({size, center?})` · `box({min, max})` | Axis-aligned box: `{size:[x,y,z]}` centered in X/Y with base at z = 0 (`center: true` also centers Z), or explicit `[x,y,z]` `{min, max}` corners. |
181
184
  | `prism({points, h, twist?, scaleTop?})` | Extrude one CCW contour (point list or arc profile) from z = 0. `twist` = total degrees over the height; `scaleTop` = uniform top scale (1 straight, 0 → apex). |
182
185
  | `extrude({profile, h, twist?, scaleTop?, bevel?})` | Same, for a polygon-with-holes region — `profile` is `{outer, holes?}` (bare contour = outer only) — in one op, no per-hole boolean. `profile` may also be a `Shape2D` (see below). `bevel` (number = both rims, `{bottom?, top?}` = per rim) cuts a 45° rim bevel; it desugars at the shared front into extrude + loft + intersect/cut, so it is backend-identical by construction and is **not** a CAD-only op (no OCCT routing). Every profile form works — point array, arc profile, `{outer, holes}` (hole rims flare outward), or `Shape2D` (multi-region bevels each and unions) — but curved profiles are **materialized to point rings** first, so a beveled extrusion is faceted at the sampling LOD even in STEP (arc contours at a fixed pure-JS LOD, backend-identical; a `Shape2D` at its backend's own LOD — `hull`'s parity class). No `twist`/`scaleTop`, and `bottom + top < h` or it throws; a bevel a rim's narrow features cannot take is deterministically reduced with a console warning (`ERROR-PATTERNS.md#extrude-bevel-reduced`). |
@@ -206,6 +209,41 @@ render the ruled form. `sweep` `closed: true` loops must be planar. Where both b
206
209
  shape they do it **by construction, not by tolerance**: sweep elbows loft the identical
207
210
  station list (`sweep.js`) on both backends.
208
211
 
212
+ ### Rounded primitives
213
+
214
+ `roundedBox` / `roundedCylinder` / `torus` are options-only compound
215
+ primitives. `roundedBox` is an atomic compound node; `roundedCylinder`/`torus`
216
+ desugar to a `shape2d` + `revolve` pair (both nodes hash deterministically
217
+ from the op's arguments). Normative semantics for `roundedBox`
218
+ (design spec 2026-07-30): the cross-section at height z is the rounded
219
+ rectangle inset by δ(z) with corner radius max(side − δ(z), 0), where δ
220
+ traces a quarter circle of the rim radius in each rim zone and is 0 in the
221
+ straight zone. Consequences an implementation must honour:
222
+
223
+ - **side ≥ max(top, bottom)**: top/bottom corners are exact torus patches
224
+ (sphere octants when equal).
225
+ - **side = 0**: each rim round-over runs the full edge length and adjacent
226
+ round-overs meet in their natural intersection curve — NOT a
227
+ kernel-specific vertex blend; the top/bottom face keeps sharp corners.
228
+ - **0 < side < max(top, bottom)**: the rim radii CLAMP DOWN to side, with a
229
+ console warning (deduped per distinct message). A clamped call and its
230
+ explicitly-clamped equivalent are the same normalized arguments — one
231
+ cache node. For a rim-only round-over use side: 0 exactly.
232
+
233
+ Validation (op-named plain Errors, backend-identical): radii ≥ 0 and finite;
234
+ box: 2·r ≤ min(w, d) for every group, top + bottom ≤ h (strict < when
235
+ side > 0 — the two rim fillets would meet tangentially, which the B-rep
236
+ backend cannot build; side: 0 full-height round-overs stay valid); cylinder:
237
+ rims ≤ r, top + bottom ≤ h; torus: 0 < rMinor < rMajor. `roundedCylinder`/
238
+ `torus` are single lathe revolves of arc-exact profiles — B-rep backends
239
+ carry real torus/sphere faces to STEP; mesh backends facet at the segs LOD
240
+ (the standard exact-vs-faceted split, not a parity waiver). `roundedBox` is
241
+ faceted at the segs LOD on mesh backends and exact B-rep on OCCT; measure
242
+ parity holds within facet tolerance — except where a rim fillet hits a
243
+ degenerate boundary (e.g. rim = side on a stadium profile) and the B-rep
244
+ backend skips it with a warning (`ERROR-PATTERNS.md`,
245
+ `roundedbox-fillet-skipped`) rather than export invalid geometry.
246
+
209
247
  ## Solid ops (combine / transform / query / output)
210
248
 
211
249
  Normative signatures: `kernel.js`'s `@typedef Solid`.
@@ -222,7 +260,7 @@ Normative signatures: `kernel.js`'s `@typedef Solid`.
222
260
  | `genus()` / `isEmpty()` | Optional (`SOLID_OPTIONAL_OPS`): mesh-topology queries — through-hole count / no-geometry test. The mesh backend provides them; OCCT has no cheap equivalent. |
223
261
  | `toMesh({quality?})` | Render mesh: `{positions, normals, indices?, triangles, edges?, featureIds?, features?}`. `indices` optional (a backend may emit soup or indexed); `normals` may be empty (`length 0`) to delegate creasing to the viewer; `edges` (feature-line segments) and the feature fields are optional metadata. |
224
262
  | `toSTL({quality?})` | `Promise<ArrayBuffer>`, binary STL, outward CCW winding. Stored facet normals may be zero — slicers recompute them (the mesh backend happens to write them). |
225
- | `toIndexedMesh()` | `{positions, indices}` indexed mesh (3MF path). |
263
+ | `toIndexedMesh({quality?})` | `{positions, indices}` indexed mesh (3MF path); defaults to `"print"` like `toSTL`. Coincident vertices need NOT be welded — the 3MF writer welds, because that format reads topology from the indices rather than re-stitching soup by position the way an STL consumer does. |
226
264
  | `fillet(r)` · `fillet({r, edges?})` / `chamfer(d)` · `chamfer({d, edges?})` / `shell({t, open})` | B-rep class (core throws `KernelCapabilityError`). Scalar `fillet(3)`/`chamfer(1)` acts on all edges; the options form adds an `edges` selector. `shell` hollows inward, keeping outer dimensions; `open` (face selector) is required. |
227
265
 
228
266
  `quality` (`"preview"` | `"print"`) is **advisory**: it trades tessellation density for
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.36.0",
3
+ "version": "0.37.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",
@@ -28,6 +28,7 @@ import { textGlyphs } from "./text2d.js";
28
28
  import { beveledExtrude } from "./rim-bevel.js";
29
29
  import { DEFAULT_FONT_BYTES } from "./fonts/default-font.js";
30
30
  import { convexHull, hullPoints } from "./hull.js";
31
+ import { latheRoundedRect, torusContour } from "./rounded-solids.js";
31
32
 
32
33
  export function finishKernel(k) {
33
34
  // Compound default: bored-through cylinder (tool overshoots 2 mm each end for
@@ -36,6 +37,16 @@ export function finishKernel(k) {
36
37
  k.boredCylinder ??= ({ od, h, bore }) =>
37
38
  k.cylinder(od / 2, od / 2, h).cut(k.cylinder(bore / 2, bore / 2, h + 4).translate([0, 0, -2]));
38
39
 
40
+ // Compound defaults: rounded lathe solids — ONE revolve of an arc-exact
41
+ // profile, so OCCT gets real torus/sphere/cylinder faces in STEP and
42
+ // Manifold facets at the mesh LOD. Zero booleans; neither backend overrides.
43
+ k.roundedCylinder ??= ({ r, h, center, round }) => {
44
+ const s = k.revolve({ profile: k.shape2d(latheRoundedRect(r, h, round.top, round.bottom)) });
45
+ return center ? s.translate([0, 0, -h / 2]) : s;
46
+ };
47
+ k.torus ??= ({ rMajor, rMinor }) =>
48
+ k.revolve({ profile: k.shape2d(torusContour(rMajor, rMinor)) });
49
+
39
50
  for (const [op, { toArgs, check }] of Object.entries(KERNEL_OP_SPECS)) {
40
51
  const raw = k[op];
41
52
  if (!raw) continue;
@@ -20,6 +20,7 @@ export const CONTRACT_VERSION = 1;
20
20
  export const KERNEL_OPS = [
21
21
  "cylinder", "boredCylinder", "sphere", "box", "prism", "extrude", "revolve",
22
22
  "loft", "sweep", "helixSweptTube", "union", "shape2d", "text2d", "hull", "hullChain", "toSTEP",
23
+ "roundedCylinder", "torus", "roundedBox",
23
24
  ];
24
25
 
25
26
  // Backend-optional kernel ops: the sub-part cache brackets + WASM lifetime hooks.
@@ -99,6 +100,9 @@ export const OCCT_ONLY_OPS = ["fillet", "chamfer", "shell"];
99
100
  * @property {(o:{r?:number,d?:number,r1?:number,r2?:number,d1?:number,d2?:number,h:number,center?:boolean}) => Solid} cylinder canonical: {r|d,h} straight, {r1,r2,h}|{d1,d2,h} cone; legacy (rBottom,rTop,h,opts) accepted until contract v2
100
101
  * @property {(o:{od:number,h:number,bore:number}) => Solid} boredCylinder compound: bored-through cylinder (one cache node)
101
102
  * @property {(o:{r?:number,d?:number}) => Solid} sphere sphere centred at the origin; {r|d}; bare sphere(r) stays valid
103
+ * @property {(o:{r?:number,d?:number,h:number,center?:boolean,round:number|{top?:number,bottom?:number}}) => Solid} roundedCylinder rim round-overs via one lathe revolve; options-only; round ≤ r, top+bottom ≤ h
104
+ * @property {(o:{rMajor:number,rMinor:number}) => Solid} torus centered at origin, tube centerline in the z=0 plane; 0 < rMinor < rMajor; options-only
105
+ * @property {(o:{size:number[],center?:boolean,round:number|{side?:number,top?:number,bottom?:number}}) => Solid} roundedBox selective edge rounding (side = vertical edges, top/bottom = rims); 0 < side < rim clamps rims down to side with a console.warn; options-only
102
106
  * @property {(o:{size?:number[],center?:boolean,min?:number[],max?:number[]}) => Solid} box {size} = centered X/Y, base z=0 ({center:true} centers Z too) or {min,max}; legacy (min,max) accepted until v2
103
107
  * @property {(o:{points:number[][],h:number,twist?:number,scaleTop?:number}) => Solid} prism extrude polygon from z=0; legacy (points,h,opts) accepted until v2
104
108
  * @property {(o:{profile:number[][]|{outer:number[][],holes?:number[][][]},h:number,twist?:number,scaleTop?:number,bevel?:number|{bottom?:number,top?:number}}) => Solid} extrude polygon-with-holes region from z=0; bevel = 45° rim bevel (any profile form incl. Shape2D, materialized to point rings; no twist/scaleTop); legacy (profile,h,opts) accepted until v2
@@ -1,6 +1,7 @@
1
1
  import { helixTube } from "./helix-tube.js";
2
2
  import { loftMesh } from "./loft.js";
3
3
  import { sweepMesh } from "./sweep.js";
4
+ import { roundedBoxRings } from "./rounded-solids.js";
4
5
  import { tessellateContour, tessellateProfile } from "./profile.js";
5
6
  import { h } from "./solid-hash.js";
6
7
  import { createSolidCache } from "./solid-cache.js";
@@ -197,6 +198,16 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
197
198
  const tool = T(tool0.translate([0, 0, -2])); // raw ops: track each result
198
199
  return T(body.subtract(tool));
199
200
  }),
201
+ // Rounded box: ONE hand-meshed ring stack (no booleans) whose cross-
202
+ // sections follow the normative formula (rounded-solids.js / the design
203
+ // spec). Reuses loftMesh's stitch/cap/winding machinery. Atomic cache
204
+ // node hashed from its own args, like boredCylinder.
205
+ roundedBox: ({ size, center, round }) => cached(
206
+ h("roundedBox", size, center, round.side, round.top, round.bottom, segs),
207
+ () => {
208
+ const solid = T(loftMesh(wasm, roundedBoxRings(size, round, segs)));
209
+ return center ? T(solid.translate([0, 0, -size[2] / 2])) : solid;
210
+ }),
200
211
  sphere: (r) => wrap(T(Manifold.sphere(r, segs)), h("sphere", r, segs)),
201
212
  box: (min, max) => {
202
213
  const cube = T(Manifold.cube([max[0] - min[0], max[1] - min[1], max[2] - min[2]]));
@@ -26,6 +26,7 @@ import { classifyFaceGroups } from "./feature-attribution.js";
26
26
  import { resolveRings } from "./loft.js";
27
27
  import { resolveSweepStations } from "./sweep.js";
28
28
  import { normalizeProfile } from "./profile.js";
29
+ import { roundedRectContour } from "./rounded-solids.js";
29
30
  import { h } from "./solid-hash.js";
30
31
  import { createSolidCache } from "./solid-cache.js";
31
32
  import { composePose, transformPositions } from "./pose.js";
@@ -34,7 +35,7 @@ const MESH = { preview: { tolerance: 0.1, angularTolerance: 0.5 }, print: { tole
34
35
 
35
36
  export function createOcctKernel(replicad) {
36
37
  const { makeCylinder, makeBox, makeCircle, makeHelix, assembleWire, genericSweep,
37
- makeCompound, loft, draw, exportSTEP, measureVolume, makeSphere, makeLine, Plane } = replicad;
38
+ loft, draw, exportSTEP, measureVolume, makeSphere, makeLine, Plane } = replicad;
38
39
 
39
40
  // Fillet/chamfer/shell failure recovery (skip-on-failure, chamfer binary search) —
40
41
  // see occt-repair.js for the policies and why they differ per op.
@@ -127,8 +128,12 @@ export function createOcctKernel(replicad) {
127
128
  const key = h("cutAll", hash, tools.map((t) => t._hash));
128
129
  return cached(key, () => {
129
130
  const a = mat(), bs = tools.map((t) => t._mat());
131
+ if (bs.length === 0) return wrap(a._s.clone(), cloneLabels(a._labels), key);
132
+ const fusedTools = bs
133
+ .slice(1)
134
+ .reduce((acc, b) => acc.fuse(b._s.clone()), bs[0]._s.clone());
130
135
  return wrap(
131
- a._s.clone().cut(makeCompound(bs.map((b) => b._s.clone()))),
136
+ a._s.clone().cut(fusedTools),
132
137
  [...cloneLabels(a._labels), ...bs.flatMap((b) => cloneLabels(b._labels))],
133
138
  key,
134
139
  );
@@ -210,8 +215,10 @@ export function createOcctKernel(replicad) {
210
215
  });
211
216
  },
212
217
  volume: () => measureVolume(mat()._s),
213
- toIndexedMesh: () => {
214
- const base = baseMesh("preview");
218
+ // Same default as toSTL: an export is an export, so a .3mf must not ship a
219
+ // coarser tessellation than the .stl of the same solid would.
220
+ toIndexedMesh: ({ quality = "print" } = {}) => {
221
+ const base = baseMesh(quality);
215
222
  return { positions: posedPositions(base), indices: Uint32Array.from(base.indices) };
216
223
  },
217
224
  });
@@ -230,6 +237,53 @@ export function createOcctKernel(replicad) {
230
237
  });
231
238
  };
232
239
 
240
+ // Rounded box. side > 0: an arc-exact rounded-rect extrusion (real CIRCLE
241
+ // wall edges in STEP) with each rim rounded by ONE native fillet on its
242
+ // smooth rim loop — exact torus/sphere corner patches, matching the mesh
243
+ // backend's ring semantics by construction. side = 0: rim round-overs are
244
+ // CUT with strip-minus-quarter-cylinder wedges running the full edge
245
+ // length, so corners are the deterministic intersection of adjacent
246
+ // round-overs (native fillet's vertex blend is kernel-specific and the
247
+ // mesh backend could not reproduce it — see the design spec).
248
+ const roundedBox = ({ size, center, round }) => {
249
+ const [w, d, hgt] = size;
250
+ const { side, top, bottom } = round;
251
+ const key = h("roundedBox", size, center, side, top, bottom);
252
+ return cached(key, () => {
253
+ const z0 = center ? -hgt / 2 : 0;
254
+ if (side > 0) {
255
+ let s = contourDrawing(roundedRectContour(w, d, side)).sketchOnPlane("XY", z0).extrude(hgt);
256
+ // A rim fillet can only remove material, never add it — OCCT's fillet is
257
+ // known to produce invalid-but-nonempty geometry at certain degenerate
258
+ // boundaries (e.g. rim radius == side on a stadium profile), so gate
259
+ // acceptance on strict volume reduction rather than just "non-empty".
260
+ const isRimFilletValid = (vol, before) => vol > 0 && vol < before - 1e-9;
261
+ if (top > 0) s = safeOp(s, (sh) => sh.fillet(top, (e) => e.inPlane("XY", z0 + hgt)), `fillet(${top})`, isRimFilletValid);
262
+ if (bottom > 0) s = safeOp(s, (sh) => sh.fillet(bottom, (e) => e.inPlane("XY", z0)), `fillet(${bottom})`, isRimFilletValid);
263
+ return wrap(s, [], key);
264
+ }
265
+ let s = makeBox([-w / 2, -d / 2, z0], [w / 2, d / 2, z0 + hgt]);
266
+ for (const [rim, zFace, into] of [[top, z0 + hgt, -1], [bottom, z0, 1]]) {
267
+ if (!(rim > 0)) continue;
268
+ const zAxis = zFace + into * rim;
269
+ const zMin = Math.min(zFace, zAxis), zMax = Math.max(zFace, zAxis);
270
+ for (const sy of [1, -1]) { // ±Y walls: strip + quarter-cylinder axis along X
271
+ const strip = makeBox([-w / 2, sy > 0 ? d / 2 - rim : -d / 2, zMin],
272
+ [w / 2, sy > 0 ? d / 2 : -d / 2 + rim, zMax]);
273
+ const cyl = makeCylinder(rim, w + 4, [-w / 2 - 2, sy * (d / 2 - rim), zAxis], [1, 0, 0]);
274
+ s = s.cut(strip.cut(cyl));
275
+ }
276
+ for (const sx of [1, -1]) { // ±X walls: strip + axis along Y
277
+ const strip = makeBox([sx > 0 ? w / 2 - rim : -w / 2, -d / 2, zMin],
278
+ [sx > 0 ? w / 2 : -w / 2 + rim, d / 2, zMax]);
279
+ const cyl = makeCylinder(rim, d + 4, [sx * (w / 2 - rim), -d / 2 - 2, zAxis], [0, 1, 0]);
280
+ s = s.cut(strip.cut(cyl));
281
+ }
282
+ }
283
+ return wrap(s, [], key);
284
+ });
285
+ };
286
+
233
287
  // Draw a closed Drawing from a Contour: a legacy 2-D point list (all straight edges,
234
288
  // the former polyDrawing) OR an ArcContour whose { to, via } segments become true
235
289
  // OCCT arc edges via threePointsArcTo — so a rounded corner survives to STEP as a
@@ -435,6 +489,7 @@ export function createOcctKernel(replicad) {
435
489
  const kernel = finishKernel({
436
490
  cylinder, // boredCylinder: the kernel front's default composition is exactly right here
437
491
  box: (min, max) => cached(h("box", min, max), () => wrap(makeBox(min, max), [], h("box", min, max))),
492
+ roundedBox,
438
493
  prism, extrude, revolve, loft: loftOp, sweep, helixSweptTube,
439
494
  sphere: (r) => cached(h("sphere", r), () => wrap(makeSphere(r), [], h("sphere", r))),
440
495
  union: (solids) => {
@@ -74,13 +74,24 @@ export function createOcctRepair(measureVolume) {
74
74
  // or awkward edge interactions. Rather than letting the whole part vanish, attempt
75
75
  // the op on a clone and fall back to the original shape (feature skipped) on a
76
76
  // throw or empty result, with a console warning so it's discoverable.
77
- const safeOp = (shape, op, label) => {
77
+ //
78
+ // `isValid` is an optional second acceptance gate, evaluated ONLY when the op
79
+ // produces non-empty geometry: (resultVolume, inputVolume) => boolean. Some OCCT
80
+ // fillet failures don't throw or empty out — they return non-empty geometry that
81
+ // is nonetheless topologically invalid (e.g. a rim fillet on a stadium profile
82
+ // whose radius equals the straight-side radius can report MORE volume than the
83
+ // un-filleted input, which is impossible for a material-removing fillet). The
84
+ // default (no `isValid`) keeps every existing caller's behavior unchanged.
85
+ const safeOp = (shape, op, label, isValid) => {
78
86
  const backup = shape.clone();
87
+ const beforeVolume = isValid ? measureVolume(shape) : undefined;
79
88
  try {
80
89
  const result = op(shape);
81
- if (measureVolume(result) > 0) { backup.delete?.(); return result; }
90
+ const resultVolume = measureVolume(result);
91
+ if (resultVolume > 0 && (!isValid || isValid(resultVolume, beforeVolume))) { backup.delete?.(); return result; }
82
92
  result.delete?.();
83
- console.warn(`partforge: ${label} produced an empty solid — feature skipped (radius out of range?)`);
93
+ if (resultVolume > 0) console.warn(`partforge: ${label} produced invalid geometry — feature skipped`);
94
+ else console.warn(`partforge: ${label} produced an empty solid — feature skipped (radius out of range?)`);
84
95
  } catch (e) {
85
96
  console.warn(`partforge: ${label} failed (${e?.message || e}) — feature skipped`);
86
97
  }
@@ -126,6 +126,94 @@ export function sweepArgs(o) {
126
126
  ...tail(o, ["closed", "cornerRadius", "ruled", "smooth"])];
127
127
  }
128
128
 
129
+ // ---- rounded primitives (options-only ops) -------------------------------
130
+
131
+ // Clamp warnings are deduped per distinct message so a slider sweep doesn't
132
+ // spam the console on every rebuild. Console-only side channel; the returned
133
+ // geometry stays a pure function of the arguments.
134
+ const warnedClamps = new Set();
135
+
136
+ // `round` accepts a number (broadcast to every group) or a plain object with
137
+ // the op's group keys (missing keys → 0).
138
+ const normalizeRound = (op, v, keys) => {
139
+ if (typeof v === "number") return Object.fromEntries(keys.map((key) => [key, v]));
140
+ if (isPlainOptions(v)) {
141
+ checkKeys(`${op}: round`, v, keys);
142
+ return Object.fromEntries(keys.map((key) => [key, v[key] ?? 0]));
143
+ }
144
+ throw new Error(`${op}: round must be a number or { ${keys.join("?, ")}? }`);
145
+ };
146
+
147
+ const checkRoundRadius = (op, name, v, max, maxDesc) => {
148
+ if (!(typeof v === "number" && Number.isFinite(v) && v >= 0))
149
+ throw new Error(`${op}: ${name} must be a finite number ≥ 0`);
150
+ if (v > max + 1e-9) throw new Error(`${op}: ${name} (${v}) must be ≤ ${maxDesc}`);
151
+ };
152
+
153
+ export function roundedBoxArgs(o) {
154
+ checkKeys("roundedBox", o, ["size", "center", "round"]);
155
+ const size = req("roundedBox", o, "size");
156
+ if (!Array.isArray(size) || size.length !== 3 || !size.every((v) => Number.isFinite(v) && v > 0))
157
+ throw new Error("roundedBox: size must be [w, d, h] with three positive numbers");
158
+ const [w, d, h] = size;
159
+ const round = normalizeRound("roundedBox", req("roundedBox", o, "round"), ["side", "top", "bottom"]);
160
+ checkRoundRadius("roundedBox", "round.side", round.side, Math.min(w, d) / 2, "min(w, d)/2");
161
+ checkRoundRadius("roundedBox", "round.top", round.top, Math.min(w, d) / 2, "min(w, d)/2");
162
+ checkRoundRadius("roundedBox", "round.bottom", round.bottom, Math.min(w, d) / 2, "min(w, d)/2");
163
+ // Normalize the +1e-9 validation slack above to the exact stadium bound, so
164
+ // Manifold and OCCT build from bit-identical arguments at that boundary.
165
+ round.side = Math.min(round.side, Math.min(w, d) / 2);
166
+ // Middle regime (0 < side < rim): rims clamp DOWN to side — side defines the
167
+ // footprint and must not grow; a shrunk round-over only adds material. The
168
+ // side = 0 sphere-free rim round-over stays fully valid (see the spec).
169
+ if (round.side > 0) {
170
+ for (const key of ["top", "bottom"]) {
171
+ if (round[key] > round.side) {
172
+ // Keyed on op/field/side rather than the full message — a slider sweep
173
+ // over the raw rim value (round[key]) would otherwise mint a distinct
174
+ // message (and a distinct Set entry) on every rebuild, defeating the dedupe.
175
+ const dedupeKey = `roundedBox.${key}|${round.side}`;
176
+ const msg = `roundedBox: round.${key} ${round[key]} clamped to round.side ${round.side} (side must be 0 or ≥ rim radii; use side: 0 for a rim-only round-over)`;
177
+ if (!warnedClamps.has(dedupeKey)) { warnedClamps.add(dedupeKey); console.warn(msg); }
178
+ round[key] = round.side;
179
+ }
180
+ }
181
+ }
182
+ if (round.top + round.bottom > h + 1e-9)
183
+ throw new Error("roundedBox: round.top + round.bottom must be ≤ h");
184
+ if (round.side > 0 && round.top + round.bottom > h - 1e-6)
185
+ throw new Error("roundedBox: with round.side > 0, round.top + round.bottom must be < h (the rim fillets would meet tangentially; reduce the rim radii slightly, or use side: 0 for a sharp-sided full-height round-over)");
186
+ return [{ size: [w, d, h], center: o.center === true, round }];
187
+ }
188
+
189
+ export function roundedCylinderArgs(o) {
190
+ checkKeys("roundedCylinder", o, ["r", "d", "h", "center", "round"]);
191
+ const hasR = o.r !== undefined;
192
+ if (hasR === (o.d !== undefined)) throw new Error("roundedCylinder: pass exactly one of r/d");
193
+ const r = hasR ? o.r : o.d / 2;
194
+ if (!(Number.isFinite(r) && r > 0)) throw new Error("roundedCylinder: r must be > 0");
195
+ const h = req("roundedCylinder", o, "h");
196
+ if (!(Number.isFinite(h) && h > 0)) throw new Error("roundedCylinder: h must be > 0");
197
+ const round = normalizeRound("roundedCylinder", req("roundedCylinder", o, "round"), ["top", "bottom"]);
198
+ checkRoundRadius("roundedCylinder", "round.top", round.top, r, "r");
199
+ checkRoundRadius("roundedCylinder", "round.bottom", round.bottom, r, "r");
200
+ // Normalize the +1e-9 validation slack to the exact bound (the roundedBox
201
+ // side clamp's twin) so the lathe profile never crosses the revolve axis.
202
+ round.top = Math.min(round.top, r);
203
+ round.bottom = Math.min(round.bottom, r);
204
+ if (round.top + round.bottom > h + 1e-9)
205
+ throw new Error("roundedCylinder: round.top + round.bottom must be ≤ h");
206
+ return [{ r, h, center: o.center === true, round }];
207
+ }
208
+
209
+ export function torusArgs(o) {
210
+ checkKeys("torus", o, ["rMajor", "rMinor"]);
211
+ const rMajor = req("torus", o, "rMajor"), rMinor = req("torus", o, "rMinor");
212
+ if (!(Number.isFinite(rMajor) && Number.isFinite(rMinor) && rMinor > 0 && rMinor < rMajor))
213
+ throw new Error("torus: requires 0 < rMinor < rMajor");
214
+ return [{ rMajor, rMinor }];
215
+ }
216
+
129
217
  // Per-op semantic validations, applied to the NORMALIZED positional args so
130
218
  // they cover both calling forms (these moved here from kernel-front.js).
131
219
  const checkScaleTop = (op) => (_profile, _h, opts) => {
@@ -151,7 +239,11 @@ export const KERNEL_OP_SPECS = {
151
239
  extrude: { toArgs: extrudeArgs, check: checkScaleTop("extrude") },
152
240
  revolve: { toArgs: revolveArgs, check: (pts) => {
153
241
  if (pts && pts._shape2d) {
154
- if (pts.boundingBox().min[0] < 0) throw new Error("revolve: profile radius must be ≥ 0");
242
+ // The B-rep backend's Drawing bounding box is tolerance-padded (1e-6 on
243
+ // every side, measured), so a lathe profile touching the revolve axis at
244
+ // x = 0 reports min[0] = -1e-6. Tolerate the padding; a real negative-
245
+ // radius profile still trips the check.
246
+ if (pts.boundingBox().min[0] < -1e-5) throw new Error("revolve: profile radius must be ≥ 0");
155
247
  return;
156
248
  }
157
249
  for (const [r] of pts) if (r < 0) throw new Error("revolve: profile radius must be ≥ 0");
@@ -161,6 +253,9 @@ export const KERNEL_OP_SPECS = {
161
253
  boredCylinder: { toArgs: passThrough("boredCylinder", ["od", "h", "bore"], ["od", "h", "bore"]) },
162
254
  helixSweptTube: { toArgs: passThrough("helixSweptTube",
163
255
  ["pathR", "profileR", "pitch", "turns", "z0", "lefthand"], ["pathR", "profileR", "pitch", "turns"]) },
256
+ roundedBox: { toArgs: roundedBoxArgs },
257
+ roundedCylinder: { toArgs: roundedCylinderArgs },
258
+ torus: { toArgs: torusArgs },
164
259
  };
165
260
 
166
261
  // Solid ops under the options convention; addSugar() wraps these when the
@@ -0,0 +1,126 @@
1
+ // src/framework/geometry/rounded-solids.js
2
+ // Pure geometry builders for the rounded 3-D primitives (roundedBox /
3
+ // roundedCylinder / torus). No WASM, no DOM — shared by the kernel front
4
+ // (lathe contours for the revolve-based ops) and the Manifold backend
5
+ // (roundedBox ring stack). Normative semantics: the design spec
6
+ // (docs/superpowers/specs/2026-07-30-rounded-primitives-design.md) — at
7
+ // height z the box cross-section is the rounded rect inset δ(z) with corner
8
+ // radius max(side − δ, ~0), δ tracing a quarter circle in each rim zone.
9
+
10
+ const COS45 = Math.SQRT1_2;
11
+
12
+ // Minimum corner radius standing in for a "sharp" ring corner: keeps every
13
+ // ring at the same vertex count (4·(A+1)) without coincident points, so the
14
+ // loft stitching never sees a degenerate quad. Far below mesh/print resolution.
15
+ export const EPS_R = 1e-6;
16
+
17
+ // One CCW rounded-rectangle ring: half-extents hw/hd, corner radius rc, A arc
18
+ // segments per corner → 4·(A+1) points. Corner centers sit at (±(hw−rc),
19
+ // ±(hd−rc)); the straight edges are implied between consecutive corner arcs.
20
+ // rc is clamped into [EPS_R, min(hw, hd) − EPS_R] so sharp corners and
21
+ // full-radius (stadium) corners never emit coincident points.
22
+ export function roundedRectRing(hw, hd, rc, A) {
23
+ const r = Math.min(Math.max(rc, EPS_R), Math.max(EPS_R, Math.min(hw, hd) - EPS_R));
24
+ const cx = hw - r, cy = hd - r;
25
+ const C = [[cx, cy], [-cx, cy], [-cx, -cy], [cx, -cy]];
26
+ const pts = [];
27
+ for (let q = 0; q < 4; q++) {
28
+ const a0 = (q * Math.PI) / 2; // corner (+,+) spans 0..90°, then CCW
29
+ for (let i = 0; i <= A; i++) {
30
+ const a = a0 + (i / A) * (Math.PI / 2);
31
+ pts.push([C[q][0] + r * Math.cos(a), C[q][1] + r * Math.sin(a)]);
32
+ }
33
+ }
34
+ return pts;
35
+ }
36
+
37
+ // Ring stack for the Manifold roundedBox: ascending-z loft ring specs
38
+ // [{ polygon, z }]. A (arc samples per corner AND z-stations per rim zone) is
39
+ // derived from the kernel's segs so the z-sampling matches the in-plane LOD.
40
+ // Consecutive duplicate stations (top + bottom = h) are deduped so the loft
41
+ // never sees a zero-height band.
42
+ export function roundedBoxRings([w, d, h], { side, top, bottom }, segs) {
43
+ const A = Math.max(2, Math.ceil(segs / 8));
44
+ const st = [];
45
+ const push = (z, delta) => {
46
+ const last = st[st.length - 1];
47
+ if (last && Math.abs(last.z - z) < 1e-9 && Math.abs(last.delta - delta) < 1e-9) return;
48
+ st.push({ z, delta });
49
+ };
50
+ if (bottom > 0) {
51
+ for (let i = 0; i <= A; i++) {
52
+ const phi = (i / A) * (Math.PI / 2);
53
+ push(bottom * (1 - Math.cos(phi)), bottom * (1 - Math.sin(phi)));
54
+ }
55
+ } else push(0, 0);
56
+ if (top > 0) {
57
+ for (let i = A; i >= 0; i--) {
58
+ const phi = (i / A) * (Math.PI / 2);
59
+ push(h - top * (1 - Math.cos(phi)), top * (1 - Math.sin(phi)));
60
+ }
61
+ } else push(h, 0);
62
+ return st.map(({ z, delta }) =>
63
+ ({ polygon: roundedRectRing(w / 2 - delta, d / 2 - delta, side - delta, A), z }));
64
+ }
65
+
66
+ // ArcContour for the roundedCylinder lathe profile: the rectangle
67
+ // [0,0]→[r,0]→[r,h]→[0,h] with the two outer corners rounded (rBottom, rTop).
68
+ // Built with explicit tangent/via points — NOT roundedProfile, whose
69
+ // conservative per-corner clamp (tangent ≤ min-adjacent-edge/2) would
70
+ // silently shrink a capsule's full-radius corner. Zero-length lines are
71
+ // skipped so boundary radii (rBottom = r, rTop + rBottom = h) stay valid.
72
+ export function latheRoundedRect(r, h, rTop, rBottom) {
73
+ const start = [0, 0];
74
+ const segments = [];
75
+ let cur = start;
76
+ const lineTo = (p) => { if (Math.hypot(p[0] - cur[0], p[1] - cur[1]) > 1e-12) { segments.push({ to: p }); cur = p; } };
77
+ const arcTo = (to, via) => { segments.push({ to, via }); cur = to; };
78
+ lineTo([r - rBottom, 0]);
79
+ if (rBottom > 0)
80
+ arcTo([r, rBottom], [r - rBottom * (1 - COS45), rBottom * (1 - COS45)]);
81
+ lineTo([r, h - rTop]);
82
+ if (rTop > 0)
83
+ arcTo([r - rTop, h], [r - rTop * (1 - COS45), h - rTop * (1 - COS45)]);
84
+ lineTo([0, h]);
85
+ return { start, segments }; // consumers close() back down the revolve axis
86
+ }
87
+
88
+ // ArcContour for a rounded rectangle centered at the origin (the OCCT
89
+ // roundedBox base profile). Like latheRoundedRect, built with explicit
90
+ // tangent/via points and zero-length lines SKIPPED, so the exact stadium
91
+ // boundary (2·r = min(w, d), adjacent arcs meeting at the edge midpoint)
92
+ // stays a valid Drawing — roundedProfile emits zero-length segments there.
93
+ // Requires r > 0 (the side = 0 box never takes this path). The loop ends
94
+ // exactly on its start (close() is a no-op, like torusContour).
95
+ export function roundedRectContour(w, d, r) {
96
+ const hw = w / 2, hd = d / 2, c = r * (1 - COS45);
97
+ const start = [hw, -(hd - r)];
98
+ const segments = [];
99
+ let cur = start;
100
+ const lineTo = (p) => { if (Math.hypot(p[0] - cur[0], p[1] - cur[1]) > 1e-12) { segments.push({ to: p }); cur = p; } };
101
+ const arcTo = (to, via) => { segments.push({ to, via }); cur = to; };
102
+ lineTo([hw, hd - r]);
103
+ arcTo([hw - r, hd], [hw - c, hd - c]);
104
+ lineTo([-(hw - r), hd]);
105
+ arcTo([-hw, hd - r], [-(hw - c), hd - c]);
106
+ lineTo([-hw, -(hd - r)]);
107
+ arcTo([-(hw - r), -hd], [-(hw - c), -(hd - c)]);
108
+ lineTo([hw - r, -hd]);
109
+ arcTo([hw, -(hd - r)], [hw - c, -(hd - c)]);
110
+ return { start, segments };
111
+ }
112
+
113
+ // ArcContour for the torus lathe profile: a full circle of radius rMinor
114
+ // centered at [rMajor, 0], as four quarter arcs. The loop ends exactly on its
115
+ // start: replicad's close() skips the closing line when the pen is already
116
+ // home (verified in _closeSketch), and the Manifold tessellator's duplicated
117
+ // seam point is cleaned by Clipper2 (CrossSection.ofPolygons).
118
+ export function torusContour(rMajor, rMinor) {
119
+ const R = rMajor, r = rMinor, c = r * COS45;
120
+ return { start: [R + r, 0], segments: [
121
+ { to: [R, r], via: [R + c, c] },
122
+ { to: [R - r, 0], via: [R - c, c] },
123
+ { to: [R, -r], via: [R - c, -c] },
124
+ { to: [R + r, 0], via: [R + c, -c] },
125
+ ] };
126
+ }
@@ -19,6 +19,54 @@ const RELS =
19
19
  const xmlEsc = (s) => String(s).replace(/[<>&"]/g, (c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;", '"': "&quot;" }[c]));
20
20
  const r = (x) => +x.toFixed(4); // 0.1 µm precision — finer than any printer, much smaller XML
21
21
 
22
+ // Weld coincident vertices and drop triangles that collapse to zero area.
23
+ //
24
+ // This is what keeps a .3mf manifold, and it has no STL equivalent: STL is vertex
25
+ // soup, so a consumer re-stitches triangles by POSITION and never sees how the
26
+ // mesh was indexed. 3MF reads topology from the indices instead, so two triangles
27
+ // that meet along an edge must literally cite the same two vertex ids — otherwise
28
+ // each counts the shared edge as its own boundary and slicers report the solid as
29
+ // non-manifold. The OCCT backend triangulates every B-rep face independently and
30
+ // concatenates the results, so it hands us one copy of each seam vertex PER
31
+ // adjacent face; welding here is what closes that mesh. (Manifold's own output is
32
+ // already welded, so for that backend this is a no-op beyond the copy.)
33
+ //
34
+ // Welding uses `r` — exactly the precision the file is written at — so any two
35
+ // vertices that would print identical coordinates always collapse to one id. A
36
+ // looser tolerance would move geometry; a tighter one would leave split vertices
37
+ // sitting at coordinates the file cannot tell apart.
38
+ function weld(positions, indices) {
39
+ const idOf = new Map(); // "x,y,z" → canonical vertex id
40
+ const coord = []; // canonical id → rounded x,y,z (flat)
41
+ const canon = new Uint32Array(positions.length / 3);
42
+ for (let i = 0, v = 0; i < positions.length; i += 3, v++) {
43
+ const x = r(positions[i]), y = r(positions[i + 1]), z = r(positions[i + 2]);
44
+ const key = `${x},${y},${z}`;
45
+ let id = idOf.get(key);
46
+ if (id === undefined) { id = coord.length / 3; idOf.set(key, id); coord.push(x, y, z); }
47
+ canon[v] = id;
48
+ }
49
+ // Emit vertices in order of first use by a surviving triangle, so a vertex left
50
+ // behind by a dropped degenerate never ships as an unreferenced <vertex>.
51
+ const emitted = new Map(); // canonical id → written vertex index
52
+ const verts = [], tris = [];
53
+ const emit = (id) => {
54
+ let at = emitted.get(id);
55
+ if (at === undefined) {
56
+ at = verts.length / 3;
57
+ emitted.set(id, at);
58
+ verts.push(coord[id * 3], coord[id * 3 + 1], coord[id * 3 + 2]);
59
+ }
60
+ return at;
61
+ };
62
+ for (let k = 0; k < indices.length; k += 3) {
63
+ const a = canon[indices[k]], b = canon[indices[k + 1]], c = canon[indices[k + 2]];
64
+ if (a === b || b === c || a === c) continue; // zero area at print precision — carries no surface
65
+ tris.push(emit(a), emit(b), emit(c));
66
+ }
67
+ return { verts, tris };
68
+ }
69
+
22
70
  // parts: [{ name, positions: Float32Array (x,y,z per vertex), indices: Uint32Array (3 per triangle) }]
23
71
  // → ArrayBuffer of the .3mf zip (millimetre units; one <object> + <build> item per part).
24
72
  export function meshTo3MF(parts) {
@@ -29,10 +77,9 @@ export function meshTo3MF(parts) {
29
77
  ];
30
78
  parts.forEach((p, i) => {
31
79
  out.push(`<object id="${i + 1}" type="model" name="${xmlEsc(p.name)}"><mesh><vertices>`);
32
- const v = p.positions;
33
- for (let k = 0; k < v.length; k += 3) out.push(`<vertex x="${r(v[k])}" y="${r(v[k + 1])}" z="${r(v[k + 2])}"/>`);
80
+ const { verts: v, tris: t } = weld(p.positions, p.indices);
81
+ for (let k = 0; k < v.length; k += 3) out.push(`<vertex x="${v[k]}" y="${v[k + 1]}" z="${v[k + 2]}"/>`);
34
82
  out.push("</vertices><triangles>");
35
- const t = p.indices;
36
83
  for (let k = 0; k < t.length; k += 3) out.push(`<triangle v1="${t[k]}" v2="${t[k + 1]}" v3="${t[k + 2]}"/>`);
37
84
  out.push("</triangles></mesh></object>");
38
85
  });
@@ -136,7 +136,7 @@ export async function handle(kernel, part, msg, post, opts = {}) {
136
136
  if (names.length === 0) throw new Error("no exportable parts selected");
137
137
  const meshes = names.map((name) => {
138
138
  onProgress(`building ${label(name)}`);
139
- const { positions, indices } = posed(name, "export", onProgress).toIndexedMesh();
139
+ const { positions, indices } = posed(name, "export", onProgress).toIndexedMesh({ quality: msg.quality ?? "print" });
140
140
  return { name: exportName(name), positions, indices };
141
141
  });
142
142
  onProgress("writing 3MF file");