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
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -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
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
403
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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:**
|
|
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
|
|
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)`
|
|
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:**
|
|
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.
|
package/docs/KERNEL-CONTRACT.md
CHANGED
|
@@ -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`):
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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.
|
|
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
|
|
536
|
-
|
|
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
|
-
-
|
|
539
|
-
(
|
|
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
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
|
+
}
|