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 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; run `npm run dev` and open
51
- `/demo.html` to see it live.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
+ });
@@ -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: #3b82f6; --on-accent: #fff; --input-bg: #161a20; --err: #f8746c;
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: #3b82f6; --on-accent: #fff; --input-bg: #ffffff; --err: #d8453d;
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: 252px;
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 { color: var(--muted); font-size: 11px; margin: 0 0 12px; }
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; font-size: 12px;
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: 9px 10px;
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 { font-weight: 600; color: var(--text-strong); margin-bottom: 7px; }
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: 5px 7px; font-size: 11px;
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: 9px; border-left: 2px solid var(--border); }
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: 3px 0; width: 100%; border: 0; border-radius: 5px;
58
- background: transparent; color: var(--muted); cursor: pointer; font-size: 11px;
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: 7px 0; }
75
+ .slider { margin: 9px 0; }
66
76
  .row { display: flex; justify-content: space-between; align-items: center;
67
- margin: 0 0 3px; gap: 8px; }
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: 52px; text-align: right; font: inherit; font-variant-numeric: tabular-nums;
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: 2px 5px;
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: 11px; }
79
- input[type="range"] { width: 100%; }
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: 12px; }
89
- .dl-head { font-weight: 600; color: var(--text-strong); margin-bottom: 6px; }
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); font-weight: 600;
94
- font-size: 12px; cursor: pointer;
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: 10px; min-height: 16px; color: var(--status); font-size: 11px; }
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
- return T(cs.extrude(height, nDiv, twist, scaleTop));
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
+ };
@@ -0,0 +1,3 @@
1
+ import part from "./parts/planter.js";
2
+ import { runWorker } from "./framework/worker.js";
3
+ runWorker(part);