partforge 0.55.0 → 0.56.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 +110 -2
- package/docs/ERROR-PATTERNS.md +28 -2
- package/docs/KERNEL-CONTRACT.md +107 -30
- package/package.json +1 -1
- package/src/app-gasket.js +14 -0
- package/src/framework/backend-select.js +5 -2
- package/src/framework/geometry/contour-ops.js +1090 -0
- package/src/framework/geometry/curve-fill.js +1 -59
- package/src/framework/geometry/kernel.js +24 -6
- package/src/framework/geometry/manifold-backend.js +72 -62
- package/src/framework/geometry/occt-backend.js +121 -84
- package/src/framework/geometry/paper-bridge.js +224 -0
- package/src/framework/geometry/polygon.js +8 -0
- package/src/framework/geometry/probe.js +41 -16
- package/src/framework/geometry/profile.js +51 -0
- package/src/framework/geometry/shape2d-regions.js +100 -0
- package/src/framework/geometry/shape2d.js +91 -0
- package/src/framework/jobs.js +16 -3
- package/src/framework/lint/rules-build.js +5 -2
- package/src/gasket-worker.js +3 -0
- package/src/parts/gasket.js +111 -0
- package/types/geometry.d.ts +121 -2
- package/types/kernel.d.ts +66 -6
|
@@ -7,65 +7,7 @@
|
|
|
7
7
|
// 2. CompoundPath of all the simple sub-paths;
|
|
8
8
|
// 3. set the font's nonzero/evenodd rule;
|
|
9
9
|
// 4. unite(self) to normalize overlaps and crossings into simple paths.
|
|
10
|
-
import
|
|
11
|
-
|
|
12
|
-
// Lazy, private PaperScope: built on first use (not at module load), so parts that never
|
|
13
|
-
// call k.text2d don't pull paper-core's setup onto the geometry worker. Never paper's
|
|
14
|
-
// package-global project — another consumer in the same worker may import paper too.
|
|
15
|
-
let _scope = null;
|
|
16
|
-
function paperScope() {
|
|
17
|
-
if (!_scope) { _scope = new paper.PaperScope(); _scope.setup(new _scope.Size(1, 1)); }
|
|
18
|
-
return _scope;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function toPaperPath(scope, contour) {
|
|
22
|
-
const path = new scope.Path({ insert: false });
|
|
23
|
-
path.moveTo(new scope.Point(contour.start[0], contour.start[1]));
|
|
24
|
-
for (const s of contour.segments) {
|
|
25
|
-
if (s.c1) path.cubicCurveTo(
|
|
26
|
-
new scope.Point(s.c1[0], s.c1[1]),
|
|
27
|
-
new scope.Point(s.c2[0], s.c2[1]),
|
|
28
|
-
new scope.Point(s.to[0], s.to[1]));
|
|
29
|
-
else path.lineTo(new scope.Point(s.to[0], s.to[1]));
|
|
30
|
-
}
|
|
31
|
-
path.closePath();
|
|
32
|
-
return path;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function toContour(path) {
|
|
36
|
-
const segs = path.segments;
|
|
37
|
-
const start = [segs[0].point.x, segs[0].point.y];
|
|
38
|
-
const out = { start, segments: [] };
|
|
39
|
-
for (let i = 0; i < segs.length; i++) {
|
|
40
|
-
const a = segs[i], b = segs[(i + 1) % segs.length];
|
|
41
|
-
const straight = a.handleOut.isZero() && b.handleIn.isZero();
|
|
42
|
-
const closing = i === segs.length - 1;
|
|
43
|
-
if (closing && straight) continue; // implicit straight close
|
|
44
|
-
const to = [b.point.x, b.point.y];
|
|
45
|
-
if (straight) out.segments.push({ to });
|
|
46
|
-
else out.segments.push({ to, c1: [a.point.x + a.handleOut.x, a.point.y + a.handleOut.y], c2: [b.point.x + b.handleIn.x, b.point.y + b.handleIn.y] });
|
|
47
|
-
}
|
|
48
|
-
return out;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// Group while paths are still Paper geometry. Path.area includes cubic handles and
|
|
52
|
-
// interiorPoint is guaranteed to lie inside the curve; never reduce curves to endpoint rings.
|
|
53
|
-
function groupPaperPaths(paths) {
|
|
54
|
-
const largest = paths.reduce((a, b) => Math.abs(b.area) > Math.abs(a.area) ? b : a);
|
|
55
|
-
const outerClockwise = largest.clockwise;
|
|
56
|
-
const outers = paths.filter((p) => p.clockwise === outerClockwise)
|
|
57
|
-
.map((path) => ({ path, holes: [] }));
|
|
58
|
-
for (const hole of paths.filter((p) => p.clockwise !== outerClockwise)) {
|
|
59
|
-
const home = outers.filter((o) => o.path.contains(hole.interiorPoint))
|
|
60
|
-
.sort((a, b) => Math.abs(a.path.area) - Math.abs(b.path.area))[0];
|
|
61
|
-
if (!home) throw new Error("curve-fill: resolved hole has no containing outer");
|
|
62
|
-
home.holes.push(hole);
|
|
63
|
-
}
|
|
64
|
-
return outers.map(({ path, holes }) => ({
|
|
65
|
-
outer: toContour(path),
|
|
66
|
-
holes: holes.map(toContour),
|
|
67
|
-
}));
|
|
68
|
-
}
|
|
10
|
+
import { paperScope, toPaperPath, groupPaperPaths } from "./paper-bridge.js";
|
|
69
11
|
|
|
70
12
|
export function resolveCurveFill(contours, { fillRule = "nonzero" } = {}) {
|
|
71
13
|
if (fillRule !== "nonzero" && fillRule !== "evenodd")
|
|
@@ -44,9 +44,13 @@ export const SOLID_OPS = [
|
|
|
44
44
|
export const SOLID_OPTIONAL_OPS = ["genus", "isEmpty"];
|
|
45
45
|
|
|
46
46
|
// Public methods every Shape2D exposes (2-D boolean value; contract-linted).
|
|
47
|
+
// 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.
|
|
47
50
|
export const SHAPE2D_OPS = [
|
|
48
51
|
"union", "cut", "cutAll", "intersect", "offset", "area", "boundingBox", "toRegions", "simple", "regions", "clone",
|
|
49
52
|
"extrude", "revolve",
|
|
53
|
+
"translate", "rotate", "scale", "mirror", "toContours", "fillet", "chamfer", "simplify", "corners", "contains",
|
|
50
54
|
];
|
|
51
55
|
|
|
52
56
|
// Solid ops only OCCT implements natively. Single source of truth: probe.js routes
|
|
@@ -85,16 +89,30 @@ export const OCCT_ONLY_OPS = ["fillet", "chamfer", "shell"];
|
|
|
85
89
|
* @property {() => number} [genus] through-hole count (Manifold only)
|
|
86
90
|
* @property {() => boolean} [isEmpty] no geometry at all (Manifold only)
|
|
87
91
|
*
|
|
88
|
-
* @typedef {Object} Shape2D
|
|
92
|
+
* @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.
|
|
89
93
|
* @property {(other: Shape2D|number[][]) => Shape2D} union
|
|
90
94
|
* @property {(other: Shape2D|number[][]) => Shape2D} cut
|
|
91
95
|
* @property {(others: (Shape2D|number[][])[]) => Shape2D} cutAll batch subtract
|
|
92
96
|
* @property {(other: Shape2D|number[][]) => Shape2D} intersect
|
|
93
|
-
* @property {() =>
|
|
94
|
-
* @property {() =>
|
|
95
|
-
* @property {() => {
|
|
97
|
+
* @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
|
|
98
|
+
* @property {() => number} area net area (outers minus holes), mm² — curve-exact, not tessellated
|
|
99
|
+
* @property {() => {min:number[],max:number[]}} boundingBox axis-aligned 2-D bounds (curve-exact)
|
|
100
|
+
* @property {() => {outer:number[][],holes:number[][][]}[]} toRegions materialize into point-ring region arrays (tessellated at the backend's LOD)
|
|
96
101
|
* @property {() => {outer:number[][],holes:number[][][]}} simple toRegions(), unwrapped — throws unless exactly 1 region
|
|
97
|
-
* @property {() => Shape2D}
|
|
102
|
+
* @property {() => Shape2D[]} regions scission: each disjoint region as its own Shape2D
|
|
103
|
+
* @property {() => {outer:object,holes:object[]}[]} toContours the stored contour IR (curve-native, lossless) — a deep copy, safe to mutate
|
|
104
|
+
* @property {() => Shape2D} clone independent copy
|
|
105
|
+
* @property {(v:number[]) => Shape2D} translate translate by [dx,dy]
|
|
106
|
+
* @property {(deg:number, center?:number[]) => Shape2D} rotate rotate about center (default origin)
|
|
107
|
+
* @property {(f:number|number[], center?:number[]) => Shape2D} scale scale about center (default origin); a bare number scales uniformly, [sx,sy] scales each axis independently
|
|
108
|
+
* @property {(axis:"x"|"y"|{point:number[],dir:number[]}) => Shape2D} mirror reflect across an axis line
|
|
109
|
+
* @property {(r:number|number[], opts?:{corners?:"all"|"convex"|"concave"|{indices:number[]}|{near:number[],count?:number}}) => Shape2D} fillet round selected corners with true arcs
|
|
110
|
+
* @property {(d:number|number[], opts?:{corners?:"all"|"convex"|"concave"|{indices:number[]}|{near:number[],count?:number}}) => Shape2D} chamfer bevel selected corners with straight chords
|
|
111
|
+
* @property {(tolerance:number) => Shape2D} simplify corner-preserving decimation/refit within tolerance
|
|
112
|
+
* @property {() => {index:number,point:number[],interiorAngleDeg:number,convex:boolean,segTypes:string[]}[]} corners corner list (the positional order fillet/chamfer `{indices}` index into)
|
|
113
|
+
* @property {(p:number[]) => boolean} contains is point [x,y] inside the shape (holes excluded)
|
|
114
|
+
* @property {(o?:{h:number,twist?:number,scaleTop?:number}) => Solid} extrude sugar for k.extrude({profile:this,…})
|
|
115
|
+
* @property {(o?:{degrees?:number}) => Solid} revolve sugar for k.revolve({profile:this,…})
|
|
98
116
|
*
|
|
99
117
|
* @typedef {Object} GeometryKernel
|
|
100
118
|
* @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
|
|
@@ -112,7 +130,7 @@ export const OCCT_ONLY_OPS = ["fillet", "chamfer", "shell"];
|
|
|
112
130
|
* @property {(o:{pathR:number,profileR:number,pitch:number,turns:number,z0:number,lefthand:boolean}) => Solid} helixSweptTube
|
|
113
131
|
* @property {(o:{profile:number[][],pitch:number,turns:number,lefthand?:boolean}) => Solid} screwSweep screw-motion sweep of an axial [[r,z]] profile — threads; options-only
|
|
114
132
|
* @property {(solids:Solid[]) => Solid} union
|
|
115
|
-
* @property {(profile: number[][]|{outer:number[][],holes?:number[][][]}|Shape2D) => Shape2D} shape2d 2-D boolean value
|
|
133
|
+
* @property {(profile: number[][]|{outer:number[][],holes?:number[][][]}|{start:number[],segments:object[]}|Shape2D) => Shape2D} shape2d 2-D boolean value; one shared contour-storage implementation on both backends
|
|
116
134
|
* @property {(inputs: (Shape2D|number[][]|{start:number[],segments:object[]})[]) => Shape2D} hull convex hull of all inputs → a convex Shape2D (faceted; pure-JS monotone chain)
|
|
117
135
|
* @property {(inputs: (Shape2D|number[][]|{start:number[],segments:object[]})[]) => Shape2D} hullChain swept hull over an ordered sequence (≥2): union of hull([inᵢ,inᵢ₊₁])
|
|
118
136
|
* @property {(named:{name:string,solid:Solid}[]) => Promise<ArrayBuffer>} toSTEP OCCT only (Manifold throws KernelCapabilityError)
|
|
@@ -2,11 +2,11 @@ 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 } from "./profile.js";
|
|
5
|
+
import { tessellateContour, tessellateProfile, pointsToContour } 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
|
-
import {
|
|
9
|
+
import { makeShape2dFactory } from "./shape2d.js";
|
|
10
10
|
import { assembleRegions } from "./shape2d-regions.js";
|
|
11
11
|
import { finishKernel } from "./kernel-front.js";
|
|
12
12
|
import { meshToStl } from "./mesh-stl.js";
|
|
@@ -53,63 +53,70 @@ 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
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if (!["round", "chamfer", "sharp"].includes(corners))
|
|
81
|
-
throw new Error('Shape2D.offset: corners must be "round" | "chamfer" | "sharp"');
|
|
82
|
-
if (!Number.isFinite(delta)) throw new Error("Shape2D.offset: delta must be a finite number");
|
|
83
|
-
// chamfer is a true 45° bevel — Clipper2 has no bevel join, but a Round join
|
|
84
|
-
// forced to a single chord per corner (circularSegments=4 → 1 segment per corner
|
|
85
|
-
// whose turn ≤ 90°, i.e. interior angle ≥ 90°) IS the bevel: round's tangent points
|
|
86
|
-
// are exactly the bevel's endpoints. Matches OCCT's `bevel` to float precision for
|
|
87
|
-
// interior angle ≥ 90° (square 142.0000, pentagon 298.920). At acute (<90°) convex
|
|
88
|
-
// corners Clipper2 emits 2 chords (ceil(turn/90°)), so Manifold bulges ~0.4% beyond
|
|
89
|
-
// OCCT's single-chord bevel there. round = arc at mesh LOD; sharp = miter.
|
|
90
|
-
const [joinType, cseg] = corners === "sharp" ? ["Miter", nSeg]
|
|
91
|
-
: corners === "chamfer" ? ["Round", 4]
|
|
92
|
-
: ["Round", nSeg];
|
|
93
|
-
return cachedCS(h("offset2d", hash, delta, corners, cseg), () => {
|
|
94
|
-
const out = T(cs.offset(delta, joinType, 2, cseg)); // miterLimit 2 (Clipper2 default)
|
|
95
|
-
if (out.numContour() === 0) throw new Error("Shape2D.offset: offset collapses the shape (reduce |delta|)");
|
|
96
|
-
return out;
|
|
97
|
-
});
|
|
98
|
-
},
|
|
99
|
-
area: () => cs.area(),
|
|
100
|
-
boundingBox: () => { const r = cs.bounds(); return { min: [r.min[0], r.min[1]], max: [r.max[0], r.max[1]] }; },
|
|
101
|
-
toRegions: () => assembleRegions(cs.toPolygons()),
|
|
102
|
-
clone: () => wrapShape2d(cs, hash),
|
|
103
|
-
}, { shape2d, extrude: kernel.extrude, revolve: kernel.revolve });
|
|
104
|
-
const shape2d = (profile) => {
|
|
105
|
-
if (profile && profile._shape2d) return profile; // idempotent
|
|
106
|
-
const hash = h("shape2d", profile, segs);
|
|
107
|
-
return cachedCS(hash, () => {
|
|
108
|
-
const { outer, holes } = tessellateProfile(profile, segs);
|
|
109
|
-
return T(CrossSection.ofPolygons([outer, ...holes], "EvenOdd"));
|
|
110
|
-
});
|
|
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;
|
|
111
80
|
};
|
|
112
81
|
|
|
82
|
+
// Contour-IR region list -> flat point rings at `nSeg` (outer + holes, even/odd
|
|
83
|
+
// fill sorts them out). The one place the IR meets CrossSection.ofPolygons.
|
|
84
|
+
const regionPolys = (regions, nSeg) => regions.flatMap((rg) =>
|
|
85
|
+
[tessellateContour(rg.outer, nSeg), ...rg.holes.map((hl) => tessellateContour(hl, nSeg))]);
|
|
86
|
+
|
|
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
|
+
// 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.
|
|
105
|
+
const shape2d = makeShape2dFactory({
|
|
106
|
+
segs,
|
|
107
|
+
offsetRegions,
|
|
108
|
+
extrude: (o) => kernel.extrude(o),
|
|
109
|
+
revolve: (o) => kernel.revolve(o),
|
|
110
|
+
});
|
|
111
|
+
// Lazy CrossSection materialization, memoized through the solid cache by content
|
|
112
|
+
// hash + LOD: the same shape extruded twice (or extruded and revolved) tessellates
|
|
113
|
+
// once, and the cache's pin/dispose keeps the WASM object alive exactly as long as
|
|
114
|
+
// the entry (cleanup() skips pinned objects).
|
|
115
|
+
const csFor = (shape) => cache.lookup(h("cs2d", shape._hash, segs), () => {
|
|
116
|
+
const cs = T(CrossSection.ofPolygons(regionPolys(shape._regions, segs), "EvenOdd"));
|
|
117
|
+
return { value: cs, pin: cs, dispose: () => cs.delete?.() };
|
|
118
|
+
});
|
|
119
|
+
|
|
113
120
|
// Copy the mesh out into JS-owned arrays (so it survives cleanup) and free the
|
|
114
121
|
// transient mesh handle.
|
|
115
122
|
function meshOut(m, asStl) {
|
|
@@ -270,13 +277,13 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
270
277
|
}),
|
|
271
278
|
// Polygon-with-holes extrude in one op: even/odd fill turns the extra contours into
|
|
272
279
|
// holes regardless of their winding (outer + holes, no per-hole boolean cut).
|
|
273
|
-
// A Shape2D `profile` (
|
|
274
|
-
//
|
|
275
|
-
//
|
|
280
|
+
// A Shape2D `profile` (curve-native, possibly multi-region) materializes through
|
|
281
|
+
// csFor — one memoized tessellation per shape+LOD — and folds into the cache key
|
|
282
|
+
// by `_hash` like any other solid operand.
|
|
276
283
|
extrude: (profile, height, { twist = 0, scaleTop = 1 } = {}) => {
|
|
277
284
|
const shape = profile && profile._shape2d ? profile : null;
|
|
278
285
|
return cached(h("extrude", shape ? shape._hash : profile, height, twist, scaleTop, segs), () => {
|
|
279
|
-
const cs = shape ? shape
|
|
286
|
+
const cs = shape ? csFor(shape) : (() => {
|
|
280
287
|
const { outer, holes } = tessellateProfile(profile, segs);
|
|
281
288
|
return T(CrossSection.ofPolygons([outer, ...holes], "EvenOdd"));
|
|
282
289
|
})();
|
|
@@ -309,7 +316,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
309
316
|
helixSweptTube: (o) => cached(h("helixSweptTube", o, tube), () => T(helixTube(wasm, { ...o, ...tube }))),
|
|
310
317
|
revolve: (pts, { degrees = 360 } = {}) => {
|
|
311
318
|
if (pts && pts._shape2d)
|
|
312
|
-
return cached(h("revolve", pts._hash, degrees, segs), () => T(pts.
|
|
319
|
+
return cached(h("revolve", pts._hash, degrees, segs), () => T(csFor(pts).revolve(segs, degrees)));
|
|
313
320
|
return cached(h("revolve", pts, degrees, segs), () => T(Manifold.revolve([pts], segs, degrees)));
|
|
314
321
|
},
|
|
315
322
|
// A one-solid union is an identity — no new WASM / cache entry (avoids double-free):
|
|
@@ -319,6 +326,9 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
319
326
|
? solids[0]
|
|
320
327
|
: cached(h("union", solids.map((s) => s._hash)), () => unionRaw(solids.map((s) => s._m))),
|
|
321
328
|
shape2d,
|
|
329
|
+
// Backend-internal region adapter: the same function Shape2D.offset runs on
|
|
330
|
+
// (defined above). `_`-prefixed — not part of the public kernel surface.
|
|
331
|
+
_offsetRegions: offsetRegions,
|
|
322
332
|
beginSubPart: (name) => cache.begin(name),
|
|
323
333
|
endSubPart: () => cache.end(),
|
|
324
334
|
sweepCache: () => cache.sweep(),
|
|
@@ -18,14 +18,14 @@
|
|
|
18
18
|
import { toEdgeFinder } from "./edge-selector.js";
|
|
19
19
|
import { toFaceFinder } from "./face-selector.js";
|
|
20
20
|
import { addSugar } from "./solid-sugar.js";
|
|
21
|
-
import {
|
|
22
|
-
import { assembleRegions,
|
|
21
|
+
import { makeShape2dFactory } from "./shape2d.js";
|
|
22
|
+
import { assembleRegions, svgPathToContours, pointInRing, ringArea } from "./shape2d-regions.js";
|
|
23
23
|
import { finishKernel } from "./kernel-front.js";
|
|
24
24
|
import { createOcctRepair } from "./occt-repair.js";
|
|
25
25
|
import { classifyFaceGroups } from "./feature-attribution.js";
|
|
26
26
|
import { resolveRings } from "./loft.js";
|
|
27
27
|
import { resolveSweepStations } from "./sweep.js";
|
|
28
|
-
import { normalizeProfile } from "./profile.js";
|
|
28
|
+
import { normalizeProfile, tessellateContour, reverseContour } from "./profile.js";
|
|
29
29
|
import { roundedRectContour } from "./rounded-solids.js";
|
|
30
30
|
import { h } from "./solid-hash.js";
|
|
31
31
|
import { createSolidCache } from "./solid-cache.js";
|
|
@@ -318,96 +318,129 @@ export function createOcctKernel(replicad) {
|
|
|
318
318
|
return pen.close();
|
|
319
319
|
};
|
|
320
320
|
|
|
321
|
+
// Shape2D's tessellation LOD: the segment count toRegions()/simple() discretize
|
|
322
|
+
// curve contours at. 64 preserves the LOD the Drawing-backed Shape2D materialized
|
|
323
|
+
// at, so toRegions() output keeps the resolution parts already depend on.
|
|
324
|
+
const SHAPE2D_SEGS = 64;
|
|
321
325
|
// Region (outer + holes) -> Drawing, exactly like extrude's former inline region
|
|
322
326
|
// path: draw the outer contour, then .cut() each hole Drawing out of it.
|
|
323
|
-
const SHAPE2D_SEGS = 64; // materialization LOD for toRegions() discretization
|
|
324
327
|
const drawingFromProfile = (profile) => {
|
|
325
328
|
const { outer, holes } = normalizeProfile(profile);
|
|
326
329
|
let region = contourDrawing(outer);
|
|
327
330
|
for (const hole of holes) region = region.cut(contourDrawing(hole));
|
|
328
331
|
return region;
|
|
329
332
|
};
|
|
330
|
-
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
//
|
|
333
|
+
// Region list -> fused Drawing: draw each region's outer contour, cut its holes
|
|
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.
|
|
338
|
+
const drawingFromRegions = (regions) => regions.reduce((acc, rg) => {
|
|
339
|
+
let region = contourDrawing(rg.outer);
|
|
340
|
+
for (const hole of rg.holes) region = region.cut(contourDrawing(hole));
|
|
341
|
+
return acc ? acc.fuse(region) : region;
|
|
342
|
+
}, null);
|
|
343
|
+
|
|
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.
|
|
335
358
|
//
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
// reversal would double-flip it back to a positive area — misbucketing the
|
|
357
|
-
// hole as a second outer (409/2-regions/0-holes instead of 391/1/1).
|
|
358
|
-
const drawingRegionRings = (drawing) => {
|
|
359
|
-
const rings = drawing.toSVGPaths().flat(Infinity)
|
|
360
|
-
.flatMap((d) => svgPathToRings(d, SHAPE2D_SEGS))
|
|
361
|
-
.map((ring) => ring.map(([x, y]) => [x, -y]));
|
|
362
|
-
const containedBy = rings.map((r, i) =>
|
|
363
|
-
rings.reduce((n, other, j) => (i !== j && pointInRing(r[0], other) ? n + 1 : n), 0));
|
|
364
|
-
return rings.map((r, i) => {
|
|
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) => {
|
|
365
379
|
const wantOuter = containedBy[i] % 2 === 0; // even depth = outer
|
|
366
|
-
|
|
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() };
|
|
367
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)) }));
|
|
368
388
|
};
|
|
369
|
-
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
const result = drawing.clone().offset(delta, { lineJoinType }); // clone — replicad consumes the operand
|
|
395
|
-
// Collapse doesn't throw and Drawing has no public `blueprints` array (that's on
|
|
396
|
-
// Blueprints/CompoundBlueprint, not Drawing) — replicad instead returns a Drawing
|
|
397
|
-
// whose private `innerShape` is null (confirmed by probe). That's the collapse signal.
|
|
398
|
-
// NB: `innerShape` is replicad-internal; the "collapse throws immediately (OCCT)" test
|
|
399
|
-
// guards this — a replicad upgrade that renames it must keep that test green.
|
|
400
|
-
if (!result || !result.innerShape)
|
|
401
|
-
throw new Error("Shape2D.offset: offset collapses the shape (reduce |delta|)");
|
|
402
|
-
return wrapShape2d(result, h("offset2d", hash, delta, corners));
|
|
403
|
-
},
|
|
404
|
-
area: () => regionsArea(toRegions()), // no native Drawing area → derive from materialized regions
|
|
405
|
-
boundingBox: () => { const b = drawing.boundingBox; return { min: [b.bounds[0][0], b.bounds[0][1]], max: [b.bounds[1][0], b.bounds[1][1]] }; },
|
|
406
|
-
toRegions,
|
|
407
|
-
clone: () => wrapShape2d(drawing.clone(), hash),
|
|
408
|
-
}, { shape2d, extrude: kernel.extrude, revolve: kernel.revolve });
|
|
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;
|
|
409
414
|
};
|
|
410
|
-
|
|
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
|
+
// 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.
|
|
431
|
+
const shape2d = makeShape2dFactory({
|
|
432
|
+
segs: SHAPE2D_SEGS,
|
|
433
|
+
offsetRegions,
|
|
434
|
+
extrude: (o) => kernel.extrude(o),
|
|
435
|
+
revolve: (o) => kernel.revolve(o),
|
|
436
|
+
});
|
|
437
|
+
// Lazy Drawing materialization for the kernel ops that need one. drawingFromRegions
|
|
438
|
+
// draws a FRESH Drawing on every call, so callers never need to .clone() the result
|
|
439
|
+
// before handing it to a consuming replicad op.
|
|
440
|
+
// An empty shape (e.g. the intersection of two disjoint shapes) has no regions to
|
|
441
|
+
// draw; say so rather than letting the null reach replicad as a TypeError.
|
|
442
|
+
const drawingFor = (shape) => drawingFromRegions(shape._regions)
|
|
443
|
+
?? (() => { throw new Error("Shape2D: the shape is empty — nothing to build"); })();
|
|
411
444
|
|
|
412
445
|
// extrude a 2-D polygon from z=0 (arguments validated by the kernel front)
|
|
413
446
|
const prism = (pts, hgt, { twist = 0, scaleTop = 1 } = {}) => {
|
|
@@ -426,19 +459,20 @@ export function createOcctKernel(replicad) {
|
|
|
426
459
|
const revolve = (pts, { degrees = 360 } = {}) => {
|
|
427
460
|
const key = h("revolve", pts && pts._shape2d ? pts._hash : pts, degrees);
|
|
428
461
|
return cached(key, () => {
|
|
429
|
-
const region = pts && pts._shape2d ? pts
|
|
462
|
+
const region = pts && pts._shape2d ? drawingFor(pts) : contourDrawing(pts);
|
|
430
463
|
return wrap(region.sketchOnPlane("XZ").revolve([0, 0, 1], { angle: degrees }), [], key);
|
|
431
464
|
});
|
|
432
465
|
};
|
|
433
466
|
|
|
434
467
|
// extrude a polygon-with-holes region from z=0: cut each hole Drawing out of the outer
|
|
435
468
|
// Drawing (winding-agnostic 2-D boolean), sketch it, then extrude (twist/taper via cfg).
|
|
436
|
-
// A Shape2D `profile` (
|
|
437
|
-
//
|
|
469
|
+
// A Shape2D `profile` (curve-native, possibly multi-region) materializes through
|
|
470
|
+
// drawingFor — a fresh Drawing per call, so no .clone() is needed before the
|
|
471
|
+
// sketch consumes it (replicad booleans/extrude consume their operand).
|
|
438
472
|
const extrude = (profile, hgt, { twist = 0, scaleTop = 1 } = {}) => {
|
|
439
473
|
const key = h("extrude", profile && profile._shape2d ? profile._hash : profile, hgt, twist, scaleTop);
|
|
440
474
|
return cached(key, () => {
|
|
441
|
-
const region = profile && profile._shape2d ? profile
|
|
475
|
+
const region = profile && profile._shape2d ? drawingFor(profile) : drawingFromProfile(profile);
|
|
442
476
|
const sketch = region.sketchOnPlane("XY");
|
|
443
477
|
if (twist === 0 && scaleTop === 1) return wrap(sketch.extrude(hgt), [], key);
|
|
444
478
|
const cfg = {};
|
|
@@ -518,6 +552,9 @@ export function createOcctKernel(replicad) {
|
|
|
518
552
|
});
|
|
519
553
|
},
|
|
520
554
|
shape2d,
|
|
555
|
+
// Backend-internal region adapter: the same function Shape2D.offset runs on
|
|
556
|
+
// (defined above). `_`-prefixed — not part of the public kernel surface.
|
|
557
|
+
_offsetRegions: offsetRegions,
|
|
521
558
|
toSTEP: (named) => exportSTEP(named.map(({ name, solid }) => ({ name, shape: solid._mat()._s }))).arrayBuffer(),
|
|
522
559
|
beginSubPart: (name) => cache.begin(name),
|
|
523
560
|
endSubPart: () => cache.end(),
|