partforge 0.82.0 → 0.83.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/AUTHORING-PARTS.md +36 -15
- package/docs/ERROR-PATTERNS.md +16 -1
- package/docs/KERNEL-CONTRACT.md +24 -8
- package/package.json +1 -1
- package/src/app-lofted-bottle.js +22 -0
- package/src/framework/geometry/kernel.js +1 -1
- package/src/framework/geometry/loft-rings.js +377 -0
- package/src/framework/geometry/loft.js +138 -61
- package/src/framework/geometry/manifold-backend.js +48 -6
- package/src/framework/geometry/mesh-build.js +16 -0
- package/src/framework/geometry/occt-backend.js +17 -6
- package/src/framework/geometry/profile.js +26 -18
- package/src/framework/geometry/shading-policy.js +14 -8
- package/src/framework/geometry/sweep.js +1 -1
- package/src/lofted-bottle-worker.js +3 -0
- package/src/parts/lofted-bottle.js +61 -0
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -315,7 +315,7 @@ and the detection rule.
|
|
|
315
315
|
| `k.box({ size, center? })` · `k.box({ min, max })` | `{size:[x,y,z]}` = centered X/Y, base at z=0 (`center:true` also centers Z); `{min,max}` = explicit `[x,y,z]` corners |
|
|
316
316
|
| `k.prism({ points, h, twist?, scaleTop? })` | extrude a 2-D polygon (or an **arc profile** from `roundedProfile`) from z=0; optional `twist` (degrees over the height) and `scaleTop` (uniform top taper: 1 straight, <1 taper in, 0 → point/cone) |
|
|
317
317
|
| `k.extrude({ profile, h, twist?, scaleTop? })` | extrude a **polygon-with-holes** region from z=0 in one op — `profile` is `{ outer, holes? }` where each contour is a points array **or an arc profile** (`roundedProfile`, for true STEP fillets), or a bare points array / arc profile for outer-only; same `twist`/`scaleTop` as `prism` (both backends) |
|
|
318
|
-
| `k.loft({ rings, ruled?, closed?, shading? })` | 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. `shading?: "smooth" \| "faceted"` overrides facet/smooth shading inference (default: <32-side rings shade as flat facets, drawing no same-surface lines at all — not even their own cap rims — though cut seams against other solids still draw; ≥32 sides shade smooth) |
|
|
318
|
+
| `k.loft({ rings, ruled?, closed?, shading? })` | stack polygon cross-sections into a solid — ruled walls between consecutive rings, capped ends (both backends; `closed:true` capless loops are Manifold-only). A ring's `polygon` may be a point list, `sides`+`radius`, a curve contour, or a single-region hole-free `Shape2D` (multi-region / holed shapes throw). Identical all-line rings are bit-identical on both backends (unchanged legacy). Identical curve-structure rings loft curve-natively on OCCT (STEP keeps true arcs) and facet at fixed LOD on Manifold; structurally different rings auto-resample to a common vertex count with a deterministic seam and share the same faceted STEP at sampling LOD on both backends. `ruled:false` (smooth C2 blend) is honoured only by OCCT/STEP export; the Manifold preview always shows faceted straight walls. `shading?: "smooth" \| "faceted"` overrides facet/smooth shading inference (point-ring default: <32-side rings shade as flat facets, drawing no same-surface lines at all — not even their own cap rims — though cut seams against other solids still draw; ≥32 sides shade smooth. Curve/resample rings shade by tessellation provenance — smooth only along smooth contour spans, with dividing lines at sharp corners and silhouette-kink rings; see the shading-intent note) |
|
|
319
319
|
| `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 |
|
|
320
320
|
| `k.sphere({ r\|d })` | sphere centred at the origin; bare `k.sphere(r)` also stays valid |
|
|
321
321
|
| `k.roundedBox({ size, center?, round })` | box with rounded edges — `round` = number (all edges) or `{ side?, top?, bottom? }` (vertical edges / rims); built as one hand-meshed ring stack (no booleans at all, cheaper than `fillet`'s cutters); `side` must be 0 or ≥ the rim radii (between clamps with a warning); with `side > 0`, `top + bottom` must be strictly `< h` |
|
|
@@ -326,16 +326,32 @@ and the detection rule.
|
|
|
326
326
|
| `k.screwSweep({ profile, pitch, turns, lefthand? })` | screw-motion sweep of an **axial** lathe profile `[[r, z], …]` (same convention as `k.revolve`) — threads, worms, helical ridges. `h = pitch · turns`. The profile's axial extent must not exceed `pitch`; a profile spanning exactly `pitch` must be **periodic** (first radius == last radius) and yields a complete threaded body with no boolean (both backends) |
|
|
327
327
|
| `k.union(solids[])` | boolean union |
|
|
328
328
|
|
|
329
|
-
**`loft` rings** — each ring is `{ polygon:[[x,y],…] | sides+radius, z, rotate?, scale? }`
|
|
330
|
-
(
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
329
|
+
**`loft` rings** — each ring is `{ polygon:[[x,y],…] | sides+radius | {start,segments} | Shape2D, z, rotate?, scale? }`
|
|
330
|
+
(`rotate` is degrees about Z, `scale` is a number or `[sx,sy]`). A ring's `polygon` may be:
|
|
331
|
+
- a point list `[[x,y],…]` — plain polygon
|
|
332
|
+
- `{sides, radius}` — shorthand for a regular polygon
|
|
333
|
+
- a curve contour `{start:[x,y], segments:[...]}` — from `roundedProfile` (true CIRCLE/CUBIC edges in STEP)
|
|
334
|
+
- a single-region hole-free `Shape2D` — from a 2-D boolean, fillet, or other shape operation
|
|
335
|
+
|
|
336
|
+
Rings with **identical all-line segment structure** (the same straight-sided shape at different z/scale/rotate) are
|
|
337
|
+
bit-identical on both backends — unchanged legacy behavior, parity by construction. When such a ring set mixes a
|
|
338
|
+
point-list ring with a Shape2D/contour-sourced one, start-vertex correspondence is not author-controlled (a Shape2D's
|
|
339
|
+
contour starts wherever its outline begins) — use per-ring `rotate`, or keep every ring the same form, to control twist
|
|
340
|
+
phase. Rings with **identical curve structure**
|
|
341
|
+
(containing arcs/Béziers, the same shape at different z/scale/rotate) loft curve-natively on OCCT (STEP keeps exact arc
|
|
342
|
+
edges), while a Manifold preview facets at fixed LOD. **Structurally different rings** (e.g. a square morphing to a circle,
|
|
343
|
+
or unequal-N point lists) auto-resample to a common vertex count in shared pure-JS code, with seam = the outermost +X-ray
|
|
344
|
+
crossing from each ring's centroid; use per-ring `rotate` to tune the twist phase. Every backend then lofts the identical
|
|
345
|
+
resampled point rings — parity by construction on both — and STEP is faceted at the sampling LOD.
|
|
346
|
+
|
|
347
|
+
Author rings CCW and ordered by ascending `z` (the `regularPolygon` / `polygon.js` helpers are already CCW);
|
|
348
|
+
loft self-corrects a fully-inverted result so CW-wound or descending-z rings still export a valid outward solid.
|
|
349
|
+
Multi-region or holed `Shape2D` throws — loft each region as its own solid and union the lofts, or cut holes from
|
|
350
|
+
the lofted solid after it closes.
|
|
351
|
+
|
|
352
|
+
**`sweep`** takes the same CCW `polygon.js` outline as its `profile` and a plain `[[x,y,z],…]` point list as its
|
|
353
|
+
`path`; the profile stays perpendicular to the path (a rotation-minimizing frame), with sharp mitered corners by
|
|
354
|
+
default or `cornerRadius` fillets. Worked snippets:
|
|
339
355
|
|
|
340
356
|
```js
|
|
341
357
|
// a square tube (extrude a region with a hole) — one op, no boolean cut
|
|
@@ -2530,10 +2546,15 @@ OCCT). Within one sub-part's build there is no per-op backend mixing.
|
|
|
2530
2546
|
|
|
2531
2547
|
**Shading intent.** The kernel decides what shades smooth and where edge lines
|
|
2532
2548
|
draw — spheres, cylinders and fillets are smooth by construction; boolean cut
|
|
2533
|
-
seams always shade hard and draw a line; a loft's facets shade flat
|
|
2534
|
-
rings have fewer than 32 sides
|
|
2535
|
-
|
|
2536
|
-
|
|
2549
|
+
seams always shade hard and draw a line; a point-ring loft's facets shade flat
|
|
2550
|
+
when its rings have fewer than 32 sides. A curve or resample loft shades by
|
|
2551
|
+
**tessellation provenance**: only wall sections that came from a smoothly
|
|
2552
|
+
tessellated contour span (an arc/Bézier run) shade smooth, sharp contour
|
|
2553
|
+
corners and silhouette-kink rings (an abrupt direction change up the stack,
|
|
2554
|
+
like a belly break) flat-shade and draw a dividing line, and a morph's snapped
|
|
2555
|
+
corners do the same. `shading: "smooth"|"faceted"` on `k.loft` overrides all of
|
|
2556
|
+
this either way. If your part previews smooth but would print faceted — or the
|
|
2557
|
+
reverse — set the hint rather than changing facet counts.
|
|
2537
2558
|
|
|
2538
2559
|
> `partforge measure` reports `watertight`/`holes` as `n/a` for OCCT-run parts
|
|
2539
2560
|
> (Manifold-only topology); `render` works on both. Filleted parts now measure on
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -478,10 +478,25 @@ Variant literal for a curve-adjacent corner: `filletProfile: corner <i> at (<x>,
|
|
|
478
478
|
- **Symptom:** an intentionally faceted loft (low-side-count rings) previews
|
|
479
479
|
smooth-shaded, but exports/prints show flat facets.
|
|
480
480
|
- **Cause:** the loft's shading policy resolved to smooth — a `shading:
|
|
481
|
-
"smooth"` hint, `ruled: false`,
|
|
481
|
+
"smooth"` hint, `ruled: false`, rings with 32+ sides, or (for curve/resample
|
|
482
|
+
rings) the smooth-shaded section came from a smoothly tessellated contour
|
|
483
|
+
span — arcs/Béziers shade smooth per SECTOR, while sharp corners and
|
|
484
|
+
silhouette-kink rings flat-shade with a dividing line.
|
|
482
485
|
- **Fix:** pass `shading: "faceted"` to `k.loft` (or drop the smooth-implying
|
|
483
486
|
option) per [AUTHORING-PARTS.md](AUTHORING-PARTS.md) shading-intent note.
|
|
484
487
|
|
|
488
|
+
## loft-ring-multi-region-shape2d
|
|
489
|
+
|
|
490
|
+
- **Symptom:** `loft: ring 0 is a Shape2D with 2 regions — a loft ring must be a single closed outline (union the regions into one, or loft each separately)`
|
|
491
|
+
- **Cause:** the Shape2D handed to a loft ring holds several disjoint outlines (usually the result of a union that never overlapped).
|
|
492
|
+
- **Fix:** loft each region as its own solid and union the lofts, or rebuild the profile so the outlines actually merge into one. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Geometry: the kernel / `Solid` API" (`loft` rings).
|
|
493
|
+
|
|
494
|
+
## loft-ring-has-holes
|
|
495
|
+
|
|
496
|
+
- **Symptom:** `loft: ring 0 has holes — loft rings must be hole-free outlines (cut the holes from the lofted solid instead)`
|
|
497
|
+
- **Cause:** the ring Shape2D has an inner contour (a `.cut()` inside the outline). Lofting hole tunnels needs its own correspondence and is not supported.
|
|
498
|
+
- **Fix:** loft the outer outline, then `.cut()` a second loft (or an extrusion) of the hole profile from the solid. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Geometry: the kernel / `Solid` API" (`loft` rings).
|
|
499
|
+
|
|
485
500
|
## duplicate-preset-name-throws
|
|
486
501
|
|
|
487
502
|
- **Symptom:** `duplicate preset name across sections:` thrown from verify/measure, naming the repeated preset (e.g. `duplicate preset name across sections: "Compact"`).
|
package/docs/KERNEL-CONTRACT.md
CHANGED
|
@@ -284,7 +284,7 @@ above. All ops return a `Solid`.
|
|
|
284
284
|
| `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). |
|
|
285
285
|
| `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`). |
|
|
286
286
|
| `revolve({profile, degrees?})` | Revolve a lathe profile `[[r, z], …]` (r ≥ 0) about Z; `degrees` < 360 gives a capped partial revolve. Default 360. |
|
|
287
|
-
| `loft({rings, ruled?, closed?})` | Stack
|
|
287
|
+
| `loft({rings, ruled?, closed?})` | Stack cross-sections along Z with ruled walls and capped ends (per-ring `z`/`rotate`/`scale`). A ring's `polygon` may be a point list, `sides`+`radius`, a curve contour, or a single-region hole-free `Shape2D` (multi-region / holed shapes throw). Rings with **identical all-line segment structure** (one straight-sided shape reused at different z/scale/rotate) loft **bit-identically** on both backends — parity by construction, unchanged legacy behavior. Rings with **identical curve structure** (containing arcs or Béziers, the same shape at different z/scale/rotate) loft curve-natively on a B-rep kernel — STEP keeps exact arc edges — while a mesh kernel facets the same sections at a fixed LOD (`hull`'s parity class). **Structurally different rings** (a rounded square morphing to a circle, unequal-N point lists) are arc-length-resampled once, in shared pure-JS code, to a common vertex count with a deterministic seam (the outermost +X-ray crossing from each ring's centroid; per-ring `rotate` tunes the phase) and snapped corners — every backend then lofts the **identical** resampled point rings, so the result is parity **by construction** and STEP is faceted at the sampling LOD. Must self-correct a fully inverted result (CW rings / descending z) to an outward solid. |
|
|
288
288
|
| `sweep({profile, path, closed?, cornerRadius?, ruled?, smooth?})` | Sweep a fixed CCW profile along a polyline with a rotation-minimizing frame; sharp mitered corners, or `cornerRadius` fillets; capped ends. |
|
|
289
289
|
| `helixSweptTube({pathR, profileR, pitch, turns, z0, lefthand})` | Circle of radius `profileR` swept along a helix (e.g. a rope groove). Circular profile on a frenet frame that rolls with the helix — **not for threads**; use `screwSweep`. |
|
|
290
290
|
| `screwSweep({profile, pitch, turns, lefthand})` | Screw-motion sweep of an axial lathe profile `[[r, z], …]` (r ≥ 0) — threads. The profile travels to `(r·cosθ, r·sinθ, z + pitch·θ/2π)`; `h = pitch · turns`. Axial extent must not exceed `pitch` or consecutive turns interpenetrate (throws). A profile spanning exactly `pitch` is **periodic**: first and last radius must agree, and it yields a complete threaded body needing no boolean. Compound: the polar-remapped, densified section extruded with `twist = 360 · turns`, exactly as composed in `kernel-front.js`; a backend may override only for caching, never for different geometry. Options-only. Parity: **within tolerance, not by construction** — both backends receive the identical densified polygon, but the mesh backend facets the twist at its own resolution while the B-rep backend builds an exact spline (`hull`'s parity class). |
|
|
@@ -309,7 +309,10 @@ mesh backends** (Manifold); B-rep kernels throw a plain `Error` naming the limit
|
|
|
309
309
|
`smooth: true` (native swept B-rep) are honored only by B-rep kernels; mesh kernels
|
|
310
310
|
render the ruled form. `sweep` `closed: true` loops must be planar. Where both backends build the same
|
|
311
311
|
shape they do it **by construction, not by tolerance**: sweep elbows loft the identical
|
|
312
|
-
station list (`sweep.js`)
|
|
312
|
+
station list (`sweep.js`), and structurally-different loft rings loft the identical resampled ring list
|
|
313
|
+
(`loft-rings.js`) on both backends. Structurally-identical all-line rings remain bit-identical on both backends
|
|
314
|
+
(unchanged legacy behavior). Structurally-identical curve rings are the exception by design: the B-rep kernel keeps the
|
|
315
|
+
exact curves (STEP-exact) while a mesh kernel facets them — `hull`'s parity class.
|
|
313
316
|
|
|
314
317
|
### Rounded primitives
|
|
315
318
|
|
|
@@ -386,11 +389,20 @@ are present:
|
|
|
386
389
|
filtered out; Manifold ships policy-gated sharp/seam segments.
|
|
387
390
|
|
|
388
391
|
`loft` accepts `shading?: "smooth" | "faceted"` to override facet-vs-smooth
|
|
389
|
-
inference
|
|
390
|
-
facets with no same-surface edge lines, while
|
|
391
|
-
`ruled: false` lofts) shade smooth.
|
|
392
|
-
|
|
393
|
-
|
|
392
|
+
inference. Point-list (poly-exact) lofts infer as before: rings with fewer than
|
|
393
|
+
32 sides shade as intentional flat facets with no same-surface edge lines, while
|
|
394
|
+
rings with 32+ sides (and `ruled: false` lofts) shade smooth. Curve and resample
|
|
395
|
+
lofts shade by **tessellation provenance** instead of a single whole-solid
|
|
396
|
+
policy: the walls partition into shading sectors split at sharp contour joints
|
|
397
|
+
(and at snapped corners in a resample morph), and into band groups split at
|
|
398
|
+
silhouette-kink rings (wall direction bending more than the 5° tangent bar —
|
|
399
|
+
the same bar at which a B-rep loft's real ring edges draw lines). Sector and
|
|
400
|
+
band-group boundaries flat-shade and draw dividing lines regardless of bend
|
|
401
|
+
angle; only sectors whose facets come from smoothly tessellated curve spans
|
|
402
|
+
shade smooth inside. `shading: "smooth"` forces whole-solid smooth shading;
|
|
403
|
+
`shading: "faceted"` forces facets — either hint bypasses sectoring entirely;
|
|
404
|
+
any other non-nullish value throws. Thresholds live in
|
|
405
|
+
`src/framework/geometry/shading-policy.js`.
|
|
394
406
|
|
|
395
407
|
Known limitation: the OCCT backend ignores `shading` — a loft forced to OCCT
|
|
396
408
|
via `meta.backend` draws its facet corner edges as B-rep feature lines. The
|
|
@@ -401,7 +413,11 @@ collapses it to a single shading surface that inherits the majority policy of
|
|
|
401
413
|
its registered constituent surfaces, weighted by triangle count. A constituent
|
|
402
414
|
with no registered policy of its own (e.g. a plain boolean tool) still votes,
|
|
403
415
|
as SMOOTH — the policy it actually renders with — and an exact tie resolves to
|
|
404
|
-
the no-lines (faceted) policy.
|
|
416
|
+
the no-lines (faceted) policy. Two exceptions preserve their surface partition
|
|
417
|
+
through labeling instead of collapsing: fillet/chamfer blend bands (base +
|
|
418
|
+
blend re-stamp as two surfaces), and provenance-sectored lofts (every sector
|
|
419
|
+
and band-group run re-stamps 1:1) — in both cases all the resulting surfaces
|
|
420
|
+
carry the one label, so hover/pick still reads a single feature.
|
|
405
421
|
|
|
406
422
|
**Selectors** (`fillet`/`chamfer` `edges` selector, `shell` `open` face selector) are
|
|
407
423
|
declarative objects, criteria AND-combined:
|
package/package.json
CHANGED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Self-hosted Geist + Geist Mono for the dev demos, so a standalone forge looks
|
|
2
|
+
// like the product. Dev-only: --pf-sans/--pf-mono fall back to system stacks for
|
|
3
|
+
// any consumer that doesn't load them (spec §2.2).
|
|
4
|
+
import "@fontsource-variable/geist";
|
|
5
|
+
import "@fontsource-variable/geist-mono";
|
|
6
|
+
import loftedBottlePart from "./parts/lofted-bottle.js";
|
|
7
|
+
import { mount } from "./framework/index.js";
|
|
8
|
+
|
|
9
|
+
// Dev-only example app for the lofted-bottle part (the Shape2D-loft reference part).
|
|
10
|
+
// Identical wiring to app.js — the only thing that differs per part is which definition
|
|
11
|
+
// you import and which worker entry you point at. `npm run dev`, then open
|
|
12
|
+
// /lofted-bottle.html.
|
|
13
|
+
// Dev-only: the handle is stashed on window so scripts/check-app.mjs can drive
|
|
14
|
+
// the embedding contract (runtime.captureCurrent) the way an embedder would.
|
|
15
|
+
window.__pfRuntime = mount(loftedBottlePart, {
|
|
16
|
+
createWorker: (name) =>
|
|
17
|
+
new Worker(new URL("./lofted-bottle-worker.js", import.meta.url), { type: "module", name }),
|
|
18
|
+
onAnnotationSend: (payload) => {
|
|
19
|
+
window.__pfLastAnnotation = payload;
|
|
20
|
+
console.log("annotation payload", payload);
|
|
21
|
+
},
|
|
22
|
+
});
|
|
@@ -142,7 +142,7 @@ export const ROUTED_CAD_OPS = ["shell"];
|
|
|
142
142
|
* @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 for now (see file header)
|
|
143
143
|
* @property {(o:{points:number[][],h:number,twist?:number,scaleTop?:number}) => Solid} prism extrude polygon from z=0; legacy (points,h,opts) accepted for now (see file header)
|
|
144
144
|
* @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 for now (see file header)
|
|
145
|
-
* @property {(o:{rings:{polygon?:number[][],sides?:number,radius?:number,z:number,rotate?:number,scale?:number|number[]}[],ruled?:boolean,closed?:boolean,shading?:"smooth"|"faceted"}) => Solid} loft stack polygon cross-sections; shading overrides facet-vs-smooth shading inference; legacy (rings,opts) accepted for now (see file header)
|
|
145
|
+
* @property {(o:{rings:{polygon?:number[][]|{start:number[],segments:object[]}|Shape2D,sides?:number,radius?:number,z:number,rotate?:number,scale?:number|number[]}[],ruled?:boolean,closed?:boolean,shading?:"smooth"|"faceted"}) => Solid} loft stack polygon cross-sections; polygon accepts point lists, curve contours, or hole-free Shape2D; shading overrides facet-vs-smooth shading inference; legacy (rings,opts) accepted for now (see file header)
|
|
146
146
|
* @property {(o:{profile:number[][],path:number[][],closed?:boolean,cornerRadius?:number,ruled?:boolean,smooth?:boolean}) => Solid} sweep sweep a 2-D profile along a 3-D polyline; legacy (profile,path,opts) accepted for now (see file header)
|
|
147
147
|
* @property {(o:{profile:number[][],degrees?:number}) => Solid} revolve revolve a lathe profile [[r,z],…] around Z; legacy (points,opts) accepted for now (see file header)
|
|
148
148
|
* @property {(o:{pathR:number,profileR:number,pitch:number,turns:number,z0:number,lefthand:boolean}) => Solid} helixSweptTube
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
// Shared Shape2D/curve-aware loft ring resolution — the pure-JS leaf both backends
|
|
2
|
+
// call before touching a kernel. Lifts every accepted ring form to the curve-contour
|
|
3
|
+
// IR, bakes each ring's scale-then-rotate(Z) transform ONCE, and classifies the loft
|
|
4
|
+
// into one of three modes (see docs/superpowers/plans/2026-08-23-shape2d-loft-design.md):
|
|
5
|
+
// poly-exact — all-line identical signatures: today's path, bit-for-bit;
|
|
6
|
+
// curve — identical signatures with arcs/cubics: matched per-segment sampling
|
|
7
|
+
// (Manifold) + original curve wires (OCCT, STEP-exact);
|
|
8
|
+
// resample — structurally different rings: shared arc-length resample, both
|
|
9
|
+
// backends loft the IDENTICAL rings (parity by construction).
|
|
10
|
+
import { regularPolygon } from "./polygon.js";
|
|
11
|
+
import { pointsToContour, reverseContour, closeContourGap, arcGeometry, sampleBezier, tessellateContour } from "./profile.js";
|
|
12
|
+
import { rotateProfile, scaleProfile, contourIsCCW, cubicAt, profileCorners } from "./contour-ops.js";
|
|
13
|
+
import { SMOOTH_SIDES_MIN } from "./shading-policy.js";
|
|
14
|
+
|
|
15
|
+
export const LOFT_SEGS = 64; // fixed pure-JS LOD for curve rings (hull.js precedent)
|
|
16
|
+
|
|
17
|
+
const isPointList = (x) => Array.isArray(x) && Array.isArray(x[0]);
|
|
18
|
+
const isContour = (x) => x && !Array.isArray(x) && Array.isArray(x.segments);
|
|
19
|
+
|
|
20
|
+
// Legacy transform bake for point rings — EXACTLY resolveRings' math, kept verbatim so
|
|
21
|
+
// every existing part's loft stays bit-identical (mesh-fillet tools, rim-bevel, roundedBox).
|
|
22
|
+
const bakePts = (pts, r) => {
|
|
23
|
+
const s = r.scale ?? 1;
|
|
24
|
+
const [sx, sy] = Array.isArray(s) ? s : [s, s];
|
|
25
|
+
const rot = ((r.rotate ?? 0) * Math.PI) / 180, cos = Math.cos(rot), sin = Math.sin(rot);
|
|
26
|
+
return pts.map(([x, y]) => {
|
|
27
|
+
const X = x * sx, Y = y * sy;
|
|
28
|
+
return [X * cos - Y * sin, X * sin + Y * cos];
|
|
29
|
+
});
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Contour bake: scale about origin then rotate about origin — the same composite map,
|
|
33
|
+
// applied through contour-ops so arcs survive similarity maps exactly and become
|
|
34
|
+
// cubics under non-uniform scale (transformContour's rule).
|
|
35
|
+
const bakeContour = (contour, r) => {
|
|
36
|
+
let c = closeContourGap(contour); // Ensure explicit closing so signature is consistent
|
|
37
|
+
const s = r.scale ?? 1;
|
|
38
|
+
if (!(s === 1 || (Array.isArray(s) && s[0] === 1 && s[1] === 1))) c = scaleProfile(c, s, [0, 0]);
|
|
39
|
+
if ((r.rotate ?? 0) !== 0) c = rotateProfile(c, r.rotate, [0, 0]);
|
|
40
|
+
return contourIsCCW(c) ? c : reverseContour(c);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export function liftLoftRings(rings) {
|
|
44
|
+
if (!Array.isArray(rings) || rings.length < 2)
|
|
45
|
+
throw new Error("loft: rings must be an array of at least 2 rings");
|
|
46
|
+
return rings.map((r, i) => {
|
|
47
|
+
if (!r || typeof r !== "object") throw new Error(`loft: ring ${i} must be an object { polygon|sides+radius, z }`);
|
|
48
|
+
if (!Number.isFinite(r.z)) throw new Error(`loft: ring ${i} needs a finite z`);
|
|
49
|
+
let poly = r.polygon;
|
|
50
|
+
if (poly && poly._shape2d) {
|
|
51
|
+
const regions = poly._regions;
|
|
52
|
+
if (regions.length === 0) throw new Error(`loft: ring ${i} is an empty Shape2D — nothing to loft`);
|
|
53
|
+
if (regions.length > 1) throw new Error(
|
|
54
|
+
`loft: ring ${i} is a Shape2D with ${regions.length} regions — a loft ring must be a single closed outline (union the regions into one, or loft each separately)`);
|
|
55
|
+
if (regions[0].holes.length > 0) throw new Error(
|
|
56
|
+
`loft: ring ${i} has holes — loft rings must be hole-free outlines (cut the holes from the lofted solid instead)`);
|
|
57
|
+
poly = JSON.parse(JSON.stringify(regions[0].outer));
|
|
58
|
+
}
|
|
59
|
+
if (!poly && Number.isFinite(r.sides) && Number.isFinite(r.radius)) poly = regularPolygon(r.sides, r.radius);
|
|
60
|
+
if (isContour(poly)) {
|
|
61
|
+
const contour = bakeContour(poly, r);
|
|
62
|
+
const allLines = contour.segments.every((s) => !s.via && !s.c1);
|
|
63
|
+
if (allLines && contour.segments.length < 3)
|
|
64
|
+
throw new Error(`loft: ring ${i}'s contour has only ${contour.segments.length} line segment(s) — an all-line contour needs at least 3 to close a polygon (a curved contour, e.g. a circle, may legitimately have fewer)`);
|
|
65
|
+
return { raw: r, contour, pts: null, z: r.z };
|
|
66
|
+
}
|
|
67
|
+
if (!isPointList(poly) || poly.length < 3)
|
|
68
|
+
throw new Error(`loft: ring ${i} needs polygon:[[x,y],…] (≥3 points), a curve contour, a Shape2D, or sides+radius shorthand`);
|
|
69
|
+
const pts = bakePts(poly, r);
|
|
70
|
+
return { raw: r, contour: bakeContour(pointsToContour(poly), r), pts, z: r.z };
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const signatureOf = (contour) => contour.segments.map((s) => (s.c1 ? "C" : s.via ? "A" : "L")).join("");
|
|
75
|
+
|
|
76
|
+
export function classifyLoftRings(lifted) {
|
|
77
|
+
const sigs = lifted.map((r) => signatureOf(r.contour));
|
|
78
|
+
const hasCurve = sigs.some((s) => /[AC]/.test(s));
|
|
79
|
+
const identical = sigs.every((s) => s === sigs[0]);
|
|
80
|
+
// Identical all-line signatures imply equal vertex counts (an N-point ring lifts to
|
|
81
|
+
// exactly N line segments), so this IS today's equal-N legacy case, bit-for-bit.
|
|
82
|
+
if (identical && !hasCurve) return { mode: "poly-exact", hasCurve: false };
|
|
83
|
+
if (identical) return { mode: "curve", hasCurve };
|
|
84
|
+
return { mode: "resample", hasCurve };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Cache-key form of a ring list: replace live Shape2D values with their content hash
|
|
88
|
+
// so h()'s canonical serializer never walks a shape's methods.
|
|
89
|
+
export function loftRingsKey(rings) {
|
|
90
|
+
if (!Array.isArray(rings)) return rings;
|
|
91
|
+
return rings.map((r) => (r && typeof r === "object"
|
|
92
|
+
? { ...r, polygon: r.polygon && r.polygon._shape2d ? "s2d:" + r.polygon._hash : r.polygon }
|
|
93
|
+
: r));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Natural facet count a segment would get at LOFT_SEGS — the per-segment budget the
|
|
97
|
+
// matched sampler levels up to across rings.
|
|
98
|
+
const segNaturalCount = (prev, seg) => {
|
|
99
|
+
if (seg.c1) return Math.max(1, sampleBezier(prev, seg.c1, seg.c2, seg.to, LOFT_SEGS).length);
|
|
100
|
+
if (seg.via) {
|
|
101
|
+
const g = arcGeometry(prev, seg.via, seg.to);
|
|
102
|
+
return g ? Math.max(2, Math.ceil((LOFT_SEGS * Math.abs(g.dA)) / (2 * Math.PI))) : 1;
|
|
103
|
+
}
|
|
104
|
+
return 1;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// Sample one segment with EXACTLY n points (uniform in angle/parameter), last point
|
|
108
|
+
// pinned to seg.to. Fixed counts are what keep corresponding vertices aligned across
|
|
109
|
+
// rings — the adaptive samplers must not be used here.
|
|
110
|
+
const sampleSegN = (prev, seg, n) => {
|
|
111
|
+
const out = [];
|
|
112
|
+
if (seg.c1) {
|
|
113
|
+
for (let s = 1; s <= n; s++) out.push(cubicAt(prev, seg.c1, seg.c2, seg.to, s / n));
|
|
114
|
+
} else if (seg.via) {
|
|
115
|
+
const g = arcGeometry(prev, seg.via, seg.to);
|
|
116
|
+
if (!g) { for (let s = 1; s <= n; s++) out.push([prev[0] + (seg.to[0] - prev[0]) * (s / n), prev[1] + (seg.to[1] - prev[1]) * (s / n)]); }
|
|
117
|
+
else for (let s = 1; s <= n; s++) {
|
|
118
|
+
const ang = g.a0 + g.dA * (s / n);
|
|
119
|
+
out.push([g.cx + g.r * Math.cos(ang), g.cy + g.r * Math.sin(ang)]);
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
for (let s = 1; s <= n; s++) out.push([prev[0] + (seg.to[0] - prev[0]) * (s / n), prev[1] + (seg.to[1] - prev[1]) * (s / n)]);
|
|
123
|
+
}
|
|
124
|
+
out[out.length - 1] = [seg.to[0], seg.to[1]];
|
|
125
|
+
return out;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// Curve mode: identical signatures guaranteed by classifyLoftRings. Per segment index,
|
|
129
|
+
// every ring samples with the same count (the max natural count), so vertex i lies at
|
|
130
|
+
// the same curve parameter on every ring; the seam is each contour's start.
|
|
131
|
+
export function matchedTessellation(lifted) {
|
|
132
|
+
return matchedTessellationDetail(lifted).rings;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Detail form: also reports the shared per-segment sample counts, which the shading
|
|
136
|
+
// provenance below needs to map contour joints onto sample indices.
|
|
137
|
+
function matchedTessellationDetail(lifted) {
|
|
138
|
+
const segCount = lifted[0].contour.segments.length;
|
|
139
|
+
const counts = [];
|
|
140
|
+
for (let j = 0; j < segCount; j++) {
|
|
141
|
+
let n = 1, prevs = lifted.map((r) => (j === 0 ? r.contour.start : r.contour.segments[j - 1].to));
|
|
142
|
+
lifted.forEach((r, k) => { n = Math.max(n, segNaturalCount(prevs[k], r.contour.segments[j])); });
|
|
143
|
+
counts.push(n);
|
|
144
|
+
}
|
|
145
|
+
const rings = lifted.map((r) => {
|
|
146
|
+
const ring = [[r.contour.start[0], r.contour.start[1]]];
|
|
147
|
+
let prev = r.contour.start;
|
|
148
|
+
r.contour.segments.forEach((seg, j) => { for (const p of sampleSegN(prev, seg, counts[j])) ring.push(p); prev = seg.to; });
|
|
149
|
+
// stored contours close explicitly (last segment lands on start) — drop the closure
|
|
150
|
+
ring.pop();
|
|
151
|
+
return ring;
|
|
152
|
+
});
|
|
153
|
+
return { rings, counts };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const shoelace = (ring) => ring.reduce((a, [x, y], i) => {
|
|
157
|
+
const [nx, ny] = ring[(i + 1) % ring.length];
|
|
158
|
+
return a + x * ny - nx * y;
|
|
159
|
+
}, 0) / 2;
|
|
160
|
+
|
|
161
|
+
// Deterministic seam: the outermost crossing of the +X ray from the ring's centroid.
|
|
162
|
+
// Returns { edge, t } — a parametric position on the ring's edge list. Falls back to
|
|
163
|
+
// all crossings of the full horizontal line, then to vertex 0, so it is total.
|
|
164
|
+
const seamOf = (ring) => {
|
|
165
|
+
let cx = 0, cy = 0;
|
|
166
|
+
for (const [x, y] of ring) { cx += x; cy += y; }
|
|
167
|
+
cx /= ring.length; cy /= ring.length;
|
|
168
|
+
let best = null;
|
|
169
|
+
for (let pass = 0; pass < 2 && !best; pass++) {
|
|
170
|
+
for (let i = 0; i < ring.length; i++) {
|
|
171
|
+
const [px, py] = ring[i], [qx, qy] = ring[(i + 1) % ring.length];
|
|
172
|
+
if ((py <= cy) === (qy <= cy)) continue; // half-open: each crossing once
|
|
173
|
+
const t = (cy - py) / (qy - py);
|
|
174
|
+
const x = px + t * (qx - px);
|
|
175
|
+
if (pass === 0 && x <= cx) continue; // pass 0: +X ray only
|
|
176
|
+
if (!best || x > best.x) best = { edge: i, t, x };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return best ?? { edge: 0, t: 0, x: ring[0][0] };
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// Arc-length resample one CCW ring to N points starting at its seam, then snap each
|
|
183
|
+
// sharp corner onto its nearest sample (closest corner wins a contested sample).
|
|
184
|
+
const resampleRing = (ring, N, corners) => {
|
|
185
|
+
const seam = seamOf(ring);
|
|
186
|
+
const pts = [];
|
|
187
|
+
// unroll the ring into an open polyline starting exactly at the seam point
|
|
188
|
+
const start = [ring[seam.edge][0] + seam.t * (ring[(seam.edge + 1) % ring.length][0] - ring[seam.edge][0]),
|
|
189
|
+
ring[seam.edge][1] + seam.t * (ring[(seam.edge + 1) % ring.length][1] - ring[seam.edge][1])];
|
|
190
|
+
pts.push(start);
|
|
191
|
+
for (let k = 1; k <= ring.length; k++) {
|
|
192
|
+
const i = (seam.edge + k) % ring.length;
|
|
193
|
+
pts.push([ring[i][0], ring[i][1]]);
|
|
194
|
+
}
|
|
195
|
+
// pts is now start → all vertices → start's edge-begin; close it back to start
|
|
196
|
+
pts.push([start[0], start[1]]);
|
|
197
|
+
const cum = [0];
|
|
198
|
+
for (let i = 1; i < pts.length; i++) cum.push(cum[i - 1] + Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]));
|
|
199
|
+
const L = cum[cum.length - 1];
|
|
200
|
+
const out = [];
|
|
201
|
+
let seg = 0;
|
|
202
|
+
for (let k = 0; k < N; k++) {
|
|
203
|
+
const target = (k * L) / N;
|
|
204
|
+
while (seg < cum.length - 2 && cum[seg + 1] < target) seg++;
|
|
205
|
+
const span = cum[seg + 1] - cum[seg] || 1;
|
|
206
|
+
const t = (target - cum[seg]) / span;
|
|
207
|
+
out.push([pts[seg][0] + t * (pts[seg + 1][0] - pts[seg][0]), pts[seg][1] + t * (pts[seg + 1][1] - pts[seg][1])]);
|
|
208
|
+
}
|
|
209
|
+
// corner snapping: a sharp corner within one sample-spacing of a sample replaces it
|
|
210
|
+
// Snapshot the original sample positions before snapping, so distance calculations
|
|
211
|
+
// measure against original positions, not mutated ones (contest rule: closer corner wins)
|
|
212
|
+
const spacing = L / N;
|
|
213
|
+
const orig = out.map((p) => [p[0], p[1]]);
|
|
214
|
+
const owner = new Map(); // sample index -> snap distance
|
|
215
|
+
const ownerOf = new Map(); // corner list index -> sample index it won
|
|
216
|
+
corners.forEach((c, ci) => {
|
|
217
|
+
let bi = -1, bd = Infinity;
|
|
218
|
+
for (let i = 0; i < orig.length; i++) {
|
|
219
|
+
const d = Math.hypot(orig[i][0] - c[0], orig[i][1] - c[1]);
|
|
220
|
+
if (d < bd) { bd = d; bi = i; }
|
|
221
|
+
}
|
|
222
|
+
if (bd < spacing && (!owner.has(bi) || bd < owner.get(bi))) {
|
|
223
|
+
out[bi] = [c[0], c[1]];
|
|
224
|
+
owner.set(bi, bd);
|
|
225
|
+
for (const [k, v] of ownerOf) if (v === bi) ownerOf.delete(k); // evicted corner loses the sample
|
|
226
|
+
ownerOf.set(ci, bi);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
return { out, ownerOf };
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
// Resample mode: every ring tessellated at the fixed LOD, resampled to a common N.
|
|
233
|
+
export function resampleTessellation(lifted) {
|
|
234
|
+
return resampleTessellationDetail(lifted).rings;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Detail form: also reports each ring's snapped-corner sample indices, which the
|
|
238
|
+
// shading provenance below uses as sector boundaries.
|
|
239
|
+
function resampleTessellationDetail(lifted) {
|
|
240
|
+
const source = lifted.map((r) => {
|
|
241
|
+
let ring = r.pts ?? tessellateContour(r.contour, LOFT_SEGS);
|
|
242
|
+
// tessellateContour of a contour returns an explicitly closed ring — drop the closure
|
|
243
|
+
if (ring.length > 1 && ring[0][0] === ring[ring.length - 1][0] && ring[0][1] === ring[ring.length - 1][1])
|
|
244
|
+
ring = ring.slice(0, -1);
|
|
245
|
+
if (shoelace(ring) < 0) ring = [...ring].reverse();
|
|
246
|
+
return ring;
|
|
247
|
+
});
|
|
248
|
+
const N = Math.max(...source.map((r) => r.length));
|
|
249
|
+
const rings = [], snapped = [];
|
|
250
|
+
lifted.forEach((r, i) => {
|
|
251
|
+
const corners = profileCorners(r.contour);
|
|
252
|
+
const res = resampleRing(source[i], N, corners.map((c) => c.point));
|
|
253
|
+
rings.push(res.out);
|
|
254
|
+
// Only SHARP corners become sector boundaries (see sharpTurn below): a
|
|
255
|
+
// polygonized circle's 7.5°-per-vertex "corners" all snap, but must not
|
|
256
|
+
// shatter the ring into per-facet sectors.
|
|
257
|
+
const sharpSet = new Set();
|
|
258
|
+
corners.forEach((c, ci) => { if (sharpTurn(c) && res.ownerOf.has(ci)) sharpSet.add(res.ownerOf.get(ci)); });
|
|
259
|
+
snapped.push(sharpSet);
|
|
260
|
+
});
|
|
261
|
+
return { rings, snapped };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ── Shading provenance ──────────────────────────────────────────────────────
|
|
265
|
+
// Column j is the wall strip between samples j and j+1 (wrapping). A sector is a
|
|
266
|
+
// maximal run of columns between sharp contour features — sharp joints in curve
|
|
267
|
+
// mode, snapped corners in resample mode — so the mesh builder (loft.js) can give
|
|
268
|
+
// each sector its own shading surface. sectorSmooth[k] says whether sector k's
|
|
269
|
+
// facets approximate a smooth curve (its shading policy creases gently and draws
|
|
270
|
+
// no facet wireframe) or are flat/faceted geometry.
|
|
271
|
+
|
|
272
|
+
// A joint is SHARP (a sector boundary) only when it turns more than a smooth
|
|
273
|
+
// tessellation ever produces per facet — the same 360/SMOOTH_SIDES_MIN bar as the
|
|
274
|
+
// legacy "≥32 sides reads smooth" rule. profileCorners' own 1° bar is author-intent
|
|
275
|
+
// (corner ops); reusing it here would shatter a polygonized circle into sectors.
|
|
276
|
+
const SHARP_TURN_DEG = 360 / SMOOTH_SIDES_MIN;
|
|
277
|
+
const sharpTurn = (corner) => Math.abs(180 - corner.interiorAngleDeg) > SHARP_TURN_DEG;
|
|
278
|
+
|
|
279
|
+
const sectorsFromBoundaries = (N, boundarySet) => {
|
|
280
|
+
const B = [...boundarySet].sort((a, b) => a - b);
|
|
281
|
+
const sectorOf = new Array(N).fill(0);
|
|
282
|
+
// Known limitation: a SINGLE sharp joint on an otherwise smooth ring (a teardrop
|
|
283
|
+
// cusp) cannot be expressed — one boundary yields one sector, so both sides of
|
|
284
|
+
// the cusp share a run and it shades per the sector policy (smooth if any curve).
|
|
285
|
+
// Splitting the ring artificially would draw a fake dividing line at the split
|
|
286
|
+
// (smooth facets bend > the 5° cross-run bar at LOFT_SEGS). Add a second corner,
|
|
287
|
+
// or force `shading: "faceted"`, to make a lone cusp read.
|
|
288
|
+
if (B.length > 1) {
|
|
289
|
+
for (let k = 0; k < B.length; k++) {
|
|
290
|
+
const from = B[k], to = B[(k + 1) % B.length];
|
|
291
|
+
for (let j = from; j !== to; j = (j + 1) % N) sectorOf[j] = k;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return sectorOf;
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
// Curve mode: sector boundaries at sharp joints (profileCorners, unioned across all
|
|
298
|
+
// rings — structurally identical contours can still disagree on which joints bend).
|
|
299
|
+
// A sector is smooth when any column in it samples a curved (arc/cubic) segment.
|
|
300
|
+
const curveShading = (lifted, counts) => {
|
|
301
|
+
const N = counts.reduce((a, b) => a + b, 0);
|
|
302
|
+
const jointSample = [0]; // joint j (start of segment j) lands at this sample index
|
|
303
|
+
for (let j = 1; j < counts.length; j++) jointSample.push(jointSample[j - 1] + counts[j - 1]);
|
|
304
|
+
const sharp = new Set();
|
|
305
|
+
for (const r of lifted) for (const c of profileCorners(r.contour)) if (sharpTurn(c)) sharp.add(jointSample[c.index]);
|
|
306
|
+
const sectorOf = sectorsFromBoundaries(N, sharp);
|
|
307
|
+
const segSmooth = lifted[0].contour.segments.map((s) => !!(s.via || s.c1));
|
|
308
|
+
const K = Math.max(...sectorOf) + 1;
|
|
309
|
+
const sectorSmooth = new Array(K).fill(false);
|
|
310
|
+
let seg = 0;
|
|
311
|
+
for (let j = 0; j < N; j++) {
|
|
312
|
+
while (seg < counts.length - 1 && j >= jointSample[seg + 1]) seg++;
|
|
313
|
+
if (segSmooth[seg]) sectorSmooth[sectorOf[j]] = true;
|
|
314
|
+
}
|
|
315
|
+
return { sectorOf, sectorSmooth };
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
// Resample mode: sector boundaries at the union of every ring's snapped corners. A
|
|
319
|
+
// sector is smooth when every interior sample on every ring turns gently — the same
|
|
320
|
+
// bar as the legacy "≥ SMOOTH_SIDES_MIN sides reads smooth" rule (360/32 per facet).
|
|
321
|
+
const resampleShading = (rings, snappedSets) => {
|
|
322
|
+
const N = rings[0].length;
|
|
323
|
+
const boundaries = new Set();
|
|
324
|
+
for (const s of snappedSets) for (const i of s) boundaries.add(i);
|
|
325
|
+
const sectorOf = sectorsFromBoundaries(N, boundaries);
|
|
326
|
+
const K = Math.max(...sectorOf) + 1;
|
|
327
|
+
const limit = (2 * Math.PI) / SMOOTH_SIDES_MIN;
|
|
328
|
+
const sectorSmooth = new Array(K).fill(true);
|
|
329
|
+
for (const ring of rings)
|
|
330
|
+
for (let i = 0; i < N; i++) {
|
|
331
|
+
if (boundaries.has(i)) continue; // corners split sectors; they are not interior turns
|
|
332
|
+
const p = ring[(i - 1 + N) % N], q = ring[i], r2 = ring[(i + 1) % N];
|
|
333
|
+
const ux = q[0] - p[0], uy = q[1] - p[1], vx = r2[0] - q[0], vy = r2[1] - q[1];
|
|
334
|
+
const turn = Math.abs(Math.atan2(ux * vy - uy * vx, ux * vx + uy * vy));
|
|
335
|
+
if (turn > limit) { // the kink sits AT sample i — both adjacent columns' sectors go faceted
|
|
336
|
+
sectorSmooth[sectorOf[i]] = false;
|
|
337
|
+
sectorSmooth[sectorOf[(i - 1 + N) % N]] = false;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return { sectorOf, sectorSmooth };
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
// Orchestrates lift -> classify -> tessellate into the single shape both backends consume:
|
|
344
|
+
// resolved[i] = { pts2d, z, contour } — pts2d for Manifold's hand-mesh, contour (curve mode
|
|
345
|
+
// only) for OCCT's native wire loft. See docs/superpowers/plans/2026-08-23-shape2d-loft-design.md.
|
|
346
|
+
export function resolveLoftRings(rings) {
|
|
347
|
+
const lifted = liftLoftRings(rings);
|
|
348
|
+
const { mode, hasCurve } = classifyLoftRings(lifted);
|
|
349
|
+
let ptRings, shading = null;
|
|
350
|
+
if (mode === "poly-exact") {
|
|
351
|
+
// Point-list rings keep their legacy un-normalized winding for bit-exactness — UNLESS
|
|
352
|
+
// the ring set also mixes in a contour/Shape2D-sourced ring (r.pts === null), whose
|
|
353
|
+
// winding bakeContour already forced CCW. Left alone, a CW point ring paired with a
|
|
354
|
+
// CCW contour ring cancels the side walls (mixed winding) into an empty solid instead
|
|
355
|
+
// of erroring or self-correcting (loftMesh's volume<0 latch only catches a FULLY
|
|
356
|
+
// inverted mesh, not this partial cancellation) — so only in the mixed case do we
|
|
357
|
+
// also normalize each point ring to CCW here. All-point-list ring sets are untouched.
|
|
358
|
+
const mixed = lifted.some((r) => r.pts) && lifted.some((r) => !r.pts);
|
|
359
|
+
ptRings = lifted.map((r) => {
|
|
360
|
+
if (!r.pts) return matchedTessellation([r, r])[0];
|
|
361
|
+
return mixed && shoelace(r.pts) < 0 ? [...r.pts].reverse() : r.pts;
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
else if (mode === "curve") {
|
|
365
|
+
const d = matchedTessellationDetail(lifted);
|
|
366
|
+
ptRings = d.rings;
|
|
367
|
+
shading = curveShading(lifted, d.counts);
|
|
368
|
+
} else {
|
|
369
|
+
const d = resampleTessellationDetail(lifted);
|
|
370
|
+
ptRings = d.rings;
|
|
371
|
+
shading = resampleShading(d.rings, d.snapped);
|
|
372
|
+
}
|
|
373
|
+
return {
|
|
374
|
+
mode, hasCurve, shading,
|
|
375
|
+
resolved: lifted.map((r, i) => ({ pts2d: ptRings[i], z: r.z, contour: mode === "curve" ? r.contour : null })),
|
|
376
|
+
};
|
|
377
|
+
}
|
|
@@ -1,76 +1,153 @@
|
|
|
1
|
-
// Backend-shared loft support
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
// Backend-shared loft support: resolveLoftRings (loft-rings.js) validates and
|
|
2
|
+
// tessellates the declarative ring specs; loftMesh() is the Manifold path — stacked
|
|
3
|
+
// rings stitched with side quads and closed with TRIANGULATED caps (wasm.triangulate),
|
|
4
|
+
// so non-convex Shape2D rings cap correctly (the old centroid fan was star-convex-only).
|
|
5
|
+
//
|
|
6
|
+
// Curve/resample lofts additionally partition their triangles into RUNS carrying
|
|
7
|
+
// reserved original IDs (mesh-fillet's blend-band mechanism): one run per contour
|
|
8
|
+
// sector per band group, plus each cap. Sector boundaries (sharp joints, snapped
|
|
9
|
+
// corners) and band-group boundaries (silhouette kinks bending more than
|
|
10
|
+
// TANGENT_ANGLE — the same bar the B-rep backend draws real edges at) then shade
|
|
11
|
+
// hard and draw dividing lines through creased-normals' existing cross-surface
|
|
12
|
+
// rules, while each smooth sector keeps gentle crease behavior inside. Provenance
|
|
13
|
+
// decides the shading; angle inference alone could not (a 10° belly kink sits far
|
|
14
|
+
// below SMOOTH's 35° crease). Point-ring (poly-exact) lofts and hinted lofts keep
|
|
15
|
+
// the legacy single-surface path bit-for-bit.
|
|
16
|
+
import { resolveLoftRings } from "./loft-rings.js";
|
|
17
|
+
import { sideQuads, manifoldFromMesh, manifoldFromMeshRuns, reverseWinding } from "./mesh-build.js";
|
|
18
|
+
import { TANGENT_ANGLE, LOFT_SECTOR_SMOOTH, LOFT_SECTOR_FACETED, cosDeg } from "./shading-policy.js";
|
|
9
19
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const pts2d = pts.map(([x, y]) => {
|
|
30
|
-
const X = x * sx, Y = y * sy; // scale in-plane, then rotate about Z
|
|
31
|
-
return [X * cos - Y * sin, X * sin + Y * cos];
|
|
32
|
-
});
|
|
33
|
-
return { pts2d, z: r.z };
|
|
34
|
-
});
|
|
35
|
-
const N = out[0].pts2d.length;
|
|
36
|
-
for (const r of out) if (r.pts2d.length !== N)
|
|
37
|
-
throw new Error("loft: every ring must have the same number of points (straight quad stitching, no re-sampling)");
|
|
38
|
-
return out;
|
|
20
|
+
const shoelace = (ring) => ring.reduce((a, [x, y], i) => {
|
|
21
|
+
const [nx, ny] = ring[(i + 1) % ring.length];
|
|
22
|
+
return a + x * ny - nx * y;
|
|
23
|
+
}, 0) / 2;
|
|
24
|
+
|
|
25
|
+
// Triangulated end cap. Winding must stay CONSISTENT with the side walls: a CCW ring's
|
|
26
|
+
// top cap faces +Z and its bottom cap −Z; a CW ring (legacy point lists — walls come
|
|
27
|
+
// out inverted and the whole-mesh volume check below flips everything at once) gets
|
|
28
|
+
// both caps inverted too, so the mesh is orientable either way.
|
|
29
|
+
function triCap(wasm, Tr, ringStart, pts2d, bottom) {
|
|
30
|
+
const ccw = shoelace(pts2d) >= 0;
|
|
31
|
+
const ring = ccw ? pts2d : [...pts2d].reverse();
|
|
32
|
+
const tris = wasm.triangulate([ring], 1e-9);
|
|
33
|
+
const remap = (i) => ringStart + (ccw ? i : pts2d.length - 1 - i);
|
|
34
|
+
const flip = bottom !== !ccw; // XOR: see winding table in the test file
|
|
35
|
+
for (const t of tris) {
|
|
36
|
+
const a = remap(t[0]), b = remap(t[1]), c = remap(t[2]);
|
|
37
|
+
if (flip) Tr.push(a, c, b); else Tr.push(a, b, c);
|
|
38
|
+
}
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
41
|
+
// Interior rings where the wall direction bends more than TANGENT_ANGLE at any
|
|
42
|
+
// vertex column — author-placed silhouette features, not tessellation of a smooth
|
|
43
|
+
// sweep. Matches the bar at which the B-rep backend's real loft edges draw lines,
|
|
44
|
+
// so the Manifold preview and an OCCT export agree on which rings read as features.
|
|
45
|
+
function kinkRingsOf(resolved, closed = false) {
|
|
46
|
+
const R = resolved.length, N = resolved[0].pts2d.length;
|
|
47
|
+
const cosKink = cosDeg(TANGENT_ANGLE);
|
|
48
|
+
const kinks = new Set();
|
|
49
|
+
// open lofts bend only at interior rings; a closed loop also bends across the
|
|
50
|
+
// wrap, so rings 0 and R-1 are checked there too (their neighbors wrap modulo R)
|
|
51
|
+
for (let k = closed ? 0 : 1; k < (closed ? R : R - 1); k++) {
|
|
52
|
+
const A = resolved[(k - 1 + R) % R], B = resolved[k], C = resolved[(k + 1) % R];
|
|
53
|
+
for (let i = 0; i < N; i++) {
|
|
54
|
+
const ux = B.pts2d[i][0] - A.pts2d[i][0], uy = B.pts2d[i][1] - A.pts2d[i][1], uz = B.z - A.z;
|
|
55
|
+
const vx = C.pts2d[i][0] - B.pts2d[i][0], vy = C.pts2d[i][1] - B.pts2d[i][1], vz = C.z - B.z;
|
|
56
|
+
const lu = Math.hypot(ux, uy, uz), lv = Math.hypot(vx, vy, vz);
|
|
57
|
+
// a zero-length band (duplicated ring) has no direction — it cannot bend;
|
|
58
|
+
// without this skip the 0-dot always reads as a >5° kink and splits runs
|
|
59
|
+
if (lu < 1e-9 || lv < 1e-9) continue;
|
|
60
|
+
if ((ux * vx + uy * vy + uz * vz) / (lu * lv) < cosKink) { kinks.add(k); break; }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return kinks;
|
|
64
|
+
}
|
|
46
65
|
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
66
|
+
// Run-partitioned mesh: wall triangles grouped by (band group × sector), caps as
|
|
67
|
+
// their own runs, every run stamped with a freshly reserved original ID and its
|
|
68
|
+
// policy recorded in `runPolicies` for the backend to register.
|
|
69
|
+
function sectoredMesh(wasm, resolvedLoft, kinks, { closed = false } = {}, runPolicies) {
|
|
70
|
+
const { resolved, shading } = resolvedLoft;
|
|
71
|
+
const N = resolved[0].pts2d.length, R = resolved.length;
|
|
72
|
+
const V = [];
|
|
73
|
+
for (const { pts2d, z } of resolved) for (const [x, y] of pts2d) V.push(x, y, z);
|
|
74
|
+
|
|
75
|
+
const bands = closed ? R : R - 1;
|
|
76
|
+
const groupOf = new Array(bands);
|
|
77
|
+
let g = 0;
|
|
78
|
+
for (let b = 0; b < bands; b++) { if (b > 0 && kinks.has(b)) g++; groupOf[b] = g; }
|
|
79
|
+
// closed loop: unless ring 0 itself kinks, the last group continues into the first
|
|
80
|
+
if (closed && g > 0 && !kinks.has(0)) for (let b = bands - 1; b >= 0 && groupOf[b] === g; b--) groupOf[b] = 0;
|
|
81
|
+
|
|
82
|
+
const K = Math.max(...shading.sectorOf) + 1;
|
|
83
|
+
const runTris = new Map(); // (group * K + sector) -> flat tri indices
|
|
84
|
+
for (let b = 0; b < bands; b++) {
|
|
85
|
+
const i0 = b * N, i1 = ((b + 1) % R) * N;
|
|
86
|
+
for (let j = 0; j < N; j++) {
|
|
87
|
+
const key = groupOf[b] * K + shading.sectorOf[j];
|
|
88
|
+
let arr = runTris.get(key);
|
|
89
|
+
if (!arr) runTris.set(key, (arr = []));
|
|
90
|
+
const a = i0 + j, b2 = i0 + (j + 1) % N, cc = i1 + j, dd = i1 + (j + 1) % N;
|
|
91
|
+
arr.push(a, dd, cc, a, b2, dd); // same winding as sideQuads
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const capBottom = [], capTop = [];
|
|
95
|
+
if (!closed) {
|
|
96
|
+
triCap(wasm, capBottom, 0, resolved[0].pts2d, true);
|
|
97
|
+
triCap(wasm, capTop, (R - 1) * N, resolved[R - 1].pts2d, false);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const wallKeys = [...runTris.keys()].sort((a, b) => a - b);
|
|
101
|
+
const totalRuns = wallKeys.length + (closed ? 0 : 2);
|
|
102
|
+
const base = wasm.Manifold.reserveIDs(totalRuns);
|
|
103
|
+
const Tr = [], runIndex = [0], runOriginalID = [];
|
|
104
|
+
let next = base;
|
|
105
|
+
for (const key of wallKeys) {
|
|
106
|
+
for (const t of runTris.get(key)) Tr.push(t);
|
|
107
|
+
runIndex.push(Tr.length);
|
|
108
|
+
runOriginalID.push(next);
|
|
109
|
+
runPolicies?.set(next, shading.sectorSmooth[key % K] ? LOFT_SECTOR_SMOOTH : LOFT_SECTOR_FACETED);
|
|
110
|
+
next++;
|
|
111
|
+
}
|
|
112
|
+
for (const cap of closed ? [] : [capBottom, capTop]) {
|
|
113
|
+
for (const t of cap) Tr.push(t);
|
|
114
|
+
runIndex.push(Tr.length);
|
|
115
|
+
runOriginalID.push(next);
|
|
116
|
+
runPolicies?.set(next, LOFT_SECTOR_FACETED); // caps are planar
|
|
117
|
+
next++;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let out = manifoldFromMeshRuns(wasm, V, Tr, runIndex, runOriginalID);
|
|
121
|
+
if (out.volume() < 0) { // descending-z stacks: rebuild outward, runs unchanged
|
|
122
|
+
out.delete?.();
|
|
123
|
+
reverseWinding(Tr);
|
|
124
|
+
out = manifoldFromMeshRuns(wasm, V, Tr, runIndex, runOriginalID);
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function loftMesh(wasm, rings, opts = {}, runPolicies = null) {
|
|
130
|
+
const rl = Array.isArray(rings) ? resolveLoftRings(rings) : rings;
|
|
131
|
+
const { resolved, shading } = rl;
|
|
132
|
+
const { closed = false, shading: hint } = opts;
|
|
133
|
+
if (shading && hint == null) {
|
|
134
|
+
const kinks = kinkRingsOf(resolved, closed);
|
|
135
|
+
const K = Math.max(...shading.sectorOf) + 1;
|
|
136
|
+
// Partition only when there is a boundary to express; a single smooth sector
|
|
137
|
+
// with no kinks keeps the legacy single-surface path (and its policy inference).
|
|
138
|
+
if (K > 1 || kinks.size > 0) return sectoredMesh(wasm, rl, kinks, opts, runPolicies);
|
|
139
|
+
}
|
|
57
140
|
const N = resolved[0].pts2d.length;
|
|
58
141
|
const V = [];
|
|
59
142
|
for (const { pts2d, z } of resolved) for (const [x, y] of pts2d) V.push(x, y, z);
|
|
60
143
|
const Tr = [];
|
|
61
144
|
sideQuads(Tr, resolved.length, N, closed);
|
|
62
145
|
if (!closed) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
fanCap(V, Tr, (resolved.length - 1) * N, N, centroid(lastR.pts2d, lastR.z), false); // top faces +Z
|
|
146
|
+
triCap(wasm, Tr, 0, resolved[0].pts2d, true);
|
|
147
|
+
triCap(wasm, Tr, (resolved.length - 1) * N, resolved[resolved.length - 1].pts2d, false);
|
|
66
148
|
}
|
|
67
149
|
let out = manifoldFromMesh(wasm, V, Tr);
|
|
68
|
-
|
|
69
|
-
// rings invert every face, yielding a negative-volume solid that ofMesh imports without
|
|
70
|
-
// complaint but that behaves BACKWARDS under booleans (cut adds material). Detect the
|
|
71
|
-
// inversion and rebuild with reversed winding so loft is winding/z-order agnostic — this
|
|
72
|
-
// matches OCCT, whose native loft always returns a positively-oriented solid.
|
|
73
|
-
if (out.volume() < 0) {
|
|
150
|
+
if (out.volume() < 0) { // CW rings / descending z: rebuild outward (unchanged)
|
|
74
151
|
out.delete?.();
|
|
75
152
|
reverseWinding(Tr);
|
|
76
153
|
out = manifoldFromMesh(wasm, V, Tr);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { helixTube } from "./helix-tube.js";
|
|
2
2
|
import { loftMesh } from "./loft.js";
|
|
3
|
+
import { resolveLoftRings, loftRingsKey } from "./loft-rings.js";
|
|
3
4
|
import { sweepMesh } from "./sweep.js";
|
|
4
5
|
import { roundedBoxRings } from "./rounded-solids.js";
|
|
5
6
|
import { tessellateContour, tessellateProfile } from "./profile.js";
|
|
@@ -434,6 +435,34 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
434
435
|
const g0 = m.getMesh();
|
|
435
436
|
const isBlend = (oid) => !!oidPolicies.get(oid)?.boundaryLines;
|
|
436
437
|
const oids0 = new Set(g0.runOriginalID);
|
|
438
|
+
// Sector-aware re-stamp: a provenance-sectored loft (the `sector` policy
|
|
439
|
+
// marker) must keep every run's identity — folding them (either the plain
|
|
440
|
+
// asOriginal below or the blend path's two-group stamp) would erase the
|
|
441
|
+
// sector creases and dividing lines the runs exist to draw. Re-stamp every
|
|
442
|
+
// distinct id 1:1 onto fresh reserved ids: each keeps its policy (blend
|
|
443
|
+
// runs riding along keep BLEND), all map to the one label string, and the
|
|
444
|
+
// fresh ids give the same reuse-under-another-label guarantee as
|
|
445
|
+
// asOriginal(). Unregistered ids (plain boolean tools) stay unregistered —
|
|
446
|
+
// they shade SMOOTH by default exactly as before.
|
|
447
|
+
if ([...oids0].some((oid) => !!oidPolicies.get(oid)?.sector)) {
|
|
448
|
+
const olds = [...oids0];
|
|
449
|
+
const base2 = Manifold.reserveIDs(olds.length);
|
|
450
|
+
const mapId = new Map(olds.map((o2, i2) => [o2, base2 + i2]));
|
|
451
|
+
g0.runOriginalID = Uint32Array.from(g0.runOriginalID, (o2) => mapId.get(o2));
|
|
452
|
+
const o = T(new Manifold(g0));
|
|
453
|
+
g0.delete?.();
|
|
454
|
+
const setIds = [];
|
|
455
|
+
for (const [oldId, newId] of mapId) {
|
|
456
|
+
featureLabels.set(newId, name);
|
|
457
|
+
const pol = oidPolicies.get(oldId);
|
|
458
|
+
if (pol !== undefined) oidPolicies.set(newId, pol);
|
|
459
|
+
setIds.push(newId);
|
|
460
|
+
}
|
|
461
|
+
return { value: wrap(o, lh), pin: o, dispose: () => {
|
|
462
|
+
for (const id2 of setIds) { featureLabels.delete(id2); oidPolicies.delete(id2); }
|
|
463
|
+
o.delete?.();
|
|
464
|
+
} };
|
|
465
|
+
}
|
|
437
466
|
if ([...oids0].some(isBlend)) {
|
|
438
467
|
const baseId = Manifold.reserveIDs(2), blendId = baseId + 1;
|
|
439
468
|
// base-group policy: the same triangle-weighted majority vote as the plain
|
|
@@ -605,16 +634,29 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
605
634
|
// Ring loft: hand-meshed via the shared ring-mesh helpers (helix-tube recipe).
|
|
606
635
|
// Cached atomically; the hash folds every ring's points/z/rotate/scale and the
|
|
607
636
|
// opts (including `shading`, so toggling the hint is a fresh cache node).
|
|
608
|
-
//
|
|
609
|
-
//
|
|
610
|
-
//
|
|
637
|
+
// Sectored lofts (curve/resample rings with sharp features or silhouette kinks)
|
|
638
|
+
// come back already stamped with reserved per-run original IDs, whose policies
|
|
639
|
+
// loftMesh reports through the out-param — register them all and never
|
|
640
|
+
// asOriginal() (it would fold the runs into one surface and erase every
|
|
641
|
+
// provenance crease). Unsectored lofts keep the legacy single-surface path:
|
|
642
|
+
// asOriginal() + one inferred policy. Either way the registrations live
|
|
643
|
+
// exactly as long as the cache pins the solid.
|
|
611
644
|
loft: (rings, opts = {}) => {
|
|
612
|
-
const key = h("loft", rings, opts);
|
|
645
|
+
const key = h("loft", loftRingsKey(rings), opts);
|
|
613
646
|
return cache.lookup(key, () => {
|
|
614
|
-
const
|
|
647
|
+
const rl = resolveLoftRings(rings); // resolve once: mesh + shading share it
|
|
648
|
+
const runPol = new Map();
|
|
649
|
+
const raw = T(loftMesh(wasm, rl, opts, runPol));
|
|
650
|
+
if (runPol.size > 0) {
|
|
651
|
+
for (const [oid, pol] of runPol) oidPolicies.set(oid, pol);
|
|
652
|
+
return { value: wrap(raw, key), pin: raw, dispose: () => {
|
|
653
|
+
for (const oid of runPol.keys()) oidPolicies.delete(oid);
|
|
654
|
+
raw.delete?.();
|
|
655
|
+
} };
|
|
656
|
+
}
|
|
615
657
|
const m = T(raw.asOriginal());
|
|
616
658
|
const id = m.originalID();
|
|
617
|
-
oidPolicies.set(id, loftShadingPolicy(
|
|
659
|
+
oidPolicies.set(id, loftShadingPolicy(rl, opts));
|
|
618
660
|
return { value: wrap(m, key), pin: m, dispose: () => { oidPolicies.delete(id); m.delete?.(); } };
|
|
619
661
|
});
|
|
620
662
|
},
|
|
@@ -51,3 +51,19 @@ export function manifoldFromMesh(wasm, V, Tr) {
|
|
|
51
51
|
mesh.delete?.(); // input mesh is consumed by ofMesh; free it (caller tracks `out`)
|
|
52
52
|
return out;
|
|
53
53
|
}
|
|
54
|
+
|
|
55
|
+
// Same import, but with the triangles partitioned into runs carrying pre-reserved
|
|
56
|
+
// original IDs (Manifold.reserveIDs), so downstream shading (creased-normals) can
|
|
57
|
+
// treat each run as its own surface with its own policy — and the partition
|
|
58
|
+
// survives every boolean, exactly like mesh-fillet's blend bands. `runIndex` is in
|
|
59
|
+
// triVert units (3 × triangle count, MeshGL convention), first entry 0, last Tr.length.
|
|
60
|
+
export function manifoldFromMeshRuns(wasm, V, Tr, runIndex, runOriginalID) {
|
|
61
|
+
const mesh = new wasm.Mesh({
|
|
62
|
+
numProp: 3, vertProperties: Float32Array.from(V), triVerts: Uint32Array.from(Tr),
|
|
63
|
+
runIndex: Uint32Array.from(runIndex), runOriginalID: Uint32Array.from(runOriginalID),
|
|
64
|
+
});
|
|
65
|
+
mesh.merge();
|
|
66
|
+
const out = wasm.Manifold.ofMesh(mesh);
|
|
67
|
+
mesh.delete?.();
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
@@ -23,7 +23,7 @@ import { finishKernel } from "./kernel-front.js";
|
|
|
23
23
|
import { createOcctRepair } from "./occt-repair.js";
|
|
24
24
|
import { occtRoundAll } from "./occt-roundall.js";
|
|
25
25
|
import { classifyFaceGroups } from "./feature-attribution.js";
|
|
26
|
-
import {
|
|
26
|
+
import { resolveLoftRings, loftRingsKey } from "./loft-rings.js";
|
|
27
27
|
import { resolveSweepStations } from "./sweep.js";
|
|
28
28
|
import { normalizeProfile } from "./profile.js";
|
|
29
29
|
import { roundedRectContour } from "./rounded-solids.js";
|
|
@@ -423,14 +423,25 @@ export function createOcctKernel(replicad) {
|
|
|
423
423
|
});
|
|
424
424
|
};
|
|
425
425
|
|
|
426
|
-
// ring loft:
|
|
427
|
-
//
|
|
426
|
+
// ring loft: mode "curve" (structurally identical curve rings) lofts the ORIGINAL
|
|
427
|
+
// baked contours as true arc/spline wires — STEP stays curve-exact; every other mode
|
|
428
|
+
// lofts the same resolved point rings the Manifold backend stitches, so parity is by
|
|
429
|
+
// construction (ThruSections' own wire-matching never gets to pick a different seam).
|
|
430
|
+
// closed:true loops are Manifold-only (replicad loft is open).
|
|
431
|
+
// ThruSections can hand back a NEGATIVELY-oriented solid for some all-cubic wires
|
|
432
|
+
// even though every input wire is CCW (arc and line wires never trigger this), so a
|
|
433
|
+
// volume latch restores the positive orientation — the OCCT twin of loftMesh's
|
|
434
|
+
// whole-mesh volume<0 rebuild on the Manifold side.
|
|
428
435
|
const loftOp = (rings, { ruled = true, closed = false } = {}) => {
|
|
429
436
|
if (closed) throw new Error("loft: closed:true loops are only supported on the Manifold backend");
|
|
430
|
-
const key = h("loft", rings, ruled);
|
|
437
|
+
const key = h("loft", loftRingsKey(rings), ruled);
|
|
431
438
|
return cached(key, () => {
|
|
432
|
-
const
|
|
433
|
-
|
|
439
|
+
const { mode, resolved } = resolveLoftRings(rings);
|
|
440
|
+
const wires = resolved.map(({ pts2d, contour, z }) =>
|
|
441
|
+
(mode === "curve" ? contourDrawing(contour) : contourDrawing(pts2d)).sketchOnPlane("XY", z).wire);
|
|
442
|
+
const solid = loft(wires, { ruled });
|
|
443
|
+
if (measureVolume(solid) < 0) solid.wrapped.Reverse();
|
|
444
|
+
return wrap(solid, [], key);
|
|
434
445
|
});
|
|
435
446
|
};
|
|
436
447
|
|
|
@@ -53,6 +53,26 @@ export function normalizeProfile(profile) {
|
|
|
53
53
|
return { outer, holes };
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
// Solve the circle through (p0, via, p1) and the CCW-normalized sweep from p0 to p1
|
|
57
|
+
// that passes through `via`. Returns null for a collinear triple (callers emit a
|
|
58
|
+
// straight segment). Shared by sampleArc and loft-rings' fixed-count arc sampler.
|
|
59
|
+
export function arcGeometry(p0, via, p1) {
|
|
60
|
+
const [ax, ay] = p0, [bx, by] = via, [cx0, cy0] = p1;
|
|
61
|
+
const d = 2 * (ax * (by - cy0) + bx * (cy0 - ay) + cx0 * (ay - by));
|
|
62
|
+
if (Math.abs(d) < 1e-12) return null;
|
|
63
|
+
const sa = ax * ax + ay * ay, sb = bx * bx + by * by, sc = cx0 * cx0 + cy0 * cy0;
|
|
64
|
+
const cx = (sa * (by - cy0) + sb * (cy0 - ay) + sc * (ay - by)) / d;
|
|
65
|
+
const cy = (sa * (cx0 - bx) + sb * (ax - cx0) + sc * (bx - ax)) / d;
|
|
66
|
+
const r = Math.hypot(ax - cx, ay - cy);
|
|
67
|
+
const a0 = Math.atan2(ay - cy, ax - cx);
|
|
68
|
+
const av = Math.atan2(by - cy, bx - cx);
|
|
69
|
+
const a1 = Math.atan2(cy0 - cy, cx0 - cx);
|
|
70
|
+
const twoPi = 2 * Math.PI;
|
|
71
|
+
const ccw = (x) => { let v = x % twoPi; if (v < 0) v += twoPi; return v; };
|
|
72
|
+
const dCCW = ccw(a1 - a0), vCCW = ccw(av - a0);
|
|
73
|
+
return { cx, cy, r, a0, dA: vCCW <= dCCW ? dCCW : dCCW - twoPi };
|
|
74
|
+
}
|
|
75
|
+
|
|
56
76
|
// Sample the circular arc through (p0, via, p1) — the three-point form roundedProfile
|
|
57
77
|
// emits — into a point list p1…pN (EXCLUDING the start p0, which the ring already holds;
|
|
58
78
|
// the last point is exactly p1). The circle is recovered from the circumcircle of the
|
|
@@ -63,27 +83,15 @@ export function normalizeProfile(profile) {
|
|
|
63
83
|
// triple falls back to a single straight segment to p1 — the same "plain line" the OCCT
|
|
64
84
|
// side gets when roundedProfile emits no `via`.
|
|
65
85
|
export function sampleArc(p0, via, p1, segs) {
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const sa = ax * ax + ay * ay, sb = bx * bx + by * by, sc = cx * cx + cy * cy;
|
|
70
|
-
const ux = (sa * (by - cy) + sb * (cy - ay) + sc * (ay - by)) / d;
|
|
71
|
-
const uy = (sa * (cx - bx) + sb * (ax - cx) + sc * (bx - ax)) / d;
|
|
72
|
-
const rr = Math.hypot(ax - ux, ay - uy);
|
|
73
|
-
const a0 = Math.atan2(ay - uy, ax - ux);
|
|
74
|
-
const av = Math.atan2(by - uy, bx - ux);
|
|
75
|
-
const a1 = Math.atan2(cy - uy, cx - ux);
|
|
76
|
-
const twoPi = 2 * Math.PI;
|
|
77
|
-
const ccw = (x) => { let v = x % twoPi; if (v < 0) v += twoPi; return v; };
|
|
78
|
-
const dCCW = ccw(a1 - a0), vCCW = ccw(av - a0);
|
|
79
|
-
const dA = vCCW <= dCCW ? dCCW : dCCW - twoPi; // pick the sweep containing `via`
|
|
80
|
-
const steps = Math.max(2, Math.ceil((segs * Math.abs(dA)) / twoPi));
|
|
86
|
+
const g = arcGeometry(p0, via, p1);
|
|
87
|
+
if (!g) return [[p1[0], p1[1]]]; // collinear → straight line
|
|
88
|
+
const steps = Math.max(2, Math.ceil((segs * Math.abs(g.dA)) / (2 * Math.PI)));
|
|
81
89
|
const out = [];
|
|
82
90
|
for (let s = 1; s <= steps; s++) {
|
|
83
|
-
const ang = a0 + dA * (s / steps);
|
|
84
|
-
out.push([
|
|
91
|
+
const ang = g.a0 + g.dA * (s / steps);
|
|
92
|
+
out.push([g.cx + g.r * Math.cos(ang), g.cy + g.r * Math.sin(ang)]);
|
|
85
93
|
}
|
|
86
|
-
out[out.length - 1] = [
|
|
94
|
+
out[out.length - 1] = [p1[0], p1[1]]; // pin the exact endpoint
|
|
87
95
|
return out;
|
|
88
96
|
}
|
|
89
97
|
|
|
@@ -17,6 +17,13 @@ export const FACETED = Object.freeze({ creaseAngle: 10, sameSurfaceLines: false
|
|
|
17
17
|
// stay invisible while real mitre crossings still draw.
|
|
18
18
|
export const BLEND = Object.freeze({ creaseAngle: 35, sameSurfaceLines: true, boundaryLines: true });
|
|
19
19
|
|
|
20
|
+
// Loft sector policies: identical crease/line behavior to SMOOTH/FACETED, plus the
|
|
21
|
+
// `sector` marker label() uses to preserve a sectored loft's runs 1:1 when it
|
|
22
|
+
// re-stamps original IDs (a plain asOriginal would fold the sectors into one
|
|
23
|
+
// surface and erase every provenance crease and dividing line).
|
|
24
|
+
export const LOFT_SECTOR_SMOOTH = Object.freeze({ ...SMOOTH, sector: true });
|
|
25
|
+
export const LOFT_SECTOR_FACETED = Object.freeze({ ...FACETED, sector: true });
|
|
26
|
+
|
|
20
27
|
export const COPLANAR_ANGLE = 5; // deg — cut seams bending less than this are coplanar: no line
|
|
21
28
|
export const TANGENT_ANGLE = 5; // deg — B-rep edges whose faces agree within this are tangent: no line
|
|
22
29
|
export const MIN_EDGE = 0.01; // mm — drop shorter segments (degenerate slivers, pole edges)
|
|
@@ -36,18 +43,17 @@ export const SMOOTH_SIDES_MIN = 32;
|
|
|
36
43
|
|
|
37
44
|
export const cosDeg = (deg) => Math.cos((deg * Math.PI) / 180);
|
|
38
45
|
|
|
39
|
-
// Loft shading inference
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
46
|
+
// Loft shading inference over RESOLVED rings (resolveLoftRings' result). An explicit
|
|
47
|
+
// `shading` hint wins; `ruled:false` must preview smooth (it exports smooth via OCCT);
|
|
48
|
+
// any curved ring segment (arc/cubic) is smooth-surface intent; otherwise low
|
|
49
|
+
// resolved side counts are intentional facets.
|
|
50
|
+
export function loftShadingPolicy(resolvedLoft, { shading, ruled } = {}) {
|
|
43
51
|
if (shading === "smooth") return SMOOTH;
|
|
44
52
|
if (shading === "faceted") return FACETED;
|
|
45
53
|
if (shading != null) throw new Error('loft: shading must be "smooth" | "faceted"');
|
|
46
54
|
if (ruled === false) return SMOOTH;
|
|
55
|
+
if (resolvedLoft?.hasCurve) return SMOOTH;
|
|
47
56
|
let maxSides = 0;
|
|
48
|
-
|
|
49
|
-
const n = Array.isArray(r?.polygon) ? r.polygon.length : (Number.isFinite(r?.sides) ? r.sides : 0);
|
|
50
|
-
if (n > maxSides) maxSides = n;
|
|
51
|
-
}
|
|
57
|
+
for (const r of resolvedLoft?.resolved ?? []) if (r.pts2d.length > maxSides) maxSides = r.pts2d.length;
|
|
52
58
|
return maxSides >= SMOOTH_SIDES_MIN ? SMOOTH : FACETED;
|
|
53
59
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// set of 3-D cross-section stations. BOTH backends build from that SAME station list —
|
|
6
6
|
// Manifold hand-meshes it (sweepMesh below, the loft/helix-tube recipe via mesh-build.js),
|
|
7
7
|
// OCCT lofts the same rings ruled (occt-backend.js). So the elbow shape agrees BY
|
|
8
|
-
// CONSTRUCTION, not by tolerance — the same parity mechanism loft's
|
|
8
|
+
// CONSTRUCTION, not by tolerance — the same parity mechanism loft's resolveLoftRings uses.
|
|
9
9
|
//
|
|
10
10
|
// Corners: cornerRadius==0 → a SHARP MITER (one station per vertex, in the bisecting
|
|
11
11
|
// plane, stretched by 1/cos(turn/2) so straight walls meet flush). cornerRadius>0 →
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Example PartDefinition — the Shape2D-loft reference part. One rounded-square Shape2D
|
|
2
|
+
// is reused up the body with per-ring scales (structurally identical rings: OCCT lofts
|
|
3
|
+
// the original arc wires, so STEP keeps true circles); the shoulder morphs that square
|
|
4
|
+
// into a circle (structurally different rings: both backends loft the identical
|
|
5
|
+
// resampled sections). See docs/AUTHORING-PARTS.md for the conventions.
|
|
6
|
+
import { circleProfile } from "partforge/geometry";
|
|
7
|
+
|
|
8
|
+
export default {
|
|
9
|
+
meta: { title: "Lofted Bottle", units: "mm" },
|
|
10
|
+
parameters: [
|
|
11
|
+
{
|
|
12
|
+
id: "body",
|
|
13
|
+
title: "Bottle",
|
|
14
|
+
description: "A bottle lofted from a rounded-square base to a round neck (`k.loft` " +
|
|
15
|
+
"with `Shape2D` rings). **Corner radius** shapes the base; **Belly** bulges the body.",
|
|
16
|
+
presets: {
|
|
17
|
+
"Flask": { width: 40, bodyH: 70, cornerR: 8, belly: 1.15, neckD: 22, neckH: 25 },
|
|
18
|
+
"Square jar": { width: 60, bodyH: 50, cornerR: 6, belly: 1.0, neckD: 40, neckH: 12 },
|
|
19
|
+
"Slim vial": { width: 24, bodyH: 60, cornerR: 10, belly: 1.05, neckD: 14, neckH: 20 },
|
|
20
|
+
},
|
|
21
|
+
advanced: [
|
|
22
|
+
{ key: "width", label: "Base width", unit: "mm", min: 20, max: 80, step: 1, description: "Across-flats width of the rounded-square base." },
|
|
23
|
+
{ key: "bodyH", label: "Body height", unit: "mm", min: 30, max: 120, step: 1, description: "Height of the square-section body." },
|
|
24
|
+
{ key: "cornerR", label: "Corner radius", unit: "mm", min: 1, max: 12, step: 0.5, description: "Base corner rounding — these arcs stay true circles in STEP." },
|
|
25
|
+
{ key: "belly", label: "Belly", min: 0.9, max: 1.4, step: 0.05, description: "Mid-body scale — above 1 bulges, below 1 pinches." },
|
|
26
|
+
{ key: "neckD", label: "Neck diameter", unit: "mm", min: 10, max: 50, step: 1, description: "Round neck diameter at the mouth." },
|
|
27
|
+
{ key: "neckH", label: "Neck height", unit: "mm", min: 8, max: 40, step: 1, description: "Height of the square-to-circle shoulder." },
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
defaults: { width: 40, bodyH: 70, cornerR: 8, belly: 1.15, neckD: 22, neckH: 25 },
|
|
32
|
+
parts: {
|
|
33
|
+
bottle: {
|
|
34
|
+
label: "Bottle", views: ["bottle"], export: { name: "lofted-bottle" },
|
|
35
|
+
build: (k, p) => {
|
|
36
|
+
const half = p.width / 2;
|
|
37
|
+
const r = Math.min(p.cornerR, half - 0.5); // fillet must fit the half-width
|
|
38
|
+
const sq = k.shape2d([[-half, -half], [half, -half], [half, half], [-half, half]]).fillet(r);
|
|
39
|
+
// Body: one Shape2D reused with per-ring scale → curve mode (STEP-exact arcs).
|
|
40
|
+
const body = k.loft({ rings: [
|
|
41
|
+
{ polygon: sq, z: 0 },
|
|
42
|
+
{ polygon: sq, z: p.bodyH * 0.45, scale: p.belly },
|
|
43
|
+
{ polygon: sq, z: p.bodyH },
|
|
44
|
+
] }).label("Body");
|
|
45
|
+
// Shoulder: rounded square → circle → resample mode (shared rings, both backends).
|
|
46
|
+
const shoulder = k.loft({ rings: [
|
|
47
|
+
{ polygon: sq, z: p.bodyH },
|
|
48
|
+
{ polygon: circleProfile(p.neckD / 2), z: p.bodyH + p.neckH },
|
|
49
|
+
] }).label("Shoulder");
|
|
50
|
+
return body.union(shoulder);
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
views: { bottle: { label: "Bottle" } },
|
|
55
|
+
verify: {
|
|
56
|
+
expect: {
|
|
57
|
+
bottle: { holes: 0, bbox: "<=[120,120,165]" },
|
|
58
|
+
_view: { overlaps: 0 },
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
};
|