partforge 0.17.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 +24 -0
- package/docs/ERROR-PATTERNS.md +15 -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 +95 -5
- package/src/framework/geometry/op-options.js +4 -0
- 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
|
@@ -474,6 +474,30 @@ const hole = k.cylinder({ r: 2, h: 20 }).translate([20, 0, 0]);
|
|
|
474
474
|
body = body.cutAll(circularPattern(hole, 8, { axis: "Z" })); // 8 bolt holes on a 40mm circle
|
|
475
475
|
```
|
|
476
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
|
+
|
|
477
501
|
---
|
|
478
502
|
|
|
479
503
|
## Wiring a part into a runnable app
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -233,6 +233,21 @@ Variant literals under this entry: `offsetPolygon: delta must be a finite number
|
|
|
233
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
234
|
- **Fix:** Provide both control points as finite `[x,y]`. A cubic Bézier needs two controls between the previous point and `to`.
|
|
235
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
|
+
|
|
236
251
|
# Hardware library
|
|
237
252
|
|
|
238
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
|
|
@@ -106,6 +109,90 @@ export function createOcctKernel(replicad) {
|
|
|
106
109
|
return pen.close();
|
|
107
110
|
};
|
|
108
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
|
+
|
|
109
196
|
// extrude a 2-D polygon from z=0 (arguments validated by the kernel front)
|
|
110
197
|
const prism = (pts, h, { twist = 0, scaleTop = 1 } = {}) => {
|
|
111
198
|
const sketch = contourDrawing(pts).sketchOnPlane("XY");
|
|
@@ -117,15 +204,17 @@ export function createOcctKernel(replicad) {
|
|
|
117
204
|
};
|
|
118
205
|
|
|
119
206
|
// revolve a lathe profile [[r,z],…] around the Z axis (degrees defaults to 360)
|
|
120
|
-
const revolve = (pts, { degrees = 360 } = {}) =>
|
|
121
|
-
|
|
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
|
+
};
|
|
122
211
|
|
|
123
212
|
// extrude a polygon-with-holes region from z=0: cut each hole Drawing out of the outer
|
|
124
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).
|
|
125
216
|
const extrude = (profile, h, { twist = 0, scaleTop = 1 } = {}) => {
|
|
126
|
-
const
|
|
127
|
-
let region = contourDrawing(outer);
|
|
128
|
-
for (const hole of holes) region = region.cut(contourDrawing(hole));
|
|
217
|
+
const region = profile && profile._shape2d ? profile._drawing.clone() : drawingFromProfile(profile);
|
|
129
218
|
const sketch = region.sketchOnPlane("XY");
|
|
130
219
|
if (twist === 0 && scaleTop === 1) return wrap(sketch.extrude(h));
|
|
131
220
|
const cfg = {};
|
|
@@ -184,6 +273,7 @@ export function createOcctKernel(replicad) {
|
|
|
184
273
|
solids.map((s) => s._s).reduce((a, b) => a.fuse(b)),
|
|
185
274
|
solids.flatMap((s) => cloneLabels(s._labels ?? []))
|
|
186
275
|
),
|
|
276
|
+
shape2d,
|
|
187
277
|
toSTEP: (named) => exportSTEP(named.map(({ name, solid }) => ({ name, shape: solid._s }))).arrayBuffer(),
|
|
188
278
|
});
|
|
189
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 },
|
|
@@ -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
|
},
|