partforge 0.81.0 → 0.83.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/bin/cli.js +28 -0
- package/docs/AUTHORING-PARTS.md +116 -20
- 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 +10 -5
- package/src/framework/geometry/probe.js +21 -8
- 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/framework/lint/rules-build.js +12 -2
- package/src/framework/lint/rules-shape.js +19 -0
- package/src/framework/oracle/measure.js +87 -0
- package/src/framework/oracle/verify.js +4 -1
- package/src/lofted-bottle-worker.js +3 -0
- package/src/parts/import-demo.js +22 -1
- package/src/parts/lofted-bottle.js +61 -0
package/bin/cli.js
CHANGED
|
@@ -438,6 +438,34 @@ function printMeasure(r) {
|
|
|
438
438
|
console.log(` near-misses: ${r.nearMisses.length
|
|
439
439
|
? r.nearMisses.map((g) => `${g.a}×${g.b} (${g.distance.toFixed(2)}mm at [${g.at.map((n) => n.toFixed(1)).join(", ")}])`).join(", ")
|
|
440
440
|
: "none"}`);
|
|
441
|
+
if (r.probes) {
|
|
442
|
+
// Solid-fact probes get the sub-part line treatment (in mm³ — probes are
|
|
443
|
+
// localization instruments, a slab's volume in cm³ rounds to noise), one
|
|
444
|
+
// level deep too so the common paired shape ({ mine, ref }) reads as lines
|
|
445
|
+
// rather than a JSON wall; anything else prints as JSON; a failed probe
|
|
446
|
+
// prints its error where the reader is.
|
|
447
|
+
const isFacts = (v) => v && typeof v === "object" && "empty" in v && "volume" in v;
|
|
448
|
+
const factsLine = (v) => (v.empty
|
|
449
|
+
? `empty (no material in the probed region)`
|
|
450
|
+
: `bbox ${v.bbox.map((n) => n.toFixed(2)).join("×")} ` +
|
|
451
|
+
`bounds [${v.bounds.min.map((n) => n.toFixed(2)).join(", ")}]…[${v.bounds.max.map((n) => n.toFixed(2)).join(", ")}] ` +
|
|
452
|
+
`vol ${v.volume.toFixed(2)}mm³`);
|
|
453
|
+
console.log(` probes:`);
|
|
454
|
+
for (const [name, v] of Object.entries(r.probes)) {
|
|
455
|
+
if (v && typeof v === "object" && typeof v.error === "string") {
|
|
456
|
+
console.log(` ${name} ERROR: ${v.error}`);
|
|
457
|
+
} else if (isFacts(v)) {
|
|
458
|
+
console.log(` ${name} ${factsLine(v)}`);
|
|
459
|
+
} else if (v && typeof v === "object" && !Array.isArray(v) && Object.values(v).some(isFacts)) {
|
|
460
|
+
console.log(` ${name}:`);
|
|
461
|
+
for (const [key, sub] of Object.entries(v)) {
|
|
462
|
+
console.log(` ${key} ${isFacts(sub) ? factsLine(sub) : JSON.stringify(sub)}`);
|
|
463
|
+
}
|
|
464
|
+
} else {
|
|
465
|
+
console.log(` ${name} ${JSON.stringify(v)}`);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
441
469
|
}
|
|
442
470
|
|
|
443
471
|
function printVerify(v) {
|
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -88,6 +88,8 @@ export default {
|
|
|
88
88
|
},
|
|
89
89
|
},
|
|
90
90
|
views: { <name>: { label, default?, animations? } }, // view tabs; a view may own animations (below)
|
|
91
|
+
probes?, // { name: (k, p, d) => Solid | plain JSON } — measurements reported by
|
|
92
|
+
// measure/inspect, never rendered or exported (see "Probes" below)
|
|
91
93
|
};
|
|
92
94
|
```
|
|
93
95
|
|
|
@@ -313,7 +315,7 @@ and the detection rule.
|
|
|
313
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 |
|
|
314
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) |
|
|
315
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) |
|
|
316
|
-
| `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) |
|
|
317
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 |
|
|
318
320
|
| `k.sphere({ r\|d })` | sphere centred at the origin; bare `k.sphere(r)` also stays valid |
|
|
319
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` |
|
|
@@ -324,16 +326,32 @@ and the detection rule.
|
|
|
324
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) |
|
|
325
327
|
| `k.union(solids[])` | boolean union |
|
|
326
328
|
|
|
327
|
-
**`loft` rings** — each ring is `{ polygon:[[x,y],…] | sides+radius, z, rotate?, scale? }`
|
|
328
|
-
(
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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:
|
|
337
355
|
|
|
338
356
|
```js
|
|
339
357
|
// a square tube (extrude a region with a hole) — one op, no boolean cut
|
|
@@ -1520,6 +1538,76 @@ keeps only the seams:
|
|
|
1520
1538
|
`meshTriangles`, `parseStl`, `parse3MF`); those exports are part of its contract.
|
|
1521
1539
|
|
|
1522
1540
|
|
|
1541
|
+
## Probes: measuring geometry into the report
|
|
1542
|
+
|
|
1543
|
+
A `probes` block turns the measure report into an instrument panel. Each probe is
|
|
1544
|
+
a pure `(k, p, d)` function with **build's exact contract** — same kernel handle,
|
|
1545
|
+
same resolved params and derived values — but its result lands in the **report**
|
|
1546
|
+
instead of the scene:
|
|
1547
|
+
|
|
1548
|
+
```js
|
|
1549
|
+
probes: {
|
|
1550
|
+
// A Solid anywhere in the return value is measured into a fact object:
|
|
1551
|
+
// { empty, bbox, bounds, centerOfMass, volume, surfaceArea, triangleCount,
|
|
1552
|
+
// watertight, holes }
|
|
1553
|
+
slabX12: (k, p, d) => buildBody(k, p, d)
|
|
1554
|
+
.intersect(k.box({ min: [12, -25, -4], max: [13, 25, 4] })),
|
|
1555
|
+
|
|
1556
|
+
// The paired form — the localizing workhorse when a rebuild drifts from its
|
|
1557
|
+
// imported reference: the same thin slab through both solids, side by side.
|
|
1558
|
+
slabPair: (k, p, d) => ({
|
|
1559
|
+
mine: buildBody(k, p, d).intersect(k.box({ min: [12, -25, -4], max: [13, 25, 4] })),
|
|
1560
|
+
ref: k.import("scan").intersect(k.box({ min: [12, -25, -4], max: [13, 25, 4] })),
|
|
1561
|
+
}),
|
|
1562
|
+
|
|
1563
|
+
// Plain JSON passes through verbatim — compute any number the solid queries
|
|
1564
|
+
// can reach (volume/boundingBox booleans, arc fits, whatever).
|
|
1565
|
+
xor: (k, p, d) => {
|
|
1566
|
+
const mine = buildBody(k, p, d), ref = k.import("scan");
|
|
1567
|
+
return mine.volume() + ref.volume() - 2 * mine.intersect(ref).volume();
|
|
1568
|
+
},
|
|
1569
|
+
}
|
|
1570
|
+
```
|
|
1571
|
+
|
|
1572
|
+
**Where they show up.** `npx partforge measure` prints a `probes:` section and
|
|
1573
|
+
includes `probes: { name: value }` in `--json`; the worker's `inspect` job carries
|
|
1574
|
+
the same key, so any host reporting measure output (e.g. an agent's check loop)
|
|
1575
|
+
sees probe values on every edit with no extra wiring. Probes are **part-level,
|
|
1576
|
+
not per-view**: every measured view reports them, so they never disappear because
|
|
1577
|
+
the "wrong" tab was measured.
|
|
1578
|
+
|
|
1579
|
+
**What they replace.** Before probes, getting a cross-section's numbers out of
|
|
1580
|
+
the pipeline meant authoring throwaway `exportable: false` sub-parts and fishing
|
|
1581
|
+
their facts out of the sub-part list — polluting views, the control panel's
|
|
1582
|
+
mental model, and the overlap check. Probes are invisible to the viewer, the
|
|
1583
|
+
exporter, the assembly checks, and `verify` gates; they exist only in the report.
|
|
1584
|
+
|
|
1585
|
+
**Failure is contained.** A probe that throws reports `{ error: "…" }` in its
|
|
1586
|
+
own slot — it never crashes the measurement and never flips the report's `ok`.
|
|
1587
|
+
An empty boolean result (a slab that misses the part) reports
|
|
1588
|
+
`{ empty: true, volume: 0 }` rather than degenerate infinite bounds — "no
|
|
1589
|
+
material here" is a first-class answer for a localizing probe. Lint covers
|
|
1590
|
+
probes with the same pass as builds: a throwing probe is `probe-throws`, a
|
|
1591
|
+
malformed block is `invalid-probes`, and unknown ops / bad options / impurity
|
|
1592
|
+
are caught exactly as in `build`.
|
|
1593
|
+
|
|
1594
|
+
**Driving geometry from a live measurement.** Probes get numbers *out*. To feed
|
|
1595
|
+
a measurement *into* geometry, remember that `build` already holds a real
|
|
1596
|
+
kernel: `k.import("scan").boundingBox()` (and `.volume()`, and booleans between
|
|
1597
|
+
solids) work live inside any build, so a sub-part can size itself off another
|
|
1598
|
+
solid directly — no probe needed. Keep it pure: the measurement is deterministic
|
|
1599
|
+
for a given import + params, which is exactly what the geometry cache assumes.
|
|
1600
|
+
To set parameter **defaults** from a reference (the "rebuild this STL" flow),
|
|
1601
|
+
declare a probe that reads the value, run `measure`, and bake the reported
|
|
1602
|
+
number into `defaults` — the probe then keeps watching it on every regen, so a
|
|
1603
|
+
swapped import shows up as a probe delta instead of silently stale defaults.
|
|
1604
|
+
|
|
1605
|
+
**Cost.** Probes run on every `measure`/`inspect` (including quick checks — the
|
|
1606
|
+
agent loop is exactly who reads them), so keep them proportionate: a handful of
|
|
1607
|
+
thin-slab booleans is cheap; a dense sweep of whole-part XORs is not. `verify`'s
|
|
1608
|
+
per-case re-measures skip probes entirely (no gate reads them). The reference
|
|
1609
|
+
part for probes is [`src/parts/import-demo.js`](../src/parts/import-demo.js).
|
|
1610
|
+
|
|
1523
1611
|
## Wiring a part into a runnable app
|
|
1524
1612
|
|
|
1525
1613
|
Three tiny glue files per part (copy from the demo). The worker statically imports
|
|
@@ -1953,8 +2041,9 @@ previously didn't; that's the fix working as intended, not a regression.
|
|
|
1953
2041
|
### Rule catalog
|
|
1954
2042
|
|
|
1955
2043
|
**Definition shape** — `missing-meta-title`, `missing-defaults`, `no-buildable-parts`,
|
|
1956
|
-
`missing-views`, `part-view-unknown` (all errors); `view-unused`,
|
|
1957
|
-
`default-view-ambiguous` (warnings).
|
|
2044
|
+
`missing-views`, `part-view-unknown`, `invalid-probes` (all errors); `view-unused`,
|
|
2045
|
+
`default-view-ambiguous` (warnings). `invalid-probes` fires when a declared
|
|
2046
|
+
`probes` block isn't an object of functions (see "Probes" above).
|
|
1958
2047
|
|
|
1959
2048
|
**Parameter schema** — `features-requires-sliders`, `features-requires-on`,
|
|
1960
2049
|
`control-key-not-in-defaults`, `control-default-not-primitive`,
|
|
@@ -2033,10 +2122,12 @@ internals (`hidden: true`). Grouping controls organizes them but does not
|
|
|
2033
2122
|
reduce the count — the check recurses into groups — so a group alone doesn't
|
|
2034
2123
|
bring a section back under budget.
|
|
2035
2124
|
|
|
2036
|
-
**Kernel API**, found by executing `build()`
|
|
2125
|
+
**Kernel API**, found by executing `build()` — and every declared probe, which
|
|
2126
|
+
shares build's `(k, p, d)` contract — against a geometry-free probe —
|
|
2037
2127
|
`unknown-kernel-op`, `unknown-solid-op`, `invalid-op-options`, `build-throws`,
|
|
2038
|
-
`derive-throws`, `manifold-backend-uses-occt-op`,
|
|
2039
|
-
`nondeterministic-build` (warning, from diffing two
|
|
2128
|
+
`probe-throws`, `derive-throws`, `manifold-backend-uses-occt-op`,
|
|
2129
|
+
`build-runaway` (errors); `nondeterministic-build` (warning, from diffing two
|
|
2130
|
+
probe runs).
|
|
2040
2131
|
|
|
2041
2132
|
**Verify block** — `verify-unknown-metric`, `verify-unknown-subpart`,
|
|
2042
2133
|
`verify-bad-expr`, `verify-bad-pair-check`, `verify-unknown-process`,
|
|
@@ -2455,10 +2546,15 @@ OCCT). Within one sub-part's build there is no per-op backend mixing.
|
|
|
2455
2546
|
|
|
2456
2547
|
**Shading intent.** The kernel decides what shades smooth and where edge lines
|
|
2457
2548
|
draw — spheres, cylinders and fillets are smooth by construction; boolean cut
|
|
2458
|
-
seams always shade hard and draw a line; a loft's facets shade flat
|
|
2459
|
-
rings have fewer than 32 sides
|
|
2460
|
-
|
|
2461
|
-
|
|
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.
|
|
2462
2558
|
|
|
2463
2559
|
> `partforge measure` reports `watertight`/`holes` as `n/a` for OCCT-run parts
|
|
2464
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
|