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.
@@ -0,0 +1,124 @@
1
+ // Pure stroke model for annotation mode: normalized-coordinate polylines with
2
+ // point thinning while drawing, undo/clear, closed-stroke detection and anchor
3
+ // selection. No DOM, no three — unit-testable directly (the feature-dims.js
4
+ // stance). Points are [nx, ny] normalized 0..1 per viewport axis; distances
5
+ // are measured in viewport-DIAGONAL units so thresholds mean the same thing
6
+ // horizontally and vertically regardless of aspect.
7
+
8
+ // Stroke width as a fraction of the viewport's short edge (spec: payload
9
+ // carries this unit so any re-render can reproduce line weight).
10
+ export const DEFAULT_STROKE_WIDTH = 0.004;
11
+ // Spec: endpoints within 5% of the viewport diagonal = closed stroke.
12
+ const CLOSED_THRESHOLD = 0.05;
13
+ // pointermove fires per-pixel; keep only points this far (in diagonal units)
14
+ // from the previous kept point. ~2px at 1080p.
15
+ const MIN_POINT_DISTANCE = 0.0015;
16
+
17
+ export function diagDistance(a, b, aspect = 1) {
18
+ const dx = (a[0] - b[0]) * aspect;
19
+ const dy = a[1] - b[1];
20
+ return Math.hypot(dx, dy) / Math.hypot(aspect, 1);
21
+ }
22
+
23
+ export function createInkStore({ minDistance = MIN_POINT_DISTANCE } = {}) {
24
+ const strokes = [];
25
+ let active = null;
26
+ const listeners = new Set();
27
+ const notify = () => { for (const cb of [...listeners]) cb(); };
28
+ return {
29
+ begin(nx, ny, { width = DEFAULT_STROKE_WIDTH, aspect = 1 } = {}) {
30
+ active = { points: [[nx, ny]], width, aspect };
31
+ strokes.push(active);
32
+ notify();
33
+ },
34
+ extend(nx, ny) {
35
+ if (!active) return;
36
+ const last = active.points[active.points.length - 1];
37
+ if (diagDistance([nx, ny], last, active.aspect) < minDistance) return;
38
+ active.points.push([nx, ny]);
39
+ notify();
40
+ },
41
+ end() {
42
+ if (!active) return;
43
+ active = null; // one-point strokes stay: a click leaves a visible dot
44
+ notify();
45
+ },
46
+ strokes: () => strokes.map((s) => ({ points: s.points.map((p) => [...p]), width: s.width })),
47
+ isEmpty: () => strokes.length === 0,
48
+ strokeCount: () => strokes.length,
49
+ undo() {
50
+ if (!strokes.length) return;
51
+ strokes.pop();
52
+ active = null;
53
+ notify();
54
+ },
55
+ clear() {
56
+ if (!strokes.length && !active) return;
57
+ strokes.length = 0;
58
+ active = null;
59
+ notify();
60
+ },
61
+ onChange(cb) { listeners.add(cb); return () => listeners.delete(cb); },
62
+ };
63
+ }
64
+
65
+ export function pointAt(points, t, aspect = 1) {
66
+ if (points.length === 1) return [...points[0]];
67
+ const lengths = [0];
68
+ for (let i = 1; i < points.length; i++) {
69
+ lengths.push(lengths[i - 1] + diagDistance(points[i], points[i - 1], aspect));
70
+ }
71
+ const total = lengths[lengths.length - 1];
72
+ if (total === 0) return [...points[0]];
73
+ const target = t * total;
74
+ let i = 1;
75
+ while (i < lengths.length - 1 && lengths[i] < target) i++;
76
+ const span = lengths[i] - lengths[i - 1];
77
+ const f = span === 0 ? 0 : (target - lengths[i - 1]) / span;
78
+ const [ax, ay] = points[i - 1];
79
+ const [bx, by] = points[i];
80
+ return [ax + (bx - ax) * f, ay + (by - ay) * f];
81
+ }
82
+
83
+ export function isClosedStroke(points, aspect = 1) {
84
+ if (points.length < 3) return false;
85
+ return diagDistance(points[0], points[points.length - 1], aspect) <= CLOSED_THRESHOLD;
86
+ }
87
+
88
+ // Area-weighted polygon centroid (shoelace); for a degenerate (near-zero-area)
89
+ // point set, fall back to the plain point average.
90
+ export function strokeCentroid(points) {
91
+ let area2 = 0, cx = 0, cy = 0;
92
+ for (let i = 0; i < points.length; i++) {
93
+ const [x0, y0] = points[i];
94
+ const [x1, y1] = points[(i + 1) % points.length];
95
+ const cross = x0 * y1 - x1 * y0;
96
+ area2 += cross;
97
+ cx += (x0 + x1) * cross;
98
+ cy += (y0 + y1) * cross;
99
+ }
100
+ if (Math.abs(area2) < 1e-9) {
101
+ let sx = 0, sy = 0;
102
+ for (const [x, y] of points) { sx += x; sy += y; }
103
+ return [sx / points.length, sy / points.length];
104
+ }
105
+ return [cx / (3 * area2), cy / (3 * area2)];
106
+ }
107
+
108
+ // Anchor sample points for one stroke: start / arc-length-midpoint / end, plus
109
+ // the enclosed-region centroid when the stroke closes on itself ("what did
110
+ // they circle"). A one-point dot gets a single anchor. The orchestrator turns
111
+ // each spec's normalized `screen` point into a raycast.
112
+ export function anchorSpecs(points, aspect = 1) {
113
+ if (points.length === 0) return [];
114
+ if (points.length === 1) return [{ t: 0, screen: [...points[0]] }];
115
+ const specs = [
116
+ { t: 0, screen: [...points[0]] },
117
+ { t: 0.5, screen: pointAt(points, 0.5, aspect) },
118
+ { t: 1, screen: [...points[points.length - 1]] },
119
+ ];
120
+ if (isClosedStroke(points, aspect)) {
121
+ specs.push({ kind: "centroid", screen: strokeCentroid(points) });
122
+ }
123
+ return specs;
124
+ }
@@ -323,6 +323,20 @@ button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible
323
323
  #viewbar .pf-measure-actions { display: flex; gap: 4px; }
324
324
  #viewbar .pf-measure-actions[hidden] { display: none; }
325
325
  #viewbar .pf-measure-actions button { width: auto; min-width: 56px; padding: 0 8px; }
326
+ #viewbar .pf-annotate-actions { display: flex; gap: 4px; }
327
+ #viewbar .pf-annotate-actions[hidden] { display: none; }
328
+ #viewbar .pf-annotate-actions button { width: auto; min-width: 56px; padding: 0 8px; }
329
+ /* Annotate's actions row has three buttons (Undo/Clear/Send) against
330
+ cutaway's/measure's two, so it is the first to overflow the stage's left
331
+ edge as the viewport narrows — the full-size pill (5 icon buttons + this
332
+ row, ~374px) already exceeds a 375-390px phone's usable width (viewport
333
+ minus the stage's 12px margins on both sides) before the shared 360px
334
+ rule below ever engages. Shrink only this row here; the icon buttons and
335
+ the other two action rows still have room down to 360px. */
336
+ @media (max-width: 430px) {
337
+ #viewbar .pf-annotate-actions { gap: 3px; }
338
+ #viewbar .pf-annotate-actions button { min-width: 40px; padding: 0 5px; font-size: 11px; }
339
+ }
326
340
  #viewbar button:disabled { opacity: .38; cursor: not-allowed; }
327
341
  #viewbar button:disabled:hover { color: var(--pf-muted-2); background: transparent; }
328
342
  #viewbar button:hover { color: var(--pf-text); background: var(--pf-surface-2); }
@@ -337,8 +351,12 @@ button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible
337
351
  @media (max-width: 360px) {
338
352
  #viewbar { gap: 3px; }
339
353
  #viewbar button { width: 30px; height: 30px; font-size: 13px; }
340
- #viewbar .pf-cutaway-actions, #viewbar .pf-measure-actions { gap: 3px; }
341
- #viewbar .pf-cutaway-actions button, #viewbar .pf-measure-actions button { min-width: 44px; padding: 0 6px; }
354
+ #viewbar .pf-cutaway-actions, #viewbar .pf-measure-actions, #viewbar .pf-annotate-actions { gap: 3px; }
355
+ #viewbar .pf-cutaway-actions button, #viewbar .pf-measure-actions button, #viewbar .pf-annotate-actions button { min-width: 44px; padding: 0 6px; }
356
+ /* Annotate's three-button row (Undo/Clear/Send) is wider than cutaway's or
357
+ measure's two-button rows at the shared size above, so it still clips the
358
+ pill's left edge at 320px — shrink it further than the shared rule. */
359
+ #viewbar .pf-annotate-actions button { min-width: 38px; padding: 0 4px; font-size: 10px; }
342
360
  }
343
361
 
344
362
  /* ---- measurement mode -----------------------------------------------------
@@ -356,3 +356,21 @@
356
356
  @media (prefers-reduced-motion: reduce) {
357
357
  .pf-rail, .pf-rail-seam > span { transition: none; }
358
358
  }
359
+
360
+ /* ---- annotation ink layer: a transparent 2D canvas over the viewer --------
361
+ Shown only while annotation mode is on. It deliberately owns pointer events
362
+ while visible — that is what freezes orbit/pan/zoom during drawing. Below
363
+ the viewbar (z 15) so Undo/Clear/Send stay clickable. */
364
+ .pf-ink-canvas {
365
+ position: absolute;
366
+ inset: 0;
367
+ width: 100%;
368
+ height: 100%;
369
+ z-index: 10;
370
+ /* Pencil cursor (the viewbar toggle's lucide pencil), hotspot at the tip.
371
+ White halo under a dark stroke keeps it readable over both the model and
372
+ the backdrop; crosshair is the no-custom-cursor fallback. */
373
+ cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cg stroke='white' stroke-width='4.5'%3E%3Cpath d='M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z'/%3E%3Cpath d='m15 5 4 4'/%3E%3C/g%3E%3Cg stroke='%231d232b' stroke-width='2'%3E%3Cpath d='M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z'/%3E%3Cpath d='m15 5 4 4'/%3E%3C/g%3E%3C/svg%3E") 2 22, crosshair;
374
+ touch-action: none;
375
+ }
376
+ .pf-ink-canvas[hidden] { display: none; }
@@ -713,12 +713,14 @@ const flattenRing = (contour, segs) => {
713
713
  //
714
714
  // RATES, and where they come from. `node scripts/offset-rates.mjs` sweeps the committed
715
715
  // corpus (600 seeded shapes + 6 glyphs, 20 deltas, 3 styles = 36 090 offsets). After the
716
- // adaptive pinch classifier, failures before the ladder / after it are:
717
- // round 1 -> 0 chamfer 2 -> 0 sharp 4 -> 0
718
- // All seven rescues are oracle-checked: median area error 0.0972 %, worst 1.663 %, with zero
719
- // region-count losses and zero complete arc losses. The ladder stays because those seven raw
716
+ // adaptive pinch classifier and the fold-aware clearance fix in contour-winding's
717
+ // scanArrangement (which also resolved five of the seven former pre-ladder failures),
718
+ // failures before the ladder / after it are:
719
+ // round 0 -> 0 chamfer 1 -> 0 sharp 1 -> 0
720
+ // Both rescues are oracle-checked: median area error 0.0727 %, worst 0.073 %, with zero
721
+ // region-count losses and zero complete arc losses. The ladder stays because those two raw
720
722
  // arrangements remain numerically unclosable, not because the formerly parked comb/text
721
- // failures still exist. Those seven are all erosion (negative delta) or single-region cases;
723
+ // failures still exist. Both are erosion (negative delta) or single-region cases;
722
724
  // the per-region rung below is positive-delta-and-multi-region only, so it wins none of them
723
725
  // and the rates above are unchanged by its addition — its own coverage class (whole-word text
724
726
  // dilation, feedback 86970b00) sits outside this corpus, whose glyphs are single characters
@@ -407,16 +407,24 @@ function bisectMaxDistChamfer(fromA, segA, fromB, segB, dist) {
407
407
  // cubic or arc) and return {tA, tB, TA, TB, connector} — tA/tB in the
408
408
  // neighbors' own parameterizations, ready for trimSegment(); connector is
409
409
  // the {to,via?} spliced between the trimmed neighbors.
410
- function solveCurveCorner(pts, contour, n, i, param, isFillet, label) {
410
+ function solveCurveCorner(pts, contour, n, i, param, isFillet, label, record) {
411
411
  const inIdx = (i - 1 + n) % n, fromA = pts[inIdx], segA = contour.segments[inIdx];
412
412
  const fromB = pts[i], segB = contour.segments[i];
413
413
  const A = curveEvaluator(fromA, segA), B = curveEvaluator(fromB, segB);
414
414
  const p1 = pts[i];
415
415
  if (isFillet) {
416
- const solved = solveFilletTangency(A, B, param);
416
+ let solved = solveFilletTangency(A, B, param);
417
417
  if (!solved) {
418
- const maxR = roundNice(bisectMaxRFillet(A, B, param));
419
- throw new Error(`${label}: corner ${i} at (${p1[0]}, ${p1[1]}): could not fit r=${param} against the curved segment; max ≈ ${maxR}`);
418
+ // Clamp rather than refuse. bisectMaxRFillet returns a radius the solver
419
+ // ACCEPTED (lo only ever moves to a solved midpoint), so re-solving at it
420
+ // succeeds — except when it never found one at all (lo stays 0), which is
421
+ // a corner with no valid fillet at any radius and stays an error.
422
+ const maxR = bisectMaxRFillet(A, B, param);
423
+ solved = maxR > 0 ? solveFilletTangency(A, B, maxR) : null;
424
+ if (!solved)
425
+ throw new Error(`${label}: corner ${i} at (${p1[0]}, ${p1[1]}): could not fit r=${param} against the curved segment; max ≈ ${roundNice(maxR)}`);
426
+ record?.(`${label}: corner ${i} at (${p1[0]}, ${p1[1]}): r=${param} does not fit against the curved segment — clamped to ${roundNice(maxR)}`);
427
+ param = maxR;
420
428
  }
421
429
  const { tA, tB, TA, TB, C } = solved;
422
430
  const a0 = Math.atan2(TA[1] - C[1], TA[0] - C[0]);
@@ -427,10 +435,15 @@ function solveCurveCorner(pts, contour, n, i, param, isFillet, label) {
427
435
  const M = [C[0] + param * Math.cos(mid), C[1] + param * Math.sin(mid)];
428
436
  return { tA, tB, TA, connector: { to: TB, via: M } };
429
437
  }
430
- const solved = solveChamferArcLength(fromA, segA, fromB, segB, param);
438
+ let solved = solveChamferArcLength(fromA, segA, fromB, segB, param);
431
439
  if (!solved) {
432
- const maxDist = roundNice(bisectMaxDistChamfer(fromA, segA, fromB, segB, param));
433
- throw new Error(`${label}: corner ${i} at (${p1[0]}, ${p1[1]}): could not fit dist=${param} against the curved segment; max ≈ ${maxDist}`);
440
+ // Same clamp-don't-refuse rule as the fillet branch above.
441
+ const maxDist = bisectMaxDistChamfer(fromA, segA, fromB, segB, param);
442
+ solved = maxDist > 0 ? solveChamferArcLength(fromA, segA, fromB, segB, maxDist) : null;
443
+ if (!solved)
444
+ throw new Error(`${label}: corner ${i} at (${p1[0]}, ${p1[1]}): could not fit dist=${param} against the curved segment; max ≈ ${roundNice(maxDist)}`);
445
+ record?.(`${label}: corner ${i} at (${p1[0]}, ${p1[1]}): dist=${param} does not fit against the curved segment — clamped to ${roundNice(maxDist)}`);
446
+ param = maxDist;
434
447
  }
435
448
  const { tA, tB, TA, TB } = solved;
436
449
  return { tA, tB, TA, connector: { to: TB } };
@@ -440,19 +453,26 @@ function solveCurveCorner(pts, contour, n, i, param, isFillet, label) {
440
453
  // Mirrors cornerArc's tangent/center math (polygon.js:107) but WITHOUT its silent
441
454
  // per-corner clamp — filletProfile/chamferProfile throw instead of clamping, so the
442
455
  // clamp math is reproduced here unclamped, gated by our own explicit fit checks.
443
- function buildCornerOpRing(contour, picks, isFillet, label) {
456
+ // ONE attempt at a ring, with `paramAt` supplying each selected corner's current
457
+ // magnitude. Per-corner over-runs are clamped in place here (each has its own
458
+ // computable ceiling); a SHARED-EDGE overlap cannot be, because shrinking one
459
+ // corner changes what its neighbour may claim — so those are reported back as
460
+ // `overlaps` for buildCornerOpRing's loop to resolve and retry.
461
+ function attemptCornerOpRing(contour, picks, isFillet, label, paramAt, clamp, record) {
444
462
  const n = contour.segments.length;
445
463
  const pts = [contour.start, ...contour.segments.map((s) => s.to)].slice(0, n);
446
464
  const plans = new Map(); // vertex index -> {A, B, M, setback} (line-line corners only)
447
465
  const curvePlans = new Map(); // vertex index -> {tA, tB, connector} (curve-adjacent corners)
448
466
  const selected = new Set(picks.map((p) => p.corner.index)); // this ring's selected vertex indices
467
+ const overlaps = [];
449
468
 
450
- for (const { corner, param } of picks) {
469
+ for (const { corner } of picks) {
451
470
  const i = corner.index;
471
+ let param = paramAt(i);
452
472
  if (corner.segTypes[0] !== "line" || corner.segTypes[1] !== "line") {
453
473
  // Curve-adjacent corner: routed through the numeric tangency solver, never
454
474
  // through the line-line closed-form math below (exactness/speed for lines).
455
- curvePlans.set(i, solveCurveCorner(pts, contour, n, i, param, isFillet, label));
475
+ curvePlans.set(i, solveCurveCorner(pts, contour, n, i, param, isFillet, label, record));
456
476
  continue;
457
477
  }
458
478
  const p0 = pts[(i - 1 + n) % n], p1 = pts[i], p2 = pts[(i + 1) % n];
@@ -461,7 +481,7 @@ function buildCornerOpRing(contour, picks, isFillet, label) {
461
481
  const v0 = [v0x / l0, v0y / l0], v2 = [v2x / l2, v2y / l2];
462
482
  const cosA = Math.max(-1, Math.min(1, v0[0] * v2[0] + v0[1] * v2[1]));
463
483
  const half = Math.acos(cosA) / 2; // angle between the two edges, halved
464
- const setback = isFillet ? param / Math.tan(half) : param;
484
+ let setback = isFillet ? param / Math.tan(half) : param;
465
485
  // Per-corner ceiling: never past either edge's own end (hard cap, always full — a
466
486
  // tangent point can never pass an edge's own extent regardless of who else is
467
487
  // selected), and never past half the LONGER edge's "fair share" (soft cap). The soft
@@ -474,9 +494,15 @@ function buildCornerOpRing(contour, picks, isFillet, label) {
474
494
  const softL0 = prevShared ? l0 / 2 : l0, softL2 = nextShared ? l2 / 2 : l2;
475
495
  const maxSetback = Math.min(l0, l2, Math.max(softL0, softL2));
476
496
  if (setback > maxSetback + 1e-9) {
477
- const maxParam = roundNice(isFillet ? maxSetback * Math.tan(half) : maxSetback);
497
+ // Clamp to the ceiling this corner's own edges allow, and go on. The old
498
+ // throw named the very number used here, so nothing is being guessed —
499
+ // the caller is simply spared having to read an error and retry by hand.
500
+ const maxParam = isFillet ? maxSetback * Math.tan(half) : maxSetback;
478
501
  const paramTxt = isFillet ? `r=${param}` : `dist=${param}`;
479
- throw new Error(`${label}: corner ${i} at (${p1[0]}, ${p1[1]}): ${paramTxt} does not fit; max ${maxParam}`);
502
+ record?.(`${label}: corner ${i} at (${p1[0]}, ${p1[1]}): ${paramTxt} does not fit clamped to ${roundNice(maxParam)}`);
503
+ clamp(i, maxParam);
504
+ param = maxParam;
505
+ setback = maxSetback;
480
506
  }
481
507
  const A = [p1[0] + v0[0] * setback, p1[1] + v0[1] * setback];
482
508
  const B = [p1[0] + v2[0] * setback, p1[1] + v2[1] * setback];
@@ -506,8 +532,10 @@ function buildCornerOpRing(contour, picks, isFillet, label) {
506
532
  // Curved segment: only curve corners can claim it (line-line requires both
507
533
  // neighbors to be "line", so a curved seg is never in `plans`). Overlap ⇔
508
534
  // the kept t-span [startCurve.tB, endCurve.tA] collapses or reverses.
509
- if (endCurve.tA - startCurve.tB <= 1e-9)
510
- throw new Error(`${label}: corners ${k} and ${kNext} overlap on segment ${k} (reduce r)`);
535
+ // A curve segment's claims are t-parameters, which are not linear in the
536
+ // magnitude, so there is no exact scale factor to solve for — report the
537
+ // pair and let the loop back both off geometrically until they fit.
538
+ if (endCurve.tA - startCurve.tB <= 1e-9) overlaps.push({ k, kNext, factor: null });
511
539
  } else {
512
540
  // Line segment: a curve-corner claim on it is a t-parameter (curvePlans.tB
513
541
  // measures forward from this segment's start; curvePlans.tA forward from its
@@ -516,8 +544,10 @@ function buildCornerOpRing(contour, picks, isFillet, label) {
516
544
  const segLen = Math.hypot(pts[kNext][0] - pts[k][0], pts[kNext][1] - pts[k][1]);
517
545
  const startClaim = startPlan ? startPlan.setback : startCurve.tB * segLen;
518
546
  const endClaim = endPlan ? endPlan.setback : (1 - endCurve.tA) * segLen;
547
+ // On a straight segment the claim IS the setback, linear in the magnitude,
548
+ // so the exact scale that makes the pair fit is solvable in one step.
519
549
  if (startClaim + endClaim > segLen + 1e-9)
520
- throw new Error(`${label}: corners ${k} and ${kNext} overlap on segment ${k} (reduce r)`);
550
+ overlaps.push({ k, kNext, factor: segLen / (startClaim + endClaim) });
521
551
  }
522
552
  }
523
553
 
@@ -540,14 +570,69 @@ function buildCornerOpRing(contour, picks, isFillet, label) {
540
570
  if (endPlan) segments.push(isFillet ? { to: endPlan.B, via: endPlan.M } : { to: endPlan.B });
541
571
  if (endCurve) segments.push(endCurve.connector);
542
572
  }
543
- return { start, segments };
573
+ return { ring: { start, segments }, overlaps };
544
574
  }
545
575
 
546
- function applyCornerOp(input, param, opts, label, isFillet) {
576
+ // How many times the loop below may back off overlapping corner pairs. Each pass
577
+ // only ever REDUCES magnitudes and a straight-segment pair is solved exactly in
578
+ // one step, so real inputs settle in one or two; the bound exists so a
579
+ // pathological ring cannot spin, and reaching it is a genuine failure that
580
+ // throws rather than emitting a ring built from magnitudes still known to
581
+ // overlap.
582
+ const MAX_OVERLAP_PASSES = 8;
583
+
584
+ // Fillet/chamfer one ring, CLAMPING every magnitude that does not fit rather
585
+ // than refusing the whole profile. Two ceilings apply: a per-corner one, applied
586
+ // in place by the attempt above, and a shared-edge one between two corners
587
+ // claiming the same segment, resolved here because backing one corner off
588
+ // changes what its neighbour may take.
589
+ function buildCornerOpRing(contour, picks, isFillet, label, record) {
590
+ const params = new Map(picks.map((p) => [p.corner.index, p.param]));
591
+ const requested = new Map(params);
592
+ for (let pass = 0; ; pass++) {
593
+ const last = pass === MAX_OVERLAP_PASSES;
594
+ const passClamps = new Map(); // corner -> per-corner ceiling this pass applied
595
+ const messages = [];
596
+ const { ring, overlaps } = attemptCornerOpRing(
597
+ contour, picks, isFillet, label,
598
+ (i) => params.get(i),
599
+ (i, v) => passClamps.set(i, v),
600
+ (msg) => messages.push(msg),
601
+ );
602
+ if (overlaps.length === 0) {
603
+ // Report only now, from the pass that actually produced the ring: an
604
+ // earlier pass's clamp is routinely superseded by a later, smaller one,
605
+ // and emitting both would describe magnitudes the result never used.
606
+ for (const msg of messages) record?.(msg);
607
+ // A magnitude reduced by the overlap loop rather than by a per-corner
608
+ // ceiling has no message of its own — the attempt never saw it as a
609
+ // clamp, it was simply handed a smaller number. Report those here, so a
610
+ // shared-edge shrink is as visible as a per-corner one.
611
+ for (const { corner } of picks) {
612
+ const i = corner.index, was = requested.get(i), now = params.get(i);
613
+ if (now < was - 1e-9 && !passClamps.has(i))
614
+ record?.(`${label}: corner ${i}: ${isFillet ? "r" : "dist"}=${was} overruns the edge it shares with a neighbouring corner — clamped to ${roundNice(now)}`);
615
+ }
616
+ return ring;
617
+ }
618
+ if (last)
619
+ throw new Error(`${label}: corners ${overlaps[0].k} and ${overlaps[0].kNext} overlap on segment ${overlaps[0].k} (reduce r)`);
620
+ for (const { k, kNext, factor } of overlaps) {
621
+ // A hair under the exact fit so the next pass's `> segLen + 1e-9` test
622
+ // clears rather than landing back on the boundary; a null factor (curved
623
+ // segment, no closed-form scale) backs off geometrically instead.
624
+ const f = factor === null ? 0.8 : factor * 0.999;
625
+ for (const i of [k, kNext]) if (params.has(i)) params.set(i, params.get(i) * f);
626
+ }
627
+ }
628
+ }
629
+
630
+
631
+ function applyCornerOp(input, param, opts, label, isFillet, record) {
547
632
  const { kind, regions } = liftProfile(input);
548
633
  if (kind === "points" || kind === "contour") {
549
634
  const picks = resolveCornerSelector(contourCorners(regions[0].outer), param, opts, label);
550
- const outer = buildCornerOpRing(regions[0].outer, picks, isFillet, label);
635
+ const outer = buildCornerOpRing(regions[0].outer, picks, isFillet, label, record);
551
636
  // Always surface a {start,segments} contour, even for a "points" input and an
552
637
  // all-line chamfer result: restoreProfile's points-downgrade is for shape-preserving
553
638
  // transforms, but a corner op changes the vertex count — it must not collapse back.
@@ -572,20 +657,26 @@ function applyCornerOp(input, param, opts, label, isFillet) {
572
657
  for (const { ringRef, picks: ringPicks } of byRing.values()) {
573
658
  const rg = newRegions[ringRef.ri];
574
659
  const contour = ringRef.key === "outer" ? rg.outer : rg.holes[ringRef.hi];
575
- const rebuilt = buildCornerOpRing(contour, ringPicks, isFillet, label);
660
+ const rebuilt = buildCornerOpRing(contour, ringPicks, isFillet, label, record);
576
661
  if (ringRef.key === "outer") rg.outer = rebuilt; else rg.holes[ringRef.hi] = rebuilt;
577
662
  }
578
663
  return restoreProfile(kind, newRegions);
579
664
  }
580
665
 
581
- export function filletProfile(input, r, opts) {
582
- return applyCornerOp(input, r, opts, "filletProfile", true);
666
+ // `record` receives one message per magnitude CLAMPED to what the geometry can
667
+ // take (see buildCornerOpRing). Defaulted to console.warn so a direct call still
668
+ // says something; Shape2D threads its kernel's recorder in, which is what puts a
669
+ // clamp on the build result where a caller — or the cloud agent — can act on it.
670
+ export function filletProfile(input, r, opts, record = defaultRecord) {
671
+ return applyCornerOp(input, r, opts, "filletProfile", true, record);
583
672
  }
584
673
 
585
- export function chamferProfile(input, dist, opts) {
586
- return applyCornerOp(input, dist, opts, "chamferProfile", false);
674
+ export function chamferProfile(input, dist, opts, record = defaultRecord) {
675
+ return applyCornerOp(input, dist, opts, "chamferProfile", false, record);
587
676
  }
588
677
 
678
+ const defaultRecord = (msg) => console.warn(`partforge: ${msg}`);
679
+
589
680
  // ── simplifyProfile (Task 9) ─────────────────────────────────────────────────
590
681
  // Corner-preserving decimation/refit: split each contour at its corners (contourCorners,
591
682
  // SMOOTH_JOINT_DEG), then reduce each run independently, and reassemble. Corner points are
@@ -309,7 +309,21 @@ function scanArrangement(p, tessRings, near = null) {
309
309
  if (near) {
310
310
  const n = ring.length;
311
311
  const delta = r === near.ring ? (i - near.edge + n) % n : -1;
312
- const incident = r === near.ring && (delta === 0 || delta === 1 || delta === n - 1);
312
+ // The projected edge and its immediate neighbours are incident geometry, not an
313
+ // obstruction — but ONLY while the neighbour actually continues the run. At a fold
314
+ // apex (a hairpin doubling back on itself within a couple of tessellation edges),
315
+ // the antiparallel return branch IS edge±1, and blanket-excluding it made clearance
316
+ // overestimate the safe probe radius by an order of magnitude: the probe stepped
317
+ // across the fold into a face not adjacent to the piece at all, and _classify kept
318
+ // an interior piece on the fabricated wRight (the Scott-label italic offset,
319
+ // feedback 746c4ac2). A neighbour that turns back against the projected edge
320
+ // (direction dot < 0) is a wall the probe can hit, so it participates in clearance.
321
+ let incident = r === near.ring && (delta === 0 || delta === 1 || delta === n - 1);
322
+ if (incident && delta !== 0) {
323
+ const e = ring[(near.edge + 1) % n], s = ring[near.edge];
324
+ const dot = (b[0] - a[0]) * (e[0] - s[0]) + (b[1] - a[1]) * (e[1] - s[1]);
325
+ if (dot < 0) incident = false;
326
+ }
313
327
  if (!incident) clearance = Math.min(clearance, pointEdgeDistance(near.point, a, b));
314
328
  }
315
329
  }
@@ -59,7 +59,10 @@ export function finishKernel(k) {
59
59
  const raw = k[op];
60
60
  if (!raw) continue;
61
61
  k[op] = (...a) => {
62
- const pos = a.length === 1 && isPlainOptions(a[0]) ? toArgs(a[0]) : a;
62
+ // toArgs gets the kernel's warning recorder: a couple of specs (roundedBox's
63
+ // rim clamp) DEGRADE during normalization rather than throwing, and that
64
+ // degrade has to reach the build's warning list, not just the console.
65
+ const pos = a.length === 1 && isPlainOptions(a[0]) ? toArgs(a[0], k._recordWarning) : a;
63
66
  check?.(...pos);
64
67
  return raw(...pos);
65
68
  };
@@ -31,6 +31,12 @@ export const KERNEL_OPS = [
31
31
  // `?.`, so a third-party backend may simply omit them.
32
32
  export const KERNEL_OPTIONAL_OPS = [
33
33
  "beginSubPart", "endSubPart", "sweepCache", "cacheStats", "resetCacheStats", "cleanup",
34
+ // Drains the feature-skip warnings recorded since the last drain — a fillet or
35
+ // chamfer the geometry defeated and the backend skipped rather than failed the
36
+ // build over. Both backends implement it; a host that never calls it sees the
37
+ // pre-0.69 behavior (console.warn only). See KERNEL-CONTRACT.md § "Feature-skip
38
+ // warnings channel".
39
+ "takeBuildWarnings",
34
40
  ];
35
41
 
36
42
  // Ops every Solid must implement (including the sugar addSugar() attaches).
@@ -153,4 +159,5 @@ export const ROUTED_CAD_OPS = ["shell"];
153
159
  * @property {() => {hits:number,misses:number}} [cacheStats]
154
160
  * @property {() => void} [resetCacheStats]
155
161
  * @property {() => void} [cleanup] free per-job WASM objects (Manifold backend); call after each job
162
+ * @property {() => string[]} [takeBuildWarnings] drain feature-skip warnings (a fillet/chamfer/roundAll the backend skipped rather than failing the build over); drain per sub-part to attribute each message
156
163
  */