partforge 0.67.4 → 0.69.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.
@@ -54,6 +54,33 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
54
54
  const unionRaw = (ms) => (ms.length === 1 ? ms[0] : T(Manifold.union(ms)));
55
55
 
56
56
  const cache = createSolidCache();
57
+ // Feature-skip warnings (the OCCT backend's safeOp policy, adopted here): a
58
+ // fillet/chamfer whose mesh machinery is defeated by the geometry returns its
59
+ // INPUT solid unchanged and records one message here instead of failing the
60
+ // whole build. jobs.js drains this per sub-part (takeBuildWarnings) and ships
61
+ // it out on the meshes message, so a caller — the cloud agent above all — is
62
+ // TOLD the feature was skipped rather than left believing it landed.
63
+ const buildWarnings = [];
64
+ // cache key -> warning message for ops that skipped. The identity result is
65
+ // deliberately NOT cached (a later build should re-attempt the feature after
66
+ // upstream geometry changes — same key means same failure, so re-warning is
67
+ // cheap), but a repeated call in the SAME session must still re-emit the
68
+ // warning: without this map, a no-op re-apply would rebuild from warm caches
69
+ // upstream, hit the recorded skip nowhere, silently re-fail and re-warn — fine
70
+ // — but a memoized wrapper above us could also swallow the retry. Keeping the
71
+ // message per key makes "skipped before, skipped again" deterministic and free.
72
+ const skippedOps = new Map();
73
+ // The one recorder. Shared, backend-neutral degrades (the extrude rim bevel in
74
+ // rim-bevel.js, roundedBox's rim clamp in op-options.js, Shape2D's corner-op
75
+ // clamps in contour-ops.js) reach it through the kernel's `_recordWarning`, so
76
+ // every degrade in the build lands in one drainable list rather than only in
77
+ // the console.
78
+ const recordWarning = (msg) => { buildWarnings.push(msg); console.warn(`partforge: ${msg}`); };
79
+ const skipFeature = (key, op, magnitude, err) => {
80
+ const msg = `${op} ${magnitude} failed (${String(err?.message || err).slice(0, 200)}) — feature skipped, edges left sharp`;
81
+ skippedOps.set(key, msg);
82
+ recordWarning(msg);
83
+ };
57
84
  const featureLabels = new Map(); // originalID -> label string (grows per label(); tiny)
58
85
  const oidPolicies = new Map(); // originalID -> shading policy (grows per faceted/hinted loft; tiny)
59
86
  // name -> { m, digest, hash } | { error, digest } — imported geometry the framework
@@ -80,6 +107,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
80
107
  segs,
81
108
  extrude: (o) => kernel.extrude(o),
82
109
  revolve: (o) => kernel.revolve(o),
110
+ recordWarning,
83
111
  });
84
112
  // Lazy CrossSection materialization, memoized through the solid cache by content
85
113
  // hash + LOD: the same shape extruded twice (or extruded and revolved) tessellates
@@ -237,8 +265,13 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
237
265
  let base = T(Manifold.extrude(cur, height));
238
266
  if (z0 !== 0) base = T(base.translate([0, 0, z0]));
239
267
  const wrapped = wrap(base, h("roundAllPrismBase", mHash, r, quality));
240
- // selector-free: every sharp edge of the mitered prism gets its radius here
241
- const filleted = wrapped.fillet(r);
268
+ // selector-free: every sharp edge of the mitered prism gets its radius here.
269
+ // The THROWING form deliberately: this path's answer to a failed fillet is
270
+ // the `catch` below, which returns null and hands the job to the reference
271
+ // Minkowski roundAll. The degrading public fillet would instead hand back
272
+ // the un-rounded prism, and this function would emit it as a successful
273
+ // roundAll — silently wrong geometry instead of a correct slow result.
274
+ const filleted = wrapped._filletRaw(r);
242
275
  // Decouple from the fillet cache's pin: cached() will pin the object this
243
276
  // returns under the roundAll hash, and one WASM object must never sit
244
277
  // under two cache entries (double-dispose on eviction). The decouple is
@@ -273,22 +306,66 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
273
306
  }
274
307
  };
275
308
 
276
- const wrap = (m, hash) => addSugar({
309
+ // `self` names the wrapper being built so the degrading public fillet/chamfer
310
+ // can delegate to their throwing `_`-prefixed twins above without re-deriving
311
+ // the cache key or the capability checks. Declared as a binding the closures
312
+ // capture: every reference runs after addSugar has returned.
313
+ const wrap = (m, hash) => {
314
+ const self = addSugar({
277
315
  _m: m,
278
316
  _hash: hash,
279
317
  cut: (t) => cached(h("cut", hash, t._hash), () => T(m.subtract(t._m))),
280
- fillet: (r, selector) => {
318
+ // THROWING forms. These are the composition primitives — internal callers
319
+ // that have their own recovery (prismRoundAllFast, which answers a failed
320
+ // fillet by falling back to the reference Minkowski roundAll) must use
321
+ // these, never the degrading public ops below: a skip there would emit an
322
+ // UN-rounded prism as a successful roundAll, which is silently wrong
323
+ // geometry rather than a reported missing feature.
324
+ _filletRaw: (r, selector) => {
281
325
  if (typeof selector === "function") throw new KernelCapabilityError("fillet: function selectors need the OCCT backend");
282
326
  if (r === 0) return wrap(m, hash); // contract: zero magnitude is the identity
283
327
  return cached(h("fillet", hash, r, selector ?? null, segs), () =>
284
328
  meshCadOp("fillet", m, () => meshFillet(kernel, wrap(m, hash), { r, edges: selector, segs })));
285
329
  },
286
- chamfer: (d, selector) => {
330
+ _chamferRaw: (d, selector) => {
287
331
  if (typeof selector === "function") throw new KernelCapabilityError("chamfer: function selectors need the OCCT backend");
288
332
  if (d === 0) return wrap(m, hash);
289
333
  return cached(h("chamfer", hash, d, selector ?? null, segs), () =>
290
334
  meshCadOp("chamfer", m, () => meshChamfer(kernel, wrap(m, hash), { d, edges: selector, segs })));
291
335
  },
336
+
337
+ // The AUTHOR-FACING ops degrade on failure instead of failing the build (the
338
+ // OCCT backend's safeOp policy — see occt-repair.js): a defeated op returns
339
+ // the INPUT solid and records a feature-skip warning. Only NEEDS_OCCT
340
+ // capability errors still propagate — they are the split-backend reroute
341
+ // signal, not a geometry failure, and swallowing one would strand the
342
+ // sub-part on the wrong backend. The skip result is not cached: same key →
343
+ // same failure → same cheap re-warn, while an upstream geometry change mints
344
+ // a new key and genuinely re-attempts the feature.
345
+ fillet: (r, selector) => {
346
+ const key = h("fillet", hash, r, selector ?? null, segs);
347
+ const skipped = skippedOps.get(key);
348
+ if (skipped !== undefined) { buildWarnings.push(skipped); return wrap(m, hash); }
349
+ try {
350
+ return self._filletRaw(r, selector);
351
+ } catch (e) {
352
+ if (e?.code === "NEEDS_OCCT") throw e;
353
+ skipFeature(key, "fillet", r, e);
354
+ return wrap(m, hash);
355
+ }
356
+ },
357
+ chamfer: (d, selector) => {
358
+ const key = h("chamfer", hash, d, selector ?? null, segs);
359
+ const skipped = skippedOps.get(key);
360
+ if (skipped !== undefined) { buildWarnings.push(skipped); return wrap(m, hash); }
361
+ try {
362
+ return self._chamferRaw(d, selector);
363
+ } catch (e) {
364
+ if (e?.code === "NEEDS_OCCT") throw e;
365
+ skipFeature(key, "chamfer", d, e);
366
+ return wrap(m, hash);
367
+ }
368
+ },
292
369
  roundAll: (r) => {
293
370
  if (r === 0) return wrap(m, hash); // contract: zero magnitude is the identity
294
371
  // `quality` in the key is redundant but harmless — the cache lives on a
@@ -433,7 +510,9 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
433
510
  toMesh: () => meshOut(m, false),
434
511
  toSTL: () => Promise.resolve(meshOut(m, true)),
435
512
  toIndexedMesh: () => indexedMeshOut(m),
436
- });
513
+ });
514
+ return self;
515
+ };
437
516
 
438
517
  const kernel = finishKernel({
439
518
  cylinder: (rb, rt, h2, { center = false } = {}) =>
@@ -571,6 +650,13 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
571
650
  sweepCache: () => cache.sweep(),
572
651
  cacheStats: () => cache.stats(),
573
652
  resetCacheStats: () => cache.resetStats(),
653
+ // Drain the feature-skip warnings recorded since the last drain (see
654
+ // buildWarnings above). jobs.js calls this per sub-part so a warning is
655
+ // attributed to the sub-part whose build recorded it.
656
+ takeBuildWarnings: () => buildWarnings.splice(0),
657
+ // Internal (underscore = not the contract surface): the recorder shared,
658
+ // backend-neutral helpers report their own degrades through.
659
+ _recordWarning: recordWarning,
574
660
  // Free every WASM object created since the last cleanup EXCEPT solids the cache
575
661
  // still pins (they must survive for the next build to resume from them).
576
662
  cleanup: () => { for (const o of tracked) if (!cache.isPinned(o)) o.delete?.(); tracked.length = 0; },
@@ -39,9 +39,15 @@ export function createOcctKernel(replicad) {
39
39
  const { makeCylinder, makeBox, makeCircle, makeHelix, assembleWire, genericSweep,
40
40
  loft, draw, exportSTEP, importSTEP, measureVolume, makeSphere, makeLine, Plane } = replicad;
41
41
 
42
+ // Feature-skip warnings: everything occt-repair (and roundAll) skips or rescues
43
+ // is recorded here as well as console.warned, and jobs.js drains it per
44
+ // sub-part (takeBuildWarnings) onto the meshes message — same channel as the
45
+ // Manifold backend's fillet/chamfer degradation.
46
+ const buildWarnings = [];
47
+ const recordWarning = (msg) => { buildWarnings.push(msg); console.warn(`partforge: ${msg}`); };
42
48
  // Fillet/chamfer/shell failure recovery (skip-on-failure, chamfer binary search) —
43
49
  // see occt-repair.js for the policies and why they differ per op.
44
- const { validChamfer, safeOp } = createOcctRepair(measureVolume);
50
+ const { validChamfer, safeOp } = createOcctRepair(measureVolume, recordWarning);
45
51
 
46
52
  // name -> { shape, digest } | { error, digest } — imported geometry the framework
47
53
  // registers pre-build via `_registerImport` (kernel-lifetime, untracked by the
@@ -234,7 +240,7 @@ export function createOcctKernel(replicad) {
234
240
  return cached(key, () => {
235
241
  const a = mat();
236
242
  if (r === 0) return wrap(a._s.clone(), cloneLabels(a._labels), key);
237
- return wrap(occtRoundAll(replicad, a._s, r), cloneLabels(a._labels), key);
243
+ return wrap(occtRoundAll(replicad, a._s, r, recordWarning), cloneLabels(a._labels), key);
238
244
  });
239
245
  },
240
246
  shell: (thickness, openFaces) => {
@@ -367,6 +373,7 @@ export function createOcctKernel(replicad) {
367
373
  segs: SHAPE2D_SEGS,
368
374
  extrude: (o) => kernel.extrude(o),
369
375
  revolve: (o) => kernel.revolve(o),
376
+ recordWarning,
370
377
  });
371
378
  // Lazy Drawing materialization for the kernel ops that need one. drawingFromRegions
372
379
  // draws a FRESH Drawing on every call, so callers never need to .clone() the result
@@ -524,6 +531,12 @@ export function createOcctKernel(replicad) {
524
531
  sweepCache: () => cache.sweep(),
525
532
  cacheStats: () => cache.stats(),
526
533
  resetCacheStats: () => cache.resetStats(),
534
+ // Drain the feature-skip warnings recorded since the last drain — the
535
+ // Manifold backend's channel, mirrored (see occt-repair.js for the sources).
536
+ takeBuildWarnings: () => buildWarnings.splice(0),
537
+ // Internal: the recorder shared, backend-neutral helpers report through
538
+ // (rim-bevel, roundedBox's clamp, Shape2D corner-op clamps).
539
+ _recordWarning: recordWarning,
527
540
  });
528
541
  return kernel;
529
542
  }
@@ -33,7 +33,11 @@ export const isClosedSolid = (shape) => {
33
33
  return true;
34
34
  };
35
35
 
36
- export function createOcctRepair(measureVolume) {
36
+ // `warn` receives every feature-skip / rescue message. The default keeps the
37
+ // historical console.warn; the OCCT backend injects a recorder that ALSO feeds
38
+ // the kernel's takeBuildWarnings channel, so a skipped feature reaches the
39
+ // build result (and, in the cloud app, the agent) instead of only the console.
40
+ export function createOcctRepair(measureVolume, warn = (msg) => console.warn(`partforge: ${msg}`)) {
37
41
  // The true maximum chamfer for an edge depends on local angles and adjacent features,
38
42
  // which is hard to predict analytically (and OCCT exposes no max-radius query). So
39
43
  // VALIDATE the result instead of guessing: try the requested distance, and if it makes
@@ -65,8 +69,8 @@ export function createOcctRepair(measureVolume) {
65
69
  // multiplies an already-expensive op by ~8x, so make the cost loud enough to
66
70
  // act on (lower the distance, or bevel profile rims with a loft instead).
67
71
  const cost = `${attempts} attempts, ${((performance.now() - t0) / 1000).toFixed(1)}s — see ERROR-PATTERNS.md#chamfer-rescue-bisection`;
68
- if (best) { console.warn(`partforge: chamfer ${distance} over-ran the geometry — reduced to ${bestD.toFixed(2)} (largest valid; ${cost})`); return best; }
69
- console.warn(`partforge: chamfer ${distance} has no valid distance for this geometry — feature skipped (${cost})`);
72
+ if (best) { warn(`chamfer ${distance} over-ran the geometry — reduced to ${bestD.toFixed(2)} (largest valid; ${cost})`); return best; }
73
+ warn(`chamfer ${distance} has no valid distance for this geometry — feature skipped (${cost})`);
70
74
  return shape.clone(); // nothing valid — skip the chamfer
71
75
  };
72
76
 
@@ -90,10 +94,10 @@ export function createOcctRepair(measureVolume) {
90
94
  const resultVolume = measureVolume(result);
91
95
  if (resultVolume > 0 && (!isValid || isValid(resultVolume, beforeVolume))) { backup.delete?.(); return result; }
92
96
  result.delete?.();
93
- if (resultVolume > 0) console.warn(`partforge: ${label} produced invalid geometry — feature skipped`);
94
- else console.warn(`partforge: ${label} produced an empty solid — feature skipped (radius out of range?)`);
97
+ if (resultVolume > 0) warn(`${label} produced invalid geometry — feature skipped`);
98
+ else warn(`${label} produced an empty solid — feature skipped (radius out of range?)`);
95
99
  } catch (e) {
96
- console.warn(`partforge: ${label} failed (${e?.message || e}) — feature skipped`);
100
+ warn(`${label} failed (${e?.message || e}) — feature skipped`);
97
101
  }
98
102
  return backup;
99
103
  };
@@ -31,7 +31,7 @@ const VARIANTS = [
31
31
  { join: "int", inter: true },
32
32
  ];
33
33
 
34
- export function occtRoundAll(replicad, shape, r) {
34
+ export function occtRoundAll(replicad, shape, r, warn = (msg) => console.warn(`partforge: ${msg}`)) {
35
35
  if (!Number.isFinite(r) || r <= 0) throw new Error("roundAll: r must be a finite number > 0 (r = 0 is handled as the identity by the caller)");
36
36
  const oc = replicad.getOC();
37
37
  const tryOffset = (topo, offset, v) => {
@@ -60,7 +60,7 @@ export function occtRoundAll(replicad, shape, r) {
60
60
  vol = replicad.measureVolume(shape);
61
61
  } catch (e) {
62
62
  // Can't gate what can't be measured — skip rather than run the cascade blind.
63
- console.warn(`partforge: roundall-skipped: the input solid's volume could not be measured (${e?.message || e}); returning the un-rounded solid`);
63
+ warn(`roundall-skipped: the input solid's volume could not be measured (${e?.message || e}); returning the un-rounded solid`);
64
64
  return shape.clone(); // if the clone throws too, the caller's shape is unusable — let it propagate
65
65
  }
66
66
  let cur = shape;
@@ -85,7 +85,7 @@ export function occtRoundAll(replicad, shape, r) {
85
85
  }
86
86
  if (cur !== shape) cur.delete?.(); // superseded intermediate; never the caller's shape
87
87
  if (!next) {
88
- console.warn(`partforge: roundall-skipped: offset step ${off} produced no valid solid — r=${r} is likely at/above the smallest feature size; returning the un-rounded solid`);
88
+ warn(`roundall-skipped: offset step ${off} produced no valid solid — r=${r} is likely at/above the smallest feature size; returning the un-rounded solid`);
89
89
  return shape.clone();
90
90
  }
91
91
  cur = next;
@@ -150,7 +150,7 @@ const checkRoundRadius = (op, name, v, max, maxDesc) => {
150
150
  if (v > max + 1e-9) throw new Error(`${op}: ${name} (${v}) must be ≤ ${maxDesc}`);
151
151
  };
152
152
 
153
- export function roundedBoxArgs(o) {
153
+ export function roundedBoxArgs(o, record) {
154
154
  checkKeys("roundedBox", o, ["size", "center", "round"]);
155
155
  const size = req("roundedBox", o, "size");
156
156
  if (!Array.isArray(size) || size.length !== 3 || !size.every((v) => Number.isFinite(v) && v > 0))
@@ -174,6 +174,11 @@ export function roundedBoxArgs(o) {
174
174
  // message (and a distinct Set entry) on every rebuild, defeating the dedupe.
175
175
  const dedupeKey = `roundedBox.${key}|${round.side}`;
176
176
  const msg = `roundedBox: round.${key} ${round[key]} clamped to round.side ${round.side} (side must be 0 or ≥ rim radii; use side: 0 for a rim-only round-over)`;
177
+ // The console dedupe stays (a slider sweep would otherwise spam it), but
178
+ // the RECORDER is fed every time: warnings are drained per build, so
179
+ // deduping them across builds would silently drop the clamp from the
180
+ // second and every later build that still clamps.
181
+ record?.(msg);
177
182
  if (!warnedClamps.has(dedupeKey)) { warnedClamps.add(dedupeKey); console.warn(msg); }
178
183
  round[key] = round.side;
179
184
  }
@@ -67,7 +67,7 @@ const ccw = (r) => {
67
67
  // loop is deterministic, preserving build purity. `corners: "sharp"` keeps the
68
68
  // offset 1:1 with the input points — loft stitching requires every ring to
69
69
  // share the profile's exact point count (a mismatch is treated as a failed try).
70
- const fit = (ring, delta, what) => {
70
+ const fit = (ring, delta, what, record) => {
71
71
  const requested = Math.abs(delta), sign = Math.sign(delta);
72
72
  let c = requested;
73
73
  for (;;) {
@@ -75,13 +75,13 @@ const fit = (ring, delta, what) => {
75
75
  const off = offsetPolygon(ring, sign * c, { corners: "sharp" });
76
76
  if (off.length === ring.length) {
77
77
  if (c < requested)
78
- console.warn(`partforge: extrude bevel ${requested} exceeds what the ${what} can take — reduced to ${c.toFixed(2)}`);
78
+ record(`extrude bevel ${requested} exceeds what the ${what} can take — reduced to ${c.toFixed(2)}`);
79
79
  return { ring: off, c };
80
80
  }
81
81
  } catch { /* offset collapsed or self-intersected — try smaller */ }
82
82
  c *= 0.85;
83
83
  if (c < 0.05) {
84
- console.warn(`partforge: extrude bevel ${requested} has no valid offset for this ${what} — rim left square`);
84
+ record(`extrude bevel ${requested} has no valid offset for this ${what} — rim left square`);
85
85
  return null;
86
86
  }
87
87
  }
@@ -96,12 +96,12 @@ const outerRings = (outer, h, b, t) => {
96
96
  return rings;
97
97
  };
98
98
 
99
- const bevelRegion = (k, region, h, bottom, top) => {
99
+ const bevelRegion = (k, region, h, bottom, top, record) => {
100
100
  const outer = ccw(region.outer);
101
101
  const holes = (region.holes ?? []).map(ccw);
102
102
  let s = k.extrude({ profile: holes.length ? { outer, holes } : outer, h });
103
- const b = bottom > 0 ? fit(outer, -bottom, "profile") : null;
104
- const t = top > 0 ? fit(outer, -top, "profile") : null;
103
+ const b = bottom > 0 ? fit(outer, -bottom, "profile", record) : null;
104
+ const t = top > 0 ? fit(outer, -top, "profile", record) : null;
105
105
  // shading: "smooth" on all three internal lofts — a bevel band inherits the
106
106
  // profile's own shading intent (sharp corners at the bevel's start/end
107
107
  // rings, as a real chamfer would look), not the loft op's own facet-vs-
@@ -112,11 +112,11 @@ const bevelRegion = (k, region, h, bottom, top) => {
112
112
  if (b || t) s = s.intersect(k.loft({ rings: outerRings(outer, h, b, t), shading: "smooth" }));
113
113
  const cutters = [];
114
114
  for (const hole of holes) {
115
- const hb = bottom > 0 ? fit(hole, bottom, "hole") : null;
115
+ const hb = bottom > 0 ? fit(hole, bottom, "hole", record) : null;
116
116
  if (hb) cutters.push(k.loft({ rings: [
117
117
  { polygon: hb.ring, z: -1 }, { polygon: hb.ring, z: 0 }, { polygon: hole, z: hb.c },
118
118
  ], shading: "smooth" }));
119
- const ht = top > 0 ? fit(hole, top, "hole") : null;
119
+ const ht = top > 0 ? fit(hole, top, "hole", record) : null;
120
120
  if (ht) cutters.push(k.loft({ rings: [
121
121
  { polygon: hole, z: h - ht.c }, { polygon: ht.ring, z: h }, { polygon: ht.ring, z: h + 1 },
122
122
  ], shading: "smooth" }));
@@ -124,7 +124,14 @@ const bevelRegion = (k, region, h, bottom, top) => {
124
124
  return cutters.length ? s.cutAll(cutters) : s;
125
125
  };
126
126
 
127
- export function beveledExtrude(k, { profile, h, twist, scaleTop, bevel }) {
127
+ // `record` reports the two ways a rim bevel degrades — reduced to what the ring
128
+ // can take, or skipped entirely with the rim left square. Both used to reach only
129
+ // console.warn, which meant a build could come back ok:true with a bevel the part
130
+ // asked for and never got, invisible to the caller (and to the cloud agent). The
131
+ // kernel supplies its own recorder; the default keeps the historical console.warn
132
+ // for a direct call with no kernel recorder behind it.
133
+ export function beveledExtrude(k, { profile, h, twist, scaleTop, bevel },
134
+ record = k?._recordWarning ?? ((m) => console.warn(`partforge: ${m}`))) {
128
135
  if (twist !== undefined || scaleTop !== undefined)
129
136
  throw new Error("extrude: bevel cannot combine with twist or scaleTop");
130
137
  const { bottom, top } = resolveBevel(bevel, h);
@@ -135,5 +142,5 @@ export function beveledExtrude(k, { profile, h, twist, scaleTop, bevel }) {
135
142
  ? profile.toRegions()
136
143
  : [tessellateProfile(profile, BEVEL_SEGS)];
137
144
  if (regions.length === 0) throw new Error("extrude: bevel profile produced no regions");
138
- return regions.map((r) => bevelRegion(k, r, h, bottom, top)).reduce((a, x) => a.union(x));
145
+ return regions.map((r) => bevelRegion(k, r, h, bottom, top, record)).reduce((a, x) => a.union(x));
139
146
  }
@@ -45,7 +45,11 @@ const checkProfile = (x) => {
45
45
  }
46
46
  };
47
47
 
48
- export function makeShape2dFactory({ segs, extrude, revolve }) {
48
+ // `recordWarning` is the kernel's build-warning recorder (see manifold-backend /
49
+ // occt-backend). Corner ops CLAMP a magnitude the geometry cannot take rather
50
+ // than throwing, and a clamp that only reached the console would leave a caller
51
+ // believing it got the radius it asked for.
52
+ export function makeShape2dFactory({ segs, extrude, revolve, recordWarning }) {
49
53
  // Lift any accepted profile form into stored regions: a live Shape2D is deep-copied out
50
54
  // via its own toContours() (value semantics — never alias another shape's storage);
51
55
  // anything else goes through liftProfile + per-ring winding normalization.
@@ -86,8 +90,8 @@ export function makeShape2dFactory({ segs, extrude, revolve }) {
86
90
  rotate: (deg, center) => viaOps((r) => rotateProfile(r, deg, center)),
87
91
  scale: (f, center) => viaOps((r) => scaleProfile(r, f, center)),
88
92
  mirror: (axis) => viaOps((r) => mirrorProfile(r, axis)),
89
- fillet: (r, opts) => viaOps((rg) => filletProfile(rg, r, opts)),
90
- chamfer: (d, opts) => viaOps((rg) => chamferProfile(rg, d, opts)),
93
+ fillet: (r, opts) => viaOps((rg) => filletProfile(rg, r, opts, recordWarning)),
94
+ chamfer: (d, opts) => viaOps((rg) => chamferProfile(rg, d, opts, recordWarning)),
91
95
  simplify: (tol) => viaOps((r) => simplifyProfile(r, tol)),
92
96
  corners: () => profileCorners(regions),
93
97
  contains: (p) => profileContains(regions, p),
@@ -130,6 +130,13 @@ export async function handle(kernel, part, msg, post, opts = {}) {
130
130
  const t0 = Date.now();
131
131
  const useCache = msg.cache !== false; // ?debug toggle can disable caching (cache:false)
132
132
  const meshes = [];
133
+ // Feature-skip warnings (a fillet/chamfer the geometry defeated — see the
134
+ // backends' takeBuildWarnings): drained per sub-part below so each message
135
+ // names the sub-part whose build recorded it, and drained-and-discarded here
136
+ // first so a previous job's stragglers (an oracle build, an export) cannot be
137
+ // misattributed to this build's first sub-part.
138
+ kernel.takeBuildWarnings?.();
139
+ const warnings = [];
133
140
  kernel.resetCacheStats?.(); // count hits/misses for just this job
134
141
  for (const [i, name] of msg.subparts.entries()) {
135
142
  if (useCache) kernel.beginSubPart?.(name); // open the per-sub-part cache round
@@ -137,6 +144,7 @@ export async function handle(kernel, part, msg, post, opts = {}) {
137
144
  const m = posed(name, "display").toMesh({ quality: "preview" });
138
145
  meshes.push({ name, positions: m.positions, normals: m.normals, indices: m.indices, triangles: m.triangles, edges: m.edges, featureIds: m.featureIds, features: m.features });
139
146
  } finally {
147
+ for (const message of kernel.takeBuildWarnings?.() ?? []) warnings.push({ part: name, message });
140
148
  if (useCache) kernel.endSubPart?.(); // always close the bracket — a throw mid-build must not strand pinned solids
141
149
  kernel.cleanup?.(); // free this round's transients (cached/pinned solids survive)
142
150
  }
@@ -152,7 +160,8 @@ export async function handle(kernel, part, msg, post, opts = {}) {
152
160
  }
153
161
  const transfer = meshes.flatMap((m) =>
154
162
  [m.positions.buffer, m.normals?.buffer, m.indices?.buffer, m.edges?.buffer, m.featureIds?.buffer].filter(Boolean));
155
- post({ type: "meshes", meshes, ms: Date.now() - t0, cache: kernel.cacheStats?.() }, transfer);
163
+ post({ type: "meshes", meshes, ms: Date.now() - t0, cache: kernel.cacheStats?.(),
164
+ ...(warnings.length ? { warnings } : {}) }, transfer);
156
165
  } else if (msg.type === "capture-generate") {
157
166
  // A private, job-correlated one-shot channel for captureView — builds a
158
167
  // (possibly non-active) view's meshes off the regen loop, so it can never
@@ -161,19 +170,23 @@ export async function handle(kernel, part, msg, post, opts = {}) {
161
170
  // isStale/superseded polling — there's nothing to supersede a one-shot.
162
171
  const useCache = msg.cache !== false;
163
172
  const meshes = [];
173
+ kernel.takeBuildWarnings?.(); // discard a previous job's stragglers (same as generate)
174
+ const warnings = [];
164
175
  for (const name of msg.subparts) {
165
176
  if (useCache) kernel.beginSubPart?.(name);
166
177
  try {
167
178
  const m = posed(name, "display").toMesh({ quality: "preview" });
168
179
  meshes.push({ name, positions: m.positions, normals: m.normals, indices: m.indices, triangles: m.triangles, edges: m.edges, featureIds: m.featureIds, features: m.features });
169
180
  } finally {
181
+ for (const message of kernel.takeBuildWarnings?.() ?? []) warnings.push({ part: name, message });
170
182
  if (useCache) kernel.endSubPart?.();
171
183
  kernel.cleanup?.();
172
184
  }
173
185
  }
174
186
  const captureTransfer = meshes.flatMap((m) =>
175
187
  [m.positions.buffer, m.normals?.buffer, m.indices?.buffer, m.edges?.buffer, m.featureIds?.buffer].filter(Boolean));
176
- post({ type: "capture-meshes", jobId: msg.jobId, meshes }, captureTransfer);
188
+ post({ type: "capture-meshes", jobId: msg.jobId, meshes,
189
+ ...(warnings.length ? { warnings } : {}) }, captureTransfer);
177
190
  } else if (msg.type === "export-stl") {
178
191
  const names = selected();
179
192
  if (names.length === 0) throw new Error("no exportable parts selected");
@@ -28,6 +28,8 @@ import { attachAnimationControls } from "./animation-controls.js";
28
28
  import { resolveDefaultView } from "./default-view.js";
29
29
  import { createMeasureMode } from "./measure/measure-mode.js";
30
30
  import { attachMeasureControls } from "./measure/measure-controls.js";
31
+ import { createAnnotateMode } from "./annotate/annotate-mode.js";
32
+ import { attachAnnotateControls } from "./annotate/annotate-controls.js";
31
33
 
32
34
  // The mount handle, factored out so its shape is unit-testable without booting
33
35
  // the full mount() pipeline (WASM + workers + DOM).
@@ -37,6 +39,14 @@ const NOOP_TOOLTIP_BINDING = { sync: () => {}, hide: () => {}, detach: () => {}
37
39
  // Same no-op-default stance as attachTooltips/setHostPane below, for a
38
40
  // makeHandle caller (or a direct test) that doesn't wire measure mode.
39
41
  const NOOP_MEASURE = { isEnabled: () => false, setEnabled: () => {}, clearPins: () => {}, pinCount: () => 0 };
42
+ // Same stance as NOOP_MEASURE: the handle's annotate surface exists whether or
43
+ // not this mount wired the mode (it wires only when the host passes
44
+ // onAnnotationSend — without a sink, Send would have nowhere to go).
45
+ const NOOP_ANNOTATE = {
46
+ isEnabled: () => false, setEnabled: () => {}, undo: () => {}, clear: () => {},
47
+ strokeCount: () => 0, send: () => false,
48
+ onInkChange: () => () => {}, onModeChange: () => () => {},
49
+ };
40
50
  // The STEP-on-Manifold import crossover's broken-state message (a second
41
51
  // needs-import-mesh after the mesh is already primed — see the "needs-import-mesh"
42
52
  // case below). One shared string so the status line, onBuild, and the ready
@@ -49,7 +59,7 @@ const IMPORT_MESH_BROKEN_MESSAGE = "STEP import tessellation failed to satisfy t
49
59
  // carries the worker's own error text. See the correlated "error" case below.
50
60
  const importTessellateFailedMessage = (workerMessage) => `STEP import tessellation failed — ${workerMessage}`;
51
61
 
52
- export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, setHostPane, animation, getView, setView, captureView, attachTooltips, measure }) {
62
+ export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, setHostPane, animation, getView, setView, captureView, attachTooltips, measure, annotate }) {
53
63
  return {
54
64
  ready, dispose, setParams,
55
65
  // Part-declared animation playback (spec 2026-08-02): animations are
@@ -91,6 +101,11 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
91
101
  // button. Dimensions render in the scene, so a dimensioned capture is just
92
102
  // captureCurrent() taken while the mode is on.
93
103
  measure: measure ?? NOOP_MEASURE,
104
+ // Annotation-mode API (spec 2026-08-18): { isEnabled, setEnabled, clear,
105
+ // strokeCount, send, onModeChange } — an embedder drives the mode without
106
+ // the built-in pencil button. send() delivers to onAnnotationSend and
107
+ // returns false when there is no ink or the capture failed.
108
+ annotate: annotate ?? NOOP_ANNOTATE,
94
109
  };
95
110
  }
96
111
 
@@ -182,6 +197,12 @@ function createCleanupStack() {
182
197
  // const off = runtime.onContextLost(() => …); // WebGL context loss, i.e. the GPU or the
183
198
  // // OS gave up — surface it rather than showing a dead
184
199
  // // canvas. Returns an unsubscribe.
200
+ // runtime.annotate: { isEnabled, setEnabled, undo, clear, strokeCount, send, onInkChange,
201
+ // onModeChange } — drive annotation mode without the built-in button;
202
+ // // no-op when onAnnotationSend was not supplied. Both
203
+ // // subscribes return an unsubscribe; onInkChange fires on
204
+ // // every stroke/undo/clear, which is what a host driving its
205
+ // // own Send button gates that button on (strokeCount() > 0).
185
206
  // runtime.dispose(); // full teardown
186
207
  // onBuild fires per completed build, so it does NOT fire for a pose-only edit —
187
208
  // those are repaired in the viewer and produce no build at all.
@@ -194,10 +215,27 @@ function createCleanupStack() {
194
215
  // // is a snapshot copy. Never fired by setParams or
195
216
  // // animation playback — hosts call setParams from their
196
217
  // // own undo/reset, and firing here would loop.
218
+ // onAnnotationSend(payload) // receive user annotations (freehand ink over the frozen
219
+ // // view). Supplying this reveals the #annotate viewbar
220
+ // // button; omitting it hides the button entirely.
221
+ // // payload.images carries two data URLs (the ink drawing
222
+ // // and the rendered model), each bounded to a 2048px long
223
+ // // edge — a stage bigger than that exports scaled down
224
+ // // rather than at its own hi-DPI size. Still hundreds of
225
+ // // KB of base64 apiece, so a host should not assume this
226
+ // // payload is small, only that it is bounded.
227
+ // annotateSend: "viewbar" | "host" // who owns the Send affordance. "viewbar" (default) puts
228
+ // // Send beside Undo/Clear in the annotate actions row.
229
+ // // "host" drops it and leaves Undo/Clear: the host draws
230
+ // // its own send control — e.g. a composer that pairs the
231
+ // // sketch with a typed message — and calls
232
+ // // runtime.annotate.send() itself. Ignored without
233
+ // // onAnnotationSend (there is no button to place).
197
234
  // Every `elements` entry defaults to the legacy global-ID lookup (below), resolved
198
235
  // exactly once here — submodules take element refs and never query the document.
199
236
  // `container`/`controls` remain as deprecated aliases for elements.viewer/.controls.
200
- export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDownload, onViewChange, onParamsCommit,
237
+ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDownload, onViewChange, onParamsCommit, onAnnotationSend,
238
+ annotateSend = "viewbar",
201
239
  container: legacyContainer, controls: legacyControls } = {}) {
202
240
  // --- element resolution (the only getElementById calls in the framework, save the ?pickserver client's optional #viewbar lookup) ----
203
241
  const byId = (id) => document.getElementById(id);
@@ -228,6 +266,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
228
266
  theme: elements.chrome?.theme ?? byId("theme"),
229
267
  cutaway: elements.chrome?.cutaway ?? byId("cutaway"),
230
268
  measure: elements.chrome?.measure ?? byId("measure"),
269
+ annotate: elements.chrome?.annotate ?? byId("annotate"),
231
270
  railToggle: elements.chrome?.railToggle ?? byId("rail-toggle"),
232
271
  },
233
272
  };
@@ -349,6 +388,30 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
349
388
  getParamsVersion: () => loop.version(),
350
389
  });
351
390
  cleanup.defer(() => measureMode.detach());
391
+ // Annotation mode (spec 2026-08-18): freehand ink over the frozen view,
392
+ // delivered to the host via onAnnotationSend. Wired only when the host
393
+ // passes the sink; the chrome hides the button otherwise (mode = null).
394
+ let annotateMode = null;
395
+ if (onAnnotationSend) {
396
+ annotateMode = createAnnotateMode(viewer, {
397
+ stage: els.viewer,
398
+ getContext: () => ({ view: view(), params }),
399
+ onSend: onAnnotationSend,
400
+ });
401
+ cleanup.defer(() => annotateMode.detach());
402
+ // Annotate and measure both claim canvas pointer input — mutually
403
+ // exclusive, whichever turns on turns the other off.
404
+ cleanup.defer(annotateMode.onModeChange(() => {
405
+ if (annotateMode.isEnabled()) measureMode.setEnabled(false);
406
+ }));
407
+ cleanup.defer(measureMode.onModeChange(() => {
408
+ if (measureMode.isEnabled()) annotateMode.setEnabled(false);
409
+ }));
410
+ }
411
+ const annotateChrome = attachAnnotateControls(viewer, annotateMode, {
412
+ annotate: els.chrome.annotate,
413
+ }, { tooltip, escapeScope: els.viewer, send: annotateSend });
414
+ cleanup.defer(() => annotateChrome.detach());
352
415
  // escapeScope: cutaway's Flip/Reset buttons are canvas SIBLINGS inside
353
416
  // #viewbar, not descendants of the canvas — attaching Escape to
354
417
  // viewer.domElement alone would leave a guarded Escape from those buttons
@@ -360,13 +423,15 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
360
423
  cleanup.defer(() => measureChrome.detach());
361
424
  const cutawayChrome = attachCutawayControls(viewer, {
362
425
  cutaway: els.chrome.cutaway,
363
- }, { tooltip, escapeGuard: () => measureMode.isEnabled() });
426
+ }, { tooltip, escapeGuard: () => measureMode.isEnabled() || (annotateMode?.isEnabled() ?? false) });
364
427
  cleanup.defer(() => cutawayChrome.detach());
365
- // Suppress the always-on hover tooltip while measure mode is active — its
366
- // own feature highlight + dims take over the pointer.
367
- const offMeasureHover = measureMode.onModeChange(() =>
368
- hover.setSuppressed(measureMode.isEnabled()));
369
- cleanup.defer(offMeasureHover);
428
+ // Suppress the always-on hover tooltip while measure OR annotate mode is
429
+ // active measure's highlight + dims take the pointer; annotate's overlay
430
+ // canvas takes it entirely.
431
+ const syncHoverSuppression = () =>
432
+ hover.setSuppressed(measureMode.isEnabled() || (annotateMode?.isEnabled() ?? false));
433
+ cleanup.defer(measureMode.onModeChange(syncHoverSuppression));
434
+ if (annotateMode) cleanup.defer(annotateMode.onModeChange(syncHoverSuppression));
370
435
 
371
436
  // Current selection context for the pickers: the active view + live params +
372
437
  // derived values. Shared by every pick mode below.
@@ -392,7 +457,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
392
457
  // resync on mode changes. The ?pick/?pickserver harnesses below are
393
458
  // deliberately not guarded — one is armed by an explicit dev toggle,
394
459
  // the other per agent request.
395
- suppressed: () => measureMode.isEnabled(),
460
+ suppressed: () => measureMode.isEnabled() || (annotateMode?.isEnabled() ?? false),
396
461
  onPick: (selection) => onPick({
397
462
  selection,
398
463
  label: selection.feature?.label ?? part.parts[selection.subPart]?.label ?? selection.subPart,
@@ -869,6 +934,16 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
869
934
  clearPins: measureMode.clearPins,
870
935
  pinCount: measureMode.pinCount,
871
936
  },
937
+ annotate: annotateMode ? {
938
+ isEnabled: annotateMode.isEnabled,
939
+ setEnabled: annotateMode.setEnabled,
940
+ undo: annotateMode.undo,
941
+ clear: annotateMode.clear,
942
+ strokeCount: annotateMode.strokeCount,
943
+ send: annotateMode.send,
944
+ onInkChange: annotateMode.onInkChange,
945
+ onModeChange: annotateMode.onModeChange,
946
+ } : null,
872
947
  });
873
948
  } catch (error) {
874
949
  try {