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.
@@ -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 with one free-form curved side (exact on STEP, faceted at mesh LOD):
434
- const tab = pathProfile([0, 0])
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: tab, h: 3 });
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` (not yet
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
@@ -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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.111.1",
3
+ "version": "0.113.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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 crossSuffix = other.contourIndex === first.contourIndex ? "" : ` (or crosses contour ${other.contourIndex})`;
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
- return regions.map((r) => k.shape2d(r)).reduce((a, b) => a.union(b));
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
- prism: { toArgs: prismArgs, check: checkScaleTop("prism") },
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
- loft: { toArgs: loftArgs },
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), cubicTo {to,c1,c2} (cubic Bézier).
161
- // close() returns the plain contour object (feeds extrude/revolve/prism), not a Solid.
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) { segments.push({ to: fin2(to, "lineTo point") }); return api; },
172
- arcTo(to, via) { segments.push({ to: fin2(to, "arcTo point"), via: fin2(via, "arcTo via") }); return api; },
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
- segments.push({ to: fin2(to, "cubicTo point"), c1: fin2(c1, "cubicTo c1"), c2: fin2(c2, "cubicTo c2") });
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
- s.regions = () => s.toRegions().map((r) => shape2d(r));
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
- const liftRegions = (x) => {
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
  }
@@ -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. */