partforge 0.1.0 → 0.4.0
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 +200 -3
- package/package.json +15 -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/debug-overlay.js +40 -0
- 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 +9 -1
- package/src/framework/geometry/manifold-backend.js +76 -18
- package/src/framework/geometry/occt-backend.js +118 -4
- package/src/framework/geometry/polygon.js +117 -0
- package/src/framework/geometry/probe.js +52 -0
- package/src/framework/geometry/solid-cache.js +39 -0
- package/src/framework/geometry/solid-hash.js +21 -0
- package/src/framework/geometry-service.js +8 -10
- package/src/framework/jobs.js +24 -8
- package/src/framework/markdown.js +41 -0
- package/src/framework/mount.js +144 -18
- package/src/framework/param-deps.js +81 -0
- package/src/framework/selection/format.js +31 -0
- package/src/framework/selection/index.js +5 -0
- package/src/framework/selection/pick.js +48 -0
- package/src/framework/selection/resolve.js +54 -0
- package/src/framework/view-state.js +55 -0
- package/src/framework/viewer.js +42 -3
- 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
|
},
|
|
@@ -87,7 +88,9 @@ handles. The same code runs on **Manifold** (fast meshes — preview + STL + 3MF
|
|
|
87
88
|
|---|---|
|
|
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
|
-
| `k.prism(points2D, h)` | extrude a 2-D polygon
|
|
91
|
+
| `k.prism(points2D, h, { twist?, scaleTop? })` | extrude a 2-D polygon from z=0; optional `twist` (degrees over the height) and `scaleTop` (uniform top taper: 1 straight, <1 taper in, 0 → point/cone) |
|
|
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,9 @@ 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.scale(factor, center?)` | uniform scale (single factor) about `center` (default origin) — scaling an off-origin part about the origin also moves it; pass a center (e.g. `s.boundingBox().center`) to resize in place |
|
|
112
|
+
| `s.clone()` | independent copy (replicad consumes solids on transform) |
|
|
113
|
+
| `s.boundingBox()` | `{ min, max, center, size }` axis-aligned bounds (query) |
|
|
108
114
|
| `s.volume()` | volume in mm³ (Manifold) |
|
|
109
115
|
| `s.toMesh({ quality })` / `s.toSTL({ quality })` / `s.toIndexedMesh()` | meshes / STL / indexed mesh (3MF) — the framework calls these |
|
|
110
116
|
| `k.toSTEP(named[])` | STEP bytes (OCCT only) — the framework calls this |
|
|
@@ -112,6 +118,18 @@ entry pulls in the DOM viewer/controls, and your build functions run in a Web Wo
|
|
|
112
118
|
You normally only call the *make/combine/transform* ops; the framework handles
|
|
113
119
|
`toMesh`/`toSTL`/`toIndexedMesh`/`toSTEP`. Units are millimetres.
|
|
114
120
|
|
|
121
|
+
### Caching & determinism
|
|
122
|
+
|
|
123
|
+
The preview kernel memoizes geometry by content hash, so editing a parameter only
|
|
124
|
+
re-runs the operations that parameter actually affects. For this to be sound, a
|
|
125
|
+
`build` must be a **pure function of `(k, p, d)`** — no `Math.random`, no clock, no
|
|
126
|
+
module-level mutable state. An impure build will silently return stale geometry.
|
|
127
|
+
|
|
128
|
+
Cache granularity follows the operations you call. Booleans and heavy primitives are
|
|
129
|
+
cached; cheap transforms are recomputed. To make a multi-step shape into a single
|
|
130
|
+
cache node, use (or add) a **compound op** like `k.boredCylinder({ od, h, bore })` —
|
|
131
|
+
it hashes from its own arguments and never exposes its internals to the cache.
|
|
132
|
+
|
|
115
133
|
---
|
|
116
134
|
|
|
117
135
|
## Parameters: the control-panel schema
|
|
@@ -151,8 +169,112 @@ slider or type an exact value (finer than `step` is allowed; typed values clamp
|
|
|
151
169
|
}
|
|
152
170
|
```
|
|
153
171
|
|
|
154
|
-
Every `key` used must exist in `defaults`.
|
|
155
|
-
|
|
172
|
+
Every `key` used must exist in `defaults`. `src/parts/demo.js` is the worked example for
|
|
173
|
+
everything below.
|
|
174
|
+
|
|
175
|
+
**Control metadata (optional — on any control def, feature, or section):**
|
|
176
|
+
|
|
177
|
+
- `description` — a CommonMark string shown in a click-open **ⓘ** popover beside the
|
|
178
|
+
label. Supports **bold/italic**, lists, `code`, links, and images (for diagrams);
|
|
179
|
+
links open in a new tab and the rendered HTML is sanitized. Write one for every
|
|
180
|
+
control — see "A description for every control" below.
|
|
181
|
+
- `hidden: true` — omits the control/feature/section from the panel. Its `key` must still
|
|
182
|
+
exist in `defaults` and still drives the geometry: use it for internal constants the
|
|
183
|
+
end user shouldn't edit (it is *no UI*, not *no parameter*). A section left with no
|
|
184
|
+
presets and no visible controls doesn't render at all.
|
|
185
|
+
|
|
186
|
+
```js
|
|
187
|
+
advanced: [
|
|
188
|
+
{ key: "od", label: "Outer diameter", unit: "mm", min: 4, max: 40, step: 0.5,
|
|
189
|
+
description: "Barrel OD. Keep it larger than the bore so a wall remains. See the [guide](https://example.com)." },
|
|
190
|
+
{ key: "wall_seg", min: 8, max: 256, step: 1, hidden: true, // internal constant; no UI, still in defaults
|
|
191
|
+
description: "Facet count — fixed by the design." },
|
|
192
|
+
],
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
## Designing the control panel
|
|
198
|
+
|
|
199
|
+
A good part exposes a **simple** interface — a handful of controls most users will
|
|
200
|
+
touch — while still giving deep, correct adjustability underneath. `src/parts/demo.js`
|
|
201
|
+
is the worked example for the patterns below.
|
|
202
|
+
|
|
203
|
+
### Procedural & parametric parts
|
|
204
|
+
|
|
205
|
+
Drive many features from a few controls, so tweaking one control reshapes the part
|
|
206
|
+
coherently:
|
|
207
|
+
|
|
208
|
+
- **`derive(p) => d`** computes shared/dependent values once per build; sub-part `build`
|
|
209
|
+
functions read `d`. Put the "design intent" math here — clearances, ratios, wall
|
|
210
|
+
thicknesses — so a single input feeds everything downstream. In the demo, `derive`
|
|
211
|
+
turns the nominal `bore` into `boreR` (with a fixed print clearance) and `h` into the
|
|
212
|
+
cut-tool height `cutH`; `build(k, p, d)` reads those.
|
|
213
|
+
- **Reuse a param `key`** across sub-parts/features so one slider moves all of them.
|
|
214
|
+
- **`enabled(p)`** gates a whole sub-part on a toggle param (the part appears/disappears
|
|
215
|
+
with the control).
|
|
216
|
+
|
|
217
|
+
### Progressive disclosure (simple, but deep)
|
|
218
|
+
|
|
219
|
+
Tier the controls so the default view is uncluttered:
|
|
220
|
+
|
|
221
|
+
1. **Presets** for the common cases — the first thing most users pick.
|
|
222
|
+
2. A **few primary sliders** for the dimensions users change most.
|
|
223
|
+
3. **`Advanced`** (the collapsible block) for the rest.
|
|
224
|
+
4. **`hidden`** for internal constants the end user shouldn't edit.
|
|
225
|
+
|
|
226
|
+
Aim for a panel with a few visible controls that still exposes the full design when
|
|
227
|
+
someone opens Advanced.
|
|
228
|
+
|
|
229
|
+
### A description for every control
|
|
230
|
+
|
|
231
|
+
Give every section and control a `description`. Keep each one short and make it cover:
|
|
232
|
+
|
|
233
|
+
- **what** the control does,
|
|
234
|
+
- its **units**,
|
|
235
|
+
- a **sensible range** (and what's typical),
|
|
236
|
+
- **when it matters** (what it interacts with).
|
|
237
|
+
|
|
238
|
+
Use Markdown links or images for diagrams and deeper reference. These are the popovers
|
|
239
|
+
end users rely on — treat writing them as part of authoring the control, not an
|
|
240
|
+
afterthought.
|
|
241
|
+
|
|
242
|
+
### The relevance-aware panel
|
|
243
|
+
|
|
244
|
+
The panel updates itself to match what's on screen: a **section is hidden** when none of
|
|
245
|
+
its controls affect the active view's visible parts, and a **control is dimmed** (but
|
|
246
|
+
still usable) when it doesn't currently affect them — recomputed as the view and the
|
|
247
|
+
parameters change. You don't wire this up; it's automatic. To get the most from it:
|
|
248
|
+
|
|
249
|
+
- Group controls into **sections by the sub-parts they affect**, so whole sections drop
|
|
250
|
+
away in views that don't use them.
|
|
251
|
+
- Scope a parameter to the **views/sub-parts that read it** — a control read by no
|
|
252
|
+
on-screen part shows dimmed, which is a useful signal that it's vestigial or
|
|
253
|
+
misplaced.
|
|
254
|
+
|
|
255
|
+
---
|
|
256
|
+
|
|
257
|
+
## Profiles & patterns
|
|
258
|
+
|
|
259
|
+
Pure helpers from `partforge/geometry` (no backend dependency):
|
|
260
|
+
|
|
261
|
+
**2-D profiles** (CCW point arrays for `k.prism` / `k.revolve`):
|
|
262
|
+
`roundedRectPolygon(w,h,r)`, `regularPolygon(n,r,{flat})`, `ellipsePolygon(rx,ry)`,
|
|
263
|
+
`slotPolygon(length,r)` (overall length = `length + 2r`), `starPolygon(points,outerR,innerR)`,
|
|
264
|
+
`ringSectorPolygon(innerR,outerR,arcDeg)` (**arcDeg < 360** — a full ring is a contour-with-hole;
|
|
265
|
+
cut an inner cylinder from an outer one instead).
|
|
266
|
+
`circleProfile(r, center?)` — a circle of radius `r` centered at `[cx,cy]` (default origin).
|
|
267
|
+
Compose it for round solids: `k.prism(circleProfile(r), h)` is a cylinder, and
|
|
268
|
+
**a torus is `k.revolve(circleProfile(minorR, [majorR, 0]))`** (with `majorR > minorR`) —
|
|
269
|
+
partforge has no `torus` primitive because it's just a revolved circle.
|
|
270
|
+
|
|
271
|
+
**Patterns** (return `Solid[]` — feed to `k.union(...)` for features or `s.cutAll(...)` for holes):
|
|
272
|
+
`linearPattern(solid, count, [dx,dy,dz])`, `circularPattern(solid, count, { center, axis, angle, rotateCopies })`.
|
|
273
|
+
|
|
274
|
+
```js
|
|
275
|
+
const hole = k.cylinder(2, 2, 20).translate([20, 0, 0]);
|
|
276
|
+
body = body.cutAll(circularPattern(hole, 8, { axis: "Z" })); // 8 bolt holes on a 40mm circle
|
|
277
|
+
```
|
|
156
278
|
|
|
157
279
|
---
|
|
158
280
|
|
|
@@ -254,6 +376,81 @@ own files (vitest isolates files). For Manifold↔OCCT volume parity, see `test/
|
|
|
254
376
|
|
|
255
377
|
---
|
|
256
378
|
|
|
379
|
+
## Verifying a part headlessly (render + measure)
|
|
380
|
+
|
|
381
|
+
Once the package is installed you get two CLI commands that build your part in
|
|
382
|
+
pure Node (no dev server, no browser) so you — or an LLM authoring the part — can
|
|
383
|
+
check it without opening the app:
|
|
384
|
+
|
|
385
|
+
npx partforge measure src/parts/<part>.js [view] # geometric facts
|
|
386
|
+
npx partforge render src/parts/<part>.js [view] # canonical-angle PNGs
|
|
387
|
+
|
|
388
|
+
`measure` prints a report and writes `measure-<part>-<view>.json`: per sub-part
|
|
389
|
+
and per view it reports bounding box, volume, surface area, triangle count,
|
|
390
|
+
whether the solid is watertight, and the number of through-holes (genus), plus an
|
|
391
|
+
assembly overlap check. It exits non-zero if any sub-part isn't watertight or any
|
|
392
|
+
parts interpenetrate — so it doubles as a CI/agent gate. (Manifold output is
|
|
393
|
+
manifold by construction, so `watertight` is mainly a build-sanity check for
|
|
394
|
+
empty/degenerate results; `holes` is the informative topology number.)
|
|
395
|
+
|
|
396
|
+
`render` writes one PNG per angle (`iso`, `front`, `top` by default; choose with
|
|
397
|
+
`--views iso,front`, output dir with `--out`) to `render/`. The view defaults to
|
|
398
|
+
the part's first declared view.
|
|
399
|
+
|
|
400
|
+
The `measure` function is also exported for vitest (boot a Manifold kernel as in
|
|
401
|
+
"Testing a part", then `measure(kernel, part, "<view>")`):
|
|
402
|
+
|
|
403
|
+
import { measure } from "partforge/testing";
|
|
404
|
+
test("part is sound", () => {
|
|
405
|
+
const r = measure(kernel, part, "<view>");
|
|
406
|
+
expect(r.ok).toBe(true);
|
|
407
|
+
expect(r.subparts[0].holes).toBe(1); // e.g. expects one bore
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
---
|
|
411
|
+
|
|
412
|
+
## Fillet & chamfer (automatic OCCT backend)
|
|
413
|
+
|
|
414
|
+
Two backends build your part: **Manifold** (fast meshes — preview, STL, 3MF) and
|
|
415
|
+
**OCCT/replicad** (exact B-rep — STEP). Most parts run on Manifold. But Manifold has no
|
|
416
|
+
fillet, so if your `build` calls a **CAD-only op** the framework automatically routes the
|
|
417
|
+
whole part to OCCT — no declaration needed:
|
|
418
|
+
|
|
419
|
+
| Op | Meaning |
|
|
420
|
+
|---|---|
|
|
421
|
+
| `s.fillet(radius, selector?)` | round edges (curve-following, exact) |
|
|
422
|
+
| `s.chamfer(distance, selector?)` | bevel edges |
|
|
423
|
+
| `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. |
|
|
424
|
+
|
|
425
|
+
`selector` chooses which edges (omit it for **all** edges):
|
|
426
|
+
|
|
427
|
+
- `{ dir: "X"|"Y"|"Z" }` — edges running along an axis (e.g. `{dir:"Z"}` = the vertical edges)
|
|
428
|
+
- `{ inPlane: "XY"|"XZ"|"YZ", at }` — edges lying in a plane (e.g. base edges: `{inPlane:"XY", at:0}`)
|
|
429
|
+
- `{ near: [x,y,z] }` — edges passing through a point
|
|
430
|
+
- a raw `(edgeFinder) => edgeFinder` replicad finder, for anything fancier
|
|
431
|
+
|
|
432
|
+
```js
|
|
433
|
+
let s = k.box([0,0,0],[40,30,16]);
|
|
434
|
+
s = s.fillet(3, { dir: "Z" }); // round the 4 vertical edges
|
|
435
|
+
s = s.chamfer(1, { inPlane: "XY", at: 0 }); // bevel the base
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
See `src/parts/filleted-box.js` for the worked example.
|
|
439
|
+
|
|
440
|
+
**Automatic backend selection.** Before building, the framework runs a geometry-free *probe*
|
|
441
|
+
of your `build` to see whether it uses a CAD-only op, and routes accordingly — Manifold for
|
|
442
|
+
everything else (so sweep-heavy parts like the drum stay fast). Force it with
|
|
443
|
+
`meta.backend: "occt" | "manifold"` if you ever need to. Because an OCCT part is built
|
|
444
|
+
entirely on OCCT, its fillets are exact in the STEP **and** present in the printed STL.
|
|
445
|
+
|
|
446
|
+
> Trade-off: OCCT is much slower on heavy swept geometry (helical grooves), so don't reach for
|
|
447
|
+
> `fillet`/`chamfer` on a sweep-heavy part — design those edges in, or keep the part on Manifold.
|
|
448
|
+
|
|
449
|
+
> `partforge measure` reports `watertight`/`holes` as `n/a` for OCCT parts (Manifold-only
|
|
450
|
+
> topology); `render` works on both.
|
|
451
|
+
|
|
452
|
+
---
|
|
453
|
+
|
|
257
454
|
## Conventions & gotchas
|
|
258
455
|
|
|
259
456
|
- **replicad (OCCT) transforms consume their input.** `s.translate/.rotate/.mirror/.cut`
|
package/package.json
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "partforge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
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": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/scottsykora/partforge.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/scottsykora/partforge#readme",
|
|
12
|
+
"bugs": "https://github.com/scottsykora/partforge/issues",
|
|
7
13
|
"engines": {
|
|
8
14
|
"node": ">=24"
|
|
9
15
|
},
|
|
10
16
|
"files": [
|
|
11
17
|
"src",
|
|
18
|
+
"bin",
|
|
12
19
|
"docs/AUTHORING-PARTS.md",
|
|
13
20
|
"README.md"
|
|
14
21
|
],
|
|
@@ -18,6 +25,9 @@
|
|
|
18
25
|
"./geometry": "./src/framework/geometry/polygon.js",
|
|
19
26
|
"./testing": "./src/testing.js"
|
|
20
27
|
},
|
|
28
|
+
"bin": {
|
|
29
|
+
"partforge": "./bin/cli.js"
|
|
30
|
+
},
|
|
21
31
|
"scripts": {
|
|
22
32
|
"dev": "vite",
|
|
23
33
|
"build": "vite build",
|
|
@@ -27,13 +37,17 @@
|
|
|
27
37
|
"check": "node scripts/check-app.mjs"
|
|
28
38
|
},
|
|
29
39
|
"dependencies": {
|
|
40
|
+
"dompurify": "^3.4.11",
|
|
30
41
|
"fflate": "^0.8.3",
|
|
31
42
|
"manifold-3d": "^3.5.1",
|
|
43
|
+
"marked": "^18.0.5",
|
|
44
|
+
"pngjs": "^7.0.0",
|
|
32
45
|
"replicad": "^0.23.1",
|
|
33
46
|
"replicad-opencascadejs": "^0.23.0",
|
|
34
47
|
"three": "^0.184.0"
|
|
35
48
|
},
|
|
36
49
|
"devDependencies": {
|
|
50
|
+
"happy-dom": "^20.10.6",
|
|
37
51
|
"playwright": "^1.49.0",
|
|
38
52
|
"vite": "^8.0.16",
|
|
39
53
|
"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; }
|