partforge 0.1.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 +53 -0
- package/docs/AUTHORING-PARTS.md +272 -0
- package/package.json +41 -0
- package/src/app-demo.js +10 -0
- package/src/demo-worker.js +3 -0
- package/src/framework/app.css +141 -0
- package/src/framework/assembly.js +29 -0
- package/src/framework/controls.js +180 -0
- package/src/framework/geometry/fuzzy-cut.js +32 -0
- package/src/framework/geometry/helix-tube.js +50 -0
- package/src/framework/geometry/kernel.js +25 -0
- package/src/framework/geometry/manifold-backend.js +194 -0
- package/src/framework/geometry/occt-backend.js +59 -0
- package/src/framework/geometry/polygon.js +23 -0
- package/src/framework/geometry/threemf.js +52 -0
- package/src/framework/geometry-service.js +20 -0
- package/src/framework/index.js +1 -0
- package/src/framework/jobs.js +73 -0
- package/src/framework/mount.js +227 -0
- package/src/framework/viewer.js +204 -0
- package/src/framework/worker.js +76 -0
- package/src/index.js +6 -0
- package/src/parts/demo.js +40 -0
- package/src/testing/mesh.js +21 -0
- package/src/testing/occt.js +18 -0
- package/src/testing.js +8 -0
package/README.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# partforge
|
|
2
|
+
|
|
3
|
+
Turn a declarative **part definition** into a full parametric-CAD web app — a 3-D
|
|
4
|
+
viewer, a control panel, geometry workers, and STL / STEP / 3MF export. You write one
|
|
5
|
+
script (geometry build functions + a parameter schema); partforge renders the app.
|
|
6
|
+
|
|
7
|
+
Two geometry backends run in Web Workers: [Manifold](https://github.com/elalish/manifold)
|
|
8
|
+
for fast preview meshes + STL/3MF, and [Replicad](https://replicad.xyz)
|
|
9
|
+
(OpenCASCADE-in-WebAssembly) for exact STEP export. The viewer is [three.js](https://threejs.org).
|
|
10
|
+
|
|
11
|
+
> **Requires a Vite-based app.** partforge is published as plain ESM source and relies
|
|
12
|
+
> on Vite's worker / WASM / CSS import handling.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install partforge
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Use
|
|
21
|
+
|
|
22
|
+
```js
|
|
23
|
+
// app.js
|
|
24
|
+
import { mount } from "partforge";
|
|
25
|
+
import part from "./parts/my-part.js";
|
|
26
|
+
mount(part, {
|
|
27
|
+
createWorker: (name) =>
|
|
28
|
+
new Worker(new URL("./part-worker.js", import.meta.url), { type: "module", name }),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// part-worker.js
|
|
32
|
+
import { runWorker } from "partforge/worker";
|
|
33
|
+
import part from "./parts/my-part.js";
|
|
34
|
+
runWorker(part);
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Test your parts headlessly with `partforge/testing`
|
|
38
|
+
(`createManifoldKernel`, `handle`, `assemblyOverlaps`, `bootOcctKernel`, `meshVolume`, `bboxSize`).
|
|
39
|
+
|
|
40
|
+
**Smoke-test that an app actually boots** (real Chromium, real worker/WASM): `npm run check`
|
|
41
|
+
(or `node scripts/check-app.mjs <entry>.html`) — it loads the app and verifies the kernel
|
|
42
|
+
boots with no errors. Needs Playwright: `npm i -D playwright && npx playwright install chromium`.
|
|
43
|
+
|
|
44
|
+
## Authoring guide
|
|
45
|
+
|
|
46
|
+
**[docs/AUTHORING-PARTS.md](docs/AUTHORING-PARTS.md)** is the full guide — the part
|
|
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
|
|
49
|
+
`/demo.html` to see it live.
|
|
50
|
+
|
|
51
|
+
## License
|
|
52
|
+
|
|
53
|
+
MIT
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
# Authoring parts
|
|
2
|
+
|
|
3
|
+
This app is a small **framework** that turns a declarative **`PartDefinition`** into
|
|
4
|
+
a full parametric-CAD web app: a 3-D viewer, a control panel built from your
|
|
5
|
+
parameter schema, two geometry workers, and STL / STEP / 3MF export. To make a new
|
|
6
|
+
part you write **one script** — geometry build functions + a parameter schema — and
|
|
7
|
+
the framework does the rest.
|
|
8
|
+
|
|
9
|
+
- Reusable framework: `src/framework/` (knows nothing about any specific part).
|
|
10
|
+
- Parts: `src/parts/` — e.g. `drum.js` (full, complex) and `demo.js` (minimal).
|
|
11
|
+
- A part module is **plain data + pure functions**: no DOM, no side effects (it
|
|
12
|
+
loads in both the main thread and a Web Worker).
|
|
13
|
+
|
|
14
|
+
Two worked examples to read alongside this guide: **`src/parts/demo.js`** (a
|
|
15
|
+
parametric spacer — the smallest complete part) and **`src/parts/drum.js`** (the
|
|
16
|
+
capstan drum — every feature in use).
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Quickstart
|
|
21
|
+
|
|
22
|
+
1. Copy `src/parts/demo.js` to `src/parts/<your-part>.js` and edit it.
|
|
23
|
+
2. Copy the three glue files, repointing them at your part:
|
|
24
|
+
- `demo.html` → `<your-part>.html`
|
|
25
|
+
- `src/app-demo.js` → `src/app-<your-part>.js`
|
|
26
|
+
- `src/demo-worker.js` → `src/<your-part>-worker.js`
|
|
27
|
+
3. `nvm use && npm install` (Node 24), then `npm run dev` and open
|
|
28
|
+
`http://localhost:5173/<your-part>.html`.
|
|
29
|
+
|
|
30
|
+
That's the whole loop. The chrome (panel, tabs, viewer, export buttons) is shared —
|
|
31
|
+
your HTML is ~30 lines of structural markup and carries no CSS (the framework
|
|
32
|
+
supplies it via `framework/app.css`, imported by `mount`).
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## The `PartDefinition` contract
|
|
37
|
+
|
|
38
|
+
A part is a default-exported object. Full shape (optional fields marked `?`):
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
export default {
|
|
42
|
+
meta: { title, units, background? }, // title string; units e.g. "mm"; background = 0xRRGGBB scene colour
|
|
43
|
+
parameters, // the control-panel schema (array of sections — see below)
|
|
44
|
+
defaults, // flat { paramKey: value } — seeds params + control values
|
|
45
|
+
derive?, // (p) => d optional dependent values computed once per build
|
|
46
|
+
parts: { // named sub-parts; each builds ONE solid
|
|
47
|
+
<name>: {
|
|
48
|
+
label?, // display name (tabs/progress); defaults to the key
|
|
49
|
+
build: (k, p, d, onProgress?) => Solid, // REQUIRED — see kernel API
|
|
50
|
+
place?: (solid, { view, purpose, p, d }) => Solid, // optional reposition; default identity
|
|
51
|
+
views, // string[] — which views show this sub-part
|
|
52
|
+
enabled?: (p) => boolean, // optional — gate a conditional sub-part
|
|
53
|
+
export?: { name }, // filename/object name on export; defaults to the key
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
views: { <name>: { label } }, // the view tabs (a view = a set of sub-parts)
|
|
57
|
+
};
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**Rules:**
|
|
61
|
+
|
|
62
|
+
- `build(k, p, d, onProgress?)` returns the **canonical** solid (e.g. at the origin).
|
|
63
|
+
It is the only required function per sub-part. `p` is `{ ...defaults, ...userParams }`;
|
|
64
|
+
`d` is `derive(p)` (or `{}`). `onProgress?.("phase")` is optional per-feature progress
|
|
65
|
+
shown during export — call it before expensive steps.
|
|
66
|
+
- `place(solid, ctx)` is an optional escape hatch for parts whose **display pose differs
|
|
67
|
+
from their export pose** (e.g. positioning a sub-part in an assembly). `ctx.purpose` is
|
|
68
|
+
`"display"` or `"export"`; `ctx.view` is the active view. Default is identity, so simple
|
|
69
|
+
parts omit it. **Display placement must not depend on `view`** — display meshes are built
|
|
70
|
+
once per sub-part and cached across views (the viewer re-centres per view).
|
|
71
|
+
- `enabled(p)` gates a conditional sub-part (e.g. only present when a feature is on).
|
|
72
|
+
- A view's sub-parts are derived, never hard-coded: those whose `views` include the view
|
|
73
|
+
and whose `enabled(p)` is true.
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Geometry: the kernel / `Solid` API
|
|
78
|
+
|
|
79
|
+
`build` receives a backend-agnostic `kernel` (`k`). It returns and combines `Solid`
|
|
80
|
+
handles. The same code runs on **Manifold** (fast meshes — preview + STL + 3MF) and
|
|
81
|
+
**OCCT/replicad** (exact B-rep — STEP). Contract lives in
|
|
82
|
+
`src/framework/geometry/kernel.js`.
|
|
83
|
+
|
|
84
|
+
**Kernel — make solids:**
|
|
85
|
+
|
|
86
|
+
| Call | Result |
|
|
87
|
+
|---|---|
|
|
88
|
+
| `k.cylinder(rBottom, rTop, h, { center? })` | cylinder/cone along +Z (frustum if radii differ) |
|
|
89
|
+
| `k.box(min, max)` | axis-aligned box from `[x,y,z]` min/max |
|
|
90
|
+
| `k.prism(points2D, h)` | extrude a 2-D polygon (CCW `[[x,y],…]`) from z=0 |
|
|
91
|
+
| `k.helixSweptTube({ pathR, profileR, pitch, turns, z0, lefthand })` | circle swept along a helix (e.g. a rope groove) |
|
|
92
|
+
| `k.union(solids[])` | boolean union |
|
|
93
|
+
|
|
94
|
+
2-D polygon helpers for `prism`: `import { piePolygon, hexPolygon } from "partforge/geometry"`.
|
|
95
|
+
**Import geometry helpers from `partforge/geometry`, never from `partforge`** — the main
|
|
96
|
+
entry pulls in the DOM viewer/controls, and your build functions run in a Web Worker
|
|
97
|
+
(importing the main entry there throws `document is not defined`).
|
|
98
|
+
|
|
99
|
+
**`Solid` — combine / transform / export:**
|
|
100
|
+
|
|
101
|
+
| Call | Result |
|
|
102
|
+
|---|---|
|
|
103
|
+
| `s.cut(tool)` / `s.cutAll(tools[])` | boolean subtract (one / batch) |
|
|
104
|
+
| `s.intersect(other)` | boolean intersection (Manifold; used by collision tests) |
|
|
105
|
+
| `s.translate([x,y,z])` | move |
|
|
106
|
+
| `s.rotate(deg, center, axis)` | rotate `deg` about `axis` through `center` |
|
|
107
|
+
| `s.mirror("XY"\|"XZ"\|"YZ")` | mirror across a plane |
|
|
108
|
+
| `s.volume()` | volume in mm³ (Manifold) |
|
|
109
|
+
| `s.toMesh({ quality })` / `s.toSTL({ quality })` / `s.toIndexedMesh()` | meshes / STL / indexed mesh (3MF) — the framework calls these |
|
|
110
|
+
| `k.toSTEP(named[])` | STEP bytes (OCCT only) — the framework calls this |
|
|
111
|
+
|
|
112
|
+
You normally only call the *make/combine/transform* ops; the framework handles
|
|
113
|
+
`toMesh`/`toSTL`/`toIndexedMesh`/`toSTEP`. Units are millimetres.
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## Parameters: the control-panel schema
|
|
118
|
+
|
|
119
|
+
`parameters` is an **array of sections**; the framework builds the panel from it and
|
|
120
|
+
binds each control to a key in `defaults`. Two section kinds:
|
|
121
|
+
|
|
122
|
+
**Preset + sliders section:**
|
|
123
|
+
|
|
124
|
+
```js
|
|
125
|
+
{
|
|
126
|
+
id: "body",
|
|
127
|
+
title: "Body",
|
|
128
|
+
presets: { M3: { od: 8, bore: 3.4, h: 10 }, M5: { od: 12, bore: 5.4, h: 16 } }, // name → param overrides
|
|
129
|
+
advanced: [ // sliders revealed under "Advanced"
|
|
130
|
+
{ key: "od", label: "Outer diameter", unit: "mm", min: 4, max: 40, step: 0.5 },
|
|
131
|
+
{ key: "bore", label: "Bore", unit: "mm", min: 1, max: 30, step: 0.1, control: "number" },
|
|
132
|
+
],
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Each slider/feature control shows an **editable number box** beside it — drag the
|
|
137
|
+
slider or type an exact value (finer than `step` is allowed; typed values clamp to
|
|
138
|
+
`[min, max]`). Optional `control` per parameter: omit it (or `"slider"`) for a slider
|
|
139
|
+
+ box; `"number"` for a box only (no slider — handy for precise or wide-range values).
|
|
140
|
+
|
|
141
|
+
**Feature-toggle section** (checkbox enables a feature + reveals its sliders; `0` = off):
|
|
142
|
+
|
|
143
|
+
```js
|
|
144
|
+
{
|
|
145
|
+
id: "flange",
|
|
146
|
+
title: "Flange",
|
|
147
|
+
features: [
|
|
148
|
+
{ label: "Base flange", key: "flange_d", on: 16, // checked → set key to `on`; unchecked → 0
|
|
149
|
+
sliders: [{ key: "flange_d", label: "Flange diameter", unit: "mm", min: 8, max: 50, step: 1 }] },
|
|
150
|
+
],
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
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.)
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## Wiring a part into a runnable app
|
|
160
|
+
|
|
161
|
+
Three tiny glue files per part (copy from the demo). The worker statically imports
|
|
162
|
+
your part, so it can't be injected at runtime — hence the per-part entries.
|
|
163
|
+
|
|
164
|
+
`src/app-<part>.js`:
|
|
165
|
+
|
|
166
|
+
```js
|
|
167
|
+
import part from "./parts/<part>.js";
|
|
168
|
+
import { mount } from "partforge";
|
|
169
|
+
mount(part, {
|
|
170
|
+
// NB: the `new Worker(new URL(...))` MUST stay inline here or Vite won't bundle the worker.
|
|
171
|
+
createWorker: (name) => new Worker(new URL("./<part>-worker.js", import.meta.url), { type: "module", name }),
|
|
172
|
+
});
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
`src/<part>-worker.js`:
|
|
176
|
+
|
|
177
|
+
```js
|
|
178
|
+
import part from "./parts/<part>.js";
|
|
179
|
+
import { runWorker } from "partforge/worker";
|
|
180
|
+
runWorker(part);
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
`<part>.html` — structural markup only (no CSS; `mount` pulls in partforge's
|
|
184
|
+
stylesheet). `mount` looks up these element IDs:
|
|
185
|
+
|
|
186
|
+
| ID | Purpose |
|
|
187
|
+
|---|---|
|
|
188
|
+
| `#app` | viewer canvas mounts here |
|
|
189
|
+
| `#controls` | control panel is built into this |
|
|
190
|
+
| `#part` | view-tab bar: one `<button data-part="<view>">` per view, the active one with `class="on"` |
|
|
191
|
+
| `#download-step` / `#download` / `#download-3mf` | STEP / STL / 3MF export buttons |
|
|
192
|
+
| `#status`, `#busy`, `#phase` | status line + busy overlay |
|
|
193
|
+
| `#viewbar` with `#pause` / `#reframe` / `#theme` | optional viewer controls (omit any you don't want) |
|
|
194
|
+
|
|
195
|
+
Copy `demo.html` and change the title, the `#part` buttons (one per view), the panel
|
|
196
|
+
heading, and the `<script src>`. Two workers are spawned from your one worker entry
|
|
197
|
+
(`name` = `"manifold"` for preview/STL/3MF, `"occt"` for STEP — handled for you).
|
|
198
|
+
|
|
199
|
+
> Production deploy builds `index.html` only. Extra `*.html` files are **dev-only**
|
|
200
|
+
> (Vite serves any root HTML in `npm run dev`). To also ship one, add it to
|
|
201
|
+
> `build.rollupOptions.input` in `vite.config.js`.
|
|
202
|
+
|
|
203
|
+
### Developing against a local (linked) partforge
|
|
204
|
+
|
|
205
|
+
A normal `npm install partforge` needs no extra config. But if you `npm link` a local
|
|
206
|
+
partforge checkout (to co-develop the framework), it lives **outside your project root**,
|
|
207
|
+
so Vite refuses to serve its files — including the Manifold/OCCT WASM, which fails with a
|
|
208
|
+
403 and the kernel never boots. Allow-list it in your `vite.config.js`:
|
|
209
|
+
|
|
210
|
+
```js
|
|
211
|
+
server: { fs: { allow: ["./", "../partforge"] } } // path to your linked checkout
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
(Geometry/asset imports are already worker-safe; this is purely Vite's dev-server file
|
|
215
|
+
access. It's harmless to leave in when partforge is a normal install.)
|
|
216
|
+
|
|
217
|
+
---
|
|
218
|
+
|
|
219
|
+
## Testing a part
|
|
220
|
+
|
|
221
|
+
Tests run under **Node 24** (`nvm use` first; the default shell Node is too old) via
|
|
222
|
+
`npx vitest run`. Build geometry directly off your part with a Manifold kernel:
|
|
223
|
+
|
|
224
|
+
```js
|
|
225
|
+
import Module from "manifold-3d";
|
|
226
|
+
import { createManifoldKernel } from "../../src/framework/geometry/manifold-backend.js";
|
|
227
|
+
import part from "../../src/parts/<part>.js";
|
|
228
|
+
|
|
229
|
+
const w = await Module(); w.setup();
|
|
230
|
+
const k = createManifoldKernel(w, { quality: "preview" });
|
|
231
|
+
const solid = part.parts.<name>.build(k, part.defaults, part.derive?.(part.defaults) ?? {});
|
|
232
|
+
expect(solid.toMesh().triangles).toBeGreaterThan(0);
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
**Collision check (assemblies).** `assemblyOverlaps` builds every sub-part of a view in
|
|
236
|
+
its assembly pose and returns any interpenetrating pair with its overlap volume —
|
|
237
|
+
parts meant to fit (e.g. seated in a pocket) read ~0 and don't trip it:
|
|
238
|
+
|
|
239
|
+
```js
|
|
240
|
+
import { assemblyOverlaps } from "../../src/framework/assembly.js";
|
|
241
|
+
test("assembly has no interpenetrating parts", () => {
|
|
242
|
+
expect(assemblyOverlaps(k, part, "<view>", {})).toEqual([]); // [{a,b,volume}] on failure
|
|
243
|
+
});
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
See `test/parts/drum-assembly.test.js` for a real example, and `test/framework/jobs.test.js`
|
|
247
|
+
for exporting through the job loop.
|
|
248
|
+
|
|
249
|
+
**OCCT tests** (STEP / B-rep parity) boot via `bootOcctKernel()` in `test/occt-kernel.js`.
|
|
250
|
+
**OCCT and Manifold must not boot in the same process** — keep OCCT-booting tests in their
|
|
251
|
+
own files (vitest isolates files). For Manifold↔OCCT volume parity, see `test/parity.test.js`
|
|
252
|
+
+ the `test/fixtures/occt-volumes.json` fixture (regenerate with
|
|
253
|
+
`node scripts/gen-occt-fixtures.mjs` after a geometry change).
|
|
254
|
+
|
|
255
|
+
---
|
|
256
|
+
|
|
257
|
+
## Conventions & gotchas
|
|
258
|
+
|
|
259
|
+
- **replicad (OCCT) transforms consume their input.** `s.translate/.rotate/.mirror/.cut`
|
|
260
|
+
delete the operand and return a new solid; never reuse a solid after transforming it.
|
|
261
|
+
The framework rebuilds each sub-part fresh per job and applies `place` once, which
|
|
262
|
+
avoids this — follow the same pattern in your own code.
|
|
263
|
+
- **Part modules are DOM-free and side-effect-free** — they import into both the main
|
|
264
|
+
thread (schema → controls) and the worker (build → kernel).
|
|
265
|
+
- **Units are millimetres** throughout.
|
|
266
|
+
- **Preview vs print quality.** Manifold bakes segment counts in at primitive creation,
|
|
267
|
+
so the export path uses a separate high-res "print" kernel — your `build` is quality-
|
|
268
|
+
agnostic; just build the geometry.
|
|
269
|
+
- **Display placement is view-independent** (so meshes cache across views); only
|
|
270
|
+
`place(..., { purpose: "export" })` may depend on `view`.
|
|
271
|
+
- Keep geometry backend-agnostic (kernel calls only) so it works in both backends; only
|
|
272
|
+
STEP requires OCCT.
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "partforge",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=24"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"docs/AUTHORING-PARTS.md",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"exports": {
|
|
16
|
+
".": "./src/index.js",
|
|
17
|
+
"./worker": "./src/framework/worker.js",
|
|
18
|
+
"./geometry": "./src/framework/geometry/polygon.js",
|
|
19
|
+
"./testing": "./src/testing.js"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"dev": "vite",
|
|
23
|
+
"build": "vite build",
|
|
24
|
+
"preview": "vite preview",
|
|
25
|
+
"test": "vitest run",
|
|
26
|
+
"test:watch": "vitest",
|
|
27
|
+
"check": "node scripts/check-app.mjs"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"fflate": "^0.8.3",
|
|
31
|
+
"manifold-3d": "^3.5.1",
|
|
32
|
+
"replicad": "^0.23.1",
|
|
33
|
+
"replicad-opencascadejs": "^0.23.0",
|
|
34
|
+
"three": "^0.184.0"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"playwright": "^1.49.0",
|
|
38
|
+
"vite": "^8.0.16",
|
|
39
|
+
"vitest": "^4.1.9"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/app-demo.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import demoPart from "./parts/demo.js";
|
|
2
|
+
import { mount } from "./framework/index.js";
|
|
3
|
+
|
|
4
|
+
// Dev-only example app for the demo part (a parametric spacer). Identical wiring to
|
|
5
|
+
// app.js — the only thing that differs per part is which definition you import and
|
|
6
|
+
// which worker entry you point at. `npm run dev`, then open /demo.html.
|
|
7
|
+
mount(demoPart, {
|
|
8
|
+
createWorker: (name) =>
|
|
9
|
+
new Worker(new URL("./demo-worker.js", import.meta.url), { type: "module", name }),
|
|
10
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/* Shared chrome styles for any part app (panel, tabs, viewbar, download row,
|
|
2
|
+
busy overlay) + the light/dark palettes. Imported by framework/mount.js, so
|
|
3
|
+
every part-app gets it; each part's HTML only carries structural markup.
|
|
4
|
+
See docs/AUTHORING-PARTS.md. */
|
|
5
|
+
|
|
6
|
+
:root {
|
|
7
|
+
color-scheme: dark;
|
|
8
|
+
--bg: #15181d; --surface: #1f242c; --surface-2: #20262e; --border: #2c333d;
|
|
9
|
+
--text: #d6dbe2; --text-strong: #e7ebf1; --text-2: #cdd4dd;
|
|
10
|
+
--muted: #7d8794; --muted-2: #aab2bd; --status: #8b94a0; --hint: #6b7480;
|
|
11
|
+
--accent: #3b82f6; --on-accent: #fff; --input-bg: #161a20; --err: #f8746c;
|
|
12
|
+
}
|
|
13
|
+
:root[data-theme="light"] {
|
|
14
|
+
color-scheme: light;
|
|
15
|
+
--bg: #eef1f5; --surface: #ffffff; --surface-2: #f4f6f9; --border: #d4dae2;
|
|
16
|
+
--text: #2b333d; --text-strong: #1a2129; --text-2: #3a434e;
|
|
17
|
+
--muted: #6b7480; --muted-2: #59636f; --status: #6b7480; --hint: #8a93a0;
|
|
18
|
+
--accent: #3b82f6; --on-accent: #fff; --input-bg: #ffffff; --err: #d8453d;
|
|
19
|
+
}
|
|
20
|
+
* { box-sizing: border-box; }
|
|
21
|
+
html, body { margin: 0; height: 100%; overflow: hidden;
|
|
22
|
+
font: 13px/1.4 -apple-system, system-ui, sans-serif; }
|
|
23
|
+
#app { position: fixed; inset: 0; background: var(--bg); }
|
|
24
|
+
canvas { display: block; }
|
|
25
|
+
|
|
26
|
+
#panel {
|
|
27
|
+
position: fixed; top: 12px; left: 12px; width: 252px;
|
|
28
|
+
max-height: calc(100vh - 24px); overflow-y: auto; z-index: 10;
|
|
29
|
+
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
|
|
30
|
+
padding: 14px; color: var(--text); box-shadow: 0 6px 24px rgba(0,0,0,.35);
|
|
31
|
+
}
|
|
32
|
+
#panel h1 { font-size: 14px; margin: 0 0 2px; }
|
|
33
|
+
#panel .sub { color: var(--muted); font-size: 11px; margin: 0 0 12px; }
|
|
34
|
+
|
|
35
|
+
.seg { display: flex; gap: 4px; margin-bottom: 12px; }
|
|
36
|
+
.seg button {
|
|
37
|
+
flex: 1; padding: 7px 0; border: 1px solid var(--border); border-radius: 7px;
|
|
38
|
+
background: var(--surface-2); color: var(--muted-2); cursor: pointer; font-size: 12px;
|
|
39
|
+
}
|
|
40
|
+
.seg button.on { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
|
|
41
|
+
|
|
42
|
+
.section {
|
|
43
|
+
border: 1px solid var(--border); border-radius: 8px; padding: 9px 10px;
|
|
44
|
+
margin-bottom: 8px; background: var(--surface-2);
|
|
45
|
+
}
|
|
46
|
+
.sec-title { font-weight: 600; color: var(--text-strong); margin-bottom: 7px; }
|
|
47
|
+
select.preset {
|
|
48
|
+
width: 100%; background: var(--input-bg); color: var(--text-2);
|
|
49
|
+
border: 1px solid var(--border); border-radius: 6px; padding: 5px 7px; font-size: 11px;
|
|
50
|
+
}
|
|
51
|
+
.feat { display: flex; align-items: center; gap: 8px; margin: 6px 0;
|
|
52
|
+
color: var(--text-2); cursor: pointer; }
|
|
53
|
+
.feat input { cursor: pointer; }
|
|
54
|
+
.feat-group { margin: 2px 0 8px; padding-left: 9px; border-left: 2px solid var(--border); }
|
|
55
|
+
.feat-group.hidden { display: none; }
|
|
56
|
+
.adv-toggle {
|
|
57
|
+
margin-top: 8px; padding: 3px 0; width: 100%; border: 0; border-radius: 5px;
|
|
58
|
+
background: transparent; color: var(--muted); cursor: pointer; font-size: 11px;
|
|
59
|
+
text-align: left;
|
|
60
|
+
}
|
|
61
|
+
.adv-toggle:hover { color: var(--muted-2); }
|
|
62
|
+
.adv.hidden { display: none; }
|
|
63
|
+
.adv { margin-top: 4px; }
|
|
64
|
+
|
|
65
|
+
.slider { margin: 7px 0; }
|
|
66
|
+
.row { display: flex; justify-content: space-between; align-items: center;
|
|
67
|
+
margin: 0 0 3px; gap: 8px; }
|
|
68
|
+
.row label { color: var(--muted-2); }
|
|
69
|
+
.row .val { display: flex; align-items: baseline; gap: 4px; flex: none; }
|
|
70
|
+
.row .num {
|
|
71
|
+
width: 52px; text-align: right; font: inherit; font-variant-numeric: tabular-nums;
|
|
72
|
+
background: var(--input-bg); color: var(--text-strong);
|
|
73
|
+
border: 1px solid var(--border); border-radius: 5px; padding: 2px 5px;
|
|
74
|
+
}
|
|
75
|
+
.row .num:focus { outline: none; border-color: var(--accent); }
|
|
76
|
+
.row .num::-webkit-outer-spin-button, .row .num::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
|
77
|
+
.row .num { -moz-appearance: textfield; }
|
|
78
|
+
.row .unit { color: var(--muted); font-size: 11px; }
|
|
79
|
+
input[type="range"] { width: 100%; }
|
|
80
|
+
|
|
81
|
+
button.action {
|
|
82
|
+
width: 100%; margin-top: 8px; padding: 9px; border: 0; border-radius: 7px;
|
|
83
|
+
background: var(--accent); color: var(--on-accent); font-weight: 600; cursor: pointer;
|
|
84
|
+
}
|
|
85
|
+
button.ghost { background: var(--border); color: var(--text-2); font-weight: 500; }
|
|
86
|
+
button.action:disabled { opacity: .5; cursor: default; }
|
|
87
|
+
|
|
88
|
+
.dl { margin-top: 12px; }
|
|
89
|
+
.dl-head { font-weight: 600; color: var(--text-strong); margin-bottom: 6px; }
|
|
90
|
+
.dl-row { display: flex; gap: 6px; }
|
|
91
|
+
.dl-row button {
|
|
92
|
+
flex: 1; padding: 8px 0; border: 1px solid var(--border); border-radius: 7px;
|
|
93
|
+
background: var(--surface-2); color: var(--text-2); font-weight: 600;
|
|
94
|
+
font-size: 12px; cursor: pointer;
|
|
95
|
+
}
|
|
96
|
+
.dl-row button:hover:not(:disabled) { border-color: var(--accent); color: var(--text-strong); }
|
|
97
|
+
.dl-row button:disabled { opacity: .45; cursor: default; }
|
|
98
|
+
#status { margin-top: 10px; min-height: 16px; color: var(--status); font-size: 11px; }
|
|
99
|
+
#status.err { color: var(--err); }
|
|
100
|
+
.hint { margin-top: 8px; color: var(--hint); font-size: 10px; }
|
|
101
|
+
|
|
102
|
+
/* part tabs, floated top-centre over the viewport */
|
|
103
|
+
#topbar {
|
|
104
|
+
position: fixed; top: 12px; left: 50%; transform: translateX(-50%);
|
|
105
|
+
z-index: 15;
|
|
106
|
+
}
|
|
107
|
+
#topbar .seg {
|
|
108
|
+
margin: 0; padding: 4px; background: var(--surface); border: 1px solid var(--border);
|
|
109
|
+
border-radius: 9px; box-shadow: 0 6px 24px rgba(0,0,0,.35);
|
|
110
|
+
}
|
|
111
|
+
#topbar .seg button { min-width: 70px; padding: 7px 10px; }
|
|
112
|
+
|
|
113
|
+
/* viewer controls, top-right */
|
|
114
|
+
#viewbar {
|
|
115
|
+
position: fixed; top: 12px; right: 12px; z-index: 15;
|
|
116
|
+
display: flex; gap: 4px; padding: 4px;
|
|
117
|
+
background: var(--surface); border: 1px solid var(--border);
|
|
118
|
+
border-radius: 9px; box-shadow: 0 6px 24px rgba(0,0,0,.35);
|
|
119
|
+
}
|
|
120
|
+
#viewbar button {
|
|
121
|
+
width: 34px; height: 34px; border: 1px solid var(--border); border-radius: 7px;
|
|
122
|
+
background: var(--surface-2); color: var(--muted-2); cursor: pointer;
|
|
123
|
+
font-size: 15px; line-height: 1;
|
|
124
|
+
display: flex; align-items: center; justify-content: center;
|
|
125
|
+
}
|
|
126
|
+
#viewbar button:hover { color: var(--text); }
|
|
127
|
+
#viewbar button.on { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
|
|
128
|
+
|
|
129
|
+
#busy {
|
|
130
|
+
position: fixed; inset: 0; z-index: 20; pointer-events: none;
|
|
131
|
+
display: none; flex-direction: column; align-items: center;
|
|
132
|
+
justify-content: center; gap: 14px;
|
|
133
|
+
}
|
|
134
|
+
#busy.show { display: flex; }
|
|
135
|
+
#busy .ring {
|
|
136
|
+
width: 46px; height: 46px; border-radius: 50%;
|
|
137
|
+
border: 4px solid var(--border); border-top-color: var(--accent);
|
|
138
|
+
animation: spin 0.9s linear infinite;
|
|
139
|
+
}
|
|
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); } }
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { viewSubParts } from "./jobs.js";
|
|
2
|
+
|
|
3
|
+
// Collision check for an assembled view: build each sub-part in its display
|
|
4
|
+
// (assembly) pose and return the pairs whose solid-intersection volume exceeds
|
|
5
|
+
// `tolerance` (mm³) — i.e. parts that interpenetrate rather than merely touch.
|
|
6
|
+
// Parts meant to fit together (e.g. a block seated in a pocket void) read ~0 and
|
|
7
|
+
// don't trip it. Manifold-only (needs Solid.intersect + Solid.volume); meant for
|
|
8
|
+
// part tests so an author/LLM editing a part sees collisions fail.
|
|
9
|
+
// → [{ a, b, volume }] for each offending pair (empty = no collisions)
|
|
10
|
+
export function assemblyOverlaps(kernel, part, view, params = {}, { tolerance = 1 } = {}) {
|
|
11
|
+
const p = { ...part.defaults, ...params };
|
|
12
|
+
const d = part.derive ? part.derive(p) : {};
|
|
13
|
+
const posed = viewSubParts(part, view, p).map((name) => {
|
|
14
|
+
const sp = part.parts[name];
|
|
15
|
+
let solid = sp.build(kernel, p, d);
|
|
16
|
+
if (sp.place) solid = sp.place(solid, { view, purpose: "display", p, d });
|
|
17
|
+
return { name, solid };
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const overlaps = [];
|
|
21
|
+
for (let i = 0; i < posed.length; i++) {
|
|
22
|
+
for (let j = i + 1; j < posed.length; j++) {
|
|
23
|
+
const volume = posed[i].solid.intersect(posed[j].solid).volume();
|
|
24
|
+
if (volume > tolerance) overlaps.push({ a: posed[i].name, b: posed[j].name, volume });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
kernel.cleanup?.(); // free the per-check WASM objects
|
|
28
|
+
return overlaps;
|
|
29
|
+
}
|