partforge 0.36.1 → 0.38.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/docs/AUTHORING-PARTS.md +29 -2
- package/docs/ERROR-PATTERNS.md +18 -0
- package/docs/KERNEL-CONTRACT.md +38 -0
- package/package.json +1 -1
- package/src/app-demo.js +3 -1
- package/src/framework/geometry/kernel-front.js +11 -0
- package/src/framework/geometry/kernel.js +4 -0
- package/src/framework/geometry/manifold-backend.js +11 -0
- package/src/framework/geometry/occt-backend.js +49 -0
- package/src/framework/geometry/occt-repair.js +14 -3
- package/src/framework/geometry/op-options.js +96 -1
- package/src/framework/geometry/rounded-solids.js +126 -0
- package/src/framework/mount.js +6 -1
- package/src/framework/viewer.js +68 -12
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -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
|
-
**
|
|
546
|
-
|
|
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 })`.
|
|
@@ -723,6 +731,25 @@ returns instead:
|
|
|
723
731
|
Pass `onDownload({ data, filename, mime })` to `mount()` to receive the exported bytes
|
|
724
732
|
yourself (e.g. to download from a different origin) instead of partforge's own DOM download.
|
|
725
733
|
|
|
734
|
+
**Showcase capture (the mount handle).** The handle can also render the user's *current*
|
|
735
|
+
framing offscreen at a resolution independent of the window size and devicePixelRatio —
|
|
736
|
+
for gallery/preview images, where grabbing the live canvas would be capped at the viewer
|
|
737
|
+
pane's pixel size:
|
|
738
|
+
|
|
739
|
+
- `runtime.captureCurrent({ size = 2048, hideGrid = true, quality = 0.9 } = {}) → string | null` —
|
|
740
|
+
one offscreen render from the live camera's pose (position, up, and orbit target — not a
|
|
741
|
+
canonical pose) with the live viewport's aspect ratio, `size` px on the long edge
|
|
742
|
+
(clamped into `[256, maxTextureSize]`). Renders with 4× MSAA and the same
|
|
743
|
+
camera-relative capture lighting as `captureViews`, so the result is print-quality even
|
|
744
|
+
from a small window on a 1× display. Returns a `data:image/jpeg;base64,…` string, or
|
|
745
|
+
`null` when the runtime is disposed or nothing is built/visible yet — it never throws.
|
|
746
|
+
`hideGrid: false` keeps the floor grid so the capture matches the on-screen look
|
|
747
|
+
exactly. The live view is untouched: the camera never moves, and lights/grid/render
|
|
748
|
+
target are restored after the render.
|
|
749
|
+
- `runtime.captureViews(viewNames) → [{ view, dataUrl }]` — the canonical-angle
|
|
750
|
+
counterpart (fixed poses, framed to the visible assembly, 1024², grid hidden). Sized
|
|
751
|
+
for feeding a vision model, not for display; use `captureCurrent` for showcase images.
|
|
752
|
+
|
|
726
753
|
**The markup convention (`demo.html` is the canonical copy-me page):** `<body>` carries
|
|
727
754
|
`class="pf-shell"`, the flex row that lays the viewer column next to the rail. `#app`
|
|
728
755
|
(`class="pf-stage"`) *is* that viewer column, and now contains the floating chrome
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -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.
|
package/docs/KERNEL-CONTRACT.md
CHANGED
|
@@ -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`.
|
package/package.json
CHANGED
package/src/app-demo.js
CHANGED
|
@@ -9,7 +9,9 @@ import { mount } from "./framework/index.js";
|
|
|
9
9
|
// Dev-only example app for the demo part (a parametric spacer). Identical wiring to
|
|
10
10
|
// app.js — the only thing that differs per part is which definition you import and
|
|
11
11
|
// which worker entry you point at. `npm run dev`, then open /demo.html.
|
|
12
|
-
|
|
12
|
+
// Dev-only: the handle is stashed on window so scripts/check-app.mjs can drive
|
|
13
|
+
// the embedding contract (runtime.captureCurrent) the way an embedder would.
|
|
14
|
+
window.__pfRuntime = mount(demoPart, {
|
|
13
15
|
createWorker: (name) =>
|
|
14
16
|
new Worker(new URL("./demo-worker.js", import.meta.url), { type: "module", name }),
|
|
15
17
|
});
|
|
@@ -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";
|
|
@@ -236,6 +237,53 @@ export function createOcctKernel(replicad) {
|
|
|
236
237
|
});
|
|
237
238
|
};
|
|
238
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
|
+
|
|
239
287
|
// Draw a closed Drawing from a Contour: a legacy 2-D point list (all straight edges,
|
|
240
288
|
// the former polyDrawing) OR an ArcContour whose { to, via } segments become true
|
|
241
289
|
// OCCT arc edges via threePointsArcTo — so a rounded corner survives to STEP as a
|
|
@@ -441,6 +489,7 @@ export function createOcctKernel(replicad) {
|
|
|
441
489
|
const kernel = finishKernel({
|
|
442
490
|
cylinder, // boredCylinder: the kernel front's default composition is exactly right here
|
|
443
491
|
box: (min, max) => cached(h("box", min, max), () => wrap(makeBox(min, max), [], h("box", min, max))),
|
|
492
|
+
roundedBox,
|
|
444
493
|
prism, extrude, revolve, loft: loftOp, sweep, helixSweptTube,
|
|
445
494
|
sphere: (r) => cached(h("sphere", r), () => wrap(makeSphere(r), [], h("sphere", r))),
|
|
446
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/framework/mount.js
CHANGED
|
@@ -29,6 +29,7 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
|
|
|
29
29
|
return {
|
|
30
30
|
ready, dispose, setParams,
|
|
31
31
|
captureViews: (viewNames) => viewer.captureCanonicalViews(viewNames),
|
|
32
|
+
captureCurrent: (opts) => viewer.captureCurrent(opts),
|
|
32
33
|
listExportableParts,
|
|
33
34
|
exportParts,
|
|
34
35
|
};
|
|
@@ -63,10 +64,14 @@ function createCleanupStack() {
|
|
|
63
64
|
// mesh-validity cache, and the geometry workers. The app supplies `createWorker(name)`
|
|
64
65
|
// so Vite can bundle the worker (see geometry-service.js).
|
|
65
66
|
//
|
|
66
|
-
// Embedding contract (0.
|
|
67
|
+
// Embedding contract (0.38.0):
|
|
67
68
|
// const runtime = mount(part, { createWorker, elements, onBuild, onPick, onDownload });
|
|
68
69
|
// await runtime.ready; // first successful build of the default view
|
|
69
70
|
// runtime.setParams({ openAngle: 45 }); // programmatic edit; pose-only changes apply instantly
|
|
71
|
+
// runtime.captureCurrent({ size: 2048 }); // one offscreen render of the user's current
|
|
72
|
+
// // framing (live camera pose + viewport aspect) at the
|
|
73
|
+
// // given long-edge resolution → JPEG data URL, or null
|
|
74
|
+
// // when disposed / nothing built yet
|
|
70
75
|
// runtime.listExportableParts(); // [{ name, label }] — every exportable sub-part,
|
|
71
76
|
// // independent of the active view (for an embedder-drawn export UI)
|
|
72
77
|
// await runtime.exportParts({ parts: ["base"], format: "stl", onProgress });
|
package/src/framework/viewer.js
CHANGED
|
@@ -56,6 +56,36 @@ export function captureViewsFromScene(viewNames, { renderer, liveCamera, grid, b
|
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
// Render the LIVE camera's current framing offscreen, once, at a caller-chosen
|
|
60
|
+
// resolution — the showcase capture behind the runtime handle's captureCurrent.
|
|
61
|
+
// Same injected-renderer split as captureViewsFromScene so it runs without a GL
|
|
62
|
+
// context: pose comes from the live camera (never a canonical pose), the output
|
|
63
|
+
// long edge is `size` clamped into [256, maxTextureSize], and the short edge
|
|
64
|
+
// follows the live camera's aspect so the capture matches what the user framed.
|
|
65
|
+
export function captureCurrentFromScene(
|
|
66
|
+
{ size = 2048, hideGrid = true, quality = 0.9 } = {},
|
|
67
|
+
{ renderer, liveCamera, target, grid, maxTextureSize },
|
|
68
|
+
) {
|
|
69
|
+
const MIN_SIZE = 256;
|
|
70
|
+
// WebGL2 guarantees MAX_TEXTURE_SIZE >= 2048; only trust a larger reported cap.
|
|
71
|
+
const long = Math.min(Math.max(Math.round(size) || MIN_SIZE, MIN_SIZE), maxTextureSize ?? 2048);
|
|
72
|
+
const aspect = liveCamera.aspect || 1;
|
|
73
|
+
const width = aspect >= 1 ? long : Math.max(1, Math.round(long * aspect));
|
|
74
|
+
const height = aspect >= 1 ? Math.max(1, Math.round(long / aspect)) : long;
|
|
75
|
+
const before = liveCamera.position.clone();
|
|
76
|
+
const gridWasVisible = grid?.visible;
|
|
77
|
+
if (grid && hideGrid) grid.visible = false;
|
|
78
|
+
try {
|
|
79
|
+
return renderer.renderOffscreen(
|
|
80
|
+
{ position: liveCamera.position.toArray(), up: liveCamera.up.toArray(), target },
|
|
81
|
+
{ width, height, fov: liveCamera.fov, quality },
|
|
82
|
+
);
|
|
83
|
+
} finally {
|
|
84
|
+
if (grid && hideGrid) grid.visible = gridWasVisible;
|
|
85
|
+
liveCamera.position.copy(before); // belt-and-suspenders: never leak camera state
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
59
89
|
export function createViewer(container, part) {
|
|
60
90
|
const names = Object.keys(part.parts);
|
|
61
91
|
|
|
@@ -345,14 +375,22 @@ export function createViewer(container, part) {
|
|
|
345
375
|
const _rtSize = 1024;
|
|
346
376
|
let _rt = null;
|
|
347
377
|
let _capLights = null;
|
|
348
|
-
|
|
349
|
-
|
|
378
|
+
// Defaults reproduce the canonical-view capture exactly (1024² cached target,
|
|
379
|
+
// fov 45, quality 0.9). A custom size (captureCurrent) gets a fresh render
|
|
380
|
+
// target, disposed after the read — those captures are rare, so per-call
|
|
381
|
+
// allocation beats caching one target per size ever requested.
|
|
382
|
+
function renderOffscreen({ position, up, target },
|
|
383
|
+
{ width = _rtSize, height = _rtSize, fov = 45, quality = 0.9 } = {}) {
|
|
384
|
+
const cachedSize = width === _rtSize && height === _rtSize;
|
|
385
|
+
const rt = cachedSize
|
|
386
|
+
? (_rt = _rt ?? new THREE.WebGLRenderTarget(_rtSize, _rtSize, { samples: 4 }))
|
|
387
|
+
: new THREE.WebGLRenderTarget(width, height, { samples: 4 });
|
|
350
388
|
_capLights = _capLights ?? createCaptureLights();
|
|
351
|
-
const cam = new THREE.PerspectiveCamera(
|
|
389
|
+
const cam = new THREE.PerspectiveCamera(fov, width / height, 0.1, 1000);
|
|
352
390
|
cam.position.set(position[0], position[1], position[2]);
|
|
353
391
|
cam.up.set(up[0], up[1], up[2]);
|
|
354
392
|
cam.lookAt(target[0], target[1], target[2]);
|
|
355
|
-
const buf = new Uint8Array(
|
|
393
|
+
const buf = new Uint8Array(width * height * 4);
|
|
356
394
|
// Swap the world-fixed key/fill for the camera-relative pair, for this one render
|
|
357
395
|
// only. A DirectionalLight aims at its `target`, whose matrixWorld only updates
|
|
358
396
|
// while it is in the scene graph, so both go in and both come back out.
|
|
@@ -365,30 +403,31 @@ export function createViewer(container, part) {
|
|
|
365
403
|
liveLights.fill.visible = false;
|
|
366
404
|
scene.add(capKey, capKey.target, capFill, capFill.target);
|
|
367
405
|
try {
|
|
368
|
-
renderer.setRenderTarget(
|
|
406
|
+
renderer.setRenderTarget(rt);
|
|
369
407
|
renderer.render(scene, cam);
|
|
370
408
|
// render() resolves the multisample renderbuffer into the target texture, so this
|
|
371
409
|
// reads antialiased pixels.
|
|
372
|
-
renderer.readRenderTargetPixels(
|
|
410
|
+
renderer.readRenderTargetPixels(rt, 0, 0, width, height, buf);
|
|
373
411
|
} finally {
|
|
374
412
|
// Never leave the user's own view unlit or pointed at the offscreen target.
|
|
375
413
|
renderer.setRenderTarget(null);
|
|
376
414
|
scene.remove(capKey, capKey.target, capFill, capFill.target);
|
|
377
415
|
liveLights.key.visible = true;
|
|
378
416
|
liveLights.fill.visible = true;
|
|
417
|
+
if (!cachedSize) rt.dispose();
|
|
379
418
|
}
|
|
380
419
|
const canvas = document.createElement("canvas");
|
|
381
|
-
canvas.width =
|
|
420
|
+
canvas.width = width; canvas.height = height;
|
|
382
421
|
const ctx = canvas.getContext("2d");
|
|
383
|
-
const img = ctx.createImageData(
|
|
422
|
+
const img = ctx.createImageData(width, height);
|
|
384
423
|
// flip rows (GL origin is bottom-left)
|
|
385
|
-
for (let y = 0; y <
|
|
386
|
-
const src = (
|
|
387
|
-
img.data.set(buf.subarray(src, src +
|
|
424
|
+
for (let y = 0; y < height; y++) {
|
|
425
|
+
const src = (height - 1 - y) * width * 4;
|
|
426
|
+
img.data.set(buf.subarray(src, src + width * 4), y * width * 4);
|
|
388
427
|
}
|
|
389
428
|
srgbEncodeInPlace(img.data);
|
|
390
429
|
ctx.putImageData(img, 0, 0);
|
|
391
|
-
return canvas.toDataURL("image/jpeg",
|
|
430
|
+
return canvas.toDataURL("image/jpeg", quality);
|
|
392
431
|
}
|
|
393
432
|
|
|
394
433
|
// Render the canonical camera angles offscreen, framed to whatever is visible,
|
|
@@ -408,6 +447,22 @@ export function createViewer(container, part) {
|
|
|
408
447
|
});
|
|
409
448
|
}
|
|
410
449
|
|
|
450
|
+
// One offscreen render of the user's CURRENT framing (live camera pose +
|
|
451
|
+
// orbit target, live aspect) at a caller-chosen resolution — the showcase
|
|
452
|
+
// capture. Returns a JPEG data URL, or null when disposed / nothing visible.
|
|
453
|
+
function captureCurrent(opts) {
|
|
454
|
+
if (disposed) return null;
|
|
455
|
+
const box = getVisibleWorldBounds();
|
|
456
|
+
if (!box || box.isEmpty()) return null;
|
|
457
|
+
return captureCurrentFromScene(opts, {
|
|
458
|
+
renderer: { renderOffscreen },
|
|
459
|
+
liveCamera: camera,
|
|
460
|
+
target: controls.target.toArray(),
|
|
461
|
+
grid,
|
|
462
|
+
maxTextureSize: renderer.capabilities?.maxTextureSize,
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
|
|
411
466
|
// --- render loop ----------------------------------------------------------
|
|
412
467
|
renderer.setAnimationLoop(() => {
|
|
413
468
|
controls.update();
|
|
@@ -484,6 +539,7 @@ export function createViewer(container, part) {
|
|
|
484
539
|
subTriangles,
|
|
485
540
|
frame,
|
|
486
541
|
captureCanonicalViews,
|
|
542
|
+
captureCurrent,
|
|
487
543
|
setAutoRotate,
|
|
488
544
|
setTheme,
|
|
489
545
|
getCameraState,
|