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.
@@ -1209,6 +1209,14 @@ Three rules worth internalizing before reaching for any of this:
1209
1209
  paper.js, which is cubic-only — an arc entering a boolean returns as a cubic
1210
1210
  approximation (relative error ~1e-6). `union`/`cut` first, `fillet` last keeps the
1211
1211
  rounded corners true circular arcs all the way to STEP export.
1212
+ - **A radius that doesn't fit is CLAMPED, not refused.** `fillet`/`chamfer` reduce any
1213
+ corner whose magnitude its edges cannot hold down to the largest that they can, and
1214
+ report each clamp on the build's warnings — so a slider that used to kill the part at
1215
+ r=3.1 now rounds at whatever fits. Two ceilings apply: the corner's own edges, and
1216
+ the edge it shares with a neighbouring selected corner (both back off together there).
1217
+ It still throws when there is no feasible magnitude at all. **If an exact radius is
1218
+ functionally required** — a bearing seat, a mating fit — do not trust the request:
1219
+ clamp it yourself from the geometry that limits it, or assert it in `verify`.
1212
1220
  - **Run `validateProfile` after mutations.** `fillet`/`chamfer` check only their own
1213
1221
  corner's local fit — not whether the result self-intersects globally (a large radius
1214
1222
  on a narrow profile can produce arcs that cross the far side). `validateProfile`
@@ -2213,6 +2221,17 @@ radius self-intersects its cutters and yields a wrong shape rather than a skippe
2213
2221
  feature (OCCT skips instead). Clamp magnitudes against local geometry the way
2214
2222
  `filleted-box.js` does: `Math.min(p.fillet, halfWidth - 0.5, p.h - 0.5)`.
2215
2223
 
2224
+ **A defeated fillet/chamfer skips, and the build reports it.** On both backends a
2225
+ fillet or chamfer the geometry defeats does **not** fail the build: the op returns its
2226
+ input solid unchanged (edges left sharp) and the build result carries a feature-skip
2227
+ warning naming the op, its magnitude, and the reason. The same channel carries every
2228
+ other degrade — an `extrude` rim bevel reduced or left square, a `roundedBox` rim
2229
+ clamped to `round.side`, a `Shape2D` corner rounded smaller than asked — so the part on screen is real,
2230
+ minus that one feature, with everything downstream of it still applied. When a build
2231
+ answer includes such a warning, treat it as a failed feature, not a success: say so,
2232
+ and either adjust the geometry/radius and retry or leave the feature off deliberately.
2233
+ Do not conclude a fillet landed just because the build succeeded.
2234
+
2216
2235
  **Preview routing is per sub-part.** Each sub-part is probed and routed independently, and
2217
2236
  a mixed part's regen fans out to both workers in parallel — a shelled body pays for OCCT
2218
2237
  while a plain lid rebuilds at Manifold speed beside it. Two scopes
@@ -81,11 +81,11 @@ Variant literals under this entry: `extrude: unknown bevel option`, `extrude: be
81
81
 
82
82
  - **Symptom:** `partforge: extrude bevel` warning saying the requested distance `exceeds what the profile can take — reduced to` a smaller one (or `has no valid offset for this profile — rim left square`; `hole` in place of `profile` when a hole's flare is the limit).
83
83
  - **Cause:** Offsetting the rim by the bevel distance would pinch a narrow feature (a tooth land, a thin bar, a thin web beside a hole) shut, so the bevel deterministically backs off to the largest offset the outline can take — the same geometric limit OCCT's chamfer hits, resolved in pure JS instead of kernel re-runs.
84
- - **Fix:** Usually nothing — the reduced bevel is the correct maximum for the geometry. To silence it, clamp the bevel parameter below the printed value or widen the narrow feature.
84
+ - **Fix:** Usually nothing — the reduced bevel is the correct maximum for the geometry. To silence it, clamp the bevel parameter below the printed value or widen the narrow feature. Since partforge 0.69 this warning also rides the build result's `warnings` (see [feature-skipped-warning](#feature-skipped-warning)), so a host or agent is told the rim was left square rather than having to read the console.
85
85
 
86
86
  ## roundedbox-rim-clamped
87
87
 
88
- - **Symptom:** `roundedBox: round.top <n> clamped to round.side <m> (side must be 0 or ≥ rim radii; use side: 0 for a rim-only round-over)` in the console, and the built rim round-over is smaller than the `round.top`/`round.bottom` you passed.
88
+ - **Symptom:** `roundedBox: round.top <n> clamped to round.side <m> (side must be 0 or ≥ rim radii; use side: 0 for a rim-only round-over)` in the console and, since partforge 0.69, on the build result's `warnings` (see [feature-skipped-warning](#feature-skipped-warning)) — with the built rim round-over smaller than the `round.top`/`round.bottom` you passed.
89
89
  - **Cause:** the middle regime `0 < side < rim` has no closed-form corner shared by both backends, so the rim radii clamp down to `side` (the footprint-defining radius never grows silently).
90
90
  - **Fix:** either raise `round.side` to ≥ the rim radii (torus/sphere corners), or set `side: 0` exactly for a full-size rim-only round-over on sharp vertical edges.
91
91
 
@@ -394,37 +394,44 @@ growing a shape past the hole's own width.
394
394
  - **Symptom:** `contour-winding: could not chain offset boundary (incomplete intersection
395
395
  set)` thrown from `Shape2D.offset` (or `offsetPolygon`) after a raw offset self-overlaps
396
396
  at a narrow pinch.
397
- - **Cause:** *(Known corpus fixed in partforge 0.60; ID retained permanently.)* The resolver
397
+ - **Cause:** *(Known corpus fixed in partforge 0.60, fold-apex case in 0.68.1; ID retained
398
+ permanently.)* The resolver
398
399
  used to classify every boundary piece from one fixed midpoint probe. At a narrow cell that
399
400
  probe could cross a nearby non-incident edge, read the wrong winding on both sides, and
400
401
  drop a real continuation. Fully eroded round text counters were a separate upstream cause:
401
- their raw offset could retain a negative pocket even under a correct Positive fill.
402
- - **Fix:** Upgrade to partforge ≥ 0.60. The classifier now chooses among deterministic
403
- interior samples by local boundary clearance and caps its probe distance accordingly.
402
+ their raw offset could retain a negative pocket even under a correct Positive fill. A third
403
+ cause survived to 0.68.1: at a hairpin fold the antiparallel return branch is the probe
404
+ anchor's immediate ring neighbour, which the clearance measurement blanket-excluded as
405
+ incident geometry — the probe stepped across the fold and kept an interior piece
406
+ (italic-sheared whole-word text was the reproduction).
407
+ - **Fix:** Upgrade to partforge ≥ 0.68.1. The classifier chooses among deterministic
408
+ interior samples by local boundary clearance, caps its probe distance accordingly, and
409
+ counts a fold-back neighbour edge (direction reversed against the anchor's) as an
410
+ obstruction rather than incident geometry.
404
411
  Positive round offsets also decide counter collapse from the source hole's inradius before
405
412
  generating a raw outline. On the committed 36,090-offset corpus
406
413
  (`node scripts/offset-rates.mjs`), chain failures before the retry ladder are
407
- 1 round / 2 chamfer / 4 sharp and **zero remain after it**; the full glyph matrix,
414
+ 0 round / 1 chamfer / 1 sharp and **zero remain after it**; the full glyph matrix,
408
415
  including `"Scott"` through +3, has no throw or topology divergence.
409
416
 
410
417
  The literal error remains intentionally loud if a new pathological arrangement defeats
411
- every retry rung. If it appears on ≥0.60, report the profile, delta, and corner style so it
418
+ every retry rung. If it appears on ≥0.68.1, report the profile, delta, and corner style so it
412
419
  can become a deterministic fixture. Reducing `|delta|` or simplifying nearly coincident
413
420
  features is a temporary workaround; changing corner style is not a reliable general fix.
414
421
 
415
422
  ## fillet-chamfer-radius-does-not-fit
416
423
 
417
- - **Symptom:** `filletProfile: corner <i> at (<x>, <y>): r=<r> does not fit; max <m>` (or `chamferProfile: … dist=<d> does not fit; max <m>`) thrown from `Shape2D.fillet`/`.chamfer` or the free `filletProfile`/`chamferProfile` functions.
424
+ - **Symptom:** *(partforge ≥ 0.69 — a WARNING, no longer a throw.)* `filletProfile: corner <i> at (<x>, <y>): r=<r> does not fit clamped to <m>` (or `chamferProfile: … dist=<d> does not fit clamped to <m>`) from `Shape2D.fillet`/`.chamfer` or the free `filletProfile`/`chamferProfile` functions, and the corner comes back rounded at `<m>` rather than at what was asked for.
418
425
  - **Cause:** The requested radius/distance exceeds what the corner's adjacent edges (or curved neighbor) can hold before the tangent point runs past the segment's own end.
419
- - **Fix:** Use the reported `max ≈` value, or narrow `opts.corners` to skip that corner. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Editing profiles".
426
+ - **Fix:** Usually nothing — the clamp is the designed degrade, and `<m>` is the largest magnitude that corner can hold. It throws only when a corner admits **no** valid magnitude at all. If the exact radius is functionally required (a bearing seat, a mating fit), the part must give the corner longer edges or select fewer corners; assert it in a `verify` block rather than trusting the request. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Editing profiles".
420
427
 
421
- Variant literal for a curve-adjacent corner (note the semicolon form, not parenthesized): `filletProfile: corner <i> at (<x>, <y>): could not fit r=<r> against the curved segment; max <m>` (`chamferProfile: … could not fit dist=<d> against the curved segment; max ≈ <m>` for chamfer).
428
+ Variant literal for a curve-adjacent corner: `filletProfile: corner <i> at (<x>, <y>): r=<r> does not fit against the curved segment clamped to <m>` (`chamferProfile: … dist=<d> …` for chamfer). Its ceiling is bisected rather than closed-form, and the residual throw (`could not fit r=<r> against the curved segment; max ≈ <m>`) survives for a corner where the solver finds no valid radius at all.
422
429
 
423
430
  ## fillet-chamfer-corners-overlap
424
431
 
425
- - **Symptom:** `filletProfile: corners <i> and <j> overlap on segment <k> (reduce r)` (or the same from `chamferProfile`).
432
+ - **Symptom:** *(partforge ≥ 0.69 — normally a WARNING now.)* `filletProfile: corner <i>: r=<r> overruns the edge it shares with a neighbouring corner — clamped to <m>`. The throw `filletProfile: corners <i> and <j> overlap on segment <k> (reduce r)` survives only as a backstop, when eight successive back-off passes still cannot fit the pair.
426
433
  - **Cause:** Two adjacent selected corners each claim more of the edge between them than it has — their combined setbacks exceed the segment's length (or curved arc-length span).
427
- - **Fix:** Reduce `r`/`dist`, or fillet/chamfer only one of the two corners (drop the other from `opts.corners`). See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Editing profiles".
434
+ - **Fix:** Usually nothing both corners are scaled down until they fit (exactly, in one step, on a straight shared edge; geometrically on a curved one). Fillet/chamfer only one of the two corners if you would rather keep the other's full radius. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Editing profiles".
428
435
 
429
436
  ## profile-query-needs-single-contour
430
437
 
@@ -585,6 +592,15 @@ between the Manifold preview and the OCCT STEP export.
585
592
  - **Symptom:** A Manifold-built fillet/chamfer produces a mangled or over-cut shape (no error), where the same part on OCCT would skip the feature with a `fillet(…) failed` warning.
586
593
  - **Cause:** The mesh fillet does not validate radius feasibility — a magnitude larger than the local geometry self-intersects its cutter solids and the booleans happily apply them.
587
594
  - **Fix:** Clamp the magnitude against local dimensions in the part (`Math.min(p.fillet, halfWidth - 0.5, …)` — see `src/parts/filleted-box.js`), which is required practice on the mesh class per [KERNEL-CONTRACT.md](KERNEL-CONTRACT.md) § "Mesh degrade policy".
595
+
596
+ ## feature-skipped-warning
597
+
598
+ - **Symptom:** The build succeeds but its result carries a warning like `fillet 1.15 failed (<reason>) — feature skipped, edges left sharp` (mesh backend), or `fillet(2) failed (…) — feature skipped` / `chamfer 3 over-ran the geometry — reduced to …` (OCCT repair policy), and the rendered part is missing the blend.
599
+ - **Cause:** *(partforge ≥ 0.69.)* A fillet/chamfer the geometry (or its own selector) defeats no longer fails the whole build on either backend: the op returns its input solid unchanged, everything downstream still applies, and the skip is recorded on the build result's `warnings` (`kernel.takeBuildWarnings()` / the `meshes` message's `warnings: [{part, message}]`).
600
+ - **Fix:** Read the parenthesized reason. A bad selector (unknown plane, wrong `at` height) is a part bug — fix the selector. A geometry-defeated blend usually wants a smaller magnitude or simpler input (clamp per the entry above), or the feature deliberately left off. Treat the warning as "this feature did not land", never as a cosmetic note — the shape on screen genuinely lacks it.
601
+
602
+ The same channel carries every other degrade in a build: an `extrude` rim bevel reduced or skipped (`extrude bevel <b> …`), a `roundedBox` rim radius clamped to `round.side`, and the `Shape2D` corner-op clamps in the two entries above. A build result's `warnings` is the complete list of what the part asked for and did not get.
603
+
588
604
  # Hardware library
589
605
 
590
606
  Reserved for `hardware-*` patterns (issue #30). No entries yet.
@@ -396,14 +396,43 @@ garbage). A failing chamfer instead binary-searches the largest valid distance.
396
396
  conforming B-rep kernel must degrade this way — a fillet request must never brick the
397
397
  build, and authors should expect all-or-nothing filleting per call, not per edge.
398
398
 
399
- **Mesh degrade policy** (`mesh-fillet.js`): the mesh class degrades by *rerouting*, not
400
- skipping an unsupported edge class or an empty selection throws
401
- `KernelCapabilityError` and the framework retries the build on the B-rep kernel, which
402
- then applies its own repair policy. One asymmetry is deliberate: the mesh class does
403
- **not** validate radius feasibility (an oversized radius yields self-intersecting tools
404
- and a wrong shape rather than a skipped feature), so parts should clamp magnitudes
405
- against local geometry the way `filleted-box.js` does good practice on both classes,
406
- mandatory on this one.
399
+ **Mesh degrade policy** (`mesh-fillet.js` + `manifold-backend.js`): an unsupported edge
400
+ class or a function selector *reroutes* it throws `KernelCapabilityError` and the
401
+ framework retries the build on the B-rep kernel, which then applies its own repair
402
+ policy. Every other fillet/chamfer failure on the mesh class (a geometry-defeated
403
+ blend, a selector naming an unknown plane, an empty selection error from the machinery)
404
+ now **skips like the B-rep policy**: the op returns its input solid unchanged and
405
+ records a feature-skip warning instead of failing the build. One asymmetry is
406
+ deliberate: the mesh class does **not** validate radius feasibility (an oversized
407
+ radius yields self-intersecting tools and a wrong shape rather than a skipped feature),
408
+ so parts should clamp magnitudes against local geometry the way `filleted-box.js`
409
+ does — good practice on both classes, mandatory on this one.
410
+
411
+ **2-D corner-op policy** (`contour-ops.js`, partforge 0.69): `Shape2D.fillet`/`.chamfer`
412
+ — and the free `filletProfile`/`chamferProfile` — **CLAMP** a magnitude the geometry
413
+ cannot take rather than throwing. Two ceilings apply, and both were already computed
414
+ for the error messages this replaces: a per-corner one (closed-form for a line-line
415
+ corner, bisected for a curve-adjacent one) and a shared-edge one where two selected
416
+ corners claim the same segment, resolved by scaling both until they fit — exactly in
417
+ one step on a straight edge, geometrically on a curved one, bounded at 8 passes. Each
418
+ clamp is reported through the warnings channel. It still throws where there is no
419
+ feasible magnitude at all: a corner the curve solver cannot fit at any radius, a
420
+ selector matching no corner, and a shared edge still overlapping after the pass bound.
421
+ A conforming implementation must not silently return the requested magnitude.
422
+
423
+ **Feature-skip warnings channel** (both backends, partforge 0.69): every skipped,
424
+ clamped, or rescued feature is recorded on the kernel and drained with
425
+ `kernel.takeBuildWarnings()`. The full set: a mesh fillet/chamfer that returned its
426
+ input, occt-repair's skip/bisection rescues, a `roundall-skipped`, an `extrude` rim
427
+ bevel reduced or left square, a `roundedBox` rim radius clamped to `round.side`, and
428
+ a `Shape2D.fillet`/`.chamfer` corner clamped to what its edges can hold. Backend-neutral
429
+ helpers reach the recorder through the kernel's internal `_recordWarning`, so there is
430
+ one list per build rather than one per subsystem. The worker job layer (`jobs.js`) drains per
431
+ sub-part and attaches `warnings: [{part, message}]` to the `meshes` /
432
+ `capture-meshes` result when any were recorded, so a host can tell its user (or its
433
+ agent) that the part on screen is missing a feature it asked for. A skipped op still
434
+ console.warns as before; the channel is additive. Hosts that ignore the field see
435
+ exactly the old behavior.
407
436
 
408
437
  ## Shape2D (2-D booleans)
409
438
 
@@ -530,13 +559,14 @@ delta.
530
559
 
531
560
  **Measured failure surface.** The committed instrument is
532
561
  `node scripts/offset-rates.mjs`, over 600 deterministic seeded shapes plus six glyph cases,
533
- 20 deltas, and three corner styles (36,090 attempts). In partforge 0.60 it reports:
562
+ 20 deltas, and three corner styles (36,090 attempts). In partforge 0.68.1 (after the
563
+ fold-aware clearance fix in the winding classifier) it reports:
534
564
 
535
- - before the retry ladder: round 1/12,030 (0.008%), chamfer 2/12,030 (0.017%), sharp
536
- 4/12,030 (0.033%);
565
+ - before the retry ladder: round 0/12,030, chamfer 1/12,030 (0.008%), sharp
566
+ 1/12,030 (0.008%);
537
567
  - after the retry ladder: zero chain-incomplete failures for all three styles;
538
- - seven oracle-checked rescues, with median area error 0.0972%, worst 1.663%
539
- (2.2373 mm²), zero region-count losses, and zero complete arc losses.
568
+ - two oracle-checked rescues, with median area error 0.0727%, worst 0.073%
569
+ (0.0720 mm²), zero region-count losses, and zero complete arc losses.
540
570
 
541
571
  The ladder remains a numerical escape hatch: it perturbs delta by 1e-9, coarsens crossing
542
572
  clustering, then tries polyline outlines. A future case that reaches a coarse clustering or
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.67.4",
3
+ "version": "0.69.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/app-demo.js CHANGED
@@ -14,4 +14,8 @@ import { mount } from "./framework/index.js";
14
14
  window.__pfRuntime = mount(demoPart, {
15
15
  createWorker: (name) =>
16
16
  new Worker(new URL("./demo-worker.js", import.meta.url), { type: "module", name }),
17
+ onAnnotationSend: (payload) => {
18
+ window.__pfLastAnnotation = payload;
19
+ console.log("annotation payload", payload);
20
+ },
17
21
  });
@@ -0,0 +1,134 @@
1
+ // Viewbar chrome for annotation mode: the pencil toggle + contextual actions
2
+ // (Undo / Clear / Send) shown while the mode is on. A direct sibling of
3
+ // measure-controls.js — same no-op-without-button contract, same attribute
4
+ // restore discipline on detach. The mode object (annotate-mode.js) owns all
5
+ // behavior; this file only puts it on screen. One extra contract: a host whose
6
+ // markup HAS the button but whose mount passed no onAnnotationSend gets the
7
+ // button hidden entirely (spec: no dead Send) — mount passes mode = null.
8
+ //
9
+ // `send: "host"` drops the Send button from the row and leaves Undo/Clear.
10
+ // It is for a host that draws its own send affordance (partforge-cloud pairs
11
+ // the sketch with a typed prompt in its own composer, then calls
12
+ // runtime.annotate.send()) — two Send buttons in two places, one of which
13
+ // ignores the typed message, is the failure this avoids.
14
+ import { attachButtonTooltips } from "../tooltip.js";
15
+ import { runCleanupSteps, captureAttributes, restoreAttributes } from "../teardown.js";
16
+
17
+ const BUTTON_ATTRIBUTES = ["type", "aria-pressed", "aria-label", "title", "disabled", "hidden"];
18
+ const PENCIL_ICON = `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path 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"/><path d="m15 5 4 4"/></svg>`;
19
+
20
+ const noop = () => {};
21
+
22
+ export function attachAnnotateControls(viewer, mode, { annotate: button } = {}, { tooltip, escapeScope, send = "viewbar" } = {}) {
23
+ if (!button) return { detach: noop };
24
+
25
+ const hostAttributes = captureAttributes(button, BUTTON_ATTRIBUTES);
26
+ if (!mode) {
27
+ button.hidden = true;
28
+ let restored = false;
29
+ return {
30
+ detach() {
31
+ if (restored) return;
32
+ restored = true;
33
+ restoreAttributes(button, hostAttributes);
34
+ },
35
+ };
36
+ }
37
+ const hostHtml = button.innerHTML;
38
+ const hostOn = button.classList.contains("on");
39
+
40
+ button.type = "button";
41
+ button.innerHTML = PENCIL_ICON;
42
+ button.setAttribute("aria-pressed", "false");
43
+ if (!tooltip && !button.hasAttribute("title")) button.title = "Sketch";
44
+
45
+ const actions = document.createElement("span");
46
+ actions.className = "pf-annotate-actions";
47
+ const undoButton = document.createElement("button");
48
+ undoButton.type = "button";
49
+ undoButton.textContent = "Undo";
50
+ undoButton.title = "Remove the last stroke";
51
+ undoButton.setAttribute("aria-label", "Remove the last stroke");
52
+ const clearButton = document.createElement("button");
53
+ clearButton.type = "button";
54
+ clearButton.textContent = "Clear";
55
+ clearButton.title = "Remove all strokes";
56
+ clearButton.setAttribute("aria-label", "Remove all strokes");
57
+ let sendButton = null;
58
+ if (send !== "host") {
59
+ sendButton = document.createElement("button");
60
+ sendButton.type = "button";
61
+ sendButton.className = "pf-annotate-send";
62
+ sendButton.textContent = "Send";
63
+ sendButton.title = "Send the annotation";
64
+ sendButton.setAttribute("aria-label", "Send the annotation");
65
+ }
66
+ actions.append(...[undoButton, clearButton, sendButton].filter(Boolean));
67
+ button.after(actions);
68
+
69
+ const buttons = [button, undoButton, clearButton, sendButton].filter(Boolean);
70
+ const tooltipBinding = tooltip
71
+ ? attachButtonTooltips(tooltip, buttons.map((element) => ({ element })))
72
+ : null;
73
+
74
+ function sync() {
75
+ const on = mode.isEnabled();
76
+ button.setAttribute("aria-pressed", String(on));
77
+ button.setAttribute("aria-label", on ? "Stop sketching" : "Sketch");
78
+ button.classList.toggle("on", on);
79
+ actions.hidden = !on;
80
+ const empty = mode.strokeCount() === 0;
81
+ undoButton.disabled = empty;
82
+ clearButton.disabled = empty;
83
+ if (sendButton) sendButton.disabled = empty;
84
+ tooltipBinding?.sync();
85
+ }
86
+
87
+ const onToggle = () => { mode.setEnabled(!mode.isEnabled()); sync(); };
88
+ const onUndo = () => { mode.undo(); sync(); };
89
+ const onClear = () => { mode.clear(); sync(); };
90
+ const onSendClick = () => { mode.send(); sync(); };
91
+ const onEscape = (event) => {
92
+ if (event.key !== "Escape" || !mode.isEnabled()) return;
93
+ event.preventDefault();
94
+ // Consume the keystroke — same order-independence contract as
95
+ // measure-controls.js vs cutaway (which covers itself with escapeGuard;
96
+ // mount extends that guard to include annotate).
97
+ event.stopImmediatePropagation();
98
+ mode.setEnabled(false);
99
+ sync();
100
+ tooltipBinding?.hide();
101
+ };
102
+ const offInk = mode.onInkChange(sync);
103
+ const offMode = mode.onModeChange(sync);
104
+
105
+ button.addEventListener("click", onToggle);
106
+ undoButton.addEventListener("click", onUndo);
107
+ clearButton.addEventListener("click", onClear);
108
+ sendButton?.addEventListener("click", onSendClick);
109
+ const escapeTargets = [escapeScope ?? viewer.domElement, ...buttons];
110
+ for (const element of escapeTargets) element.addEventListener("keydown", onEscape);
111
+ sync();
112
+
113
+ let detached = false;
114
+ return {
115
+ detach() {
116
+ if (detached) return;
117
+ detached = true;
118
+ runCleanupSteps([
119
+ offInk,
120
+ offMode,
121
+ () => button.removeEventListener("click", onToggle),
122
+ () => undoButton.removeEventListener("click", onUndo),
123
+ () => clearButton.removeEventListener("click", onClear),
124
+ () => sendButton?.removeEventListener("click", onSendClick),
125
+ ...escapeTargets.map((element) => () => element.removeEventListener("keydown", onEscape)),
126
+ () => tooltipBinding?.detach(),
127
+ () => actions.remove(),
128
+ () => restoreAttributes(button, hostAttributes),
129
+ () => { button.innerHTML = hostHtml; },
130
+ () => button.classList.toggle("on", hostOn),
131
+ ], "annotate control cleanup failed");
132
+ },
133
+ };
134
+ }
@@ -0,0 +1,165 @@
1
+ // Annotation-mode orchestrator — the one annotate module touching both the DOM
2
+ // and the viewer (the measure-mode.js stance). Owns pointer→ink, the mode
3
+ // lifecycle, and payload assembly. The overlay canvas is lazy-created on first
4
+ // enable and kept across toggles; INK is not — exiting the mode discards it,
5
+ // because screen-space ink is only meaningful against the camera pose it was
6
+ // drawn over (deliberately unlike measure pins).
7
+ import * as THREE from "three";
8
+ import { createInkStore, anchorSpecs, DEFAULT_STROKE_WIDTH } from "./ink.js";
9
+ import { createInkCanvas } from "./ink-canvas.js";
10
+ import { raycastViewer } from "../selection/raycast.js";
11
+
12
+ export const ANNOTATION_VERSION = 1;
13
+ // Long-edge bound on BOTH pictures in the payload. The ink canvas is stage
14
+ // sized × devicePixelRatio, so an unbounded send on a large hi-DPI display
15
+ // hands the host a multi-megabyte pair of base64 strings — slow to encode,
16
+ // and past the ceiling a host that ships them anywhere has to enforce. 2048
17
+ // is captureCurrent's own default and comfortably above what any reviewer
18
+ // (human or model) reads a sketch at; a smaller stage exports at its own size
19
+ // and pays nothing.
20
+ const SEND_MAX_EDGE = 2048;
21
+
22
+ export function createAnnotateMode(viewer, { stage, getContext, onSend, createCanvas = createInkCanvas } = {}) {
23
+ const ink = createInkStore();
24
+ let canvas = null; // lazy; created on first enable
25
+ let enabled = false;
26
+ let drawing = false;
27
+ const modeListeners = new Set();
28
+ const notifyMode = () => { for (const cb of [...modeListeners]) cb(); };
29
+ const offInk = ink.onChange(() => canvas?.setStrokes(ink.strokes()));
30
+
31
+ const rectOf = () => canvas.element.getBoundingClientRect();
32
+ const normalized = (event, rect) => [
33
+ Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width)),
34
+ Math.min(1, Math.max(0, (event.clientY - rect.top) / rect.height)),
35
+ ];
36
+
37
+ // isPrimary === false (a second simultaneous touch) is ignored; undefined
38
+ // (plain MouseEvent, some test environments) draws normally.
39
+ const onPointerDown = (event) => {
40
+ if (event.isPrimary === false || drawing) return;
41
+ const rect = rectOf();
42
+ if (!rect.width || !rect.height) return;
43
+ drawing = true;
44
+ canvas.element.setPointerCapture?.(event.pointerId);
45
+ const [nx, ny] = normalized(event, rect);
46
+ ink.begin(nx, ny, { width: DEFAULT_STROKE_WIDTH, aspect: rect.width / rect.height });
47
+ };
48
+ const onPointerMove = (event) => {
49
+ if (!drawing || event.isPrimary === false) return;
50
+ const [nx, ny] = normalized(event, rectOf());
51
+ ink.extend(nx, ny);
52
+ };
53
+ const onPointerEnd = (event) => {
54
+ if (!drawing || event.isPrimary === false) return;
55
+ drawing = false;
56
+ ink.end();
57
+ };
58
+
59
+ function ensureCanvas() {
60
+ if (canvas) return;
61
+ canvas = createCanvas(stage);
62
+ canvas.element.addEventListener("pointerdown", onPointerDown);
63
+ canvas.element.addEventListener("pointermove", onPointerMove);
64
+ canvas.element.addEventListener("pointerup", onPointerEnd);
65
+ canvas.element.addEventListener("pointercancel", onPointerEnd);
66
+ }
67
+
68
+ function setEnabled(on) {
69
+ if (on === enabled) return;
70
+ enabled = on;
71
+ if (on) {
72
+ ensureCanvas();
73
+ canvas.show();
74
+ } else {
75
+ drawing = false;
76
+ ink.clear(); // spec: ink never survives an exit
77
+ canvas?.hide();
78
+ }
79
+ notifyMode();
80
+ }
81
+
82
+ // The camera pose in two frames. World replays exactly against THIS build;
83
+ // the parts frame (through the inverse of the shared parts parent's
84
+ // matrixWorld — the measure-mode idiom) stays pinned to the CAD geometry, so
85
+ // it survives the per-view bbox recentring when the model is rebuilt later.
86
+ function cameraBlock() {
87
+ const { pos, target } = viewer.getCameraState();
88
+ const world = { pos, target, up: viewer.camera.up.toArray(), fov: viewer.camera.fov };
89
+ const parent = Object.values(viewer._subMeshes ?? {})[0]?.parent ?? null;
90
+ if (!parent) return { world, parts: null };
91
+ parent.updateWorldMatrix(true, false);
92
+ const inv = parent.matrixWorld.clone().invert();
93
+ const map = (v) => new THREE.Vector3(v[0], v[1], v[2]).applyMatrix4(inv).toArray();
94
+ const up = new THREE.Vector3(world.up[0], world.up[1], world.up[2]).transformDirection(inv).toArray();
95
+ return { world, parts: { pos: map(world.pos), target: map(world.target), up, fov: world.fov } };
96
+ }
97
+
98
+ function send() {
99
+ if (!enabled || ink.isEmpty()) return false;
100
+ const rect = rectOf();
101
+ if (!rect.width || !rect.height) return false;
102
+ const { width, height, dpr } = canvas.size();
103
+ // Model render FIRST: on a lost WebGL context captureCurrent returns null
104
+ // and we abort with the ink intact — nothing is silently dropped.
105
+ const model = viewer.captureCurrent({ size: Math.min(Math.max(width, height), SEND_MAX_EDGE) });
106
+ if (!model) return false;
107
+ const strokes = ink.strokes();
108
+ const aspect = rect.width / rect.height;
109
+ const anchors = strokes.flatMap((stroke, index) =>
110
+ anchorSpecs(stroke.points, aspect).map((spec) => {
111
+ const hit = raycastViewer(
112
+ viewer,
113
+ rect.left + spec.screen[0] * rect.width,
114
+ rect.top + spec.screen[1] * rect.height,
115
+ );
116
+ return {
117
+ stroke: index,
118
+ ...(spec.kind ? { kind: spec.kind } : { t: spec.t }),
119
+ screen: spec.screen,
120
+ // a miss is kept as null — "circled empty space" is signal
121
+ hit: hit ? { subPart: hit.subPart, pointLocal: hit.pointLocal } : null,
122
+ };
123
+ }));
124
+ const { view, params } = getContext();
125
+ onSend?.({
126
+ version: ANNOTATION_VERSION,
127
+ strokes,
128
+ anchors,
129
+ images: { drawing: canvas.toDataUrl({ maxEdge: SEND_MAX_EDGE }), model },
130
+ camera: cameraBlock(),
131
+ viewport: { width: rect.width, height: rect.height, dpr },
132
+ context: { view, params: { ...params } },
133
+ });
134
+ setEnabled(false); // sent: exit and discard
135
+ return true;
136
+ }
137
+
138
+ let detached = false;
139
+ return {
140
+ setEnabled,
141
+ isEnabled: () => enabled,
142
+ undo: () => ink.undo(),
143
+ clear: () => ink.clear(),
144
+ strokeCount: () => ink.strokeCount(),
145
+ send,
146
+ onInkChange: (cb) => ink.onChange(cb),
147
+ onModeChange: (cb) => { modeListeners.add(cb); return () => modeListeners.delete(cb); },
148
+ detach() {
149
+ if (detached) return;
150
+ detached = true;
151
+ // Leave the state machine honest: a detach while enabled must not
152
+ // strand isEnabled() at true forever. Runs before listeners/canvas
153
+ // teardown below — setEnabled(false) notifies mode listeners and hides
154
+ // the canvas, both of which still need to be live for this call.
155
+ setEnabled(false);
156
+ offInk();
157
+ if (!canvas) return;
158
+ canvas.element.removeEventListener("pointerdown", onPointerDown);
159
+ canvas.element.removeEventListener("pointermove", onPointerMove);
160
+ canvas.element.removeEventListener("pointerup", onPointerEnd);
161
+ canvas.element.removeEventListener("pointercancel", onPointerEnd);
162
+ canvas.dispose();
163
+ },
164
+ };
165
+ }
@@ -0,0 +1,129 @@
1
+ // The annotation ink layer: a transparent 2D canvas stacked over the viewer
2
+ // canvas (the first screen-space overlay canvas in the framework — everything
3
+ // else that follows the model is in-scene, see dim3-scene.js). Appended to the
4
+ // STAGE, not document.body, so it lives in .pf-stage's positioning context and
5
+ // behaves under the narrow-pane layout. While visible it owns all pointer
6
+ // events, which is what freezes orbit controls during annotation — no viewer
7
+ // changes needed. Strokes render dark-core-over-light-halo so ink reads on
8
+ // both themes and any model color.
9
+ import { runCleanupSteps } from "../teardown.js";
10
+
11
+ const CORE_COLOR = "#d92d20";
12
+ const HALO_COLOR = "rgba(255, 255, 255, 0.85)";
13
+ const HALO_RATIO = 2.2; // halo pass width relative to the core width
14
+
15
+ export function createInkCanvas(stage, {
16
+ getContext2d = (canvas) => canvas.getContext("2d"),
17
+ createCanvas = () => document.createElement("canvas"),
18
+ } = {}) {
19
+ const canvas = createCanvas();
20
+ canvas.className = "pf-ink-canvas";
21
+ canvas.hidden = true;
22
+ stage.appendChild(canvas);
23
+ const ctx = getContext2d(canvas);
24
+ let strokes = [];
25
+
26
+ // Strokes are normalized, so a pass is written against whatever bitmap it is
27
+ // handed — the live canvas, or the scratch one toDataUrl uses to bound an
28
+ // export. Width scales with the target's short edge for the same reason.
29
+ function drawPass(target, targetCtx, color, widthScale) {
30
+ targetCtx.strokeStyle = color;
31
+ targetCtx.fillStyle = color;
32
+ targetCtx.lineCap = "round";
33
+ targetCtx.lineJoin = "round";
34
+ const short = Math.min(target.width, target.height);
35
+ for (const stroke of strokes) {
36
+ const w = stroke.width * short * widthScale;
37
+ if (stroke.points.length === 1) {
38
+ const [nx, ny] = stroke.points[0];
39
+ targetCtx.beginPath();
40
+ targetCtx.arc(nx * target.width, ny * target.height, w / 2, 0, Math.PI * 2);
41
+ targetCtx.fill();
42
+ continue;
43
+ }
44
+ targetCtx.lineWidth = w;
45
+ targetCtx.beginPath();
46
+ stroke.points.forEach(([nx, ny], i) => {
47
+ const x = nx * target.width;
48
+ const y = ny * target.height;
49
+ if (i === 0) targetCtx.moveTo(x, y);
50
+ else targetCtx.lineTo(x, y);
51
+ });
52
+ targetCtx.stroke();
53
+ }
54
+ }
55
+
56
+ function drawInto(targetCtx, target) {
57
+ targetCtx.clearRect(0, 0, target.width, target.height);
58
+ drawPass(target, targetCtx, HALO_COLOR, HALO_RATIO);
59
+ drawPass(target, targetCtx, CORE_COLOR, 1);
60
+ }
61
+
62
+ function draw() {
63
+ if (!ctx) return;
64
+ drawInto(ctx, canvas);
65
+ }
66
+
67
+ function resize() {
68
+ const rect = stage.getBoundingClientRect();
69
+ const dpr = globalThis.devicePixelRatio || 1;
70
+ const width = Math.max(1, Math.round(rect.width * dpr));
71
+ const height = Math.max(1, Math.round(rect.height * dpr));
72
+ if (canvas.width !== width || canvas.height !== height) {
73
+ canvas.width = width;
74
+ canvas.height = height;
75
+ }
76
+ draw();
77
+ }
78
+
79
+ // The viewer's own ResizeObserver is internal (viewer.js exposes no resize
80
+ // hook), so the overlay runs its own — ink is normalized, so a resize is
81
+ // just a re-rasterize at the new bitmap size. Skip it while hidden: the
82
+ // stage keeps resizing (rail drags, window resizes) whether or not
83
+ // annotate mode is on, and re-rasterizing an invisible canvas is wasted
84
+ // work; show() already calls resize() so nothing is missed on re-entry.
85
+ const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => {
86
+ if (canvas.hidden) return;
87
+ resize();
88
+ });
89
+ observer?.observe(stage);
90
+
91
+ let disposed = false;
92
+ return {
93
+ element: canvas,
94
+ show() { canvas.hidden = false; resize(); },
95
+ hide() { canvas.hidden = true; },
96
+ setStrokes(next) { strokes = next; draw(); },
97
+ // The ink layer as a transparent PNG. `maxEdge` bounds the exported
98
+ // bitmap: the live canvas is stage-sized × devicePixelRatio, so on a large
99
+ // hi-DPI display it runs to several thousand pixels a side, and a PNG that
100
+ // big is both slow to encode and large enough that a host with a payload
101
+ // ceiling would have to drop it — losing the drawing while keeping the
102
+ // picture of the model, which is the one outcome worse than failing. Above
103
+ // the bound the strokes are re-rasterized into a scratch canvas rather than
104
+ // resampled, so thin ink stays crisp instead of turning to mush. Under it
105
+ // (the ordinary case) nothing is copied and the live canvas exports
106
+ // directly.
107
+ toDataUrl({ maxEdge } = {}) {
108
+ const long = Math.max(canvas.width, canvas.height);
109
+ if (!maxEdge || long <= maxEdge) return canvas.toDataURL("image/png");
110
+ const scale = maxEdge / long;
111
+ const scratch = createCanvas();
112
+ scratch.width = Math.max(1, Math.round(canvas.width * scale));
113
+ scratch.height = Math.max(1, Math.round(canvas.height * scale));
114
+ const scratchCtx = getContext2d(scratch);
115
+ if (!scratchCtx) return canvas.toDataURL("image/png");
116
+ drawInto(scratchCtx, scratch);
117
+ return scratch.toDataURL("image/png");
118
+ },
119
+ size: () => ({ width: canvas.width, height: canvas.height, dpr: globalThis.devicePixelRatio || 1 }),
120
+ dispose() {
121
+ if (disposed) return;
122
+ disposed = true;
123
+ runCleanupSteps([
124
+ () => observer?.disconnect(),
125
+ () => canvas.remove(),
126
+ ], "ink canvas cleanup failed");
127
+ },
128
+ };
129
+ }