partforge 0.111.1 → 0.113.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/AUTHORING-PARTS.md +24 -5
- package/docs/ERROR-PATTERNS.md +13 -1
- package/docs/KERNEL-CONTRACT.md +19 -0
- package/package.json +1 -1
- package/src/framework/geometry/contour-ops.js +8 -1
- package/src/framework/geometry/kernel-front.js +10 -3
- package/src/framework/geometry/kernel.js +3 -3
- package/src/framework/geometry/manifold-backend.js +8 -1
- package/src/framework/geometry/occt-backend.js +8 -1
- package/src/framework/geometry/op-options.js +34 -5
- package/src/framework/geometry/polygon.js +67 -5
- package/src/framework/geometry/profile-warnings.js +93 -0
- package/src/framework/geometry/shape2d-sugar.js +4 -1
- package/src/framework/geometry/shape2d.js +9 -3
- package/types/geometry.d.ts +2 -0
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -430,12 +430,23 @@ k.prism({ points: roundedProfile(bracketOutline, 3), h: 4 }); // true CIRCLE co
|
|
|
430
430
|
k.extrude({ profile: offsetPolygon(slotPolygon(20, 3), 0.2), h: 10 }); // slot cut 0.2 mm looser all around
|
|
431
431
|
offsetPolygon(outline, -wall, { corners: "sharp" }); // inset a wall (see planter.js)
|
|
432
432
|
|
|
433
|
-
// A tab
|
|
434
|
-
|
|
433
|
+
// A mounting tab: square at the root, semicircular at the tip. The arc names the
|
|
434
|
+
// point it must pass THROUGH (its apex), so its direction can never flip — there is
|
|
435
|
+
// no sweep sign to get wrong, and no Math.cos loop to write.
|
|
436
|
+
const tab = pathProfile([0, -w / 2])
|
|
437
|
+
.lineTo([len, -w / 2])
|
|
438
|
+
.arcTo([len, w / 2], [len + w / 2, 0]) // tip, via the apex
|
|
439
|
+
// same arc: .arcTo([len, w / 2], { r: w / 2 }) — radius form, no via to compute
|
|
440
|
+
.lineTo([0, w / 2])
|
|
441
|
+
.close();
|
|
442
|
+
k.extrude({ profile: tab, h: 3 });
|
|
443
|
+
|
|
444
|
+
// A free-form curved side (exact on STEP, faceted at mesh LOD):
|
|
445
|
+
const lip = pathProfile([0, 0])
|
|
435
446
|
.lineTo([20, 0]).lineTo([20, 8])
|
|
436
447
|
.cubicTo([0, 8], [14, 16], [6, 16]) // curved top edge
|
|
437
448
|
.close();
|
|
438
|
-
k.extrude({ profile:
|
|
449
|
+
k.extrude({ profile: lip, h: 3 });
|
|
439
450
|
|
|
440
451
|
// Rounded enclosure: soft vertical edges, a softer lid, a flat base.
|
|
441
452
|
const shell = k.roundedBox({ size: [60, 40, 22], round: { side: 4, top: 2, bottom: 0 } });
|
|
@@ -448,8 +459,7 @@ every corner of a CCW polygon (per-corner radius clamped so neighbouring arcs ne
|
|
|
448
459
|
and returns points usable by `prism`/`extrude`/`loft` on both backends — but it **bakes each
|
|
449
460
|
corner into line facets**, so STEP corners are faceted. `roundedProfile(points, r | r[])`
|
|
450
461
|
rounds corners the same way but keeps them **mathematically true** — it carries the arc
|
|
451
|
-
symbolically so STEP export gets real circular edges. Use it for `prism`/`extrude` (
|
|
452
|
-
`loft` — arc rings are rejected there in v1). A scalar `r` rounds every corner; a per-corner
|
|
462
|
+
symbolically so STEP export gets real circular edges. Use it for `prism`/`extrude`/`loft` alike (loft lifts arc rings into its curve mode). A scalar `r` rounds every corner; a per-corner
|
|
453
463
|
`r[]` (length = points) rounds selectively (a `0`, a zero-length edge, or a straight/180°
|
|
454
464
|
corner stays sharp). `offsetPolygon(profile, delta, { corners?, segs? })` offsets a
|
|
455
465
|
point-list polygon or `{ outer, holes }` region by `delta` mm — positive grows material,
|
|
@@ -462,6 +472,7 @@ dumbbell past its waist) **throws** a greppable error rather than returning dege
|
|
|
462
472
|
geometry. Being pure, it works in `derive()` as well as `build()` — the natural home for
|
|
463
473
|
clearance math.
|
|
464
474
|
`pathProfile(start)` is a fluent builder for a curve-native path contour (`lineTo` / `arcTo` / `cubicTo` / `close`); cubic segments become exact B-rep spline edges on the OCCT/STEP backend and facet at the mesh LOD on Manifold — the same exact-vs-faceted split as `roundedProfile` arcs.
|
|
475
|
+
`arcTo(to, via)` is a **three-point arc**: `via` is any point on the arc between the current point and `to` (its midpoint is the natural choice), and the sweep is whichever direction passes through it — so an arc's direction is a property of a point you can see, never of a sign. `arcTo(to, { r, sweep?, large? })` is the **radius form** for when you know the radius, not a point on the arc: it computes `via` from the current point, `to`, and `r`, emitting the exact same `{to, via}` segment the three-point form does. `sweep` names the direction the arc itself is traversed (default `"ccw"`), so on a counter-clockwise outline `"ccw"` bulges OUTWARD (a convex bump), and inward on a clockwise hole; `"cw"` is the reverse. `large` (default `false`) picks the major arc over the minor one when both are possible. A radius shorter than half the distance between the current point and `to` throws rather than being silently scaled up (the way SVG's arc command does) — the smallest circle joining the two points is a semicircle at `r = d/2`. Build the symmetric half of a profile once and `mirrorProfile` it (see "Editing profiles") rather than writing the mirrored arcs by hand. `loft` accepts these contours as rings (every ring with the same segment signature lofts curve-to-curve).
|
|
465
476
|
**`pathProfile` or an authored vector file?** Reach for `pathProfile` (and the polygon helpers above) when the geometry is **computed from parameters** — a profile whose dimensions come from `p`/`d`, which a JSON file cannot see. Reach for an authored `partforge-vector` document (`k.vector2d`, see "Vector geometry" below) when the geometry is **drawn** — a logo, a faceplate outline, a decorative cutout, where each number means one thing and gets edited on its own. The two are freely composable: both produce ordinary 2-D geometry that the same booleans and editing ops accept.
|
|
466
477
|
**Import geometry helpers from `partforge/geometry`, never from `partforge`** — the main
|
|
467
478
|
entry pulls in the DOM viewer/controls, and your build functions run in a Web Worker
|
|
@@ -1416,6 +1427,7 @@ Three rules worth internalizing before reaching for any of this:
|
|
|
1416
1427
|
on a narrow profile can produce arcs that cross the far side). `validateProfile`
|
|
1417
1428
|
never throws, so it's cheap to call after any edit and inspect `issues` before
|
|
1418
1429
|
committing to the result.
|
|
1430
|
+
Since 0.112 the kernel runs it for you on the way IN to `extrude`/`prism`/`revolve`/`sweep`/`loft`/`shape2d` and reports each crossing as a build warning ([profile-self-intersects](ERROR-PATTERNS.md#profile-self-intersects)); the manual call is for inspecting a result BEFORE committing to it.
|
|
1419
1431
|
- **Guard vanishing features with `isEmpty()`.** A boolean chain can legitimately
|
|
1420
1432
|
produce an *empty* shape (an `intersect` of shapes a parameter drove apart, a `cut`
|
|
1421
1433
|
that removed everything). The empty shape is a fine 2-D value — further booleans,
|
|
@@ -3485,6 +3497,13 @@ symptom first** — it maps error text → cause → fix. The invariants, one li
|
|
|
3485
3497
|
- **`build` is a pure function of `(k, p, d)`** — impurity silently defeats the geometry
|
|
3486
3498
|
cache ([impure-build-stale-preview](ERROR-PATTERNS.md#impure-build-stale-preview)).
|
|
3487
3499
|
- **Units are millimetres** throughout.
|
|
3500
|
+
- **Never sample an arc into points by hand.** A `Math.cos` loop hides the sweep direction
|
|
3501
|
+
in a sign, and a wrong sign produces a self-crossing outline that builds with inverted
|
|
3502
|
+
fill and no error — only a `profile-self-intersects` warning
|
|
3503
|
+
([profile-self-intersects](ERROR-PATTERNS.md#profile-self-intersects)). Build curved
|
|
3504
|
+
outlines with `pathProfile().arcTo(to, via)`, `roundedProfile`, `filletPolygon`,
|
|
3505
|
+
`slotPolygon`, `ringSectorPolygon` and `circleProfile`; mirror a symmetric half with
|
|
3506
|
+
`mirrorProfile`.
|
|
3488
3507
|
- **Preview vs print quality:** Manifold bakes segment counts in at primitive creation,
|
|
3489
3508
|
so builds are quality-agnostic; the export path uses a separate high-res "print" kernel.
|
|
3490
3509
|
- **Display placement is view-independent**; only `place(..., { purpose: "export" })` may
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -316,6 +316,12 @@ Variant literals under this entry: `offsetPolygon: delta must be a finite number
|
|
|
316
316
|
- **Cause:** A cubic segment is missing `c1` or `c2`, or a control point is not a finite `[x,y]` (e.g. `NaN`, wrong length).
|
|
317
317
|
- **Fix:** Provide both control points as finite `[x,y]`. A cubic Bézier needs two controls between the previous point and `to`.
|
|
318
318
|
|
|
319
|
+
## arcto-radius-too-short
|
|
320
|
+
|
|
321
|
+
- **Symptom:** `pathProfile: arcTo r=<r> is shorter than half the chord (<half-chord>) from (<x0>, <y0>) to (<x1>, <y1>) — the smallest arc that can join these points has r=<half-chord> (a semicircle)`
|
|
322
|
+
- **Cause:** `pathProfile().arcTo(to, { r, sweep?, large? })`'s `r` is smaller than half the distance between the current point and `to` — no circle of that radius passes through both points.
|
|
323
|
+
- **Fix:** Raise `r` to at least half the chord (the message states the exact minimum), or move the endpoint closer. Unlike SVG's arc command, partforge refuses rather than silently scaling `r` up to fit — the model should learn the number it wrote was wrong rather than have it quietly corrected.
|
|
324
|
+
|
|
319
325
|
## shape2d-simple-not-single-region
|
|
320
326
|
|
|
321
327
|
- **Symptom:** `Shape2D.simple: result has N regions, not 1 (use toRegions())`
|
|
@@ -773,7 +779,7 @@ between the Manifold preview and the OCCT STEP export.
|
|
|
773
779
|
- **Cause:** *(partforge ≥ 0.69.)* A fillet/chamfer the geometry (or its own selector) defeats no longer fails the whole build on either backend: the op returns its input solid unchanged, everything downstream still applies, and the skip is recorded on the build result's `warnings` (`kernel.takeBuildWarnings()` / the `meshes` message's `warnings: [{part, message}]`).
|
|
774
780
|
- **Fix:** Read the parenthesized reason. A bad selector (unknown plane, wrong `at` height) is a part bug — fix the selector. A geometry-defeated blend usually wants a smaller magnitude or simpler input (clamp per the entry above), or the feature deliberately left off. Treat the warning as "this feature did not land", never as a cosmetic note — the shape on screen genuinely lacks it.
|
|
775
781
|
|
|
776
|
-
The same channel carries every other degrade in a build: an `extrude` rim bevel reduced or skipped (`extrude bevel <b> …`), a `roundedBox` rim radius clamped to `round.side`, and the `Shape2D` corner-op clamps in the two entries above. A build result's `warnings` is the complete list of what the part asked for and did not get.
|
|
782
|
+
The same channel carries every other degrade in a build: an `extrude` rim bevel reduced or skipped (`extrude bevel <b> …`), a `roundedBox` rim radius clamped to `round.side`, and the `Shape2D` corner-op clamps in the two entries above. The same channel also carries [profile-self-intersects](#profile-self-intersects) — a hand-authored outline that crosses itself and built with inverted fill. A build result's `warnings` is the complete list of what the part asked for and did not get — or, for `profile-self-intersects`, asked for and should not have.
|
|
777
783
|
|
|
778
784
|
## control-default-not-literal
|
|
779
785
|
|
|
@@ -811,6 +817,12 @@ between the Manifold preview and the OCCT STEP export.
|
|
|
811
817
|
- **Cause:** OCCT's heightfield path triangulates the depth-map grid the same way Manifold does, then goes mesh → STL → B-rep (`StlAPI_Reader` + `ShapeUpgrade_UnifySameDomain` + `MakeSolid`) so the result can boolean/fillet/export to STEP like any other B-rep shape. That sewing step can fail outright on a large or high-frequency grid — before failure, a triangle count above the plan's measured threshold already emits a slow-sew/large-STEP warning on the same build (see `feature-skipped-warning`'s sibling channel), and this is what happens when the grid is pushed further still.
|
|
812
818
|
- **Fix:** Raise `pitch` on the `k.heightfield` call to coarsen the grid (fewer triangles to sew), or keep this sub-part on the Manifold backend (drop the `meta.backend`/CAD-op pin forcing OCCT) — Manifold's heightfield path never sews through OCCT, so it has no equivalent failure mode. STEP export specifically needs OCCT, so a part that must export a relief to STEP has to bring the triangle count under the sewable range rather than avoid OCCT.
|
|
813
819
|
|
|
820
|
+
## profile-self-intersects
|
|
821
|
+
|
|
822
|
+
- **Symptom:** The build succeeds and its result carries a warning like `extrude: profile self-intersects near (12.3400, -6.3800) — the outline crosses itself, so the fill inverts there …` (also `prism: profile …`, `revolve: profile …`, `sweep: profile …`, `loft: ring 2 …`, `shape2d: profile …`), and the rendered part has a cavity, a missing lobe, or a sliver where a curved edge should be. Identical text on both backends.
|
|
823
|
+
- **Cause:** *(partforge ≥ 0.112.)* A hand-authored 2-D profile (a point list, a `pathProfile` contour, a `{outer, holes}` region — handed to a factory op, to `k.shape2d`, or as a `Shape2D` boolean operand) crosses itself. Manifold fills a point ring even-odd, so the crossing quietly inverts the fill on one side instead of failing. The usual author of the crossing is an arc sampled into points by hand (a `Math.cos` loop) whose sweep sign or endpoint order is wrong, or a mirrored half whose point order was not reversed. The kernel now runs `validateProfile` on the way in and reports each crossing on the build result's `warnings`; a `Shape2D` is not re-validated, `text2d`/`vector2d` lifts are trusted, and a profile over 4000 segments is skipped (for `loft`, the ceiling is the sum over all its rings). At most **three** crossings are reported per profile — the third reads `… (and N more crossings on this profile)` — so one badly-drawn star cannot evict every other warning in the build. A hole whose edge touches or runs along its outer is **not** reported: that builds exactly as drawn, and only a contour crossing itself inverts the fill.
|
|
824
|
+
- **Fix:** Rebuild the curved parts of the outline with `pathProfile(start).lineTo(p).arcTo(to, via).close()` — a three-point arc sweeps through `via`, so its direction cannot flip — or with the `partforge/geometry` helpers (`roundedProfile`, `filletPolygon`, `slotPolygon`, `ringSectorPolygon`, `circleProfile`); build a symmetric half once and `mirrorProfile` it rather than writing the mirror by hand. Confirm with `validateProfile(profile).ok` before extruding. The reported coordinate is in the profile's own frame (before any `rotate`/`at`).
|
|
825
|
+
|
|
814
826
|
# Hardware library
|
|
815
827
|
|
|
816
828
|
Reserved for `hardware-*` patterns (issue #30). No entries yet.
|
package/docs/KERNEL-CONTRACT.md
CHANGED
|
@@ -704,6 +704,25 @@ the helpers come along unmodified. (`test/kernel-contract.test.js` asserts every
|
|
|
704
704
|
`arcTo` / `cubicTo` / `close`); cubic segments become exact B-rep on OCCT and
|
|
705
705
|
facet at mesh LOD on Manifold.
|
|
706
706
|
|
|
707
|
+
**Profile validation on the way in** (0.112). `prism`, `extrude`, `revolve`, `sweep`,
|
|
708
|
+
`loft` (per ring) and `shape2d` (including boolean operands) run `validateProfile` on a
|
|
709
|
+
hand-authored profile — a point list, a `{start, segments}` contour, a `{outer, holes}`
|
|
710
|
+
region — and record each `self-intersection` issue on the build's warnings
|
|
711
|
+
(`takeBuildWarnings()`), prefixed `<op>: profile` / `loft: ring <i>`, deduplicated per
|
|
712
|
+
drain. It never throws and never changes the built geometry; a `Shape2D` is never
|
|
713
|
+
re-validated. Three bounds keep it cheap and keep its output readable: a profile over
|
|
714
|
+
4000 contour segments (counted as authored, before curve sampling) is skipped; `loft`
|
|
715
|
+
applies that ceiling to the **sum** over its rings, not per ring, so a many-ring loft
|
|
716
|
+
(everything `loftSmooth` produces) is skipped whole rather than validated ring by ring on
|
|
717
|
+
every rebuild; and at most **three** crossings are reported per profile, the third
|
|
718
|
+
carrying `(and N more crossings on this profile)`. A contact between two contours of one
|
|
719
|
+
region — a hole drawn flush with its outer — is **not** reported: it builds exactly as
|
|
720
|
+
drawn, so `validateProfile` tags it `crosses` and the warning skips it. This lives in the
|
|
721
|
+
shared front (`profile-warnings.js`), so both backends emit identical text — a host
|
|
722
|
+
implementing the kernel gets it by wiring one warner (build it beside the warnings list,
|
|
723
|
+
expose `_warnProfile`, pass `warnProfile` to the Shape2D factory, reset it on drain). Not
|
|
724
|
+
a contract-version change (additive, the import-op precedent).
|
|
725
|
+
|
|
707
726
|
### 2-D editing ops
|
|
708
727
|
|
|
709
728
|
The **2-D editing ops** are the free-function twins of the `Shape2D` transforms,
|
package/package.json
CHANGED
|
@@ -1109,9 +1109,16 @@ function selfIntersectionInRegion(contours) {
|
|
|
1109
1109
|
if (!pt) continue;
|
|
1110
1110
|
const first = eI.contourIndex <= eJ.contourIndex ? eI : eJ;
|
|
1111
1111
|
const other = first === eI ? eJ : eI;
|
|
1112
|
-
const
|
|
1112
|
+
const crossContour = other.contourIndex !== first.contourIndex;
|
|
1113
|
+
const crossSuffix = crossContour ? ` (or crosses contour ${other.contourIndex})` : "";
|
|
1113
1114
|
issues.push({
|
|
1114
1115
|
type: "self-intersection", contourIndex: first.contourIndex, segmentIndex: first.segmentIndex, point: pt,
|
|
1116
|
+
// `crosses` (present ONLY on a contact between two contours of this
|
|
1117
|
+
// region — an outer and its own hole, typically a hole drawn flush with
|
|
1118
|
+
// the outer wall) separates that case from a contour crossing ITSELF.
|
|
1119
|
+
// Both are reported; only the latter inverts the fill, so the build
|
|
1120
|
+
// warning in profile-warnings.js reports the ones without this key.
|
|
1121
|
+
...(crossContour ? { crosses: other.contourIndex } : {}),
|
|
1115
1122
|
message: `contour ${first.contourIndex} self-intersects${crossSuffix} near (${pt[0].toFixed(4)}, ${pt[1].toFixed(4)})`,
|
|
1116
1123
|
});
|
|
1117
1124
|
flagged.add(eI.contourIndex); flagged.add(eJ.contourIndex);
|
|
@@ -158,7 +158,7 @@ export function finishKernel(k) {
|
|
|
158
158
|
: k.loft({ rings: smoothLoftRings(sections, { stations, samples, closed }), ...(shading ? { shading } : {}), closed });
|
|
159
159
|
};
|
|
160
160
|
|
|
161
|
-
for (const [op, { toArgs, check }] of Object.entries(KERNEL_OP_SPECS)) {
|
|
161
|
+
for (const [op, { toArgs, check, warn }] of Object.entries(KERNEL_OP_SPECS)) {
|
|
162
162
|
const raw = k[op];
|
|
163
163
|
if (!raw) continue;
|
|
164
164
|
k[op] = (...a) => {
|
|
@@ -167,6 +167,10 @@ export function finishKernel(k) {
|
|
|
167
167
|
// degrade has to reach the build's warning list, not just the console.
|
|
168
168
|
const pos = a.length === 1 && isPlainOptions(a[0]) ? toArgs(a[0], k._recordWarning) : a;
|
|
169
169
|
check?.(...pos);
|
|
170
|
+
// A self-crossing hand-authored profile is a warning, never a failure
|
|
171
|
+
// (profile-warnings.js). Runs after `check` so a rejected input reports
|
|
172
|
+
// the op's own error, and before the backend so both backends warn alike.
|
|
173
|
+
warn?.(k._warnProfile, ...pos);
|
|
170
174
|
return raw(...pos);
|
|
171
175
|
};
|
|
172
176
|
}
|
|
@@ -231,7 +235,9 @@ export function finishKernel(k) {
|
|
|
231
235
|
const parsed = resolveFont(font);
|
|
232
236
|
const regions = textGlyphs(parsed, string, { size, align, valign, lineHeight, tracking, kerning });
|
|
233
237
|
if (regions.length === 0) throw new Error("text2d: string produced no glyph geometry (empty or all-whitespace?)");
|
|
234
|
-
|
|
238
|
+
// Glyph regions are machine-resolved by curve-fill — trusted, so a text
|
|
239
|
+
// part does not pay profile validation per glyph per rebuild.
|
|
240
|
+
return regions.map((r) => k.shape2d.trusted(r)).reduce((a, b) => a.union(b));
|
|
235
241
|
};
|
|
236
242
|
|
|
237
243
|
// 2-D vector art as a Shape2D. Backend-agnostic for the same reason text2d is:
|
|
@@ -250,8 +256,9 @@ export function finishKernel(k) {
|
|
|
250
256
|
throw new Error("vector2d: first argument must be the name of an entry in the part's `vectors` field");
|
|
251
257
|
const doc = k._vectors.get(name);
|
|
252
258
|
if (!doc) throw new Error(`vector2d: unknown vector "${name}" — declare it in the part's \`vectors\` field`);
|
|
259
|
+
// vector documents carry their own validation (VECTOR-FORMAT.md); trusted lift
|
|
253
260
|
const lift = (regions, measureAgainst = regions) =>
|
|
254
|
-
placeRegions(regions, doc.units, opts, { measureAgainst, name }).map((r) => k.shape2d(r)).reduce((a, b) => a.union(b));
|
|
261
|
+
placeRegions(regions, doc.units, opts, { measureAgainst, name }).map((r) => k.shape2d.trusted(r)).reduce((a, b) => a.union(b));
|
|
255
262
|
if (opts.shape != null) {
|
|
256
263
|
const entry = doc.shapes.get(opts.shape);
|
|
257
264
|
if (!entry) {
|
|
@@ -146,11 +146,11 @@ export const ROUTED_CAD_OPS = ["shell"];
|
|
|
146
146
|
* @property {(o:{rMajor:number,rMinor:number}) => Solid} torus centered at origin, tube centerline in the z=0 plane; 0 < rMinor < rMajor; options-only
|
|
147
147
|
* @property {(o:{size:number[],center?:boolean,round:number|{side?:number,top?:number,bottom?:number}}) => Solid} roundedBox selective edge rounding (side = vertical edges, top/bottom = rims); 0 < side < rim clamps rims down to side with a console.warn; options-only
|
|
148
148
|
* @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)
|
|
149
|
-
* @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)
|
|
150
|
-
* @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)
|
|
149
|
+
* @property {(o:{points:number[][]|{start:number[],segments:object[]},h:number,twist?:number,scaleTop?:number}) => Solid} prism extrude polygon from z=0; legacy (points,h,opts) accepted for now (see file header)
|
|
150
|
+
* @property {(o:{profile:number[][]|{start:number[],segments:object[]}|{outer:number[][],holes?:number[][][]}|Shape2D,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)
|
|
151
151
|
* @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)
|
|
152
152
|
* @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)
|
|
153
|
-
* @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)
|
|
153
|
+
* @property {(o:{profile:number[][]|Shape2D,degrees?:number}) => Solid} revolve revolve a lathe profile [[r,z],…] around Z; legacy (points,opts) accepted for now (see file header)
|
|
154
154
|
* @property {(o:{pathR:number,profileR:number,pitch:number,turns:number,z0:number,lefthand:boolean}) => Solid} helixSweptTube
|
|
155
155
|
* @property {(o:{profile:number[][],pitch:number,turns:number,lefthand?:boolean}) => Solid} screwSweep screw-motion sweep of an axial [[r,z]] profile — threads; options-only
|
|
156
156
|
* @property {(o:{d:number,pitch:number,turns:number,depth?:number,crest?:number,lefthand?:boolean,rootSink?:number,overshoot?:number}) => Solid} tappedBore compound: a tapped hole as ONE cut tool — bore plus thread, root sunk inside the bore so the two never share a face
|
|
@@ -13,6 +13,7 @@ import { addSugar } from "./solid-sugar.js";
|
|
|
13
13
|
import { makeShape2dFactory } from "./shape2d.js";
|
|
14
14
|
import { offsetRegions } from "./contour-offset.js";
|
|
15
15
|
import { finishKernel } from "./kernel-front.js";
|
|
16
|
+
import { makeProfileWarner } from "./profile-warnings.js";
|
|
16
17
|
import { meshToStl } from "./mesh-stl.js";
|
|
17
18
|
import { creasedNormals } from "./creased-normals.js";
|
|
18
19
|
import { loftShadingPolicy, SMOOTH, BLEND } from "./shading-policy.js";
|
|
@@ -116,6 +117,9 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
116
117
|
// every degrade in the build lands in one drainable list rather than only in
|
|
117
118
|
// the console.
|
|
118
119
|
const recordWarning = (msg) => { buildWarnings.push(msg); console.warn(`partforge: ${msg}`); };
|
|
120
|
+
// Profile-validity warnings (profile-warnings.js): one warner per kernel so a
|
|
121
|
+
// self-crossing profile built six times records ONE line; reset per drain.
|
|
122
|
+
const profileWarner = makeProfileWarner(recordWarning);
|
|
119
123
|
const skipFeature = (key, op, magnitude, err) => {
|
|
120
124
|
const msg = `${op} ${magnitude} failed (${String(err?.message || err).slice(0, 200)}) — feature skipped, edges left sharp`;
|
|
121
125
|
skippedOps.set(key, msg);
|
|
@@ -166,6 +170,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
166
170
|
extrude: (o) => kernel.extrude(o),
|
|
167
171
|
revolve: (o) => kernel.revolve(o),
|
|
168
172
|
recordWarning,
|
|
173
|
+
warnProfile: profileWarner.warn,
|
|
169
174
|
});
|
|
170
175
|
// Lazy CrossSection materialization, memoized through the solid cache by content
|
|
171
176
|
// hash + LOD: the same shape extruded twice (or extruded and revolved) tessellates
|
|
@@ -831,10 +836,12 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
831
836
|
// Drain the feature-skip warnings recorded since the last drain (see
|
|
832
837
|
// buildWarnings above). jobs.js calls this per sub-part so a warning is
|
|
833
838
|
// attributed to the sub-part whose build recorded it.
|
|
834
|
-
takeBuildWarnings: () => buildWarnings.splice(0),
|
|
839
|
+
takeBuildWarnings: () => { profileWarner.reset(); return buildWarnings.splice(0); },
|
|
835
840
|
// Internal (underscore = not the contract surface): the recorder shared,
|
|
836
841
|
// backend-neutral helpers report their own degrades through.
|
|
837
842
|
_recordWarning: recordWarning,
|
|
843
|
+
// Internal: the profile-validity warner the kernel front's `warn` slot calls.
|
|
844
|
+
_warnProfile: profileWarner.warn,
|
|
838
845
|
// Free every WASM object created since the last cleanup EXCEPT solids the cache
|
|
839
846
|
// still pins (they must survive for the next build to resume from them).
|
|
840
847
|
cleanup: () => { for (const o of tracked) if (!cache.isPinned(o)) o.delete?.(); tracked.length = 0; },
|
|
@@ -22,6 +22,7 @@ import { toFaceFinder } from "./face-selector.js";
|
|
|
22
22
|
import { addSugar } from "./solid-sugar.js";
|
|
23
23
|
import { makeShape2dFactory } from "./shape2d.js";
|
|
24
24
|
import { finishKernel } from "./kernel-front.js";
|
|
25
|
+
import { makeProfileWarner } from "./profile-warnings.js";
|
|
25
26
|
import { createOcctRepair } from "./occt-repair.js";
|
|
26
27
|
import { occtRoundAll } from "./occt-roundall.js";
|
|
27
28
|
import { classifyFaceGroups } from "./feature-attribution.js";
|
|
@@ -119,6 +120,9 @@ export function createOcctKernel(replicad) {
|
|
|
119
120
|
// Manifold backend's fillet/chamfer degradation.
|
|
120
121
|
const buildWarnings = [];
|
|
121
122
|
const recordWarning = (msg) => { buildWarnings.push(msg); console.warn(`partforge: ${msg}`); };
|
|
123
|
+
// Profile-validity warnings (profile-warnings.js): one warner per kernel so a
|
|
124
|
+
// self-crossing profile built six times records ONE line; reset per drain.
|
|
125
|
+
const profileWarner = makeProfileWarner(recordWarning);
|
|
122
126
|
// The raw OCCT instance, for the coincident-boolean guard (occt-coincidence.js).
|
|
123
127
|
// Absent (older replicad, or a boot path that skipped setOC) the guard is a no-op —
|
|
124
128
|
// detection is an upgrade, never a dependency.
|
|
@@ -534,6 +538,7 @@ export function createOcctKernel(replicad) {
|
|
|
534
538
|
extrude: (o) => kernel.extrude(o),
|
|
535
539
|
revolve: (o) => kernel.revolve(o),
|
|
536
540
|
recordWarning,
|
|
541
|
+
warnProfile: profileWarner.warn,
|
|
537
542
|
});
|
|
538
543
|
// Lazy Drawing materialization for the kernel ops that need one. drawingFromRegions
|
|
539
544
|
// draws a FRESH Drawing on every call, so callers never need to .clone() the result
|
|
@@ -797,10 +802,12 @@ export function createOcctKernel(replicad) {
|
|
|
797
802
|
resetCacheStats: () => cache.resetStats(),
|
|
798
803
|
// Drain the feature-skip warnings recorded since the last drain — the
|
|
799
804
|
// Manifold backend's channel, mirrored (see occt-repair.js for the sources).
|
|
800
|
-
takeBuildWarnings: () => buildWarnings.splice(0),
|
|
805
|
+
takeBuildWarnings: () => { profileWarner.reset(); return buildWarnings.splice(0); },
|
|
801
806
|
// Internal: the recorder shared, backend-neutral helpers report through
|
|
802
807
|
// (rim-bevel, roundedBox's clamp, Shape2D corner-op clamps).
|
|
803
808
|
_recordWarning: recordWarning,
|
|
809
|
+
// Internal: the profile-validity warner the kernel front's `warn` slot calls.
|
|
810
|
+
_warnProfile: profileWarner.warn,
|
|
804
811
|
});
|
|
805
812
|
return kernel;
|
|
806
813
|
}
|
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
// a call is options form when the op receives exactly one plain-object argument.
|
|
6
6
|
// kernel-front.js and solid-sugar.js apply these at the backend-shared seams, so
|
|
7
7
|
// backends stay positional and the Manifold solid cache hashes normalized args —
|
|
8
|
-
// both spellings of a call share one cache entry. Geometry-free by design
|
|
8
|
+
// both spellings of a call share one cache entry. Geometry-free by design — it is
|
|
9
|
+
// inside partforge/lint's import closure (test/lint-purity.test.js), so it may not
|
|
10
|
+
// import profile-warnings.js either: that reaches paper.js through contour-ops.js.
|
|
11
|
+
// The `warn` slots take everything they need on the warner they are handed.
|
|
9
12
|
|
|
10
13
|
export function isPlainOptions(x) {
|
|
11
14
|
if (x === null || typeof x !== "object") return false;
|
|
@@ -250,11 +253,15 @@ export const KERNEL_OP_SPECS = {
|
|
|
250
253
|
cylinder: { toArgs: cylinderArgs },
|
|
251
254
|
sphere: { toArgs: sphereArgs },
|
|
252
255
|
box: { toArgs: boxArgs },
|
|
253
|
-
|
|
256
|
+
// `warn` (finishKernel calls it after `check`, with the kernel's profile
|
|
257
|
+
// warner first) reports a self-crossing hand-authored profile as a build
|
|
258
|
+
// warning — the build proceeds; see profile-warnings.js.
|
|
259
|
+
prism: { toArgs: prismArgs, check: checkScaleTop("prism"),
|
|
260
|
+
warn: (warnProfile, points) => warnProfile?.("prism: profile", points) },
|
|
254
261
|
extrude: { toArgs: extrudeArgs, check: (profile, h, opts) => {
|
|
255
262
|
checkNonEmptyProfile("extrude", profile);
|
|
256
263
|
checkScaleTop("extrude")(profile, h, opts);
|
|
257
|
-
} },
|
|
264
|
+
}, warn: (warnProfile, profile) => warnProfile?.("extrude: profile", profile) },
|
|
258
265
|
revolve: { toArgs: revolveArgs, check: (pts) => {
|
|
259
266
|
checkNonEmptyProfile("revolve", pts);
|
|
260
267
|
if (pts && pts._shape2d) {
|
|
@@ -265,10 +272,32 @@ export const KERNEL_OP_SPECS = {
|
|
|
265
272
|
if (pts.boundingBox().min[0] < -1e-5) throw new Error("revolve: profile radius must be ≥ 0");
|
|
266
273
|
return;
|
|
267
274
|
}
|
|
275
|
+
// revolve takes a lathe POINT LIST or a Shape2D — it has no contour path, and a
|
|
276
|
+
// {start, segments} contour used to reach the loop below and die as a bare
|
|
277
|
+
// "pts is not iterable" TypeError. Name the real problem instead.
|
|
278
|
+
if (!Array.isArray(pts) || !pts.every((p) => Array.isArray(p)))
|
|
279
|
+
throw new Error("revolve: profile must be an [[r, z], …] point list or a Shape2D (a {start, segments} contour is not accepted — lift it with k.shape2d first)");
|
|
268
280
|
for (const [r] of pts) if (r < 0) throw new Error("revolve: profile radius must be ≥ 0");
|
|
281
|
+
}, warn: (warnProfile, pts) => warnProfile?.("revolve: profile", pts) },
|
|
282
|
+
// The loft ceiling is an AGGREGATE over the rings, never per ring: loftSmooth
|
|
283
|
+
// densifies a handful of control sections into dozens of rings, each of which
|
|
284
|
+
// sits comfortably under the per-profile bound, so a per-ring ceiling let a
|
|
285
|
+
// many-ring loft revalidate the whole stack on every rebuild (+30% measured on
|
|
286
|
+
// src/parts/propeller.js). Sum first; past the ceiling the whole loft is skipped.
|
|
287
|
+
loft: { toArgs: loftArgs, warn: (warnProfile, rings) => {
|
|
288
|
+
if (!Array.isArray(rings) || !warnProfile) return;
|
|
289
|
+
const authored = (r) => r?.polygon && !r.polygon._shape2d;
|
|
290
|
+
// `segmentCount`/`maxSegments` ride on the warner (makeProfileWarner) rather
|
|
291
|
+
// than being imported, to keep this module geometry-free — see the header. A
|
|
292
|
+
// warner without them can still warn; it just cannot bound the whole loft.
|
|
293
|
+
if (warnProfile.segmentCount) {
|
|
294
|
+
let total = 0;
|
|
295
|
+
for (const r of rings) if (authored(r)) total += warnProfile.segmentCount(r.polygon);
|
|
296
|
+
if (total > warnProfile.maxSegments) return;
|
|
297
|
+
}
|
|
298
|
+
rings.forEach((r, i) => { if (authored(r)) warnProfile(`loft: ring ${i}`, r.polygon); });
|
|
269
299
|
} },
|
|
270
|
-
|
|
271
|
-
sweep: { toArgs: sweepArgs },
|
|
300
|
+
sweep: { toArgs: sweepArgs, warn: (warnProfile, profile) => warnProfile?.("sweep: profile", profile) },
|
|
272
301
|
boredCylinder: { toArgs: passThrough("boredCylinder", ["od", "h", "bore"], ["od", "h", "bore"]) },
|
|
273
302
|
helixSweptTube: { toArgs: passThrough("helixSweptTube",
|
|
274
303
|
["pathR", "profileR", "pitch", "turns", "z0", "lefthand"], ["pathR", "profileR", "pitch", "turns"]) },
|
|
@@ -157,8 +157,9 @@ export function filletPolygon(points, r, { segs = 8 } = {}) {
|
|
|
157
157
|
}
|
|
158
158
|
|
|
159
159
|
// Fluent builder for a curve-native path contour { start, segments }. Segment kinds:
|
|
160
|
-
// lineTo → {to}, arcTo → {to,via} (three-point arc)
|
|
161
|
-
// close() returns the plain contour object
|
|
160
|
+
// lineTo → {to}, arcTo → {to,via} (three-point arc) or {to,via} computed from a radius
|
|
161
|
+
// spec, cubicTo → {to,c1,c2} (cubic Bézier). close() returns the plain contour object
|
|
162
|
+
// (feeds extrude/revolve/prism), not a Solid.
|
|
162
163
|
export function pathProfile(start) {
|
|
163
164
|
const fin2 = (p, what) => {
|
|
164
165
|
if (!Array.isArray(p) || p.length < 2 || !Number.isFinite(p[0]) || !Number.isFinite(p[1]))
|
|
@@ -166,12 +167,73 @@ export function pathProfile(start) {
|
|
|
166
167
|
return [p[0], p[1]];
|
|
167
168
|
};
|
|
168
169
|
const s = fin2(start, "start");
|
|
170
|
+
let cur = s;
|
|
169
171
|
const segments = [];
|
|
170
172
|
const api = {
|
|
171
|
-
lineTo(to) {
|
|
172
|
-
|
|
173
|
+
lineTo(to) {
|
|
174
|
+
const p = fin2(to, "lineTo point");
|
|
175
|
+
segments.push({ to: p });
|
|
176
|
+
cur = p;
|
|
177
|
+
return api;
|
|
178
|
+
},
|
|
179
|
+
// arcTo(to, via) is the three-point form: `via` is any point on the arc.
|
|
180
|
+
// arcTo(to, { r, sweep?, large? }) is the radius form: `via` is computed here
|
|
181
|
+
// from the current point, `to`, and the radius spec so the emitted segment is
|
|
182
|
+
// byte-for-byte what the three-point form emits (see docs/AUTHORING-PARTS.md).
|
|
183
|
+
arcTo(to, second) {
|
|
184
|
+
const p1 = fin2(to, "arcTo point");
|
|
185
|
+
if (Array.isArray(second)) {
|
|
186
|
+
const via = fin2(second, "arcTo via");
|
|
187
|
+
segments.push({ to: p1, via });
|
|
188
|
+
cur = p1;
|
|
189
|
+
return api;
|
|
190
|
+
}
|
|
191
|
+
if (second !== null && typeof second === "object") {
|
|
192
|
+
const ARC_SPEC_KEYS = ["r", "sweep", "large"];
|
|
193
|
+
const unknownKeys = Object.keys(second).filter((k) => !ARC_SPEC_KEYS.includes(k));
|
|
194
|
+
if (unknownKeys.length > 0)
|
|
195
|
+
throw new Error(
|
|
196
|
+
`pathProfile: arcTo arc spec has unknown ${unknownKeys.length > 1 ? "keys" : "key"} ${unknownKeys.map((k) => JSON.stringify(k)).join(", ")} — the keys are r, sweep, large`,
|
|
197
|
+
);
|
|
198
|
+
const { r, sweep = "ccw", large = false } = second;
|
|
199
|
+
// Cheap key/enum/boolean checks run BEFORE the numeric ones below, so a
|
|
200
|
+
// typo'd sweep/large is reported on its own rather than being masked by
|
|
201
|
+
// an unrelated radius complaint on the same call.
|
|
202
|
+
if (sweep !== "ccw" && sweep !== "cw")
|
|
203
|
+
throw new Error(`pathProfile: arcTo sweep must be "ccw" or "cw", got ${JSON.stringify(sweep)}`);
|
|
204
|
+
if (typeof large !== "boolean")
|
|
205
|
+
throw new Error("pathProfile: arcTo large must be a boolean");
|
|
206
|
+
const [x0, y0] = cur;
|
|
207
|
+
const [x1, y1] = p1;
|
|
208
|
+
const dx = x1 - x0, dy = y1 - y0;
|
|
209
|
+
const d = Math.hypot(dx, dy);
|
|
210
|
+
if (d < 1e-9)
|
|
211
|
+
throw new Error(`pathProfile: arcTo to (${x1}, ${y1}) coincides with the current point`);
|
|
212
|
+
if (!(r > 0) || !Number.isFinite(r))
|
|
213
|
+
throw new Error(`pathProfile: arcTo r must be > 0 and finite, got ${JSON.stringify(r)}`);
|
|
214
|
+
if (r < d / 2 - 1e-9)
|
|
215
|
+
throw new Error(
|
|
216
|
+
`pathProfile: arcTo r=${r} is shorter than half the chord (${(d / 2).toFixed(4)}) from (${x0}, ${y0}) to (${x1}, ${y1}) — the smallest arc that can join these points has r=${(d / 2).toFixed(4)} (a semicircle)`,
|
|
217
|
+
);
|
|
218
|
+
const rr = Math.max(r, d / 2); // absorb the 1e-9 tolerance so h is never NaN
|
|
219
|
+
const h = Math.sqrt(rr * rr - (d / 2) * (d / 2)); // centre's distance from the chord midpoint
|
|
220
|
+
const ux = dx / d, uy = dy / d; // unit chord direction
|
|
221
|
+
const nx = -uy, ny = ux; // unit LEFT normal of the direction of travel
|
|
222
|
+
const mx = (x0 + x1) / 2, my = (y0 + y1) / 2;
|
|
223
|
+
const side = sweep === "ccw" ? -1 : 1; // which side of the chord the arc bulges to
|
|
224
|
+
const sgn = large ? side : -side;
|
|
225
|
+
const cx = mx + nx * h * sgn, cy = my + ny * h * sgn;
|
|
226
|
+
const via = [cx + nx * rr * side, cy + ny * rr * side]; // the arc's midpoint
|
|
227
|
+
segments.push({ to: p1, via });
|
|
228
|
+
cur = p1;
|
|
229
|
+
return api;
|
|
230
|
+
}
|
|
231
|
+
throw new Error("pathProfile: arcTo needs a via [x,y] or an { r, sweep?, large? } arc spec");
|
|
232
|
+
},
|
|
173
233
|
cubicTo(to, c1, c2) {
|
|
174
|
-
|
|
234
|
+
const p = fin2(to, "cubicTo point");
|
|
235
|
+
segments.push({ to: p, c1: fin2(c1, "cubicTo c1"), c2: fin2(c2, "cubicTo c2") });
|
|
236
|
+
cur = p;
|
|
175
237
|
return api;
|
|
176
238
|
},
|
|
177
239
|
close() {
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Profile-validity warnings: the pure half of the "your outline crosses itself"
|
|
2
|
+
// signal. A hand-authored 2-D profile (a point list, a pathProfile contour, a
|
|
3
|
+
// {outer, holes} region) that self-intersects builds WITHOUT error on both
|
|
4
|
+
// backends — Manifold fills even-odd, so the crossing quietly inverts the fill
|
|
5
|
+
// (a lobe becomes a hole). Nothing told the author. This module turns
|
|
6
|
+
// validateProfile's self-intersection issues into build-warning messages;
|
|
7
|
+
// the kernel front (KERNEL_OP_SPECS' `warn` slot) and the Shape2D factory's
|
|
8
|
+
// liftRegions call it, and each backend owns one warner so a message lands at
|
|
9
|
+
// most once per build (six placements of one bad socket profile = one line).
|
|
10
|
+
//
|
|
11
|
+
// Never throws, never blocks: a profile liftProfile rejects is left for the
|
|
12
|
+
// op's own error path, and a Shape2D is never re-validated (it was validated
|
|
13
|
+
// when it was lifted, and every boolean it went through resolves crossings).
|
|
14
|
+
import { liftProfile, validateProfile } from "./contour-ops.js";
|
|
15
|
+
|
|
16
|
+
// Contour segments above which validation is skipped, so an unusually dense
|
|
17
|
+
// authored profile can never make this the slowest step of a build. The count is
|
|
18
|
+
// of CONTOUR SEGMENTS as authored, BEFORE curve sampling: the validator expands
|
|
19
|
+
// each curved segment into VALIDATE_SEGS (8) sampled edges, so a curve-heavy
|
|
20
|
+
// profile at the ceiling costs roughly 8× the edges a polyline one does. The
|
|
21
|
+
// ceiling therefore bounds AUTHORED SIZE, not validator work exactly. Authored
|
|
22
|
+
// geometry is expected to sit far below it. `loft` applies it to the SUM over its
|
|
23
|
+
// rings (see op-options.js) — per ring it would not bound a many-ring loft at all.
|
|
24
|
+
export const PROFILE_VALIDATE_MAX_SEGMENTS = 4000;
|
|
25
|
+
|
|
26
|
+
// Messages emitted per profile. A profile that crosses itself once usually
|
|
27
|
+
// crosses itself many times (a 24-point {24/7} star reports 144), and the host's
|
|
28
|
+
// warning list is short and shared with every other degrade in the build — an
|
|
29
|
+
// unbounded profile would evict all of them. The last message carries the count
|
|
30
|
+
// of the crossings it stands in for.
|
|
31
|
+
export const PROFILE_WARN_MAX_PER_PROFILE = 3;
|
|
32
|
+
|
|
33
|
+
const COACH =
|
|
34
|
+
"the outline crosses itself, so the fill inverts there (a lobe becomes a hole, or vice versa). " +
|
|
35
|
+
"A hand-sampled arc traversed the wrong way is the usual cause; build curved contours with " +
|
|
36
|
+
"pathProfile().arcTo(to, via) or the partforge/geometry helpers, and check the outline with validateProfile().";
|
|
37
|
+
|
|
38
|
+
const segmentCount = (regions) =>
|
|
39
|
+
regions.reduce((n, rg) => n + rg.outer.segments.length + rg.holes.reduce((m, h) => m + h.segments.length, 0), 0);
|
|
40
|
+
|
|
41
|
+
// Authored contour segments in one profile, before curve sampling. 0 for a
|
|
42
|
+
// Shape2D (never re-validated) and for anything liftProfile rejects (the op's own
|
|
43
|
+
// error path) — both are profiles this module will not validate anyway, so they
|
|
44
|
+
// contribute nothing to a caller summing the ceiling across several profiles.
|
|
45
|
+
export function profileSegmentCount(profile) {
|
|
46
|
+
if (!profile || profile._shape2d) return 0;
|
|
47
|
+
try { return segmentCount(liftProfile(profile).regions); } catch { return 0; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// One message per self-intersection issue, prefixed by the op and role that
|
|
51
|
+
// received the profile (`extrude: profile`, `loft: ring 2`, `shape2d: profile`).
|
|
52
|
+
export function profileWarningMessages(prefix, profile) {
|
|
53
|
+
if (!profile || profile._shape2d) return [];
|
|
54
|
+
let lifted;
|
|
55
|
+
try { lifted = liftProfile(profile); } catch { return []; }
|
|
56
|
+
if (segmentCount(lifted.regions) > PROFILE_VALIDATE_MAX_SEGMENTS) return [];
|
|
57
|
+
let result;
|
|
58
|
+
try { result = validateProfile(profile); } catch { return []; }
|
|
59
|
+
// `crosses` marks a contact BETWEEN two contours of one region — an outer and
|
|
60
|
+
// its own hole sharing an edge, the commonest of which is a hole flush with the
|
|
61
|
+
// outer wall. validateProfile files those under self-intersection, but they
|
|
62
|
+
// build exactly as drawn and inverted fill is not what happens, so they are not
|
|
63
|
+
// this warning's subject. Only a contour crossing ITSELF is reported.
|
|
64
|
+
const crossings = result.issues.filter(
|
|
65
|
+
(i) => i.type === "self-intersection" && i.crosses === undefined && Array.isArray(i.point));
|
|
66
|
+
const text = (i) => `${prefix} self-intersects near (${i.point[0].toFixed(4)}, ${i.point[1].toFixed(4)}) — ${COACH}`;
|
|
67
|
+
const msgs = crossings.slice(0, PROFILE_WARN_MAX_PER_PROFILE).map(text);
|
|
68
|
+
const more = crossings.length - PROFILE_WARN_MAX_PER_PROFILE;
|
|
69
|
+
if (more > 0) msgs[msgs.length - 1] += ` (and ${more} more crossings on this profile)`;
|
|
70
|
+
return msgs;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// A per-kernel warner: `warn` records each distinct message at most once until
|
|
74
|
+
// `reset` (the backend calls reset inside takeBuildWarnings, i.e. per drain).
|
|
75
|
+
export function makeProfileWarner(recordWarning) {
|
|
76
|
+
const seen = new Set();
|
|
77
|
+
const warn = (prefix, profile) => {
|
|
78
|
+
if (typeof recordWarning !== "function") return;
|
|
79
|
+
for (const msg of profileWarningMessages(prefix, profile)) {
|
|
80
|
+
if (seen.has(msg)) continue;
|
|
81
|
+
seen.add(msg);
|
|
82
|
+
recordWarning(msg);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
// The ceiling and the counter travel WITH the warner because op-options.js's
|
|
86
|
+
// `loft` slot has to apply them to the SUM over the rings and that module must
|
|
87
|
+
// stay geometry-free — it sits inside partforge/lint's import closure, which
|
|
88
|
+
// may reach no geometry module at all (test/lint-purity.test.js), and this one
|
|
89
|
+
// reaches paper.js through contour-ops.js.
|
|
90
|
+
warn.segmentCount = profileSegmentCount;
|
|
91
|
+
warn.maxSegments = PROFILE_VALIDATE_MAX_SEGMENTS;
|
|
92
|
+
return { reset: () => seen.clear(), warn };
|
|
93
|
+
}
|
|
@@ -10,7 +10,10 @@ export function addShape2dSugar(s, { shape2d, extrude, revolve }) {
|
|
|
10
10
|
return regions[0];
|
|
11
11
|
};
|
|
12
12
|
// .regions() → scission: each disjoint region as its own live Shape2D (booleanable further).
|
|
13
|
-
|
|
13
|
+
// `trusted`: these regions are this shape's own, already validated when it was
|
|
14
|
+
// lifted and resolved by every boolean since — re-lifting them untrusted would
|
|
15
|
+
// re-run profile validation on machine-produced geometry on every call.
|
|
16
|
+
s.regions = () => s.toRegions().map((r) => shape2d.trusted(r));
|
|
14
17
|
// .extrude({ h, twist?, scaleTop? }) / .revolve({ degrees? }) → Solid. Sugar for
|
|
15
18
|
// k.extrude({ profile: shape, … }) / k.revolve({ profile: shape, … }). Passed as an
|
|
16
19
|
// options object (not positional) so the kernel op's key/required-arg validation still
|
|
@@ -49,13 +49,18 @@ const checkProfile = (x) => {
|
|
|
49
49
|
// occt-backend). Corner ops CLAMP a magnitude the geometry cannot take rather
|
|
50
50
|
// than throwing, and a clamp that only reached the console would leave a caller
|
|
51
51
|
// believing it got the radius it asked for.
|
|
52
|
-
export function makeShape2dFactory({ segs, extrude, revolve, recordWarning }) {
|
|
52
|
+
export function makeShape2dFactory({ segs, extrude, revolve, recordWarning, warnProfile }) {
|
|
53
53
|
// Lift any accepted profile form into stored regions: a live Shape2D is deep-copied out
|
|
54
54
|
// via its own toContours() (value semantics — never alias another shape's storage);
|
|
55
|
-
// anything else goes through liftProfile + per-ring winding normalization.
|
|
56
|
-
|
|
55
|
+
// anything else goes through liftProfile + per-ring winding normalization. A raw
|
|
56
|
+
// profile is also the one place a hand-authored outline enters 2-D storage — from
|
|
57
|
+
// k.shape2d(x) or as a boolean operand — so it is where a self-crossing one is
|
|
58
|
+
// reported (profile-warnings.js). `trusted` skips that for the kernel's own
|
|
59
|
+
// machine-resolved lifts (text2d glyphs, vector2d documents).
|
|
60
|
+
const liftRegions = (x, { trusted = false } = {}) => {
|
|
57
61
|
if (x && x._shape2d) return deepCopy(x._regions);
|
|
58
62
|
checkProfile(x);
|
|
63
|
+
if (!trusted) warnProfile?.("shape2d: profile", x);
|
|
59
64
|
return liftProfile(x).regions.map(ensureRegionWinding);
|
|
60
65
|
};
|
|
61
66
|
|
|
@@ -99,5 +104,6 @@ export function makeShape2dFactory({ segs, extrude, revolve, recordWarning }) {
|
|
|
99
104
|
return addShape2dSugar(s, { shape2d, extrude, revolve });
|
|
100
105
|
};
|
|
101
106
|
const shape2d = (profile) => (profile && profile._shape2d ? profile : make(liftRegions(profile)));
|
|
107
|
+
shape2d.trusted = (profile) => (profile && profile._shape2d ? profile : make(liftRegions(profile, { trusted: true })));
|
|
102
108
|
return shape2d;
|
|
103
109
|
}
|
package/types/geometry.d.ts
CHANGED
|
@@ -86,6 +86,8 @@ export interface PathProfileBuilder {
|
|
|
86
86
|
lineTo(to: Point2): PathProfileBuilder;
|
|
87
87
|
/** A circular arc to `to` passing through `via`. */
|
|
88
88
|
arcTo(to: Point2, via: Point2): PathProfileBuilder;
|
|
89
|
+
/** A circular arc to `to` of radius `r`; `via` is computed from the current point. */
|
|
90
|
+
arcTo(to: Point2, arc: { r: number; sweep?: "ccw" | "cw"; large?: boolean }): PathProfileBuilder;
|
|
89
91
|
/** A cubic Bézier to `to` with control points `c1`/`c2`. */
|
|
90
92
|
cubicTo(to: Point2, c1: Point2, c2: Point2): PathProfileBuilder;
|
|
91
93
|
/** Close the contour and return it. Needs at least one segment. */
|