partforge 0.58.0 → 0.59.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.
@@ -6,7 +6,9 @@
6
6
  // options-object form first (the canonical calling convention — normalizers and
7
7
  // exact valid-key lists live in op-options.js, wired in at kernel-front.js
8
8
  // (finishKernel) and solid-sugar.js (addSugar); legacy positional forms stay
9
- // silently accepted until contract v2). The prose half of the contract
9
+ // silently accepted until a future breaking contract version removes them
10
+ // contract v2 (partforge 0.59) did NOT remove them, it only changed `offset`
11
+ // semantics; see KERNEL-CONTRACT.md's Versioning section). The prose half of the contract —
10
12
  // conventions, value semantics, conformance classes, versioning policy — is
11
13
  // docs/KERNEL-CONTRACT.md; change either side and you must update the other.
12
14
  // (2-D polygon helpers live in ./polygon.js.)
@@ -14,7 +16,7 @@
14
16
  // The prose half's version: docs/KERNEL-CONTRACT.md's "Contract version" header
15
17
  // must match this number (asserted in kernel-contract.test.js). Bump only on a
16
18
  // breaking contract change — see the doc's Versioning section.
17
- export const CONTRACT_VERSION = 1;
19
+ export const CONTRACT_VERSION = 2;
18
20
 
19
21
  // Ops every backend kernel must implement.
20
22
  export const KERNEL_OPS = [
@@ -45,8 +47,9 @@ export const SOLID_OPTIONAL_OPS = ["genus", "isEmpty"];
45
47
 
46
48
  // Public methods every Shape2D exposes (2-D boolean value; contract-linted).
47
49
  // One shared implementation backs both backends (geometry/shape2d.js) — storage is
48
- // the curve-native contour IR, so booleans/transforms/queries are backend-identical
49
- // and only `offset` routes into the backend's own 2-D engine.
50
+ // the curve-native contour IR, so every op including `offset` is backend-identical
51
+ // by construction (offset runs the native contour-offset engine, not a backend's
52
+ // own 2-D engine).
50
53
  export const SHAPE2D_OPS = [
51
54
  "union", "cut", "cutAll", "intersect", "offset", "area", "boundingBox", "toRegions", "simple", "regions", "clone",
52
55
  "extrude", "revolve",
@@ -84,18 +87,18 @@ export const OCCT_ONLY_OPS = ["fillet", "chamfer", "shell"];
84
87
  * `normals`/`edges` are authoritative shading intent from both backends — see docs/KERNEL-CONTRACT.md "Shading intent"; quality is advisory — the Manifold kernel bakes it at creation
85
88
  * @property {(opts?: {quality?: "preview"|"print"}) => Promise<ArrayBuffer>} toSTL
86
89
  * @property {() => {positions:Float32Array, indices:Uint32Array}} toIndexedMesh indexed mesh, for 3MF
87
- * @property {(r:number|{r:number,edges?:object}) => Solid} fillet round edges (OCCT only); fillet(3) or fillet({r,edges}); legacy (r,selector) accepted until v2
88
- * @property {(d:number|{d:number,edges?:object}) => Solid} chamfer bevel edges (OCCT only); chamfer(1) or chamfer({d,edges}); legacy (d,selector) accepted until v2
89
- * @property {(o:{t:number,open:object}) => Solid} shell hollow inward (OCCT only); legacy (thickness,openFaces) accepted until v2
90
+ * @property {(r:number|{r:number,edges?:object}) => Solid} fillet round edges (OCCT only); fillet(3) or fillet({r,edges}); legacy (r,selector) accepted for now (see file header)
91
+ * @property {(d:number|{d:number,edges?:object}) => Solid} chamfer bevel edges (OCCT only); chamfer(1) or chamfer({d,edges}); legacy (d,selector) accepted for now (see file header)
92
+ * @property {(o:{t:number,open:object}) => Solid} shell hollow inward (OCCT only); legacy (thickness,openFaces) accepted for now (see file header)
90
93
  * @property {() => number} [genus] through-hole count (Manifold only)
91
94
  * @property {() => boolean} [isEmpty] no geometry at all (Manifold only)
92
95
  *
93
- * @typedef {Object} Shape2D A 2-D boolean value. ONE shared implementation on both backends: storage is the curve-native contour IR (arcs/cubics survive every op), so results are backend-identical except `offset`. `_`-prefixed keys are internals.
96
+ * @typedef {Object} Shape2D A 2-D boolean value. ONE shared implementation on both backends: storage is the curve-native contour IR (arcs/cubics survive every op), so results are backend-identical by construction, `offset` included. `_`-prefixed keys are internals.
94
97
  * @property {(other: Shape2D|number[][]) => Shape2D} union
95
98
  * @property {(other: Shape2D|number[][]) => Shape2D} cut
96
99
  * @property {(others: (Shape2D|number[][])[]) => Shape2D} cutAll batch subtract
97
100
  * @property {(other: Shape2D|number[][]) => Shape2D} intersect
98
- * @property {(delta:number, opts?:{corners?:"round"|"chamfer"|"sharp",segs?:number}) => Shape2D} offset grow (+) / shrink (−) by delta; the one backend-specific op (Clipper2 vs OCCT) — throws when the shape collapses; empty in → empty out
101
+ * @property {(delta:number, opts?:{corners?:"round"|"chamfer"|"sharp",segs?:number}) => Shape2D} offset grow (+) / shrink (−) by delta; backend-identical by construction like every other Shape2D op (native contour-offset engine, not a backend 2-D engine); `segs` accepted and ignored — throws when the shape collapses; empty in → empty out
99
102
  * @property {() => boolean} isEmpty true when the shape has no regions (a cut/intersect removed everything); guard before extrude/revolve, which throw on an empty profile
100
103
  * @property {() => number} area net area (outers minus holes), mm² — curve-exact, not tessellated
101
104
  * @property {() => {min:number[],max:number[]}} boundingBox axis-aligned 2-D bounds (curve-exact)
@@ -117,18 +120,18 @@ export const OCCT_ONLY_OPS = ["fillet", "chamfer", "shell"];
117
120
  * @property {(o?:{degrees?:number}) => Solid} revolve sugar for k.revolve({profile:this,…})
118
121
  *
119
122
  * @typedef {Object} GeometryKernel
120
- * @property {(o:{r?:number,d?:number,r1?:number,r2?:number,d1?:number,d2?:number,h:number,center?:boolean}) => Solid} cylinder canonical: {r|d,h} straight, {r1,r2,h}|{d1,d2,h} cone; legacy (rBottom,rTop,h,opts) accepted until contract v2
123
+ * @property {(o:{r?:number,d?:number,r1?:number,r2?:number,d1?:number,d2?:number,h:number,center?:boolean}) => Solid} cylinder canonical: {r|d,h} straight, {r1,r2,h}|{d1,d2,h} cone; legacy (rBottom,rTop,h,opts) accepted for now (see file header)
121
124
  * @property {(o:{od:number,h:number,bore:number}) => Solid} boredCylinder compound: bored-through cylinder (one cache node)
122
125
  * @property {(o:{r?:number,d?:number}) => Solid} sphere sphere centred at the origin; {r|d}; bare sphere(r) stays valid
123
126
  * @property {(o:{r?:number,d?:number,h:number,center?:boolean,round:number|{top?:number,bottom?:number}}) => Solid} roundedCylinder rim round-overs via one lathe revolve; options-only; round ≤ r, top+bottom ≤ h
124
127
  * @property {(o:{rMajor:number,rMinor:number}) => Solid} torus centered at origin, tube centerline in the z=0 plane; 0 < rMinor < rMajor; options-only
125
128
  * @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
126
- * @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 until v2
127
- * @property {(o:{points:number[][],h:number,twist?:number,scaleTop?:number}) => Solid} prism extrude polygon from z=0; legacy (points,h,opts) accepted until v2
128
- * @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 until v2
129
- * @property {(o:{rings:{polygon?:number[][],sides?:number,radius?:number,z:number,rotate?:number,scale?:number|number[]}[],ruled?:boolean,closed?:boolean,shading?:"smooth"|"faceted"}) => Solid} loft stack polygon cross-sections; shading overrides facet-vs-smooth shading inference; legacy (rings,opts) accepted until v2
130
- * @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 until v2
131
- * @property {(o:{profile:number[][],degrees?:number}) => Solid} revolve revolve a lathe profile [[r,z],…] around Z; legacy (points,opts) accepted until v2
129
+ * @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)
130
+ * @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)
131
+ * @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)
132
+ * @property {(o:{rings:{polygon?:number[][],sides?:number,radius?:number,z:number,rotate?:number,scale?:number|number[]}[],ruled?:boolean,closed?:boolean,shading?:"smooth"|"faceted"}) => Solid} loft stack polygon cross-sections; shading overrides facet-vs-smooth shading inference; legacy (rings,opts) accepted for now (see file header)
133
+ * @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)
134
+ * @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)
132
135
  * @property {(o:{pathR:number,profileR:number,pitch:number,turns:number,z0:number,lefthand:boolean}) => Solid} helixSweptTube
133
136
  * @property {(o:{profile:number[][],pitch:number,turns:number,lefthand?:boolean}) => Solid} screwSweep screw-motion sweep of an axial [[r,z]] profile — threads; options-only
134
137
  * @property {(solids:Solid[]) => Solid} union
@@ -2,12 +2,12 @@ import { helixTube } from "./helix-tube.js";
2
2
  import { loftMesh } from "./loft.js";
3
3
  import { sweepMesh } from "./sweep.js";
4
4
  import { roundedBoxRings } from "./rounded-solids.js";
5
- import { tessellateContour, tessellateProfile, pointsToContour } from "./profile.js";
5
+ import { tessellateContour, tessellateProfile } from "./profile.js";
6
6
  import { h } from "./solid-hash.js";
7
7
  import { createSolidCache } from "./solid-cache.js";
8
8
  import { addSugar } from "./solid-sugar.js";
9
9
  import { makeShape2dFactory } from "./shape2d.js";
10
- import { assembleRegions } from "./shape2d-regions.js";
10
+ import { offsetRegions } from "./contour-offset.js";
11
11
  import { finishKernel } from "./kernel-front.js";
12
12
  import { meshToStl } from "./mesh-stl.js";
13
13
  import { creasedNormals } from "./creased-normals.js";
@@ -53,58 +53,17 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
53
53
  return { value: wrap(m, hash), pin: m, dispose: () => m.delete?.() };
54
54
  });
55
55
 
56
- // 2-D offset logic. This is the ONE op the shared Shape2D cannot do on the
57
- // contour IR itself, so it is the backend's hook into the factory below (and
58
- // is also published as k._offsetRegions). resolveOffsetJoin validates corners/delta and picks
59
- // Clipper2's join type + segment count:
60
- // chamfer is a true 45° bevel — Clipper2 has no bevel join, but a Round join
61
- // forced to a single chord per corner (circularSegments=4 → 1 segment per corner
62
- // whose turn ≤ 90°, i.e. interior angle ≥ 90°) IS the bevel: round's tangent points
63
- // are exactly the bevel's endpoints. Matches OCCT's `bevel` to float precision for
64
- // interior angle ≥ 90° (square 142.0000, pentagon 298.920). At acute (<90°) convex
65
- // corners Clipper2 emits 2 chords (ceil(turn/90°)), so Manifold bulges ~0.4% beyond
66
- // OCCT's single-chord bevel there. round = arc at mesh LOD; sharp = miter.
67
- const resolveOffsetJoin = (delta, corners, nSeg) => {
68
- if (!["round", "chamfer", "sharp"].includes(corners))
69
- throw new Error('Shape2D.offset: corners must be "round" | "chamfer" | "sharp"');
70
- if (!Number.isFinite(delta)) throw new Error("Shape2D.offset: delta must be a finite number");
71
- return corners === "sharp" ? ["Miter", nSeg]
72
- : corners === "chamfer" ? ["Round", 4]
73
- : ["Round", nSeg];
74
- };
75
- // Run the offset op on a T-tracked CrossSection; throws the pinned collapse message.
76
- const offsetCS = (cs, delta, joinType, cseg) => {
77
- const out = T(cs.offset(delta, joinType, 2, cseg)); // miterLimit 2 (Clipper2 default)
78
- if (out.numContour() === 0) throw new Error("Shape2D.offset: offset collapses the shape (reduce |delta|)");
79
- return out;
80
- };
81
-
82
56
  // Contour-IR region list -> flat point rings at `nSeg` (outer + holes, even/odd
83
57
  // fill sorts them out). The one place the IR meets CrossSection.ofPolygons.
84
58
  const regionPolys = (regions, nSeg) => regions.flatMap((rg) =>
85
59
  [tessellateContour(rg.outer, nSeg), ...rg.holes.map((hl) => tessellateContour(hl, nSeg))]);
86
60
 
87
- // Region-in / region-out offset: tessellate the contour IR at `nSeg`, build a
88
- // CrossSection, run the shared offset logic above, then lift the resulting point
89
- // rings back into line contours via pointsToContour. This is Shape2D.offset's
90
- // engine (wired into the factory below) and is also published as k._offsetRegions.
91
- const offsetRegions = (regions, delta, { corners = "round", segs: nSeg = segs } = {}) => {
92
- const [joinType, cseg] = resolveOffsetJoin(delta, corners, nSeg);
93
- const cs = T(CrossSection.ofPolygons(regionPolys(regions, nSeg), "EvenOdd"));
94
- const out = offsetCS(cs, delta, joinType, cseg);
95
- return assembleRegions(out.toPolygons()).map((rg) => ({
96
- outer: pointsToContour(rg.outer),
97
- holes: rg.holes.map(pointsToContour),
98
- }));
99
- };
100
-
101
61
  // 2-D boolean value: the SHARED Shape2D (shape2d.js). Storage is the curve-native
102
- // contour IR and every op but `offset` runs on it in pure JS no CrossSection is
103
- // built until a shape is handed to a kernel op. `extrude`/`revolve` are thunks
104
- // because `kernel` below is defined after this.
62
+ // contour IR and every op including `offset`, now the shared native engineruns
63
+ // on it in pure JS; no CrossSection is built until a shape is handed to a kernel op.
64
+ // `extrude`/`revolve` are thunks because `kernel` below is defined after this.
105
65
  const shape2d = makeShape2dFactory({
106
66
  segs,
107
- offsetRegions,
108
67
  extrude: (o) => kernel.extrude(o),
109
68
  revolve: (o) => kernel.revolve(o),
110
69
  });
@@ -326,8 +285,10 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
326
285
  ? solids[0]
327
286
  : cached(h("union", solids.map((s) => s._hash)), () => unionRaw(solids.map((s) => s._m))),
328
287
  shape2d,
329
- // Backend-internal region adapter: the same function Shape2D.offset runs on
330
- // (defined above). `_`-prefixednot part of the public kernel surface.
288
+ // Backend-internal region adapter: the shared native engine (contour-offset.js)
289
+ // that Shape2D.offset itself runs on published here for callers that want the
290
+ // region-in/region-out form directly. `_`-prefixed — not part of the public kernel
291
+ // surface.
331
292
  _offsetRegions: offsetRegions,
332
293
  beginSubPart: (name) => cache.begin(name),
333
294
  endSubPart: () => cache.end(),
@@ -19,14 +19,14 @@ import { toEdgeFinder } from "./edge-selector.js";
19
19
  import { toFaceFinder } from "./face-selector.js";
20
20
  import { addSugar } from "./solid-sugar.js";
21
21
  import { makeShape2dFactory } from "./shape2d.js";
22
- import { assembleRegions, svgPathToContours, pointInRing, ringArea } from "./shape2d-regions.js";
23
22
  import { finishKernel } from "./kernel-front.js";
24
23
  import { createOcctRepair } from "./occt-repair.js";
25
24
  import { classifyFaceGroups } from "./feature-attribution.js";
26
25
  import { resolveRings } from "./loft.js";
27
26
  import { resolveSweepStations } from "./sweep.js";
28
- import { normalizeProfile, tessellateContour, reverseContour } from "./profile.js";
27
+ import { normalizeProfile } from "./profile.js";
29
28
  import { roundedRectContour } from "./rounded-solids.js";
29
+ import { offsetRegions } from "./contour-offset.js";
30
30
  import { h } from "./solid-hash.js";
31
31
  import { createSolidCache } from "./solid-cache.js";
32
32
  import { composePose, transformPositions, rotateNormals } from "./pose.js";
@@ -332,105 +332,21 @@ export function createOcctKernel(replicad) {
332
332
  };
333
333
  // Region list -> fused Drawing: draw each region's outer contour, cut its holes
334
334
  // out, then fuse every region together — the multi-region generalization of
335
- // drawingFromProfile above. Used by _offsetRegions to materialize an arbitrary
336
- // region LIST (not a single { outer, holes }) into one Drawing to offset, and by
337
- // extrude/revolve to materialize a Shape2D's stored contours into a Drawing.
335
+ // drawingFromProfile above. Used by extrude/revolve (via drawingFor below) to
336
+ // materialize a Shape2D's stored contours into a Drawing.
338
337
  const drawingFromRegions = (regions) => regions.reduce((acc, rg) => {
339
338
  let region = contourDrawing(rg.outer);
340
339
  for (const hole of rg.holes) region = region.cut(contourDrawing(hole));
341
340
  return acc ? acc.fuse(region) : region;
342
341
  }, null);
343
342
 
344
- // y-negate a whole contour (start + every segment's to/via/c1/c2): toSVGPathD()
345
- // renders in SVG's y-down convention, so everything read back out of a Drawing
346
- // via SVG paths must be negated back into model space.
347
- const negateContourY = (c) => ({
348
- start: [c.start[0], -c.start[1]],
349
- segments: c.segments.map((s) => {
350
- const m = { to: [s.to[0], -s.to[1]] };
351
- if (s.via) m.via = [s.via[0], -s.via[1]];
352
- if (s.c1) { m.c1 = [s.c1[0], -s.c1[1]]; m.c2 = [s.c2[0], -s.c2[1]]; }
353
- return m;
354
- }),
355
- });
356
-
357
- // Classify + nest a flat list of curve-native contours into a region list.
358
- //
359
- // Drawing.toSVGPaths() nests 0-2 levels deep depending on the result shape,
360
- // INCONSISTENTLY (a single interior hole nests as [[outerD, holeD]], but two
361
- // disjoint holes from sequential .cut() calls come back flat), so array position
362
- // carries no outer/hole signal. Neither does winding: unlike Manifold's
363
- // CrossSection.toPolygons() (outer CCW, hole CW), replicad emits EVERY loop of a
364
- // region with the same rotational sense. The one signal that IS reliable is
365
- // geometric containment DEPTH: count how many OTHER contours contain a sample
366
- // point, then force each contour's winding to match its depth parity ABSOLUTELY
367
- // (even depth = outer/CCW, odd = hole/CW) via reverseContour — setting it
368
- // absolutely rather than reversing relative to the emitted sense is what makes
369
- // this winding-agnostic. The now-correctly-signed tessellations then go to
370
- // assembleRegions for the actual smallest-containing-outer nesting, reusing that
371
- // logic rather than reimplementing it. 32-segment sampling (vs. SHAPE2D_SEGS=64)
372
- // is plenty for classification, which only needs containment and area sign.
373
- const OFFSET_CLASSIFY_SEGS = 32;
374
- const groupOffsetContours = (contours) => {
375
- const samples = contours.map((c) => tessellateContour(c, OFFSET_CLASSIFY_SEGS));
376
- const containedBy = contours.map((_, i) =>
377
- samples.reduce((n, ring, j) => (i !== j && pointInRing(samples[i][0], ring) ? n + 1 : n), 0));
378
- const oriented = contours.map((c, i) => {
379
- const wantOuter = containedBy[i] % 2 === 0; // even depth = outer
380
- const isCCW = ringArea(samples[i]) >= 0;
381
- return isCCW === wantOuter
382
- ? { contour: c, ring: samples[i] }
383
- : { contour: reverseContour(c), ring: samples[i].slice().reverse() };
384
- });
385
- const byRing = new Map(oriented.map(({ contour, ring }) => [ring, contour]));
386
- const regions = assembleRegions(oriented.map(({ ring }) => ring));
387
- return regions.map((rg) => ({ outer: byRing.get(rg.outer), holes: rg.holes.map((ring) => byRing.get(ring)) }));
388
- };
389
-
390
- // Offset logic (corners validation, replicad Offset2DConfig join-type mapping,
391
- // collapse detection via `innerShape`), Drawing-in/Drawing-out. This is the ONE
392
- // op the shared Shape2D cannot do on the contour IR itself — offsetRegions below
393
- // wraps it as the backend's hook. corners map onto replicad's Offset2DConfig
394
- // lineJoinType; "chamfer" -> "bevel", a true 45° bevel — a straight chord.
395
- // Manifold now matches this via a single-chord Round join (see
396
- // manifold-backend offset) — the two agree to float precision for convex
397
- // corners with interior angle >= 90°; at acute (<90°) corners Manifold uses a
398
- // 2-facet approximation that departs slightly. See KERNEL-CONTRACT.
399
- const offsetDrawing = (drawing, delta, corners) => {
400
- const lineJoinType = { round: "round", chamfer: "bevel", sharp: "miter" }[corners];
401
- if (!lineJoinType) throw new Error('Shape2D.offset: corners must be "round" | "chamfer" | "sharp"');
402
- if (!Number.isFinite(delta)) throw new Error("Shape2D.offset: delta must be a finite number");
403
- // No clone: offset CONSUMES its operand, and the sole caller (offsetRegions) hands
404
- // in a Drawing it just built from the contour IR, owned by nobody else.
405
- const result = drawing.offset(delta, { lineJoinType });
406
- // Collapse doesn't throw and Drawing has no public `blueprints` array (that's on
407
- // Blueprints/CompoundBlueprint, not Drawing) — replicad instead returns a Drawing
408
- // whose private `innerShape` is null (confirmed by probe). That's the collapse signal.
409
- // NB: `innerShape` is replicad-internal; the "collapse throws immediately (OCCT)" test
410
- // guards this — a replicad upgrade that renames it must keep that test green.
411
- if (!result || !result.innerShape)
412
- throw new Error("Shape2D.offset: offset collapses the shape (reduce |delta|)");
413
- return result;
414
- };
415
- // Region-in / region-out offset: fuse the region list into one Drawing, run the
416
- // shared offset logic, then read the curve-native result back via SVG paths —
417
- // y-negate (toSVGPathD is y-down), svgPathToContours per subpath (curves survive
418
- // as cubics, no facet fan), then classify/orient/nest via groupOffsetContours.
419
- // This is Shape2D.offset's engine (wired into the factory below) and is also
420
- // published as k._offsetRegions.
421
- const offsetRegions = (regions, delta, { corners = "round" } = {}) => {
422
- const result = offsetDrawing(drawingFromRegions(regions), delta, corners);
423
- const contours = result.toSVGPaths().flat(Infinity)
424
- .flatMap((d) => svgPathToContours(d).map(negateContourY));
425
- return groupOffsetContours(contours);
426
- };
427
343
  // 2-D boolean value: the SHARED Shape2D (shape2d.js), identical to the Manifold
428
- // backend's. Storage is the curve-native contour IR and every op but `offset`
429
- // runs on it in pure JS, so no Drawing exists until a shape is handed to a
430
- // kernel op. `extrude`/`revolve` are thunks because `kernel` is defined below.
344
+ // backend's. Storage is the curve-native contour IR and every op including
345
+ // `offset`, now the shared native engine — runs on it in pure JS; no Drawing
346
+ // exists until a shape is handed to a kernel op. `extrude`/`revolve` are thunks
347
+ // because `kernel` is defined below.
431
348
  const shape2d = makeShape2dFactory({
432
349
  segs: SHAPE2D_SEGS,
433
- offsetRegions,
434
350
  extrude: (o) => kernel.extrude(o),
435
351
  revolve: (o) => kernel.revolve(o),
436
352
  });
@@ -552,8 +468,10 @@ export function createOcctKernel(replicad) {
552
468
  });
553
469
  },
554
470
  shape2d,
555
- // Backend-internal region adapter: the same function Shape2D.offset runs on
556
- // (defined above). `_`-prefixednot part of the public kernel surface.
471
+ // Backend-internal region adapter: the shared native engine (contour-offset.js)
472
+ // that Shape2D.offset itself runs on published here for callers that want the
473
+ // region-in/region-out form directly. `_`-prefixed — not part of the public kernel
474
+ // surface.
557
475
  _offsetRegions: offsetRegions,
558
476
  toSTEP: (named) => exportSTEP(named.map(({ name, solid }) => ({ name, shape: solid._mat()._s }))).arrayBuffer(),
559
477
  beginSubPart: (name) => cache.begin(name),
@@ -221,4 +221,33 @@ export function booleanRegions(aRegions, bRegions, op) {
221
221
  }
222
222
  }
223
223
 
224
+ // Self-resolve one region list: per-region self-unite resolves self-intersections and
225
+ // winding-inverted loops inside each region; pairwise unite fold merges overlaps BETWEEN
226
+ // regions. This two-stage approach avoids the cancellation issue of whole-list evenodd
227
+ // self-unite (which cancelled overlapping children). Same readback and winding normalization
228
+ // as booleanRegions. Empty result → []. NB paper has no arc primitive, so arcs return as
229
+ // cubic approximations (arcToCubicSegments) — identical to what every boolean already does.
230
+ export function resolveSelfRegions(regions) {
231
+ if (regions.length === 0) return [];
232
+ const scope = paperScope();
233
+ try {
234
+ let acc = null;
235
+ for (const rg of regions) {
236
+ const R = regionsToCompound(scope, [rg]);
237
+ const netInverted = R.area < -1e-9;
238
+ const r = R.unite(R, { insert: false });
239
+ if (netInverted && r.area > 1e-9) continue; // collapsed/inverted region cancels
240
+ if (Math.abs(r.area) <= 1e-9) continue;
241
+ acc = acc ? acc.unite(r, { insert: false }) : r;
242
+ }
243
+ if (!acc) return [];
244
+ const paths = (acc.className === "CompoundPath" ? acc.children : [acc])
245
+ .filter((p) => p.segments && p.segments.length >= 2 && Math.abs(p.area) > 1e-9);
246
+ if (!paths.length) return [];
247
+ return groupPaperPathsOriented(paths);
248
+ } finally {
249
+ scope.project.clear();
250
+ }
251
+ }
252
+
224
253
  export { paperScope, toContour, toOpenContour, groupPaperPaths };
@@ -2,7 +2,8 @@
2
2
  // arrays. assembleRegions groups a flat set of point-rings into {outer,holes}
3
3
  // regions by winding + point-in-polygon nesting. svgPathToRings discretizes a
4
4
  // replicad Drawing's SVG path (from toSVGPathD) into rings, reusing F1's
5
- // sampleBezier / sampleArc so an OCCT-materialized curve facets like Manifold.
5
+ // sampleBezier / sampleArc so an OCCT-materialized curve facets like Manifold
6
+ // it is TEST-ONLY now that no backend has an SVG readback route (see its comment).
6
7
  import { sampleBezier } from "./profile.js";
7
8
 
8
9
  // Signed shoelace area of a ring (CCW positive). Exported (in addition to its use
@@ -97,66 +98,18 @@ function sampleSvgArc(from, rx, ry, rotDeg, largeArc, sweep, to, segs) {
97
98
  return out;
98
99
  }
99
100
 
100
- // Split an SVG elliptical-arc segment (endpoint parameterization, same center-form
101
- // math as sampleSvgArc above) into ≤90° cubic Bézier pieces via the standard
102
- // k = (4/3)tan(dθ/4) control-point formula in the ellipse's own rotated/scaled
103
- // frame, then mapped back through the rotation+translation into model space.
104
- // Returns segments as { to, c1, c2 } (no `from` — the caller already holds it).
105
- function svgArcToCubics(from, rx, ry, rotDeg, largeArc, sweep, to) {
106
- const [x1, y1] = from, [x2, y2] = to;
107
- if (rx === 0 || ry === 0) return [{ to: [x2, y2] }];
108
- const phi = (rotDeg * Math.PI) / 180, cosP = Math.cos(phi), sinP = Math.sin(phi);
109
- const dx = (x1 - x2) / 2, dy = (y1 - y2) / 2;
110
- const x1p = cosP * dx + sinP * dy, y1p = -sinP * dx + cosP * dy;
111
- let RX = Math.abs(rx), RY = Math.abs(ry);
112
- const lambda = (x1p * x1p) / (RX * RX) + (y1p * y1p) / (RY * RY);
113
- if (lambda > 1) { const s = Math.sqrt(lambda); RX *= s; RY *= s; }
114
- const numr = RX * RX * RY * RY - RX * RX * y1p * y1p - RY * RY * x1p * x1p;
115
- const den = RX * RX * y1p * y1p + RY * RY * x1p * x1p;
116
- let coef = Math.sqrt(Math.max(0, numr / den));
117
- if (Boolean(largeArc) === Boolean(sweep)) coef = -coef;
118
- const cxp = (coef * RX * y1p) / RY, cyp = (-coef * RY * x1p) / RX;
119
- const cx = cosP * cxp - sinP * cyp + (x1 + x2) / 2;
120
- const cy = sinP * cxp + cosP * cyp + (y1 + y2) / 2;
121
- const angle = (ux, uy, vx, vy) => {
122
- const dot = ux * vx + uy * vy, len = Math.hypot(ux, uy) * Math.hypot(vx, vy) || 1e-12;
123
- let a = Math.acos(Math.min(1, Math.max(-1, dot / len)));
124
- if (ux * vy - uy * vx < 0) a = -a;
125
- return a;
126
- };
127
- const theta1 = angle(1, 0, (x1p - cxp) / RX, (y1p - cyp) / RY);
128
- let dTheta = angle((x1p - cxp) / RX, (y1p - cyp) / RY, (-x1p - cxp) / RX, (-y1p - cyp) / RY);
129
- if (!sweep && dTheta > 0) dTheta -= 2 * Math.PI;
130
- if (sweep && dTheta < 0) dTheta += 2 * Math.PI;
131
- // point on the ellipse (model space) + its tangent direction, at parameter t
132
- const pointAt = (t) => {
133
- const ex = RX * Math.cos(t), ey = RY * Math.sin(t);
134
- return [cx + cosP * ex - sinP * ey, cy + sinP * ex + cosP * ey];
135
- };
136
- const tangentAt = (t) => {
137
- const ex = -RX * Math.sin(t), ey = RY * Math.cos(t);
138
- return [cosP * ex - sinP * ey, sinP * ex + cosP * ey];
139
- };
140
- const pieces = Math.max(1, Math.ceil(Math.abs(dTheta) / (Math.PI / 2)));
141
- const dSeg = dTheta / pieces;
142
- const kFac = (4 / 3) * Math.tan(dSeg / 4);
143
- const out = [];
144
- for (let i = 0; i < pieces; i++) {
145
- const tA = theta1 + dSeg * i, tB = theta1 + dSeg * (i + 1);
146
- const pA = i === 0 ? [x1, y1] : pointAt(tA);
147
- const pB = i === pieces - 1 ? [x2, y2] : pointAt(tB);
148
- const tanA = tangentAt(tA), tanB = tangentAt(tB);
149
- const c1 = [pA[0] + kFac * tanA[0], pA[1] + kFac * tanA[1]];
150
- const c2 = [pB[0] - kFac * tanB[0], pB[1] - kFac * tanB[1]];
151
- out.push({ to: pB, c1, c2 });
152
- }
153
- return out;
154
- }
155
-
156
- // Minimal SVG-path tokenizer for the absolute commands replicad emits: M, L, C,
157
- // Q, A, Z. Coordinates are numbers separated by spaces or commas; a command may
158
- // be followed by several coordinate sets (implicit repeat). One subpath (M…Z) →
101
+ // TEST-ONLY. Minimal SVG-path tokenizer for the absolute commands replicad emits:
102
+ // M, L, C, Q, A, Z. Coordinates are numbers separated by spaces or commas; a command
103
+ // may be followed by several coordinate sets (implicit repeat). One subpath (M…Z) →
159
104
  // one ring; the start point is not duplicated. Throws on unsupported commands.
105
+ //
106
+ // This existed for the OCCT backend's SVG readback of a replicad Drawing. That route is
107
+ // gone (both backends run the native contour-offset engine), so nothing in src/ calls
108
+ // this any more — its one remaining consumer is test/offset-oracle-occt.test.js, which
109
+ // reconstructs the deleted route to cross-check the native engine against BRepOffsetAPI.
110
+ // Kept, exported and tested for that; do NOT reintroduce it into a runtime path without
111
+ // reconsidering whether an SVG round-trip is the right seam. (Its curve-preserving twin
112
+ // svgPathToContours was deleted outright in the same cleanup — it had no callers at all.)
160
113
  export function svgPathToRings(d, segs) {
161
114
  const toks = d.match(/[a-zA-Z]|-?\d*\.?\d+(?:e[-+]?\d+)?/g) ?? [];
162
115
  const rings = [];
@@ -188,47 +141,3 @@ export function svgPathToRings(d, segs) {
188
141
  pushRing();
189
142
  return rings;
190
143
  }
191
-
192
- // SVG-path tokenizer that emits contour IR ({ start, segments: [{to}|{to,c1,c2}] },
193
- // the path-contour shape from profile.js) instead of tessellated point rings — the
194
- // curve-preserving twin of svgPathToRings above, same command set (M L C Q A Z) and
195
- // the same tokenizer/error-message conventions. C stays a cubic segment as-is; Q
196
- // degree-elevates to a cubic with the identical control-point math svgPathToRings
197
- // uses (just not sampled into points); A splits into ≤90° cubic pieces via
198
- // svgArcToCubics. Z closes the subpath with a straight segment back to its start
199
- // (mirroring pointsToContour's implicit closing edge) when not already there.
200
- export function svgPathToContours(d) {
201
- const toks = d.match(/[a-zA-Z]|-?\d*\.?\d+(?:e[-+]?\d+)?/g) ?? [];
202
- const contours = [];
203
- let start = null, segments = null, cur = [0, 0], cmd = null, i = 0;
204
- const num = () => Number(toks[i++]);
205
- const pt = () => [num(), num()];
206
- const pushContour = () => { if (start && segments && segments.length >= 1) contours.push({ start, segments }); start = null; segments = null; };
207
- while (i < toks.length) {
208
- if (/^[a-zA-Z]$/.test(toks[i])) {
209
- cmd = toks[i++];
210
- if (!"MLCQAZ".includes(cmd)) throw new Error(`svgPathToContours: unsupported SVG command "${cmd}"`);
211
- }
212
- if (cmd === "M") { pushContour(); cur = pt(); start = cur.slice(); segments = []; cmd = "L"; }
213
- else if (cmd === "L") { cur = pt(); segments.push({ to: cur.slice() }); }
214
- else if (cmd === "C") { const c1 = pt(), c2 = pt(), end = pt(); segments.push({ to: end, c1, c2 }); cur = end; }
215
- else if (cmd === "Q") {
216
- const q = pt(), end = pt();
217
- const c1 = [cur[0] + (2 / 3) * (q[0] - cur[0]), cur[1] + (2 / 3) * (q[1] - cur[1])];
218
- const c2 = [end[0] + (2 / 3) * (q[0] - end[0]), end[1] + (2 / 3) * (q[1] - end[1])];
219
- segments.push({ to: end, c1, c2 }); cur = end;
220
- }
221
- else if (cmd === "A") {
222
- const rx = num(), ry = num(), rot = num(), large = num(), sweep = num(), end = pt();
223
- for (const seg of svgArcToCubics(cur, rx, ry, rot, large, sweep, end)) segments.push(seg);
224
- cur = end;
225
- }
226
- else if (cmd === "Z") {
227
- if (cur[0] !== start[0] || cur[1] !== start[1]) segments.push({ to: start.slice() });
228
- pushContour(); cmd = null;
229
- }
230
- else throw new Error("svgPathToContours: coordinate before or after a command");
231
- }
232
- pushContour();
233
- return contours;
234
- }
@@ -4,13 +4,16 @@
4
4
  // involved). `toRegions()`/`simple()` are the only points where a shape gets tessellated
5
5
  // down to point rings, for handoff to a kernel op (extrude/revolve) or export.
6
6
  //
7
- // `deps.offsetRegions`/`deps.extrude`/`deps.revolve` are the two backends' own hooks
8
- // (Task 13 wires Manifold/OCCT versions); everything else here is pure curve math shared
9
- // by both. Each op returns a NEW Shape2D value semantics, no operand is ever mutated.
7
+ // `deps.extrude`/`deps.revolve` are the two backends' own hooks (Task 13 wires
8
+ // Manifold/OCCT versions); everything else here is pure curve math shared by both,
9
+ // including offset, which now runs the native contour-offset engine directly rather
10
+ // than through a backend hook. Each op returns a NEW Shape2D — value semantics, no
11
+ // operand is ever mutated.
10
12
  import { addShape2dSugar } from "./shape2d-sugar.js";
11
13
  import { assembleRegions } from "./shape2d-regions.js";
12
14
  import { tessellateContour } from "./profile.js";
13
15
  import { booleanRegions } from "./paper-bridge.js";
16
+ import { offsetRegions } from "./contour-offset.js";
14
17
  import { h } from "./solid-hash.js";
15
18
  import { closeContourGap } from "./profile.js";
16
19
  import {
@@ -42,7 +45,7 @@ const checkProfile = (x) => {
42
45
  }
43
46
  };
44
47
 
45
- export function makeShape2dFactory({ segs, offsetRegions, extrude, revolve }) {
48
+ export function makeShape2dFactory({ segs, extrude, revolve }) {
46
49
  // Lift any accepted profile form into stored regions: a live Shape2D is deep-copied out
47
50
  // via its own toContours() (value semantics — never alias another shape's storage);
48
51
  // anything else goes through liftProfile + per-ring winding normalization.
@@ -61,14 +64,15 @@ export function makeShape2dFactory({ segs, offsetRegions, extrude, revolve }) {
61
64
  cut: (o) => make(booleanRegions(regions, liftRegions(o), "subtract")),
62
65
  cutAll: (os) => make(os.reduce((acc, o) => booleanRegions(acc, liftRegions(o), "subtract"), regions)),
63
66
  intersect: (o) => make(booleanRegions(regions, liftRegions(o), "intersect")),
64
- // offsetRegions is the one backend hook feeding straight into make()it doesn't route
65
- // through liftRegions, so unlike every other op here nothing already guaranteed its
66
- // rings are explicitly closed. Both backends' readbacks close explicitly today, so this
67
- // is a no-op in practice; it's here so the storage invariant (every stored ring
68
- // explicitly closed see closeContourGap's own comment) holds unconditionally.
69
- // Empty in empty out without calling the hook: the 2-D ops stay total on the
70
- // empty shape on both backends (the OCCT hook would otherwise choke on a null
71
- // Drawing); only 3-D materialization (extrude/revolve) rejects it.
67
+ // offset now runs the shared native engine directly, like the booleans above no
68
+ // backend hook. Its result feeds straight into make() without routing through
69
+ // liftRegions, so unlike every other op here nothing already guaranteed its rings
70
+ // are explicitly closed; offsetRegions' own readback closes explicitly today, so
71
+ // this is a no-op in practice. It's here so the storage invariant (every stored
72
+ // ring explicitly closed see closeContourGap's own comment) holds unconditionally.
73
+ // Empty in → empty out without entering the engine: the 2-D ops stay total on the
74
+ // empty shape, and the engine would otherwise read an empty result as a collapse
75
+ // and throw. Only 3-D materialization (extrude/revolve) rejects the empty shape.
72
76
  offset: (delta, opts = {}) => regions.length === 0 ? make([]) : make(offsetRegions(regions, delta, opts)
73
77
  .map((rg) => ({ outer: closeContourGap(rg.outer), holes: rg.holes.map(closeContourGap) }))),
74
78
  isEmpty: () => regions.length === 0,
@@ -346,6 +346,14 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
346
346
  if (onPick) {
347
347
  picker = attachPicker(viewer, {
348
348
  part, getContext,
349
+ // Measure mode claims canvas clicks for pinning dimensions, so while it
350
+ // is on a click must not ALSO select-and-flash (hosts turn picks into
351
+ // chat chips — one click was doing both). Same idea as the hover
352
+ // suppression above, but pull-based: checked per click, nothing to
353
+ // resync on mode changes. The ?pick/?pickserver harnesses below are
354
+ // deliberately not guarded — one is armed by an explicit dev toggle,
355
+ // the other per agent request.
356
+ suppressed: () => measureMode.isEnabled(),
349
357
  onPick: (selection) => onPick({
350
358
  selection,
351
359
  label: selection.feature?.label ?? part.parts[selection.subPart]?.label ?? selection.subPart,
@@ -6,13 +6,19 @@ import { createDragTracker } from "./drag-tracker.js";
6
6
 
7
7
  export { worldToSubPartLocal };
8
8
 
9
- export function attachPicker(viewer, { part, getContext, onPick }) {
9
+ // `suppressed` is an optional pull-based guard checked per click, for a caller
10
+ // whose suppression condition lives elsewhere (mount passes measure mode's
11
+ // isEnabled): while it returns true a click neither raycasts, flashes, nor
12
+ // picks — no resync bookkeeping the way an event-driven setActive would need.
13
+ export function attachPicker(viewer, { part, getContext, onPick, suppressed }) {
10
14
  let active = false;
11
15
  const drag = createDragTracker();
12
16
 
13
17
  function onClick(ev) {
18
+ // consumeClick() first, unconditionally — the drag tracker is stateful and
19
+ // a suppressed click must still clear its just-dragged flag.
14
20
  const wasDragged = drag.consumeClick();
15
- if (!active || wasDragged) return;
21
+ if (!active || wasDragged || suppressed?.()) return;
16
22
  const hit = raycastViewer(viewer, ev.clientX, ev.clientY);
17
23
  if (!hit) return;
18
24
  const selection = resolveSelection(part, getContext(), hit);