partforge 0.146.1 → 0.146.2

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.146.1",
3
+ "version": "0.146.2",
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 { KernelCapabilityError } from "./errors.js";
21
21
  import { isPlainOptions, KERNEL_OP_SPECS } from "./op-options.js";
22
22
  import { isPathContour, contourToPoints } from "./profile.js";
23
23
  import { textGlyphs } from "./text2d.js";
24
+ import { unionMany } from "./union-many.js";
24
25
  import { placeRegions } from "./vector2d.js";
25
26
  import { beveledExtrude } from "./rim-bevel.js";
26
27
  import { DEFAULT_FONT_BYTES } from "./fonts/default-font.js";
@@ -279,7 +280,7 @@ export function finishKernel(k) {
279
280
  if (regions.length === 0) throw new Error("text2d: string produced no glyph geometry (empty or all-whitespace?)");
280
281
  // Glyph regions are machine-resolved by curve-fill — trusted, so a text
281
282
  // part does not pay profile validation per glyph per rebuild.
282
- return regions.map((r) => k.shape2d.trusted(r)).reduce((a, b) => a.union(b));
283
+ return unionMany(regions.map((r) => k.shape2d.trusted(r)), k.shape2d.trusted);
283
284
  };
284
285
 
285
286
  // 2-D vector art as a Shape2D. Backend-agnostic for the same reason text2d is:
@@ -300,7 +301,7 @@ export function finishKernel(k) {
300
301
  if (!doc) throw new Error(`vector2d: unknown vector "${name}" — declare it in the part's \`vectors\` field`);
301
302
  // vector documents carry their own validation (VECTOR-FORMAT.md); trusted lift
302
303
  const lift = (regions, measureAgainst = regions) =>
303
- placeRegions(regions, doc.units, opts, { measureAgainst, name }).map((r) => k.shape2d.trusted(r)).reduce((a, b) => a.union(b));
304
+ unionMany(placeRegions(regions, doc.units, opts, { measureAgainst, name }).map((r) => k.shape2d.trusted(r)), k.shape2d.trusted);
304
305
  if (opts.shape != null) {
305
306
  const entry = doc.shapes.get(opts.shape);
306
307
  if (!entry) {
@@ -0,0 +1,75 @@
1
+ // Union of many Shape2Ds — the text2d / vector2d reduction — without the
2
+ // quadratic left fold those ops used to run.
3
+ //
4
+ // `shapes.reduce((a, b) => a.union(b))` hands paper.js the WHOLE accumulated
5
+ // result on every step, so n glyphs cost about n²/2 glyph conversions plus n
6
+ // planar booleans over an ever-growing compound path. Measured on a 4-line,
7
+ // 290-glyph paragraph (feedback #169, a bathroom sign): 14 s for text2d alone,
8
+ // over a minute for the forge. Two independent savings, applied in order:
9
+ //
10
+ // 1. Shapes whose bounding boxes do not touch cannot share a point, so their
11
+ // union is just their regions side by side — no boolean at all. Text is
12
+ // almost entirely this case: letters sit apart, and only a kerned pair, a
13
+ // script face or negative tracking makes neighbours overlap. Shapes are
14
+ // grouped into clusters whose boxes overlap (transitively); the clusters
15
+ // are mutually disjoint, so concatenating them IS the union.
16
+ // 2. Inside a cluster that does need booleans, the union is balanced
17
+ // (pairs, then pairs of pairs), so each shape is converted O(log n)
18
+ // times instead of O(n).
19
+ //
20
+ // Bounds are paper's exact curve bounds (Shape2D.boundingBox), padded by EPS so
21
+ // shapes that merely touch along an edge still meet a boolean — touching
22
+ // glyphs must fuse, not sit as two regions sharing an edge.
23
+ //
24
+ // Deterministic: clusters are emitted in the order of their first member, and
25
+ // members keep input order, so the same inputs give the same regions (and so
26
+ // the same Shape2D hash — build purity rests on it).
27
+ const EPS = 1e-6;
28
+
29
+ // `fromContours` builds a Shape2D from a list of already-resolved regions (the
30
+ // kernel's k.shape2d.trusted) — passed in so this module needs no kernel.
31
+ export function unionMany(shapes, fromContours) {
32
+ if (shapes.length === 0) throw new Error("unionMany: no shapes");
33
+ if (shapes.length === 1) return shapes[0];
34
+
35
+ const boxes = shapes.map((s) => s.boundingBox());
36
+ const n = shapes.length;
37
+ const parent = Array.from({ length: n }, (_, i) => i);
38
+ const find = (i) => { while (parent[i] !== i) { parent[i] = parent[parent[i]]; i = parent[i]; } return i; };
39
+ const join = (a, b) => { const ra = find(a), rb = find(b); if (ra !== rb) parent[Math.max(ra, rb)] = Math.min(ra, rb); };
40
+
41
+ // Sweep in x: `active` holds every box whose x-range can still reach the
42
+ // current one. Worst case (a vertical column of shapes) is O(n²) box checks —
43
+ // cheap arithmetic, nothing like the booleans it saves.
44
+ const order = [...boxes.keys()].sort((a, b) => boxes[a].min[0] - boxes[b].min[0] || a - b);
45
+ let active = [];
46
+ for (const i of order) {
47
+ const bi = boxes[i];
48
+ active = active.filter((j) => boxes[j].max[0] + EPS >= bi.min[0]);
49
+ for (const j of active) {
50
+ const bj = boxes[j];
51
+ if (bj.min[1] - EPS <= bi.max[1] && bi.min[1] - EPS <= bj.max[1]) join(i, j);
52
+ }
53
+ active.push(i);
54
+ }
55
+
56
+ const clusters = new Map(); // root → member indices, input order
57
+ for (let i = 0; i < n; i++) {
58
+ const r = find(i);
59
+ if (!clusters.has(r)) clusters.set(r, []);
60
+ clusters.get(r).push(i);
61
+ }
62
+
63
+ const merged = [...clusters.values()].map((members) => balancedUnion(members.map((i) => shapes[i])));
64
+ if (merged.length === 1) return merged[0];
65
+ return fromContours(merged.flatMap((s) => s.toContours()));
66
+ }
67
+
68
+ function balancedUnion(xs) {
69
+ while (xs.length > 1) {
70
+ const next = [];
71
+ for (let i = 0; i < xs.length; i += 2) next.push(i + 1 < xs.length ? xs[i].union(xs[i + 1]) : xs[i]);
72
+ xs = next;
73
+ }
74
+ return xs[0];
75
+ }