partforge 0.19.0 → 0.20.1

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.
Files changed (38) hide show
  1. package/docs/AUTHORING-PARTS.md +97 -1
  2. package/docs/ERROR-PATTERNS.md +6 -0
  3. package/package.json +3 -1
  4. package/src/app-bracket.js +9 -0
  5. package/src/app-embed-test.js +68 -0
  6. package/src/app-nameplate.js +9 -0
  7. package/src/app-text-smoke.js +10 -0
  8. package/src/bracket-worker.js +3 -0
  9. package/src/framework/app.css +23 -6
  10. package/src/framework/cutaway-controls.js +155 -0
  11. package/src/framework/cutaway-gizmo.js +686 -0
  12. package/src/framework/cutaway-math.js +53 -0
  13. package/src/framework/cutaway-render.js +338 -0
  14. package/src/framework/cutaway.js +469 -0
  15. package/src/framework/fonts.js +36 -0
  16. package/src/framework/geometry/curve-fill.js +86 -0
  17. package/src/framework/geometry/fonts/Roboto-LICENSE.txt +93 -0
  18. package/src/framework/geometry/fonts/Roboto-Regular.ttf +0 -0
  19. package/src/framework/geometry/fonts/default-font.js +3 -0
  20. package/src/framework/geometry/kernel-front.js +53 -0
  21. package/src/framework/geometry/kernel.js +1 -1
  22. package/src/framework/geometry/text2d.js +98 -0
  23. package/src/framework/geometry-service.js +21 -2
  24. package/src/framework/jobs.js +9 -0
  25. package/src/framework/mount.js +278 -223
  26. package/src/framework/selection/hover.js +102 -36
  27. package/src/framework/selection/raycast.js +4 -1
  28. package/src/framework/tooltip.js +282 -0
  29. package/src/framework/viewer-controls.js +25 -2
  30. package/src/framework/viewer-lighting.js +13 -0
  31. package/src/framework/viewer.js +83 -10
  32. package/src/nameplate-worker.js +3 -0
  33. package/src/parts/bracket.js +76 -0
  34. package/src/parts/nameplate.js +74 -0
  35. package/src/parts/text-smoke.js +21 -0
  36. package/src/testing/manifold.js +6 -2
  37. package/src/testing/occt.js +6 -2
  38. package/src/text-smoke-worker.js +3 -0
@@ -44,6 +44,7 @@ export default {
44
44
  meta: { title, units, background? }, // title string; units e.g. "mm"; background = 0xRRGGBB scene colour
45
45
  parameters, // the control-panel schema (array of sections — see below)
46
46
  defaults, // flat { paramKey: value } — seeds params + control values
47
+ fonts?, // { name: source } — fonts a part's k.text2d() needs; framework preloads before build (see below)
47
48
  derive?, // (p) => d, or { group: (p, d) => {…}, … } — dependent values computed once per build
48
49
  parts: { // named sub-parts; each builds ONE solid
49
50
  <name>: {
@@ -82,6 +83,13 @@ export default {
82
83
  - `enabled(p)` gates a conditional sub-part (e.g. only present when a feature is on).
83
84
  - A view's sub-parts are derived, never hard-coded: those whose `views` include the view
84
85
  and whose `enabled(p)` is true.
86
+ - `fonts` declares the outline fonts a part's `k.text2d()` calls need, as `{ name: source
87
+ }` — a source is inline bytes, a URL string, or a thunk (e.g. a Vite `import('./x.ttf')`,
88
+ which resolves to `{ default: url }`). The framework resolves and parses these into
89
+ `kernel._fonts` **before** the synchronous `build` runs, so `k.text2d(str, { font: name
90
+ })` can look the font up by name. See `src/framework/fonts.js` (`resolveFonts`) and
91
+ `k.text2d` in `docs/KERNEL-CONTRACT.md` for the full contract; fuller authoring guidance
92
+ (recommended font sourcing, licensing notes) lands in a follow-up pass.
85
93
 
86
94
  ---
87
95
 
@@ -498,6 +506,72 @@ const wall = k.shape2d(outer).offset(-2, { corners: "sharp" }); // inset, mite
498
506
 
499
507
  `Shape2D.offset(delta, {corners})` grows (`delta>0`) or insets (`delta<0`) a shape with round/chamfer/sharp corners — curve-preserving on OCCT, faceted at mesh LOD on Manifold; it throws if the offset collapses the shape. (For `derive()`/main-thread clearance math on plain point lists, use the pure `offsetPolygon` helper instead.)
500
508
 
509
+ ## Text (`text2d`)
510
+
511
+ `k.text2d(string, { size, font?, align?, valign?, lineHeight?, tracking?, kerning? })` renders outline-font text as a `Shape2D` — a 2-D boolean you can compose with other shapes (union / cut / offset) and extrude into 3-D geometry.
512
+
513
+ **Parameters:**
514
+
515
+ - `string` — the text to render
516
+ - `size` — **cap height in mm** (the design-height of capital letters like "H"); the layout engine scales the font to this height
517
+ - `font` — optional font name (declared in the part's `fonts` field, below); omit it to use the bundled default (Roboto)
518
+ - `align` — horizontal alignment: `"center"` (default), `"left"`, or `"right"`
519
+ - `valign` — vertical alignment: `"middle"` (default), `"baseline"`, `"top"`, or `"bottom"`. The defaults (`center`/`middle`) place the text block's centre at the origin, so `.at([x, y])` / `plate.cut(text)` compose without extra translation
520
+ - `lineHeight` — distance between baselines in **mm** for multi-line text; omit for a font-metrics default (≈ `(ascender − descender)/em × size`)
521
+ - `tracking` — letter spacing in mm (default 0); positive widens, negative tightens
522
+ - `kerning` — boolean, enable pair-wise kerning (default true)
523
+
524
+ **Shape2D composition:**
525
+
526
+ Like any `Shape2D`, the result composes with booleans and offset — you can union it onto a face, cut it out as a depression, expand it with `offset()`, or combine multiple text shapes:
527
+
528
+ ```js
529
+ // Emboss text onto a plate
530
+ const baseplate = k.extrude({ profile: roundedRectPolygon(100, 60, 4), h: 5 });
531
+ const emboss = k.text2d("v2.0", { size: 8 }).offset(0.2); // 0.2 mm relief
532
+ const part = baseplate.cut(k.extrude({ profile: emboss, h: 1 }));
533
+
534
+ // Deboss text into a lid
535
+ const lid = k.extrude({ profile: circleProfile(40), h: 3 });
536
+ const deboss = k.text2d("PART-042", { size: 6 });
537
+ const carved = lid.cut(k.extrude({ profile: deboss, h: 0.5 }));
538
+
539
+ // Extrude text as a solid letters
540
+ const raised = k.extrude({ profile: k.text2d("LOGO", { size: 10, align: "center" }), h: 2 });
541
+
542
+ // Multi-line label with tight tracking
543
+ const label = k.text2d("YEAR 2025\nSERIES A", { size: 4, align: "center", tracking: -0.1 });
544
+ ```
545
+
546
+ **Font sourcing (the `fonts` PartDefinition field):**
547
+
548
+ Declare fonts in your part definition's optional `fonts` object — a map of font names to sources. The framework resolves and parses these before `build()` runs, so `k.text2d(str, { font: name })` can look them up synchronously:
549
+
550
+ ```js
551
+ fonts: {
552
+ heading: () => import("./fonts/Raleway-Bold.ttf"), // bundle via Vite dynamic import
553
+ label: "https://cdn.example.com/fonts/Courier-Prime.ttf", // URL fetch
554
+ default: new Uint8Array([...]) // inline bytes (rare)
555
+ },
556
+ ```
557
+
558
+ - **Dynamic import:** `() => import("./path/to/font.ttf")` — Vite bundles the font; resolves to `{ default: url }` at runtime
559
+ - **URL:** a string — the framework fetches it (CORS must allow it)
560
+ - **Inline bytes:** an `ArrayBuffer` or `Uint8Array` — useful for generated or embedded fonts
561
+
562
+ Reference a font by name: `k.text2d("text", { font: "heading" })`. Omit the `font` option to use the bundled **Roboto** (Regular, SIL OFL 1.1) default.
563
+
564
+ **Build-time & curve semantics:**
565
+
566
+ `text2d` is a **build-time operation** (not `derive()`), and **the curve representation differs by backend:**
567
+
568
+ - **OCCT (B-rep):** text outlines carry **exact cubic Bézier curves** into STEP export (not tessellated)
569
+ - **Manifold (mesh):** text outlines **facet at the mesh level-of-detail** (same as other curves in preview)
570
+
571
+ Both backends produce watertight emboss/deboss geometry; the difference is export fidelity. As with any `Shape2D`, composition with booleans and offset is backend-agnostic — the same code works on both.
572
+
573
+ **Overlapping / self-intersecting glyph outlines:** real font outlines aren't always simple, correctly-nested contours — counters can overlap or self-intersect. Before glyphs become curve regions, the framework resolves each glyph's raw contours with the nonzero winding rule (how all OpenType outlines — TrueType and CFF alike — are filled), so composite/overlapping outlines still produce a single correct `{outer, holes}` shape per glyph. This resolution stays curve-exact — it never flattens beziers to polygons — so the OCCT/Manifold split above still holds.
574
+
501
575
  ---
502
576
 
503
577
  ## Wiring a part into a runnable app
@@ -534,11 +608,33 @@ stylesheet). `mount` looks up these element IDs:
534
608
  | `#part` | view-tab bar — leave the div **empty**; `mount` generates one button per entry in `part.views` |
535
609
  | `#download-step` / `#download` / `#download-3mf` | STEP / STL / 3MF export buttons |
536
610
  | `#status`, `#busy`, `#phase` | status line + busy overlay |
537
- | `#viewbar` with `#pause` / `#reframe` / `#theme` | optional viewer controls (omit any you don't want) |
611
+ | `#viewbar` with `#pause` / `#reframe` / `#cutaway` / `#theme` | optional viewer controls (omit any you don't want) |
538
612
 
539
613
  Copy `demo.html` and change the title, the panel heading, and the `<script src>`. Two workers are spawned from your one worker entry
540
614
  (`name` = `"manifold"` for preview/STL/3MF, `"occt"` for STEP — handled for you).
541
615
 
616
+ `#cutaway` is optional viewer chrome. When present, it toggles an interactive
617
+ section plane whose exposed faces are hatched; changing views resets it. Cutaway
618
+ is viewer-only and never changes STL, STEP, or 3MF exports. Hosts that omit the
619
+ button get no cutaway UI.
620
+
621
+ Programmatic hosts can provide the same optional control without relying on an
622
+ ID by passing it beside the other chrome references:
623
+
624
+ ```js
625
+ mount(part, {
626
+ createWorker,
627
+ elements: {
628
+ chrome: {
629
+ pause,
630
+ reframe,
631
+ cutaway,
632
+ theme,
633
+ },
634
+ },
635
+ });
636
+ ```
637
+
542
638
  > Production deploy compiles only the pages listed in `build.rollupOptions.input`
543
639
  > (currently the landing gallery + the demo part pages). Other root `*.html` files are
544
640
  > **dev-only** (Vite serves any root HTML in `npm run dev`) unless added there. To also
@@ -248,6 +248,12 @@ Variant literals under this entry: `offsetPolygon: delta must be a finite number
248
248
  inset. Realistic clearances (fractions of a mm) and wall insets up to the
249
249
  narrowest feature never trip this.
250
250
 
251
+ ## curve-fill-resolved-hole-uncontained
252
+
253
+ - **Symptom:** `curve-fill: resolved hole has no containing outer`
254
+ - **Cause:** paper.js returned an unexpected or numerically degenerate path topology for the supplied font outline; the resolver refuses to attach the hole to an arbitrary outer.
255
+ - **Fix:** reduce or normalize degenerate font contours, confirm the correct CFF/TrueType fill rule was selected, and add the glyph as a focused `curve-fill.test.js` regression before changing resolver tolerances.
256
+
251
257
  # Hardware library
252
258
 
253
259
  Reserved for `hardware-*` patterns (issue #30). No entries yet.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.19.0",
3
+ "version": "0.20.1",
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",
@@ -45,6 +45,8 @@
45
45
  "fflate": "^0.8.3",
46
46
  "manifold-3d": "^3.5.1",
47
47
  "marked": "^18.0.5",
48
+ "opentype.js": "^2.0.0",
49
+ "paper": "^0.12.18",
48
50
  "pngjs": "^7.0.0",
49
51
  "replicad": "^0.23.1",
50
52
  "replicad-opencascadejs": "^0.23.0",
@@ -0,0 +1,9 @@
1
+ import part from "./parts/bracket.js";
2
+ import { mount } from "./framework/index.js";
3
+
4
+ // Dev example app for the bracket demo (src/parts/bracket.js). The
5
+ // `new Worker(new URL(...))` call must stay inline here so Vite bundles the worker.
6
+ mount(part, {
7
+ createWorker: (name) =>
8
+ new Worker(new URL("./bracket-worker.js", import.meta.url), { type: "module", name }),
9
+ });
@@ -0,0 +1,68 @@
1
+ import planterPart from "./parts/planter.js";
2
+ import { mount } from "./framework/index.js";
3
+
4
+ // Dev-only smoke harness for the 0.12.0 embedding contract (see embed-test.html).
5
+ // Exercises: explicit `elements` refs (no legacy IDs on the page), runtime.ready,
6
+ // runtime.dispose() (including dispose-before-first-build and rapid remount cycles),
7
+ // onBuild outcomes, and always-on onPick with the { selection, label, prompt, token }
8
+ // payload — i.e. exactly what partforge-cloud's chat chips will consume.
9
+
10
+ const logEl = document.getElementById("log");
11
+ function log(kind, msg) {
12
+ const line = document.createElement("div");
13
+ line.className = kind;
14
+ line.textContent = `${new Date().toISOString().slice(11, 19)} ${msg}`;
15
+ logEl.prepend(line);
16
+ }
17
+
18
+ const byId = (id) => document.getElementById(id);
19
+ const elements = {
20
+ viewer: byId("pf-viewer"),
21
+ controls: byId("pf-controls"),
22
+ status: { status: byId("pf-status"), busy: byId("pf-busy"), phase: byId("pf-phase") },
23
+ tabs: byId("pf-tabs"),
24
+ exports: { stl: byId("pf-stl"), step: byId("pf-step") }, // no 3MF button on purpose (optional)
25
+ chrome: { pause: byId("pf-pause"), reframe: byId("pf-reframe"), theme: byId("pf-theme") },
26
+ };
27
+
28
+ let runtime = null;
29
+
30
+ function doMount() {
31
+ if (runtime) { log("life", "already mounted — dispose first"); return; }
32
+ runtime = mount(planterPart, {
33
+ createWorker: (name) =>
34
+ new Worker(new URL("./planter-worker.js", import.meta.url), { type: "module", name }),
35
+ elements,
36
+ onBuild: (b) => log("build", `onBuild: ${b.status}${b.ms != null ? ` (${Math.round(b.ms)} ms)` : ""}${b.error ? ` — ${b.error}` : ""}`),
37
+ onPick: ({ label, prompt, token, selection }) => {
38
+ log("pick", `onPick label="${label}"`);
39
+ log("pick", ` prompt: ${prompt}`);
40
+ log("pick", ` token: ${token}`);
41
+ console.log("onPick payload", { label, prompt, token, selection });
42
+ },
43
+ });
44
+ log("life", "mounted");
45
+ runtime.ready.then(
46
+ () => log("life", "ready ✓ (first build succeeded)"),
47
+ (e) => log("life", `ready ✗ (${e.message})`),
48
+ );
49
+ }
50
+
51
+ function doDispose() {
52
+ if (!runtime) { log("life", "not mounted"); return; }
53
+ runtime.dispose();
54
+ runtime.dispose(); // idempotence check — must be a no-op
55
+ runtime = null;
56
+ log("life", "disposed (×2 — second call must be a no-op)");
57
+ }
58
+
59
+ document.getElementById("btn-mount").addEventListener("click", doMount);
60
+ document.getElementById("btn-dispose").addEventListener("click", doDispose);
61
+ // The React-unmount torture test: dispose immediately after mount, repeatedly.
62
+ // Expect five "ready ✗ (disposed before first build)" lines, then a final clean mount.
63
+ document.getElementById("btn-cycle").addEventListener("click", () => {
64
+ for (let i = 0; i < 5; i++) { doDispose(); doMount(); doDispose(); }
65
+ doMount();
66
+ });
67
+
68
+ doMount();
@@ -0,0 +1,9 @@
1
+ import part from "./parts/nameplate.js";
2
+ import { mount } from "./framework/index.js";
3
+
4
+ // Dev example app for the nameplate demo (src/parts/nameplate.js). The
5
+ // `new Worker(new URL(...))` call must stay inline here so Vite bundles the worker.
6
+ mount(part, {
7
+ createWorker: (name) =>
8
+ new Worker(new URL("./nameplate-worker.js", import.meta.url), { type: "module", name }),
9
+ });
@@ -0,0 +1,10 @@
1
+ import part from "./parts/text-smoke.js";
2
+ import { mount } from "./framework/index.js";
3
+
4
+ // Dev-only example app for the text-smoke part (src/parts/text-smoke.js). Identical
5
+ // wiring to app-demo.js — this fixture exists to run the resolved-text build through
6
+ // the real Vite Web Worker path (see scripts/check-app.mjs text-smoke.html).
7
+ mount(part, {
8
+ createWorker: (name) =>
9
+ new Worker(new URL("./text-smoke-worker.js", import.meta.url), { type: "module", name }),
10
+ });
@@ -0,0 +1,3 @@
1
+ import part from "./parts/bracket.js";
2
+ import { runWorker } from "./framework/worker.js";
3
+ runWorker(part);
@@ -161,9 +161,22 @@ button.action:focus-visible, .adv-toggle:focus-visible, #viewbar button:focus-vi
161
161
  font-size: 15px; line-height: 1;
162
162
  display: flex; align-items: center; justify-content: center;
163
163
  }
164
+ #viewbar .pf-cutaway-actions { display: flex; gap: 4px; }
165
+ #viewbar .pf-cutaway-actions[hidden] { display: none; }
166
+ #viewbar .pf-cutaway-actions button { width: auto; min-width: 56px; padding: 0 8px; }
167
+ #viewbar button:disabled { opacity: .38; cursor: not-allowed; }
168
+ #viewbar button:disabled:hover { color: var(--pf-muted-2); background: transparent; }
164
169
  #viewbar button:hover { color: var(--pf-text); background: var(--pf-surface-2); }
165
170
  #viewbar button.on { background: var(--pf-accent); color: var(--pf-on-accent); }
166
171
 
172
+ @media (max-width: 680px) {
173
+ #panel { max-height: calc(100vh - 80px); }
174
+ #viewbar {
175
+ top: auto; right: auto; bottom: 12px; left: 50%;
176
+ transform: translateX(-50%);
177
+ }
178
+ }
179
+
167
180
  #busy {
168
181
  position: fixed; inset: 0; z-index: 20; pointer-events: none;
169
182
  display: none; flex-direction: column; align-items: center;
@@ -206,17 +219,21 @@ button.action:focus-visible, .adv-toggle:focus-visible, #viewbar button:focus-vi
206
219
  .irrelevant:hover { opacity: 0.7; }
207
220
  .section-hidden { display: none; }
208
221
 
209
- /* hover feature-label tooltip (selection/hover.js) */
210
- #pf-hover-tip {
222
+ /* shared viewer tooltip */
223
+ #pf-hover-tip,
224
+ .pf-hover-tip {
211
225
  position: fixed; z-index: 30; pointer-events: none; display: none;
212
- padding: 4px 9px; border-radius: 8px; max-width: 260px;
226
+ padding: 4px 9px; border-radius: 8px; max-width: min(260px, calc(100vw - 16px));
213
227
  background: var(--pf-surface); border: 1px solid var(--pf-border);
214
228
  color: var(--pf-text-strong); font-size: 12px; line-height: 1.35;
215
229
  box-shadow: 0 2px 4px -2px rgb(0 0 0 / .15);
216
230
  }
217
- #pf-hover-tip.show { display: block; }
218
- #pf-hover-tip .pf-hover-sub { color: var(--pf-muted); font-size: 11px; margin-left: 7px; }
219
- #pf-hover-tip .pf-hover-sub:empty { display: none; }
231
+ #pf-hover-tip.show,
232
+ .pf-hover-tip.show { display: block; }
233
+ #pf-hover-tip .pf-hover-sub,
234
+ .pf-hover-tip .pf-hover-sub { color: var(--pf-muted); font-size: 11px; margin-left: 7px; }
235
+ #pf-hover-tip .pf-hover-sub:empty,
236
+ .pf-hover-tip .pf-hover-sub:empty { display: none; }
220
237
 
221
238
  /* request-a-pick: agent prompt banner, floated top-centre well below the part tabs,
222
239
  laid out like a chat message (avatar + text). Slides/fades in when shown; themed. */
@@ -0,0 +1,155 @@
1
+ import { attachButtonTooltips } from "./tooltip.js";
2
+
3
+ const UNSUPPORTED_TITLE = "Cutaway requires a stencil-capable WebGL context";
4
+ const BUTTON_ATTRIBUTES = [
5
+ "type",
6
+ "aria-pressed",
7
+ "aria-label",
8
+ "title",
9
+ "disabled",
10
+ "aria-description",
11
+ ];
12
+
13
+ const noop = () => {};
14
+
15
+ function runCleanupSteps(steps) {
16
+ const errors = [];
17
+ for (const step of steps) {
18
+ try { step(); } catch (error) { errors.push(error); }
19
+ }
20
+ if (errors.length === 1) throw errors[0];
21
+ if (errors.length > 1) {
22
+ throw new AggregateError(errors, "cutaway control cleanup failed");
23
+ }
24
+ }
25
+
26
+ function actionButton(label, title) {
27
+ const button = document.createElement("button");
28
+ button.type = "button";
29
+ button.textContent = label;
30
+ button.title = title;
31
+ button.setAttribute("aria-label", title);
32
+ return button;
33
+ }
34
+
35
+ function captureAttributes(element, names) {
36
+ return new Map(names.map((name) => [name, {
37
+ present: element.hasAttribute(name),
38
+ value: element.getAttribute(name),
39
+ }]));
40
+ }
41
+
42
+ function restoreAttributes(element, attributes) {
43
+ for (const [name, { present, value }] of attributes) {
44
+ if (present) element.setAttribute(name, value);
45
+ else element.removeAttribute(name);
46
+ }
47
+ }
48
+
49
+ // Wire the optional cutaway button to the viewer and create its contextual
50
+ // actions. Hosts that omit the primary button opt out of all DOM behavior.
51
+ export function attachCutawayControls(viewer, { cutaway: button } = {}, { tooltip } = {}) {
52
+ if (!button) return { reset: noop, detach: noop };
53
+
54
+ const canvas = viewer.domElement;
55
+ const addedCanvasTabIndex = !canvas.hasAttribute("tabindex");
56
+ const addedCanvasLabel = !canvas.hasAttribute("aria-label");
57
+ if (addedCanvasTabIndex) canvas.tabIndex = 0;
58
+ if (addedCanvasLabel) canvas.setAttribute("aria-label", "3D part viewer");
59
+
60
+ const hostButtonAttributes = captureAttributes(button, BUTTON_ATTRIBUTES);
61
+ const hostButtonDisabled = button.disabled;
62
+ const hostButtonOn = button.classList.contains("on");
63
+
64
+ const actions = document.createElement("span");
65
+ actions.className = "pf-cutaway-actions";
66
+ const flipButton = actionButton("Flip", "Flip cutaway direction");
67
+ const resetButton = actionButton("Reset", "Reset cutaway plane");
68
+ actions.append(flipButton, resetButton);
69
+ const tooltipBinding = tooltip
70
+ ? attachButtonTooltips(tooltip, [button, flipButton, resetButton].map((element) => ({ element })))
71
+ : null;
72
+
73
+ button.type = "button";
74
+ button.setAttribute("aria-pressed", "false");
75
+ if (!tooltip && !button.hasAttribute("title")) button.title = "Toggle cutaway view";
76
+
77
+ const supported = viewer.cutawaySupported();
78
+ if (!supported) {
79
+ button.disabled = true;
80
+ if (!tooltip) button.title = UNSUPPORTED_TITLE;
81
+ button.setAttribute("aria-description", UNSUPPORTED_TITLE);
82
+ }
83
+
84
+ button.after(actions);
85
+
86
+ function sync() {
87
+ const enabled = supported && viewer.cutawayEnabled();
88
+ button.setAttribute("aria-pressed", String(enabled));
89
+ button.setAttribute(
90
+ "aria-label",
91
+ supported ? (enabled ? "Disable cutaway" : "Enable cutaway") : UNSUPPORTED_TITLE,
92
+ );
93
+ button.classList.toggle("on", enabled);
94
+ actions.hidden = !enabled;
95
+ tooltipBinding?.sync();
96
+ }
97
+
98
+ let detached = false;
99
+ function disable() {
100
+ if (detached) return;
101
+ const restoreFocus = actions.contains(document.activeElement);
102
+ viewer.setCutawayEnabled(false);
103
+ sync();
104
+ tooltipBinding?.hide();
105
+ if (restoreFocus) button.focus({ preventScroll: true });
106
+ }
107
+
108
+ const onToggle = () => {
109
+ viewer.setCutawayEnabled(!viewer.cutawayEnabled());
110
+ sync();
111
+ };
112
+ const onFlip = () => viewer.flipCutaway();
113
+ const onReset = () => {
114
+ viewer.resetCutaway();
115
+ sync();
116
+ };
117
+ const onEscape = (event) => {
118
+ if (event.key !== "Escape" || !viewer.cutawayEnabled()) return;
119
+ event.preventDefault();
120
+ disable();
121
+ };
122
+ const onCanvasPointerDown = () => canvas.focus({ preventScroll: true });
123
+
124
+ button.addEventListener("click", onToggle);
125
+ flipButton.addEventListener("click", onFlip);
126
+ resetButton.addEventListener("click", onReset);
127
+ for (const element of [canvas, button, flipButton, resetButton]) {
128
+ element.addEventListener("keydown", onEscape);
129
+ }
130
+ canvas.addEventListener("pointerdown", onCanvasPointerDown);
131
+ sync();
132
+
133
+ return {
134
+ reset: disable,
135
+ detach() {
136
+ if (detached) return;
137
+ detached = true;
138
+ runCleanupSteps([
139
+ () => button.removeEventListener("click", onToggle),
140
+ () => flipButton.removeEventListener("click", onFlip),
141
+ () => resetButton.removeEventListener("click", onReset),
142
+ ...[canvas, button, flipButton, resetButton]
143
+ .map((element) => () => element.removeEventListener("keydown", onEscape)),
144
+ () => canvas.removeEventListener("pointerdown", onCanvasPointerDown),
145
+ () => { if (addedCanvasTabIndex) canvas.removeAttribute("tabindex"); },
146
+ () => { if (addedCanvasLabel) canvas.removeAttribute("aria-label"); },
147
+ () => tooltipBinding?.detach(),
148
+ () => actions.remove(),
149
+ () => restoreAttributes(button, hostButtonAttributes),
150
+ () => { button.disabled = hostButtonDisabled; },
151
+ () => button.classList.toggle("on", hostButtonOn),
152
+ ]);
153
+ },
154
+ };
155
+ }