partforge 0.105.2 → 0.106.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
@@ -15,7 +15,7 @@ use can drive to author, test, and measure a part, then ship it as a browser app
15
15
 
16
16
  ## See it
17
17
 
18
- - **Live showcase** — https://scottsykora.github.io/partforge/ — example forges (Faceted
18
+ - **Live showcase** — https://pixiteapps.github.io/partforge/ — example forges (Faceted
19
19
  Planter, Spacer, Filleted Box) you can open, adjust, and export.
20
20
  - **A real forge** — https://scottsykora.github.io/Drum-Machine/ — a parametric capstan
21
21
  drum built with partforge for a robotics project.
@@ -25,7 +25,7 @@ use can drive to author, test, and measure a part, then ship it as a browser app
25
25
  You don't write CAD by hand. Point a tool-using AI agent (Claude Code, or the Claude /
26
26
  ChatGPT desktop apps with file access) at this repo and describe what you want:
27
27
 
28
- > Using the partforge framework at https://github.com/scottsykora/partforge, build me a
28
+ > Using the partforge framework at https://github.com/pixiteapps/partforge, build me a
29
29
  > _\<your part\>_ — _\<the dimensions, fits, and features that matter\>_.
30
30
 
31
31
  Expect a few turns. The first attempt is often rough, but you refine it in plain language
@@ -736,7 +736,15 @@ Every control accepts `key`, `type`, `label`, `description`, `hidden`, `when` an
736
736
  | `"vector"` | a drop target showing the artwork — no catalog exists | `sourceField` |
737
737
 
738
738
  Numeric controls always show the number box: drag the slider *or* type an exact
739
- value. Typed values may be finer than `step` and clamp to `[min, max]` on commit.
739
+ value. Typed values may be finer than `step` and may sit **outside `[min, max]`**:
740
+ the range bounds the slider track, not the parameter. An out-of-range value commits
741
+ as typed and the build runs with it — the box turns red while the value is outside
742
+ the range, the thumb pins at the nearer end of the track, and a build that can't
743
+ take the value fails in the status line while the viewer keeps the last good
744
+ geometry. A build that *succeeds into nothing* (a boolean that empties a sub-part)
745
+ is reported there too — "empty: body produced no geometry at these values". Author
746
+ `min`/`max` as the span the track should cover, not as a guard the build relies
747
+ on; a build that needs a floor clamps it itself.
740
748
  Text fields write `params` on every keystroke, so the rebuild loop previews the new
741
749
  string immediately; give every text key a string default (empty strings are valid,
742
750
  and the build decides whether its geometry tolerates one).
@@ -826,18 +834,21 @@ for when a raw linear slider misrepresents the parameter.
826
834
  must sit inside `[min, max]`. Add **`snap: true`** to quantize *slider drags* to the
827
835
  nearest tick; the number box stays free, so an off-tick value is always still
828
836
  typeable. Use it for stock sizes: M3/M4/M5, 3 mm / 6 mm plate.
829
- - **`recommended: [lo, hi]`** — tints that span of the track and puts a warning border
830
- on the number box when the value sits outside it. This is the **visual companion to
831
- the DFM checks**: the band is where the process the part targets is comfortable
832
- (minimum wall, nozzle multiples, sane clearances), and `verify`'s `minWall` /
833
- process checks are the same judgement enforced at measure time. It is advisory
834
- outside values remain selectable, because a user who knows their printer should not
835
- be blocked by a default profile.
837
+ - **`recommended: [lo, hi]`** — tints that span of the track. This is the **visual
838
+ companion to the DFM checks**: the band is where the process the part targets is
839
+ comfortable (minimum wall, nozzle multiples, sane clearances), and `verify`'s
840
+ `minWall` / process checks are the same judgement enforced at measure time. It is
841
+ purely advisory it never colours the number box (red is reserved for values
842
+ outside `[min, max]`), and outside values remain selectable, because a user who
843
+ knows their printer should not be blocked by a default profile. Say **why** the
844
+ band sits where it does in the control's `description` ("below 1.2 mm a 0.4 mm
845
+ nozzle prints walls poorly"); the band alone is a hint, the description is the
846
+ reason.
836
847
 
837
848
  `ticks`, `snap` and `recommended` render on a **linear track only**; combined with
838
849
  `scale: "log"` they are ignored, and `slider-refinement-invalid` warns. On a
839
- `"number"` control there is no track at all: `recommended` still tints the box on an
840
- out-of-band value, while `scale`, `ticks` and `snap` do nothing.
850
+ `"number"` control there is no track at all, so `recommended`, `scale`, `ticks` and
851
+ `snap` do nothing.
841
852
 
842
853
  ### Conditions: `when` and `whenFalse`
843
854
 
@@ -1101,6 +1112,17 @@ defaulting everything to a slider:
1101
1112
  - **A continuous dimension** → `"slider"`. Add `recommended` when there's a
1102
1113
  manufacturable band, `ticks` + `snap` when real-world stock sizes exist, and
1103
1114
  `scale: "log"` when the range spans decades.
1115
+
1116
+ **Make the range generous.** `min`/`max` bound the slider track, not the
1117
+ parameter — a user can type past either end and the build runs with it — so the
1118
+ range is the whole span the build can turn into sensible geometry, not the
1119
+ neighbourhood of the default. As a floor, reach at least ¼× and 4× the default,
1120
+ and much further for an open-ended dimension (a length, a height, a count).
1121
+ Where one value must stay below another (a fillet radius against a half-width),
1122
+ clamp it in `build` or `derive` rather than narrowing the range: a narrow range
1123
+ is a limit the user hits immediately, a clamp is one they never notice. Typical
1124
+ values belong in the `description`; a process limit belongs in `recommended`,
1125
+ with the reason in the description.
1104
1126
  - **A precise or very wide number** (a count, a tolerance, a coordinate) →
1105
1127
  `"number"`, so the user types rather than hunts.
1106
1128
  - **A discrete choice** → `"select"` when the values are a list, or `"radio"` when
package/package.json CHANGED
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.105.2",
3
+ "version": "0.106.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",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "git+https://github.com/scottsykora/partforge.git"
9
+ "url": "git+https://github.com/pixiteapps/partforge.git"
10
10
  },
11
- "homepage": "https://github.com/scottsykora/partforge#readme",
12
- "bugs": "https://github.com/scottsykora/partforge/issues",
11
+ "homepage": "https://github.com/pixiteapps/partforge#readme",
12
+ "bugs": "https://github.com/pixiteapps/partforge/issues",
13
13
  "engines": {
14
14
  "node": ">=24"
15
15
  },
@@ -794,6 +794,9 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
794
794
  }
795
795
  }
796
796
 
797
+ // Sub-parts whose latest fresh delivery had zero triangles (see the `meshes` case).
798
+ const emptySubParts = new Set();
799
+
797
800
  function refreshView() {
798
801
  const needed = viewSubParts(part, view(), params);
799
802
  if (needed.every(isCurrent)) {
@@ -838,6 +841,13 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
838
841
  for (const m of data.meshes) {
839
842
  viewer.setSubGeometry(m.name, m); // disposes any previous mesh for this name
840
843
  cache.record(m.name);
844
+ // A sub-part that built into NOTHING. Manifold booleans return an
845
+ // empty solid rather than throwing (a bore wider than its body),
846
+ // and typed values may sit outside the authored range, so a
847
+ // "successful" build can deliver zero triangles — recorded here,
848
+ // reported below once the view is whole. Undefined counts (a
849
+ // host's synthetic reply) are not empty.
850
+ if (m.triangles === 0) emptySubParts.add(m.name); else emptySubParts.delete(m.name);
841
851
  // Stamp the fast path's pose baseline. Only here, inside the
842
852
  // non-stale branch: the stamp must describe the geometry actually
843
853
  // delivered, which buildDone() true guarantees is at the live params.
@@ -848,6 +858,12 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
848
858
  // still be running — often OCCT, the slow one).
849
859
  if (missingParts().length === 0) ui.hideBusy();
850
860
  refreshView();
861
+ // After refreshView, like the error case: its all-current branch
862
+ // clears the status line, and this message has to outlive that.
863
+ if (missingParts().length === 0) {
864
+ const empty = viewSubParts(part, view(), params).filter((n) => emptySubParts.has(n));
865
+ if (empty.length) ui.setStatus(`empty: ${empty.join(", ")} produced no geometry at these values`, true);
866
+ }
851
867
  if (data.ms && missingParts().length === 0) {
852
868
  const tris = viewSubParts(part, view(), params).reduce((s, n) => s + viewer.subTriangles(n), 0);
853
869
  console.debug(`partforge: built ${tris.toLocaleString()} triangles in ${(data.ms / 1000).toFixed(1)} s`);
@@ -1,11 +1,16 @@
1
1
  // slider + number: a range input (omitted for `number`) beside an editable value
2
- // box. The box accepts exact values finer than `step`; typed values clamp to
3
- // [min, max] on commit (blur/Enter).
2
+ // box. The box accepts exact values finer than `step` AND values outside
3
+ // [min, max]: the authored range bounds the slider track, not the parameter.
4
+ // A typed out-of-range value commits as typed (the build gets to try it and
5
+ // fails visibly if it can't), the thumb pins at the nearer end of the track,
6
+ // and the box paints red until the value is back inside the range.
4
7
  import { attachInfo } from "../info.js";
5
8
 
6
9
  // Short numeric string without float noise (4 dp max) for the value box.
7
10
  const numStr = (v) => String(Math.round(v * 1e4) / 1e4);
8
11
 
12
+ // Kept as a public helper (re-exported from controls.js) for hosts; the widget
13
+ // itself no longer clamps typed input — see the header comment.
9
14
  export function clampToRange(raw, min, max) {
10
15
  const v = parseFloat(raw);
11
16
  if (!Number.isFinite(v)) return null;
@@ -31,7 +36,9 @@ export function makeNumeric(node, params, { onChange, onCommit, info }) {
31
36
  const box = document.createElement("input");
32
37
  box.type = "number";
33
38
  box.className = "num";
34
- box.min = node.min; box.max = node.max; box.step = node.step;
39
+ // No native min/max on the box: the range is advisory for typed input, and
40
+ // the attributes would add browser validity styling and clamp the spinner.
41
+ box.step = node.step;
35
42
  box.value = numStr(params[node.key]);
36
43
  val.append(box);
37
44
  if (node.unit) val.append(el("span", "unit", node.unit));
@@ -55,6 +62,11 @@ export function makeNumeric(node, params, { onChange, onCommit, info }) {
55
62
  return Math.max(0, Math.min(LOG_STEPS, Number.isFinite(t) ? t : 0));
56
63
  };
57
64
 
65
+ // Where the thumb sits for a value: pinned at the nearer end when the value
66
+ // is outside [min, max] (a range input sanitises this itself, but jsdom and
67
+ // the log mapping don't, so be explicit).
68
+ const thumbFor = (v) => log ? toPosSafe(v) : Math.max(node.min, Math.min(node.max, v));
69
+
58
70
  let slider = null;
59
71
  if (!numeric) {
60
72
  slider = document.createElement("input");
@@ -91,8 +103,9 @@ export function makeNumeric(node, params, { onChange, onCommit, info }) {
91
103
  return node.ticks.reduce((best, t) => Math.abs(t - v) < Math.abs(best - v) ? t : best);
92
104
  };
93
105
 
94
- // recommended: a tinted band of the track, and a warning on the value box
95
- // when the current value sits outside it. Linear tracks only (like ticks).
106
+ // recommended: a tinted band of the track purely visual; it never colours
107
+ // the value box (that is reserved for values outside [min, max] below).
108
+ // Linear tracks only (like ticks).
96
109
  const band = !log && Array.isArray(node.recommended) && node.recommended.length === 2
97
110
  ? node.recommended : null;
98
111
  if (band) {
@@ -104,25 +117,28 @@ export function makeNumeric(node, params, { onChange, onCommit, info }) {
104
117
  wrap.style.setProperty("--band-lo", pct(band[0]));
105
118
  wrap.style.setProperty("--band-hi", pct(band[1]));
106
119
  }
120
+ // The box goes red only when the value is outside the authored range — a
121
+ // typed overshoot, or a preset/host value the range never covered.
107
122
  const paintWarn = () => {
108
- if (band) box.classList.toggle("warn", params[node.key] < band[0] || params[node.key] > band[1]);
123
+ const v = params[node.key];
124
+ box.classList.toggle("warn", v < node.min || v > node.max);
109
125
  };
110
126
 
111
- // live preview while typing (unclamped); clamp + reformat on commit
127
+ // live preview while typing; reformat (never clamp) on commit
112
128
  box.addEventListener("input", () => {
113
129
  const v = parseFloat(box.value);
114
130
  if (!Number.isFinite(v)) return;
115
131
  params[node.key] = v;
116
- if (slider) slider.value = log ? toPosSafe(v) : v;
132
+ if (slider) slider.value = thumbFor(v);
117
133
  paintWarn();
118
134
  onChange?.();
119
135
  });
120
136
  box.addEventListener("change", () => {
121
- const v = clampToRange(box.value, node.min, node.max);
122
- if (v == null) { box.value = numStr(params[node.key]); return; } // revert invalid input
137
+ const v = parseFloat(box.value);
138
+ if (!Number.isFinite(v)) { box.value = numStr(params[node.key]); return; } // revert invalid input
123
139
  params[node.key] = v;
124
140
  box.value = numStr(v);
125
- if (slider) slider.value = log ? toPosSafe(v) : v;
141
+ if (slider) slider.value = thumbFor(v);
126
142
  paintWarn();
127
143
  onChange?.();
128
144
  onCommit?.();
@@ -130,7 +146,7 @@ export function makeNumeric(node, params, { onChange, onCommit, info }) {
130
146
 
131
147
  const sync = () => {
132
148
  box.value = numStr(params[node.key]);
133
- if (slider) slider.value = log ? toPosSafe(params[node.key]) : params[node.key];
149
+ if (slider) slider.value = thumbFor(params[node.key]);
134
150
  paintWarn();
135
151
  };
136
152
  paintWarn();
package/src/parts/demo.js CHANGED
@@ -13,7 +13,7 @@ export default {
13
13
  presets: { M3: { od: 8, bore: 3.4, h: 10 }, M5: { od: 12, bore: 5.4, h: 16 } },
14
14
  advanced: [
15
15
  { key: "od", label: "Outer diameter", unit: "mm", min: 4, max: 40, step: 0.5,
16
- description: "Barrel outer diameter. Keep it comfortably larger than the bore so a wall remains. See the [authoring guide](https://github.com/scottsykora/partforge/blob/main/docs/AUTHORING-PARTS.md)." },
16
+ description: "Barrel outer diameter. Keep it comfortably larger than the bore so a wall remains. See the [authoring guide](https://github.com/pixiteapps/partforge/blob/main/docs/AUTHORING-PARTS.md)." },
17
17
  { key: "bore", label: "Bore", unit: "mm", min: 1, max: 30, step: 0.1, control: "number",
18
18
  description: "Nominal screw clearance hole. A fixed print clearance is added automatically (see `derive`), so enter the *nominal* size." },
19
19
  { key: "h", label: "Height", unit: "mm", min: 2, max: 60, step: 1,