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 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
@@ -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
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.28.0",
3
+ "version": "0.32.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",
@@ -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) => { controls.push({ key, el: node }); keys.add(key); };
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
- register(def.key, s.wrap);
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
- register(feat.key, checkRow);
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({ ms, hits = 0, misses = 0, skipped = 0, rebuilt = 0 } = {}) {
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
- export function downloadParts({ parts, ext, mime }, zipName) {
19
- if (parts.length === 1) return triggerDownload(parts[0].data, `${parts[0].name}.${ext}`, mime);
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 tris = g.triVerts, vp = g.vertProperties, np = g.numProp, n = tris.length / 3;
367
- const ab = new ArrayBuffer(84 + n * 50); const dv = new DataView(ab); dv.setUint32(80, n, true);
368
- let o = 84; const P = (i) => [vp[i*np], vp[i*np+1], vp[i*np+2]];
369
- for (let i = 0; i < n; i++) {
370
- const a = P(tris[i*3]), b = P(tris[i*3+1]), c = P(tris[i*3+2]);
371
- // Per-facet flat normal from the winding (Manifold is CCW → outward). Slicers
372
- // recompute this, but viewers that light from the stored normal (macOS
373
- // Preview/Quick Look) render the mesh unlit if it's left as zero.
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 ab;
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
+ }