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 +3 -1
- package/bin/cli.js +73 -0
- package/docs/AUTHORING-PARTS.md +182 -2
- package/package.json +12 -1
- package/src/app-filleted-box.js +7 -0
- package/src/filleted-box-worker.js +3 -0
- package/src/framework/app.css +26 -1
- package/src/framework/controls.js +153 -36
- package/src/framework/geometry/edge-selector.js +17 -0
- package/src/framework/geometry/errors.js +10 -0
- package/src/framework/geometry/face-selector.js +19 -0
- package/src/framework/geometry/kernel.js +5 -0
- package/src/framework/geometry/manifold-backend.js +21 -0
- package/src/framework/geometry/occt-backend.js +103 -2
- package/src/framework/geometry/polygon.js +103 -0
- package/src/framework/geometry/probe.js +50 -0
- package/src/framework/geometry-service.js +8 -10
- package/src/framework/jobs.js +13 -4
- package/src/framework/markdown.js +41 -0
- package/src/framework/mount.js +57 -13
- package/src/framework/param-deps.js +50 -0
- package/src/framework/view-state.js +55 -0
- package/src/framework/viewer.js +28 -2
- package/src/parts/demo.js +29 -11
- package/src/parts/filleted-box.js +46 -0
- package/src/testing/build.js +17 -0
- package/src/testing/measure.js +53 -0
- package/src/testing/mesh.js +27 -0
- package/src/testing/render.js +159 -0
- package/src/testing.js +3 -0
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.
|
|
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
|
+
}
|
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -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`.
|
|
155
|
-
|
|
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.
|
|
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"
|
package/src/framework/app.css
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
-
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
onDirty?.();
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|