partforge 0.1.0 → 0.3.3

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
@@ -45,7 +45,9 @@ boots with no errors. Needs Playwright: `npm i -D playwright && npx playwright i
45
45
 
46
46
  **[docs/AUTHORING-PARTS.md](docs/AUTHORING-PARTS.md)** is the full guide — the part
47
47
  contract, the geometry kernel API, the parameter schema, app wiring, testing, and
48
- gotchas. `src/parts/demo.js` is a minimal worked example; run `npm run dev` and open
48
+ gotchas. See **Designing the control panel** in that guide for how to write descriptions,
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
49
51
  `/demo.html` to see it live.
50
52
 
51
53
  ## License
package/bin/cli.js ADDED
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env node
2
+ import { pathToFileURL } from "node:url";
3
+ import { resolve } from "node:path";
4
+ import { writeFileSync } from "node:fs";
5
+ import Module from "manifold-3d";
6
+ import { createManifoldKernel } from "../src/framework/geometry/manifold-backend.js";
7
+ import { detectBackend } from "../src/framework/geometry/probe.js";
8
+ import { bootOcctKernel } from "../src/testing/occt.js";
9
+ import { measure } from "../src/testing/measure.js";
10
+ import { renderViews } from "../src/testing/render.js";
11
+
12
+ const die = (msg) => { console.error(msg); process.exit(1); };
13
+ const slug = (s) => String(s).toLowerCase().replace(/\s+/g, "-");
14
+
15
+ const [, , cmd, partPath, ...rest] = process.argv;
16
+ const flags = {};
17
+ const positional = [];
18
+ for (let i = 0; i < rest.length; i++) {
19
+ if (rest[i].startsWith("--")) {
20
+ const key = rest[i].slice(2);
21
+ flags[key] = rest[i + 1] && !rest[i + 1].startsWith("--") ? rest[++i] : true;
22
+ } else positional.push(rest[i]);
23
+ }
24
+ const view = positional[0];
25
+
26
+ if (!["measure", "render"].includes(cmd)) die("usage: partforge <measure|render> <part-module> [view] [flags]");
27
+ if (!partPath) die(`usage: partforge ${cmd} <part-module> [view]`);
28
+
29
+ const mod = await import(pathToFileURL(resolve(process.cwd(), partPath)))
30
+ .catch((e) => die(`cannot load part "${partPath}": ${e.message}`));
31
+ const part = mod.default;
32
+ if (!part?.parts || !part?.views) die(`"${partPath}" has no default-exported PartDefinition`);
33
+
34
+ let kernel;
35
+ if (detectBackend(part) === "occt") {
36
+ kernel = await bootOcctKernel();
37
+ } else {
38
+ const wasm = await Module(); wasm.setup();
39
+ kernel = createManifoldKernel(wasm, { quality: "preview" });
40
+ }
41
+
42
+ try {
43
+ if (cmd === "measure") {
44
+ const report = measure(kernel, part, view);
45
+ printMeasure(report);
46
+ const file = `measure-${slug(report.part)}-${report.view}.json`;
47
+ writeFileSync(file, JSON.stringify(report, null, 2));
48
+ console.log(`\nwrote ${file}`);
49
+ if (flags.json) console.log(JSON.stringify(report, null, 2));
50
+ process.exit(report.ok ? 0 : 1);
51
+ } else {
52
+ const views = typeof flags.views === "string" ? flags.views.split(",") : undefined;
53
+ const files = await renderViews(kernel, part, view, { views, out: flags.out || "render" });
54
+ for (const f of files) console.log(`wrote ${f}`);
55
+ process.exit(0);
56
+ }
57
+ } catch (e) {
58
+ die(`${cmd} failed: ${e.message || e}`);
59
+ }
60
+
61
+ function printMeasure(r) {
62
+ console.log(`${r.part} / ${r.view}`);
63
+ for (const s of r.subparts) {
64
+ const wt = s.watertight === null ? "watertight n/a" : (s.watertight ? "watertight ✓" : "NOT watertight ✗");
65
+ const holes = s.holes === null ? "holes n/a" : `holes ${s.holes}`;
66
+ console.log(` ${s.name} bbox ${s.bbox.map((n) => n.toFixed(1)).join("×")} ` +
67
+ `vol ${(s.volume / 1000).toFixed(2)}cm³ area ${(s.surfaceArea / 100).toFixed(1)}cm² ` +
68
+ `tris ${s.triangleCount} ${wt} ${holes}`);
69
+ }
70
+ const a = r.aggregate;
71
+ console.log(` ── view bbox ${a.bbox.map((n) => n.toFixed(1)).join("×")} vol ${(a.volume / 1000).toFixed(2)}cm³ tris ${a.triangleCount}`);
72
+ console.log(` overlaps: ${r.overlaps.length ? r.overlaps.map((o) => `${o.a}×${o.b} (${o.volume.toFixed(1)}mm³)`).join(", ") : "none"}`);
73
+ }
@@ -50,6 +50,7 @@ export default {
50
50
  place?: (solid, { view, purpose, p, d }) => Solid, // optional reposition; default identity
51
51
  views, // string[] — which views show this sub-part
52
52
  enabled?: (p) => boolean, // optional — gate a conditional sub-part
53
+ display?: { color?, opacity? }, // optional viewer-only override (0xRRGGBB / 0..1) — e.g. a reference/ghost part
53
54
  export?: { name }, // filename/object name on export; defaults to the key
54
55
  },
55
56
  },
@@ -88,6 +89,8 @@ handles. The same code runs on **Manifold** (fast meshes — preview + STL + 3MF
88
89
  | `k.cylinder(rBottom, rTop, h, { center? })` | cylinder/cone along +Z (frustum if radii differ) |
89
90
  | `k.box(min, max)` | axis-aligned box from `[x,y,z]` min/max |
90
91
  | `k.prism(points2D, h)` | extrude a 2-D polygon (CCW `[[x,y],…]`) from z=0 |
92
+ | `k.sphere(r)` | sphere centred at the origin |
93
+ | `k.revolve(points2D, { degrees })` | revolve a lathe profile `[[r,z],…]` (r ≥ 0) around the Z axis (full or partial) |
91
94
  | `k.helixSweptTube({ pathR, profileR, pitch, turns, z0, lefthand })` | circle swept along a helix (e.g. a rope groove) |
92
95
  | `k.union(solids[])` | boolean union |
93
96
 
@@ -105,6 +108,8 @@ entry pulls in the DOM viewer/controls, and your build functions run in a Web Wo
105
108
  | `s.translate([x,y,z])` | move |
106
109
  | `s.rotate(deg, center, axis)` | rotate `deg` about `axis` through `center` |
107
110
  | `s.mirror("XY"\|"XZ"\|"YZ")` | mirror across a plane |
111
+ | `s.clone()` | independent copy (replicad consumes solids on transform) |
112
+ | `s.boundingBox()` | `{ min, max, center, size }` axis-aligned bounds (query) |
108
113
  | `s.volume()` | volume in mm³ (Manifold) |
109
114
  | `s.toMesh({ quality })` / `s.toSTL({ quality })` / `s.toIndexedMesh()` | meshes / STL / indexed mesh (3MF) — the framework calls these |
110
115
  | `k.toSTEP(named[])` | STEP bytes (OCCT only) — the framework calls this |
@@ -151,8 +156,108 @@ slider or type an exact value (finer than `step` is allowed; typed values clamp
151
156
  }
152
157
  ```
153
158
 
154
- Every `key` used must exist in `defaults`. (The drum's schema is exported as `SECTIONS`
155
- in `src/parts/drum/params.js` if you want a large reference.)
159
+ Every `key` used must exist in `defaults`. `src/parts/demo.js` is the worked example for
160
+ everything below.
161
+
162
+ **Control metadata (optional — on any control def, feature, or section):**
163
+
164
+ - `description` — a CommonMark string shown in a click-open **ⓘ** popover beside the
165
+ label. Supports **bold/italic**, lists, `code`, links, and images (for diagrams);
166
+ links open in a new tab and the rendered HTML is sanitized. Write one for every
167
+ control — see "A description for every control" below.
168
+ - `hidden: true` — omits the control/feature/section from the panel. Its `key` must still
169
+ exist in `defaults` and still drives the geometry: use it for internal constants the
170
+ end user shouldn't edit (it is *no UI*, not *no parameter*). A section left with no
171
+ presets and no visible controls doesn't render at all.
172
+
173
+ ```js
174
+ advanced: [
175
+ { key: "od", label: "Outer diameter", unit: "mm", min: 4, max: 40, step: 0.5,
176
+ description: "Barrel OD. Keep it larger than the bore so a wall remains. See the [guide](https://example.com)." },
177
+ { key: "wall_seg", min: 8, max: 256, step: 1, hidden: true, // internal constant; no UI, still in defaults
178
+ description: "Facet count — fixed by the design." },
179
+ ],
180
+ ```
181
+
182
+ ---
183
+
184
+ ## Designing the control panel
185
+
186
+ A good part exposes a **simple** interface — a handful of controls most users will
187
+ touch — while still giving deep, correct adjustability underneath. `src/parts/demo.js`
188
+ is the worked example for the patterns below.
189
+
190
+ ### Procedural & parametric parts
191
+
192
+ Drive many features from a few controls, so tweaking one control reshapes the part
193
+ coherently:
194
+
195
+ - **`derive(p) => d`** computes shared/dependent values once per build; sub-part `build`
196
+ functions read `d`. Put the "design intent" math here — clearances, ratios, wall
197
+ thicknesses — so a single input feeds everything downstream. In the demo, `derive`
198
+ turns the nominal `bore` into `boreR` (with a fixed print clearance) and `h` into the
199
+ cut-tool height `cutH`; `build(k, p, d)` reads those.
200
+ - **Reuse a param `key`** across sub-parts/features so one slider moves all of them.
201
+ - **`enabled(p)`** gates a whole sub-part on a toggle param (the part appears/disappears
202
+ with the control).
203
+
204
+ ### Progressive disclosure (simple, but deep)
205
+
206
+ Tier the controls so the default view is uncluttered:
207
+
208
+ 1. **Presets** for the common cases — the first thing most users pick.
209
+ 2. A **few primary sliders** for the dimensions users change most.
210
+ 3. **`Advanced`** (the collapsible block) for the rest.
211
+ 4. **`hidden`** for internal constants the end user shouldn't edit.
212
+
213
+ Aim for a panel with a few visible controls that still exposes the full design when
214
+ someone opens Advanced.
215
+
216
+ ### A description for every control
217
+
218
+ Give every section and control a `description`. Keep each one short and make it cover:
219
+
220
+ - **what** the control does,
221
+ - its **units**,
222
+ - a **sensible range** (and what's typical),
223
+ - **when it matters** (what it interacts with).
224
+
225
+ Use Markdown links or images for diagrams and deeper reference. These are the popovers
226
+ end users rely on — treat writing them as part of authoring the control, not an
227
+ afterthought.
228
+
229
+ ### The relevance-aware panel
230
+
231
+ The panel updates itself to match what's on screen: a **section is hidden** when none of
232
+ its controls affect the active view's visible parts, and a **control is dimmed** (but
233
+ still usable) when it doesn't currently affect them — recomputed as the view and the
234
+ parameters change. You don't wire this up; it's automatic. To get the most from it:
235
+
236
+ - Group controls into **sections by the sub-parts they affect**, so whole sections drop
237
+ away in views that don't use them.
238
+ - Scope a parameter to the **views/sub-parts that read it** — a control read by no
239
+ on-screen part shows dimmed, which is a useful signal that it's vestigial or
240
+ misplaced.
241
+
242
+ ---
243
+
244
+ ## Profiles & patterns
245
+
246
+ Pure helpers from `partforge/geometry` (no backend dependency):
247
+
248
+ **2-D profiles** (CCW point arrays for `k.prism` / `k.revolve`):
249
+ `roundedRectPolygon(w,h,r)`, `regularPolygon(n,r,{flat})`, `ellipsePolygon(rx,ry)`,
250
+ `slotPolygon(length,r)` (overall length = `length + 2r`), `starPolygon(points,outerR,innerR)`,
251
+ `ringSectorPolygon(innerR,outerR,arcDeg)` (**arcDeg < 360** — a full ring is a contour-with-hole;
252
+ cut an inner cylinder from an outer one instead).
253
+
254
+ **Patterns** (return `Solid[]` — feed to `k.union(...)` for features or `s.cutAll(...)` for holes):
255
+ `linearPattern(solid, count, [dx,dy,dz])`, `circularPattern(solid, count, { center, axis, angle, rotateCopies })`.
256
+
257
+ ```js
258
+ const hole = k.cylinder(2, 2, 20).translate([20, 0, 0]);
259
+ body = body.cutAll(circularPattern(hole, 8, { axis: "Z" })); // 8 bolt holes on a 40mm circle
260
+ ```
156
261
 
157
262
  ---
158
263
 
@@ -254,6 +359,81 @@ own files (vitest isolates files). For Manifold↔OCCT volume parity, see `test/
254
359
 
255
360
  ---
256
361
 
362
+ ## Verifying a part headlessly (render + measure)
363
+
364
+ Once the package is installed you get two CLI commands that build your part in
365
+ pure Node (no dev server, no browser) so you — or an LLM authoring the part — can
366
+ check it without opening the app:
367
+
368
+ npx partforge measure src/parts/<part>.js [view] # geometric facts
369
+ npx partforge render src/parts/<part>.js [view] # canonical-angle PNGs
370
+
371
+ `measure` prints a report and writes `measure-<part>-<view>.json`: per sub-part
372
+ and per view it reports bounding box, volume, surface area, triangle count,
373
+ whether the solid is watertight, and the number of through-holes (genus), plus an
374
+ assembly overlap check. It exits non-zero if any sub-part isn't watertight or any
375
+ parts interpenetrate — so it doubles as a CI/agent gate. (Manifold output is
376
+ manifold by construction, so `watertight` is mainly a build-sanity check for
377
+ empty/degenerate results; `holes` is the informative topology number.)
378
+
379
+ `render` writes one PNG per angle (`iso`, `front`, `top` by default; choose with
380
+ `--views iso,front`, output dir with `--out`) to `render/`. The view defaults to
381
+ the part's first declared view.
382
+
383
+ The `measure` function is also exported for vitest (boot a Manifold kernel as in
384
+ "Testing a part", then `measure(kernel, part, "<view>")`):
385
+
386
+ import { measure } from "partforge/testing";
387
+ test("part is sound", () => {
388
+ const r = measure(kernel, part, "<view>");
389
+ expect(r.ok).toBe(true);
390
+ expect(r.subparts[0].holes).toBe(1); // e.g. expects one bore
391
+ });
392
+
393
+ ---
394
+
395
+ ## Fillet & chamfer (automatic OCCT backend)
396
+
397
+ Two backends build your part: **Manifold** (fast meshes — preview, STL, 3MF) and
398
+ **OCCT/replicad** (exact B-rep — STEP). Most parts run on Manifold. But Manifold has no
399
+ fillet, so if your `build` calls a **CAD-only op** the framework automatically routes the
400
+ whole part to OCCT — no declaration needed:
401
+
402
+ | Op | Meaning |
403
+ |---|---|
404
+ | `s.fillet(radius, selector?)` | round edges (curve-following, exact) |
405
+ | `s.chamfer(distance, selector?)` | bevel edges |
406
+ | `s.shell(thickness, openFaces)` | hollow inward, wall = `thickness`; `openFaces` selector (`{inPlane,at}`/`{dir}`/`{near}`) chooses which face(s) to open. Closed (no-open-face) hollows are not supported. |
407
+
408
+ `selector` chooses which edges (omit it for **all** edges):
409
+
410
+ - `{ dir: "X"|"Y"|"Z" }` — edges running along an axis (e.g. `{dir:"Z"}` = the vertical edges)
411
+ - `{ inPlane: "XY"|"XZ"|"YZ", at }` — edges lying in a plane (e.g. base edges: `{inPlane:"XY", at:0}`)
412
+ - `{ near: [x,y,z] }` — edges passing through a point
413
+ - a raw `(edgeFinder) => edgeFinder` replicad finder, for anything fancier
414
+
415
+ ```js
416
+ let s = k.box([0,0,0],[40,30,16]);
417
+ s = s.fillet(3, { dir: "Z" }); // round the 4 vertical edges
418
+ s = s.chamfer(1, { inPlane: "XY", at: 0 }); // bevel the base
419
+ ```
420
+
421
+ See `src/parts/filleted-box.js` for the worked example.
422
+
423
+ **Automatic backend selection.** Before building, the framework runs a geometry-free *probe*
424
+ of your `build` to see whether it uses a CAD-only op, and routes accordingly — Manifold for
425
+ everything else (so sweep-heavy parts like the drum stay fast). Force it with
426
+ `meta.backend: "occt" | "manifold"` if you ever need to. Because an OCCT part is built
427
+ entirely on OCCT, its fillets are exact in the STEP **and** present in the printed STL.
428
+
429
+ > Trade-off: OCCT is much slower on heavy swept geometry (helical grooves), so don't reach for
430
+ > `fillet`/`chamfer` on a sweep-heavy part — design those edges in, or keep the part on Manifold.
431
+
432
+ > `partforge measure` reports `watertight`/`holes` as `n/a` for OCCT parts (Manifold-only
433
+ > topology); `render` works on both.
434
+
435
+ ---
436
+
257
437
  ## Conventions & gotchas
258
438
 
259
439
  - **replicad (OCCT) transforms consume their input.** `s.translate/.rotate/.mirror/.cut`
package/package.json CHANGED
@@ -1,14 +1,18 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.1.0",
3
+ "version": "0.3.3",
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
+ "repository": { "type": "git", "url": "git+https://github.com/scottsykora/partforge.git" },
8
+ "homepage": "https://github.com/scottsykora/partforge#readme",
9
+ "bugs": "https://github.com/scottsykora/partforge/issues",
7
10
  "engines": {
8
11
  "node": ">=24"
9
12
  },
10
13
  "files": [
11
14
  "src",
15
+ "bin",
12
16
  "docs/AUTHORING-PARTS.md",
13
17
  "README.md"
14
18
  ],
@@ -18,6 +22,9 @@
18
22
  "./geometry": "./src/framework/geometry/polygon.js",
19
23
  "./testing": "./src/testing.js"
20
24
  },
25
+ "bin": {
26
+ "partforge": "./bin/cli.js"
27
+ },
21
28
  "scripts": {
22
29
  "dev": "vite",
23
30
  "build": "vite build",
@@ -27,13 +34,17 @@
27
34
  "check": "node scripts/check-app.mjs"
28
35
  },
29
36
  "dependencies": {
37
+ "dompurify": "^3.4.11",
30
38
  "fflate": "^0.8.3",
31
39
  "manifold-3d": "^3.5.1",
40
+ "marked": "^18.0.5",
41
+ "pngjs": "^7.0.0",
32
42
  "replicad": "^0.23.1",
33
43
  "replicad-opencascadejs": "^0.23.0",
34
44
  "three": "^0.184.0"
35
45
  },
36
46
  "devDependencies": {
47
+ "happy-dom": "^20.10.6",
37
48
  "playwright": "^1.49.0",
38
49
  "vite": "^8.0.16",
39
50
  "vitest": "^4.1.9"
@@ -0,0 +1,7 @@
1
+ import part from "./parts/filleted-box.js";
2
+ import { mount } from "./framework/index.js";
3
+
4
+ mount(part, {
5
+ createWorker: (name) =>
6
+ new Worker(new URL("./filleted-box-worker.js", import.meta.url), { type: "module", name }),
7
+ });
@@ -0,0 +1,3 @@
1
+ import part from "./parts/filleted-box.js";
2
+ import { runWorker } from "./framework/worker.js";
3
+ runWorker(part);
@@ -138,4 +138,29 @@ button.action:disabled { opacity: .5; cursor: default; }
138
138
  animation: spin 0.9s linear infinite;
139
139
  }
140
140
  #busy .phase { color: var(--text); font-size: 13px; text-shadow: 0 1px 6px rgba(0,0,0,.6); }
141
- @keyframes spin { to { transform: rotate(360deg); } }
141
+ @keyframes spin { to { transform: rotate(360deg); } }
142
+
143
+ /* info glyph + description popover */
144
+ .info {
145
+ appearance: none; border: none; background: none; cursor: pointer;
146
+ color: var(--muted); font-size: 12px; line-height: 1; padding: 0 0 0 5px;
147
+ vertical-align: middle;
148
+ }
149
+ .info:hover { color: var(--text-2); }
150
+ .info:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; }
151
+ .popover {
152
+ position: fixed; z-index: 50; max-width: 280px; max-height: 50vh; overflow: auto;
153
+ background: var(--surface); color: var(--text); border: 1px solid var(--border);
154
+ border-radius: 8px; padding: 10px 12px; box-shadow: 0 6px 24px rgba(0,0,0,.35);
155
+ font-size: 12px; line-height: 1.5;
156
+ }
157
+ .popover[hidden] { display: none; }
158
+ .popover img { max-width: 100%; height: auto; border-radius: 4px; }
159
+ .popover a { color: var(--accent); }
160
+ .popover p:first-child { margin-top: 0; }
161
+ .popover p:last-child { margin-bottom: 0; }
162
+
163
+ /* relevance: controls/sections that don't affect the on-screen parts */
164
+ .irrelevant { opacity: 0.45; }
165
+ .irrelevant:hover { opacity: 0.7; }
166
+ .section-hidden { display: none; }
@@ -6,9 +6,40 @@
6
6
  // enables it and reveals those controls right below it.
7
7
  // All controls mutate the shared `params` object and call onDirty() on change.
8
8
 
9
+ import { renderMarkdown } from "./markdown.js";
10
+
9
11
  // Short numeric string without float noise (4 dp max) for the value box.
10
12
  const numStr = (v) => String(Math.round(v * 1e4) / 1e4);
11
13
 
14
+ // --- relevance (dim controls / hide sections that don't affect on-screen parts) ---
15
+ // `relevant` is a Set of param keys, or any non-Set value (e.g. RELEVANT_ALL) → show all.
16
+ function applyRelevance(relevant, controls, sections) {
17
+ const showAll = !(relevant instanceof Set);
18
+ for (const { key, el: node } of controls) {
19
+ const irrelevant = !showAll && !relevant.has(key);
20
+ node.classList.toggle("irrelevant", irrelevant);
21
+ if (irrelevant) node.title = "Doesn't affect the parts in the current view";
22
+ else node.removeAttribute("title");
23
+ }
24
+ for (const { el: node, keys } of sections) {
25
+ const anyRelevant = showAll || [...keys].some((k) => relevant.has(k));
26
+ node.classList.toggle("section-hidden", !anyRelevant);
27
+ }
28
+ }
29
+
30
+ // --- visibility (hidden controls/sections) --------------------------------
31
+ export const visibleAdvanced = (sec) => (sec.advanced ?? []).filter((d) => !d.hidden);
32
+ export const visibleFeatures = (sec) => (sec.features ?? []).filter((f) => !f.hidden);
33
+ // Standalone toggle checkboxes a preset section can show (outside the Advanced fold),
34
+ // e.g. preview switches. Each: { key, label, on?, description?, hidden? }.
35
+ export const visibleToggles = (sec) => (sec.toggles ?? []).filter((t) => !t.hidden);
36
+ export function sectionRenders(sec) {
37
+ if (sec.hidden) return false;
38
+ if (sec.features) return visibleFeatures(sec).length > 0;
39
+ const hasPresets = sec.presets && Object.keys(sec.presets).length > 0;
40
+ return !!hasPresets || visibleAdvanced(sec).length > 0 || visibleToggles(sec).length > 0;
41
+ }
42
+
12
43
  // Parse a typed value → clamped to [min, max], or null if not a finite number.
13
44
  export function clampToRange(raw, min, max) {
14
45
  const v = parseFloat(raw);
@@ -16,6 +47,58 @@ export function clampToRange(raw, min, max) {
16
47
  return Math.min(max, Math.max(min, v));
17
48
  }
18
49
 
50
+ // --- info glyph + shared popover ------------------------------------------
51
+ // One popover element, reused across glyphs (only one open at a time). Global
52
+ // dismiss listeners are registered once at module load.
53
+ let popover = null;
54
+ function ensurePopover() {
55
+ if (!popover || !popover.isConnected) {
56
+ popover = document.createElement("div");
57
+ popover.className = "popover";
58
+ popover.hidden = true;
59
+ document.body.append(popover);
60
+ }
61
+ return popover;
62
+ }
63
+ function closePopover() {
64
+ if (popover && !popover.hidden) {
65
+ popover.hidden = true;
66
+ if (popover._owner) { popover._owner.setAttribute("aria-expanded", "false"); popover._owner = null; }
67
+ }
68
+ }
69
+ if (typeof document !== "undefined") {
70
+ document.addEventListener("click", (e) => {
71
+ if (popover && !popover.hidden && !popover.contains(e.target) && !e.target.closest?.(".info")) closePopover();
72
+ });
73
+ document.addEventListener("keydown", (e) => { if (e.key === "Escape") closePopover(); });
74
+ }
75
+
76
+ // Append a focusable ⓘ glyph to `container` that toggles the shared popover with
77
+ // `description` (Markdown). No-op when description is empty.
78
+ function attachInfo(container, description) {
79
+ if (typeof description !== "string" || !description.trim()) return;
80
+ const glyph = document.createElement("button");
81
+ glyph.type = "button";
82
+ glyph.className = "info";
83
+ glyph.textContent = "ⓘ";
84
+ glyph.setAttribute("aria-label", "More info");
85
+ glyph.setAttribute("aria-expanded", "false");
86
+ glyph.addEventListener("click", (e) => {
87
+ e.stopPropagation();
88
+ const pop = ensurePopover();
89
+ if (pop._owner === glyph) { closePopover(); return; } // toggle off
90
+ closePopover();
91
+ pop.innerHTML = renderMarkdown(description);
92
+ pop.hidden = false;
93
+ pop._owner = glyph;
94
+ glyph.setAttribute("aria-expanded", "true");
95
+ const r = glyph.getBoundingClientRect();
96
+ pop.style.top = `${r.bottom + 6}px`;
97
+ pop.style.left = `${Math.max(8, r.left - 8)}px`;
98
+ });
99
+ container.append(glyph);
100
+ }
101
+
19
102
  function el(tag, className, text) {
20
103
  const node = document.createElement(tag);
21
104
  if (className) node.className = className;
@@ -32,7 +115,9 @@ function makeSlider(def, params, onChange) {
32
115
  const numeric = def.control === "number";
33
116
  const wrap = el("div", "slider");
34
117
  const row = el("div", "row");
35
- row.append(el("label", "", def.label));
118
+ const label = el("label", "", def.label);
119
+ attachInfo(label, def.description);
120
+ row.append(label);
36
121
 
37
122
  // editable value box (+ optional unit suffix)
38
123
  const val = el("div", "val");
@@ -96,70 +181,102 @@ function advancedBlock() {
96
181
  }
97
182
 
98
183
  export function buildControls(root, parameters, params, onDirty) {
184
+ const controls = []; // { key, el } per control element
185
+ const sections = []; // { el, keys:Set } per rendered section
99
186
  for (const sec of parameters) {
187
+ if (!sectionRenders(sec)) continue;
100
188
  const section = el("div", "section");
101
- section.append(el("div", "sec-title", sec.title));
102
- if (sec.features) buildFeatureSection(section, sec, params, onDirty);
103
- else buildPresetSection(section, sec, params, onDirty);
189
+ const title = el("div", "sec-title", sec.title);
190
+ attachInfo(title, sec.description);
191
+ section.append(title);
192
+ const keys = new Set();
193
+ const register = (key, node) => { controls.push({ key, el: node }); keys.add(key); };
194
+ if (sec.features) buildFeatureSection(section, sec, params, onDirty, register);
195
+ else buildPresetSection(section, sec, params, onDirty, register);
104
196
  root.append(section);
197
+ sections.push({ el: section, keys });
105
198
  }
199
+ return { applyRelevance: (relevant) => applyRelevance(relevant, controls, sections) };
106
200
  }
107
201
 
108
- function buildPresetSection(section, sec, params, onDirty) {
109
- // preset picker, below the title, full width
110
- const preset = document.createElement("select");
111
- preset.className = "preset";
112
- const presetNames = Object.keys(sec.presets);
113
- for (const name of [...presetNames, "Custom"]) {
114
- const o = document.createElement("option");
115
- o.value = name;
116
- o.textContent = name;
117
- preset.append(o);
202
+ function buildPresetSection(section, sec, params, onDirty, register) {
203
+ // preset picker, below the title, full width (omitted when the section has no presets)
204
+ let preset = null;
205
+ const presetNames = sec.presets ? Object.keys(sec.presets) : [];
206
+ if (presetNames.length) {
207
+ preset = document.createElement("select");
208
+ preset.className = "preset";
209
+ for (const name of [...presetNames, "Custom"]) {
210
+ const o = document.createElement("option");
211
+ o.value = name; o.textContent = name; preset.append(o);
212
+ }
213
+ preset.value = presetNames[0];
214
+ section.append(preset);
118
215
  }
119
- preset.value = presetNames[0];
120
- section.append(preset);
121
216
 
122
- const { adv, toggle } = advancedBlock();
217
+ // standalone toggle checkboxes (e.g. preview switches), shown below the preset and
218
+ // outside the Advanced fold so they stay visible. Independent of the preset selector.
219
+ for (const t of visibleToggles(sec)) {
220
+ const row = el("label", "feat");
221
+ const box = document.createElement("input");
222
+ box.type = "checkbox";
223
+ box.checked = params[t.key] > 0;
224
+ const lbl = el("span", "", t.label);
225
+ attachInfo(lbl, t.description);
226
+ row.append(box, lbl);
227
+ box.addEventListener("change", () => { params[t.key] = box.checked ? (t.on ?? 1) : 0; onDirty?.(); });
228
+ register(t.key, row);
229
+ section.append(row);
230
+ }
231
+
232
+ const advanced = visibleAdvanced(sec);
123
233
  const syncs = {};
124
- for (const def of sec.advanced) {
125
- const s = makeSlider(def, params, () => {
126
- preset.value = "Custom";
127
- onDirty?.();
128
- });
129
- adv.append(s.wrap);
130
- syncs[def.key] = s.sync;
234
+ if (advanced.length) {
235
+ const { adv, toggle } = advancedBlock();
236
+ for (const def of advanced) {
237
+ const s = makeSlider(def, params, () => { if (preset) preset.value = "Custom"; onDirty?.(); });
238
+ adv.append(s.wrap);
239
+ syncs[def.key] = s.sync;
240
+ register(def.key, s.wrap);
241
+ }
242
+ section.append(toggle, adv);
131
243
  }
132
- section.append(toggle, adv);
133
244
 
134
245
  // applying a preset overwrites its keys and refreshes this section's sliders
135
- preset.addEventListener("change", () => {
136
- const bundle = sec.presets[preset.value];
137
- if (!bundle) return; // "Custom"
138
- Object.assign(params, bundle);
139
- for (const key in syncs) if (key in params) syncs[key]();
140
- onDirty?.();
141
- });
246
+ if (preset) {
247
+ preset.addEventListener("change", () => {
248
+ const bundle = sec.presets[preset.value];
249
+ if (!bundle) return; // "Custom"
250
+ Object.assign(params, bundle);
251
+ for (const key in syncs) if (key in params) syncs[key]();
252
+ onDirty?.();
253
+ });
254
+ }
142
255
  }
143
256
 
144
- function buildFeatureSection(section, sec, params, onDirty) {
257
+ function buildFeatureSection(section, sec, params, onDirty, register) {
145
258
  // Everything lives under Advanced: each feature is a checkbox followed by its
146
259
  // own controls, which appear directly below it when the box is checked.
147
260
  const { adv, toggle } = advancedBlock();
148
261
  section.append(toggle, adv);
149
262
 
150
- for (const feat of sec.features) {
263
+ for (const feat of visibleFeatures(sec)) {
151
264
  const checkRow = el("label", "feat");
152
265
  const box = document.createElement("input");
153
266
  box.type = "checkbox";
154
267
  box.checked = params[feat.key] > 0;
155
- checkRow.append(box, el("span", "", feat.label));
268
+ const featLabel = el("span", "", feat.label);
269
+ attachInfo(featLabel, feat.description);
270
+ checkRow.append(box, featLabel);
271
+ register(feat.key, checkRow);
156
272
 
157
273
  const group = el("div", "feat-group");
158
274
  const syncs = [];
159
- for (const def of feat.sliders) {
275
+ for (const def of feat.sliders.filter((d) => !d.hidden)) {
160
276
  const s = makeSlider(def, params, onDirty);
161
277
  group.append(s.wrap);
162
278
  syncs.push(s.sync);
279
+ register(def.key, s.wrap);
163
280
  }
164
281
  group.classList.toggle("hidden", !box.checked);
165
282
 
@@ -0,0 +1,17 @@
1
+ // Map partforge's declarative edge selector onto a replicad EdgeFinder filter.
2
+ // undefined → undefined (all edges)
3
+ // (e) => e... → passed through (raw replicad finder escape hatch)
4
+ // { dir, inPlane, at, near } → a filter applying the given criteria (AND)
5
+ const AXIS = { X: [1, 0, 0], Y: [0, 1, 0], Z: [0, 0, 1] };
6
+
7
+ export function toEdgeFinder(selector) {
8
+ if (selector == null) return undefined;
9
+ if (typeof selector === "function") return selector;
10
+ return (e) => {
11
+ let f = e;
12
+ if (selector.dir != null) f = f.inDirection(Array.isArray(selector.dir) ? selector.dir : AXIS[selector.dir]);
13
+ if (selector.inPlane != null) f = f.inPlane(selector.inPlane, selector.at);
14
+ if (selector.near != null) f = f.containsPoint(selector.near);
15
+ return f;
16
+ };
17
+ }