partforge 0.16.0 → 0.19.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.
- package/docs/AUTHORING-PARTS.md +33 -1
- package/docs/ERROR-PATTERNS.md +27 -0
- package/package.json +1 -1
- package/src/framework/geometry/kernel-front.js +4 -1
- package/src/framework/geometry/kernel.js +20 -2
- package/src/framework/geometry/manifold-backend.js +78 -7
- package/src/framework/geometry/occt-backend.js +102 -8
- package/src/framework/geometry/op-options.js +4 -0
- package/src/framework/geometry/polygon.js +26 -0
- package/src/framework/geometry/profile.js +59 -10
- package/src/framework/geometry/shape2d-regions.js +134 -0
- package/src/framework/geometry/shape2d-sugar.js +11 -0
- package/src/parts/demo.js +1 -1
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -151,11 +151,18 @@ k.prism({ points: roundedProfile(bracketOutline, 3), h: 4 }); // true CIRCLE co
|
|
|
151
151
|
// print clearance on an arbitrary cut profile, or an inset wall
|
|
152
152
|
k.extrude({ profile: offsetPolygon(slotPolygon(20, 3), 0.2), h: 10 }); // slot cut 0.2 mm looser all around
|
|
153
153
|
offsetPolygon(outline, -wall, { corners: "sharp" }); // inset a wall (see planter.js)
|
|
154
|
+
|
|
155
|
+
// A tab with one free-form curved side (exact on STEP, faceted at mesh LOD):
|
|
156
|
+
const tab = pathProfile([0, 0])
|
|
157
|
+
.lineTo([20, 0]).lineTo([20, 8])
|
|
158
|
+
.cubicTo([0, 8], [14, 16], [6, 16]) // curved top edge
|
|
159
|
+
.close();
|
|
160
|
+
k.extrude({ profile: tab, h: 3 });
|
|
154
161
|
```
|
|
155
162
|
|
|
156
163
|
2-D polygon helpers for `prism`/`extrude`/`loft`: `import { piePolygon, hexPolygon,
|
|
157
164
|
regularPolygon, roundedRectPolygon, starPolygon, slotPolygon, circleProfile, filletPolygon,
|
|
158
|
-
roundedProfile, offsetPolygon } from "partforge/geometry"`. `filletPolygon(points, r, { segs? })` rounds
|
|
165
|
+
roundedProfile, offsetPolygon, pathProfile } from "partforge/geometry"`. `filletPolygon(points, r, { segs? })` rounds
|
|
159
166
|
every corner of a CCW polygon (per-corner radius clamped so neighbouring arcs never overlap)
|
|
160
167
|
and returns points usable by `prism`/`extrude`/`loft` on both backends — but it **bakes each
|
|
161
168
|
corner into line facets**, so STEP corners are faceted. `roundedProfile(points, r | r[])`
|
|
@@ -173,6 +180,7 @@ an offset whose true result would collapse or split into multiple contours (e.g.
|
|
|
173
180
|
dumbbell past its waist) **throws** a greppable error rather than returning degenerate
|
|
174
181
|
geometry. Being pure, it works in `derive()` as well as `build()` — the natural home for
|
|
175
182
|
clearance math.
|
|
183
|
+
`pathProfile(start)` is a fluent builder for a curve-native path contour (`lineTo` / `arcTo` / `cubicTo` / `close`); cubic segments become exact B-rep spline edges on the OCCT/STEP backend and facet at the mesh LOD on Manifold — the same exact-vs-faceted split as `roundedProfile` arcs.
|
|
176
184
|
**Import geometry helpers from `partforge/geometry`, never from `partforge`** — the main
|
|
177
185
|
entry pulls in the DOM viewer/controls, and your build functions run in a Web Worker
|
|
178
186
|
(importing the main entry there throws `document is not defined`).
|
|
@@ -466,6 +474,30 @@ const hole = k.cylinder({ r: 2, h: 20 }).translate([20, 0, 0]);
|
|
|
466
474
|
body = body.cutAll(circularPattern(hole, 8, { axis: "Z" })); // 8 bolt holes on a 40mm circle
|
|
467
475
|
```
|
|
468
476
|
|
|
477
|
+
## 2-D booleans
|
|
478
|
+
|
|
479
|
+
`k.shape2d(profile)` lifts a point list, arc profile, or region into a `Shape2D` — an opaque 2-D boolean value. You can then compose booleans, and feed the result directly to `extrude` or `revolve` without materializing intermediate regions. The same `content-hash caching` discipline applies: identical arguments produce identical geometry.
|
|
480
|
+
|
|
481
|
+
**Shape2D booleans are a build-time operation** (not `derive()`), and the curve semantics differ between backends: on OCCT the result carries exact circular arcs and Bézier curves into STEP export; on Manifold the curves facet to mesh LOD.
|
|
482
|
+
|
|
483
|
+
```js
|
|
484
|
+
// Keyhole plate: union a disc onto a rect, punch a slot, extrude.
|
|
485
|
+
const plate = k.shape2d(roundedRectPolygon(40, 24, 4))
|
|
486
|
+
.union(circleProfile(8))
|
|
487
|
+
.cut(slotPolygon(16, 3));
|
|
488
|
+
k.extrude({ profile: plate, h: 3 });
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
```js
|
|
492
|
+
// A 0.2 mm printer clearance around a bore, then a 2 mm wall inset:
|
|
493
|
+
const bore = k.shape2d(circleProfile(3)).offset(0.2); // looser
|
|
494
|
+
const wall = k.shape2d(outer).offset(-2, { corners: "sharp" }); // inset, mitered
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
(This achieves the same geometry as building the profiles separately and using `k.extrude({ profile: { outer, holes }, h })`, but the Shape2D path is more idiomatic for complex 2-D operations.)
|
|
498
|
+
|
|
499
|
+
`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.)
|
|
500
|
+
|
|
469
501
|
---
|
|
470
502
|
|
|
471
503
|
## Wiring a part into a runnable app
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -221,6 +221,33 @@ Variant literals under this entry: `offsetPolygon: delta must be a finite number
|
|
|
221
221
|
- **Cause:** the true offset of this shape at this `|delta|` is not a single simple polygon (e.g. insetting a dumbbell past its waist would split it in two) — out of `offsetPolygon`'s envelope.
|
|
222
222
|
- **Fix:** reduce `|delta|`, or decompose the profile into separately-offset simple contours.
|
|
223
223
|
|
|
224
|
+
## cubic-segment-mixes-arc-and-cubic
|
|
225
|
+
|
|
226
|
+
- **Symptom:** `extrude: <role> segment cannot mix arc (via) and cubic (c1/c2)`
|
|
227
|
+
- **Cause:** A path-contour segment carries both `via` (three-point arc) and `c1`/`c2` (cubic Bézier). A segment is exactly one kind.
|
|
228
|
+
- **Fix:** Drop `via` for a cubic, or drop `c1`/`c2` for an arc. Use `pathProfile().arcTo(to, via)` or `.cubicTo(to, c1, c2)` to build segments.
|
|
229
|
+
|
|
230
|
+
## cubic-segment-missing-controls
|
|
231
|
+
|
|
232
|
+
- **Symptom:** `extrude: <role> cubic segment needs c1 and c2 as finite [x,y]`
|
|
233
|
+
- **Cause:** A cubic segment is missing `c1` or `c2`, or a control point is not a finite `[x,y]` (e.g. `NaN`, wrong length).
|
|
234
|
+
- **Fix:** Provide both control points as finite `[x,y]`. A cubic Bézier needs two controls between the previous point and `to`.
|
|
235
|
+
|
|
236
|
+
## shape2d-simple-not-single-region
|
|
237
|
+
|
|
238
|
+
- **Symptom:** `Shape2D.simple: result has N regions, not 1 (use toRegions())`
|
|
239
|
+
- **Cause:** `.simple()` was called on a boolean result that is empty or split into multiple disjoint regions (e.g. `intersect` of disjoint shapes, or a `cut` that severs a shape in two).
|
|
240
|
+
- **Fix:** Use `.toRegions()` to get the array, or adjust the operands so the result is a single connected region. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "2-D booleans".
|
|
241
|
+
|
|
242
|
+
## shape2d-offset-collapses
|
|
243
|
+
|
|
244
|
+
- **Symptom:** `Shape2D.offset: offset collapses the shape (reduce |delta|)`
|
|
245
|
+
- **Cause:** A negative (inset) `offset` removed more than the shape's half-width,
|
|
246
|
+
leaving no geometry — or the delta is larger than the feature it offsets.
|
|
247
|
+
- **Fix:** Reduce `|delta|`, or check the source profile is large enough for the
|
|
248
|
+
inset. Realistic clearances (fractions of a mm) and wall insets up to the
|
|
249
|
+
narrowest feature never trip this.
|
|
250
|
+
|
|
224
251
|
# Hardware library
|
|
225
252
|
|
|
226
253
|
Reserved for `hardware-*` patterns (issue #30). No entries yet.
|
package/package.json
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
// cache hashes normalized args;
|
|
9
9
|
// - default compound-op compositions — a backend only overrides one when it has
|
|
10
10
|
// a reason to (Manifold's boredCylinder hashes atomically for its solid cache);
|
|
11
|
-
// - a KernelCapabilityError stub for toSTEP when
|
|
11
|
+
// - a KernelCapabilityError stub for toSTEP / shape2d when a backend lacks that
|
|
12
|
+
// capability (Manifold can't do toSTEP; both backends now define shape2d, so
|
|
13
|
+
// that stub is dead in practice — kept as a safety net for a future backend).
|
|
12
14
|
// The per-Solid twin of this layer is addSugar() in solid-sugar.js.
|
|
13
15
|
import { KernelCapabilityError } from "./errors.js";
|
|
14
16
|
import { isPlainOptions, KERNEL_OP_SPECS } from "./op-options.js";
|
|
@@ -31,6 +33,7 @@ export function finishKernel(k) {
|
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
k.toSTEP ??= () => { throw new KernelCapabilityError("toSTEP requires the OCCT backend"); };
|
|
36
|
+
k.shape2d ??= () => { throw new KernelCapabilityError("shape2d requires the Manifold backend"); };
|
|
34
37
|
|
|
35
38
|
return k;
|
|
36
39
|
}
|
|
@@ -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", "toSTEP",
|
|
22
|
+
"loft", "sweep", "helixSweptTube", "union", "shape2d", "toSTEP",
|
|
23
23
|
];
|
|
24
24
|
|
|
25
25
|
// Backend-optional kernel ops: the Manifold cache brackets + WASM lifetime hooks.
|
|
@@ -30,7 +30,7 @@ export const KERNEL_OPTIONAL_OPS = [
|
|
|
30
30
|
|
|
31
31
|
// Ops every Solid must implement (including the sugar addSugar() attaches).
|
|
32
32
|
export const SOLID_OPS = [
|
|
33
|
-
"cut", "cutAll", "intersect", "clone", "label", "boundingBox", "volume",
|
|
33
|
+
"cut", "cutAll", "intersect", "union", "clone", "label", "boundingBox", "volume",
|
|
34
34
|
"translate", "rotate", "rotateX", "rotateY", "rotateZ", "rotateAbout", "along", "at",
|
|
35
35
|
"mirror", "scale", "toMesh", "toSTL", "toIndexedMesh",
|
|
36
36
|
"fillet", "chamfer", "shell",
|
|
@@ -40,6 +40,11 @@ export const SOLID_OPS = [
|
|
|
40
40
|
// guards with `typeof`); OCCT has no cheap equivalent.
|
|
41
41
|
export const SOLID_OPTIONAL_OPS = ["genus", "isEmpty"];
|
|
42
42
|
|
|
43
|
+
// Public methods every Shape2D exposes (2-D boolean value; contract-linted).
|
|
44
|
+
export const SHAPE2D_OPS = [
|
|
45
|
+
"union", "cut", "cutAll", "intersect", "offset", "area", "boundingBox", "toRegions", "simple", "clone",
|
|
46
|
+
];
|
|
47
|
+
|
|
43
48
|
// Solid ops only OCCT implements natively. Single source of truth: probe.js routes
|
|
44
49
|
// a part to OCCT when its build uses one of these, and the Manifold backend
|
|
45
50
|
// generates its KernelCapabilityError stubs from the same list — adding an op here
|
|
@@ -51,6 +56,7 @@ export const OCCT_ONLY_OPS = ["fillet", "chamfer", "shell"];
|
|
|
51
56
|
* @property {(tool: Solid) => Solid} cut
|
|
52
57
|
* @property {(tools: Solid[]) => Solid} cutAll batch subtract (backend-optimized)
|
|
53
58
|
* @property {(other: Solid) => Solid} intersect boolean intersection (both backends)
|
|
59
|
+
* @property {(other: Solid) => Solid} union boolean union with one other solid (n-ary: k.union([...]))
|
|
54
60
|
* @property {() => Solid} clone independent copy (replicad consumes solids on transform)
|
|
55
61
|
* @property {(name: string) => Solid} label name this solid's surface for hover/pick feature attribution (survives transforms + booleans; same name on several solids merges into one feature)
|
|
56
62
|
* @property {() => {min:number[],max:number[],center:number[],size:number[]}} boundingBox axis-aligned bounds (query)
|
|
@@ -75,6 +81,17 @@ export const OCCT_ONLY_OPS = ["fillet", "chamfer", "shell"];
|
|
|
75
81
|
* @property {() => number} [genus] through-hole count (Manifold only)
|
|
76
82
|
* @property {() => boolean} [isEmpty] no geometry at all (Manifold only)
|
|
77
83
|
*
|
|
84
|
+
* @typedef {Object} Shape2D An opaque 2-D boolean value (both backends: Manifold wraps a CrossSection, OCCT a replicad Drawing). `_`-prefixed keys are backend internals.
|
|
85
|
+
* @property {(other: Shape2D|number[][]) => Shape2D} union
|
|
86
|
+
* @property {(other: Shape2D|number[][]) => Shape2D} cut
|
|
87
|
+
* @property {(others: (Shape2D|number[][])[]) => Shape2D} cutAll batch subtract
|
|
88
|
+
* @property {(other: Shape2D|number[][]) => Shape2D} intersect
|
|
89
|
+
* @property {() => number} area net area (outers minus holes), mm²
|
|
90
|
+
* @property {() => {min:number[],max:number[]}} boundingBox axis-aligned 2-D bounds
|
|
91
|
+
* @property {() => {outer:number[][],holes:number[][][]}[]} toRegions materialize into region arrays (assembleRegions)
|
|
92
|
+
* @property {() => {outer:number[][],holes:number[][][]}} simple toRegions(), unwrapped — throws unless exactly 1 region
|
|
93
|
+
* @property {() => Shape2D} clone independent handle
|
|
94
|
+
*
|
|
78
95
|
* @typedef {Object} GeometryKernel
|
|
79
96
|
* @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
|
|
80
97
|
* @property {(o:{od:number,h:number,bore:number}) => Solid} boredCylinder compound: bored-through cylinder (one cache node)
|
|
@@ -87,6 +104,7 @@ export const OCCT_ONLY_OPS = ["fillet", "chamfer", "shell"];
|
|
|
87
104
|
* @property {(o:{profile:number[][],degrees?:number}) => Solid} revolve revolve a lathe profile [[r,z],…] around Z; legacy (points,opts) accepted until v2
|
|
88
105
|
* @property {(o:{pathR:number,profileR:number,pitch:number,turns:number,z0:number,lefthand:boolean}) => Solid} helixSweptTube
|
|
89
106
|
* @property {(solids:Solid[]) => Solid} union
|
|
107
|
+
* @property {(profile: number[][]|{outer:number[][],holes?:number[][][]}|Shape2D) => Shape2D} shape2d 2-D boolean value (both backends: Manifold wraps a CrossSection, OCCT a replicad Drawing)
|
|
90
108
|
* @property {(named:{name:string,solid:Solid}[]) => Promise<ArrayBuffer>} toSTEP OCCT only (Manifold throws KernelCapabilityError)
|
|
91
109
|
* @property {(name:string) => void} [beginSubPart] open a per-sub-part solid-cache round (Manifold only)
|
|
92
110
|
* @property {() => void} [endSubPart] close the cache round (always pair with beginSubPart)
|
|
@@ -5,6 +5,8 @@ import { tessellateContour, tessellateProfile } from "./profile.js";
|
|
|
5
5
|
import { h } from "./solid-hash.js";
|
|
6
6
|
import { createSolidCache } from "./solid-cache.js";
|
|
7
7
|
import { addSugar } from "./solid-sugar.js";
|
|
8
|
+
import { addShape2dSugar } from "./shape2d-sugar.js";
|
|
9
|
+
import { assembleRegions } from "./shape2d-regions.js";
|
|
8
10
|
import { finishKernel } from "./kernel-front.js";
|
|
9
11
|
|
|
10
12
|
const PLANE_NORMAL = { XY: [0, 0, 1], XZ: [0, 1, 0], YZ: [1, 0, 0] };
|
|
@@ -49,6 +51,63 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
49
51
|
return { value: wrap(m, hash), pin: m, dispose: () => m.delete?.() };
|
|
50
52
|
});
|
|
51
53
|
|
|
54
|
+
// 2-D cross-section value. Mirrors wrap()/cached(): booleans route through the
|
|
55
|
+
// solid cache (dispose frees the CrossSection); operands fold by _hash.
|
|
56
|
+
const cachedCS = (hash, computeCS) => cache.lookup(hash, () => {
|
|
57
|
+
const cs = computeCS(); // already T()-tracked
|
|
58
|
+
return { value: wrapShape2d(cs, hash), pin: cs, dispose: () => cs.delete?.() };
|
|
59
|
+
});
|
|
60
|
+
const liftCS = (x) => (x && x._shape2d ? x : shape2d(x));
|
|
61
|
+
const wrapShape2d = (cs, hash) => addShape2dSugar({
|
|
62
|
+
_cs: cs,
|
|
63
|
+
_shape2d: true,
|
|
64
|
+
_hash: hash,
|
|
65
|
+
union: (o) => { const t = liftCS(o); return cachedCS(h("union2d", hash, t._hash), () => T(cs.add(t._cs))); },
|
|
66
|
+
cut: (o) => { const t = liftCS(o); return cachedCS(h("cut2d", hash, t._hash), () => T(cs.subtract(t._cs))); },
|
|
67
|
+
cutAll: (os) => {
|
|
68
|
+
if (os.length === 0) return wrapShape2d(cs, hash); // identity — no new WASM / cache entry (avoids double-free)
|
|
69
|
+
const ts = os.map(liftCS);
|
|
70
|
+
// The reducer's inner T() already tracks every step (incl. the final) — no
|
|
71
|
+
// outer T() around the reduce, or the result lands in `tracked` twice and
|
|
72
|
+
// cleanup() double-frees it. os is non-empty here, so the seed cs is never
|
|
73
|
+
// returned; the result is always a fresh subtract, never aliasing the input.
|
|
74
|
+
return cachedCS(h("cutAll2d", hash, ts.map((t) => t._hash)), () => ts.reduce((acc, t) => T(acc.subtract(t._cs)), cs));
|
|
75
|
+
},
|
|
76
|
+
intersect: (o) => { const t = liftCS(o); return cachedCS(h("intersect2d", hash, t._hash), () => T(cs.intersect(t._cs))); },
|
|
77
|
+
offset: (delta, { corners = "round", segs: nSeg = segs } = {}) => {
|
|
78
|
+
if (!["round", "chamfer", "sharp"].includes(corners))
|
|
79
|
+
throw new Error('Shape2D.offset: corners must be "round" | "chamfer" | "sharp"');
|
|
80
|
+
if (!Number.isFinite(delta)) throw new Error("Shape2D.offset: delta must be a finite number");
|
|
81
|
+
// chamfer is a true 45° bevel — Clipper2 has no bevel join, but a Round join
|
|
82
|
+
// forced to a single chord per corner (circularSegments=4 → 1 segment per corner
|
|
83
|
+
// whose turn ≤ 90°, i.e. interior angle ≥ 90°) IS the bevel: round's tangent points
|
|
84
|
+
// are exactly the bevel's endpoints. Matches OCCT's `bevel` to float precision for
|
|
85
|
+
// interior angle ≥ 90° (square 142.0000, pentagon 298.920). At acute (<90°) convex
|
|
86
|
+
// corners Clipper2 emits 2 chords (ceil(turn/90°)), so Manifold bulges ~0.4% beyond
|
|
87
|
+
// OCCT's single-chord bevel there. round = arc at mesh LOD; sharp = miter.
|
|
88
|
+
const [joinType, cseg] = corners === "sharp" ? ["Miter", nSeg]
|
|
89
|
+
: corners === "chamfer" ? ["Round", 4]
|
|
90
|
+
: ["Round", nSeg];
|
|
91
|
+
return cachedCS(h("offset2d", hash, delta, corners, cseg), () => {
|
|
92
|
+
const out = T(cs.offset(delta, joinType, 2, cseg)); // miterLimit 2 (Clipper2 default)
|
|
93
|
+
if (out.numContour() === 0) throw new Error("Shape2D.offset: offset collapses the shape (reduce |delta|)");
|
|
94
|
+
return out;
|
|
95
|
+
});
|
|
96
|
+
},
|
|
97
|
+
area: () => cs.area(),
|
|
98
|
+
boundingBox: () => { const r = cs.bounds(); return { min: [r.min[0], r.min[1]], max: [r.max[0], r.max[1]] }; },
|
|
99
|
+
toRegions: () => assembleRegions(cs.toPolygons()),
|
|
100
|
+
clone: () => wrapShape2d(cs, hash),
|
|
101
|
+
});
|
|
102
|
+
const shape2d = (profile) => {
|
|
103
|
+
if (profile && profile._shape2d) return profile; // idempotent
|
|
104
|
+
const hash = h("shape2d", profile, segs);
|
|
105
|
+
return cachedCS(hash, () => {
|
|
106
|
+
const { outer, holes } = tessellateProfile(profile, segs);
|
|
107
|
+
return T(CrossSection.ofPolygons([outer, ...holes], "EvenOdd"));
|
|
108
|
+
});
|
|
109
|
+
};
|
|
110
|
+
|
|
52
111
|
// Copy the mesh out into JS-owned arrays (so it survives cleanup) and free the
|
|
53
112
|
// transient mesh handle.
|
|
54
113
|
function meshOut(m, asStl) {
|
|
@@ -82,6 +141,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
82
141
|
cutAll: (tools) => cached(h("cutAll", hash, tools.map((t) => t._hash)),
|
|
83
142
|
() => T(m.subtract(unionRaw(tools.map((t) => t._m))))),
|
|
84
143
|
intersect: (t) => cached(h("intersect", hash, t._hash), () => T(m.intersect(t._m))),
|
|
144
|
+
union: (t) => cached(h("union", [hash, t._hash]), () => unionRaw([m, t._m])),
|
|
85
145
|
clone: () => wrap(m, hash),
|
|
86
146
|
// Name this solid's surface for hover/pick feature attribution. asOriginal()
|
|
87
147
|
// stamps a fresh originalID that survives transforms and booleans, so every
|
|
@@ -152,14 +212,21 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
152
212
|
}),
|
|
153
213
|
// Polygon-with-holes extrude in one op: even/odd fill turns the extra contours into
|
|
154
214
|
// holes regardless of their winding (outer + holes, no per-hole boolean cut).
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
215
|
+
// A Shape2D `profile` (already a CrossSection, possibly multi-region) extrudes
|
|
216
|
+
// directly off its own `_cs` — no re-tessellation — and folds into the cache
|
|
217
|
+
// key by `_hash` like any other solid operand.
|
|
218
|
+
extrude: (profile, height, { twist = 0, scaleTop = 1 } = {}) => {
|
|
219
|
+
const shape = profile && profile._shape2d ? profile : null;
|
|
220
|
+
return cached(h("extrude", shape ? shape._hash : profile, height, twist, scaleTop, segs), () => {
|
|
221
|
+
const cs = shape ? shape._cs : (() => {
|
|
222
|
+
const { outer, holes } = tessellateProfile(profile, segs);
|
|
223
|
+
return T(CrossSection.ofPolygons([outer, ...holes], "EvenOdd"));
|
|
224
|
+
})();
|
|
159
225
|
if (twist === 0 && scaleTop === 1) return T(cs.extrude(height));
|
|
160
226
|
const nDiv = Math.max(1, Math.ceil(Math.abs(twist) / 5));
|
|
161
227
|
return T(cs.extrude(height, nDiv, twist, [scaleTop, scaleTop]));
|
|
162
|
-
})
|
|
228
|
+
});
|
|
229
|
+
},
|
|
163
230
|
// Ring loft: hand-meshed via the shared ring-mesh helpers (helix-tube recipe).
|
|
164
231
|
// Cached atomically; the hash folds every ring's points/z/rotate/scale and the opts.
|
|
165
232
|
loft: (rings, opts = {}) => cached(h("loft", rings, opts), () => T(loftMesh(wasm, rings, opts))),
|
|
@@ -169,9 +236,13 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
169
236
|
// (closed/cornerRadius) so a shape change is a fresh node and an identical rebuild hits.
|
|
170
237
|
sweep: (profile, path, opts = {}) => cached(h("sweep", profile, path, opts), () => T(sweepMesh(wasm, profile, path, opts))),
|
|
171
238
|
helixSweptTube: (o) => cached(h("helixSweptTube", o, tube), () => T(helixTube(wasm, { ...o, ...tube }))),
|
|
172
|
-
revolve: (pts, { degrees = 360 } = {}) =>
|
|
173
|
-
|
|
239
|
+
revolve: (pts, { degrees = 360 } = {}) => {
|
|
240
|
+
if (pts && pts._shape2d)
|
|
241
|
+
return cached(h("revolve", pts._hash, degrees, segs), () => T(pts._cs.revolve(segs, degrees)));
|
|
242
|
+
return cached(h("revolve", pts, degrees, segs), () => T(Manifold.revolve([pts], segs, degrees)));
|
|
243
|
+
},
|
|
174
244
|
union: (solids) => cached(h("union", solids.map((s) => s._hash)), () => unionRaw(solids.map((s) => s._m))),
|
|
245
|
+
shape2d,
|
|
175
246
|
beginSubPart: (name) => cache.begin(name),
|
|
176
247
|
endSubPart: () => cache.end(),
|
|
177
248
|
cacheStats: () => cache.stats(),
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
import { toEdgeFinder } from "./edge-selector.js";
|
|
5
5
|
import { toFaceFinder } from "./face-selector.js";
|
|
6
6
|
import { addSugar } from "./solid-sugar.js";
|
|
7
|
+
import { addShape2dSugar } from "./shape2d-sugar.js";
|
|
8
|
+
import { assembleRegions, svgPathToRings, regionsArea, pointInRing, ringArea } from "./shape2d-regions.js";
|
|
7
9
|
import { finishKernel } from "./kernel-front.js";
|
|
8
10
|
import { createOcctRepair } from "./occt-repair.js";
|
|
9
11
|
import { classifyFaceGroups } from "./feature-attribution.js";
|
|
@@ -36,6 +38,7 @@ export function createOcctKernel(replicad) {
|
|
|
36
38
|
[...cloneLabels(labels), ...tools.flatMap((t) => cloneLabels(t._labels ?? []))]
|
|
37
39
|
),
|
|
38
40
|
intersect: (t) => wrap(shape.intersect(t._s), [...cloneLabels(labels), ...cloneLabels(t._labels ?? [])]),
|
|
41
|
+
union: (t) => wrap(shape.fuse(t._s), [...cloneLabels(labels), ...cloneLabels(t._labels ?? [])]),
|
|
39
42
|
clone: () => wrap(shape.clone(), cloneLabels(labels)),
|
|
40
43
|
boundingBox: () => {
|
|
41
44
|
const [min, max] = shape.boundingBox.bounds; // addSugar derives center/size
|
|
@@ -89,8 +92,9 @@ export function createOcctKernel(replicad) {
|
|
|
89
92
|
// Draw a closed Drawing from a Contour: a legacy 2-D point list (all straight edges,
|
|
90
93
|
// the former polyDrawing) OR an ArcContour whose { to, via } segments become true
|
|
91
94
|
// OCCT arc edges via threePointsArcTo — so a rounded corner survives to STEP as a
|
|
92
|
-
// real CIRCLE B-rep entity, not a fan of LINEs.
|
|
93
|
-
//
|
|
95
|
+
// real CIRCLE B-rep entity, not a fan of LINEs. Cubic segments ({ to, c1, c2 })
|
|
96
|
+
// map to cubicBezierCurveTo for exact B-rep spline edges. close() joins the last
|
|
97
|
+
// point back to the start with a straight edge (mirrors the implied ArcContour closure).
|
|
94
98
|
const contourDrawing = (contour) => {
|
|
95
99
|
if (Array.isArray(contour)) {
|
|
96
100
|
let pen = draw(contour[0]);
|
|
@@ -98,10 +102,97 @@ export function createOcctKernel(replicad) {
|
|
|
98
102
|
return pen.close();
|
|
99
103
|
}
|
|
100
104
|
let pen = draw(contour.start);
|
|
101
|
-
for (const seg of contour.segments)
|
|
105
|
+
for (const seg of contour.segments)
|
|
106
|
+
pen = seg.c1 ? pen.cubicBezierCurveTo(seg.to, seg.c1, seg.c2)
|
|
107
|
+
: seg.via ? pen.threePointsArcTo(seg.to, seg.via)
|
|
108
|
+
: pen.lineTo(seg.to);
|
|
102
109
|
return pen.close();
|
|
103
110
|
};
|
|
104
111
|
|
|
112
|
+
// Region (outer + holes) -> Drawing, exactly like extrude's former inline region
|
|
113
|
+
// path: draw the outer contour, then .cut() each hole Drawing out of it.
|
|
114
|
+
const SHAPE2D_SEGS = 64; // materialization LOD for toRegions() discretization
|
|
115
|
+
const drawingFromProfile = (profile) => {
|
|
116
|
+
const { outer, holes } = normalizeProfile(profile);
|
|
117
|
+
let region = contourDrawing(outer);
|
|
118
|
+
for (const hole of holes) region = region.cut(contourDrawing(hole));
|
|
119
|
+
return region;
|
|
120
|
+
};
|
|
121
|
+
const liftDrawing = (x) => (x && x._shape2d ? x : shape2d(x));
|
|
122
|
+
// Materialize a replicad Drawing into flat rings ready for the shared
|
|
123
|
+
// assembleRegions (which buckets outer/hole by ring winding SIGN). TWO
|
|
124
|
+
// corrections here, both confirmed against real replicad output rather than
|
|
125
|
+
// guessed (see task-4-report.md's probe results):
|
|
126
|
+
//
|
|
127
|
+
// 1. toSVGPathD() renders in SVG's y-down convention, so every coordinate is
|
|
128
|
+
// negated back to model space.
|
|
129
|
+
// 2. Drawing.toSVGPaths() nests 0-2 levels deep depending on the result shape,
|
|
130
|
+
// INCONSISTENTLY — e.g. a single interior hole nests as
|
|
131
|
+
// [[outerD, holeD]], but two disjoint holes built via sequential .cut()
|
|
132
|
+
// calls (cutAll) come back as a flat [outerD, hole1D, hole2D] with no
|
|
133
|
+
// grouping at all. So which array position is "the outer" can't be read off
|
|
134
|
+
// the nesting shape. Worse: unlike Manifold's CrossSection.toPolygons()
|
|
135
|
+
// (outer CCW/positive, hole CW/negative), replicad emits EVERY loop of a
|
|
136
|
+
// region — outer or hole — with the same rotational sense, so ring winding
|
|
137
|
+
// carries no outer/hole signal either (verified: an interior hole in a
|
|
138
|
+
// 20x20 square, classified by sign alone, came back as 2 disjoint "outer"
|
|
139
|
+
// regions summing 400+36 instead of one region netting 364).
|
|
140
|
+
// The one signal that IS reliable is geometric containment DEPTH: classify
|
|
141
|
+
// each ring by how many OTHER rings contain it (even-odd nesting), then
|
|
142
|
+
// force each ring's winding to match its depth parity ABSOLUTELY — even depth
|
|
143
|
+
// is an outer (CCW / positive area), odd depth is a hole (CW / negative area).
|
|
144
|
+
// Setting the orientation absolutely (rather than reversing relative to the
|
|
145
|
+
// emitted sense) is what makes this winding-agnostic: a CW-wound cut tool
|
|
146
|
+
// makes replicad emit the hole loop with the opposite sense, and a relative
|
|
147
|
+
// reversal would double-flip it back to a positive area — misbucketing the
|
|
148
|
+
// hole as a second outer (409/2-regions/0-holes instead of 391/1/1).
|
|
149
|
+
const drawingRegionRings = (drawing) => {
|
|
150
|
+
const rings = drawing.toSVGPaths().flat(Infinity)
|
|
151
|
+
.flatMap((d) => svgPathToRings(d, SHAPE2D_SEGS))
|
|
152
|
+
.map((ring) => ring.map(([x, y]) => [x, -y]));
|
|
153
|
+
const containedBy = rings.map((r, i) =>
|
|
154
|
+
rings.reduce((n, other, j) => (i !== j && pointInRing(r[0], other) ? n + 1 : n), 0));
|
|
155
|
+
return rings.map((r, i) => {
|
|
156
|
+
const wantOuter = containedBy[i] % 2 === 0; // even depth = outer
|
|
157
|
+
return (ringArea(r) >= 0) === wantOuter ? r : r.slice().reverse();
|
|
158
|
+
});
|
|
159
|
+
};
|
|
160
|
+
const wrapShape2d = (drawing) => {
|
|
161
|
+
const toRegions = () => assembleRegions(drawingRegionRings(drawing));
|
|
162
|
+
return addShape2dSugar({
|
|
163
|
+
_drawing: drawing,
|
|
164
|
+
_shape2d: true,
|
|
165
|
+
union: (o) => wrapShape2d(drawing.clone().fuse(liftDrawing(o)._drawing.clone())),
|
|
166
|
+
cut: (o) => wrapShape2d(drawing.clone().cut(liftDrawing(o)._drawing.clone())),
|
|
167
|
+
cutAll: (os) => wrapShape2d(os.map(liftDrawing).reduce((acc, t) => acc.cut(t._drawing.clone()), drawing.clone())),
|
|
168
|
+
intersect: (o) => wrapShape2d(drawing.clone().intersect(liftDrawing(o)._drawing.clone())),
|
|
169
|
+
// corners map onto replicad's Offset2DConfig.lineJoinType; "chamfer" → "bevel", a
|
|
170
|
+
// true 45° corner cut (a straight chord). Manifold now matches this via a
|
|
171
|
+
// single-chord Round join (see manifold-backend offset) — the two agree to float
|
|
172
|
+
// precision for convex corners with interior angle ≥ 90°; at acute (<90°) corners
|
|
173
|
+
// Manifold uses a 2-facet approximation that departs slightly. See KERNEL-CONTRACT.
|
|
174
|
+
offset: (delta, { corners = "round" } = {}) => {
|
|
175
|
+
const lineJoinType = { round: "round", chamfer: "bevel", sharp: "miter" }[corners];
|
|
176
|
+
if (!lineJoinType) throw new Error('Shape2D.offset: corners must be "round" | "chamfer" | "sharp"');
|
|
177
|
+
if (!Number.isFinite(delta)) throw new Error("Shape2D.offset: delta must be a finite number");
|
|
178
|
+
const result = drawing.clone().offset(delta, { lineJoinType }); // clone — replicad consumes the operand
|
|
179
|
+
// Collapse doesn't throw and Drawing has no public `blueprints` array (that's on
|
|
180
|
+
// Blueprints/CompoundBlueprint, not Drawing) — replicad instead returns a Drawing
|
|
181
|
+
// whose private `innerShape` is null (confirmed by probe). That's the collapse signal.
|
|
182
|
+
// NB: `innerShape` is replicad-internal; the "collapse throws immediately (OCCT)" test
|
|
183
|
+
// guards this — a replicad upgrade that renames it must keep that test green.
|
|
184
|
+
if (!result || !result.innerShape)
|
|
185
|
+
throw new Error("Shape2D.offset: offset collapses the shape (reduce |delta|)");
|
|
186
|
+
return wrapShape2d(result);
|
|
187
|
+
},
|
|
188
|
+
area: () => regionsArea(toRegions()), // no native Drawing area → derive from materialized regions
|
|
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
|
+
toRegions,
|
|
191
|
+
clone: () => wrapShape2d(drawing.clone()),
|
|
192
|
+
});
|
|
193
|
+
};
|
|
194
|
+
const shape2d = (profile) => (profile && profile._shape2d ? profile : wrapShape2d(drawingFromProfile(profile)));
|
|
195
|
+
|
|
105
196
|
// extrude a 2-D polygon from z=0 (arguments validated by the kernel front)
|
|
106
197
|
const prism = (pts, h, { twist = 0, scaleTop = 1 } = {}) => {
|
|
107
198
|
const sketch = contourDrawing(pts).sketchOnPlane("XY");
|
|
@@ -113,15 +204,17 @@ export function createOcctKernel(replicad) {
|
|
|
113
204
|
};
|
|
114
205
|
|
|
115
206
|
// revolve a lathe profile [[r,z],…] around the Z axis (degrees defaults to 360)
|
|
116
|
-
const revolve = (pts, { degrees = 360 } = {}) =>
|
|
117
|
-
|
|
207
|
+
const revolve = (pts, { degrees = 360 } = {}) => {
|
|
208
|
+
const region = pts && pts._shape2d ? pts._drawing.clone() : contourDrawing(pts);
|
|
209
|
+
return wrap(region.sketchOnPlane("XZ").revolve([0, 0, 1], { angle: degrees }));
|
|
210
|
+
};
|
|
118
211
|
|
|
119
212
|
// extrude a polygon-with-holes region from z=0: cut each hole Drawing out of the outer
|
|
120
213
|
// Drawing (winding-agnostic 2-D boolean), sketch it, then extrude (twist/taper via cfg).
|
|
214
|
+
// A Shape2D `profile` (already a Drawing, possibly multi-region) extrudes directly off
|
|
215
|
+
// its own `_drawing` (cloned — replicad booleans/extrude consume their operand).
|
|
121
216
|
const extrude = (profile, h, { twist = 0, scaleTop = 1 } = {}) => {
|
|
122
|
-
const
|
|
123
|
-
let region = contourDrawing(outer);
|
|
124
|
-
for (const hole of holes) region = region.cut(contourDrawing(hole));
|
|
217
|
+
const region = profile && profile._shape2d ? profile._drawing.clone() : drawingFromProfile(profile);
|
|
125
218
|
const sketch = region.sketchOnPlane("XY");
|
|
126
219
|
if (twist === 0 && scaleTop === 1) return wrap(sketch.extrude(h));
|
|
127
220
|
const cfg = {};
|
|
@@ -180,6 +273,7 @@ export function createOcctKernel(replicad) {
|
|
|
180
273
|
solids.map((s) => s._s).reduce((a, b) => a.fuse(b)),
|
|
181
274
|
solids.flatMap((s) => cloneLabels(s._labels ?? []))
|
|
182
275
|
),
|
|
276
|
+
shape2d,
|
|
183
277
|
toSTEP: (named) => exportSTEP(named.map(({ name, solid }) => ({ name, shape: solid._s }))).arrayBuffer(),
|
|
184
278
|
});
|
|
185
279
|
}
|
|
@@ -145,6 +145,10 @@ export const KERNEL_OP_SPECS = {
|
|
|
145
145
|
prism: { toArgs: prismArgs, check: checkScaleTop("prism") },
|
|
146
146
|
extrude: { toArgs: extrudeArgs, check: checkScaleTop("extrude") },
|
|
147
147
|
revolve: { toArgs: revolveArgs, check: (pts) => {
|
|
148
|
+
if (pts && pts._shape2d) {
|
|
149
|
+
if (pts.boundingBox().min[0] < 0) throw new Error("revolve: profile radius must be ≥ 0");
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
148
152
|
for (const [r] of pts) if (r < 0) throw new Error("revolve: profile radius must be ≥ 0");
|
|
149
153
|
} },
|
|
150
154
|
loft: { toArgs: loftArgs },
|
|
@@ -156,6 +156,32 @@ export function filletPolygon(points, r, { segs = 8 } = {}) {
|
|
|
156
156
|
return out;
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
// Fluent builder for a curve-native path contour { start, segments }. Segment kinds:
|
|
160
|
+
// lineTo → {to}, arcTo → {to,via} (three-point arc), cubicTo → {to,c1,c2} (cubic Bézier).
|
|
161
|
+
// close() returns the plain contour object (feeds extrude/revolve/prism), not a Solid.
|
|
162
|
+
export function pathProfile(start) {
|
|
163
|
+
const fin2 = (p, what) => {
|
|
164
|
+
if (!Array.isArray(p) || p.length < 2 || !Number.isFinite(p[0]) || !Number.isFinite(p[1]))
|
|
165
|
+
throw new Error(`pathProfile: ${what} must be a finite [x,y]`);
|
|
166
|
+
return [p[0], p[1]];
|
|
167
|
+
};
|
|
168
|
+
const s = fin2(start, "start");
|
|
169
|
+
const segments = [];
|
|
170
|
+
const api = {
|
|
171
|
+
lineTo(to) { segments.push({ to: fin2(to, "lineTo point") }); return api; },
|
|
172
|
+
arcTo(to, via) { segments.push({ to: fin2(to, "arcTo point"), via: fin2(via, "arcTo via") }); return api; },
|
|
173
|
+
cubicTo(to, c1, c2) {
|
|
174
|
+
segments.push({ to: fin2(to, "cubicTo point"), c1: fin2(c1, "cubicTo c1"), c2: fin2(c2, "cubicTo c2") });
|
|
175
|
+
return api;
|
|
176
|
+
},
|
|
177
|
+
close() {
|
|
178
|
+
if (segments.length < 1) throw new Error("pathProfile: need ≥1 segment before close()");
|
|
179
|
+
return { start: [s[0], s[1]], segments: segments.slice() }; // snapshot — chaining after close() must not mutate the returned contour
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
return api;
|
|
183
|
+
}
|
|
184
|
+
|
|
159
185
|
// Arc-aware sibling of filletPolygon: rounds the corners of a CCW polygon with the SAME
|
|
160
186
|
// tangent/centre/sweep math (via cornerArc), but instead of tessellating each arc into
|
|
161
187
|
// line facets it emits a canonical ArcContour { start, segments:[{to}|{to,via}], arc:true }
|
|
@@ -1,23 +1,37 @@
|
|
|
1
1
|
// Backend-shared 2-D region normalization + tessellation for extrude()/prism(). A contour
|
|
2
|
-
// is EITHER a bare points array (legacy, all straight edges) OR a canonical
|
|
3
|
-
// { start:[x,y], segments:[{to}|{to,via}]
|
|
4
|
-
// roundedProfile)
|
|
5
|
-
// (bare array = outer only), preserving
|
|
6
|
-
// arcs into point rings for the Manifold
|
|
7
|
-
//
|
|
8
|
-
//
|
|
2
|
+
// is EITHER a bare points array (legacy, all straight edges) OR a canonical path contour
|
|
3
|
+
// { start:[x,y], segments:[{to}|{to,via}|{to,c1,c2}] } carrying true circular arcs ({to,via},
|
|
4
|
+
// from roundedProfile) and/or cubic Béziers ({to,c1,c2}, from pathProfile). normalizeProfile
|
|
5
|
+
// validates the polymorphic { outer, holes } envelope (bare array = outer only), preserving
|
|
6
|
+
// each contour's shape; tessellateProfile turns arcs/cubics into point rings for the Manifold
|
|
7
|
+
// (mesh) path at the mesh LOD. The OCCT path consumes the same contour directly (contourDrawing
|
|
8
|
+
// → threePointsArcTo / cubicBezierCurveTo) for true CIRCLE / B-spline B-rep edges. Legacy
|
|
9
|
+
// point-array contours take the exact former path byte-for-byte — no cache-busting.
|
|
9
10
|
|
|
10
11
|
// An ArcContour is a non-array object carrying arcs symbolically.
|
|
11
12
|
export function isArcContour(c) {
|
|
12
13
|
return !!c && typeof c === "object" && !Array.isArray(c) && (c.arc === true || Array.isArray(c.segments));
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
// Curves generalize arcs; the symbolic-form predicate is the same. Prefer this name.
|
|
17
|
+
export const isPathContour = isArcContour;
|
|
18
|
+
|
|
15
19
|
function validateContour(c, role) {
|
|
16
20
|
if (isArcContour(c)) {
|
|
17
21
|
if (!Array.isArray(c.start) || c.start.length < 2)
|
|
18
22
|
throw new Error(`extrude: ${role} arc contour needs a start [x,y]`);
|
|
19
23
|
if (!Array.isArray(c.segments) || c.segments.length < 1)
|
|
20
24
|
throw new Error(`extrude: ${role} arc contour needs ≥1 segment`);
|
|
25
|
+
for (const s of c.segments) {
|
|
26
|
+
const hasCubic = s.c1 != null || s.c2 != null;
|
|
27
|
+
if (hasCubic) {
|
|
28
|
+
if (s.via != null)
|
|
29
|
+
throw new Error(`extrude: ${role} segment cannot mix arc (via) and cubic (c1/c2)`);
|
|
30
|
+
const ok = (p) => Array.isArray(p) && p.length >= 2 && Number.isFinite(p[0]) && Number.isFinite(p[1]);
|
|
31
|
+
if (!ok(s.c1) || !ok(s.c2))
|
|
32
|
+
throw new Error(`extrude: ${role} cubic segment needs c1 and c2 as finite [x,y]`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
21
35
|
return;
|
|
22
36
|
}
|
|
23
37
|
if (!Array.isArray(c) || c.length < 3) throw new Error(`extrude: ${role} needs ≥3 points`);
|
|
@@ -73,15 +87,50 @@ export function sampleArc(p0, via, p1, segs) {
|
|
|
73
87
|
return out;
|
|
74
88
|
}
|
|
75
89
|
|
|
90
|
+
// Flatten the cubic Bézier (p0,c1,c2,p1) into points p1…pN — EXCLUDING the start
|
|
91
|
+
// p0 (the ring already holds it), last point pinned exactly to p1. Adaptive: split
|
|
92
|
+
// at t=½ (de Casteljau) until the control polygon's total unsigned turn is ≤ 2π/segs
|
|
93
|
+
// — the exact generalization of sampleArc's "a point every 2π/segs of sweep", so a
|
|
94
|
+
// cubic tracing a circular arc facets like the arc primitive at the same segs. Summing
|
|
95
|
+
// |turn| at BOTH interior control points also catches S-curves a pure endpoint-tangent
|
|
96
|
+
// test would miss. Depth cap guarantees termination. Pure in (args, segs).
|
|
97
|
+
export function sampleBezier(p0, c1, c2, p1, segs) {
|
|
98
|
+
const maxTurn = (2 * Math.PI) / Math.max(3, segs);
|
|
99
|
+
const out = [];
|
|
100
|
+
const mid = (a, b) => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
|
101
|
+
const turn = (u, v) => {
|
|
102
|
+
const du = Math.hypot(u[0], u[1]), dv = Math.hypot(v[0], v[1]);
|
|
103
|
+
if (du < 1e-12 || dv < 1e-12) return 0;
|
|
104
|
+
let c = (u[0] * v[0] + u[1] * v[1]) / (du * dv);
|
|
105
|
+
if (c > 1) c = 1; else if (c < -1) c = -1;
|
|
106
|
+
return Math.acos(c);
|
|
107
|
+
};
|
|
108
|
+
const recurse = (a, b, c, d, depth) => {
|
|
109
|
+
const ab = [b[0] - a[0], b[1] - a[1]];
|
|
110
|
+
const bc = [c[0] - b[0], c[1] - b[1]];
|
|
111
|
+
const cd = [d[0] - c[0], d[1] - c[1]];
|
|
112
|
+
if (depth >= 12 || turn(ab, bc) + turn(bc, cd) <= maxTurn) { out.push([d[0], d[1]]); return; }
|
|
113
|
+
const p01 = mid(a, b), p12 = mid(b, c), p23 = mid(c, d);
|
|
114
|
+
const p012 = mid(p01, p12), p123 = mid(p12, p23), m = mid(p012, p123);
|
|
115
|
+
recurse(a, p01, p012, m, depth + 1);
|
|
116
|
+
recurse(m, p123, p23, d, depth + 1);
|
|
117
|
+
};
|
|
118
|
+
recurse(p0, c1, c2, p1, 0);
|
|
119
|
+
if (out.length === 0) out.push([p1[0], p1[1]]);
|
|
120
|
+
out[out.length - 1] = [p1[0], p1[1]]; // pin the exact endpoint
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
76
124
|
// Tessellate a single contour into a CCW point ring. A legacy array is returned unchanged
|
|
77
|
-
// (identical to the former path);
|
|
78
|
-
// pushing their `to
|
|
125
|
+
// (identical to the former path); a path contour is walked start→segment→segment, lines
|
|
126
|
+
// pushing their `to`, arcs and cubics pushing their sampled points (sampleArc/sampleBezier).
|
|
79
127
|
export function tessellateContour(contour, segs) {
|
|
80
128
|
if (Array.isArray(contour)) return contour;
|
|
81
129
|
const ring = [[contour.start[0], contour.start[1]]];
|
|
82
130
|
let prev = contour.start;
|
|
83
131
|
for (const seg of contour.segments) {
|
|
84
|
-
if (seg.
|
|
132
|
+
if (seg.c1) for (const p of sampleBezier(prev, seg.c1, seg.c2, seg.to, segs)) ring.push(p);
|
|
133
|
+
else if (seg.via) for (const p of sampleArc(prev, seg.via, seg.to, segs)) ring.push(p);
|
|
85
134
|
else ring.push([seg.to[0], seg.to[1]]);
|
|
86
135
|
prev = seg.to;
|
|
87
136
|
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Pure (WASM-free) helpers for materializing a 2-D boolean result into region
|
|
2
|
+
// arrays. assembleRegions groups a flat set of point-rings into {outer,holes}
|
|
3
|
+
// regions by winding + point-in-polygon nesting. svgPathToRings discretizes a
|
|
4
|
+
// replicad Drawing's SVG path (from toSVGPathD) into rings, reusing F1's
|
|
5
|
+
// sampleBezier / sampleArc so an OCCT-materialized curve facets like Manifold.
|
|
6
|
+
import { sampleBezier } from "./profile.js";
|
|
7
|
+
|
|
8
|
+
// Signed shoelace area of a ring (CCW positive). Exported (in addition to its use
|
|
9
|
+
// below) for the OCCT backend's absolute outer/hole orientation — see
|
|
10
|
+
// occt-backend.js's drawingRegionRings.
|
|
11
|
+
export function ringArea(p) {
|
|
12
|
+
let a = 0;
|
|
13
|
+
for (let i = 0; i < p.length; i++) { const [x1, y1] = p[i], [x2, y2] = p[(i + 1) % p.length]; a += x1 * y2 - x2 * y1; }
|
|
14
|
+
return a / 2;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Ray-cast point-in-polygon (even-odd). ring: [[x,y],…]. Exported (in addition to
|
|
18
|
+
// its use below) for the OCCT backend's containment-based outer/hole classification
|
|
19
|
+
// — see occt-backend.js's drawingRegionRings for why sign-based classification
|
|
20
|
+
// (this module's own convention, below) doesn't hold for replicad's SVG output.
|
|
21
|
+
export function pointInRing([px, py], ring) {
|
|
22
|
+
let inside = false;
|
|
23
|
+
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
|
24
|
+
const [xi, yi] = ring[i], [xj, yj] = ring[j];
|
|
25
|
+
if ((yi > py) !== (yj > py) && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi) inside = !inside;
|
|
26
|
+
}
|
|
27
|
+
return inside;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Group rings: positive-area rings are outers, negative-area are holes; nest each
|
|
31
|
+
// hole into the smallest-area outer that contains its first vertex.
|
|
32
|
+
export function assembleRegions(rings) {
|
|
33
|
+
const outers = [], holes = [];
|
|
34
|
+
for (const r of rings) {
|
|
35
|
+
if (r.length < 3) continue;
|
|
36
|
+
(ringArea(r) >= 0 ? outers : holes).push(r);
|
|
37
|
+
}
|
|
38
|
+
const regions = outers.map((outer) => ({ outer, holes: [] }));
|
|
39
|
+
regions.sort((a, b) => Math.abs(ringArea(a.outer)) - Math.abs(ringArea(b.outer)));
|
|
40
|
+
for (const hole of holes) {
|
|
41
|
+
const home = regions.find((rg) => pointInRing(hole[0], rg.outer));
|
|
42
|
+
if (home) home.holes.push(hole);
|
|
43
|
+
}
|
|
44
|
+
// largest-first for a stable, readable order
|
|
45
|
+
regions.sort((a, b) => Math.abs(ringArea(b.outer)) - Math.abs(ringArea(a.outer)));
|
|
46
|
+
return regions;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Net area of assembled regions: Σ|outer| − Σ|holes|.
|
|
50
|
+
export function regionsArea(regions) {
|
|
51
|
+
let a = 0;
|
|
52
|
+
for (const rg of regions) {
|
|
53
|
+
a += Math.abs(ringArea(rg.outer));
|
|
54
|
+
for (const hole of rg.holes) a -= Math.abs(ringArea(hole));
|
|
55
|
+
}
|
|
56
|
+
return a;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Sample an SVG elliptical-arc segment (endpoint parameterization → center form,
|
|
60
|
+
// W3C SVG 1.1 notes F.6) from `from` to `to` into points AFTER `from` (last pinned
|
|
61
|
+
// to `to`), honoring rx/ry/x-rotation and the large-arc/sweep flags. Exact for the
|
|
62
|
+
// semicircle case a three-point circle fit degenerates on.
|
|
63
|
+
function sampleSvgArc(from, rx, ry, rotDeg, largeArc, sweep, to, segs) {
|
|
64
|
+
const [x1, y1] = from, [x2, y2] = to;
|
|
65
|
+
if (rx === 0 || ry === 0) return [[x2, y2]];
|
|
66
|
+
const phi = (rotDeg * Math.PI) / 180, cosP = Math.cos(phi), sinP = Math.sin(phi);
|
|
67
|
+
const dx = (x1 - x2) / 2, dy = (y1 - y2) / 2;
|
|
68
|
+
const x1p = cosP * dx + sinP * dy, y1p = -sinP * dx + cosP * dy;
|
|
69
|
+
let RX = Math.abs(rx), RY = Math.abs(ry);
|
|
70
|
+
const lambda = (x1p * x1p) / (RX * RX) + (y1p * y1p) / (RY * RY);
|
|
71
|
+
if (lambda > 1) { const s = Math.sqrt(lambda); RX *= s; RY *= s; }
|
|
72
|
+
const numr = RX * RX * RY * RY - RX * RX * y1p * y1p - RY * RY * x1p * x1p;
|
|
73
|
+
const den = RX * RX * y1p * y1p + RY * RY * x1p * x1p;
|
|
74
|
+
let coef = Math.sqrt(Math.max(0, numr / den));
|
|
75
|
+
if (Boolean(largeArc) === Boolean(sweep)) coef = -coef;
|
|
76
|
+
const cxp = (coef * RX * y1p) / RY, cyp = (-coef * RY * x1p) / RX;
|
|
77
|
+
const cx = cosP * cxp - sinP * cyp + (x1 + x2) / 2;
|
|
78
|
+
const cy = sinP * cxp + cosP * cyp + (y1 + y2) / 2;
|
|
79
|
+
const angle = (ux, uy, vx, vy) => {
|
|
80
|
+
const dot = ux * vx + uy * vy, len = Math.hypot(ux, uy) * Math.hypot(vx, vy) || 1e-12;
|
|
81
|
+
let a = Math.acos(Math.min(1, Math.max(-1, dot / len)));
|
|
82
|
+
if (ux * vy - uy * vx < 0) a = -a;
|
|
83
|
+
return a;
|
|
84
|
+
};
|
|
85
|
+
const theta1 = angle(1, 0, (x1p - cxp) / RX, (y1p - cyp) / RY);
|
|
86
|
+
let dTheta = angle((x1p - cxp) / RX, (y1p - cyp) / RY, (-x1p - cxp) / RX, (-y1p - cyp) / RY);
|
|
87
|
+
if (!sweep && dTheta > 0) dTheta -= 2 * Math.PI;
|
|
88
|
+
if (sweep && dTheta < 0) dTheta += 2 * Math.PI;
|
|
89
|
+
const steps = Math.max(2, Math.ceil((segs * Math.abs(dTheta)) / (2 * Math.PI)));
|
|
90
|
+
const out = [];
|
|
91
|
+
for (let i = 1; i <= steps; i++) {
|
|
92
|
+
const t = theta1 + dTheta * (i / steps);
|
|
93
|
+
const ex = RX * Math.cos(t), ey = RY * Math.sin(t);
|
|
94
|
+
out.push([cx + cosP * ex - sinP * ey, cy + sinP * ex + cosP * ey]);
|
|
95
|
+
}
|
|
96
|
+
out[out.length - 1] = [x2, y2];
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Minimal SVG-path tokenizer for the absolute commands replicad emits: M, L, C,
|
|
101
|
+
// Q, A, Z. Coordinates are numbers separated by spaces or commas; a command may
|
|
102
|
+
// be followed by several coordinate sets (implicit repeat). One subpath (M…Z) →
|
|
103
|
+
// one ring; the start point is not duplicated. Throws on unsupported commands.
|
|
104
|
+
export function svgPathToRings(d, segs) {
|
|
105
|
+
const toks = d.match(/[a-zA-Z]|-?\d*\.?\d+(?:e[-+]?\d+)?/g) ?? [];
|
|
106
|
+
const rings = [];
|
|
107
|
+
let ring = null, cur = [0, 0], cmd = null, i = 0;
|
|
108
|
+
const num = () => Number(toks[i++]);
|
|
109
|
+
const pt = () => [num(), num()];
|
|
110
|
+
const pushRing = () => { if (ring && ring.length >= 3) rings.push(ring); ring = null; };
|
|
111
|
+
while (i < toks.length) {
|
|
112
|
+
if (/^[a-zA-Z]$/.test(toks[i])) {
|
|
113
|
+
cmd = toks[i++];
|
|
114
|
+
if (!"MLCQAZ".includes(cmd)) throw new Error(`svgPathToRings: unsupported SVG command "${cmd}"`);
|
|
115
|
+
}
|
|
116
|
+
if (cmd === "M") { pushRing(); cur = pt(); ring = [cur.slice()]; cmd = "L"; }
|
|
117
|
+
else if (cmd === "L") { cur = pt(); ring.push(cur.slice()); }
|
|
118
|
+
else if (cmd === "C") { const c1 = pt(), c2 = pt(), end = pt(); for (const p of sampleBezier(cur, c1, c2, end, segs)) ring.push(p); cur = end; }
|
|
119
|
+
else if (cmd === "Q") {
|
|
120
|
+
const q = pt(), end = pt();
|
|
121
|
+
const c1 = [cur[0] + (2 / 3) * (q[0] - cur[0]), cur[1] + (2 / 3) * (q[1] - cur[1])];
|
|
122
|
+
const c2 = [end[0] + (2 / 3) * (q[0] - end[0]), end[1] + (2 / 3) * (q[1] - end[1])];
|
|
123
|
+
for (const p of sampleBezier(cur, c1, c2, end, segs)) ring.push(p); cur = end;
|
|
124
|
+
}
|
|
125
|
+
else if (cmd === "A") {
|
|
126
|
+
const rx = num(), ry = num(), rot = num(), large = num(), sweep = num(), end = pt();
|
|
127
|
+
for (const p of sampleSvgArc(cur, rx, ry, rot, large, sweep, end, segs)) ring.push(p); cur = end;
|
|
128
|
+
}
|
|
129
|
+
else if (cmd === "Z") { pushRing(); cmd = null; }
|
|
130
|
+
else throw new Error("svgPathToRings: coordinate before or after a command");
|
|
131
|
+
}
|
|
132
|
+
pushRing();
|
|
133
|
+
return rings;
|
|
134
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
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) {
|
|
5
|
+
s.simple = () => {
|
|
6
|
+
const regions = s.toRegions();
|
|
7
|
+
if (regions.length !== 1) throw new Error(`Shape2D.simple: result has ${regions.length} regions, not 1 (use toRegions())`);
|
|
8
|
+
return regions[0];
|
|
9
|
+
};
|
|
10
|
+
return s;
|
|
11
|
+
}
|
package/src/parts/demo.js
CHANGED
|
@@ -49,7 +49,7 @@ export default {
|
|
|
49
49
|
export: { name: "spacer" },
|
|
50
50
|
build: (k, p, d) => {
|
|
51
51
|
let s = k.cylinder({ d: p.od, h: p.h });
|
|
52
|
-
if (p.flange_d > 0) s =
|
|
52
|
+
if (p.flange_d > 0) s = s.union(k.cylinder({ d: p.flange_d, h: p.flange_h }));
|
|
53
53
|
return s.cut(k.cylinder({ r: d.boreR, h: d.cutH }).at([0, 0, -2]));
|
|
54
54
|
},
|
|
55
55
|
},
|