partforge 0.20.1 → 0.22.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 +37 -7
- package/package.json +1 -1
- package/src/app-hull-sweep.js +9 -0
- package/src/framework/app.css +8 -0
- package/src/framework/controls.js +32 -2
- package/src/framework/cutaway-gizmo.js +6 -3
- package/src/framework/geometry/curve-fill.js +11 -6
- package/src/framework/geometry/hull.js +50 -0
- package/src/framework/geometry/kernel-front.js +20 -0
- package/src/framework/geometry/kernel.js +5 -2
- package/src/framework/geometry/manifold-backend.js +3 -2
- package/src/framework/geometry/occt-backend.js +3 -2
- package/src/framework/geometry/shape2d-sugar.js +15 -4
- package/src/hull-sweep-worker.js +3 -0
- package/src/parts/hull-sweep.js +70 -0
- package/src/parts/nameplate.js +5 -5
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -316,24 +316,36 @@ changing any of them is a fresh cache node while an identical rebuild is a hit.
|
|
|
316
316
|
`parameters` is an **array of sections**; the framework builds the panel from it and
|
|
317
317
|
binds each control to a key in `defaults`. Two section kinds:
|
|
318
318
|
|
|
319
|
-
**Preset +
|
|
319
|
+
**Preset + controls section:**
|
|
320
320
|
|
|
321
321
|
```js
|
|
322
322
|
{
|
|
323
323
|
id: "body",
|
|
324
324
|
title: "Body",
|
|
325
325
|
presets: { M3: { od: 8, bore: 3.4, h: 10 }, M5: { od: 12, bore: 5.4, h: 16 } }, // name → param overrides
|
|
326
|
-
advanced: [ //
|
|
326
|
+
advanced: [ // controls revealed under "Advanced"
|
|
327
327
|
{ key: "od", label: "Outer diameter", unit: "mm", min: 4, max: 40, step: 0.5 },
|
|
328
328
|
{ key: "bore", label: "Bore", unit: "mm", min: 1, max: 30, step: 0.1, control: "number" },
|
|
329
|
+
{ key: "title", label: "Title", control: "text" },
|
|
330
|
+
{ key: "label", label: "Label", control: "textarea" },
|
|
329
331
|
],
|
|
330
332
|
}
|
|
331
333
|
```
|
|
332
334
|
|
|
333
|
-
|
|
335
|
+
Numeric slider/feature controls show an **editable number box** beside them — drag the
|
|
334
336
|
slider or type an exact value (finer than `step` is allowed; typed values clamp to
|
|
335
|
-
`[min, max]`). Optional `control` per parameter
|
|
336
|
-
|
|
337
|
+
`[min, max]`). Optional `control` per parameter chooses the input:
|
|
338
|
+
|
|
339
|
+
- omit it (or use `"slider"`) for a slider + number box;
|
|
340
|
+
- `"number"` for a number box only (handy for precise or wide-range values);
|
|
341
|
+
- `"text"` for a single-line string field;
|
|
342
|
+
- `"textarea"` for a multiline string field whose line breaks are preserved.
|
|
343
|
+
|
|
344
|
+
Text fields update `params` live on every edit, so the existing rebuild loop previews
|
|
345
|
+
the new string immediately. Give every text key a string value in `defaults`; empty
|
|
346
|
+
strings are valid control values, while the part's build function decides whether its
|
|
347
|
+
geometry supports them. Editing any control in a preset section selects `Custom`, and
|
|
348
|
+
choosing a preset updates both numeric and text fields.
|
|
337
349
|
|
|
338
350
|
**Feature-toggle section** (checkbox enables a feature + reveals its sliders; `0` = off):
|
|
339
351
|
|
|
@@ -492,10 +504,15 @@ body = body.cutAll(circularPattern(hole, 8, { axis: "Z" })); // 8 bolt holes o
|
|
|
492
504
|
// Keyhole plate: union a disc onto a rect, punch a slot, extrude.
|
|
493
505
|
const plate = k.shape2d(roundedRectPolygon(40, 24, 4))
|
|
494
506
|
.union(circleProfile(8))
|
|
495
|
-
.cut(slotPolygon(16, 3))
|
|
496
|
-
k.extrude({ profile:
|
|
507
|
+
.cut(slotPolygon(16, 3))
|
|
508
|
+
.extrude({ h: 3 }); // sugar for k.extrude({ profile: …, h: 3 }); .revolve({ degrees }) too
|
|
497
509
|
```
|
|
498
510
|
|
|
511
|
+
A `Shape2D` also carries `.extrude({ h, twist?, scaleTop? })` and `.revolve({ degrees? })`
|
|
512
|
+
sugar (equivalent to the `k.extrude`/`k.revolve` forms), and `.regions()` — scission, which
|
|
513
|
+
returns each disjoint region as its own live `Shape2D` (vs `.toRegions()`, which returns raw
|
|
514
|
+
`{outer, holes}` data).
|
|
515
|
+
|
|
499
516
|
```js
|
|
500
517
|
// A 0.2 mm printer clearance around a bore, then a 2 mm wall inset:
|
|
501
518
|
const bore = k.shape2d(circleProfile(3)).offset(0.2); // looser
|
|
@@ -506,6 +523,19 @@ const wall = k.shape2d(outer).offset(-2, { corners: "sharp" }); // inset, mite
|
|
|
506
523
|
|
|
507
524
|
`Shape2D.offset(delta, {corners})` grows (`delta>0`) or insets (`delta<0`) a shape with round/chamfer/sharp corners — curve-preserving on OCCT, faceted at mesh LOD on Manifold; it throws if the offset collapses the shape. (For `derive()`/main-thread clearance math on plain point lists, use the pure `offsetPolygon` helper instead.)
|
|
508
525
|
|
|
526
|
+
## Convex hull
|
|
527
|
+
|
|
528
|
+
`k.hull([a, b, …])` wraps its inputs (Shape2Ds, curve contours, or point lists) in a
|
|
529
|
+
convex `Shape2D`. `k.hullChain([a, b, c, …])` sweeps the hull along an ordered sequence
|
|
530
|
+
(≥2 inputs) — the union of `hull([a,b])`, `hull([b,c])`, … — for capsules, rounded slots,
|
|
531
|
+
and organic tapers. Faceted (curved inputs facet at mesh LOD): the hull is a pure-JS
|
|
532
|
+
monotone-chain computation, never a native backend op.
|
|
533
|
+
|
|
534
|
+
```js
|
|
535
|
+
const capsule = k.hull([circleProfile(4, [0, 0]), circleProfile(4, [20, 0])]); // a stadium
|
|
536
|
+
const slot = k.hullChain([circleProfile(3, [0, 0]), circleProfile(3, [15, 0]), circleProfile(2, [25, 5])]);
|
|
537
|
+
```
|
|
538
|
+
|
|
509
539
|
## Text (`text2d`)
|
|
510
540
|
|
|
511
541
|
`k.text2d(string, { size, font?, align?, valign?, lineHeight?, tracking?, kerning? })` renders outline-font text as a `Shape2D` — a 2-D boolean you can compose with other shapes (union / cut / offset) and extrude into 3-D geometry.
|
package/package.json
CHANGED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import part from "./parts/hull-sweep.js";
|
|
2
|
+
import { mount } from "./framework/index.js";
|
|
3
|
+
|
|
4
|
+
// Dev example app for the hull-sweep demo (src/parts/hull-sweep.js). The
|
|
5
|
+
// `new Worker(new URL(...))` call must stay inline here so Vite bundles the worker.
|
|
6
|
+
mount(part, {
|
|
7
|
+
createWorker: (name) =>
|
|
8
|
+
new Worker(new URL("./hull-sweep-worker.js", import.meta.url), { type: "module", name }),
|
|
9
|
+
});
|
package/src/framework/app.css
CHANGED
|
@@ -81,6 +81,14 @@ select.preset {
|
|
|
81
81
|
.row .num::-webkit-outer-spin-button, .row .num::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
|
82
82
|
.row .num { -moz-appearance: textfield; }
|
|
83
83
|
.row .unit { font-family: var(--pf-mono); color: var(--pf-muted); font-size: 10px; }
|
|
84
|
+
.text-input {
|
|
85
|
+
display: block; width: 100%; font: 12px/1.4 var(--pf-mono);
|
|
86
|
+
background: var(--pf-input-bg); color: var(--pf-text-strong);
|
|
87
|
+
border: 1px solid var(--pf-border); border-radius: 6px; padding: 6px 8px;
|
|
88
|
+
}
|
|
89
|
+
textarea.text-input { min-height: 64px; resize: vertical; }
|
|
90
|
+
.text-input:focus { outline: none; border-color: var(--pf-accent);
|
|
91
|
+
box-shadow: 0 0 0 3px color-mix(in oklab, var(--pf-accent) 35%, transparent); }
|
|
84
92
|
|
|
85
93
|
/* crafted range slider — hairline track + CAD-blue handle (the panel's signature control) */
|
|
86
94
|
input[type="range"] { -webkit-appearance: none; appearance: none; width: 100%; height: 18px; margin: 0; background: transparent; cursor: pointer; }
|
|
@@ -113,6 +113,8 @@ function el(tag, className, text) {
|
|
|
113
113
|
// One parameter control bound to params[def.key]. `def.control`:
|
|
114
114
|
// "slider" (default) — range slider + an editable number box (drag OR type)
|
|
115
115
|
// "number" — number box only (no slider)
|
|
116
|
+
// "text" — single-line text field
|
|
117
|
+
// "textarea" — multiline text field
|
|
116
118
|
// The box accepts exact values (finer than `step`); typed values clamp to
|
|
117
119
|
// [min, max] on commit (blur/Enter). Returns { wrap, sync }.
|
|
118
120
|
function makeSlider(def, params, onChange, info) {
|
|
@@ -173,6 +175,34 @@ function makeSlider(def, params, onChange, info) {
|
|
|
173
175
|
return { wrap, sync };
|
|
174
176
|
}
|
|
175
177
|
|
|
178
|
+
function makeTextControl(def, params, onChange, info) {
|
|
179
|
+
const multiline = def.control === "textarea";
|
|
180
|
+
const wrap = el("div", "slider");
|
|
181
|
+
const row = el("div", "row");
|
|
182
|
+
const label = el("label", "", def.label);
|
|
183
|
+
attachInfo(label, def.description, info);
|
|
184
|
+
row.append(label);
|
|
185
|
+
wrap.append(row);
|
|
186
|
+
|
|
187
|
+
const field = document.createElement(multiline ? "textarea" : "input");
|
|
188
|
+
if (!multiline) field.type = "text";
|
|
189
|
+
field.className = "text-input";
|
|
190
|
+
field.value = String(params[def.key] ?? "");
|
|
191
|
+
field.addEventListener("input", () => {
|
|
192
|
+
params[def.key] = field.value;
|
|
193
|
+
onChange?.();
|
|
194
|
+
});
|
|
195
|
+
wrap.append(field);
|
|
196
|
+
|
|
197
|
+
const sync = () => { field.value = String(params[def.key] ?? ""); };
|
|
198
|
+
return { wrap, sync };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const makeParameterControl = (def, params, onChange, info) =>
|
|
202
|
+
def.control === "text" || def.control === "textarea"
|
|
203
|
+
? makeTextControl(def, params, onChange, info)
|
|
204
|
+
: makeSlider(def, params, onChange, info);
|
|
205
|
+
|
|
176
206
|
// A collapsible "Advanced ▾" block. Returns { adv, toggle }.
|
|
177
207
|
function advancedBlock() {
|
|
178
208
|
const adv = el("div", "adv hidden");
|
|
@@ -242,7 +272,7 @@ function buildPresetSection(section, sec, params, onDirty, register, info) {
|
|
|
242
272
|
if (advanced.length) {
|
|
243
273
|
const { adv, toggle } = advancedBlock();
|
|
244
274
|
for (const def of advanced) {
|
|
245
|
-
const s =
|
|
275
|
+
const s = makeParameterControl(def, params, () => { if (preset) preset.value = "Custom"; onDirty?.(); }, info);
|
|
246
276
|
adv.append(s.wrap);
|
|
247
277
|
syncs[def.key] = s.sync;
|
|
248
278
|
register(def.key, s.wrap);
|
|
@@ -281,7 +311,7 @@ function buildFeatureSection(section, sec, params, onDirty, register, info) {
|
|
|
281
311
|
const group = el("div", "feat-group");
|
|
282
312
|
const syncs = [];
|
|
283
313
|
for (const def of feat.sliders.filter((d) => !d.hidden)) {
|
|
284
|
-
const s =
|
|
314
|
+
const s = makeParameterControl(def, params, onDirty, info);
|
|
285
315
|
group.append(s.wrap);
|
|
286
316
|
syncs.push(s.sync);
|
|
287
317
|
register(def.key, s.wrap);
|
|
@@ -287,6 +287,11 @@ export function createCutawayGizmo({
|
|
|
287
287
|
|
|
288
288
|
function pick(event, ray) {
|
|
289
289
|
if (pickHandle) return resolveHandle(pickHandle(event, handles, ray));
|
|
290
|
+
handleRoot.updateWorldMatrix(true, true);
|
|
291
|
+
const intersection = raycaster.intersectObjects(hitProxies, false)[0];
|
|
292
|
+
const intersectedHandle = resolveHandle(intersection);
|
|
293
|
+
if (intersectedHandle) return intersectedHandle;
|
|
294
|
+
|
|
290
295
|
const center = projectToClient(group.position);
|
|
291
296
|
if (center) {
|
|
292
297
|
const dx = event.clientX - center.x;
|
|
@@ -296,9 +301,7 @@ export function createCutawayGizmo({
|
|
|
296
301
|
return "translate";
|
|
297
302
|
}
|
|
298
303
|
}
|
|
299
|
-
|
|
300
|
-
const intersection = raycaster.intersectObjects(hitProxies, false)[0];
|
|
301
|
-
return resolveHandle(intersection);
|
|
304
|
+
return null;
|
|
302
305
|
}
|
|
303
306
|
|
|
304
307
|
function safeCapture(pointerId) {
|
|
@@ -9,12 +9,16 @@
|
|
|
9
9
|
// 4. unite(self) to normalize overlaps and crossings into simple paths.
|
|
10
10
|
import paper from "paper/dist/paper-core.js";
|
|
11
11
|
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
|
|
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
|
+
}
|
|
16
20
|
|
|
17
|
-
function toPaperPath(contour) {
|
|
21
|
+
function toPaperPath(scope, contour) {
|
|
18
22
|
const path = new scope.Path({ insert: false });
|
|
19
23
|
path.moveTo(new scope.Point(contour.start[0], contour.start[1]));
|
|
20
24
|
for (const s of contour.segments) {
|
|
@@ -67,10 +71,11 @@ export function resolveCurveFill(contours, { fillRule = "nonzero" } = {}) {
|
|
|
67
71
|
if (fillRule !== "nonzero" && fillRule !== "evenodd")
|
|
68
72
|
throw new Error('curve-fill: fillRule must be "nonzero" or "evenodd"');
|
|
69
73
|
if (!contours || contours.length === 0) return [];
|
|
74
|
+
const scope = paperScope();
|
|
70
75
|
try {
|
|
71
76
|
const simple = [];
|
|
72
77
|
for (const ct of contours) {
|
|
73
|
-
const resolved = toPaperPath(ct).resolveCrossings();
|
|
78
|
+
const resolved = toPaperPath(scope, ct).resolveCrossings();
|
|
74
79
|
const kids = resolved.className === "CompoundPath" ? resolved.children : [resolved];
|
|
75
80
|
for (const k of kids) if (k.segments && k.segments.length >= 2) simple.push(k.clone({ insert: false }));
|
|
76
81
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Pure, backend-free 2-D convex hull (Andrew's monotone chain) + input sampling for
|
|
2
|
+
// k.hull / k.hullChain (wired in kernel-front). No WASM, no kernel — a pure function
|
|
3
|
+
// of its inputs, so hull output for point-list/contour inputs is backend-independent.
|
|
4
|
+
import { tessellateContour } from "./profile.js";
|
|
5
|
+
|
|
6
|
+
// Fixed LOD for curve-contour inputs. Sampling in pure JS (not via a backend's
|
|
7
|
+
// materialization) is what makes point/contour hull results bit-identical across backends.
|
|
8
|
+
const HULL_SEGS = 64;
|
|
9
|
+
|
|
10
|
+
// One HullInput → its contributing points.
|
|
11
|
+
// Shape2D → its materialized boundary rings (outer + holes; holes are interior
|
|
12
|
+
// to a convex hull, harmless);
|
|
13
|
+
// curve contour → tessellated at a fixed LOD (pure JS);
|
|
14
|
+
// point list → used as-is (any length ≥ 1; e.g. circleProfile's 48-gon).
|
|
15
|
+
export function hullPoints(input) {
|
|
16
|
+
if (input && input._shape2d)
|
|
17
|
+
return input.toRegions().flatMap((r) => [...r.outer, ...r.holes.flat()]);
|
|
18
|
+
if (Array.isArray(input) && input.length > 0 && Array.isArray(input[0]))
|
|
19
|
+
return input;
|
|
20
|
+
if (input && Array.isArray(input.segments))
|
|
21
|
+
return tessellateContour(input, HULL_SEGS);
|
|
22
|
+
throw new Error("hull: each input must be a Shape2D, a curve contour, or an [[x,y],…] point list");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Convex hull of a point set → CCW convex polygon [[x,y],…]. Andrew's monotone chain,
|
|
26
|
+
// O(n log n). Drops interior and on-edge points (strict turns only), so a collinear set
|
|
27
|
+
// collapses to < 3 vertices → throw (it cannot bound a 2-D region).
|
|
28
|
+
export function convexHull(points) {
|
|
29
|
+
const seen = new Set();
|
|
30
|
+
const pts = [];
|
|
31
|
+
for (const p of points) {
|
|
32
|
+
const key = `${p[0]},${p[1]}`;
|
|
33
|
+
if (!seen.has(key)) { seen.add(key); pts.push([p[0], p[1]]); }
|
|
34
|
+
}
|
|
35
|
+
if (pts.length < 3) throw new Error(`hull: need ≥3 distinct points, got ${pts.length}`);
|
|
36
|
+
pts.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
37
|
+
const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
|
|
38
|
+
const half = (src) => {
|
|
39
|
+
const out = [];
|
|
40
|
+
for (const p of src) {
|
|
41
|
+
while (out.length >= 2 && cross(out[out.length - 2], out[out.length - 1], p) <= 0) out.pop();
|
|
42
|
+
out.push(p);
|
|
43
|
+
}
|
|
44
|
+
out.pop(); // last point is shared with the other half's first
|
|
45
|
+
return out;
|
|
46
|
+
};
|
|
47
|
+
const hull = half(pts).concat(half([...pts].reverse()));
|
|
48
|
+
if (hull.length < 3) throw new Error("hull: points are collinear — no 2-D region");
|
|
49
|
+
return hull;
|
|
50
|
+
}
|
|
@@ -17,6 +17,7 @@ import { KernelCapabilityError } from "./errors.js";
|
|
|
17
17
|
import { isPlainOptions, KERNEL_OP_SPECS } from "./op-options.js";
|
|
18
18
|
import { textGlyphs } from "./text2d.js";
|
|
19
19
|
import { DEFAULT_FONT_BYTES } from "./fonts/default-font.js";
|
|
20
|
+
import { convexHull, hullPoints } from "./hull.js";
|
|
20
21
|
|
|
21
22
|
export function finishKernel(k) {
|
|
22
23
|
// Compound default: bored-through cylinder (tool overshoots 2 mm each end for
|
|
@@ -88,5 +89,24 @@ export function finishKernel(k) {
|
|
|
88
89
|
return regions.map((r) => k.shape2d(r)).reduce((a, b) => a.union(b));
|
|
89
90
|
};
|
|
90
91
|
|
|
92
|
+
// Convex hull → Shape2D. Backend-agnostic: pure-JS monotone-chain hull of the inputs'
|
|
93
|
+
// sampled points, lifted via k.shape2d. Faceted (curved inputs at a fixed LOD).
|
|
94
|
+
k.hull = (inputs) => {
|
|
95
|
+
if (!Array.isArray(inputs) || inputs.length === 0)
|
|
96
|
+
throw new Error("hull: inputs must be a non-empty array");
|
|
97
|
+
return k.shape2d(convexHull(inputs.flatMap(hullPoints)));
|
|
98
|
+
};
|
|
99
|
+
// Swept hull over an ordered sequence (≥2): union of the hull of each consecutive pair.
|
|
100
|
+
k.hullChain = (inputs) => {
|
|
101
|
+
if (!Array.isArray(inputs) || inputs.length < 2)
|
|
102
|
+
throw new Error("hullChain: needs at least 2 inputs");
|
|
103
|
+
let acc = null;
|
|
104
|
+
for (let i = 0; i < inputs.length - 1; i++) {
|
|
105
|
+
const seg = k.hull([inputs[i], inputs[i + 1]]);
|
|
106
|
+
acc = acc ? acc.union(seg) : seg;
|
|
107
|
+
}
|
|
108
|
+
return acc;
|
|
109
|
+
};
|
|
110
|
+
|
|
91
111
|
return k;
|
|
92
112
|
}
|
|
@@ -19,7 +19,7 @@ export const CONTRACT_VERSION = 1;
|
|
|
19
19
|
// Ops every backend kernel must implement.
|
|
20
20
|
export const KERNEL_OPS = [
|
|
21
21
|
"cylinder", "boredCylinder", "sphere", "box", "prism", "extrude", "revolve",
|
|
22
|
-
"loft", "sweep", "helixSweptTube", "union", "shape2d", "text2d", "toSTEP",
|
|
22
|
+
"loft", "sweep", "helixSweptTube", "union", "shape2d", "text2d", "hull", "hullChain", "toSTEP",
|
|
23
23
|
];
|
|
24
24
|
|
|
25
25
|
// Backend-optional kernel ops: the Manifold cache brackets + WASM lifetime hooks.
|
|
@@ -42,7 +42,8 @@ export const SOLID_OPTIONAL_OPS = ["genus", "isEmpty"];
|
|
|
42
42
|
|
|
43
43
|
// Public methods every Shape2D exposes (2-D boolean value; contract-linted).
|
|
44
44
|
export const SHAPE2D_OPS = [
|
|
45
|
-
"union", "cut", "cutAll", "intersect", "offset", "area", "boundingBox", "toRegions", "simple", "clone",
|
|
45
|
+
"union", "cut", "cutAll", "intersect", "offset", "area", "boundingBox", "toRegions", "simple", "regions", "clone",
|
|
46
|
+
"extrude", "revolve",
|
|
46
47
|
];
|
|
47
48
|
|
|
48
49
|
// Solid ops only OCCT implements natively. Single source of truth: probe.js routes
|
|
@@ -105,6 +106,8 @@ export const OCCT_ONLY_OPS = ["fillet", "chamfer", "shell"];
|
|
|
105
106
|
* @property {(o:{pathR:number,profileR:number,pitch:number,turns:number,z0:number,lefthand:boolean}) => Solid} helixSweptTube
|
|
106
107
|
* @property {(solids:Solid[]) => Solid} union
|
|
107
108
|
* @property {(profile: number[][]|{outer:number[][],holes?:number[][][]}|Shape2D) => Shape2D} shape2d 2-D boolean value (both backends: Manifold wraps a CrossSection, OCCT a replicad Drawing)
|
|
109
|
+
* @property {(inputs: (Shape2D|number[][]|{start:number[],segments:object[]})[]) => Shape2D} hull convex hull of all inputs → a convex Shape2D (faceted; pure-JS monotone chain)
|
|
110
|
+
* @property {(inputs: (Shape2D|number[][]|{start:number[],segments:object[]})[]) => Shape2D} hullChain swept hull over an ordered sequence (≥2): union of hull([inᵢ,inᵢ₊₁])
|
|
108
111
|
* @property {(named:{name:string,solid:Solid}[]) => Promise<ArrayBuffer>} toSTEP OCCT only (Manifold throws KernelCapabilityError)
|
|
109
112
|
* @property {(name:string) => void} [beginSubPart] open a per-sub-part solid-cache round (Manifold only)
|
|
110
113
|
* @property {() => void} [endSubPart] close the cache round (always pair with beginSubPart)
|
|
@@ -98,7 +98,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
98
98
|
boundingBox: () => { const r = cs.bounds(); return { min: [r.min[0], r.min[1]], max: [r.max[0], r.max[1]] }; },
|
|
99
99
|
toRegions: () => assembleRegions(cs.toPolygons()),
|
|
100
100
|
clone: () => wrapShape2d(cs, hash),
|
|
101
|
-
});
|
|
101
|
+
}, { shape2d, extrude: kernel.extrude, revolve: kernel.revolve });
|
|
102
102
|
const shape2d = (profile) => {
|
|
103
103
|
if (profile && profile._shape2d) return profile; // idempotent
|
|
104
104
|
const hash = h("shape2d", profile, segs);
|
|
@@ -184,7 +184,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
184
184
|
toIndexedMesh: () => indexedMeshOut(m),
|
|
185
185
|
});
|
|
186
186
|
|
|
187
|
-
|
|
187
|
+
const kernel = finishKernel({
|
|
188
188
|
cylinder: (rb, rt, h2, { center = false } = {}) =>
|
|
189
189
|
wrap(T(Manifold.cylinder(h2, rb, rt, segs, center)), h("cylinder", rb, rt, h2, center, segs)),
|
|
190
190
|
// Compound op: hashed ATOMICALLY from its own args, so it is a single cache
|
|
@@ -251,6 +251,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
251
251
|
// still pins (they must survive for the next build to resume from them).
|
|
252
252
|
cleanup: () => { for (const o of tracked) if (!cache.isPinned(o)) o.delete?.(); tracked.length = 0; },
|
|
253
253
|
});
|
|
254
|
+
return kernel;
|
|
254
255
|
}
|
|
255
256
|
|
|
256
257
|
// Build a non-indexed mesh with normals that are smooth within a single original
|
|
@@ -189,7 +189,7 @@ export function createOcctKernel(replicad) {
|
|
|
189
189
|
boundingBox: () => { const b = drawing.boundingBox; return { min: [b.bounds[0][0], b.bounds[0][1]], max: [b.bounds[1][0], b.bounds[1][1]] }; },
|
|
190
190
|
toRegions,
|
|
191
191
|
clone: () => wrapShape2d(drawing.clone()),
|
|
192
|
-
});
|
|
192
|
+
}, { shape2d, extrude: kernel.extrude, revolve: kernel.revolve });
|
|
193
193
|
};
|
|
194
194
|
const shape2d = (profile) => (profile && profile._shape2d ? profile : wrapShape2d(drawingFromProfile(profile)));
|
|
195
195
|
|
|
@@ -265,7 +265,7 @@ export function createOcctKernel(replicad) {
|
|
|
265
265
|
return wrap(genericSweep(profile, spine, { frenet: true }));
|
|
266
266
|
};
|
|
267
267
|
|
|
268
|
-
|
|
268
|
+
const kernel = finishKernel({
|
|
269
269
|
cylinder, // boredCylinder: the kernel front's default composition is exactly right here
|
|
270
270
|
box: (min, max) => wrap(makeBox(min, max)), prism, extrude, revolve, loft: loftOp, sweep, helixSweptTube,
|
|
271
271
|
sphere: (r) => wrap(makeSphere(r)),
|
|
@@ -276,4 +276,5 @@ export function createOcctKernel(replicad) {
|
|
|
276
276
|
shape2d,
|
|
277
277
|
toSTEP: (named) => exportSTEP(named.map(({ name, solid }) => ({ name, shape: solid._s }))).arrayBuffer(),
|
|
278
278
|
});
|
|
279
|
+
return kernel;
|
|
279
280
|
}
|
|
@@ -1,11 +1,22 @@
|
|
|
1
|
-
// Backend-shared Shape2D front. Like solid-sugar for Solids
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
1
|
+
// Backend-shared Shape2D front. Like solid-sugar for Solids: backends attach the geometry
|
|
2
|
+
// ops (booleans, area, boundingBox, toRegions); this layers on the backend-agnostic sugar.
|
|
3
|
+
// `deps` are the backend's own functions the sugar defers to: `shape2d` (lift a region back
|
|
4
|
+
// into a Shape2D) and `extrude`/`revolve` (build a Solid from this shape).
|
|
5
|
+
export function addShape2dSugar(s, { shape2d, extrude, revolve }) {
|
|
6
|
+
// .simple() → the single {outer,holes} region, or throw (a raw region, not a Shape2D).
|
|
5
7
|
s.simple = () => {
|
|
6
8
|
const regions = s.toRegions();
|
|
7
9
|
if (regions.length !== 1) throw new Error(`Shape2D.simple: result has ${regions.length} regions, not 1 (use toRegions())`);
|
|
8
10
|
return regions[0];
|
|
9
11
|
};
|
|
12
|
+
// .regions() → scission: each disjoint region as its own live Shape2D (booleanable further).
|
|
13
|
+
s.regions = () => s.toRegions().map((r) => shape2d(r));
|
|
14
|
+
// .extrude({ h, twist?, scaleTop? }) / .revolve({ degrees? }) → Solid. Sugar for
|
|
15
|
+
// k.extrude({ profile: shape, … }) / k.revolve({ profile: shape, … }). Passed as an
|
|
16
|
+
// options object (not positional) so the kernel op's key/required-arg validation still
|
|
17
|
+
// fires — e.g. a missing `h` throws "extrude: h is required" rather than silently
|
|
18
|
+
// producing empty geometry.
|
|
19
|
+
s.extrude = ({ h, twist, scaleTop } = {}) => extrude({ profile: s, h, twist, scaleTop });
|
|
20
|
+
s.revolve = ({ degrees } = {}) => revolve({ profile: s, degrees });
|
|
10
21
|
return s;
|
|
11
22
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Demo part — a hull sweep. Showcases k.hull / k.hullChain: a row of circles of
|
|
2
|
+
// varying radius along an arched spine becomes either one convex blob (k.hull) or a
|
|
3
|
+
// smooth swept strap/taper (k.hullChain) — the capsule/rounded-slot/organic-taper
|
|
4
|
+
// payoff. Optionally bores a hole at each node (Shape2D.cutAll) to make a linkage.
|
|
5
|
+
// Open /hull-sweep.html after `npm run dev`. Toggle "Convex wrap" to see hull vs
|
|
6
|
+
// hullChain side by side.
|
|
7
|
+
import { circleProfile } from "partforge/geometry";
|
|
8
|
+
|
|
9
|
+
export default {
|
|
10
|
+
meta: { title: "Hull sweep", units: "mm", background: 0x15181d },
|
|
11
|
+
parameters: [
|
|
12
|
+
{
|
|
13
|
+
id: "sweep",
|
|
14
|
+
title: "Sweep",
|
|
15
|
+
description: "A row of circles along an arched spine. `k.hullChain` sweeps the hull from one to the next (a strap/taper); `k.hull` wraps them all in one convex outline (see the Mode toggle).",
|
|
16
|
+
advanced: [
|
|
17
|
+
{ key: "nodes", label: "Nodes", unit: "", min: 2, max: 6, step: 1,
|
|
18
|
+
description: "How many circles along the spine (2 = a single capsule)." },
|
|
19
|
+
{ key: "length", label: "Length", unit: "mm", min: 20, max: 120, step: 1,
|
|
20
|
+
description: "Span from the first node to the last." },
|
|
21
|
+
{ key: "r0", label: "Start radius", unit: "mm", min: 2, max: 16, step: 0.5,
|
|
22
|
+
description: "Radius of the first node." },
|
|
23
|
+
{ key: "r1", label: "End radius", unit: "mm", min: 1, max: 16, step: 0.5,
|
|
24
|
+
description: "Radius of the last node — set it below the start for a taper." },
|
|
25
|
+
{ key: "bow", label: "Arch", unit: "mm", min: 0, max: 30, step: 1,
|
|
26
|
+
description: "Vertical bow of the middle nodes. 0 = a straight strap; higher = a banana/arch." },
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: "solid",
|
|
31
|
+
title: "Solid",
|
|
32
|
+
advanced: [
|
|
33
|
+
{ key: "thickness", label: "Thickness", unit: "mm", min: 1.5, max: 10, step: 0.5,
|
|
34
|
+
description: "Extrude height." },
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: "mode",
|
|
39
|
+
title: "Mode",
|
|
40
|
+
toggles: [
|
|
41
|
+
{ key: "wrap", label: "Convex wrap (k.hull instead of k.hullChain)", on: 1,
|
|
42
|
+
description: "On: one convex hull of every node (a single convex blob). Off: the swept chain — the hull of each consecutive pair, unioned." },
|
|
43
|
+
{ key: "holes", label: "Bore a hole at each node", on: 1,
|
|
44
|
+
description: "Cut a circular hole at every node (Shape2D.cutAll) — turns the strap into a linkage." },
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
defaults: { nodes: 3, length: 60, r0: 8, r1: 4, bow: 8, thickness: 4, wrap: 0, holes: 0 },
|
|
49
|
+
parts: {
|
|
50
|
+
sweep: {
|
|
51
|
+
label: "Hull sweep",
|
|
52
|
+
views: ["sweep"],
|
|
53
|
+
export: { name: "hull-sweep" },
|
|
54
|
+
build: (k, p) => {
|
|
55
|
+
const n = Math.max(2, Math.round(p.nodes));
|
|
56
|
+
const nodes = [];
|
|
57
|
+
for (let i = 0; i < n; i++) {
|
|
58
|
+
const t = i / (n - 1);
|
|
59
|
+
nodes.push({ x: -p.length / 2 + t * p.length, y: p.bow * Math.sin(Math.PI * t), r: p.r0 + (p.r1 - p.r0) * t });
|
|
60
|
+
}
|
|
61
|
+
const circles = nodes.map((nd) => circleProfile(nd.r, [nd.x, nd.y]));
|
|
62
|
+
let shape = p.wrap ? k.hull(circles) : k.hullChain(circles);
|
|
63
|
+
if (p.holes)
|
|
64
|
+
shape = shape.cutAll(nodes.map((nd) => circleProfile(Math.max(0.8, nd.r * 0.45), [nd.x, nd.y])));
|
|
65
|
+
return k.extrude({ profile: shape, h: p.thickness });
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
views: { sweep: { label: "Hull sweep" } },
|
|
70
|
+
};
|
package/src/parts/nameplate.js
CHANGED
|
@@ -5,16 +5,16 @@
|
|
|
5
5
|
// exercises shape2d + the extrude/boolean path. Open /nameplate.html after `npm run dev`.
|
|
6
6
|
import { roundedRectPolygon } from "partforge/geometry";
|
|
7
7
|
|
|
8
|
-
const LABEL = "PARTFORGE\nv0.20";
|
|
9
|
-
|
|
10
8
|
export default {
|
|
11
9
|
meta: { title: "Nameplate", units: "mm", background: 0x15181d },
|
|
12
10
|
parameters: [
|
|
13
11
|
{
|
|
14
12
|
id: "text",
|
|
15
13
|
title: "Lettering",
|
|
16
|
-
description: "
|
|
14
|
+
description: "Editable text resolved to exact glyph curves, with open counters and sizing based on cap height.",
|
|
17
15
|
advanced: [
|
|
16
|
+
{ key: "label", label: "Text", control: "textarea",
|
|
17
|
+
description: "The text rendered on the nameplate. Line breaks create multiple lines." },
|
|
18
18
|
{ key: "size", label: "Cap height", unit: "mm", min: 4, max: 16, step: 0.5,
|
|
19
19
|
description: "Height of the uppercase letters. The second line scales with it." },
|
|
20
20
|
{ key: "depth", label: "Relief depth", unit: "mm", min: 0.4, max: 3, step: 0.1,
|
|
@@ -45,14 +45,14 @@ export default {
|
|
|
45
45
|
],
|
|
46
46
|
},
|
|
47
47
|
],
|
|
48
|
-
defaults: { size: 8, depth: 1.2, stroke: 0, margin: 4, corner: 3, thickness: 3, engrave: 0 },
|
|
48
|
+
defaults: { label: "PARTFORGE\nv0.20", size: 8, depth: 1.2, stroke: 0, margin: 4, corner: 3, thickness: 3, engrave: 0 },
|
|
49
49
|
parts: {
|
|
50
50
|
plate: {
|
|
51
51
|
label: "Nameplate",
|
|
52
52
|
views: ["plate"],
|
|
53
53
|
export: { name: "nameplate" },
|
|
54
54
|
build: (k, p) => {
|
|
55
|
-
let text = k.text2d(
|
|
55
|
+
let text = k.text2d(p.label, { size: p.size, align: "center", valign: "middle", lineHeight: p.size * 1.7 });
|
|
56
56
|
// Shape2D offset on the lettering: grow (>0, bolder) or shrink (<0, thinner). Guard
|
|
57
57
|
// against a shrink that collapses thin strokes — keep the un-offset letters if so.
|
|
58
58
|
if (p.stroke !== 0) {
|