partforge 0.137.0 → 0.137.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.137.0",
3
+ "version": "0.137.1",
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",
@@ -21,6 +21,7 @@ import { toEdgeFinder } from "./edge-selector.js";
21
21
  import { toFaceFinder } from "./face-selector.js";
22
22
  import { addSugar } from "./solid-sugar.js";
23
23
  import { makeShape2dFactory } from "./shape2d.js";
24
+ import { groupInteractingRegions } from "./region-interaction.js";
24
25
  import { finishKernel } from "./kernel-front.js";
25
26
  import { makeProfileWarner } from "./profile-warnings.js";
26
27
  import { createOcctRepair } from "./occt-repair.js";
@@ -519,15 +520,47 @@ export function createOcctKernel(replicad) {
519
520
  for (const hole of holes) region = region.cut(contourDrawing(hole));
520
521
  return region;
521
522
  };
522
- // Region list -> fused Drawing: draw each region's outer contour, cut its holes
523
- // out, then fuse every region together — the multi-region generalization of
524
- // drawingFromProfile above. Used by extrude/revolve (via drawingFor below) to
525
- // materialize a Shape2D's stored contours into a Drawing.
526
- const drawingFromRegions = (regions) => regions.reduce((acc, rg) => {
527
- let region = contourDrawing(rg.outer);
528
- for (const hole of rg.holes) region = region.cut(contourDrawing(hole));
529
- return acc ? acc.fuse(region) : region;
530
- }, null);
523
+ // Region list -> Drawing, for extrude/revolve (via drawingFor below).
524
+ //
525
+ // A Shape2D's regions are NOT guaranteed disjoint: booleans/offset emit disjoint
526
+ // regions (an island inside a hole is its own top-level region), but k.shape2d([...])
527
+ // lifts a hand-authored region array verbatim, so two of them may overlap. The old
528
+ // left-fold `acc.fuse(region)` unioned them correctly but re-organised every
529
+ // accumulated blueprint on each fuse (replicad organiseBlueprints → containment
530
+ // search), so N regions cost O(N²) — a ~1,500-triangle lattice took ~45 s and
531
+ // gigabytes of embind wrappers. Now regions are grouped by whether they actually
532
+ // INTERACT (see regionsInteract); a region that touches no other is turned straight
533
+ // into Blueprints with no boolean at all, and only genuinely interacting clusters
534
+ // are fused, as a balanced tree.
535
+ //
536
+ // A region becomes an outer Blueprint plus its hole Blueprints in one CompoundBlueprint
537
+ // (outer first — what organiseBlueprints would produce); holes are never cut one at a
538
+ // time, since each cut2D re-organises too. Hole winding is irrelevant: replicad's
539
+ // CompoundSketch.face() runs ShapeFix_Face.FixOrientation.
540
+ const { Drawing, Blueprints, CompoundBlueprint } = replicad;
541
+ const bpOf = (contour) => contourDrawing(contour).innerShape;
542
+ const regionBlueprint = (rg) => (rg.holes.length
543
+ ? new CompoundBlueprint([bpOf(rg.outer), ...rg.holes.map(bpOf)])
544
+ : bpOf(rg.outer));
545
+ const fuseBalanced = (drawings) => {
546
+ while (drawings.length > 1) {
547
+ const next = [];
548
+ for (let i = 0; i < drawings.length; i += 2)
549
+ next.push(i + 1 < drawings.length ? drawings[i].fuse(drawings[i + 1]) : drawings[i]);
550
+ drawings = next;
551
+ }
552
+ return drawings[0];
553
+ };
554
+ const drawingFromRegions = (regions) => {
555
+ if (regions.length === 0) return null;
556
+ const parts = [];
557
+ for (const group of groupInteractingRegions(regions, SHAPE2D_SEGS)) {
558
+ if (group.length === 1) { parts.push(regionBlueprint(group[0])); continue; }
559
+ const fused = fuseBalanced(group.map((rg) => new Drawing(regionBlueprint(rg)))).innerShape;
560
+ parts.push(...(fused instanceof Blueprints ? fused.blueprints : [fused]));
561
+ }
562
+ return new Drawing(parts.length === 1 ? parts[0] : new Blueprints(parts));
563
+ };
531
564
 
532
565
  // 2-D boolean value: the SHARED Shape2D (shape2d.js), identical to the Manifold
533
566
  // backend's. Storage is the curve-native contour IR and every op — including
@@ -0,0 +1,98 @@
1
+ // Pure (WASM-free) grouping of Shape2D regions into clusters that must be unioned
2
+ // together before a kernel op, versus regions that provably touch nothing else and can
3
+ // be handed to the kernel as-is. Used by the OCCT backend's drawingFromRegions: a
4
+ // lattice of N disjoint regions must not pay N-1 boolean fuses (each one re-runs
5
+ // replicad's containment search over everything accumulated: O(N²)).
6
+ //
7
+ // Two regions INTERACT when their boundaries touch or cross, or one lies inside the
8
+ // other's material (inside its outer and not inside one of its holes — so an island
9
+ // sitting in another region's hole is DISJOINT, which is exactly how booleans emit it).
10
+ // The test runs on the contours tessellated at the shape's LOD; a pair separated by less
11
+ // than the chord error of a curve is a degenerate near-touch the kernel's own tolerance
12
+ // can't resolve either.
13
+ import { tessellateContour } from "./profile.js";
14
+ import { pointInRing } from "./shape2d-regions.js";
15
+
16
+ const EPS = 1e-7;
17
+
18
+ const orient = (a, b, c) => (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
19
+ const onSeg = (a, b, p) =>
20
+ Math.min(a[0], b[0]) - EPS <= p[0] && p[0] <= Math.max(a[0], b[0]) + EPS &&
21
+ Math.min(a[1], b[1]) - EPS <= p[1] && p[1] <= Math.max(a[1], b[1]) + EPS;
22
+ // Segments ab and cd cross or touch (collinear overlap and shared endpoints count).
23
+ const segsMeet = (a, b, c, d) => {
24
+ const o1 = orient(a, b, c), o2 = orient(a, b, d), o3 = orient(c, d, a), o4 = orient(c, d, b);
25
+ if (((o1 > 0 && o2 < 0) || (o1 < 0 && o2 > 0)) && ((o3 > 0 && o4 < 0) || (o3 < 0 && o4 > 0))) return true;
26
+ return (o1 === 0 && onSeg(a, b, c)) || (o2 === 0 && onSeg(a, b, d)) ||
27
+ (o3 === 0 && onSeg(c, d, a)) || (o4 === 0 && onSeg(c, d, b));
28
+ };
29
+
30
+ const ringBox = (ring) => {
31
+ let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
32
+ for (const [x, y] of ring) { if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y; }
33
+ return [x0, y0, x1, y1];
34
+ };
35
+ const boxesMeet = (a, b) => a[0] <= b[2] + EPS && b[0] <= a[2] + EPS && a[1] <= b[3] + EPS && b[1] <= a[3] + EPS;
36
+
37
+ const ringsMeet = (ra, rb) => {
38
+ if (!boxesMeet(ra.box, rb.box)) return false;
39
+ const A = ra.pts, B = rb.pts;
40
+ for (let i = 0; i < A.length; i++) {
41
+ const a = A[i], b = A[(i + 1) % A.length];
42
+ const ex0 = Math.min(a[0], b[0]) - EPS, ex1 = Math.max(a[0], b[0]) + EPS;
43
+ const ey0 = Math.min(a[1], b[1]) - EPS, ey1 = Math.max(a[1], b[1]) + EPS;
44
+ if (ex1 < rb.box[0] || ex0 > rb.box[2] || ey1 < rb.box[1] || ey0 > rb.box[3]) continue;
45
+ for (let j = 0; j < B.length; j++) {
46
+ const c = B[j], d = B[(j + 1) % B.length];
47
+ if (Math.max(c[0], d[0]) < ex0 || Math.min(c[0], d[0]) > ex1 ||
48
+ Math.max(c[1], d[1]) < ey0 || Math.min(c[1], d[1]) > ey1) continue;
49
+ if (segsMeet(a, b, c, d)) return true;
50
+ }
51
+ }
52
+ return false;
53
+ };
54
+
55
+ // p is in the region's MATERIAL: inside the outer ring, outside every hole.
56
+ const inMaterial = (p, r) =>
57
+ boxesMeet(r.outer.box, [p[0], p[1], p[0], p[1]]) && pointInRing(p, r.outer.pts) &&
58
+ !r.holes.some((h) => pointInRing(p, h.pts));
59
+
60
+ const regionsInteract = (a, b) => {
61
+ if (!boxesMeet(a.outer.box, b.outer.box)) return false;
62
+ for (const ra of [a.outer, ...a.holes])
63
+ for (const rb of [b.outer, ...b.holes])
64
+ if (ringsMeet(ra, rb)) return true;
65
+ // No boundary contact: each ring of one is wholly inside or outside the other, so one
66
+ // vertex of each outer decides.
67
+ return inMaterial(b.outer.pts[0], a) || inMaterial(a.outer.pts[0], b);
68
+ };
69
+
70
+ // regions: [{outer, holes}] (curve-native contours). Returns arrays of the ORIGINAL
71
+ // region objects: singletons are independent of everything else; larger groups
72
+ // interact transitively and need a union. Groups come out in first-member order.
73
+ export function groupInteractingRegions(regions, segs) {
74
+ const ring = (c) => { const pts = tessellateContour(c, segs); return { pts, box: ringBox(pts) }; };
75
+ const info = regions.map((rg) => ({ outer: ring(rg.outer), holes: rg.holes.map(ring) }));
76
+ const parent = regions.map((_, i) => i);
77
+ const find = (i) => { while (parent[i] !== i) { parent[i] = parent[parent[i]]; i = parent[i]; } return i; };
78
+
79
+ // Sweep along x: only regions whose x-extents overlap can interact.
80
+ const order = regions.map((_, i) => i).sort((i, j) => info[i].outer.box[0] - info[j].outer.box[0]);
81
+ for (let oi = 0; oi < order.length; oi++) {
82
+ const i = order[oi];
83
+ for (let oj = oi + 1; oj < order.length; oj++) {
84
+ const j = order[oj];
85
+ if (info[j].outer.box[0] > info[i].outer.box[2] + EPS) break;
86
+ if (find(i) === find(j)) continue;
87
+ if (regionsInteract(info[i], info[j])) parent[find(j)] = find(i);
88
+ }
89
+ }
90
+ const groups = new Map();
91
+ regions.forEach((rg, i) => {
92
+ const root = find(i);
93
+ if (!groups.has(root)) groups.set(root, []);
94
+ groups.get(root).push(rg);
95
+ });
96
+ // Map preserves insertion order: first member by index → stable output.
97
+ return [...groups.values()];
98
+ }