partforge 0.20.2 → 0.22.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.
@@ -504,10 +504,15 @@ body = body.cutAll(circularPattern(hole, 8, { axis: "Z" })); // 8 bolt holes o
504
504
  // Keyhole plate: union a disc onto a rect, punch a slot, extrude.
505
505
  const plate = k.shape2d(roundedRectPolygon(40, 24, 4))
506
506
  .union(circleProfile(8))
507
- .cut(slotPolygon(16, 3));
508
- k.extrude({ profile: plate, h: 3 });
507
+ .cut(slotPolygon(16, 3))
508
+ .extrude({ h: 3 }); // sugar for k.extrude({ profile: …, h: 3 }); .revolve({ degrees }) too
509
509
  ```
510
510
 
511
+ A `Shape2D` also carries `.extrude({ h, twist?, scaleTop? })` and `.revolve({ degrees? })`
512
+ sugar (equivalent to the `k.extrude`/`k.revolve` forms), and `.regions()` — scission, which
513
+ returns each disjoint region as its own live `Shape2D` (vs `.toRegions()`, which returns raw
514
+ `{outer, holes}` data).
515
+
511
516
  ```js
512
517
  // A 0.2 mm printer clearance around a bore, then a 2 mm wall inset:
513
518
  const bore = k.shape2d(circleProfile(3)).offset(0.2); // looser
@@ -518,6 +523,19 @@ const wall = k.shape2d(outer).offset(-2, { corners: "sharp" }); // inset, mite
518
523
 
519
524
  `Shape2D.offset(delta, {corners})` grows (`delta>0`) or insets (`delta<0`) a shape with round/chamfer/sharp corners — curve-preserving on OCCT, faceted at mesh LOD on Manifold; it throws if the offset collapses the shape. (For `derive()`/main-thread clearance math on plain point lists, use the pure `offsetPolygon` helper instead.)
520
525
 
526
+ ## Convex hull
527
+
528
+ `k.hull([a, b, …])` wraps its inputs (Shape2Ds, curve contours, or point lists) in a
529
+ convex `Shape2D`. `k.hullChain([a, b, c, …])` sweeps the hull along an ordered sequence
530
+ (≥2 inputs) — the union of `hull([a,b])`, `hull([b,c])`, … — for capsules, rounded slots,
531
+ and organic tapers. Faceted (curved inputs facet at mesh LOD): the hull is a pure-JS
532
+ monotone-chain computation, never a native backend op.
533
+
534
+ ```js
535
+ const capsule = k.hull([circleProfile(4, [0, 0]), circleProfile(4, [20, 0])]); // a stadium
536
+ const slot = k.hullChain([circleProfile(3, [0, 0]), circleProfile(3, [15, 0]), circleProfile(2, [25, 5])]);
537
+ ```
538
+
521
539
  ## Text (`text2d`)
522
540
 
523
541
  `k.text2d(string, { size, font?, align?, valign?, lineHeight?, tracking?, kerning? })` renders outline-font text as a `Shape2D` — a 2-D boolean you can compose with other shapes (union / cut / offset) and extrude into 3-D geometry.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.20.2",
3
+ "version": "0.22.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",
@@ -0,0 +1,9 @@
1
+ import part from "./parts/hull-sweep.js";
2
+ import { mount } from "./framework/index.js";
3
+
4
+ // Dev example app for the hull-sweep demo (src/parts/hull-sweep.js). The
5
+ // `new Worker(new URL(...))` call must stay inline here so Vite bundles the worker.
6
+ mount(part, {
7
+ createWorker: (name) =>
8
+ new Worker(new URL("./hull-sweep-worker.js", import.meta.url), { type: "module", name }),
9
+ });
@@ -9,12 +9,16 @@
9
9
  // 4. unite(self) to normalize overlaps and crossings into simple paths.
10
10
  import paper from "paper/dist/paper-core.js";
11
11
 
12
- // Never use paper's package-global project: another consumer in the same worker may import
13
- // paper too. This resolver owns and clears only this private, headless scope.
14
- const scope = new paper.PaperScope();
15
- scope.setup(new scope.Size(1, 1));
12
+ // Lazy, private PaperScope: built on first use (not at module load), so parts that never
13
+ // call k.text2d don't pull paper-core's setup onto the geometry worker. Never paper's
14
+ // package-global project another consumer in the same worker may import paper too.
15
+ let _scope = null;
16
+ function paperScope() {
17
+ if (!_scope) { _scope = new paper.PaperScope(); _scope.setup(new _scope.Size(1, 1)); }
18
+ return _scope;
19
+ }
16
20
 
17
- function toPaperPath(contour) {
21
+ function toPaperPath(scope, contour) {
18
22
  const path = new scope.Path({ insert: false });
19
23
  path.moveTo(new scope.Point(contour.start[0], contour.start[1]));
20
24
  for (const s of contour.segments) {
@@ -67,10 +71,11 @@ export function resolveCurveFill(contours, { fillRule = "nonzero" } = {}) {
67
71
  if (fillRule !== "nonzero" && fillRule !== "evenodd")
68
72
  throw new Error('curve-fill: fillRule must be "nonzero" or "evenodd"');
69
73
  if (!contours || contours.length === 0) return [];
74
+ const scope = paperScope();
70
75
  try {
71
76
  const simple = [];
72
77
  for (const ct of contours) {
73
- const resolved = toPaperPath(ct).resolveCrossings();
78
+ const resolved = toPaperPath(scope, ct).resolveCrossings();
74
79
  const kids = resolved.className === "CompoundPath" ? resolved.children : [resolved];
75
80
  for (const k of kids) if (k.segments && k.segments.length >= 2) simple.push(k.clone({ insert: false }));
76
81
  }
@@ -0,0 +1,50 @@
1
+ // Pure, backend-free 2-D convex hull (Andrew's monotone chain) + input sampling for
2
+ // k.hull / k.hullChain (wired in kernel-front). No WASM, no kernel — a pure function
3
+ // of its inputs, so hull output for point-list/contour inputs is backend-independent.
4
+ import { tessellateContour } from "./profile.js";
5
+
6
+ // Fixed LOD for curve-contour inputs. Sampling in pure JS (not via a backend's
7
+ // materialization) is what makes point/contour hull results bit-identical across backends.
8
+ const HULL_SEGS = 64;
9
+
10
+ // One HullInput → its contributing points.
11
+ // Shape2D → its materialized boundary rings (outer + holes; holes are interior
12
+ // to a convex hull, harmless);
13
+ // curve contour → tessellated at a fixed LOD (pure JS);
14
+ // point list → used as-is (any length ≥ 1; e.g. circleProfile's 48-gon).
15
+ export function hullPoints(input) {
16
+ if (input && input._shape2d)
17
+ return input.toRegions().flatMap((r) => [...r.outer, ...r.holes.flat()]);
18
+ if (Array.isArray(input) && input.length > 0 && Array.isArray(input[0]))
19
+ return input;
20
+ if (input && Array.isArray(input.segments))
21
+ return tessellateContour(input, HULL_SEGS);
22
+ throw new Error("hull: each input must be a Shape2D, a curve contour, or an [[x,y],…] point list");
23
+ }
24
+
25
+ // Convex hull of a point set → CCW convex polygon [[x,y],…]. Andrew's monotone chain,
26
+ // O(n log n). Drops interior and on-edge points (strict turns only), so a collinear set
27
+ // collapses to < 3 vertices → throw (it cannot bound a 2-D region).
28
+ export function convexHull(points) {
29
+ const seen = new Set();
30
+ const pts = [];
31
+ for (const p of points) {
32
+ const key = `${p[0]},${p[1]}`;
33
+ if (!seen.has(key)) { seen.add(key); pts.push([p[0], p[1]]); }
34
+ }
35
+ if (pts.length < 3) throw new Error(`hull: need ≥3 distinct points, got ${pts.length}`);
36
+ pts.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
37
+ const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
38
+ const half = (src) => {
39
+ const out = [];
40
+ for (const p of src) {
41
+ while (out.length >= 2 && cross(out[out.length - 2], out[out.length - 1], p) <= 0) out.pop();
42
+ out.push(p);
43
+ }
44
+ out.pop(); // last point is shared with the other half's first
45
+ return out;
46
+ };
47
+ const hull = half(pts).concat(half([...pts].reverse()));
48
+ if (hull.length < 3) throw new Error("hull: points are collinear — no 2-D region");
49
+ return hull;
50
+ }
@@ -17,6 +17,7 @@ import { KernelCapabilityError } from "./errors.js";
17
17
  import { isPlainOptions, KERNEL_OP_SPECS } from "./op-options.js";
18
18
  import { textGlyphs } from "./text2d.js";
19
19
  import { DEFAULT_FONT_BYTES } from "./fonts/default-font.js";
20
+ import { convexHull, hullPoints } from "./hull.js";
20
21
 
21
22
  export function finishKernel(k) {
22
23
  // Compound default: bored-through cylinder (tool overshoots 2 mm each end for
@@ -88,5 +89,24 @@ export function finishKernel(k) {
88
89
  return regions.map((r) => k.shape2d(r)).reduce((a, b) => a.union(b));
89
90
  };
90
91
 
92
+ // Convex hull → Shape2D. Backend-agnostic: pure-JS monotone-chain hull of the inputs'
93
+ // sampled points, lifted via k.shape2d. Faceted (curved inputs at a fixed LOD).
94
+ k.hull = (inputs) => {
95
+ if (!Array.isArray(inputs) || inputs.length === 0)
96
+ throw new Error("hull: inputs must be a non-empty array");
97
+ return k.shape2d(convexHull(inputs.flatMap(hullPoints)));
98
+ };
99
+ // Swept hull over an ordered sequence (≥2): union of the hull of each consecutive pair.
100
+ k.hullChain = (inputs) => {
101
+ if (!Array.isArray(inputs) || inputs.length < 2)
102
+ throw new Error("hullChain: needs at least 2 inputs");
103
+ let acc = null;
104
+ for (let i = 0; i < inputs.length - 1; i++) {
105
+ const seg = k.hull([inputs[i], inputs[i + 1]]);
106
+ acc = acc ? acc.union(seg) : seg;
107
+ }
108
+ return acc;
109
+ };
110
+
91
111
  return k;
92
112
  }
@@ -19,7 +19,7 @@ export const CONTRACT_VERSION = 1;
19
19
  // Ops every backend kernel must implement.
20
20
  export const KERNEL_OPS = [
21
21
  "cylinder", "boredCylinder", "sphere", "box", "prism", "extrude", "revolve",
22
- "loft", "sweep", "helixSweptTube", "union", "shape2d", "text2d", "toSTEP",
22
+ "loft", "sweep", "helixSweptTube", "union", "shape2d", "text2d", "hull", "hullChain", "toSTEP",
23
23
  ];
24
24
 
25
25
  // Backend-optional kernel ops: the Manifold cache brackets + WASM lifetime hooks.
@@ -42,7 +42,8 @@ export const SOLID_OPTIONAL_OPS = ["genus", "isEmpty"];
42
42
 
43
43
  // Public methods every Shape2D exposes (2-D boolean value; contract-linted).
44
44
  export const SHAPE2D_OPS = [
45
- "union", "cut", "cutAll", "intersect", "offset", "area", "boundingBox", "toRegions", "simple", "clone",
45
+ "union", "cut", "cutAll", "intersect", "offset", "area", "boundingBox", "toRegions", "simple", "regions", "clone",
46
+ "extrude", "revolve",
46
47
  ];
47
48
 
48
49
  // Solid ops only OCCT implements natively. Single source of truth: probe.js routes
@@ -105,6 +106,8 @@ export const OCCT_ONLY_OPS = ["fillet", "chamfer", "shell"];
105
106
  * @property {(o:{pathR:number,profileR:number,pitch:number,turns:number,z0:number,lefthand:boolean}) => Solid} helixSweptTube
106
107
  * @property {(solids:Solid[]) => Solid} union
107
108
  * @property {(profile: number[][]|{outer:number[][],holes?:number[][][]}|Shape2D) => Shape2D} shape2d 2-D boolean value (both backends: Manifold wraps a CrossSection, OCCT a replicad Drawing)
109
+ * @property {(inputs: (Shape2D|number[][]|{start:number[],segments:object[]})[]) => Shape2D} hull convex hull of all inputs → a convex Shape2D (faceted; pure-JS monotone chain)
110
+ * @property {(inputs: (Shape2D|number[][]|{start:number[],segments:object[]})[]) => Shape2D} hullChain swept hull over an ordered sequence (≥2): union of hull([inᵢ,inᵢ₊₁])
108
111
  * @property {(named:{name:string,solid:Solid}[]) => Promise<ArrayBuffer>} toSTEP OCCT only (Manifold throws KernelCapabilityError)
109
112
  * @property {(name:string) => void} [beginSubPart] open a per-sub-part solid-cache round (Manifold only)
110
113
  * @property {() => void} [endSubPart] close the cache round (always pair with beginSubPart)
@@ -98,7 +98,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
98
98
  boundingBox: () => { const r = cs.bounds(); return { min: [r.min[0], r.min[1]], max: [r.max[0], r.max[1]] }; },
99
99
  toRegions: () => assembleRegions(cs.toPolygons()),
100
100
  clone: () => wrapShape2d(cs, hash),
101
- });
101
+ }, { shape2d, extrude: kernel.extrude, revolve: kernel.revolve });
102
102
  const shape2d = (profile) => {
103
103
  if (profile && profile._shape2d) return profile; // idempotent
104
104
  const hash = h("shape2d", profile, segs);
@@ -184,7 +184,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
184
184
  toIndexedMesh: () => indexedMeshOut(m),
185
185
  });
186
186
 
187
- return finishKernel({
187
+ const kernel = finishKernel({
188
188
  cylinder: (rb, rt, h2, { center = false } = {}) =>
189
189
  wrap(T(Manifold.cylinder(h2, rb, rt, segs, center)), h("cylinder", rb, rt, h2, center, segs)),
190
190
  // Compound op: hashed ATOMICALLY from its own args, so it is a single cache
@@ -251,6 +251,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
251
251
  // still pins (they must survive for the next build to resume from them).
252
252
  cleanup: () => { for (const o of tracked) if (!cache.isPinned(o)) o.delete?.(); tracked.length = 0; },
253
253
  });
254
+ return kernel;
254
255
  }
255
256
 
256
257
  // Build a non-indexed mesh with normals that are smooth within a single original
@@ -189,7 +189,7 @@ export function createOcctKernel(replicad) {
189
189
  boundingBox: () => { const b = drawing.boundingBox; return { min: [b.bounds[0][0], b.bounds[0][1]], max: [b.bounds[1][0], b.bounds[1][1]] }; },
190
190
  toRegions,
191
191
  clone: () => wrapShape2d(drawing.clone()),
192
- });
192
+ }, { shape2d, extrude: kernel.extrude, revolve: kernel.revolve });
193
193
  };
194
194
  const shape2d = (profile) => (profile && profile._shape2d ? profile : wrapShape2d(drawingFromProfile(profile)));
195
195
 
@@ -265,7 +265,7 @@ export function createOcctKernel(replicad) {
265
265
  return wrap(genericSweep(profile, spine, { frenet: true }));
266
266
  };
267
267
 
268
- return finishKernel({
268
+ const kernel = finishKernel({
269
269
  cylinder, // boredCylinder: the kernel front's default composition is exactly right here
270
270
  box: (min, max) => wrap(makeBox(min, max)), prism, extrude, revolve, loft: loftOp, sweep, helixSweptTube,
271
271
  sphere: (r) => wrap(makeSphere(r)),
@@ -276,4 +276,5 @@ export function createOcctKernel(replicad) {
276
276
  shape2d,
277
277
  toSTEP: (named) => exportSTEP(named.map(({ name, solid }) => ({ name, shape: solid._s }))).arrayBuffer(),
278
278
  });
279
+ return kernel;
279
280
  }
@@ -1,11 +1,22 @@
1
- // Backend-shared Shape2D front. Like solid-sugar for Solids, but the 2-D shared
2
- // surface is small: .simple() unwraps a single-region materialization or throws.
3
- // Backends attach the geometry ops (booleans, area, boundingBox, toRegions).
4
- export function addShape2dSugar(s) {
1
+ // Backend-shared Shape2D front. Like solid-sugar for Solids: backends attach the geometry
2
+ // ops (booleans, area, boundingBox, toRegions); this layers on the backend-agnostic sugar.
3
+ // `deps` are the backend's own functions the sugar defers to: `shape2d` (lift a region back
4
+ // into a Shape2D) and `extrude`/`revolve` (build a Solid from this shape).
5
+ export function addShape2dSugar(s, { shape2d, extrude, revolve }) {
6
+ // .simple() → the single {outer,holes} region, or throw (a raw region, not a Shape2D).
5
7
  s.simple = () => {
6
8
  const regions = s.toRegions();
7
9
  if (regions.length !== 1) throw new Error(`Shape2D.simple: result has ${regions.length} regions, not 1 (use toRegions())`);
8
10
  return regions[0];
9
11
  };
12
+ // .regions() → scission: each disjoint region as its own live Shape2D (booleanable further).
13
+ s.regions = () => s.toRegions().map((r) => shape2d(r));
14
+ // .extrude({ h, twist?, scaleTop? }) / .revolve({ degrees? }) → Solid. Sugar for
15
+ // k.extrude({ profile: shape, … }) / k.revolve({ profile: shape, … }). Passed as an
16
+ // options object (not positional) so the kernel op's key/required-arg validation still
17
+ // fires — e.g. a missing `h` throws "extrude: h is required" rather than silently
18
+ // producing empty geometry.
19
+ s.extrude = ({ h, twist, scaleTop } = {}) => extrude({ profile: s, h, twist, scaleTop });
20
+ s.revolve = ({ degrees } = {}) => revolve({ profile: s, degrees });
10
21
  return s;
11
22
  }
@@ -0,0 +1,3 @@
1
+ import part from "./parts/hull-sweep.js";
2
+ import { runWorker } from "./framework/worker.js";
3
+ runWorker(part);
@@ -0,0 +1,70 @@
1
+ // Demo part — a hull sweep. Showcases k.hull / k.hullChain: a row of circles of
2
+ // varying radius along an arched spine becomes either one convex blob (k.hull) or a
3
+ // smooth swept strap/taper (k.hullChain) — the capsule/rounded-slot/organic-taper
4
+ // payoff. Optionally bores a hole at each node (Shape2D.cutAll) to make a linkage.
5
+ // Open /hull-sweep.html after `npm run dev`. Toggle "Convex wrap" to see hull vs
6
+ // hullChain side by side.
7
+ import { circleProfile } from "partforge/geometry";
8
+
9
+ export default {
10
+ meta: { title: "Hull sweep", units: "mm", background: 0x15181d },
11
+ parameters: [
12
+ {
13
+ id: "sweep",
14
+ title: "Sweep",
15
+ description: "A row of circles along an arched spine. `k.hullChain` sweeps the hull from one to the next (a strap/taper); `k.hull` wraps them all in one convex outline (see the Mode toggle).",
16
+ advanced: [
17
+ { key: "nodes", label: "Nodes", unit: "", min: 2, max: 6, step: 1,
18
+ description: "How many circles along the spine (2 = a single capsule)." },
19
+ { key: "length", label: "Length", unit: "mm", min: 20, max: 120, step: 1,
20
+ description: "Span from the first node to the last." },
21
+ { key: "r0", label: "Start radius", unit: "mm", min: 2, max: 16, step: 0.5,
22
+ description: "Radius of the first node." },
23
+ { key: "r1", label: "End radius", unit: "mm", min: 1, max: 16, step: 0.5,
24
+ description: "Radius of the last node — set it below the start for a taper." },
25
+ { key: "bow", label: "Arch", unit: "mm", min: 0, max: 30, step: 1,
26
+ description: "Vertical bow of the middle nodes. 0 = a straight strap; higher = a banana/arch." },
27
+ ],
28
+ },
29
+ {
30
+ id: "solid",
31
+ title: "Solid",
32
+ advanced: [
33
+ { key: "thickness", label: "Thickness", unit: "mm", min: 1.5, max: 10, step: 0.5,
34
+ description: "Extrude height." },
35
+ ],
36
+ },
37
+ {
38
+ id: "mode",
39
+ title: "Mode",
40
+ toggles: [
41
+ { key: "wrap", label: "Convex wrap (k.hull instead of k.hullChain)", on: 1,
42
+ description: "On: one convex hull of every node (a single convex blob). Off: the swept chain — the hull of each consecutive pair, unioned." },
43
+ { key: "holes", label: "Bore a hole at each node", on: 1,
44
+ description: "Cut a circular hole at every node (Shape2D.cutAll) — turns the strap into a linkage." },
45
+ ],
46
+ },
47
+ ],
48
+ defaults: { nodes: 3, length: 60, r0: 8, r1: 4, bow: 8, thickness: 4, wrap: 0, holes: 0 },
49
+ parts: {
50
+ sweep: {
51
+ label: "Hull sweep",
52
+ views: ["sweep"],
53
+ export: { name: "hull-sweep" },
54
+ build: (k, p) => {
55
+ const n = Math.max(2, Math.round(p.nodes));
56
+ const nodes = [];
57
+ for (let i = 0; i < n; i++) {
58
+ const t = i / (n - 1);
59
+ nodes.push({ x: -p.length / 2 + t * p.length, y: p.bow * Math.sin(Math.PI * t), r: p.r0 + (p.r1 - p.r0) * t });
60
+ }
61
+ const circles = nodes.map((nd) => circleProfile(nd.r, [nd.x, nd.y]));
62
+ let shape = p.wrap ? k.hull(circles) : k.hullChain(circles);
63
+ if (p.holes)
64
+ shape = shape.cutAll(nodes.map((nd) => circleProfile(Math.max(0.8, nd.r * 0.45), [nd.x, nd.y])));
65
+ return k.extrude({ profile: shape, h: p.thickness });
66
+ },
67
+ },
68
+ },
69
+ views: { sweep: { label: "Hull sweep" } },
70
+ };