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.
- package/docs/AUTHORING-PARTS.md +19 -0
- package/docs/ERROR-PATTERNS.md +29 -13
- package/docs/KERNEL-CONTRACT.md +43 -13
- package/package.json +1 -1
- package/src/app-demo.js +4 -0
- package/src/framework/annotate/annotate-controls.js +134 -0
- package/src/framework/annotate/annotate-mode.js +165 -0
- package/src/framework/annotate/ink-canvas.js +129 -0
- package/src/framework/annotate/ink.js +124 -0
- package/src/framework/app.css +20 -2
- package/src/framework/chrome.css +18 -0
- package/src/framework/geometry/contour-offset.js +7 -5
- package/src/framework/geometry/contour-ops.js +115 -24
- package/src/framework/geometry/contour-winding.js +15 -1
- package/src/framework/geometry/kernel-front.js +4 -1
- package/src/framework/geometry/kernel.js +7 -0
- package/src/framework/geometry/manifold-backend.js +92 -6
- package/src/framework/geometry/occt-backend.js +15 -2
- package/src/framework/geometry/occt-repair.js +10 -6
- package/src/framework/geometry/occt-roundall.js +3 -3
- package/src/framework/geometry/op-options.js +6 -1
- package/src/framework/geometry/rim-bevel.js +17 -10
- package/src/framework/geometry/shape2d.js +7 -3
- package/src/framework/jobs.js +15 -2
- package/src/framework/mount.js +84 -9
- package/types/index.d.ts +76 -0
- package/types/kernel.d.ts +6 -0
|
@@ -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
|
+
}
|
package/src/framework/app.css
CHANGED
|
@@ -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 -----------------------------------------------------
|
package/src/framework/chrome.css
CHANGED
|
@@ -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
|
|
717
|
-
//
|
|
718
|
-
//
|
|
719
|
-
//
|
|
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.
|
|
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
|
-
|
|
416
|
+
let solved = solveFilletTangency(A, B, param);
|
|
417
417
|
if (!solved) {
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
438
|
+
let solved = solveChamferArcLength(fromA, segA, fromB, segB, param);
|
|
431
439
|
if (!solved) {
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
510
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
582
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
*/
|