partforge 0.1.0 → 0.3.3
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/README.md +3 -1
- package/bin/cli.js +73 -0
- package/docs/AUTHORING-PARTS.md +182 -2
- package/package.json +12 -1
- package/src/app-filleted-box.js +7 -0
- package/src/filleted-box-worker.js +3 -0
- package/src/framework/app.css +26 -1
- package/src/framework/controls.js +153 -36
- package/src/framework/geometry/edge-selector.js +17 -0
- package/src/framework/geometry/errors.js +10 -0
- package/src/framework/geometry/face-selector.js +19 -0
- package/src/framework/geometry/kernel.js +5 -0
- package/src/framework/geometry/manifold-backend.js +21 -0
- package/src/framework/geometry/occt-backend.js +103 -2
- package/src/framework/geometry/polygon.js +103 -0
- package/src/framework/geometry/probe.js +50 -0
- package/src/framework/geometry-service.js +8 -10
- package/src/framework/jobs.js +13 -4
- package/src/framework/markdown.js +41 -0
- package/src/framework/mount.js +57 -13
- package/src/framework/param-deps.js +50 -0
- package/src/framework/view-state.js +55 -0
- package/src/framework/viewer.js +28 -2
- package/src/parts/demo.js +29 -11
- package/src/parts/filleted-box.js +46 -0
- package/src/testing/build.js +17 -0
- package/src/testing/measure.js +53 -0
- package/src/testing/mesh.js +27 -0
- package/src/testing/render.js +159 -0
- package/src/testing.js +3 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Thrown by a backend that can't perform a requested op (e.g. Manifold has no
|
|
2
|
+
// fillet/chamfer). The framework catches `.code === "NEEDS_OCCT"` and reroutes
|
|
3
|
+
// the part to the OCCT backend.
|
|
4
|
+
export class KernelCapabilityError extends Error {
|
|
5
|
+
constructor(message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "KernelCapabilityError";
|
|
8
|
+
this.code = "NEEDS_OCCT";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Map partforge's declarative face selector onto a replicad FaceFinder filter.
|
|
2
|
+
// undefined / null → undefined (all faces)
|
|
3
|
+
// (f) => f... → passed through (raw replicad finder escape hatch)
|
|
4
|
+
// { dir, inPlane, at, near } → a filter applying the given criteria (AND)
|
|
5
|
+
// dir picks faces whose normal runs along that axis (i.e. parallel to the
|
|
6
|
+
// perpendicular plane): X→YZ, Y→XZ, Z→XY.
|
|
7
|
+
const PERP_PLANE = { X: "YZ", Y: "XZ", Z: "XY" };
|
|
8
|
+
|
|
9
|
+
export function toFaceFinder(selector) {
|
|
10
|
+
if (selector == null) return undefined;
|
|
11
|
+
if (typeof selector === "function") return selector;
|
|
12
|
+
return (f) => {
|
|
13
|
+
let r = f;
|
|
14
|
+
if (selector.dir != null) r = r.parallelTo(PERP_PLANE[selector.dir]);
|
|
15
|
+
if (selector.inPlane != null) r = r.inPlane(selector.inPlane, selector.at);
|
|
16
|
+
if (selector.near != null) r = r.containsPoint(selector.near);
|
|
17
|
+
return r;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
* @property {(tool: Solid) => Solid} cut
|
|
7
7
|
* @property {(tools: Solid[]) => Solid} cutAll batch subtract (backend-optimized)
|
|
8
8
|
* @property {(other: Solid) => Solid} intersect boolean intersection (Manifold)
|
|
9
|
+
* @property {() => Solid} clone independent copy (replicad consumes solids on transform)
|
|
10
|
+
* @property {() => {min:number[],max:number[],center:number[],size:number[]}} boundingBox axis-aligned bounds (query)
|
|
11
|
+
* @property {(thickness:number, openFaces:object) => Solid} shell hollow inward (OCCT only); openFaces selector required
|
|
9
12
|
* @property {(v: number[]) => Solid} translate
|
|
10
13
|
* @property {(deg: number, center: number[], axis: number[]) => Solid} rotate
|
|
11
14
|
* @property {(plane: "XY"|"XZ"|"YZ") => Solid} mirror
|
|
@@ -16,8 +19,10 @@
|
|
|
16
19
|
*
|
|
17
20
|
* @typedef {Object} GeometryKernel
|
|
18
21
|
* @property {(rBottom:number, rTop:number, h:number, opts?:{center?:boolean}) => Solid} cylinder
|
|
22
|
+
* @property {(r:number) => Solid} sphere sphere centred at the origin
|
|
19
23
|
* @property {(min:number[], max:number[]) => Solid} box
|
|
20
24
|
* @property {(points2D:number[][], h:number) => Solid} prism extrude polygon from z=0
|
|
25
|
+
* @property {(points2D:number[][], opts?:{degrees?:number}) => Solid} revolve revolve a lathe profile [[r,z],…] around Z
|
|
21
26
|
* @property {(o:{pathR:number,profileR:number,pitch:number,turns:number,z0:number,lefthand:boolean}) => Solid} helixSweptTube
|
|
22
27
|
* @property {(solids:Solid[]) => Solid} union
|
|
23
28
|
* @property {(named:{name:string,solid:Solid}[]) => Promise<ArrayBuffer>} toSTEP OCCT only
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { helixTube } from "./helix-tube.js";
|
|
2
|
+
import { KernelCapabilityError } from "./errors.js";
|
|
2
3
|
|
|
3
4
|
const PLANE_NORMAL = { XY: [0, 0, 1], XZ: [0, 1, 0], YZ: [1, 0, 0] };
|
|
4
5
|
// 'preview' = interactive view (fast); 'print' = STL export (high-res, used only
|
|
@@ -52,7 +53,19 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
52
53
|
cut: (t) => wrap(T(m.subtract(t._m))),
|
|
53
54
|
cutAll: (tools) => wrap(T(m.subtract(unionRaw(tools.map((t) => t._m))))),
|
|
54
55
|
intersect: (t) => wrap(T(m.intersect(t._m))),
|
|
56
|
+
clone: () => wrap(m),
|
|
57
|
+
boundingBox: () => {
|
|
58
|
+
const b = m.boundingBox(); // { min: Vec3, max: Vec3 }
|
|
59
|
+
const min = [...b.min], max = [...b.max];
|
|
60
|
+
return {
|
|
61
|
+
min, max,
|
|
62
|
+
center: [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2],
|
|
63
|
+
size: [max[0] - min[0], max[1] - min[1], max[2] - min[2]],
|
|
64
|
+
};
|
|
65
|
+
},
|
|
55
66
|
volume: () => m.volume(),
|
|
67
|
+
genus: () => m.genus(),
|
|
68
|
+
isEmpty: () => m.isEmpty(),
|
|
56
69
|
translate: (v) => wrap(T(m.translate(v))),
|
|
57
70
|
rotate: (deg, center, axis) => {
|
|
58
71
|
const euler = [axis[0] * deg, axis[1] * deg, axis[2] * deg];
|
|
@@ -64,10 +77,14 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
64
77
|
toMesh: () => meshOut(m, false),
|
|
65
78
|
toSTL: () => Promise.resolve(meshOut(m, true)),
|
|
66
79
|
toIndexedMesh: () => indexedMeshOut(m),
|
|
80
|
+
fillet: () => { throw new KernelCapabilityError("fillet requires the OCCT backend"); },
|
|
81
|
+
chamfer: () => { throw new KernelCapabilityError("chamfer requires the OCCT backend"); },
|
|
82
|
+
shell: () => { throw new KernelCapabilityError("shell requires the OCCT backend"); },
|
|
67
83
|
});
|
|
68
84
|
|
|
69
85
|
return {
|
|
70
86
|
cylinder: (rb, rt, h, { center = false } = {}) => wrap(T(Manifold.cylinder(h, rb, rt, segs, center))),
|
|
87
|
+
sphere: (r) => wrap(T(Manifold.sphere(r, segs))),
|
|
71
88
|
box: (min, max) => {
|
|
72
89
|
const cube = T(Manifold.cube([max[0] - min[0], max[1] - min[1], max[2] - min[2]]));
|
|
73
90
|
return wrap(T(cube.translate(min)));
|
|
@@ -77,6 +94,10 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
77
94
|
return wrap(T(cs.extrude(h)));
|
|
78
95
|
},
|
|
79
96
|
helixSweptTube: (o) => wrap(T(helixTube(wasm, { ...o, ...tube }))),
|
|
97
|
+
revolve: (pts, { degrees = 360 } = {}) => {
|
|
98
|
+
for (const [r] of pts) if (r < 0) throw new Error("revolve: profile radius must be ≥ 0");
|
|
99
|
+
return wrap(T(Manifold.revolve([pts], segs, degrees)));
|
|
100
|
+
},
|
|
80
101
|
union: (solids) => wrap(unionRaw(solids.map((s) => s._m))), // unionRaw already tracks its result
|
|
81
102
|
toSTEP: () => { throw new Error("STEP export not supported by the Manifold backend"); },
|
|
82
103
|
// Free every WASM object created since the last cleanup. Call after each job
|
|
@@ -1,16 +1,95 @@
|
|
|
1
1
|
// OCCT backend via replicad. Same GeometryKernel shape as the Manifold backend,
|
|
2
2
|
// and the only backend with toSTEP(). This is where today's drum.js kernel calls
|
|
3
3
|
// (makeCylinder, makeHelix+genericSweep, draw/extrude, cut/fuse) now live.
|
|
4
|
+
import { toEdgeFinder } from "./edge-selector.js";
|
|
5
|
+
import { toFaceFinder } from "./face-selector.js";
|
|
4
6
|
const MESH = { preview: { tolerance: 0.1, angularTolerance: 0.5 }, print: { tolerance: 0.01, angularTolerance: 0.1 } };
|
|
5
7
|
|
|
6
8
|
export function createOcctKernel(replicad) {
|
|
7
9
|
const { makeCylinder, makeBox, makeCircle, makeHelix, assembleWire, genericSweep,
|
|
8
|
-
makeCompound, loft, draw, exportSTEP } = replicad;
|
|
10
|
+
makeCompound, loft, draw, exportSTEP, measureVolume, makeSphere } = replicad;
|
|
11
|
+
|
|
12
|
+
// Is a shape a closed solid? A broken chamfer (one that over-ran and consumed a face)
|
|
13
|
+
// meshes to an OPEN surface; a valid one is closed. OCCT meshes each face separately,
|
|
14
|
+
// so weld vertices by position, then a closed solid has every edge shared by exactly
|
|
15
|
+
// two triangles. (A coarse mesh is enough — this is a topology check.)
|
|
16
|
+
const isClosedSolid = (shape) => {
|
|
17
|
+
const m = shape.mesh({ tolerance: 0.3, angularTolerance: 1.0 });
|
|
18
|
+
const P = m.vertices, T = m.triangles;
|
|
19
|
+
const id = new Map();
|
|
20
|
+
const vid = (i) => {
|
|
21
|
+
const key = Math.round(P[i * 3] * 32) + "," + Math.round(P[i * 3 + 1] * 32) + "," + Math.round(P[i * 3 + 2] * 32);
|
|
22
|
+
let d = id.get(key); if (d === undefined) { d = id.size; id.set(key, d); } return d;
|
|
23
|
+
};
|
|
24
|
+
const edges = new Map();
|
|
25
|
+
for (let t = 0; t < T.length / 3; t++) {
|
|
26
|
+
const a = vid(T[t * 3]), b = vid(T[t * 3 + 1]), c = vid(T[t * 3 + 2]);
|
|
27
|
+
for (const [x, y] of [[a, b], [b, c], [c, a]]) { const e = x < y ? x * 1e7 + y : y * 1e7 + x; edges.set(e, (edges.get(e) || 0) + 1); }
|
|
28
|
+
}
|
|
29
|
+
for (const n of edges.values()) if (n !== 2) return false;
|
|
30
|
+
return true;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// The true maximum chamfer for an edge depends on local angles and adjacent features,
|
|
34
|
+
// which is hard to predict analytically (and OCCT exposes no max-radius query). So
|
|
35
|
+
// VALIDATE the result instead of guessing: try the requested distance, and if it makes
|
|
36
|
+
// a closed solid, use it (valid large chamfers — e.g. on a pill — go through). If not,
|
|
37
|
+
// binary-search the largest distance that does. Discarded attempts are freed so OCCT's
|
|
38
|
+
// WASM heap doesn't grow across regenerates.
|
|
39
|
+
const validChamfer = (shape, finderFn, distance) => {
|
|
40
|
+
if (!(distance > 0)) return shape.clone();
|
|
41
|
+
const tryAt = (d) => {
|
|
42
|
+
const probe = shape.clone();
|
|
43
|
+
let res;
|
|
44
|
+
try { res = probe.chamfer(d, finderFn); } catch { return null; } // probe consumed by the op
|
|
45
|
+
if (measureVolume(res) > 0 && isClosedSolid(res)) return res;
|
|
46
|
+
res.delete?.();
|
|
47
|
+
return null;
|
|
48
|
+
};
|
|
49
|
+
let best = tryAt(distance);
|
|
50
|
+
if (best) return best; // requested distance is valid
|
|
51
|
+
let lo = 0, hi = distance, bestD = 0;
|
|
52
|
+
for (let i = 0; i < 6; i++) {
|
|
53
|
+
const mid = (lo + hi) / 2;
|
|
54
|
+
const res = tryAt(mid);
|
|
55
|
+
if (res) { best?.delete?.(); best = res; bestD = mid; lo = mid; } else hi = mid;
|
|
56
|
+
}
|
|
57
|
+
if (best) { console.info(`partforge: chamfer ${distance} reduced to ${bestD.toFixed(2)} (largest valid for this geometry)`); return best; }
|
|
58
|
+
return shape.clone(); // nothing valid — skip the chamfer
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// Native fillet/chamfer can throw or yield an empty solid for out-of-range radii
|
|
62
|
+
// or awkward edge interactions — and OCCT's failures aren't monotonic in the
|
|
63
|
+
// radius (e.g. a radius that equals an adjacent fillet's can fail while larger
|
|
64
|
+
// ones succeed). Rather than letting the whole part vanish, attempt the op on a
|
|
65
|
+
// clone and fall back to the original shape (feature skipped) on a throw or empty
|
|
66
|
+
// result, with a console warning so it's discoverable.
|
|
67
|
+
const safeOp = (shape, op, label) => {
|
|
68
|
+
const backup = shape.clone();
|
|
69
|
+
try {
|
|
70
|
+
const result = op(shape);
|
|
71
|
+
if (measureVolume(result) > 0) { backup.delete?.(); return result; }
|
|
72
|
+
result.delete?.();
|
|
73
|
+
console.warn(`partforge: ${label} produced an empty solid — feature skipped (radius out of range?)`);
|
|
74
|
+
} catch (e) {
|
|
75
|
+
console.warn(`partforge: ${label} failed (${e?.message || e}) — feature skipped`);
|
|
76
|
+
}
|
|
77
|
+
return backup;
|
|
78
|
+
};
|
|
9
79
|
|
|
10
80
|
const wrap = (shape) => ({
|
|
11
81
|
_s: shape,
|
|
12
82
|
cut: (t) => wrap(shape.cut(t._s)),
|
|
13
83
|
cutAll: (tools) => wrap(shape.cut(makeCompound(tools.map((t) => t._s)))),
|
|
84
|
+
clone: () => wrap(shape.clone()),
|
|
85
|
+
boundingBox: () => {
|
|
86
|
+
const bb = shape.boundingBox; // replicad BoundingBox: .bounds [[min],[max]], .center
|
|
87
|
+
const [min, max] = bb.bounds;
|
|
88
|
+
return {
|
|
89
|
+
min: [...min], max: [...max], center: [...bb.center],
|
|
90
|
+
size: [max[0] - min[0], max[1] - min[1], max[2] - min[2]],
|
|
91
|
+
};
|
|
92
|
+
},
|
|
14
93
|
translate: (v) => wrap(shape.translate(v)),
|
|
15
94
|
rotate: (deg, center, axis) => wrap(shape.rotate(deg, center, axis)),
|
|
16
95
|
mirror: (plane) => wrap(shape.mirror(plane)),
|
|
@@ -24,6 +103,18 @@ export function createOcctKernel(replicad) {
|
|
|
24
103
|
};
|
|
25
104
|
},
|
|
26
105
|
toSTL: ({ quality = "print" } = {}) => shape.blobSTL(MESH[quality]).arrayBuffer(),
|
|
106
|
+
fillet: (radius, selector) => wrap(safeOp(shape, (sh) => sh.fillet(radius, toEdgeFinder(selector)), `fillet(${radius})`)),
|
|
107
|
+
chamfer: (distance, selector) => wrap(validChamfer(shape, toEdgeFinder(selector), distance)),
|
|
108
|
+
shell: (thickness, openFaces) => {
|
|
109
|
+
if (openFaces == null) throw new Error("shell: openFaces is required (a fully closed hollow is not supported)");
|
|
110
|
+
// replicad shells inward with a positive thickness in this version, keeping outer dimensions.
|
|
111
|
+
return wrap(safeOp(shape, (sh) => sh.shell(thickness, toFaceFinder(openFaces)), `shell(${thickness})`));
|
|
112
|
+
},
|
|
113
|
+
volume: () => measureVolume(shape),
|
|
114
|
+
toIndexedMesh: () => {
|
|
115
|
+
const m = shape.mesh(MESH.preview);
|
|
116
|
+
return { positions: Float32Array.from(m.vertices), indices: Uint32Array.from(m.triangles) };
|
|
117
|
+
},
|
|
27
118
|
});
|
|
28
119
|
|
|
29
120
|
// cylinder OR frustum (loft of two circles) when rb !== rt
|
|
@@ -42,6 +133,15 @@ export function createOcctKernel(replicad) {
|
|
|
42
133
|
return wrap(pen.close().sketchOnPlane("XY").extrude(h));
|
|
43
134
|
};
|
|
44
135
|
|
|
136
|
+
// revolve a lathe profile [[r,z],…] around the Z axis (degrees defaults to 360)
|
|
137
|
+
const revolve = (pts, { degrees = 360 } = {}) => {
|
|
138
|
+
for (const [r] of pts) if (r < 0) throw new Error("revolve: profile radius must be ≥ 0");
|
|
139
|
+
let pen = draw(pts[0]);
|
|
140
|
+
for (let i = 1; i < pts.length; i++) pen = pen.lineTo(pts[i]);
|
|
141
|
+
const sketch = pen.close().sketchOnPlane("XZ");
|
|
142
|
+
return wrap(sketch.revolve([0, 0, 1], { angle: degrees }));
|
|
143
|
+
};
|
|
144
|
+
|
|
45
145
|
// circle profile swept along a helix (frenet)
|
|
46
146
|
const helixSweptTube = ({ pathR, profileR, pitch, turns, z0, lefthand }) => {
|
|
47
147
|
const spine = makeHelix(pitch, pitch * turns, pathR, [0, 0, z0], [0, 0, 1], lefthand);
|
|
@@ -52,7 +152,8 @@ export function createOcctKernel(replicad) {
|
|
|
52
152
|
};
|
|
53
153
|
|
|
54
154
|
return {
|
|
55
|
-
cylinder, box: (min, max) => wrap(makeBox(min, max)), prism, helixSweptTube,
|
|
155
|
+
cylinder, box: (min, max) => wrap(makeBox(min, max)), prism, revolve, helixSweptTube,
|
|
156
|
+
sphere: (r) => wrap(makeSphere(r)),
|
|
56
157
|
union: (solids) => wrap(solids.map((s) => s._s).reduce((a, b) => a.fuse(b))),
|
|
57
158
|
toSTEP: (named) => exportSTEP(named.map(({ name, solid }) => ({ name, shape: solid._s }))).arrayBuffer(),
|
|
58
159
|
};
|
|
@@ -21,3 +21,106 @@ export function hexPolygon(r) {
|
|
|
21
21
|
}
|
|
22
22
|
return pts;
|
|
23
23
|
}
|
|
24
|
+
|
|
25
|
+
// Rectangle w×h centred at the origin with radius-r corners (r clamped to min(w,h)/2).
|
|
26
|
+
export function roundedRectPolygon(w, h, r, segs = 8) {
|
|
27
|
+
r = Math.min(r, Math.min(w, h) / 2);
|
|
28
|
+
const hw = w / 2, hh = h / 2;
|
|
29
|
+
if (r <= 0) return [[hw, -hh], [hw, hh], [-hw, hh], [-hw, -hh]];
|
|
30
|
+
const corners = [
|
|
31
|
+
[hw - r, hh - r, 0], // top-right
|
|
32
|
+
[-(hw - r), hh - r, Math.PI / 2], // top-left
|
|
33
|
+
[-(hw - r), -(hh - r), Math.PI], // bottom-left
|
|
34
|
+
[hw - r, -(hh - r), (3 * Math.PI) / 2], // bottom-right
|
|
35
|
+
];
|
|
36
|
+
const pts = [];
|
|
37
|
+
for (const [cx, cy, a0] of corners)
|
|
38
|
+
for (let i = 0; i <= segs; i++) {
|
|
39
|
+
const a = a0 + (Math.PI / 2) * (i / segs);
|
|
40
|
+
pts.push([cx + r * Math.cos(a), cy + r * Math.sin(a)]);
|
|
41
|
+
}
|
|
42
|
+
return pts;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Regular n-gon, circumradius r. Vertex up by default; flat:true puts a flat edge up.
|
|
46
|
+
export function regularPolygon(n, r, { flat = false } = {}) {
|
|
47
|
+
const base = Math.PI / 2 + (flat ? Math.PI / n : 0);
|
|
48
|
+
const pts = [];
|
|
49
|
+
for (let i = 0; i < n; i++) {
|
|
50
|
+
const a = base + (2 * Math.PI * i) / n;
|
|
51
|
+
pts.push([r * Math.cos(a), r * Math.sin(a)]);
|
|
52
|
+
}
|
|
53
|
+
return pts;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Ellipse with semi-axes rx, ry.
|
|
57
|
+
export function ellipsePolygon(rx, ry, segs = 48) {
|
|
58
|
+
const pts = [];
|
|
59
|
+
for (let i = 0; i < segs; i++) {
|
|
60
|
+
const a = (2 * Math.PI * i) / segs;
|
|
61
|
+
pts.push([rx * Math.cos(a), ry * Math.sin(a)]);
|
|
62
|
+
}
|
|
63
|
+
return pts;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Stadium/obround slot: two r-radius semicircles whose centres are `length` apart
|
|
67
|
+
// (overall length = length + 2r), centred at the origin, long axis along X.
|
|
68
|
+
export function slotPolygon(length, r, segs = 16) {
|
|
69
|
+
const hl = length / 2;
|
|
70
|
+
const pts = [];
|
|
71
|
+
for (let i = 0; i <= segs; i++) { const a = -Math.PI / 2 + Math.PI * (i / segs); pts.push([hl + r * Math.cos(a), r * Math.sin(a)]); }
|
|
72
|
+
for (let i = 0; i <= segs; i++) { const a = Math.PI / 2 + Math.PI * (i / segs); pts.push([-hl + r * Math.cos(a), r * Math.sin(a)]); }
|
|
73
|
+
return pts;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Star with `points` tips, alternating outer/inner radius. First tip points up.
|
|
77
|
+
export function starPolygon(points, outerR, innerR) {
|
|
78
|
+
const pts = [];
|
|
79
|
+
for (let i = 0; i < points * 2; i++) {
|
|
80
|
+
const a = Math.PI / 2 + (Math.PI * i) / points;
|
|
81
|
+
const rr = i % 2 === 0 ? outerR : innerR;
|
|
82
|
+
pts.push([rr * Math.cos(a), rr * Math.sin(a)]);
|
|
83
|
+
}
|
|
84
|
+
return pts;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Annular sector as a single closed contour (outer arc, then inner arc back).
|
|
88
|
+
// arcDeg must be < 360 — a full annulus is a contour-with-hole; cut an inner
|
|
89
|
+
// cylinder from an outer one for a full ring.
|
|
90
|
+
export function ringSectorPolygon(innerR, outerR, arcDeg, segs = 32) {
|
|
91
|
+
if (arcDeg >= 360) throw new Error("ringSectorPolygon: arcDeg must be < 360 (use a cut for a full ring)");
|
|
92
|
+
const a = (arcDeg * Math.PI) / 180;
|
|
93
|
+
const steps = Math.max(2, Math.ceil((segs * arcDeg) / 360));
|
|
94
|
+
const pts = [];
|
|
95
|
+
for (let i = 0; i <= steps; i++) { const t = (a * i) / steps; pts.push([outerR * Math.cos(t), outerR * Math.sin(t)]); }
|
|
96
|
+
for (let i = steps; i >= 0; i--) { const t = (a * i) / steps; pts.push([innerR * Math.cos(t), innerR * Math.sin(t)]); }
|
|
97
|
+
return pts;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const PATTERN_AXIS = { X: [1, 0, 0], Y: [0, 1, 0], Z: [0, 0, 1] };
|
|
101
|
+
|
|
102
|
+
// `count` copies of `solid` translated by i*step ([dx,dy,dz]) for i in 0..count-1.
|
|
103
|
+
// Returns a Solid[] — feed to k.union(...) (features) or s.cutAll(...) (holes).
|
|
104
|
+
export function linearPattern(solid, count, step) {
|
|
105
|
+
const out = [];
|
|
106
|
+
for (let i = 0; i < count; i++)
|
|
107
|
+
out.push(solid.clone().translate([step[0] * i, step[1] * i, step[2] * i]));
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// `count` copies spaced angle/count degrees apart around `axis` through `center`.
|
|
112
|
+
// rotateCopies:true re-orients each copy to face along the circle; false places it
|
|
113
|
+
// at the orbital position with its original orientation (for radially symmetric tools).
|
|
114
|
+
export function circularPattern(solid, count, { center = [0, 0, 0], axis = "Z", angle = 360, rotateCopies = true } = {}) {
|
|
115
|
+
const ax = Array.isArray(axis) ? axis : PATTERN_AXIS[axis];
|
|
116
|
+
const out = [];
|
|
117
|
+
for (let i = 0; i < count; i++) {
|
|
118
|
+
const deg = (angle / count) * i;
|
|
119
|
+
const placed = solid.clone().rotate(deg, center, ax);
|
|
120
|
+
if (rotateCopies) { out.push(placed); continue; }
|
|
121
|
+
// cancel the orientation change by counter-rotating about the copy's own centre
|
|
122
|
+
const c = placed.boundingBox().center;
|
|
123
|
+
out.push(placed.rotate(-deg, c, ax));
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Geometry-free backend detection. A probe kernel records every op a part's
|
|
2
|
+
// build() invokes (returning chainable no-op proxies, dummy values for queries);
|
|
3
|
+
// if an OCCT-only op was used, the part needs the OCCT backend.
|
|
4
|
+
export const OCCT_ONLY = new Set(["fillet", "chamfer", "shell"]);
|
|
5
|
+
|
|
6
|
+
export function createProbeKernel() {
|
|
7
|
+
const used = new Set();
|
|
8
|
+
const note = (name) => used.add(name);
|
|
9
|
+
const proxy = {
|
|
10
|
+
cut() { note("cut"); return proxy; },
|
|
11
|
+
cutAll() { note("cutAll"); return proxy; },
|
|
12
|
+
intersect() { note("intersect"); return proxy; },
|
|
13
|
+
clone() { note("clone"); return proxy; },
|
|
14
|
+
boundingBox() { note("boundingBox"); return { min: [0, 0, 0], max: [1, 1, 1], center: [0.5, 0.5, 0.5], size: [1, 1, 1] }; },
|
|
15
|
+
translate() { note("translate"); return proxy; },
|
|
16
|
+
rotate() { note("rotate"); return proxy; },
|
|
17
|
+
mirror() { note("mirror"); return proxy; },
|
|
18
|
+
fillet() { note("fillet"); return proxy; },
|
|
19
|
+
chamfer() { note("chamfer"); return proxy; },
|
|
20
|
+
shell() { note("shell"); return proxy; },
|
|
21
|
+
volume() { note("volume"); return 1; },
|
|
22
|
+
toMesh() { note("toMesh"); return { positions: new Float32Array(9), normals: new Float32Array(9), triangles: 1, edges: new Float32Array(0) }; },
|
|
23
|
+
toSTL() { note("toSTL"); return new ArrayBuffer(0); },
|
|
24
|
+
toIndexedMesh() { note("toIndexedMesh"); return { positions: new Float32Array(9), indices: new Uint32Array(3) }; },
|
|
25
|
+
};
|
|
26
|
+
const kernel = {
|
|
27
|
+
cylinder() { note("cylinder"); return proxy; },
|
|
28
|
+
sphere() { note("sphere"); return proxy; },
|
|
29
|
+
box() { note("box"); return proxy; },
|
|
30
|
+
prism() { note("prism"); return proxy; },
|
|
31
|
+
revolve() { note("revolve"); return proxy; },
|
|
32
|
+
helixSweptTube() { note("helixSweptTube"); return proxy; },
|
|
33
|
+
union() { note("union"); return proxy; },
|
|
34
|
+
toSTEP() { note("toSTEP"); return Promise.resolve(new ArrayBuffer(0)); },
|
|
35
|
+
cleanup() {},
|
|
36
|
+
};
|
|
37
|
+
return { kernel, used };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function detectBackend(part, params = {}) {
|
|
41
|
+
if (part.meta?.backend) return part.meta.backend;
|
|
42
|
+
const p = { ...part.defaults, ...params };
|
|
43
|
+
const d = part.derive ? part.derive(p) : {};
|
|
44
|
+
const { kernel, used } = createProbeKernel();
|
|
45
|
+
for (const name of Object.keys(part.parts)) {
|
|
46
|
+
try { part.parts[name].build(kernel, p, d); } catch { /* probe miss → capability backstop covers it */ }
|
|
47
|
+
}
|
|
48
|
+
for (const op of used) if (OCCT_ONLY.has(op)) return "occt";
|
|
49
|
+
return "manifold";
|
|
50
|
+
}
|
|
@@ -5,16 +5,14 @@
|
|
|
5
5
|
// `new Worker(new URL("./part-worker.js", import.meta.url), { type:"module", name })`
|
|
6
6
|
// pattern INLINE — Vite only bundles a worker (and its backend chunks) when it sees
|
|
7
7
|
// that literal call, so the framework can't construct it from a passed-in URL.
|
|
8
|
-
export function createGeometryService({ createWorker, onMessage
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
exporter.onmessage = onMessage;
|
|
13
|
-
const genWorker = occtPreview ? exporter : preview;
|
|
8
|
+
export function createGeometryService({ createWorker, onMessage }) {
|
|
9
|
+
const workers = { manifold: createWorker("manifold"), occt: createWorker("occt") };
|
|
10
|
+
workers.manifold.onmessage = onMessage;
|
|
11
|
+
workers.occt.onmessage = onMessage;
|
|
14
12
|
return {
|
|
15
|
-
generate: (msg) =>
|
|
16
|
-
exportStl: (msg) =>
|
|
17
|
-
export3mf: (msg) =>
|
|
18
|
-
exportStep: (msg) =>
|
|
13
|
+
generate: (msg, backend = "manifold") => workers[backend].postMessage(msg),
|
|
14
|
+
exportStl: (msg, backend = "manifold") => workers[backend].postMessage(msg),
|
|
15
|
+
export3mf: (msg, backend = "manifold") => workers[backend].postMessage(msg),
|
|
16
|
+
exportStep: (msg) => workers.occt.postMessage(msg), // STEP is always OCCT
|
|
19
17
|
};
|
|
20
18
|
}
|
package/src/framework/jobs.js
CHANGED
|
@@ -11,6 +11,14 @@ export function viewSubParts(part, view, params) {
|
|
|
11
11
|
});
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
// Sub-parts to include in an EXPORT of this view: the visible sub-parts, minus any
|
|
15
|
+
// flagged `exportable: false` (reference/preview-only parts — motor ghosts, bearing
|
|
16
|
+
// placeholders, etc.). They still show in the viewer; they're just never written to
|
|
17
|
+
// an STL/STEP/3MF file, so the user never has to toggle them off before exporting.
|
|
18
|
+
export function exportSubParts(part, view, params) {
|
|
19
|
+
return viewSubParts(part, view, params).filter((name) => part.parts[name].exportable !== false);
|
|
20
|
+
}
|
|
21
|
+
|
|
14
22
|
// Handle one geometry job, posting results/progress via `post`. Backend-agnostic
|
|
15
23
|
// and part-agnostic: every part specific comes through `part`.
|
|
16
24
|
// { type:"generate", subparts, view, params } → { type:"meshes", meshes, ms }
|
|
@@ -43,13 +51,13 @@ export async function handle(kernel, part, msg, post) {
|
|
|
43
51
|
post({ type: "meshes", meshes, ms: Date.now() - t0 });
|
|
44
52
|
} else if (msg.type === "export-stl") {
|
|
45
53
|
const out = [];
|
|
46
|
-
for (const name of
|
|
54
|
+
for (const name of exportSubParts(part, msg.view, p)) {
|
|
47
55
|
onProgress(`building ${label(name)}`);
|
|
48
56
|
out.push({ name: exportName(name), data: await buildPosed(name, "export", msg.view, onProgress).toSTL({ quality: "print" }) });
|
|
49
57
|
}
|
|
50
58
|
post({ type: "download-parts", ext: "stl", mime: "model/stl", parts: out });
|
|
51
59
|
} else if (msg.type === "export-step") {
|
|
52
|
-
const solids =
|
|
60
|
+
const solids = exportSubParts(part, msg.view, p).map((name) => {
|
|
53
61
|
onProgress(`building ${label(name)}`);
|
|
54
62
|
return { name: exportName(name), solid: buildPosed(name, "export", msg.view, onProgress) };
|
|
55
63
|
});
|
|
@@ -57,7 +65,7 @@ export async function handle(kernel, part, msg, post) {
|
|
|
57
65
|
const data = await kernel.toSTEP(solids);
|
|
58
66
|
post({ type: "download", data, filename: `${msg.view}.step`, mime: "application/step" });
|
|
59
67
|
} else if (msg.type === "export-3mf") {
|
|
60
|
-
const meshes =
|
|
68
|
+
const meshes = exportSubParts(part, msg.view, p).map((name) => {
|
|
61
69
|
onProgress(`building ${label(name)}`);
|
|
62
70
|
const { positions, indices } = buildPosed(name, "export", msg.view, onProgress).toIndexedMesh();
|
|
63
71
|
return { name: exportName(name), positions, indices };
|
|
@@ -66,7 +74,8 @@ export async function handle(kernel, part, msg, post) {
|
|
|
66
74
|
post({ type: "download", data: meshTo3MF(meshes), filename: `${msg.view}.3mf`, mime: "model/3mf" });
|
|
67
75
|
}
|
|
68
76
|
} catch (err) {
|
|
69
|
-
post({ type: "
|
|
77
|
+
if (err?.code === "NEEDS_OCCT") post({ type: "needs-occt" });
|
|
78
|
+
else post({ type: "error", message: String(err?.message || err) });
|
|
70
79
|
} finally {
|
|
71
80
|
kernel.cleanup?.();
|
|
72
81
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Render a (trusted, author-authored) Markdown description to sanitized HTML for
|
|
2
|
+
// display in a control/section info popover. Main-thread only — it imports a DOM
|
|
3
|
+
// sanitizer, so it must NOT be imported from the geometry worker.
|
|
4
|
+
import { marked } from "marked";
|
|
5
|
+
import createDOMPurify from "dompurify";
|
|
6
|
+
|
|
7
|
+
const CONFIG = {
|
|
8
|
+
ALLOWED_TAGS: ["a", "img", "p", "br", "strong", "em", "code", "pre", "blockquote",
|
|
9
|
+
"ul", "ol", "li", "h1", "h2", "h3", "h4", "table", "thead", "tbody", "tr", "th", "td", "hr", "del"],
|
|
10
|
+
ALLOWED_ATTR: ["href", "src", "alt", "title", "target", "rel"],
|
|
11
|
+
// links: http(s)/mailto; images: https or data:image/. (Union applied to all URI attrs.)
|
|
12
|
+
ALLOWED_URI_REGEXP: /^(?:https?:|mailto:|data:image\/)/i,
|
|
13
|
+
FORBID_ATTR: ["style"],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// DOMPurify is initialized lazily so that window is available at call time.
|
|
17
|
+
// Eager module-level init runs before happy-dom has finished setting up its
|
|
18
|
+
// global window, causing DOMPurify.isSupported to be false.
|
|
19
|
+
let _purify = null;
|
|
20
|
+
function getPurify() {
|
|
21
|
+
if (_purify) return _purify;
|
|
22
|
+
// Pass window explicitly: works in a real browser and in the happy-dom
|
|
23
|
+
// per-file test environment (where window is global but may not be set yet
|
|
24
|
+
// at module-evaluation time).
|
|
25
|
+
_purify = createDOMPurify(window);
|
|
26
|
+
// Links open in a new tab and cannot reach window.opener.
|
|
27
|
+
_purify.addHook("afterSanitizeAttributes", (node) => {
|
|
28
|
+
if (node.tagName === "A" && node.getAttribute("href")) {
|
|
29
|
+
node.setAttribute("target", "_blank");
|
|
30
|
+
node.setAttribute("rel", "noopener noreferrer");
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
return _purify;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// src: a CommonMark string. Returns sanitized HTML. Empty/blank/non-string → "".
|
|
37
|
+
export function renderMarkdown(src) {
|
|
38
|
+
if (typeof src !== "string" || !src.trim()) return "";
|
|
39
|
+
const raw = marked.parse(src, { async: false });
|
|
40
|
+
return getPurify().sanitize(raw, CONFIG);
|
|
41
|
+
}
|