partforge 0.20.1 → 0.20.2

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.
@@ -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 + sliders section:**
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: [ // sliders revealed under "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
- Each slider/feature control shows an **editable number box** beside it — drag the
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: omit it (or `"slider"`) for a slider
336
- + box; `"number"` for a box only (no slider — handy for precise or wide-range values).
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.20.1",
3
+ "version": "0.20.2",
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",
@@ -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 = makeSlider(def, params, () => { if (preset) preset.value = "Custom"; onDirty?.(); }, info);
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 = makeSlider(def, params, onDirty, info);
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
- handleRoot.updateWorldMatrix(true, true);
300
- const intersection = raycaster.intersectObjects(hitProxies, false)[0];
301
- return resolveHandle(intersection);
304
+ return null;
302
305
  }
303
306
 
304
307
  function safeCapture(pointerId) {
@@ -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: "The text is a two-line label (`PARTFORGE` over `v0.20`). It is resolved to exact glyph curves counters in **A R O G** and the **0** stay open — and sized by cap height.",
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(LABEL, { size: p.size, align: "center", valign: "middle", lineHeight: p.size * 1.7 });
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) {