partforge 0.55.0 → 0.56.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.
@@ -28,7 +28,7 @@ export const BUILD_RULES = [
28
28
  {
29
29
  id: "unknown-solid-op",
30
30
  run: ({ probe }) => unique(probe().issues
31
- .filter((i) => i.kind === "unknown-op" && i.scope === "solid").map((i) => i.op))
31
+ .filter((i) => i.kind === "unknown-op" && i.scope !== "kernel").map((i) => i.op))
32
32
  .map((op) => err("unknown-solid-op", `\`.${op}(…)\` is not a Solid or Shape2D method`,
33
33
  `Remove the call or correct the name — see the Solid and Shape2D method tables in docs/AUTHORING-PARTS.md.`,
34
34
  "parts")),
@@ -52,7 +52,10 @@ export const BUILD_RULES = [
52
52
  id: "manifold-backend-uses-occt-op",
53
53
  run: ({ part, probe }) => {
54
54
  if (part?.meta?.backend !== "manifold") return [];
55
- return [...probe().used].filter((op) => OCCT_ONLY.has(op))
55
+ // solidUsed, not used: `Shape2D.fillet`/`.chamfer` are backend-identical pure
56
+ // JS and are fine under a pinned Manifold backend; only Solid-handle uses of
57
+ // these names are CAD-only.
58
+ return [...probe().solidUsed].filter((op) => OCCT_ONLY.has(op))
56
59
  .map((op) => err("manifold-backend-uses-occt-op",
57
60
  `\`meta.backend\` pins Manifold, but the build calls \`${op}\`, which only OCCT implements`,
58
61
  `Remove \`meta.backend: "manifold"\` and let the probe route this part to OCCT, or replace \`${op}\` with a mesh-friendly construction — as written the build throws KernelCapabilityError.`,
@@ -0,0 +1,3 @@
1
+ import part from "./parts/gasket.js";
2
+ import { runWorker } from "./framework/worker.js";
3
+ runWorker(part);
@@ -0,0 +1,111 @@
1
+ // Reference part for the 2-D editing surface (docs/AUTHORING-PARTS.md "Editing
2
+ // profiles"): a gasket plate whose outline is a curve-native `pathProfile` (one cubic
3
+ // bulge edge), with two bolt bosses unioned onto the bottom edge — their circular tabs
4
+ // centered exactly ON that edge, the coincident-edge boolean case paper.js has known
5
+ // rough edges around (see the design doc's "two carve-outs"). The corners are then
6
+ // rounded with `.fillet(…, { corners: "convex" })`, the bolt holes cut, an optional
7
+ // print-clearance offset applied, and the whole profile extruded.
8
+ //
9
+ // No `meta.backend` override needed: the probe tracks which handle kind an op ran
10
+ // on, so this build's `Shape2D.fillet` (backend-identical pure JS — KERNEL-CONTRACT.md
11
+ // "One shared implementation") does not read as a CAD-only op, and the part
12
+ // auto-routes to fast Manifold like any other non-B-rep part.
13
+ import { pathProfile, circleProfile } from "partforge/geometry";
14
+
15
+ // Pure dimension math shared with test/gasket-part.test.js, which re-derives the same
16
+ // 2-D profile with the free contour-ops/paper-bridge functions (no kernel) to check
17
+ // validateProfile/profileCorners/toContours-shaped output independent of the build.
18
+ // Exported alongside the default PartDefinition — still DOM-free and side-effect-free.
19
+ export function gasketGeometry(p) {
20
+ const w2 = p.w / 2;
21
+ const dep = p.w * 0.6; // plate depth (top edge sits at y = dep)
22
+ const bulge = p.w * 0.18; // how far the top edge's cubic bulges outward
23
+ const tabR = p.boltR + 1.5; // boss radius — enough wall around the hole
24
+ const tabX = p.w * 0.25; // boss centers, symmetric about x = 0
25
+ const tabSegs = 12; // low-poly boss facets — a fillet needs a chord it can fit inside
26
+ // Deterministic backoff (AUTHORING-PARTS "Editing profiles" — fillet throws rather
27
+ // than clamping) so cornerR never overruns a boss's own facets or the margins/gap
28
+ // between the outline and a boss, at any width/bolt-radius in the sliders' range.
29
+ // The 0.5 factor covers `fillet`'s "soft cap" halving an edge's share whenever BOTH
30
+ // its neighboring corners are selected too — true here, since `corners: "convex"`
31
+ // selects every convex corner at once, including each boss's own facet corners.
32
+ const marginX = w2 - tabX - tabR; // material outboard of each boss
33
+ const gapX = tabX - tabR; // half the gap between the two bosses
34
+ const filletR = Math.max(0, Math.min(p.cornerR, (marginX - 0.3) / 2, (gapX - 0.3) / 2, dep * 0.2, tabR * 0.4));
35
+ return { w2, dep, bulge, tabR, tabX, tabSegs, filletR };
36
+ }
37
+
38
+ export default {
39
+ meta: { title: "Gasket", units: "mm", background: 0x15181d },
40
+ parameters: [
41
+ {
42
+ id: "gasket",
43
+ title: "Gasket",
44
+ description: "A curvy gasket plate with two bolt bosses. Demonstrates `pathProfile`, `Shape2D.union`/`.fillet`/`.cut`/`.offset`, and a coincident-edge boolean (the bosses straddle the outline's bottom edge).",
45
+ controls: [
46
+ { key: "w", label: "Width", unit: "mm", min: 24, max: 80, step: 1,
47
+ description: "Overall plate width. The plate's depth, bulge, and boss spacing all scale from this one dimension." },
48
+ { key: "h", label: "Thickness", unit: "mm", min: 1, max: 8, step: 0.5,
49
+ description: "Plate thickness (the extrusion height)." },
50
+ { key: "boltR", label: "Bolt hole radius", unit: "mm", min: 1, max: 2.5, step: 0.1,
51
+ description: "Radius of each mounting hole, cut through the boss at the end." },
52
+ { key: "cornerR", label: "Corner radius", unit: "mm", min: 0, max: 4, step: 0.25,
53
+ description: "Fillet applied to every convex corner (`Shape2D.fillet`). Automatically backed off if the plate/bosses are too small to fit it — see the build's `filletR` clamp." },
54
+ { key: "clearance", label: "Print-clearance offset", unit: "mm", min: 0, max: 1, step: 0.1,
55
+ description: "Grows the whole outline outward (round corners) for a looser slip fit. 0 = none." },
56
+ ],
57
+ },
58
+ ],
59
+ defaults: { w: 44, h: 3, boltR: 1.6, cornerR: 2, clearance: 0 },
60
+ parts: {
61
+ gasket: {
62
+ label: "Gasket",
63
+ views: ["gasket"],
64
+ export: { name: "gasket" },
65
+ build: (k, p) => {
66
+ const { w2, dep, bulge, tabR, tabX, tabSegs, filletR } = gasketGeometry(p);
67
+
68
+ // Curve-native outline: straight sides and bottom, one cubic bulge across the
69
+ // top. `close()` leaves the bottom-left→start edge implicit; contour-ops
70
+ // re-closes it explicitly wherever that matters (corner/fillet math).
71
+ const outline = pathProfile([-w2, 0])
72
+ .lineTo([w2, 0])
73
+ .lineTo([w2, dep])
74
+ .cubicTo([-w2, dep], [w2 * 0.5, dep + bulge], [-w2 * 0.5, dep + bulge])
75
+ .close();
76
+
77
+ // Boss centers sit exactly ON the bottom edge (y = 0) — the coincident-edge
78
+ // union case: each tab straddles the existing straight edge rather than
79
+ // merely touching it at a point.
80
+ const tabs = [[-tabX, 0], [tabX, 0]].map(([cx, cy]) => circleProfile(tabR, [cx, cy], tabSegs));
81
+
82
+ // Fillet the outer convex corners, THEN cut the bolt holes. Note this ordering
83
+ // trades away the STEP-CIRCLE fidelity the "fillet after booleans" rule buys
84
+ // (AUTHORING-PARTS.md "Editing profiles"): the hole cuts are themselves paper.js
85
+ // booleans, so they degrade the fillet's true `{to,via}` arcs back to cubic
86
+ // approximations before export. That's fine here — the fillet still reads as a
87
+ // rounded corner (curve-exact until the cut, faceted-cubic after) — but a part
88
+ // that needs an exact STEP CIRCLE on a filleted corner should cut first and
89
+ // fillet last instead.
90
+ let plate = k.shape2d(outline).union(tabs[0]).union(tabs[1]);
91
+ if (filletR > 0) plate = plate.fillet(filletR, { corners: "convex" });
92
+ plate = plate.cut(circleProfile(p.boltR, [-tabX, 0])).cut(circleProfile(p.boltR, [tabX, 0]));
93
+ if (p.clearance) plate = plate.offset(p.clearance);
94
+
95
+ return plate.extrude({ h: p.h });
96
+ },
97
+ },
98
+ },
99
+ views: { gasket: { label: "Gasket" } },
100
+ // Now that Shape2D.fillet no longer routes the part to OCCT, this builds on
101
+ // Manifold and can gate `holes` directly (Manifold-only topology —
102
+ // ERROR-PATTERNS.md#occt-holes-watertight-na); test/gasket-part.test.js's
103
+ // genus() === 2 check pins the same fact against a directly-booted kernel.
104
+ verify: {
105
+ process: "fdm-pla",
106
+ expect: {
107
+ gasket: { bbox: "<=[60,50,6]", holes: 2 },
108
+ _view: { overlaps: 0 },
109
+ },
110
+ },
111
+ };
@@ -3,9 +3,29 @@
3
3
  // DOM-free and kernel-free: this is the entry a part's build functions import
4
4
  // (importing "partforge" inside a worker throws `document is not defined`).
5
5
 
6
- import type { ArcContour, Point2, Point3, PointsContour, Region2D, Solid } from "./kernel.js";
6
+ import type {
7
+ ArcContour,
8
+ Contour,
9
+ Corner2D,
10
+ CornerSelector,
11
+ MirrorAxis2,
12
+ Point2,
13
+ Point3,
14
+ PointsContour,
15
+ Region2D,
16
+ Solid,
17
+ } from "./kernel.js";
18
+
19
+ export type { ArcContour, Contour, Corner2D, CornerSelector, MirrorAxis2, Point2, Point3, PointsContour, Region2D, Solid };
7
20
 
8
- export type { ArcContour, Point2, Point3, PointsContour, Region2D, Solid };
21
+ /**
22
+ * Anything the 2-D editing ops below accept: a plain point list, a curve-native
23
+ * contour, a `{outer, holes}` region, or a region array. Every op returns the
24
+ * same shape of input it was given (a bare point list stays a point list, unless
25
+ * the op introduces curves — e.g. a non-uniform `scaleProfile` on an arc — in
26
+ * which case it upgrades to a `Contour`).
27
+ */
28
+ export type ProfileInput = number[][] | Contour | Region2D | Region2D[];
9
29
 
10
30
  /** A pie/sector wedge with its tip at the origin. */
11
31
  export function piePolygon(tipR: number, arcDeg: number, segs?: number): PointsContour;
@@ -115,3 +135,102 @@ export function circularPattern(
115
135
  rotateCopies?: boolean;
116
136
  },
117
137
  ): Solid[];
138
+
139
+ // ── 2-D editing ops ──────────────────────────────────────────────────────────
140
+ // Polymorphic input contract: every op below accepts a point list, a curve-native
141
+ // contour, a region, or a region array, and returns the same shape of input it
142
+ // was given — except the arc-length queries (profileLength/profilePointAt/
143
+ // profileTangentAt), which are single-contour by nature and throw on a region.
144
+
145
+ /** Translate every contour by `[dx, dy]`. Exact on all segment types. */
146
+ export function translateProfile(input: ProfileInput, delta: Point2): ProfileInput;
147
+
148
+ /** Rotate `deg` degrees about `center` (default the origin). Arcs stay arcs. */
149
+ export function rotateProfile(input: ProfileInput, deg: number, center?: Point2): ProfileInput;
150
+
151
+ /**
152
+ * Scale by a uniform or per-axis factor about `center` (default the origin).
153
+ * A non-uniform `[sx, sy]` converts `{to, via}` arcs to cubics (an ellipse is
154
+ * not a circular arc). Scale factors must be finite and non-zero.
155
+ */
156
+ export function scaleProfile(input: ProfileInput, s: number | Point2, center?: Point2): ProfileInput;
157
+
158
+ /** Reflect across `"x"`, `"y"`, or an arbitrary `{point, dir}` line. */
159
+ export function mirrorProfile(input: ProfileInput, axis: MirrorAxis2): ProfileInput;
160
+
161
+ /**
162
+ * Round selected corners with true arcs. `r` may be an array paired
163
+ * positionally with `{indices}`. Throws if no corner matches, or if `r`
164
+ * does not fit against its neighboring segments.
165
+ */
166
+ export function filletProfile(input: ProfileInput, r: number | number[], opts?: { corners?: CornerSelector }): ProfileInput;
167
+
168
+ /**
169
+ * Bevel selected corners with a straight chord (symmetric setback). `dist`
170
+ * may be an array paired positionally with `{indices}`. Throws if no corner
171
+ * matches, or if `dist` does not fit against its neighboring segments.
172
+ */
173
+ export function chamferProfile(input: ProfileInput, dist: number | number[], opts?: { corners?: CornerSelector }): ProfileInput;
174
+
175
+ /**
176
+ * The corner list — `{index, point, interiorAngleDeg, convex, segTypes}[]`,
177
+ * plus `{regionIndex, ring}` for region/regions input. This positional order
178
+ * is what `filletProfile`/`chamferProfile`'s `{indices}` selects into.
179
+ */
180
+ export function profileCorners(input: ProfileInput): Corner2D[];
181
+
182
+ /** Total arc length of a single contour, in mm. */
183
+ export function profileLength(contour: Contour): number;
184
+
185
+ /** The point at normalized position `t` (0..1) or absolute arc `length` along a single contour. */
186
+ export function profilePointAt(contour: Contour, opts: { t: number } | { length: number }): Point2;
187
+
188
+ /** The unit tangent at normalized position `t` (0..1) or absolute arc `length` along a single contour. */
189
+ export function profileTangentAt(contour: Contour, opts: { t: number } | { length: number }): Point2;
190
+
191
+ /**
192
+ * The closest point on `input` to `[x, y]` — the pick-resolution primitive.
193
+ * Accepts regions (unlike the arc-length queries above); `contourIndex`/
194
+ * `segmentIndex` follow the same flattened outer-then-holes-per-region order
195
+ * `validateProfile` uses. `t` is normalized 0..1, but for a `{to, via}` arc
196
+ * segment (expanded internally into ≤90° cubic pieces that share one
197
+ * `segmentIndex`) it is local to whichever piece the nearest point landed
198
+ * on, not a position along the arc's full sweep.
199
+ */
200
+ export function profileNearestPoint(
201
+ input: ProfileInput,
202
+ p: Point2,
203
+ ): { point: Point2; distance: number; contourIndex: number; segmentIndex: number; t: number };
204
+
205
+ /** Curve-exact axis-aligned bounds across every contour in `input`. */
206
+ export function profileBounds(input: ProfileInput): { min: Point2; max: Point2 };
207
+
208
+ /** Net curve-exact area (Σ|outers| − Σ|holes|), mm². */
209
+ export function profileArea(input: ProfileInput): number;
210
+
211
+ /** Curve-aware point-in-shape test (inside an outer, not inside a hole). */
212
+ export function profileContains(input: ProfileInput, p: Point2): boolean;
213
+
214
+ /**
215
+ * Corner-preserving decimation/refit within `tolerance` mm: splits at corners,
216
+ * then reduces or refits each run independently, reassembling with corner
217
+ * points bit-exact preserved. A run that gains curves upgrades a point-list
218
+ * input to a `Contour`.
219
+ */
220
+ export function simplifyProfile(input: ProfileInput, tolerance: number): ProfileInput;
221
+
222
+ /** One issue `validateProfile` reports. Never thrown — only returned. */
223
+ export interface ProfileIssue {
224
+ type: "degenerate" | "self-intersection" | "winding" | "nesting";
225
+ contourIndex: number;
226
+ segmentIndex?: number;
227
+ point?: Point2;
228
+ message: string;
229
+ }
230
+
231
+ /**
232
+ * Geometric sanity checks (degenerate segments/area, self-intersection,
233
+ * winding, nesting) against `input`'s sampled approximation. Never throws on
234
+ * geometric badness — only an unrecognized `input` shape throws.
235
+ */
236
+ export function validateProfile(input: ProfileInput): { ok: boolean; issues: ProfileIssue[] };
package/types/kernel.d.ts CHANGED
@@ -95,6 +95,39 @@ export type AxisName = "X" | "Y" | "Z";
95
95
  /** Convex-corner style for an offset. */
96
96
  export type OffsetCorners = "round" | "chamfer" | "sharp";
97
97
 
98
+ /** A stored contour-IR region: curve-native contours, nothing flattened. */
99
+ export interface ContourRegion {
100
+ outer: ArcContour;
101
+ holes: ArcContour[];
102
+ }
103
+
104
+ /** One corner of a `Shape2D`, as `Shape2D.corners()` reports it. */
105
+ export interface Corner2D {
106
+ /** Index of the joint within its own contour. */
107
+ index: number;
108
+ point: Point2;
109
+ interiorAngleDeg: number;
110
+ convex: boolean;
111
+ /** Segment kinds meeting here, incoming then outgoing. */
112
+ segTypes: Array<"line" | "arc" | "cubic">;
113
+ /** Present only for multi-region shapes. */
114
+ regionIndex?: number;
115
+ /** Present only for multi-region shapes. */
116
+ ring?: "outer" | { hole: number };
117
+ }
118
+
119
+ /** Which corners a `Shape2D` `fillet`/`chamfer` applies to. Default `"all"`. */
120
+ export type CornerSelector =
121
+ | "all"
122
+ | "convex"
123
+ | "concave"
124
+ /** Positional indices into `corners()`; a per-corner `r`/`d` array pairs with these. */
125
+ | { indices: number[] }
126
+ | { near: Point2; count?: number };
127
+
128
+ /** A mirror line for `Shape2D.mirror`. */
129
+ export type MirrorAxis2 = "x" | "y" | { point: Point2; dir: Point2 };
130
+
98
131
  // --- edge / face selectors (OCCT-only ops) ---------------------------------
99
132
 
100
133
  /**
@@ -115,8 +148,13 @@ export type FaceSelector = EdgeSelector;
115
148
  // --- Shape2D ----------------------------------------------------------------
116
149
 
117
150
  /**
118
- * An opaque 2-D boolean value (Manifold wraps a CrossSection, OCCT a replicad
119
- * Drawing). `_`-prefixed keys are backend internals and are not declared.
151
+ * A 2-D sketch value: booleans, transforms, corner ops and queries. ONE shared
152
+ * implementation on both backends storage is the curve-native contour IR, so
153
+ * arcs and béziers survive every op and results are backend-identical except
154
+ * `offset`. No backend geometry exists until the shape is extruded/revolved or
155
+ * materialized via `toRegions()`. `_`-prefixed keys are internals, not declared.
156
+ *
157
+ * Every op returns a NEW `Shape2D`; no operand is ever mutated.
120
158
  */
121
159
  export interface Shape2D {
122
160
  union(other: Shape2D | Contour): Shape2D;
@@ -125,20 +163,42 @@ export interface Shape2D {
125
163
  cutAll(others: Array<Shape2D | Contour>): Shape2D;
126
164
  intersect(other: Shape2D | Contour): Shape2D;
127
165
  /**
128
- * Grow (`delta > 0`) or inset (`delta < 0`). Curve-preserving on OCCT,
129
- * faceted at mesh LOD on Manifold. Throws if the offset collapses the shape.
166
+ * Grow (`delta > 0`) or inset (`delta < 0`). The one backend-specific op:
167
+ * curve-preserving on OCCT, faceted at mesh LOD on Manifold. Throws if the
168
+ * offset collapses the shape.
130
169
  */
131
170
  offset(delta: number, opts?: { corners?: OffsetCorners; segs?: number }): Shape2D;
132
- /** Net area (outers minus holes), mm². */
171
+ /** Net area (outers minus holes), mm². Curve-exact — not measured off a tessellation. */
133
172
  area(): number;
173
+ /** Axis-aligned 2-D bounds, curve-exact. */
134
174
  boundingBox(): BoundingBox2;
135
- /** Materialize into region arrays. */
175
+ /** Materialize into point-ring region arrays, tessellating curves at the backend's LOD. */
136
176
  toRegions(): MaterializedRegion[];
177
+ /** The stored contour IR — curve-native and lossless. A deep copy, safe to mutate. */
178
+ toContours(): ContourRegion[];
137
179
  /** `toRegions()` unwrapped — throws unless there is exactly one region. */
138
180
  simple(): MaterializedRegion;
139
181
  /** Scission: each disjoint region as its own live `Shape2D`. */
140
182
  regions(): Shape2D[];
141
183
  clone(): Shape2D;
184
+ /** Translate by `[dx, dy]`. */
185
+ translate(v: Point2): Shape2D;
186
+ /** Rotate `deg` about `center` (default the origin). */
187
+ rotate(deg: number, center?: Point2): Shape2D;
188
+ /** Scale about `center` (default the origin). A single `number` scales uniformly; `[sx, sy]` scales each axis independently. */
189
+ scale(factor: number | [number, number], center?: Point2): Shape2D;
190
+ /** Reflect across an axis line. */
191
+ mirror(axis: MirrorAxis2): Shape2D;
192
+ /** Round selected corners with true arcs. `r` may be an array paired with `{ indices }`. */
193
+ fillet(r: number | number[], opts?: { corners?: CornerSelector }): Shape2D;
194
+ /** Bevel selected corners with straight chords. `d` may be an array paired with `{ indices }`. */
195
+ chamfer(d: number | number[], opts?: { corners?: CornerSelector }): Shape2D;
196
+ /** Corner-preserving decimation/refit within `tolerance` mm. */
197
+ simplify(tolerance: number): Shape2D;
198
+ /** The corner list — the positional order `fillet`/`chamfer`'s `{ indices }` selects into. */
199
+ corners(): Corner2D[];
200
+ /** Is `[x, y]` inside the shape (inside an outer, not inside a hole)? */
201
+ contains(p: Point2): boolean;
142
202
  /** Sugar for `k.extrude({ profile: this, ... })`. */
143
203
  extrude(opts: { h: number; twist?: number; scaleTop?: number }): Solid;
144
204
  /** Sugar for `k.revolve({ profile: this, ... })`. */