partforge 0.6.0 → 0.6.1
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 +5 -2
- package/package.json +1 -1
- package/src/app-planter.js +10 -0
- package/src/framework/app.css +68 -29
- package/src/framework/geometry/manifold-backend.js +3 -1
- package/src/parts/planter.js +119 -0
- package/src/planter-worker.js +3 -0
package/README.md
CHANGED
|
@@ -47,8 +47,11 @@ boots with no errors. Needs Playwright: `npm i -D playwright && npx playwright i
|
|
|
47
47
|
contract, the geometry kernel API, the parameter schema, app wiring, testing, and
|
|
48
48
|
gotchas. See **Designing the control panel** in that guide for how to write descriptions,
|
|
49
49
|
hide internal params, and keep the interface simple while staying deeply adjustable.
|
|
50
|
-
`src/parts/demo.js` is a minimal worked example
|
|
51
|
-
|
|
50
|
+
`src/parts/demo.js` is a minimal worked example.
|
|
51
|
+
|
|
52
|
+
**Live showcase:** https://scottsykora.github.io/partforge/ — the landing gallery links
|
|
53
|
+
three live example apps (Faceted Planter, Spacer, Filleted Box), auto-deployed from `main`.
|
|
54
|
+
Locally, `npm run dev` then open `/demo.html`, `/planter.html`, or `/filleted-box.html`.
|
|
52
55
|
|
|
53
56
|
- **Agent clarification (`request-a-pick`):** an external tool can ask the user to click
|
|
54
57
|
geometry and get the `Selection` back — serve with `?pickserver`, drive with
|
package/package.json
CHANGED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import planterPart from "./parts/planter.js";
|
|
2
|
+
import { mount } from "./framework/index.js";
|
|
3
|
+
|
|
4
|
+
// Dev-only example app for the faceted planter part. Identical wiring to app-demo.js —
|
|
5
|
+
// only the imported definition and the worker entry differ per part. `npm run dev`,
|
|
6
|
+
// then open /planter.html.
|
|
7
|
+
mount(planterPart, {
|
|
8
|
+
createWorker: (name) =>
|
|
9
|
+
new Worker(new URL("./planter-worker.js", import.meta.url), { type: "module", name }),
|
|
10
|
+
});
|
package/src/framework/app.css
CHANGED
|
@@ -8,14 +8,15 @@
|
|
|
8
8
|
--bg: #15181d; --surface: #1f242c; --surface-2: #20262e; --border: #2c333d;
|
|
9
9
|
--text: #d6dbe2; --text-strong: #e7ebf1; --text-2: #cdd4dd;
|
|
10
10
|
--muted: #7d8794; --muted-2: #aab2bd; --status: #8b94a0; --hint: #6b7480;
|
|
11
|
-
--accent: #
|
|
11
|
+
--accent: #3f7bf0; --accent-soft: #26314a; --on-accent: #fff; --input-bg: #161a20; --err: #f8746c;
|
|
12
|
+
--mono: ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;
|
|
12
13
|
}
|
|
13
14
|
:root[data-theme="light"] {
|
|
14
15
|
color-scheme: light;
|
|
15
16
|
--bg: #eef1f5; --surface: #ffffff; --surface-2: #f4f6f9; --border: #d4dae2;
|
|
16
17
|
--text: #2b333d; --text-strong: #1a2129; --text-2: #3a434e;
|
|
17
18
|
--muted: #6b7480; --muted-2: #59636f; --status: #6b7480; --hint: #8a93a0;
|
|
18
|
-
--accent: #
|
|
19
|
+
--accent: #1f5bd6; --accent-soft: #e6edfc; --on-accent: #fff; --input-bg: #ffffff; --err: #d8453d;
|
|
19
20
|
}
|
|
20
21
|
* { box-sizing: border-box; }
|
|
21
22
|
html, body { margin: 0; height: 100%; overflow: hidden;
|
|
@@ -24,59 +25,86 @@ html, body { margin: 0; height: 100%; overflow: hidden;
|
|
|
24
25
|
canvas { display: block; }
|
|
25
26
|
|
|
26
27
|
#panel {
|
|
27
|
-
position: fixed; top: 12px; left: 12px; width:
|
|
28
|
+
position: fixed; top: 12px; left: 12px; width: 256px;
|
|
28
29
|
max-height: calc(100vh - 24px); overflow-y: auto; z-index: 10;
|
|
29
30
|
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
|
|
30
31
|
padding: 14px; color: var(--text); box-shadow: 0 6px 24px rgba(0,0,0,.35);
|
|
31
32
|
}
|
|
32
|
-
#panel h1 { font-size: 14px; margin: 0 0 2px; }
|
|
33
|
-
#panel .sub {
|
|
33
|
+
#panel h1 { font-size: 14px; margin: 0 0 2px; color: var(--text-strong); letter-spacing: -0.01em; }
|
|
34
|
+
#panel .sub {
|
|
35
|
+
font-family: var(--mono); color: var(--muted); font-size: 10px;
|
|
36
|
+
letter-spacing: 0.04em; text-transform: uppercase;
|
|
37
|
+
margin: 0 0 12px; padding-bottom: 12px; border-bottom: 1px solid var(--border);
|
|
38
|
+
}
|
|
34
39
|
|
|
35
40
|
.seg { display: flex; gap: 4px; margin-bottom: 12px; }
|
|
36
41
|
.seg button {
|
|
37
42
|
flex: 1; padding: 7px 0; border: 1px solid var(--border); border-radius: 7px;
|
|
38
|
-
background: var(--surface-2); color: var(--muted-2); cursor: pointer;
|
|
43
|
+
background: var(--surface-2); color: var(--muted-2); cursor: pointer;
|
|
44
|
+
font-family: var(--mono); font-size: 11px; letter-spacing: 0.02em;
|
|
39
45
|
}
|
|
40
46
|
.seg button.on { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
|
|
41
47
|
|
|
42
48
|
.section {
|
|
43
|
-
border: 1px solid var(--border); border-radius: 8px; padding:
|
|
49
|
+
border: 1px solid var(--border); border-radius: 8px; padding: 10px;
|
|
44
50
|
margin-bottom: 8px; background: var(--surface-2);
|
|
45
51
|
}
|
|
46
|
-
.sec-title {
|
|
52
|
+
.sec-title {
|
|
53
|
+
font-family: var(--mono); font-size: 10px; font-weight: 600;
|
|
54
|
+
letter-spacing: 0.14em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 9px;
|
|
55
|
+
}
|
|
47
56
|
select.preset {
|
|
48
57
|
width: 100%; background: var(--input-bg); color: var(--text-2);
|
|
49
|
-
border: 1px solid var(--border); border-radius: 6px; padding:
|
|
58
|
+
border: 1px solid var(--border); border-radius: 6px; padding: 6px 8px;
|
|
59
|
+
font-family: var(--mono); font-size: 11px;
|
|
50
60
|
}
|
|
51
61
|
.feat { display: flex; align-items: center; gap: 8px; margin: 6px 0;
|
|
52
62
|
color: var(--text-2); cursor: pointer; }
|
|
53
|
-
.feat input { cursor: pointer; }
|
|
54
|
-
.feat-group { margin: 2px 0 8px; padding-left:
|
|
63
|
+
.feat input { cursor: pointer; accent-color: var(--accent); }
|
|
64
|
+
.feat-group { margin: 2px 0 8px; padding-left: 10px; border-left: 2px solid var(--border); }
|
|
55
65
|
.feat-group.hidden { display: none; }
|
|
56
66
|
.adv-toggle {
|
|
57
|
-
margin-top: 8px; padding:
|
|
58
|
-
background: transparent; color: var(--muted); cursor: pointer;
|
|
59
|
-
text-align: left;
|
|
67
|
+
margin-top: 8px; padding: 4px 0; width: 100%; border: 0; border-radius: 5px;
|
|
68
|
+
background: transparent; color: var(--muted); cursor: pointer;
|
|
69
|
+
font-family: var(--mono); font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; text-align: left;
|
|
60
70
|
}
|
|
61
71
|
.adv-toggle:hover { color: var(--muted-2); }
|
|
62
72
|
.adv.hidden { display: none; }
|
|
63
73
|
.adv { margin-top: 4px; }
|
|
64
74
|
|
|
65
|
-
.slider { margin:
|
|
75
|
+
.slider { margin: 9px 0; }
|
|
66
76
|
.row { display: flex; justify-content: space-between; align-items: center;
|
|
67
|
-
margin: 0 0
|
|
68
|
-
.row label { color: var(--muted-2); }
|
|
77
|
+
margin: 0 0 4px; gap: 8px; }
|
|
78
|
+
.row label { font-family: var(--mono); font-size: 11px; color: var(--muted-2); letter-spacing: 0.01em; }
|
|
69
79
|
.row .val { display: flex; align-items: baseline; gap: 4px; flex: none; }
|
|
70
80
|
.row .num {
|
|
71
|
-
width:
|
|
81
|
+
width: 54px; text-align: right; font-family: var(--mono); font-size: 12px; font-variant-numeric: tabular-nums;
|
|
72
82
|
background: var(--input-bg); color: var(--text-strong);
|
|
73
|
-
border: 1px solid var(--border); border-radius: 5px; padding:
|
|
83
|
+
border: 1px solid var(--border); border-radius: 5px; padding: 3px 6px;
|
|
74
84
|
}
|
|
75
|
-
.row .num:focus { outline: none; border-color: var(--accent); }
|
|
85
|
+
.row .num:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
|
76
86
|
.row .num::-webkit-outer-spin-button, .row .num::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
|
77
87
|
.row .num { -moz-appearance: textfield; }
|
|
78
|
-
.row .unit { color: var(--muted); font-size:
|
|
79
|
-
|
|
88
|
+
.row .unit { font-family: var(--mono); color: var(--muted); font-size: 10px; }
|
|
89
|
+
|
|
90
|
+
/* crafted range slider — hairline track + CAD-blue handle (the panel's signature control) */
|
|
91
|
+
input[type="range"] { -webkit-appearance: none; appearance: none; width: 100%; height: 18px; margin: 0; background: transparent; cursor: pointer; }
|
|
92
|
+
input[type="range"]::-webkit-slider-runnable-track { height: 3px; border-radius: 2px; background: var(--border); }
|
|
93
|
+
input[type="range"]::-moz-range-track { height: 3px; border-radius: 2px; background: var(--border); }
|
|
94
|
+
input[type="range"]::-moz-range-progress { height: 3px; border-radius: 2px; background: var(--accent); }
|
|
95
|
+
input[type="range"]::-webkit-slider-thumb {
|
|
96
|
+
-webkit-appearance: none; appearance: none; width: 14px; height: 14px; margin-top: -5.5px;
|
|
97
|
+
border-radius: 50%; background: var(--accent); border: 2px solid var(--surface-2); box-shadow: 0 0 0 1px var(--accent);
|
|
98
|
+
transition: box-shadow .12s ease;
|
|
99
|
+
}
|
|
100
|
+
input[type="range"]::-moz-range-thumb {
|
|
101
|
+
width: 14px; height: 14px; border-radius: 50%; background: var(--accent);
|
|
102
|
+
border: 2px solid var(--surface-2); box-shadow: 0 0 0 1px var(--accent);
|
|
103
|
+
}
|
|
104
|
+
input[type="range"]:hover::-webkit-slider-thumb { box-shadow: 0 0 0 5px var(--accent-soft); }
|
|
105
|
+
input[type="range"]:focus-visible { outline: none; }
|
|
106
|
+
input[type="range"]:focus-visible::-webkit-slider-thumb { box-shadow: 0 0 0 5px var(--accent-soft); }
|
|
107
|
+
input[type="range"]:focus-visible::-moz-range-thumb { box-shadow: 0 0 0 5px var(--accent-soft); }
|
|
80
108
|
|
|
81
109
|
button.action {
|
|
82
110
|
width: 100%; margin-top: 8px; padding: 9px; border: 0; border-radius: 7px;
|
|
@@ -85,19 +113,29 @@ button.action {
|
|
|
85
113
|
button.ghost { background: var(--border); color: var(--text-2); font-weight: 500; }
|
|
86
114
|
button.action:disabled { opacity: .5; cursor: default; }
|
|
87
115
|
|
|
88
|
-
.dl { margin-top:
|
|
89
|
-
.dl-head {
|
|
116
|
+
.dl { margin-top: 14px; }
|
|
117
|
+
.dl-head {
|
|
118
|
+
font-family: var(--mono); font-size: 10px; font-weight: 600;
|
|
119
|
+
letter-spacing: 0.14em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 7px;
|
|
120
|
+
}
|
|
90
121
|
.dl-row { display: flex; gap: 6px; }
|
|
91
122
|
.dl-row button {
|
|
92
123
|
flex: 1; padding: 8px 0; border: 1px solid var(--border); border-radius: 7px;
|
|
93
|
-
background: var(--surface-2); color: var(--text-2);
|
|
94
|
-
font-size:
|
|
124
|
+
background: var(--surface-2); color: var(--text-2);
|
|
125
|
+
font-family: var(--mono); font-weight: 600; font-size: 11px; letter-spacing: 0.06em; cursor: pointer;
|
|
95
126
|
}
|
|
96
127
|
.dl-row button:hover:not(:disabled) { border-color: var(--accent); color: var(--text-strong); }
|
|
97
128
|
.dl-row button:disabled { opacity: .45; cursor: default; }
|
|
98
|
-
#status { margin-top:
|
|
129
|
+
#status { font-family: var(--mono); margin-top: 12px; min-height: 16px; color: var(--status);
|
|
130
|
+
font-size: 11px; font-variant-numeric: tabular-nums; }
|
|
99
131
|
#status.err { color: var(--err); }
|
|
100
|
-
.hint { margin-top: 8px; color: var(--hint); font-size: 10px; }
|
|
132
|
+
.hint { font-family: var(--mono); margin-top: 8px; color: var(--hint); font-size: 10px; letter-spacing: 0.02em; }
|
|
133
|
+
|
|
134
|
+
/* keyboard focus ring shared across the panel's interactive controls */
|
|
135
|
+
.seg button:focus-visible, select.preset:focus-visible, .dl-row button:focus-visible,
|
|
136
|
+
button.action:focus-visible, .adv-toggle:focus-visible, #viewbar button:focus-visible {
|
|
137
|
+
outline: 2px solid var(--accent); outline-offset: 2px;
|
|
138
|
+
}
|
|
101
139
|
|
|
102
140
|
/* part tabs, floated top-centre over the viewport */
|
|
103
141
|
#topbar {
|
|
@@ -156,6 +194,7 @@ button.action:disabled { opacity: .5; cursor: default; }
|
|
|
156
194
|
}
|
|
157
195
|
.popover[hidden] { display: none; }
|
|
158
196
|
.popover img { max-width: 100%; height: auto; border-radius: 4px; }
|
|
197
|
+
.popover code { font-family: var(--mono); background: var(--surface-2); padding: 0.05em 0.35em; border-radius: 4px; font-size: 0.9em; }
|
|
159
198
|
.popover a { color: var(--accent); }
|
|
160
199
|
.popover p:first-child { margin-top: 0; }
|
|
161
200
|
.popover p:last-child { margin-bottom: 0; }
|
|
@@ -198,4 +237,4 @@ button.action:disabled { opacity: .5; cursor: default; }
|
|
|
198
237
|
@keyframes pf-pick-in {
|
|
199
238
|
from { opacity: 0; transform: translateX(-50%) translateY(-10px); }
|
|
200
239
|
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
|
201
|
-
}
|
|
240
|
+
}
|
|
@@ -138,7 +138,9 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
138
138
|
const cs = T(CrossSection.ofPolygons([pts]));
|
|
139
139
|
if (twist === 0 && scaleTop === 1) return T(cs.extrude(height));
|
|
140
140
|
const nDiv = Math.max(1, Math.ceil(Math.abs(twist) / 5));
|
|
141
|
-
|
|
141
|
+
// Manifold's extrude scaleTop is a Vec2 — a scalar is NOT broadcast (it scales
|
|
142
|
+
// X and drives Y to 0, squishing the top to a line). Broadcast for a uniform taper.
|
|
143
|
+
return T(cs.extrude(height, nDiv, twist, [scaleTop, scaleTop]));
|
|
142
144
|
}),
|
|
143
145
|
helixSweptTube: (o) => cached(h("helixSweptTube", o, tube), () => T(helixTube(wasm, { ...o, ...tube }))),
|
|
144
146
|
revolve: (pts, { degrees = 360 } = {}) =>
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Example PartDefinition — a faceted planter / cup / vase. A second worked example
|
|
2
|
+
// alongside parts/demo.js (the Spacer): it shows a prism-based body (Manifold backend,
|
|
3
|
+
// so it stays fast — no OCCT), per-control descriptions, presets, an optional feature
|
|
4
|
+
// (drainage), a hidden internal constant (floor), and a derive() that turns raw inputs
|
|
5
|
+
// into the n-gon point lists and dependent dimensions the build consumes.
|
|
6
|
+
//
|
|
7
|
+
// Why it's a good demo: every control has an obvious reason to touch it before
|
|
8
|
+
// printing. Facets/twist are pure fun, height/diameter/taper fit it to your plant or
|
|
9
|
+
// pens, the drainage hole is a real functional choice (planter vs. cup), and dropping
|
|
10
|
+
// Wall below the fdm-pla 1.2 mm minimum trips partforge's min-wall warning.
|
|
11
|
+
|
|
12
|
+
// A regular n-gon of circumradius R, in the XY plane, as [[x,y],…] for k.prism.
|
|
13
|
+
// A small rotation seats a flat edge toward the viewer so even-sided shapes read right.
|
|
14
|
+
const ngon = (R, n) => {
|
|
15
|
+
const pts = [];
|
|
16
|
+
const offset = Math.PI / n - Math.PI / 2; // flat side facing -Y
|
|
17
|
+
for (let i = 0; i < n; i++) {
|
|
18
|
+
const a = (2 * Math.PI * i) / n + offset;
|
|
19
|
+
pts.push([R * Math.cos(a), R * Math.sin(a)]);
|
|
20
|
+
}
|
|
21
|
+
return pts;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export default {
|
|
25
|
+
meta: { title: "Faceted Planter", units: "mm", background: 0x15181d },
|
|
26
|
+
parameters: [
|
|
27
|
+
{
|
|
28
|
+
id: "body",
|
|
29
|
+
title: "Body",
|
|
30
|
+
description:
|
|
31
|
+
"The faceted vessel. Pick a preset to start, or open **Advanced** for exact dimensions. " +
|
|
32
|
+
"**Facets** and **Twist** are pure styling; **Wall** is the one that decides whether it prints cleanly.",
|
|
33
|
+
presets: {
|
|
34
|
+
"Pen cup": { facets: 6, dia: 80, height: 100, taper: 1.0, twist: 0, drain: 0 },
|
|
35
|
+
Planter: { facets: 8, dia: 90, height: 80, taper: 0.9, twist: 0, drain: 8 },
|
|
36
|
+
Vase: { facets: 5, dia: 70, height: 150, taper: 1.12, twist: 40, drain: 0 },
|
|
37
|
+
},
|
|
38
|
+
advanced: [
|
|
39
|
+
{ key: "facets", label: "Facets", min: 3, max: 12, step: 1,
|
|
40
|
+
description: "Number of flat sides around the body. Low counts read as crystalline; high counts approach a smooth cylinder." },
|
|
41
|
+
{ key: "dia", label: "Diameter", unit: "mm", min: 30, max: 150, step: 1,
|
|
42
|
+
description: "Across-corners diameter at the base. Size it to the plant, pens, or shelf it has to fit." },
|
|
43
|
+
{ key: "height", label: "Height", unit: "mm", min: 20, max: 200, step: 1,
|
|
44
|
+
description: "Overall height along the axis." },
|
|
45
|
+
{ key: "taper", label: "Top taper", min: 0.6, max: 1.4, step: 0.02,
|
|
46
|
+
description: "Rim size relative to the base: below 1 tapers inward (planter), 1 is straight (cup), above 1 flares out (vase)." },
|
|
47
|
+
{ key: "wall", label: "Wall thickness", unit: "mm", min: 0.8, max: 4, step: 0.1,
|
|
48
|
+
description: "Side-wall thickness. The fdm-pla profile wants **≥ 1.2 mm** — go thinner and partforge flags a min-wall warning." },
|
|
49
|
+
{ key: "twist", label: "Twist", unit: "°", min: 0, max: 180, step: 5,
|
|
50
|
+
description: "Rotates the facets from base to rim for a spiral look. 0 keeps the facets vertical." },
|
|
51
|
+
{ key: "floor", label: "Floor thickness", unit: "mm", min: 1, max: 6, step: 0.5, hidden: true,
|
|
52
|
+
description: "Internal: solid base thickness, fixed by the design. Hidden from the end user but still drives the geometry." },
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: "drainage",
|
|
57
|
+
title: "Drainage",
|
|
58
|
+
description: "Optional drainage hole through the base — turn it on for a planter, off to hold water like a cup or vase.",
|
|
59
|
+
features: [
|
|
60
|
+
{ label: "Drainage hole", key: "drain", on: 8,
|
|
61
|
+
description: "Drills a centered hole of this diameter through the floor.",
|
|
62
|
+
sliders: [{ key: "drain", label: "Hole diameter", unit: "mm", min: 3, max: 30, step: 1,
|
|
63
|
+
description: "Diameter of the centered drainage hole." }] },
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
defaults: { facets: 6, dia: 70, height: 90, taper: 1.2, wall: 1.6, twist: 30, drain: 8, floor: 3 },
|
|
68
|
+
// derive(): turn raw inputs into the n-gon point lists and dependent dimensions the
|
|
69
|
+
// build needs, sized so the wall stays even (see build()).
|
|
70
|
+
derive: (p) => {
|
|
71
|
+
const Rout = p.dia / 2;
|
|
72
|
+
// Offset the inner polygon inward by `wall` along the FACE normals, not the radius:
|
|
73
|
+
// for a regular n-gon an edge offset of `wall` shrinks the circumradius by
|
|
74
|
+
// wall / cos(π/n). This keeps the perpendicular wall = `wall` on every flat.
|
|
75
|
+
// clamp only matters if wall is set past the slider bounds via the API
|
|
76
|
+
const Rin = Math.max(Rout - p.wall / Math.cos(Math.PI / p.facets), 1);
|
|
77
|
+
return {
|
|
78
|
+
outerPts: ngon(Rout, p.facets),
|
|
79
|
+
innerPts: ngon(Rin, p.facets),
|
|
80
|
+
// Inner taper that holds the wall constant top-to-bottom even as the body flares:
|
|
81
|
+
// pick it so inner_radius(top) = outer_radius(top) − wall.
|
|
82
|
+
innerTaper: 1 + (Rout * (p.taper - 1)) / Rin,
|
|
83
|
+
drainR: (p.drain + 0.2) / 2, // nominal hole + 0.2 mm print clearance, as a radius
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
parts: {
|
|
87
|
+
planter: {
|
|
88
|
+
label: "Planter",
|
|
89
|
+
views: ["planter"],
|
|
90
|
+
export: { name: "planter" },
|
|
91
|
+
build: (k, p, d) => {
|
|
92
|
+
const body = k.prism(d.outerPts, p.height, { scaleTop: p.taper, twist: p.twist });
|
|
93
|
+
// Hollow it. The cavity is built from z=0 sharing the body's exact twist RATE and
|
|
94
|
+
// taper slope (f rescales the ~4 mm overshoot so the rates still match), so the
|
|
95
|
+
// inner and outer facets stay radially aligned at every height — the wall can't
|
|
96
|
+
// pinch when twisted. Then clip the cavity to z ≥ floor so the base stays solid.
|
|
97
|
+
const f = (p.height + 4) / p.height;
|
|
98
|
+
const cavity = k
|
|
99
|
+
.prism(d.innerPts, p.height + 4, { scaleTop: 1 + (d.innerTaper - 1) * f, twist: p.twist * f })
|
|
100
|
+
.intersect(k.box([-1e4, -1e4, p.floor], [1e4, 1e4, p.height + 10]));
|
|
101
|
+
let s = body.cut(cavity);
|
|
102
|
+
// Optional drainage hole straight through the base.
|
|
103
|
+
if (p.drain > 0) s = s.cut(k.cylinder(d.drainR, d.drainR, p.floor + 4).at([0, 0, -2]));
|
|
104
|
+
return s;
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
views: { planter: { label: "Planter" } },
|
|
109
|
+
// Self-verification (see docs/AUTHORING-PARTS.md "Self-verification"): opt into the
|
|
110
|
+
// FDM-PLA process profile (bed-fit gate + min-wall warning) and pin the design intent
|
|
111
|
+
// — one drainage hole through the base, fits the bed, no interpenetration.
|
|
112
|
+
verify: {
|
|
113
|
+
process: "fdm-pla",
|
|
114
|
+
expect: {
|
|
115
|
+
planter: { holes: 1 /* drain=8 at defaults → 1 hole; adjust if running verify with a non-default drain */, bbox: "<=[220,220,250]" },
|
|
116
|
+
_view: { overlaps: 0 } /* _view = whole-model composite (not a named part) */,
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
};
|