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 +2 -2
- package/docs/AUTHORING-PARTS.md +32 -10
- package/package.json +4 -4
- package/src/framework/mount.js +16 -0
- package/src/framework/panel/widgets/numeric.js +28 -12
- package/src/parts/demo.js +1 -1
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://
|
|
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/
|
|
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
|
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -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
|
|
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
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
outside values remain selectable, because a user who
|
|
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
|
|
840
|
-
|
|
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.
|
|
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/
|
|
9
|
+
"url": "git+https://github.com/pixiteapps/partforge.git"
|
|
10
10
|
},
|
|
11
|
-
"homepage": "https://github.com/
|
|
12
|
-
"bugs": "https://github.com/
|
|
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
|
},
|
package/src/framework/mount.js
CHANGED
|
@@ -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
|
|
3
|
-
// [min, max]
|
|
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
|
-
|
|
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
|
|
95
|
-
//
|
|
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
|
-
|
|
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 (
|
|
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 =
|
|
132
|
+
if (slider) slider.value = thumbFor(v);
|
|
117
133
|
paintWarn();
|
|
118
134
|
onChange?.();
|
|
119
135
|
});
|
|
120
136
|
box.addEventListener("change", () => {
|
|
121
|
-
const v =
|
|
122
|
-
if (v
|
|
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 =
|
|
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 =
|
|
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/
|
|
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,
|