partforge 0.28.0 → 0.32.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 +14 -0
- package/docs/AUTHORING-PARTS.md +66 -0
- package/docs/ERROR-PATTERNS.md +29 -1
- package/package.json +1 -1
- package/src/framework/controls.js +25 -6
- package/src/framework/debug-overlay.js +10 -2
- package/src/framework/download.js +14 -6
- package/src/framework/geometry/kernel-front.js +11 -0
- package/src/framework/geometry/kernel.js +1 -1
- package/src/framework/geometry/manifold-backend.js +10 -16
- package/src/framework/geometry/mesh-stl.js +27 -0
- package/src/framework/geometry/occt-backend.js +292 -92
- package/src/framework/geometry/occt-repair.js +9 -1
- package/src/framework/geometry/op-options.js +4 -1
- package/src/framework/geometry/pose.js +79 -0
- package/src/framework/geometry/rim-bevel.js +132 -0
- package/src/framework/mount.js +56 -10
- package/src/framework/pose-fast-path.js +49 -0
- package/src/framework/pose-probe.js +139 -0
- package/src/framework/selection/hover.js +6 -3
- package/src/framework/selection/raycast.js +17 -4
- package/src/framework/viewer.js +24 -1
package/README.md
CHANGED
|
@@ -128,6 +128,20 @@ so **the rail must be a direct child of the positioned `.pf-shell`** unless
|
|
|
128
128
|
the host also supplies `elements.shell` to point at the real containing block
|
|
129
129
|
(e.g. when a wrapper div sits between them, as is common in a React layout).
|
|
130
130
|
|
|
131
|
+
`runtime.setParams(partial)` edits parameters programmatically — the entry point
|
|
132
|
+
for animating a part from host code:
|
|
133
|
+
|
|
134
|
+
```js
|
|
135
|
+
runtime.setParams({ openAngle: 45 }); // merges into the live params, syncs the panel
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
The partial is merged into the current params and the control panel updates to
|
|
139
|
+
match. Keys the part doesn't define are silently ignored. When every changed
|
|
140
|
+
parameter only moves geometry (a rotation or translation in `place()`), the
|
|
141
|
+
viewer re-poses the meshes it already has — instantly, with no worker rebuild;
|
|
142
|
+
`onBuild` does not fire for those pose-only edits. Anything that changes the
|
|
143
|
+
geometry itself rebuilds as usual.
|
|
144
|
+
|
|
131
145
|
`onPick` arms click-to-select permanently: `label` is the feature label (falling
|
|
132
146
|
back to the sub-part label/name) for compact UI, `prompt` is the LLM-ready
|
|
133
147
|
sentence, `token` the compact form, `selection` the raw object. When `onPick` is
|
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -337,6 +337,15 @@ their hash folds every shape-affecting argument (each `loft` ring's points/`z`/`
|
|
|
337
337
|
profile's segment specs from `roundedProfile`, and the tessellation from `twist`), so
|
|
338
338
|
changing any of them is a fresh cache node while an identical rebuild is a hit.
|
|
339
339
|
|
|
340
|
+
This holds on **both backends** — and on OCCT, `translate`/`rotate` are additionally
|
|
341
|
+
*pose-lazy*: the backend re-poses the cached solid's cached tessellation instead of
|
|
342
|
+
re-running any B-rep work. A parameter that only feeds a final placement rotation (a
|
|
343
|
+
lid's open angle, an exploded-view offset) therefore re-drags in ~0 ms even on the
|
|
344
|
+
slow exact kernel — keep such transforms as the last ops in `build` (or in `place`)
|
|
345
|
+
rather than baking them into the geometry earlier. In the app, such pose-only edits
|
|
346
|
+
skip the worker entirely — the viewer re-poses the cached mesh — so they stay smooth
|
|
347
|
+
even at animation rates (see `runtime.setParams`).
|
|
348
|
+
|
|
340
349
|
---
|
|
341
350
|
|
|
342
351
|
## Parameters: the control-panel schema
|
|
@@ -1210,6 +1219,63 @@ entirely on OCCT, its fillets are exact in the STEP **and** present in the print
|
|
|
1210
1219
|
> `partforge measure` reports `watertight`/`holes` as `n/a` for OCCT parts (Manifold-only
|
|
1211
1220
|
> topology); `render` works on both.
|
|
1212
1221
|
|
|
1222
|
+
### Cost: fillet/chamfer scale with edge count — and order matters
|
|
1223
|
+
|
|
1224
|
+
OCCT fillet/chamfer cost is **per selected edge**, on top of the OCCT boolean tax the
|
|
1225
|
+
routing already imposes on the rest of the part. Two habits keep it tolerable:
|
|
1226
|
+
|
|
1227
|
+
- **Fillet/chamfer as early as possible, on the simplest solid.** A fillet on a bare
|
|
1228
|
+
primitive is ~15× cheaper than the same fillet after a dozen boolean cuts have
|
|
1229
|
+
multiplied the face count — and because the solid cache keys each op by its input's
|
|
1230
|
+
content hash, an early fillet is a cache **hit** when a downstream parameter changes,
|
|
1231
|
+
while a fillet-last build re-pays the whole op on every slider step of every parameter.
|
|
1232
|
+
- **Never point a rim selector at a many-point extruded profile.** `edges: {inPlane}` on
|
|
1233
|
+
a gear-like extrusion selects *every* polygon edge (hundreds); one chamfer call then
|
|
1234
|
+
costs seconds — and if the distance doesn't fit the tooth lands, the failure-rescue
|
|
1235
|
+
bisection re-runs it ~8× (`ERROR-PATTERNS.md#chamfer-rescue-bisection`). Use the loft
|
|
1236
|
+
bevel below instead.
|
|
1237
|
+
|
|
1238
|
+
### Beveling profile rims: extrude's bevel option
|
|
1239
|
+
|
|
1240
|
+
For an **extruded profile** (gear, star, bracket outline — any `k.extrude` of a polygon),
|
|
1241
|
+
a top/bottom rim bevel doesn't need `chamfer` at all — it's built into `extrude`:
|
|
1242
|
+
|
|
1243
|
+
```js
|
|
1244
|
+
k.extrude({ profile: prof, h: 5, bevel: 0.6 }); // 45° bevel, both rims
|
|
1245
|
+
k.extrude({ profile: prof, h: 5, bevel: { top: 0.6 } }); // one rim only
|
|
1246
|
+
```
|
|
1247
|
+
|
|
1248
|
+
Same 45° bevel a rim `chamfer` would cut, but it desugars into extrude + loft +
|
|
1249
|
+
intersect at the shared kernel front, so the part **stays on the fast Manifold
|
|
1250
|
+
backend** (no CAD-only op for the probe to find) and costs one boolean regardless of
|
|
1251
|
+
profile point count. Measured on a 24-tooth involute gear: ~0.1 s on Manifold vs
|
|
1252
|
+
~40 s for the equivalent OCCT `chamfer` (576-edge rim × the rescue bisection).
|
|
1253
|
+
|
|
1254
|
+
Every profile form works: point arrays, arc profiles, `{outer, holes}` regions
|
|
1255
|
+
(hole rims flare outward — the opening is larger at the face, as a chamfer would
|
|
1256
|
+
cut it), and `Shape2D` (multi-region shapes bevel each region and union). One
|
|
1257
|
+
fidelity caveat: curved profiles are **materialized to point rings** first — the
|
|
1258
|
+
loft envelope needs matched points — so a beveled extrusion is faceted at the
|
|
1259
|
+
sampling LOD even in STEP export. Arc contours sample at a fixed LOD identically
|
|
1260
|
+
on both backends; a `Shape2D` materializes at its own backend's LOD. If you need
|
|
1261
|
+
arc-exact STEP walls, that's the one case native `chamfer` still buys you (at
|
|
1262
|
+
its OCCT cost).
|
|
1263
|
+
|
|
1264
|
+
Rules (throws otherwise — `ERROR-PATTERNS.md#extrude-bevel-invalid`): no
|
|
1265
|
+
`twist`/`scaleTop`, and `bottom + top < h` — clamp from your height parameter, e.g.
|
|
1266
|
+
`bevel: Math.min(p.chamfer, p.thickness / 2 - 0.2)`. A bevel that would pinch a
|
|
1267
|
+
narrow feature shut (a gear's tooth land) is deterministically reduced to the
|
|
1268
|
+
largest offset the rim can take, with a console warning
|
|
1269
|
+
(`ERROR-PATTERNS.md#extrude-bevel-reduced`).
|
|
1270
|
+
|
|
1271
|
+
Under the hood it insets the profile with `offsetPolygon(prof, -c, { corners:
|
|
1272
|
+
"sharp" })` and intersects with a loft envelope extended past both faces (so the
|
|
1273
|
+
envelope's own end caps never coincide with the extrusion's faces — coincident caps
|
|
1274
|
+
leave sliver-triangle shading artifacts). The same construction works by hand when
|
|
1275
|
+
you need a variant the option doesn't cover. This bevels a **whole rim**; for
|
|
1276
|
+
selective edges on a solid that's already OCCT-routed, plain `chamfer` with a tight
|
|
1277
|
+
selector is still the right tool.
|
|
1278
|
+
|
|
1213
1279
|
---
|
|
1214
1280
|
|
|
1215
1281
|
## Conventions & gotchas
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -47,7 +47,7 @@ test parses every one; keep prose like this as plain paragraphs):
|
|
|
47
47
|
- **Cause:** replicad transforms and booleans (`translate`/`rotate`/`mirror`/`cut`/…) consume their operand — the input solid is deleted and a new one returned.
|
|
48
48
|
- **Fix:** Never reuse a solid after transforming it; take a `.clone()` first when you need the original again. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Geometry: the kernel / `Solid` API" (the `s.clone()` row).
|
|
49
49
|
|
|
50
|
-
The framework itself rebuilds each sub-part fresh per job and applies `place` once, which avoids the problem — follow the same pattern in your own code.
|
|
50
|
+
The framework itself rebuilds each sub-part fresh per job and applies `place` once, which avoids the problem — follow the same pattern in your own code. (Since the OCCT solid cache landed, the in-repo backend clones internally before every consuming replicad call, so wrapped `Solid`s effectively have value semantics and this crash should no longer reproduce through the kernel API — but the portable rule stands: per KERNEL-CONTRACT.md a backend MAY consume, so a part must still not rely on reuse.)
|
|
51
51
|
|
|
52
52
|
## probe-routed-to-occt
|
|
53
53
|
|
|
@@ -55,6 +55,34 @@ The framework itself rebuilds each sub-part fresh per job and applies `place` on
|
|
|
55
55
|
- **Cause:** The geometry-free probe runs `build` against a recording proxy (dummy query values), and a `fillet`/`chamfer`/`shell` call it reaches — including a branch the real build wouldn't take, since queries return dummies — routes the whole part to OCCT.
|
|
56
56
|
- **Fix:** Remove the CAD-only call the probe reaches unnecessarily, or force the backend with `meta.backend: "manifold"` (or `"occt"`). See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Fillet & chamfer (automatic OCCT backend)".
|
|
57
57
|
|
|
58
|
+
## fillet-chamfer-many-edges-slow
|
|
59
|
+
|
|
60
|
+
- **Symptom:** A part that fillets or chamfers the rim of an extruded profile (a gear, a star, any many-point polygon) takes many seconds — even tens of seconds — per build, with no error anywhere.
|
|
61
|
+
- **Cause:** OCCT fillet/chamfer cost scales with the number of selected edges, and an `inPlane` rim selector on a many-point extruded profile selects every polygon edge (hundreds for a gear), so one op call costs seconds — and re-runs on every parameter change.
|
|
62
|
+
- **Fix:** Use `extrude`'s `bevel` option instead of `chamfer` — same geometry, stays on the fast Manifold backend. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Beveling profile rims: extrude's bevel option".
|
|
63
|
+
|
|
64
|
+
## chamfer-rescue-bisection
|
|
65
|
+
|
|
66
|
+
- **Symptom:** `partforge: chamfer` warning saying the distance `over-ran the geometry — reduced to` a smaller one (or `has no valid distance`), with an attempt count and elapsed seconds, alongside slow builds.
|
|
67
|
+
- **Cause:** The requested chamfer distance doesn't fit the geometry (it over-runs an adjacent face or a short edge), so the failure-rescue bisection in `occt-repair.js` re-runs the full chamfer up to 7 more times to find the largest valid distance — multiplying an already-expensive op by ~8× on every build, since the result is only cached per exact input hash.
|
|
68
|
+
- **Fix:** Lower the chamfer parameter to at most the printed valid distance (the rescue then never fires), clamp it in `build` from the geometry that limits it, or — for extruded profile rims — switch to `extrude`'s `bevel` option, which finds its own limit in pure JS. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Beveling profile rims: extrude's bevel option".
|
|
69
|
+
|
|
70
|
+
Variant literals under this entry: `partforge: chamfer <d> over-ran the geometry — reduced to <d'> (largest valid; <n> attempts, <t>s — see ERROR-PATTERNS.md#chamfer-rescue-bisection)`, `partforge: chamfer <d> has no valid distance for this geometry — feature skipped (<n> attempts, <t>s — see ERROR-PATTERNS.md#chamfer-rescue-bisection)`.
|
|
71
|
+
|
|
72
|
+
## extrude-bevel-invalid
|
|
73
|
+
|
|
74
|
+
- **Symptom:** `extrude: bevel must fit the height (bottom + top < h)` or `extrude: bevel cannot combine with twist or scaleTop` thrown from a build.
|
|
75
|
+
- **Cause:** `extrude`'s `bevel` option desugars into offset-loft envelopes, which need an untwisted straight extrusion and room for both bevels inside the height.
|
|
76
|
+
- **Fix:** Clamp the bevel from the height parameter (e.g. `Math.min(c, h / 2 - 0.2)`) and drop `twist`/`scaleTop`. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Beveling profile rims: extrude's bevel option".
|
|
77
|
+
|
|
78
|
+
Variant literals under this entry: `extrude: unknown bevel option`, `extrude: bevel must be a number or { bottom?, top? }`, `extrude: bevel distances must be finite numbers >= 0`.
|
|
79
|
+
|
|
80
|
+
## extrude-bevel-reduced
|
|
81
|
+
|
|
82
|
+
- **Symptom:** `partforge: extrude bevel` warning saying the requested distance `exceeds what the profile can take — reduced to` a smaller one (or `has no valid offset for this profile — rim left square`; `hole` in place of `profile` when a hole's flare is the limit).
|
|
83
|
+
- **Cause:** Offsetting the rim by the bevel distance would pinch a narrow feature (a tooth land, a thin bar, a thin web beside a hole) shut, so the bevel deterministically backs off to the largest offset the outline can take — the same geometric limit OCCT's chamfer hits, resolved in pure JS instead of kernel re-runs.
|
|
84
|
+
- **Fix:** Usually nothing — the reduced bevel is the correct maximum for the geometry. To silence it, clamp the bevel parameter below the printed value or widen the narrow feature.
|
|
85
|
+
|
|
58
86
|
## boolean-not-watertight
|
|
59
87
|
|
|
60
88
|
- **Symptom:** `NOT watertight ✗` from `partforge measure` (non-zero exit) after adding a boolean cut or union.
|
package/package.json
CHANGED
|
@@ -218,6 +218,7 @@ export function buildControls(root, parameters, params, onDirty) {
|
|
|
218
218
|
const info = createInfoPopover();
|
|
219
219
|
const controls = []; // { key, el } per control element
|
|
220
220
|
const sections = []; // { el, keys:Set } per rendered section
|
|
221
|
+
const syncFns = []; // { key, sync } for every widget that can re-read params
|
|
221
222
|
for (const sec of parameters) {
|
|
222
223
|
if (!sectionRenders(sec)) continue;
|
|
223
224
|
const section = el("div", "section");
|
|
@@ -225,7 +226,11 @@ export function buildControls(root, parameters, params, onDirty) {
|
|
|
225
226
|
attachInfo(title, sec.description, info);
|
|
226
227
|
section.append(title);
|
|
227
228
|
const keys = new Set();
|
|
228
|
-
const register = (key, node) => {
|
|
229
|
+
const register = (key, node, sync) => {
|
|
230
|
+
controls.push({ key, el: node });
|
|
231
|
+
keys.add(key);
|
|
232
|
+
if (sync) syncFns.push({ key, sync });
|
|
233
|
+
};
|
|
229
234
|
if (sec.features) buildFeatureSection(section, sec, params, onDirty, register, info);
|
|
230
235
|
else buildPresetSection(section, sec, params, onDirty, register, info);
|
|
231
236
|
root.append(section);
|
|
@@ -233,6 +238,12 @@ export function buildControls(root, parameters, params, onDirty) {
|
|
|
233
238
|
}
|
|
234
239
|
return {
|
|
235
240
|
applyRelevance: (relevant) => applyRelevance(relevant, controls, sections),
|
|
241
|
+
// Re-read params into the widgets — all of them, or just `keys`. The
|
|
242
|
+
// programmatic twin of a user edit (setParams); never fires onDirty.
|
|
243
|
+
syncValues: (keys) => {
|
|
244
|
+
const only = keys && new Set(keys);
|
|
245
|
+
for (const { key, sync } of syncFns) if (!only || only.has(key)) sync();
|
|
246
|
+
},
|
|
236
247
|
dispose: () => { info.dispose(); root.replaceChildren(); },
|
|
237
248
|
};
|
|
238
249
|
}
|
|
@@ -263,7 +274,7 @@ function buildPresetSection(section, sec, params, onDirty, register, info) {
|
|
|
263
274
|
attachInfo(lbl, t.description, info);
|
|
264
275
|
row.append(box, lbl);
|
|
265
276
|
box.addEventListener("change", () => { params[t.key] = box.checked ? (t.on ?? 1) : 0; onDirty?.(); });
|
|
266
|
-
register(t.key, row);
|
|
277
|
+
register(t.key, row, () => { box.checked = params[t.key] > 0; });
|
|
267
278
|
section.append(row);
|
|
268
279
|
}
|
|
269
280
|
|
|
@@ -274,8 +285,11 @@ function buildPresetSection(section, sec, params, onDirty, register, info) {
|
|
|
274
285
|
for (const def of advanced) {
|
|
275
286
|
const s = makeParameterControl(def, params, () => { if (preset) preset.value = "Custom"; onDirty?.(); }, info);
|
|
276
287
|
adv.append(s.wrap);
|
|
277
|
-
syncs[def.key] = s.sync;
|
|
278
|
-
|
|
288
|
+
syncs[def.key] = s.sync; // raw: preset APPLICATION must not mark itself Custom
|
|
289
|
+
// A programmatic edit diverges from the preset exactly as a user edit does,
|
|
290
|
+
// so the picker falls back to Custom — leaving a stale preset name selected
|
|
291
|
+
// would also make it unre-appliable (no change event for the current option).
|
|
292
|
+
register(def.key, s.wrap, () => { s.sync(); if (preset) preset.value = "Custom"; });
|
|
279
293
|
}
|
|
280
294
|
section.append(toggle, adv);
|
|
281
295
|
}
|
|
@@ -306,7 +320,12 @@ function buildFeatureSection(section, sec, params, onDirty, register, info) {
|
|
|
306
320
|
const featLabel = el("span", "", feat.label);
|
|
307
321
|
attachInfo(featLabel, feat.description, info);
|
|
308
322
|
checkRow.append(box, featLabel);
|
|
309
|
-
|
|
323
|
+
// `group` is created just below — the sync only ever runs after this
|
|
324
|
+
// function returns, so the closure is safely bound by then.
|
|
325
|
+
register(feat.key, checkRow, () => {
|
|
326
|
+
box.checked = params[feat.key] > 0;
|
|
327
|
+
group.classList.toggle("hidden", !box.checked);
|
|
328
|
+
});
|
|
310
329
|
|
|
311
330
|
const group = el("div", "feat-group");
|
|
312
331
|
const syncs = [];
|
|
@@ -314,7 +333,7 @@ function buildFeatureSection(section, sec, params, onDirty, register, info) {
|
|
|
314
333
|
const s = makeParameterControl(def, params, onDirty, info);
|
|
315
334
|
group.append(s.wrap);
|
|
316
335
|
syncs.push(s.sync);
|
|
317
|
-
register(def.key, s.wrap);
|
|
336
|
+
register(def.key, s.wrap, s.sync);
|
|
318
337
|
}
|
|
319
338
|
group.classList.toggle("hidden", !box.checked);
|
|
320
339
|
|
|
@@ -43,13 +43,21 @@ export function createDebugOverlay({ initialCachingOn = true, onToggle } = {}) {
|
|
|
43
43
|
|
|
44
44
|
cb.addEventListener("change", () => onToggle?.(cb.checked));
|
|
45
45
|
|
|
46
|
+
// What's currently on screen. update() MERGES over it, so a caller reporting one
|
|
47
|
+
// figure doesn't blank the others: a pose-only edit ran no build and no L2 ops,
|
|
48
|
+
// and would otherwise wipe the last build's timing back to "—" on its way to
|
|
49
|
+
// showing a posed count.
|
|
50
|
+
const shown = { ms: null, hits: 0, misses: 0, skipped: 0, rebuilt: 0, posed: 0 };
|
|
51
|
+
|
|
46
52
|
return {
|
|
47
|
-
update(
|
|
53
|
+
update(counts = {}) {
|
|
54
|
+
Object.assign(shown, counts);
|
|
55
|
+
const { ms, hits, misses, skipped, rebuilt, posed } = shown;
|
|
48
56
|
const l2 = cb.checked ? `${hits} hit / ${misses} miss` : "off";
|
|
49
57
|
readout.textContent =
|
|
50
58
|
`build: ${ms != null ? Math.round(ms) + " ms" : "—"}\n` +
|
|
51
59
|
`L2 ops: ${l2}\n` +
|
|
52
|
-
`L1 parts: ${skipped} skipped / ${rebuilt} rebuilt`;
|
|
60
|
+
`L1 parts: ${skipped} skipped / ${rebuilt} rebuilt / ${posed} posed`;
|
|
53
61
|
},
|
|
54
62
|
detach: () => box.remove(),
|
|
55
63
|
};
|
|
@@ -2,9 +2,16 @@ import { zipSync } from "fflate";
|
|
|
2
2
|
|
|
3
3
|
// Browser file-download helpers. Pure DOM/Blob utilities with no app state — the
|
|
4
4
|
// worker produces the bytes; these just hand them to the browser as a download.
|
|
5
|
+
//
|
|
6
|
+
// `sink` is an optional escape hatch for embedders that cannot download from
|
|
7
|
+
// their own document — e.g. partforge running inside a null-origin sandboxed
|
|
8
|
+
// iframe, where a blob: URL is blob:null and browsers such as WebKit refuse to
|
|
9
|
+
// load it. When `sink` is supplied it receives the FINAL bytes and no DOM work
|
|
10
|
+
// happens here; the embedder saves them from a context that can.
|
|
5
11
|
|
|
6
|
-
// Trigger a download of one binary blob under `filename
|
|
7
|
-
export function triggerDownload(data, filename, mime) {
|
|
12
|
+
// Trigger a download of one binary blob under `filename` (or hand it to `sink`).
|
|
13
|
+
export function triggerDownload(data, filename, mime, sink) {
|
|
14
|
+
if (typeof sink === "function") { sink({ data, filename, mime }); return; }
|
|
8
15
|
const url = URL.createObjectURL(new Blob([data], { type: mime }));
|
|
9
16
|
const a = document.createElement("a");
|
|
10
17
|
a.href = url;
|
|
@@ -14,10 +21,11 @@ export function triggerDownload(data, filename, mime) {
|
|
|
14
21
|
}
|
|
15
22
|
|
|
16
23
|
// Download a set of built parts: a single part downloads directly; multiple parts
|
|
17
|
-
// are bundled into one flat, store-only (level 0) zip named `zipName`.
|
|
18
|
-
|
|
19
|
-
|
|
24
|
+
// are bundled into one flat, store-only (level 0) zip named `zipName`. `sink`, if
|
|
25
|
+
// given, is forwarded to triggerDownload so it receives the final bytes.
|
|
26
|
+
export function downloadParts({ parts, ext, mime }, zipName, sink) {
|
|
27
|
+
if (parts.length === 1) return triggerDownload(parts[0].data, `${parts[0].name}.${ext}`, mime, sink);
|
|
20
28
|
const entries = {};
|
|
21
29
|
for (const p of parts) entries[`${p.name}.${ext}`] = new Uint8Array(p.data);
|
|
22
|
-
triggerDownload(zipSync(entries, { level: 0 }), zipName, "application/zip");
|
|
30
|
+
triggerDownload(zipSync(entries, { level: 0 }), zipName, "application/zip", sink);
|
|
23
31
|
}
|
|
@@ -25,6 +25,7 @@ const opentype = typeof opentypeNamespace.parse === "function"
|
|
|
25
25
|
import { KernelCapabilityError } from "./errors.js";
|
|
26
26
|
import { isPlainOptions, KERNEL_OP_SPECS } from "./op-options.js";
|
|
27
27
|
import { textGlyphs } from "./text2d.js";
|
|
28
|
+
import { beveledExtrude } from "./rim-bevel.js";
|
|
28
29
|
import { DEFAULT_FONT_BYTES } from "./fonts/default-font.js";
|
|
29
30
|
import { convexHull, hullPoints } from "./hull.js";
|
|
30
31
|
|
|
@@ -45,6 +46,16 @@ export function finishKernel(k) {
|
|
|
45
46
|
};
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
// extrude({ bevel }) desugars here — before the spec-wrapped op ever sees it —
|
|
50
|
+
// into extrude + loft + intersect (rim-bevel.js), so both backends share one
|
|
51
|
+
// implementation and the probe records no CAD-only op. beveledExtrude calls
|
|
52
|
+
// back into the wrapped k.extrude/k.loft, so caching and validation apply.
|
|
53
|
+
const specExtrude = k.extrude;
|
|
54
|
+
k.extrude = (...a) =>
|
|
55
|
+
a.length === 1 && isPlainOptions(a[0]) && a[0].bevel !== undefined
|
|
56
|
+
? beveledExtrude(k, a[0])
|
|
57
|
+
: specExtrude(...a);
|
|
58
|
+
|
|
48
59
|
k.toSTEP ??= () => { throw new KernelCapabilityError("toSTEP requires the OCCT backend"); };
|
|
49
60
|
k.shape2d ??= () => { throw new KernelCapabilityError("shape2d requires the Manifold backend"); };
|
|
50
61
|
|
|
@@ -99,7 +99,7 @@ export const OCCT_ONLY_OPS = ["fillet", "chamfer", "shell"];
|
|
|
99
99
|
* @property {(o:{r?:number,d?:number}) => Solid} sphere sphere centred at the origin; {r|d}; bare sphere(r) stays valid
|
|
100
100
|
* @property {(o:{size?:number[],center?:boolean,min?:number[],max?:number[]}) => Solid} box {size} = centered X/Y, base z=0 ({center:true} centers Z too) or {min,max}; legacy (min,max) accepted until v2
|
|
101
101
|
* @property {(o:{points:number[][],h:number,twist?:number,scaleTop?:number}) => Solid} prism extrude polygon from z=0; legacy (points,h,opts) accepted until v2
|
|
102
|
-
* @property {(o:{profile:number[][]|{outer:number[][],holes?:number[][][]},h:number,twist?:number,scaleTop?:number}) => Solid} extrude polygon-with-holes region from z=0; legacy (profile,h,opts) accepted until v2
|
|
102
|
+
* @property {(o:{profile:number[][]|{outer:number[][],holes?:number[][][]},h:number,twist?:number,scaleTop?:number,bevel?:number|{bottom?:number,top?:number}}) => Solid} extrude polygon-with-holes region from z=0; bevel = 45° rim bevel (any profile form incl. Shape2D, materialized to point rings; no twist/scaleTop); legacy (profile,h,opts) accepted until v2
|
|
103
103
|
* @property {(o:{rings:{polygon?:number[][],sides?:number,radius?:number,z:number,rotate?:number,scale?:number|number[]}[],ruled?:boolean,closed?:boolean}) => Solid} loft stack polygon cross-sections; legacy (rings,opts) accepted until v2
|
|
104
104
|
* @property {(o:{profile:number[][],path:number[][],closed?:boolean,cornerRadius?:number,ruled?:boolean,smooth?:boolean}) => Solid} sweep sweep a 2-D profile along a 3-D polyline; legacy (profile,path,opts) accepted until v2
|
|
105
105
|
* @property {(o:{profile:number[][],degrees?:number}) => Solid} revolve revolve a lathe profile [[r,z],…] around Z; legacy (points,opts) accepted until v2
|
|
@@ -8,6 +8,7 @@ import { addSugar } from "./solid-sugar.js";
|
|
|
8
8
|
import { addShape2dSugar } from "./shape2d-sugar.js";
|
|
9
9
|
import { assembleRegions } from "./shape2d-regions.js";
|
|
10
10
|
import { finishKernel } from "./kernel-front.js";
|
|
11
|
+
import { meshToStl } from "./mesh-stl.js";
|
|
11
12
|
|
|
12
13
|
const PLANE_NORMAL = { XY: [0, 0, 1], XZ: [0, 1, 0], YZ: [1, 0, 0] };
|
|
13
14
|
// 'preview' = interactive view (fast); 'print' = STL export (high-res, used only
|
|
@@ -363,21 +364,14 @@ function creasedNormals(g, sharpCos, featureLabels) {
|
|
|
363
364
|
}
|
|
364
365
|
|
|
365
366
|
function stlFromMesh(g) {
|
|
366
|
-
const
|
|
367
|
-
const
|
|
368
|
-
let
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
const ux = b[0]-a[0], uy = b[1]-a[1], uz = b[2]-a[2];
|
|
375
|
-
const vx = c[0]-a[0], vy = c[1]-a[1], vz = c[2]-a[2];
|
|
376
|
-
const nx = uy*vz - uz*vy, ny = uz*vx - ux*vz, nz = ux*vy - uy*vx;
|
|
377
|
-
const L = Math.hypot(nx, ny, nz) || 1;
|
|
378
|
-
dv.setFloat32(o, nx/L, true); dv.setFloat32(o+4, ny/L, true); dv.setFloat32(o+8, nz/L, true); o += 12;
|
|
379
|
-
for (const p of [a, b, c]) for (const x of p) { dv.setFloat32(o, x, true); o += 4; }
|
|
380
|
-
dv.setUint16(o, 0, true); o += 2;
|
|
367
|
+
const vp = g.vertProperties, np = g.numProp;
|
|
368
|
+
const nVert = (vp.length / np) | 0;
|
|
369
|
+
let positions;
|
|
370
|
+
if (np === 3) {
|
|
371
|
+
positions = vp; // already x,y,z per vertex
|
|
372
|
+
} else {
|
|
373
|
+
positions = new Float32Array(nVert * 3);
|
|
374
|
+
for (let i = 0; i < nVert; i++) { positions[i*3] = vp[i*np]; positions[i*3+1] = vp[i*np+1]; positions[i*3+2] = vp[i*np+2]; }
|
|
381
375
|
}
|
|
382
|
-
return
|
|
376
|
+
return meshToStl(positions, g.triVerts);
|
|
383
377
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Pure-JS binary STL writer, shared by both geometry backends. Takes a flat
|
|
2
|
+
// vertex-position array (x,y,z per vertex) and triangle indices, and returns a
|
|
3
|
+
// binary STL ArrayBuffer. STL is a triangle-mesh format, so this is the one and
|
|
4
|
+
// only STL path — OCCT and Manifold both feed it a mesh. It deliberately does
|
|
5
|
+
// NOT touch Blobs: the sandbox worker on Safari cannot read a Blob, so every
|
|
6
|
+
// export must hand back a raw ArrayBuffer.
|
|
7
|
+
export function meshToStl(positions, indices) {
|
|
8
|
+
const n = (indices.length / 3) | 0;
|
|
9
|
+
const ab = new ArrayBuffer(84 + n * 50);
|
|
10
|
+
const dv = new DataView(ab);
|
|
11
|
+
dv.setUint32(80, n, true); // triangle count (80-byte header left zero)
|
|
12
|
+
let o = 84;
|
|
13
|
+
const P = (i) => [positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2]];
|
|
14
|
+
for (let i = 0; i < n; i++) {
|
|
15
|
+
const a = P(indices[i * 3]), b = P(indices[i * 3 + 1]), c = P(indices[i * 3 + 2]);
|
|
16
|
+
// Per-facet flat normal from the winding. Slicers recompute this, but some
|
|
17
|
+
// viewers (macOS Preview/Quick Look) render unlit if it's left zero.
|
|
18
|
+
const ux = b[0] - a[0], uy = b[1] - a[1], uz = b[2] - a[2];
|
|
19
|
+
const vx = c[0] - a[0], vy = c[1] - a[1], vz = c[2] - a[2];
|
|
20
|
+
const nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
|
|
21
|
+
const L = Math.hypot(nx, ny, nz) || 1;
|
|
22
|
+
dv.setFloat32(o, nx / L, true); dv.setFloat32(o + 4, ny / L, true); dv.setFloat32(o + 8, nz / L, true); o += 12;
|
|
23
|
+
for (const p of [a, b, c]) for (const x of p) { dv.setFloat32(o, x, true); o += 4; }
|
|
24
|
+
dv.setUint16(o, 0, true); o += 2;
|
|
25
|
+
}
|
|
26
|
+
return ab;
|
|
27
|
+
}
|