partforge 0.7.0 → 0.8.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 +50 -3
- package/package.json +1 -1
- package/src/app-faceted-vase.js +10 -0
- package/src/faceted-vase-worker.js +3 -0
- package/src/framework/geometry/helix-tube.js +10 -20
- package/src/framework/geometry/kernel-front.js +6 -0
- package/src/framework/geometry/kernel.js +5 -2
- package/src/framework/geometry/loft.js +79 -0
- package/src/framework/geometry/manifold-backend.js +22 -1
- package/src/framework/geometry/mesh-build.js +53 -0
- package/src/framework/geometry/occt-backend.js +70 -10
- package/src/framework/geometry/polygon.js +89 -0
- package/src/framework/geometry/profile.js +96 -0
- package/src/framework/geometry/sweep.js +151 -0
- package/src/parts/faceted-vase.js +75 -0
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -90,13 +90,55 @@ handles. The same code runs on **Manifold** (fast meshes — preview + STL + 3MF
|
|
|
90
90
|
|---|---|
|
|
91
91
|
| `k.cylinder(rBottom, rTop, h, { center? })` | cylinder/cone along +Z (frustum if radii differ) |
|
|
92
92
|
| `k.box(min, max)` | axis-aligned box from `[x,y,z]` min/max |
|
|
93
|
-
| `k.prism(points2D, h, { twist?, scaleTop? })` | extrude a 2-D polygon from z=0; optional `twist` (degrees over the height) and `scaleTop` (uniform top taper: 1 straight, <1 taper in, 0 → point/cone) |
|
|
93
|
+
| `k.prism(points2D, h, { twist?, scaleTop? })` | extrude a 2-D polygon (or an **arc profile** from `roundedProfile`) from z=0; optional `twist` (degrees over the height) and `scaleTop` (uniform top taper: 1 straight, <1 taper in, 0 → point/cone) |
|
|
94
|
+
| `k.extrude(profile, h, { twist?, scaleTop? })` | extrude a **polygon-with-holes** region from z=0 in one op — `profile` is `{ outer, holes? }` where each contour is a points array **or an arc profile** (`roundedProfile`, for true STEP fillets), or a bare points array / arc profile for outer-only; same `twist`/`scaleTop` as `prism` (both backends) |
|
|
95
|
+
| `k.loft(rings, { ruled?, closed? })` | stack polygon cross-sections into a solid — ruled walls between consecutive rings, capped ends (both backends; `closed:true` capless loops are Manifold-only). `ruled:false` (smooth C2 blend) is honoured only by OCCT/STEP export; the Manifold preview always shows faceted straight walls |
|
|
96
|
+
| `k.sweep(profile2D, path3D, { cornerRadius?, closed?, ruled?, smooth? })` | sweep a fixed 2-D profile along a 3-D polyline path — sharp mitered corners (or `cornerRadius` fillets), capped ends (both backends). `closed:true` capless loops and `smooth:true` (OCCT-native swept B-rep, STEP-exact / preview-faceted) are backend-specific, like loft's `closed`/`ruled:false`. `closed:true` loops must be **planar** — RMF frame-transport holonomy can seam-twist a non-planar closed loop where the last station rejoins the first, so only planar closed loops are supported/tested |
|
|
94
97
|
| `k.sphere(r)` | sphere centred at the origin |
|
|
95
98
|
| `k.revolve(points2D, { degrees })` | revolve a lathe profile `[[r,z],…]` (r ≥ 0) around the Z axis (full or partial) |
|
|
96
99
|
| `k.helixSweptTube({ pathR, profileR, pitch, turns, z0, lefthand })` | circle swept along a helix (e.g. a rope groove) |
|
|
97
100
|
| `k.union(solids[])` | boolean union |
|
|
98
101
|
|
|
99
|
-
|
|
102
|
+
**`loft` rings** — each ring is `{ polygon:[[x,y],…] | sides+radius, z, rotate?, scale? }`
|
|
103
|
+
(all rings must share the same vertex count; `rotate` is degrees about Z, `scale` is a
|
|
104
|
+
number or `[sx,sy]`). Author rings CCW and ordered by ascending `z` (the `regularPolygon`
|
|
105
|
+
/ `polygon.js` helpers are already CCW); loft self-corrects a fully-inverted result so
|
|
106
|
+
CW-wound or descending-z rings still export a valid outward solid. (Arc profiles from
|
|
107
|
+
`roundedProfile` are **not** accepted as loft rings yet — a ring must be a point array;
|
|
108
|
+
use `prism`/`extrude` for true-arc STEP export.) **`sweep`** takes the same CCW
|
|
109
|
+
`polygon.js` outline as its `profile2D` and a plain `[[x,y,z],…]` point list as its
|
|
110
|
+
`path3D`; the profile stays perpendicular to the path (a rotation-minimizing frame), with
|
|
111
|
+
sharp mitered corners by default or `cornerRadius` fillets. Worked snippets:
|
|
112
|
+
|
|
113
|
+
```js
|
|
114
|
+
// a square tube (extrude a region with a hole) — one op, no boolean cut
|
|
115
|
+
k.extrude({ outer: roundedRectPolygon(40, 30, 4), holes: [circleProfile(6)] }, 10);
|
|
116
|
+
|
|
117
|
+
// a tapered, twisting faceted vase wall (see src/parts/faceted-vase.js)
|
|
118
|
+
const rings = [];
|
|
119
|
+
for (let i = 0; i <= 24; i++) { const t = i / 24;
|
|
120
|
+
rings.push({ sides: 6, radius: 30 - 8 * t, z: 120 * t, rotate: 90 * t }); }
|
|
121
|
+
k.loft(rings); // ruled walls, capped ends
|
|
122
|
+
|
|
123
|
+
// a cable/hose: sweep a circle along a 3-D polyline, with rounded bends
|
|
124
|
+
k.sweep(circleProfile(3), [[0, 0, 0], [0, 0, 20], [15, 0, 20]], { cornerRadius: 5 });
|
|
125
|
+
|
|
126
|
+
// round every corner of any CCW outline, then extrude/loft/prism it
|
|
127
|
+
k.prism(filletPolygon(bracketOutline, 3), 4); // tessellated corners (faceted in STEP)
|
|
128
|
+
k.prism(roundedProfile(bracketOutline, 3), 4); // true CIRCLE corners in STEP export
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
2-D polygon helpers for `prism`/`extrude`/`loft`: `import { piePolygon, hexPolygon,
|
|
132
|
+
regularPolygon, roundedRectPolygon, starPolygon, circleProfile, filletPolygon,
|
|
133
|
+
roundedProfile } from "partforge/geometry"`. `filletPolygon(points, r, { segs? })` rounds
|
|
134
|
+
every corner of a CCW polygon (per-corner radius clamped so neighbouring arcs never overlap)
|
|
135
|
+
and returns points usable by `prism`/`extrude`/`loft` on both backends — but it **bakes each
|
|
136
|
+
corner into line facets**, so STEP corners are faceted. `roundedProfile(points, r | r[])`
|
|
137
|
+
rounds corners the same way but keeps them **mathematically true** — it carries the arc
|
|
138
|
+
symbolically so STEP export gets real circular edges. Use it for `prism`/`extrude` (not yet
|
|
139
|
+
`loft` — arc rings are rejected there in v1). A scalar `r` rounds every corner; a per-corner
|
|
140
|
+
`r[]` (length = points) rounds selectively (a `0`, a zero-length edge, or a straight/180°
|
|
141
|
+
corner stays sharp).
|
|
100
142
|
**Import geometry helpers from `partforge/geometry`, never from `partforge`** — the main
|
|
101
143
|
entry pulls in the DOM viewer/controls, and your build functions run in a Web Worker
|
|
102
144
|
(importing the main entry there throws `document is not defined`).
|
|
@@ -195,7 +237,12 @@ module-level mutable state. An impure build will silently return stale geometry.
|
|
|
195
237
|
Cache granularity follows the operations you call. Booleans and heavy primitives are
|
|
196
238
|
cached; cheap transforms are recomputed. To make a multi-step shape into a single
|
|
197
239
|
cache node, use (or add) a **compound op** like `k.boredCylinder({ od, h, bore })` —
|
|
198
|
-
it hashes from its own arguments and never exposes its internals to the cache.
|
|
240
|
+
it hashes from its own arguments and never exposes its internals to the cache. The heavy
|
|
241
|
+
primitives `loft`, `sweep`, `extrude`, `prism`, and `revolve` are cached this way too:
|
|
242
|
+
their hash folds every shape-affecting argument (each `loft` ring's points/`z`/`rotate`/`scale`,
|
|
243
|
+
`sweep`'s profile points/path points/`cornerRadius`/`closed`, `extrude`'s holes, an arc
|
|
244
|
+
profile's segment specs from `roundedProfile`, and the tessellation from `twist`), so
|
|
245
|
+
changing any of them is a fresh cache node while an identical rebuild is a hit.
|
|
199
246
|
|
|
200
247
|
---
|
|
201
248
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import vasePart from "./parts/faceted-vase.js";
|
|
2
|
+
import { mount } from "./framework/index.js";
|
|
3
|
+
|
|
4
|
+
// Dev-only example app for the faceted vase part. Identical wiring to app-planter.js —
|
|
5
|
+
// only the imported definition and the worker entry differ per part. `npm run dev`,
|
|
6
|
+
// then open /faceted-vase.html.
|
|
7
|
+
mount(vasePart, {
|
|
8
|
+
createWorker: (name) =>
|
|
9
|
+
new Worker(new URL("./faceted-vase-worker.js", import.meta.url), { type: "module", name }),
|
|
10
|
+
});
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// Builds a watertight triangle mesh of a circular profile swept along a helix in
|
|
2
2
|
// its frenet frame, then imports it as a Manifold solid. The profile stays
|
|
3
3
|
// perpendicular to the helix tangent (unlike twist-extrude), so it matches an
|
|
4
|
-
// exact frenet sweep. Winding is consistent-outward;
|
|
5
|
-
//
|
|
4
|
+
// exact frenet sweep. Winding is consistent-outward; the ring stitching + caps +
|
|
5
|
+
// ofMesh import are the shared ring-mesh helpers in mesh-build.js (also used by loft).
|
|
6
|
+
import { sideQuads, fanCap, manifoldFromMesh } from "./mesh-build.js";
|
|
7
|
+
|
|
6
8
|
const norm = (v) => { const m = Math.hypot(...v); return [v[0] / m, v[1] / m, v[2] / m]; };
|
|
7
9
|
const cross = (a, b) => [a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]];
|
|
8
10
|
|
|
@@ -28,23 +30,11 @@ export function helixTube(wasm, opts) {
|
|
|
28
30
|
ctr[2] + profileR * (Math.cos(a) * N[2] + Math.sin(a) * B[2]));
|
|
29
31
|
}
|
|
30
32
|
}
|
|
31
|
-
// side
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
// end caps
|
|
38
|
-
const c0 = V.length / 3;
|
|
39
|
-
V.push(pathR * Math.cos(0), pathR * Math.sin(0), z0);
|
|
40
|
-
for (let j = 0; j < ringSegs; j++) Tr.push(c0, (j+1)%ringSegs, j);
|
|
41
|
-
const base = (n - 1) * ringSegs, cz = V.length / 3;
|
|
42
|
-
V.push(pathR * Math.cos(sign * phiMax), pathR * Math.sin(sign * phiMax), z0 + c * phiMax);
|
|
43
|
-
for (let j = 0; j < ringSegs; j++) Tr.push(cz, base + j, base + (j+1)%ringSegs);
|
|
33
|
+
sideQuads(Tr, n, ringSegs, false); // side walls between the n stations
|
|
34
|
+
// end caps fanned from each end's path-center (outward: bottom flipped, top not)
|
|
35
|
+
fanCap(V, Tr, 0, ringSegs, [pathR, 0, z0], true);
|
|
36
|
+
fanCap(V, Tr, (n - 1) * ringSegs, ringSegs,
|
|
37
|
+
[pathR * Math.cos(sign * phiMax), pathR * Math.sin(sign * phiMax), z0 + c * phiMax], false);
|
|
44
38
|
|
|
45
|
-
|
|
46
|
-
mesh.merge();
|
|
47
|
-
const out = wasm.Manifold.ofMesh(mesh);
|
|
48
|
-
mesh.delete?.(); // input mesh is consumed by ofMesh; free it (caller tracks `out`)
|
|
49
|
-
return out;
|
|
39
|
+
return manifoldFromMesh(wasm, V, Tr);
|
|
50
40
|
}
|
|
@@ -15,6 +15,12 @@ export function finishKernel(k) {
|
|
|
15
15
|
return rawPrism(pts, h, opts);
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
+
const rawExtrude = k.extrude;
|
|
19
|
+
k.extrude = (profile, h, opts) => {
|
|
20
|
+
if ((opts?.scaleTop ?? 1) < 0) throw new Error("extrude: scaleTop must be ≥ 0");
|
|
21
|
+
return rawExtrude(profile, h, opts);
|
|
22
|
+
};
|
|
23
|
+
|
|
18
24
|
const rawRevolve = k.revolve;
|
|
19
25
|
k.revolve = (pts, opts) => {
|
|
20
26
|
for (const [r] of pts) if (r < 0) throw new Error("revolve: profile radius must be ≥ 0");
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
|
|
8
8
|
// Ops every backend kernel must implement.
|
|
9
9
|
export const KERNEL_OPS = [
|
|
10
|
-
"cylinder", "boredCylinder", "sphere", "box", "prism", "revolve",
|
|
11
|
-
"helixSweptTube", "union", "toSTEP",
|
|
10
|
+
"cylinder", "boredCylinder", "sphere", "box", "prism", "extrude", "revolve",
|
|
11
|
+
"loft", "sweep", "helixSweptTube", "union", "toSTEP",
|
|
12
12
|
];
|
|
13
13
|
|
|
14
14
|
// Backend-optional kernel ops: the Manifold cache brackets + WASM lifetime hooks.
|
|
@@ -70,6 +70,9 @@ export const OCCT_ONLY_OPS = ["fillet", "chamfer", "shell"];
|
|
|
70
70
|
* @property {(r:number) => Solid} sphere sphere centred at the origin
|
|
71
71
|
* @property {(min:number[], max:number[]) => Solid} box
|
|
72
72
|
* @property {(points2D:number[][], h:number, opts?:{twist?:number,scaleTop?:number}) => Solid} prism extrude polygon from z=0 (optional twist° + uniform top taper)
|
|
73
|
+
* @property {(profile:number[][]|{outer:number[][],holes?:number[][][]}, h:number, opts?:{twist?:number,scaleTop?:number}) => Solid} extrude extrude a polygon-with-holes region from z=0 in one op (bare array = outer only)
|
|
74
|
+
* @property {(rings:{polygon?:number[][],sides?:number,radius?:number,z:number,rotate?:number,scale?:number|number[]}[], opts?:{ruled?:boolean,closed?:boolean}) => Solid} loft stack polygon cross-sections (per-ring z/rotate/scale), ruled walls, capped ends
|
|
75
|
+
* @property {(profile2D:number[][], path3D:number[][], opts?:{closed?:boolean,cornerRadius?:number,ruled?:boolean,smooth?:boolean}) => Solid} sweep sweep a fixed 2-D profile along a 3-D polyline path (sharp mitered corners or cornerRadius fillets; capped ends; closed:true loops and smooth:true native B-rep are backend-specific)
|
|
73
76
|
* @property {(points2D:number[][], opts?:{degrees?:number}) => Solid} revolve revolve a lathe profile [[r,z],…] around Z
|
|
74
77
|
* @property {(o:{pathR:number,profileR:number,pitch:number,turns:number,z0:number,lefthand:boolean}) => Solid} helixSweptTube
|
|
75
78
|
* @property {(solids:Solid[]) => Solid} union
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Backend-shared loft support. resolveRings() validates the declarative ring specs and
|
|
2
|
+
// applies each ring's in-plane transform ONCE, so both backends build the identical set
|
|
3
|
+
// of placed cross-sections (Manifold hand-meshes them; OCCT turns each into a wire and
|
|
4
|
+
// calls native loft). loftMesh() is the Manifold path — the helix-tube ring recipe
|
|
5
|
+
// generalized to arbitrary polygon rings via the mesh-build.js helpers.
|
|
6
|
+
import { regularPolygon } from "./polygon.js";
|
|
7
|
+
import { isArcContour } from "./profile.js";
|
|
8
|
+
import { sideQuads, fanCap, manifoldFromMesh, reverseWinding } from "./mesh-build.js";
|
|
9
|
+
|
|
10
|
+
// A ring: { polygon:[[x,y],…] | (sides,radius), z, rotate?:deg, scale?:number|[sx,sy] }.
|
|
11
|
+
// Returns [{ pts2d:[[x,y],…], z }] with scale-then-rotate(Z) baked into pts2d. Throws
|
|
12
|
+
// on malformed input and on rings whose vertex counts differ (straight quad stitching
|
|
13
|
+
// needs a shared N — no re-sampling), so an LLM gets a loud, specific error.
|
|
14
|
+
export function resolveRings(rings) {
|
|
15
|
+
if (!Array.isArray(rings) || rings.length < 2)
|
|
16
|
+
throw new Error("loft: rings must be an array of at least 2 rings");
|
|
17
|
+
const out = rings.map((r, i) => {
|
|
18
|
+
if (!r || typeof r !== "object") throw new Error(`loft: ring ${i} must be an object { polygon|sides+radius, z }`);
|
|
19
|
+
if (!Number.isFinite(r.z)) throw new Error(`loft: ring ${i} needs a finite z`);
|
|
20
|
+
let pts = r.polygon;
|
|
21
|
+
if (isArcContour(pts)) // arc profiles (roundedProfile) are extrude/prism-only in v1
|
|
22
|
+
throw new Error(`loft: ring ${i} is an arc profile — loft rings must be a point array (arc rings are not supported yet; use prism/extrude for true STEP arcs)`);
|
|
23
|
+
if (!pts && Number.isFinite(r.sides) && Number.isFinite(r.radius)) pts = regularPolygon(r.sides, r.radius);
|
|
24
|
+
if (!Array.isArray(pts) || pts.length < 3)
|
|
25
|
+
throw new Error(`loft: ring ${i} needs polygon:[[x,y],…] (≥3 points) or sides+radius shorthand`);
|
|
26
|
+
const s = r.scale ?? 1;
|
|
27
|
+
const [sx, sy] = Array.isArray(s) ? s : [s, s];
|
|
28
|
+
const rot = ((r.rotate ?? 0) * Math.PI) / 180, cos = Math.cos(rot), sin = Math.sin(rot);
|
|
29
|
+
const pts2d = pts.map(([x, y]) => {
|
|
30
|
+
const X = x * sx, Y = y * sy; // scale in-plane, then rotate about Z
|
|
31
|
+
return [X * cos - Y * sin, X * sin + Y * cos];
|
|
32
|
+
});
|
|
33
|
+
return { pts2d, z: r.z };
|
|
34
|
+
});
|
|
35
|
+
const N = out[0].pts2d.length;
|
|
36
|
+
for (const r of out) if (r.pts2d.length !== N)
|
|
37
|
+
throw new Error("loft: every ring must have the same number of points (straight quad stitching, no re-sampling)");
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const centroid = (pts2d, z) => {
|
|
42
|
+
let cx = 0, cy = 0;
|
|
43
|
+
for (const [x, y] of pts2d) { cx += x; cy += y; }
|
|
44
|
+
return [cx / pts2d.length, cy / pts2d.length, z];
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Manifold path: stack the resolved rings, stitch side quads, and (unless closed) fan a
|
|
48
|
+
// cap over each end from its centroid. Caps assume star-convex-from-centroid rings, which
|
|
49
|
+
// covers regular n-gons and every polygon.js helper. Returns a raw Manifold (caller T()s).
|
|
50
|
+
//
|
|
51
|
+
// NOTE on `ruled`: the OCCT backend's native loft honours `ruled:false` (a smooth C2 blend
|
|
52
|
+
// between rings); this hand-mesh always emits faceted straight walls between consecutive
|
|
53
|
+
// rings and ignores `ruled`. So `ruled:false` previews faceted here and only exports the
|
|
54
|
+
// true smooth surface via the OCCT (STEP) path — documented on the loft doc row.
|
|
55
|
+
export function loftMesh(wasm, rings, { closed = false } = {}) {
|
|
56
|
+
const resolved = resolveRings(rings);
|
|
57
|
+
const N = resolved[0].pts2d.length;
|
|
58
|
+
const V = [];
|
|
59
|
+
for (const { pts2d, z } of resolved) for (const [x, y] of pts2d) V.push(x, y, z);
|
|
60
|
+
const Tr = [];
|
|
61
|
+
sideQuads(Tr, resolved.length, N, closed);
|
|
62
|
+
if (!closed) {
|
|
63
|
+
const first = resolved[0], lastR = resolved[resolved.length - 1];
|
|
64
|
+
fanCap(V, Tr, 0, N, centroid(first.pts2d, first.z), true); // bottom faces −Z
|
|
65
|
+
fanCap(V, Tr, (resolved.length - 1) * N, N, centroid(lastR.pts2d, lastR.z), false); // top faces +Z
|
|
66
|
+
}
|
|
67
|
+
let out = manifoldFromMesh(wasm, V, Tr);
|
|
68
|
+
// The mesh helpers wind for CCW rings ordered along +Z. CW-wound rings or descending-z
|
|
69
|
+
// rings invert every face, yielding a negative-volume solid that ofMesh imports without
|
|
70
|
+
// complaint but that behaves BACKWARDS under booleans (cut adds material). Detect the
|
|
71
|
+
// inversion and rebuild with reversed winding so loft is winding/z-order agnostic — this
|
|
72
|
+
// matches OCCT, whose native loft always returns a positively-oriented solid.
|
|
73
|
+
if (out.volume() < 0) {
|
|
74
|
+
out.delete?.();
|
|
75
|
+
reverseWinding(Tr);
|
|
76
|
+
out = manifoldFromMesh(wasm, V, Tr);
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { helixTube } from "./helix-tube.js";
|
|
2
|
+
import { loftMesh } from "./loft.js";
|
|
3
|
+
import { sweepMesh } from "./sweep.js";
|
|
4
|
+
import { tessellateContour, tessellateProfile } from "./profile.js";
|
|
2
5
|
import { h } from "./solid-hash.js";
|
|
3
6
|
import { createSolidCache } from "./solid-cache.js";
|
|
4
7
|
import { addSugar } from "./solid-sugar.js";
|
|
@@ -140,13 +143,31 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
140
143
|
},
|
|
141
144
|
prism: (pts, height, { twist = 0, scaleTop = 1 } = {}) =>
|
|
142
145
|
cached(h("prism", pts, height, twist, scaleTop, segs), () => {
|
|
143
|
-
const cs = T(CrossSection.ofPolygons([pts]));
|
|
146
|
+
const cs = T(CrossSection.ofPolygons([tessellateContour(pts, segs)]));
|
|
144
147
|
if (twist === 0 && scaleTop === 1) return T(cs.extrude(height));
|
|
145
148
|
const nDiv = Math.max(1, Math.ceil(Math.abs(twist) / 5));
|
|
146
149
|
// Manifold's extrude scaleTop is a Vec2 — a scalar is NOT broadcast (it scales
|
|
147
150
|
// X and drives Y to 0, squishing the top to a line). Broadcast for a uniform taper.
|
|
148
151
|
return T(cs.extrude(height, nDiv, twist, [scaleTop, scaleTop]));
|
|
149
152
|
}),
|
|
153
|
+
// Polygon-with-holes extrude in one op: even/odd fill turns the extra contours into
|
|
154
|
+
// holes regardless of their winding (outer + holes, no per-hole boolean cut).
|
|
155
|
+
extrude: (profile, height, { twist = 0, scaleTop = 1 } = {}) =>
|
|
156
|
+
cached(h("extrude", profile, height, twist, scaleTop, segs), () => {
|
|
157
|
+
const { outer, holes } = tessellateProfile(profile, segs);
|
|
158
|
+
const cs = T(CrossSection.ofPolygons([outer, ...holes], "EvenOdd"));
|
|
159
|
+
if (twist === 0 && scaleTop === 1) return T(cs.extrude(height));
|
|
160
|
+
const nDiv = Math.max(1, Math.ceil(Math.abs(twist) / 5));
|
|
161
|
+
return T(cs.extrude(height, nDiv, twist, [scaleTop, scaleTop]));
|
|
162
|
+
}),
|
|
163
|
+
// Ring loft: hand-meshed via the shared ring-mesh helpers (helix-tube recipe).
|
|
164
|
+
// Cached atomically; the hash folds every ring's points/z/rotate/scale and the opts.
|
|
165
|
+
loft: (rings, opts = {}) => cached(h("loft", rings, opts), () => T(loftMesh(wasm, rings, opts))),
|
|
166
|
+
// Sweep a fixed 2-D profile along a 3-D polyline: hand-meshed from the shared station
|
|
167
|
+
// list (sweep.js), so it agrees with OCCT's ruled loft of the same stations by
|
|
168
|
+
// construction. Cached atomically; the hash folds profile pts, path pts, and opts
|
|
169
|
+
// (closed/cornerRadius) so a shape change is a fresh node and an identical rebuild hits.
|
|
170
|
+
sweep: (profile, path, opts = {}) => cached(h("sweep", profile, path, opts), () => T(sweepMesh(wasm, profile, path, opts))),
|
|
150
171
|
helixSweptTube: (o) => cached(h("helixSweptTube", o, tube), () => T(helixTube(wasm, { ...o, ...tube }))),
|
|
151
172
|
revolve: (pts, { degrees = 360 } = {}) =>
|
|
152
173
|
cached(h("revolve", pts, degrees, segs), () => T(Manifold.revolve([pts], segs, degrees))),
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Shared triangle-mesh assembly for ring-based Manifold primitives. Both helix-tube
|
|
2
|
+
// (a circular profile swept up a helix) and loft (stacked polygon cross-sections)
|
|
3
|
+
// build the same way: N-vertex rings stacked in order, stitched with side quads and
|
|
4
|
+
// closed with fan caps, then imported via Manifold.ofMesh. The winding convention is
|
|
5
|
+
// CCW = outward (getting it wrong makes ofMesh throw or import an inverted solid), so
|
|
6
|
+
// these helpers own the winding once for every ring-mesh primitive.
|
|
7
|
+
|
|
8
|
+
// Stitch side walls between consecutive rings. `V` already holds ringCount rings of
|
|
9
|
+
// ringSegs vertices each, ring i occupying indices [i*ringSegs, (i+1)*ringSegs). When
|
|
10
|
+
// `closed` also stitches the last ring back to the first (a tube/loop with no caps).
|
|
11
|
+
// Winding assumes each ring is CCW viewed from +Z and rings are ordered along +Z.
|
|
12
|
+
export function sideQuads(Tr, ringCount, ringSegs, closed = false) {
|
|
13
|
+
const last = closed ? ringCount : ringCount - 1;
|
|
14
|
+
for (let i = 0; i < last; i++) {
|
|
15
|
+
const i0 = i * ringSegs, i1 = ((i + 1) % ringCount) * ringSegs;
|
|
16
|
+
for (let j = 0; j < ringSegs; j++) {
|
|
17
|
+
const a = i0 + j, b = i0 + (j + 1) % ringSegs;
|
|
18
|
+
const cc = i1 + j, dd = i1 + (j + 1) % ringSegs;
|
|
19
|
+
Tr.push(a, dd, cc, a, b, dd);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Add a triangle fan closing one ring around a center point (pushed as a new vertex).
|
|
25
|
+
// `flip` reverses the winding: use flip=true for a bottom cap (faces −Z) and flip=false
|
|
26
|
+
// for a top cap (faces +Z), so both caps point outward. The center should lie inside the
|
|
27
|
+
// ring's polygon (its centroid), so the fan is valid for convex / star-convex rings.
|
|
28
|
+
export function fanCap(V, Tr, ringStart, ringSegs, center, flip) {
|
|
29
|
+
const c = V.length / 3;
|
|
30
|
+
V.push(center[0], center[1], center[2]);
|
|
31
|
+
for (let j = 0; j < ringSegs; j++) {
|
|
32
|
+
const a = ringStart + j, b = ringStart + (j + 1) % ringSegs;
|
|
33
|
+
if (flip) Tr.push(c, b, a); else Tr.push(c, a, b);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Reverse the winding of every triangle in `Tr` in place (swap the 2nd and 3rd index of
|
|
38
|
+
// each tri). Flips which way all faces point, i.e. turns an inward-facing (negative-volume)
|
|
39
|
+
// mesh into an outward-facing one. Used to make loft winding/z-order agnostic.
|
|
40
|
+
export function reverseWinding(Tr) {
|
|
41
|
+
for (let t = 0; t < Tr.length; t += 3) { const tmp = Tr[t + 1]; Tr[t + 1] = Tr[t + 2]; Tr[t + 2] = tmp; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Import a flat vertex array + triangle indices as a watertight Manifold. merge() welds
|
|
45
|
+
// coincident vertices so ofMesh sees a closed manifold; ofMesh consumes the mesh handle,
|
|
46
|
+
// so free it here and let the caller track the returned Manifold.
|
|
47
|
+
export function manifoldFromMesh(wasm, V, Tr) {
|
|
48
|
+
const mesh = new wasm.Mesh({ numProp: 3, vertProperties: Float32Array.from(V), triVerts: Uint32Array.from(Tr) });
|
|
49
|
+
mesh.merge();
|
|
50
|
+
const out = wasm.Manifold.ofMesh(mesh);
|
|
51
|
+
mesh.delete?.(); // input mesh is consumed by ofMesh; free it (caller tracks `out`)
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
@@ -7,11 +7,14 @@ import { addSugar } from "./solid-sugar.js";
|
|
|
7
7
|
import { finishKernel } from "./kernel-front.js";
|
|
8
8
|
import { createOcctRepair } from "./occt-repair.js";
|
|
9
9
|
import { classifyFaceGroups } from "./feature-attribution.js";
|
|
10
|
+
import { resolveRings } from "./loft.js";
|
|
11
|
+
import { resolveSweepStations } from "./sweep.js";
|
|
12
|
+
import { normalizeProfile } from "./profile.js";
|
|
10
13
|
const MESH = { preview: { tolerance: 0.1, angularTolerance: 0.5 }, print: { tolerance: 0.01, angularTolerance: 0.1 } };
|
|
11
14
|
|
|
12
15
|
export function createOcctKernel(replicad) {
|
|
13
16
|
const { makeCylinder, makeBox, makeCircle, makeHelix, assembleWire, genericSweep,
|
|
14
|
-
makeCompound, loft, draw, exportSTEP, measureVolume, makeSphere } = replicad;
|
|
17
|
+
makeCompound, loft, draw, exportSTEP, measureVolume, makeSphere, makeLine, Plane } = replicad;
|
|
15
18
|
|
|
16
19
|
// Fillet/chamfer/shell failure recovery (skip-on-failure, chamfer binary search) —
|
|
17
20
|
// see occt-repair.js for the policies and why they differ per op.
|
|
@@ -83,11 +86,25 @@ export function createOcctKernel(replicad) {
|
|
|
83
86
|
return wrap(loft([w1, w2]));
|
|
84
87
|
};
|
|
85
88
|
|
|
89
|
+
// Draw a closed Drawing from a Contour: a legacy 2-D point list (all straight edges,
|
|
90
|
+
// the former polyDrawing) OR an ArcContour whose { to, via } segments become true
|
|
91
|
+
// OCCT arc edges via threePointsArcTo — so a rounded corner survives to STEP as a
|
|
92
|
+
// real CIRCLE B-rep entity, not a fan of LINEs. close() joins the last point back to
|
|
93
|
+
// the start with a straight edge (mirrors the implied ArcContour closure).
|
|
94
|
+
const contourDrawing = (contour) => {
|
|
95
|
+
if (Array.isArray(contour)) {
|
|
96
|
+
let pen = draw(contour[0]);
|
|
97
|
+
for (let i = 1; i < contour.length; i++) pen = pen.lineTo(contour[i]);
|
|
98
|
+
return pen.close();
|
|
99
|
+
}
|
|
100
|
+
let pen = draw(contour.start);
|
|
101
|
+
for (const seg of contour.segments) pen = seg.via ? pen.threePointsArcTo(seg.to, seg.via) : pen.lineTo(seg.to);
|
|
102
|
+
return pen.close();
|
|
103
|
+
};
|
|
104
|
+
|
|
86
105
|
// extrude a 2-D polygon from z=0 (arguments validated by the kernel front)
|
|
87
106
|
const prism = (pts, h, { twist = 0, scaleTop = 1 } = {}) => {
|
|
88
|
-
|
|
89
|
-
for (let i = 1; i < pts.length; i++) pen = pen.lineTo(pts[i]);
|
|
90
|
-
const sketch = pen.close().sketchOnPlane("XY");
|
|
107
|
+
const sketch = contourDrawing(pts).sketchOnPlane("XY");
|
|
91
108
|
if (twist === 0 && scaleTop === 1) return wrap(sketch.extrude(h));
|
|
92
109
|
const cfg = {};
|
|
93
110
|
if (twist !== 0) cfg.twistAngle = twist;
|
|
@@ -96,11 +113,54 @@ export function createOcctKernel(replicad) {
|
|
|
96
113
|
};
|
|
97
114
|
|
|
98
115
|
// revolve a lathe profile [[r,z],…] around the Z axis (degrees defaults to 360)
|
|
99
|
-
const revolve = (pts, { degrees = 360 } = {}) =>
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
116
|
+
const revolve = (pts, { degrees = 360 } = {}) =>
|
|
117
|
+
wrap(contourDrawing(pts).sketchOnPlane("XZ").revolve([0, 0, 1], { angle: degrees }));
|
|
118
|
+
|
|
119
|
+
// extrude a polygon-with-holes region from z=0: cut each hole Drawing out of the outer
|
|
120
|
+
// Drawing (winding-agnostic 2-D boolean), sketch it, then extrude (twist/taper via cfg).
|
|
121
|
+
const extrude = (profile, h, { twist = 0, scaleTop = 1 } = {}) => {
|
|
122
|
+
const { outer, holes } = normalizeProfile(profile);
|
|
123
|
+
let region = contourDrawing(outer);
|
|
124
|
+
for (const hole of holes) region = region.cut(contourDrawing(hole));
|
|
125
|
+
const sketch = region.sketchOnPlane("XY");
|
|
126
|
+
if (twist === 0 && scaleTop === 1) return wrap(sketch.extrude(h));
|
|
127
|
+
const cfg = {};
|
|
128
|
+
if (twist !== 0) cfg.twistAngle = twist;
|
|
129
|
+
if (scaleTop !== 1) cfg.extrusionProfile = { profile: "linear", endFactor: scaleTop };
|
|
130
|
+
return wrap(sketch.extrude(h, cfg));
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// ring loft: each ring becomes a closed polygon wire placed at its z (native loft closes
|
|
134
|
+
// the ends for closed wires). closed:true loops are Manifold-only (replicad loft is open).
|
|
135
|
+
const loftOp = (rings, { ruled = true, closed = false } = {}) => {
|
|
136
|
+
if (closed) throw new Error("loft: closed:true loops are only supported on the Manifold backend");
|
|
137
|
+
const wires = resolveRings(rings).map(({ pts2d, z }) => contourDrawing(pts2d).sketchOnPlane("XY", z).wire);
|
|
138
|
+
return wrap(loft(wires, { ruled }));
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// Sweep a 2-D profile along a 3-D polyline path. DEFAULT (§3A recipe): loft the SAME
|
|
142
|
+
// 3-D stations resolveSweepStations() hands the Manifold backend, as ruled polygon wires
|
|
143
|
+
// — so the two backends produce identical elbow geometry by construction (the loft-parity
|
|
144
|
+
// mechanism, not a tolerance). smooth:true switches to the OCCT-native genericSweep along
|
|
145
|
+
// a spline spine for an exact swept B-rep (STEP-exact / preview-faceted, parity waived —
|
|
146
|
+
// the same contract loft ships for ruled:false). closed:true loops are Manifold-only.
|
|
147
|
+
const sweepSmooth = (profile2D, path3D, cornerRadius) => {
|
|
148
|
+
const edges = [];
|
|
149
|
+
for (let i = 0; i < path3D.length - 1; i++) edges.push(makeLine(path3D[i], path3D[i + 1]));
|
|
150
|
+
const spine = assembleWire(edges);
|
|
151
|
+
const t0 = [path3D[1][0] - path3D[0][0], path3D[1][1] - path3D[0][1], path3D[1][2] - path3D[0][2]];
|
|
152
|
+
const profileWire = contourDrawing(profile2D).sketchOnPlane(new Plane(path3D[0], null, t0)).wire;
|
|
153
|
+
return wrap(genericSweep(profileWire, spine, {
|
|
154
|
+
transitionMode: cornerRadius > 0 ? "round" : "right", // sharp miter analogue vs rounded joint
|
|
155
|
+
forceProfileSpineOthogonality: true,
|
|
156
|
+
}));
|
|
157
|
+
};
|
|
158
|
+
const sweep = (profile2D, path3D, { closed = false, cornerRadius = 0, ruled = true, smooth = false } = {}) => {
|
|
159
|
+
if (closed) throw new Error("sweep: closed:true loops are only supported on the Manifold backend");
|
|
160
|
+
if (smooth) return sweepSmooth(profile2D, path3D, cornerRadius);
|
|
161
|
+
const { stations } = resolveSweepStations(profile2D, path3D, { closed, cornerRadius });
|
|
162
|
+
const wires = stations.map((ring) => assembleWire(ring.map((p, i) => makeLine(p, ring[(i + 1) % ring.length]))));
|
|
163
|
+
return wrap(loft(wires, { ruled }));
|
|
104
164
|
};
|
|
105
165
|
|
|
106
166
|
// circle profile swept along a helix (frenet)
|
|
@@ -114,7 +174,7 @@ export function createOcctKernel(replicad) {
|
|
|
114
174
|
|
|
115
175
|
return finishKernel({
|
|
116
176
|
cylinder, // boredCylinder: the kernel front's default composition is exactly right here
|
|
117
|
-
box: (min, max) => wrap(makeBox(min, max)), prism, revolve, helixSweptTube,
|
|
177
|
+
box: (min, max) => wrap(makeBox(min, max)), prism, extrude, revolve, loft: loftOp, sweep, helixSweptTube,
|
|
118
178
|
sphere: (r) => wrap(makeSphere(r)),
|
|
119
179
|
union: (solids) => wrap(
|
|
120
180
|
solids.map((s) => s._s).reduce((a, b) => a.fuse(b)),
|
|
@@ -97,6 +97,95 @@ export function ringSectorPolygon(innerR, outerR, arcDeg, segs = 32) {
|
|
|
97
97
|
return pts;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
// Per-corner rounding geometry, shared verbatim by filletPolygon (which tessellates it)
|
|
101
|
+
// and roundedProfile (which emits it as a symbolic arc). Given a corner p0→p1→p2 and a
|
|
102
|
+
// requested radius r, returns the incoming/outgoing tangent points `a`/`b`, the arc centre
|
|
103
|
+
// `c`, the clamped radius `rr`, the short sweep `dA`, and the incoming start angle `a0` —
|
|
104
|
+
// or null for a corner that must stay sharp (zero-length edge or a straight/180° corner).
|
|
105
|
+
// The per-corner clamp (t ≤ min(l0,l2)/2) keeps neighbouring arcs from overlapping.
|
|
106
|
+
// Extracting this means the two consumers can never diverge on clamping/winding.
|
|
107
|
+
export function cornerArc(p0, p1, p2, r) {
|
|
108
|
+
let v0 = [p0[0] - p1[0], p0[1] - p1[1]], v2 = [p2[0] - p1[0], p2[1] - p1[1]];
|
|
109
|
+
const l0 = Math.hypot(v0[0], v0[1]), l2 = Math.hypot(v2[0], v2[1]);
|
|
110
|
+
if (l0 < 1e-9 || l2 < 1e-9) return null; // zero-length edge → sharp
|
|
111
|
+
v0 = [v0[0] / l0, v0[1] / l0]; v2 = [v2[0] / l2, v2[1] / l2];
|
|
112
|
+
const cosA = Math.max(-1, Math.min(1, v0[0] * v2[0] + v0[1] * v2[1]));
|
|
113
|
+
const half = Math.acos(cosA) / 2; // half the corner's interior angle
|
|
114
|
+
let bis = [v0[0] + v2[0], v0[1] + v2[1]];
|
|
115
|
+
const bl = Math.hypot(bis[0], bis[1]);
|
|
116
|
+
if (half < 1e-6 || bl < 1e-9) return null; // straight (180°) corner → sharp
|
|
117
|
+
bis = [bis[0] / bl, bis[1] / bl];
|
|
118
|
+
let rr = r, t = r / Math.tan(half); // tangent setback along each edge
|
|
119
|
+
const tmax = Math.min(l0, l2) / 2; // clamp: never past an edge midpoint
|
|
120
|
+
if (t > tmax) { t = tmax; rr = t * Math.tan(half); }
|
|
121
|
+
const a = [p1[0] + v0[0] * t, p1[1] + v0[1] * t]; // tangent point on the incoming edge
|
|
122
|
+
const b = [p1[0] + v2[0] * t, p1[1] + v2[1] * t]; // tangent point on the outgoing edge
|
|
123
|
+
const c = [p1[0] + bis[0] * (rr / Math.sin(half)), p1[1] + bis[1] * (rr / Math.sin(half))]; // arc center
|
|
124
|
+
const a0 = Math.atan2(a[1] - c[1], a[0] - c[0]);
|
|
125
|
+
let dA = Math.atan2(b[1] - c[1], b[0] - c[0]) - a0; // sweep the SHORT arc from a to b
|
|
126
|
+
while (dA <= -Math.PI) dA += 2 * Math.PI;
|
|
127
|
+
while (dA > Math.PI) dA -= 2 * Math.PI;
|
|
128
|
+
return { a, b, c, rr, dA, a0 };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Round every corner of a CCW polygon: each vertex is replaced by a tangent circular
|
|
132
|
+
// arc of radius r, tessellated with `segs` segments per corner (default 8, matching
|
|
133
|
+
// roundedRectPolygon). Returns a plain [[x,y],…] point list usable by prism/extrude/loft
|
|
134
|
+
// on BOTH kernels by construction. Corners are CLAMPED per-corner: r is reduced so an
|
|
135
|
+
// arc's tangent points never pass the midpoint of either adjacent edge, so neighbouring
|
|
136
|
+
// rounded corners can never overlap (pass a very large r to fully round every corner).
|
|
137
|
+
// Intended for convex CCW outlines (brackets, gussets, pads, knob/star profiles); a
|
|
138
|
+
// reflex corner is still rounded but its arc is placed on the angle bisector.
|
|
139
|
+
// NOTE: bakes each arc into `segs` straight facets, so STEP export of a filletPolygon
|
|
140
|
+
// part has faceted (LINE) corners; for mathematically-true CIRCLE corners in STEP use
|
|
141
|
+
// roundedProfile, which carries the arc symbolically to both backends.
|
|
142
|
+
export function filletPolygon(points, r, { segs = 8 } = {}) {
|
|
143
|
+
const n = points.length;
|
|
144
|
+
if (n < 3) throw new Error("filletPolygon: need at least 3 points");
|
|
145
|
+
if (!(r > 0)) throw new Error("filletPolygon: r must be > 0");
|
|
146
|
+
const out = [];
|
|
147
|
+
for (let i = 0; i < n; i++) {
|
|
148
|
+
const arc = cornerArc(points[(i - 1 + n) % n], points[i], points[(i + 1) % n], r);
|
|
149
|
+
if (!arc) { out.push([points[i][0], points[i][1]]); continue; } // sharp corner
|
|
150
|
+
const { c, rr, dA, a0 } = arc;
|
|
151
|
+
for (let s = 0; s <= segs; s++) {
|
|
152
|
+
const ang = a0 + dA * (s / segs);
|
|
153
|
+
out.push([c[0] + rr * Math.cos(ang), c[1] + rr * Math.sin(ang)]);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Arc-aware sibling of filletPolygon: rounds the corners of a CCW polygon with the SAME
|
|
160
|
+
// tangent/centre/sweep math (via cornerArc), but instead of tessellating each arc into
|
|
161
|
+
// line facets it emits a canonical ArcContour { start, segments:[{to}|{to,via}], arc:true }
|
|
162
|
+
// that carries the arc SYMBOLICALLY. Feed it to prism/extrude (not loft yet): OCCT builds a
|
|
163
|
+
// true CIRCLE B-rep edge (exact STEP fillets) while Manifold tessellates the same spec, so
|
|
164
|
+
// both kernels agree by construction. `r` is a scalar (every corner) or a per-corner array
|
|
165
|
+
// r[] (length === points.length; a 0 or a degenerate corner stays sharp — a plain line).
|
|
166
|
+
export function roundedProfile(points, r) {
|
|
167
|
+
const n = points.length;
|
|
168
|
+
if (n < 3) throw new Error("roundedProfile: need at least 3 points");
|
|
169
|
+
const radii = Array.isArray(r) ? r : null;
|
|
170
|
+
if (radii && radii.length !== n)
|
|
171
|
+
throw new Error("roundedProfile: r[] length must match points length");
|
|
172
|
+
if (!radii && !(r >= 0)) throw new Error("roundedProfile: r must be ≥ 0 (or a per-corner r[]); 0 keeps every corner sharp");
|
|
173
|
+
const segments = [];
|
|
174
|
+
let start = null;
|
|
175
|
+
const lineTo = (p) => { if (start === null) start = [p[0], p[1]]; else segments.push({ to: [p[0], p[1]] }); };
|
|
176
|
+
for (let i = 0; i < n; i++) {
|
|
177
|
+
const p1 = points[i];
|
|
178
|
+
const ri = radii ? radii[i] : r;
|
|
179
|
+
const arc = ri > 0 ? cornerArc(points[(i - 1 + n) % n], p1, points[(i + 1) % n], ri) : null;
|
|
180
|
+
if (!arc) { lineTo(p1); continue; } // sharp / degenerate corner → plain vertex
|
|
181
|
+
const { a, b, c, rr, dA, a0 } = arc;
|
|
182
|
+
lineTo(a); // straight run into the incoming tangent point
|
|
183
|
+
const mid = a0 + dA / 2; // arc midpoint (three-point via — sign/winding-free)
|
|
184
|
+
segments.push({ to: [b[0], b[1]], via: [c[0] + rr * Math.cos(mid), c[1] + rr * Math.sin(mid)] });
|
|
185
|
+
}
|
|
186
|
+
return { start, segments, arc: true };
|
|
187
|
+
}
|
|
188
|
+
|
|
100
189
|
const PATTERN_AXIS = { X: [1, 0, 0], Y: [0, 1, 0], Z: [0, 0, 1] };
|
|
101
190
|
|
|
102
191
|
// `count` copies of `solid` translated by i*step ([dx,dy,dz]) for i in 0..count-1.
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Backend-shared 2-D region normalization + tessellation for extrude()/prism(). A contour
|
|
2
|
+
// is EITHER a bare points array (legacy, all straight edges) OR a canonical ArcContour
|
|
3
|
+
// { start:[x,y], segments:[{to}|{to,via}], arc:true } carrying true circular arcs (from
|
|
4
|
+
// roundedProfile). normalizeProfile validates the polymorphic { outer, holes } envelope
|
|
5
|
+
// (bare array = outer only), preserving each contour's shape; tessellateProfile turns the
|
|
6
|
+
// arcs into point rings for the Manifold (mesh) path. The OCCT path consumes the same
|
|
7
|
+
// ArcContour directly (contourDrawing → threePointsArcTo) for true CIRCLE B-rep edges.
|
|
8
|
+
// Legacy point-array contours take the exact former path byte-for-byte — no cache-busting.
|
|
9
|
+
|
|
10
|
+
// An ArcContour is a non-array object carrying arcs symbolically.
|
|
11
|
+
export function isArcContour(c) {
|
|
12
|
+
return !!c && typeof c === "object" && !Array.isArray(c) && (c.arc === true || Array.isArray(c.segments));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function validateContour(c, role) {
|
|
16
|
+
if (isArcContour(c)) {
|
|
17
|
+
if (!Array.isArray(c.start) || c.start.length < 2)
|
|
18
|
+
throw new Error(`extrude: ${role} arc contour needs a start [x,y]`);
|
|
19
|
+
if (!Array.isArray(c.segments) || c.segments.length < 1)
|
|
20
|
+
throw new Error(`extrude: ${role} arc contour needs ≥1 segment`);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (!Array.isArray(c) || c.length < 3) throw new Error(`extrude: ${role} needs ≥3 points`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function normalizeProfile(profile) {
|
|
27
|
+
let outer, holes;
|
|
28
|
+
if (Array.isArray(profile) || isArcContour(profile)) { outer = profile; holes = []; }
|
|
29
|
+
else if (profile && typeof profile === "object") { outer = profile.outer; holes = profile.holes ?? []; }
|
|
30
|
+
else throw new Error("extrude: profile must be [[x,y],…], an arc contour, or { outer, holes? }");
|
|
31
|
+
// Preserve the historical, test-pinned wording for the legacy point-array path.
|
|
32
|
+
if (isArcContour(outer)) validateContour(outer, "outer contour");
|
|
33
|
+
else if (!Array.isArray(outer) || outer.length < 3) throw new Error("extrude: outer contour needs ≥3 points");
|
|
34
|
+
if (!Array.isArray(holes)) throw new Error("extrude: holes must be an array of contours");
|
|
35
|
+
for (const hole of holes) {
|
|
36
|
+
if (isArcContour(hole)) validateContour(hole, "hole arc contour");
|
|
37
|
+
else if (!Array.isArray(hole) || hole.length < 3) throw new Error("extrude: each hole needs ≥3 points");
|
|
38
|
+
}
|
|
39
|
+
return { outer, holes };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Sample the circular arc through (p0, via, p1) — the three-point form roundedProfile
|
|
43
|
+
// emits — into a point list p1…pN (EXCLUDING the start p0, which the ring already holds;
|
|
44
|
+
// the last point is exactly p1). The circle is recovered from the circumcircle of the
|
|
45
|
+
// three points; the sweep direction is the one whose arc actually passes through `via`
|
|
46
|
+
// (sign-free, winding-free). Facet count scales with the sweep's fraction of the kernel's
|
|
47
|
+
// full-circle resolution `segs`, matching the piePolygon/circleProfile convention, so an
|
|
48
|
+
// arc and a circleProfile of equal radius facet identically. A degenerate (collinear)
|
|
49
|
+
// triple falls back to a single straight segment to p1 — the same "plain line" the OCCT
|
|
50
|
+
// side gets when roundedProfile emits no `via`.
|
|
51
|
+
export function sampleArc(p0, via, p1, segs) {
|
|
52
|
+
const [ax, ay] = p0, [bx, by] = via, [cx, cy] = p1;
|
|
53
|
+
const d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
|
|
54
|
+
if (Math.abs(d) < 1e-12) return [[cx, cy]]; // collinear → straight line
|
|
55
|
+
const sa = ax * ax + ay * ay, sb = bx * bx + by * by, sc = cx * cx + cy * cy;
|
|
56
|
+
const ux = (sa * (by - cy) + sb * (cy - ay) + sc * (ay - by)) / d;
|
|
57
|
+
const uy = (sa * (cx - bx) + sb * (ax - cx) + sc * (bx - ax)) / d;
|
|
58
|
+
const rr = Math.hypot(ax - ux, ay - uy);
|
|
59
|
+
const a0 = Math.atan2(ay - uy, ax - ux);
|
|
60
|
+
const av = Math.atan2(by - uy, bx - ux);
|
|
61
|
+
const a1 = Math.atan2(cy - uy, cx - ux);
|
|
62
|
+
const twoPi = 2 * Math.PI;
|
|
63
|
+
const ccw = (x) => { let v = x % twoPi; if (v < 0) v += twoPi; return v; };
|
|
64
|
+
const dCCW = ccw(a1 - a0), vCCW = ccw(av - a0);
|
|
65
|
+
const dA = vCCW <= dCCW ? dCCW : dCCW - twoPi; // pick the sweep containing `via`
|
|
66
|
+
const steps = Math.max(2, Math.ceil((segs * Math.abs(dA)) / twoPi));
|
|
67
|
+
const out = [];
|
|
68
|
+
for (let s = 1; s <= steps; s++) {
|
|
69
|
+
const ang = a0 + dA * (s / steps);
|
|
70
|
+
out.push([ux + rr * Math.cos(ang), uy + rr * Math.sin(ang)]);
|
|
71
|
+
}
|
|
72
|
+
out[out.length - 1] = [cx, cy]; // pin the exact endpoint
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Tessellate a single contour into a CCW point ring. A legacy array is returned unchanged
|
|
77
|
+
// (identical to the former path); an ArcContour is walked start→segment→segment, lines
|
|
78
|
+
// pushing their `to` and arcs pushing their sampled points.
|
|
79
|
+
export function tessellateContour(contour, segs) {
|
|
80
|
+
if (Array.isArray(contour)) return contour;
|
|
81
|
+
const ring = [[contour.start[0], contour.start[1]]];
|
|
82
|
+
let prev = contour.start;
|
|
83
|
+
for (const seg of contour.segments) {
|
|
84
|
+
if (seg.via) for (const p of sampleArc(prev, seg.via, seg.to, segs)) ring.push(p);
|
|
85
|
+
else ring.push([seg.to[0], seg.to[1]]);
|
|
86
|
+
prev = seg.to;
|
|
87
|
+
}
|
|
88
|
+
return ring;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Normalize + tessellate a whole region to { outer:[[x,y],…], holes:[[[x,y],…],…] } of
|
|
92
|
+
// point rings, ready for CrossSection.ofPolygons on the Manifold path.
|
|
93
|
+
export function tessellateProfile(profile, segs) {
|
|
94
|
+
const { outer, holes } = normalizeProfile(profile);
|
|
95
|
+
return { outer: tessellateContour(outer, segs), holes: holes.map((hl) => tessellateContour(hl, segs)) };
|
|
96
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Backend-shared sweep support. resolveSweepStations() walks a 3-D polyline path with a
|
|
2
|
+
// rotation-minimizing frame (parallel transport, specialised to piecewise-linear paths:
|
|
3
|
+
// the frame only ever rotates ACROSS a vertex, by the minimal rotation carrying the
|
|
4
|
+
// incoming tangent onto the outgoing one) and places the 2-D profile at a fixed, shared
|
|
5
|
+
// set of 3-D cross-section stations. BOTH backends build from that SAME station list —
|
|
6
|
+
// Manifold hand-meshes it (sweepMesh below, the loft/helix-tube recipe via mesh-build.js),
|
|
7
|
+
// OCCT lofts the same rings ruled (occt-backend.js). So the elbow shape agrees BY
|
|
8
|
+
// CONSTRUCTION, not by tolerance — the same parity mechanism loft's resolveRings uses.
|
|
9
|
+
//
|
|
10
|
+
// Corners: cornerRadius==0 → a SHARP MITER (one station per vertex, in the bisecting
|
|
11
|
+
// plane, stretched by 1/cos(turn/2) so straight walls meet flush). cornerRadius>0 →
|
|
12
|
+
// a tangent circular ARC FAN (setback clamped like filletPolygon). Fold conditions
|
|
13
|
+
// (profile too wide for a bend; 180° reversal) throw up front — the volume+bbox oracle
|
|
14
|
+
// would ship a fold silently otherwise.
|
|
15
|
+
import { sideQuads, fanCap, manifoldFromMesh, reverseWinding } from "./mesh-build.js";
|
|
16
|
+
|
|
17
|
+
const EPS = 1e-9;
|
|
18
|
+
const Z = [0, 0, 1], X = [1, 0, 0];
|
|
19
|
+
// Corner-arc station density, in degrees of turn per station. A shared CONSTANT (not a
|
|
20
|
+
// backend/quality value) so both backends subdivide a cornerRadius arc identically →
|
|
21
|
+
// identical stations → parity by construction.
|
|
22
|
+
const ARC_STEP_DEG = 12;
|
|
23
|
+
|
|
24
|
+
const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
|
25
|
+
const add = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
|
|
26
|
+
const scl = (a, s) => [a[0] * s, a[1] * s, a[2] * s];
|
|
27
|
+
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
|
28
|
+
const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
|
|
29
|
+
const vlen = (a) => Math.hypot(a[0], a[1], a[2]);
|
|
30
|
+
const norm = (a) => { const m = vlen(a) || 1; return [a[0] / m, a[1] / m, a[2] / m]; };
|
|
31
|
+
// Rotate vector v about unit axis k by angle ang (Rodrigues) — same math as
|
|
32
|
+
// manifold-backend.js axisAngleMat4, applied to a vector.
|
|
33
|
+
const rodrigues = (v, k, ang) => {
|
|
34
|
+
const c = Math.cos(ang), s = Math.sin(ang), kd = dot(k, v), kv = cross(k, v);
|
|
35
|
+
return [
|
|
36
|
+
v[0] * c + kv[0] * s + k[0] * kd * (1 - c),
|
|
37
|
+
v[1] * c + kv[1] * s + k[1] * kd * (1 - c),
|
|
38
|
+
v[2] * c + kv[2] * s + k[2] * kd * (1 - c),
|
|
39
|
+
];
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Place the 2-D profile into 3-D at `center` using frame axes (N=profile-x, B=profile-y).
|
|
43
|
+
const placeRing = (profile2D, center, N, B) =>
|
|
44
|
+
profile2D.map(([x, y]) => add(center, add(scl(N, x), scl(B, y))));
|
|
45
|
+
|
|
46
|
+
export function resolveSweepStations(profile2D, path3D, { closed = false, cornerRadius = 0 } = {}) {
|
|
47
|
+
if (!Array.isArray(profile2D) || profile2D.length < 3)
|
|
48
|
+
throw new Error("sweep: profile2D must be an array of ≥3 [x,y] points");
|
|
49
|
+
if (!Array.isArray(path3D) || path3D.length < 2)
|
|
50
|
+
throw new Error("sweep: path3D must be an array of ≥2 [x,y,z] points");
|
|
51
|
+
for (let i = 0; i < path3D.length; i++) {
|
|
52
|
+
const p = path3D[i];
|
|
53
|
+
if (!Array.isArray(p) || p.length < 3 || !Number.isFinite(p[0]) || !Number.isFinite(p[1]) || !Number.isFinite(p[2]))
|
|
54
|
+
throw new Error(`sweep: path3D[${i}] must be a finite [x,y,z]`);
|
|
55
|
+
}
|
|
56
|
+
const P = path3D, m = P.length;
|
|
57
|
+
const segCount = closed ? m : m - 1;
|
|
58
|
+
const dir = [], segLen = [];
|
|
59
|
+
for (let k = 0; k < segCount; k++) {
|
|
60
|
+
const d = sub(P[(k + 1) % m], P[k]), l = vlen(d);
|
|
61
|
+
if (l < EPS) throw new Error(`sweep: path segment ${k} has zero length (coincident points ${k} and ${(k + 1) % m})`);
|
|
62
|
+
dir.push(scl(d, 1 / l)); segLen.push(l);
|
|
63
|
+
}
|
|
64
|
+
// profile half-width = the farthest a profile vertex reaches from its own origin
|
|
65
|
+
let maxReach = 0;
|
|
66
|
+
for (const [x, y] of profile2D) maxReach = Math.max(maxReach, Math.hypot(x, y));
|
|
67
|
+
|
|
68
|
+
// Seed the frame ⟂ the tangent coming INTO the first processed station (reference-vector
|
|
69
|
+
// method; the ref pick avoids N collapsing when the path starts along Z).
|
|
70
|
+
const seedT = closed ? dir[segCount - 1] : dir[0];
|
|
71
|
+
const ref = Math.abs(dot(seedT, Z)) < 0.9 ? Z : X;
|
|
72
|
+
let N = norm(sub(ref, scl(seedT, dot(ref, seedT))));
|
|
73
|
+
let B = cross(seedT, N);
|
|
74
|
+
|
|
75
|
+
const stations = [];
|
|
76
|
+
|
|
77
|
+
// Emit station(s) for an interior vertex and advance (N,B) from ⟂tIn to ⟂tOut.
|
|
78
|
+
const corner = (center, tIn, tOut, vtx, lenIn, lenOut) => {
|
|
79
|
+
const axisRaw = cross(tIn, tOut), s = vlen(axisRaw);
|
|
80
|
+
const cdot = Math.max(-1, Math.min(1, dot(tIn, tOut)));
|
|
81
|
+
if (cdot < -1 + 1e-6)
|
|
82
|
+
throw new Error(`sweep: 180° reversal at vertex ${vtx} is ambiguous — insert an intermediate point or use cornerRadius`);
|
|
83
|
+
if (s < EPS) { stations.push(placeRing(profile2D, center, N, B)); return; } // collinear: no turn, frame unchanged
|
|
84
|
+
const axis = scl(axisRaw, 1 / s);
|
|
85
|
+
const theta = Math.atan2(s, cdot); // exterior turn angle
|
|
86
|
+
if (cornerRadius > 0) {
|
|
87
|
+
if (cornerRadius < maxReach)
|
|
88
|
+
throw new Error(`sweep: cornerRadius ${cornerRadius} < profile half-width ${maxReach.toFixed(3)} at vertex ${vtx} — the inner wall would fold; increase cornerRadius`);
|
|
89
|
+
const t = cornerRadius * Math.tan(theta / 2); // tangent setback along each leg (= r/tan(interiorHalf))
|
|
90
|
+
const tmax = Math.min(lenIn, lenOut) / 2;
|
|
91
|
+
if (t > tmax)
|
|
92
|
+
throw new Error(`sweep: cornerRadius too large for the bend at vertex ${vtx} (setback ${t.toFixed(3)} > half the shorter segment ${tmax.toFixed(3)}) — reduce cornerRadius or lengthen the segment`);
|
|
93
|
+
const a = sub(center, scl(tIn, t)); // arc start on the incoming leg
|
|
94
|
+
const arcCenter = add(center, scl(norm(sub(tOut, tIn)), cornerRadius / Math.cos(theta / 2)));
|
|
95
|
+
const va = sub(a, arcCenter);
|
|
96
|
+
const steps = Math.max(2, Math.ceil(((theta * 180) / Math.PI) / ARC_STEP_DEG));
|
|
97
|
+
for (let i = 0; i <= steps; i++) { // smooth arc: rotate frame with the tangent, no miter tilt
|
|
98
|
+
const ang = (theta * i) / steps;
|
|
99
|
+
stations.push(placeRing(profile2D, add(arcCenter, rodrigues(va, axis, ang)),
|
|
100
|
+
rodrigues(N, axis, ang), rodrigues(B, axis, ang)));
|
|
101
|
+
}
|
|
102
|
+
} else { // sharp miter: one station in the bisecting plane
|
|
103
|
+
if (maxReach * Math.tan(theta / 2) > 0.5 * Math.min(lenIn, lenOut))
|
|
104
|
+
throw new Error(`sweep: profile too wide for the bend at vertex ${vtx} (turn too sharp / segment too short) — increase cornerRadius or lengthen the segment`);
|
|
105
|
+
const Nh = rodrigues(N, axis, theta / 2), Bh = rodrigues(B, axis, theta / 2);
|
|
106
|
+
const mDir = rodrigues(tIn, axis, theta / 2); // ring-plane normal (average travel dir)
|
|
107
|
+
const u = norm(cross(axis, mDir)); // in-plane bend direction (stretch axis)
|
|
108
|
+
const cosh = Math.cos(theta / 2);
|
|
109
|
+
stations.push(profile2D.map(([x, y]) => {
|
|
110
|
+
const p = add(scl(Nh, x), scl(Bh, y)); // profile point in the miter plane (spanned by u, axis)
|
|
111
|
+
return add(center, add(scl(axis, dot(p, axis)), scl(u, dot(p, u) / cosh))); // stretch the u component
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
N = rodrigues(N, axis, theta); B = rodrigues(B, axis, theta); // advance frame to ⟂ tOut
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
if (closed) {
|
|
118
|
+
for (let k = 0; k < m; k++)
|
|
119
|
+
corner(P[k], dir[(k - 1 + m) % m], dir[k], k, segLen[(k - 1 + m) % m], segLen[k]);
|
|
120
|
+
} else {
|
|
121
|
+
stations.push(placeRing(profile2D, P[0], N, B)); // start cap ring ⟂ dir[0]
|
|
122
|
+
for (let k = 1; k <= m - 2; k++) corner(P[k], dir[k - 1], dir[k], k, segLen[k - 1], segLen[k]);
|
|
123
|
+
stations.push(placeRing(profile2D, P[m - 1], N, B)); // end cap ring ⟂ dir[last]
|
|
124
|
+
}
|
|
125
|
+
return { stations, closed };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const centroid = (ring) => {
|
|
129
|
+
let cx = 0, cy = 0, cz = 0;
|
|
130
|
+
for (const [x, y, z] of ring) { cx += x; cy += y; cz += z; }
|
|
131
|
+
return [cx / ring.length, cy / ring.length, cz / ring.length];
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Manifold path: stack the resolved stations, stitch side quads, and (unless closed) fan a
|
|
135
|
+
// cap over each end from its 3-D centroid. Winding self-heals via loft's signed-volume
|
|
136
|
+
// check so the sweep is winding/direction-agnostic. Returns a raw Manifold (caller T()s).
|
|
137
|
+
export function sweepMesh(wasm, profile2D, path3D, opts = {}) {
|
|
138
|
+
const { stations, closed } = resolveSweepStations(profile2D, path3D, opts);
|
|
139
|
+
const N = profile2D.length;
|
|
140
|
+
const V = [];
|
|
141
|
+
for (const ring of stations) for (const [x, y, z] of ring) V.push(x, y, z);
|
|
142
|
+
const Tr = [];
|
|
143
|
+
sideQuads(Tr, stations.length, N, closed);
|
|
144
|
+
if (!closed) {
|
|
145
|
+
fanCap(V, Tr, 0, N, centroid(stations[0]), true); // start cap faces backward
|
|
146
|
+
fanCap(V, Tr, (stations.length - 1) * N, N, centroid(stations[stations.length - 1]), false); // end faces forward
|
|
147
|
+
}
|
|
148
|
+
let out = manifoldFromMesh(wasm, V, Tr);
|
|
149
|
+
if (out.volume() < 0) { out.delete?.(); reverseWinding(Tr); out = manifoldFromMesh(wasm, V, Tr); }
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Example PartDefinition — the motivating showcase for k.loft(). Silhouette rings are
|
|
2
|
+
// stacked up a smooth base→waist→rim curve; each ring is a regular n-gon rotated by a
|
|
3
|
+
// running twist plus an alternating half-facet offset, so the facets zig-zag into a
|
|
4
|
+
// woven look. A second, wall-inset loft is cut from the body to hollow it (Manifold
|
|
5
|
+
// backend, so it stays fast — no OCCT). See docs/AUTHORING-PARTS.md for the conventions.
|
|
6
|
+
import { regularPolygon } from "partforge/geometry";
|
|
7
|
+
|
|
8
|
+
const RINGS = 28; // silhouette resolution (ring count up the height)
|
|
9
|
+
|
|
10
|
+
// Body radius at height fraction t (0..1): a quadratic Bézier through base/waist/rim.
|
|
11
|
+
const silhouette = (t, p) => { const a = 1 - t; return a * a * p.baseR + 2 * a * t * p.waistR + t * t * p.rimR; };
|
|
12
|
+
|
|
13
|
+
// Ring list for a wall at radial `inner` inset (offset along the face normal so the
|
|
14
|
+
// perpendicular wall stays == p.wall on every facet). inner=false → outer surface.
|
|
15
|
+
const vaseRings = (p, inner) => {
|
|
16
|
+
const inset = inner ? p.wall / Math.cos(Math.PI / p.facets) : 0;
|
|
17
|
+
const out = [];
|
|
18
|
+
for (let i = 0; i <= RINGS; i++) {
|
|
19
|
+
const t = i / RINGS;
|
|
20
|
+
const radius = Math.max(silhouette(t, p) - inset, 0.5);
|
|
21
|
+
const rotate = p.twist * t + (i % 2) * (180 / p.facets); // running twist + alternating half-facet
|
|
22
|
+
out.push({ sides: p.facets, radius, z: p.height * t, rotate });
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export default {
|
|
28
|
+
meta: { title: "Faceted Vase", units: "mm", background: 0x15181d },
|
|
29
|
+
parameters: [
|
|
30
|
+
{
|
|
31
|
+
id: "body",
|
|
32
|
+
title: "Body",
|
|
33
|
+
description: "A faceted, twisting vase built from stacked cross-sections (`k.loft`). " +
|
|
34
|
+
"Pick a preset, or open **Advanced** for exact dimensions. **Facets** and **Twist** are the styling; **Wall** decides whether it prints cleanly.",
|
|
35
|
+
presets: {
|
|
36
|
+
"Tulip vase": { height: 150, baseR: 35, waistR: 26, rimR: 40, facets: 5, twist: 40, wall: 2 },
|
|
37
|
+
"Barrel pot": { height: 90, baseR: 40, waistR: 44, rimR: 38, facets: 8, twist: 0, wall: 2.4 },
|
|
38
|
+
"Twist column": { height: 180, baseR: 30, waistR: 30, rimR: 30, facets: 6, twist: 120, wall: 2 },
|
|
39
|
+
},
|
|
40
|
+
advanced: [
|
|
41
|
+
{ key: "height", label: "Height", unit: "mm", min: 40, max: 220, step: 1, description: "Overall height along the axis." },
|
|
42
|
+
{ key: "baseR", label: "Base radius", unit: "mm", min: 15, max: 70, step: 1, description: "Across-corners radius at the foot." },
|
|
43
|
+
{ key: "waistR", label: "Waist radius", unit: "mm", min: 12, max: 80, step: 1, description: "Radius at mid-height — set below base+rim to pinch a waist, above to bulge a belly." },
|
|
44
|
+
{ key: "rimR", label: "Rim radius", unit: "mm", min: 12, max: 80, step: 1, description: "Across-corners radius at the mouth." },
|
|
45
|
+
{ key: "facets", label: "Facets", min: 3, max: 12, step: 1, description: "Sides of each cross-section. Low counts read crystalline; high counts approach smooth." },
|
|
46
|
+
{ key: "twist", label: "Twist", unit: "°", min: 0, max: 180, step: 5, description: "Total rotation of the facets from foot to rim, for a spiral." },
|
|
47
|
+
{ key: "wall", label: "Wall thickness", unit: "mm", min: 1, max: 5, step: 0.1, description: "Perpendicular wall thickness. The fdm-pla profile wants **≥ 1.2 mm**." },
|
|
48
|
+
{ key: "floor", label: "Floor thickness", unit: "mm", min: 1, max: 8, step: 0.5, hidden: true, description: "Internal: solid base thickness; hidden but drives the geometry." },
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
defaults: { height: 150, baseR: 35, waistR: 26, rimR: 40, facets: 5, twist: 40, wall: 2, floor: 3 },
|
|
53
|
+
parts: {
|
|
54
|
+
vase: {
|
|
55
|
+
label: "Vase", views: ["vase"], export: { name: "vase" },
|
|
56
|
+
build: (k, p) => {
|
|
57
|
+
const body = k.loft(vaseRings(p, false)).label("Faceted wall");
|
|
58
|
+
// Hollow it: an inset loft clipped to z ≥ floor (so the base stays solid), cut from the body.
|
|
59
|
+
const cavity = k.loft(vaseRings(p, true))
|
|
60
|
+
.intersect(k.box([-1e4, -1e4, p.floor], [1e4, 1e4, p.height + 10])).label("Cavity");
|
|
61
|
+
return body.cut(cavity);
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
views: { vase: { label: "Vase" } },
|
|
66
|
+
// Self-verification: opt into the FDM-PLA profile (bed-fit gate + min-wall warning) and
|
|
67
|
+
// pin the intent — an open vessel (no through-holes), fits the bed, no interpenetration.
|
|
68
|
+
verify: {
|
|
69
|
+
process: "fdm-pla",
|
|
70
|
+
expect: {
|
|
71
|
+
vase: { holes: 0, bbox: "<=[220,220,230]" },
|
|
72
|
+
_view: { overlaps: 0 },
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
};
|