partforge 0.41.0 → 0.45.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.
Files changed (74) hide show
  1. package/README.md +31 -10
  2. package/bin/cli.js +138 -27
  3. package/docs/AUTHORING-PARTS.md +164 -17
  4. package/docs/ERROR-PATTERNS.md +6 -0
  5. package/package.json +48 -7
  6. package/skills/partforge/SKILL.md +17 -3
  7. package/src/app-embed-test.js +1 -1
  8. package/src/app-hinged-box.js +12 -0
  9. package/src/framework/animation-controls.js +254 -0
  10. package/src/framework/animation.js +271 -0
  11. package/src/framework/app.css +32 -0
  12. package/src/framework/assembly.js +1 -1
  13. package/src/framework/backend-select.js +25 -0
  14. package/src/framework/camera-tween.js +58 -0
  15. package/src/framework/capture-build.js +59 -0
  16. package/src/framework/chrome.css +16 -0
  17. package/src/framework/controls.js +13 -3
  18. package/src/framework/cutaway-gizmo-scene.js +244 -0
  19. package/src/framework/cutaway-gizmo.js +80 -243
  20. package/src/framework/default-view.js +46 -0
  21. package/src/framework/download.js +7 -2
  22. package/src/framework/export-controller.js +13 -2
  23. package/src/framework/geometry/probe.js +3 -22
  24. package/src/framework/jobs.js +30 -40
  25. package/src/framework/lint/finding.js +4 -0
  26. package/src/framework/lint/index.js +7 -3
  27. package/src/framework/lint/rules-animations.js +441 -0
  28. package/src/framework/lint/rules-place.js +76 -0
  29. package/src/framework/lint/rules-schema.js +22 -0
  30. package/src/framework/lint/rules-shape.js +12 -0
  31. package/src/framework/lint/rules-verify.js +2 -2
  32. package/src/framework/mount.js +147 -20
  33. package/src/{testing → framework/oracle}/build.js +1 -1
  34. package/src/{testing → framework/oracle}/bvh.js +1 -1
  35. package/src/{testing → framework/oracle}/measure.js +1 -1
  36. package/src/{testing → framework/oracle}/min-wall.js +1 -1
  37. package/src/{testing → framework/oracle}/verify.js +3 -3
  38. package/src/framework/param-deps.js +1 -1
  39. package/src/framework/part-model.js +48 -0
  40. package/src/framework/pick-request/client.js +11 -3
  41. package/src/framework/pick-request/endpoint.js +60 -0
  42. package/src/framework/pick-request/index.js +6 -0
  43. package/src/framework/pick-request/server.js +222 -34
  44. package/src/framework/pick-request/token-store.js +31 -0
  45. package/src/framework/pose-fast-path.js +12 -1
  46. package/src/framework/pose-probe-core.js +129 -0
  47. package/src/framework/pose-probe.js +7 -123
  48. package/src/framework/regen-loop.js +10 -3
  49. package/src/framework/safe-name.js +26 -0
  50. package/src/framework/verify-metrics.js +4 -4
  51. package/src/framework/view-state.js +25 -21
  52. package/src/framework/view-tabs.js +35 -7
  53. package/src/framework/viewer-controls.js +5 -26
  54. package/src/framework/viewer-lighting.js +8 -1
  55. package/src/framework/viewer.js +139 -20
  56. package/src/framework/worker.js +5 -1
  57. package/src/hinged-box-worker.js +3 -0
  58. package/src/index.js +1 -1
  59. package/src/parts/hinged-box.js +94 -0
  60. package/src/testing/render.js +19 -8
  61. package/src/testing.js +15 -8
  62. package/types/derive.d.ts +14 -0
  63. package/types/geometry.d.ts +117 -0
  64. package/types/index.d.ts +259 -0
  65. package/types/kernel.d.ts +409 -0
  66. package/types/lint.d.ts +85 -0
  67. package/types/part.d.ts +409 -0
  68. package/types/testing.d.ts +362 -0
  69. package/types/worker.d.ts +21 -0
  70. /package/src/{testing → framework/oracle}/assert-dsl.js +0 -0
  71. /package/src/{testing → framework/oracle}/cases.js +0 -0
  72. /package/src/{testing → framework/oracle}/dfm-profiles.js +0 -0
  73. /package/src/{testing → framework/oracle}/gaps.js +0 -0
  74. /package/src/{testing → framework/oracle}/mesh.js +0 -0
@@ -217,6 +217,38 @@ button.action:focus-visible, .adv-toggle:focus-visible, #viewbar button:focus-vi
217
217
  #viewbar .pf-cutaway-actions button { min-width: 44px; padding: 0 6px; }
218
218
  }
219
219
 
220
+ /* animation transport bar (placement: chrome.css's .pf-anim-bar). APPEARANCE
221
+ is ungated for the same reason as #viewbar above. */
222
+ .pf-anim-bar {
223
+ display: flex; align-items: center; gap: 8px;
224
+ padding: 6px 10px;
225
+ background: var(--pf-surface); border: 1px solid var(--pf-border);
226
+ border-radius: var(--pf-radius-control); box-shadow: var(--pf-shadow-float);
227
+ }
228
+ .pf-anim-bar button {
229
+ border: 0; background: transparent; color: var(--pf-muted);
230
+ cursor: pointer; font-size: 13px; padding: 2px 4px;
231
+ }
232
+ .pf-anim-bar button:hover { color: var(--pf-text-2); }
233
+ .pf-anim-title, .pf-anim-pick {
234
+ font-family: var(--pf-mono); font-size: 11px; color: var(--pf-text-2);
235
+ }
236
+ .pf-anim-pick {
237
+ background: var(--pf-input-bg); border: 1px solid var(--pf-border);
238
+ border-radius: var(--pf-radius-control); padding: 3px 6px;
239
+ }
240
+ .pf-anim-step {
241
+ font-family: var(--pf-mono); font-size: 10px; color: var(--pf-muted-2);
242
+ min-width: 90px; text-align: center; white-space: nowrap;
243
+ overflow: hidden; text-overflow: ellipsis;
244
+ }
245
+ .pf-anim-scrub-wrap { position: relative; display: inline-flex; align-items: center; width: 140px; }
246
+ .pf-anim-scrub { width: 100%; accent-color: var(--pf-accent); }
247
+ .pf-anim-tick {
248
+ position: absolute; top: 50%; width: 2px; height: 8px; margin-top: -4px;
249
+ background: var(--pf-muted); pointer-events: none;
250
+ }
251
+
220
252
  /* Legacy id-only markup only: classed markup's viewbar lives inside .pf-stage
221
253
  (bottom-right, see chrome.css's .pf-float-viewbar) so it never meets the
222
254
  top-left floating #panel card. Legacy markup still floats #viewbar top-right
@@ -1,4 +1,4 @@
1
- import { viewSubParts, resolveParams, buildPosed } from "./jobs.js";
1
+ import { viewSubParts, resolveParams, buildPosed } from "./part-model.js";
2
2
 
3
3
  // Collision check for an assembled view: build each sub-part in its display
4
4
  // (assembly) pose and return the pairs whose solid-intersection volume exceeds
@@ -0,0 +1,25 @@
1
+ // Chooses which geometry backend (manifold vs occt) a part should build against.
2
+ // This is framework-level policy, not geometry-kernel plumbing: it knows the full
3
+ // PartDefinition shape (meta.backend, defaults, parts[name].build), unlike
4
+ // everything in geometry/, which is part-agnostic.
5
+ import { OCCT_ONLY_OPS } from "./geometry/kernel.js";
6
+ import { createProbeKernel } from "./geometry/probe.js";
7
+ import { resolveDerived } from "./derive.js";
8
+
9
+ const OCCT_ONLY = new Set(OCCT_ONLY_OPS);
10
+
11
+ export function detectBackend(part, params = {}) {
12
+ if (part.meta?.backend) return part.meta.backend;
13
+ const p = { ...part.defaults, ...params };
14
+ let d = {};
15
+ // A throwing derive must not escape here — this runs on the main thread mid
16
+ // regen (after the busy spinner goes up). Probe with an empty `d`; the worker
17
+ // build hits the same throw and posts a proper error for the UI.
18
+ try { d = resolveDerived(part, p); } catch { /* fall through with d = {} */ }
19
+ const { kernel, used } = createProbeKernel();
20
+ for (const name of Object.keys(part.parts)) {
21
+ try { part.parts[name].build(kernel, p, d); } catch { /* probe miss → capability backstop covers it */ }
22
+ }
23
+ for (const op of used) if (OCCT_ONLY.has(op)) return "occt";
24
+ return "manifold";
25
+ }
@@ -0,0 +1,58 @@
1
+ import * as THREE from "three";
2
+ import { EASINGS } from "./animation.js";
3
+
4
+ // Retargetable orbit-camera tween for animation camera cues: eased spherical
5
+ // interpolation of {position, target} pairs about the (linearly moving) orbit
6
+ // target, shortest-path in azimuth, clamped off the poles so OrbitControls
7
+ // never gimbal-locks on a "top"/"bottom" cue. Pure math, no clock — the viewer
8
+ // feeds dt seconds into update() each frame and applies the returned pose.
9
+ const POLE_EPS = 0.01;
10
+
11
+ function toSpherical(position, target) {
12
+ const off = new THREE.Vector3().fromArray(position).sub(new THREE.Vector3().fromArray(target));
13
+ const sph = new THREE.Spherical().setFromVector3(off);
14
+ sph.phi = Math.min(Math.PI - POLE_EPS, Math.max(POLE_EPS, sph.phi));
15
+ return sph;
16
+ }
17
+
18
+ export function createCameraTween() {
19
+ let tw = null; // { fromSph, toSph, fromTarget, toTarget, duration, elapsed, onComplete }
20
+
21
+ // `from` is always the CALLER's current pose, which is what makes a restart
22
+ // mid-flight retarget smoothly: the new tween begins wherever the camera is.
23
+ function start(from, to, { duration = 0.6, onComplete } = {}) {
24
+ const fromSph = toSpherical(from.position, from.target);
25
+ const toSph = toSpherical(to.position, to.target);
26
+ const d = toSph.theta - fromSph.theta;
27
+ if (d > Math.PI) toSph.theta -= 2 * Math.PI;
28
+ if (d < -Math.PI) toSph.theta += 2 * Math.PI;
29
+ tw = {
30
+ fromSph, toSph,
31
+ fromTarget: new THREE.Vector3().fromArray(from.target),
32
+ toTarget: new THREE.Vector3().fromArray(to.target),
33
+ duration, elapsed: 0, onComplete,
34
+ };
35
+ }
36
+
37
+ function update(dt) {
38
+ if (!tw) return null;
39
+ tw.elapsed += dt;
40
+ const done = tw.elapsed >= tw.duration;
41
+ const u = done ? 1 : EASINGS["ease-in-out"](tw.elapsed / tw.duration);
42
+ const lerp = (a, b) => a + (b - a) * u;
43
+ const sph = new THREE.Spherical(
44
+ lerp(tw.fromSph.radius, tw.toSph.radius),
45
+ lerp(tw.fromSph.phi, tw.toSph.phi),
46
+ lerp(tw.fromSph.theta, tw.toSph.theta),
47
+ );
48
+ const target = tw.fromTarget.clone().lerp(tw.toTarget, u);
49
+ const position = new THREE.Vector3().setFromSpherical(sph).add(target);
50
+ const onComplete = tw.onComplete;
51
+ if (done) tw = null;
52
+ const out = { position: position.toArray(), target: target.toArray(), done };
53
+ if (done) onComplete?.();
54
+ return out;
55
+ }
56
+
57
+ return { start, update, cancel: () => { tw = null; }, isActive: () => !!tw };
58
+ }
@@ -0,0 +1,59 @@
1
+ // Correlated one-shot geometry builds for captureView — a private channel that
2
+ // does NOT go through the regen loop. Same shape as export-controller's pending
3
+ // Map (export-controller.js): allocate a jobId, resolve when the matching
4
+ // reply arrives. Pure — no DOM, no worker; `send` is injected.
5
+ export function createCaptureBuild({ send }) {
6
+ let nextId = 1;
7
+ let disposed = false;
8
+ const pending = new Map(); // jobId -> resolve
9
+
10
+ function request({ subparts, view, params, backend }) {
11
+ // After teardown the workers are gone, so a fresh send would post to a terminated
12
+ // worker (a silent no-op) and its promise would hang forever. Resolve null instead
13
+ // — captureView's documented "disposed runtime resolves null" contract.
14
+ if (disposed) return Promise.resolve(null);
15
+ // String-namespaced ("cap-N") so a capture jobId can never collide with
16
+ // export-controller's numeric jobIds — both share the same worker message
17
+ // space, and exportCtl.handleMessage does a raw pending.get(m.jobId) before
18
+ // checking type, so a colliding id could otherwise settle the wrong promise.
19
+ const jobId = `cap-${nextId++}`;
20
+ return new Promise((resolve) => {
21
+ pending.set(jobId, resolve);
22
+ // cache:true so the worker reuses its per-sub-part geometry memo (the
23
+ // expensive CSG); only the per-view place() + meshing re-run.
24
+ send({ type: "capture-generate", jobId, subparts, view, params, cache: true }, backend);
25
+ });
26
+ }
27
+
28
+ // Returns true iff this message was a reply this controller owns (so the
29
+ // caller — mount.js's onWorkerMessage — can skip it entirely). Keyed on
30
+ // membership in `pending` first: the namespaced jobId guarantees another
31
+ // channel's message never matches, so a hit here is always ours. A failed
32
+ // build (the worker's shared catch posts a generic error/needs-occt, jobId
33
+ // intact) resolves to null rather than leaving the caller hanging forever —
34
+ // captureView treats null as "capture failed, skip".
35
+ function handleMessage(data) {
36
+ const jobId = data?.jobId;
37
+ if (jobId == null || !pending.has(jobId)) return false;
38
+ if (data.type === "capture-meshes") {
39
+ pending.get(jobId)(data.meshes);
40
+ } else if (data.type === "error" || data.type === "needs-occt") {
41
+ pending.get(jobId)(null);
42
+ } else {
43
+ return false;
44
+ }
45
+ pending.delete(jobId);
46
+ return true;
47
+ }
48
+
49
+ // Teardown / worker death: settle every in-flight request to null instead
50
+ // of leaving its promise permanently pending (a caller awaiting captureView
51
+ // across a viewer dispose must still get an answer, even a negative one).
52
+ function dispose() {
53
+ disposed = true;
54
+ for (const resolve of pending.values()) resolve(null);
55
+ pending.clear();
56
+ }
57
+
58
+ return { request, handleMessage, dispose };
59
+ }
@@ -161,6 +161,14 @@
161
161
  .pf-float-tabs { top: 12px; left: 50%; transform: translateX(-50%); }
162
162
  .pf-float-viewbar { bottom: 12px; right: 12px; }
163
163
 
164
+ /* --- animation transport bar (generated by animation-controls.js) ----------
165
+ PLACEMENT ONLY, per the rule above; appearance lives in app.css next to
166
+ #viewbar's, so a host that re-anchors this bar still inherits its chrome. */
167
+ .pf-anim-bar {
168
+ position: absolute; left: 50%; bottom: 14px; transform: translateX(-50%);
169
+ z-index: 15; max-width: calc(100% - 24px);
170
+ }
171
+
164
172
  /* ---- the narrow-layout tab bar ------------------------------------------
165
173
  Created by mobile-tabs.js (no host markup declares it). Hidden by default:
166
174
  below the breakpoint the rail sits beside the viewer and needs no tab, so
@@ -229,6 +237,14 @@
229
237
  }
230
238
  .pf-rail-seam { display: none; }
231
239
  .pf-tabbar { display: flex; }
240
+ /* Lift the transport bar clear of the viewbar. On a phone the bar is nearly
241
+ the full stage width (max-width: 100% - 24px) while #viewbar sits at the
242
+ bottom-right of the same stage, at the same z-index — so at bottom: 14px
243
+ the transport bar paints straight over cutaway/reframe/theme and
244
+ makes them unclickable. The viewbar occupies 12px…56px from the bottom
245
+ (12px offset + 4px padding + 34px button + 4px padding + 2px border);
246
+ 64px clears it with an 8px gap. */
247
+ .pf-anim-bar { bottom: 64px; }
232
248
  }
233
249
 
234
250
 
@@ -48,10 +48,20 @@ export function clampToRange(raw, min, max) {
48
48
  }
49
49
 
50
50
  // --- info glyph + per-panel popover -----------------------------------------
51
+ // Popover top edge: below the glyph when it fits, flipped above when the
52
+ // viewport bottom would clip it (e.g. the animation transport bar's ⓘ, which
53
+ // sits at the bottom of the stage). Pure, for direct unit testing — happy-dom
54
+ // reports zero layout metrics, so the flip can't be exercised via the DOM.
55
+ export function popoverTop({ glyphTop, glyphBottom, popHeight, viewportHeight }) {
56
+ const below = glyphBottom + 6;
57
+ if (below + popHeight <= viewportHeight - 8) return below;
58
+ return Math.max(8, glyphTop - 6 - popHeight);
59
+ }
60
+
51
61
  // One popover element per panel, shared by all its glyphs (only one open at a
52
62
  // time). Document-level dismiss listeners are registered per panel and removed
53
63
  // by panel.dispose().
54
- function createInfoPopover() {
64
+ export function createInfoPopover() {
55
65
  const pop = el("div", "popover");
56
66
  pop.hidden = true;
57
67
  document.body.append(pop);
@@ -78,7 +88,7 @@ function createInfoPopover() {
78
88
  owner = glyph;
79
89
  glyph.setAttribute("aria-expanded", "true");
80
90
  const r = glyph.getBoundingClientRect();
81
- pop.style.top = `${r.bottom + 6}px`;
91
+ pop.style.top = `${popoverTop({ glyphTop: r.top, glyphBottom: r.bottom, popHeight: pop.offsetHeight, viewportHeight: window.innerHeight })}px`;
82
92
  pop.style.left = `${Math.max(8, r.left - 8)}px`;
83
93
  },
84
94
  dispose() {
@@ -91,7 +101,7 @@ function createInfoPopover() {
91
101
 
92
102
  // Append a focusable ⓘ glyph to `container` that toggles the panel's shared
93
103
  // popover with `description` (Markdown). No-op when description is empty.
94
- function attachInfo(container, description, info) {
104
+ export function attachInfo(container, description, info) {
95
105
  if (typeof description !== "string" || !description.trim()) return;
96
106
  const glyph = document.createElement("button");
97
107
  glyph.type = "button";
@@ -0,0 +1,244 @@
1
+ import * as THREE from "three";
2
+ import { CUTAWAY_OVERLAY_RENDER_ORDER } from "./cutaway-render.js";
3
+
4
+ const GIZMO_RENDER_ORDER = CUTAWAY_OVERLAY_RENDER_ORDER + 1;
5
+ const HANDLE_HOVER_THICKNESS = 1.6;
6
+
7
+ /**
8
+ * Builds the cutaway gizmo's three.js scene graph: the ghost plane, the
9
+ * translate arrow, the two rotation arcs, and the invisible hit proxies that
10
+ * stand in for them while picking.
11
+ *
12
+ * Pure construction - the objects come back detached, so mounting them into a
13
+ * scene, posing them, and reacting to the pointer all stay in
14
+ * `createCutawayGizmo`.
15
+ *
16
+ * `theme` is one palette out of the gizmo's theme table. Colors are baked in
17
+ * here rather than written as literals so the very first rendered frame
18
+ * already agrees with the palette that later appearance updates re-derive.
19
+ */
20
+ export function buildGizmoScene(theme) {
21
+ const geometries = new Set();
22
+ const materials = new Set();
23
+
24
+ const group = new THREE.Group();
25
+
26
+ const fill = new THREE.Mesh(
27
+ new THREE.PlaneGeometry(1, 1),
28
+ new THREE.MeshBasicMaterial({
29
+ color: theme.fill,
30
+ opacity: 0.18,
31
+ transparent: true,
32
+ depthTest: true,
33
+ depthWrite: false,
34
+ side: THREE.DoubleSide,
35
+ }),
36
+ );
37
+ geometries.add(fill.geometry);
38
+ materials.add(fill.material);
39
+
40
+ const borderGeometry = new THREE.BufferGeometry().setFromPoints([
41
+ new THREE.Vector3(-0.5, -0.5, 0),
42
+ new THREE.Vector3(0.5, -0.5, 0),
43
+ new THREE.Vector3(0.5, 0.5, 0),
44
+ new THREE.Vector3(-0.5, 0.5, 0),
45
+ ]);
46
+ const borderMaterial = new THREE.LineBasicMaterial({
47
+ color: theme.border,
48
+ opacity: 1,
49
+ transparent: true,
50
+ depthTest: false,
51
+ depthWrite: false,
52
+ });
53
+ const border = new THREE.LineLoop(borderGeometry, borderMaterial);
54
+ border.renderOrder = GIZMO_RENDER_ORDER;
55
+ geometries.add(borderGeometry);
56
+ materials.add(borderMaterial);
57
+
58
+ const handleRoot = new THREE.Group();
59
+ const translateVisualRoot = new THREE.Group();
60
+ const arcRoot = new THREE.Group();
61
+ const translateMaterial = new THREE.MeshBasicMaterial({
62
+ color: theme.translate,
63
+ transparent: true,
64
+ depthTest: true,
65
+ depthWrite: true,
66
+ });
67
+ const rotateXMaterial = new THREE.MeshBasicMaterial({
68
+ color: theme.rotateX,
69
+ transparent: true,
70
+ depthTest: true,
71
+ depthWrite: true,
72
+ });
73
+ const rotateYMaterial = new THREE.MeshBasicMaterial({
74
+ color: theme.rotateY,
75
+ transparent: true,
76
+ depthTest: true,
77
+ depthWrite: true,
78
+ });
79
+ materials.add(translateMaterial);
80
+ materials.add(rotateXMaterial);
81
+ materials.add(rotateYMaterial);
82
+
83
+ const shaftGeometry = new THREE.CylinderGeometry(0.025, 0.025, 0.58, 12);
84
+ const shaftHoverGeometry = new THREE.CylinderGeometry(
85
+ 0.025 * HANDLE_HOVER_THICKNESS,
86
+ 0.025 * HANDLE_HOVER_THICKNESS,
87
+ 0.58,
88
+ 12,
89
+ );
90
+ const shaft = new THREE.Mesh(shaftGeometry, translateMaterial);
91
+ shaft.rotation.x = Math.PI / 2;
92
+ shaft.position.z = 0.29;
93
+ const coneGeometry = new THREE.ConeGeometry(0.075, 0.2, 16);
94
+ const coneHoverGeometry = new THREE.ConeGeometry(
95
+ 0.075 * HANDLE_HOVER_THICKNESS,
96
+ 0.2,
97
+ 16,
98
+ );
99
+ const cone = new THREE.Mesh(coneGeometry, translateMaterial);
100
+ cone.rotation.x = Math.PI / 2;
101
+ cone.position.z = 0.68;
102
+ geometries.add(shaftGeometry);
103
+ geometries.add(shaftHoverGeometry);
104
+ geometries.add(coneGeometry);
105
+ geometries.add(coneHoverGeometry);
106
+
107
+ const ringXGeometry = new THREE.TorusGeometry(
108
+ 0.42,
109
+ 0.015,
110
+ 8,
111
+ 64,
112
+ Math.PI,
113
+ );
114
+ const ringX = new THREE.Mesh(ringXGeometry, rotateXMaterial);
115
+ const ringXHoverGeometry = new THREE.TorusGeometry(
116
+ 0.42,
117
+ 0.015 * HANDLE_HOVER_THICKNESS,
118
+ 8,
119
+ 64,
120
+ Math.PI,
121
+ );
122
+ ringX.quaternion
123
+ .setFromAxisAngle(new THREE.Vector3(1, 0, 0), -Math.PI / 2)
124
+ .multiply(new THREE.Quaternion().setFromAxisAngle(
125
+ new THREE.Vector3(0, 1, 0),
126
+ Math.PI / 2,
127
+ ));
128
+ const ringYGeometry = new THREE.TorusGeometry(
129
+ 0.42,
130
+ 0.015,
131
+ 8,
132
+ 64,
133
+ Math.PI,
134
+ );
135
+ const ringY = new THREE.Mesh(ringYGeometry, rotateYMaterial);
136
+ const ringYHoverGeometry = new THREE.TorusGeometry(
137
+ 0.42,
138
+ 0.015 * HANDLE_HOVER_THICKNESS,
139
+ 8,
140
+ 64,
141
+ Math.PI,
142
+ );
143
+ ringY.rotation.x = -Math.PI / 2;
144
+ geometries.add(ringXGeometry);
145
+ geometries.add(ringXHoverGeometry);
146
+ geometries.add(ringYGeometry);
147
+ geometries.add(ringYHoverGeometry);
148
+
149
+ const hitMaterial = new THREE.MeshBasicMaterial({
150
+ color: 0xffffff,
151
+ opacity: 0,
152
+ transparent: true,
153
+ depthWrite: false,
154
+ });
155
+ materials.add(hitMaterial);
156
+ const translateHitGeometry = new THREE.CylinderGeometry(0.1, 0.1, 0.95, 10);
157
+ const translateHit = new THREE.Mesh(translateHitGeometry, hitMaterial);
158
+ translateHit.rotation.x = Math.PI / 2;
159
+ translateHit.position.z = 0.38;
160
+ translateHit.userData.cutawayHandle = "translate";
161
+ const rotateXHitGeometry = new THREE.TorusGeometry(
162
+ 0.42,
163
+ 0.12,
164
+ 8,
165
+ 48,
166
+ Math.PI,
167
+ );
168
+ const rotateXHit = new THREE.Mesh(rotateXHitGeometry, hitMaterial);
169
+ rotateXHit.quaternion.copy(ringX.quaternion);
170
+ rotateXHit.userData.cutawayHandle = "rotate-x";
171
+ const rotateYHitGeometry = new THREE.TorusGeometry(
172
+ 0.42,
173
+ 0.12,
174
+ 8,
175
+ 48,
176
+ Math.PI,
177
+ );
178
+ const rotateYHit = new THREE.Mesh(rotateYHitGeometry, hitMaterial);
179
+ rotateYHit.quaternion.copy(ringY.quaternion);
180
+ rotateYHit.userData.cutawayHandle = "rotate-y";
181
+ geometries.add(translateHitGeometry);
182
+ geometries.add(rotateXHitGeometry);
183
+ geometries.add(rotateYHitGeometry);
184
+
185
+ translateVisualRoot.add(shaft, cone);
186
+ arcRoot.add(ringX, ringY, rotateXHit, rotateYHit);
187
+ handleRoot.add(translateVisualRoot, translateHit, arcRoot);
188
+ group.add(fill, border);
189
+
190
+ const handles = {
191
+ translate: translateHit,
192
+ rotateX: rotateXHit,
193
+ rotateY: rotateYHit,
194
+ };
195
+ const handleVisuals = {
196
+ translate: translateVisualRoot,
197
+ rotateX: ringX,
198
+ rotateY: ringY,
199
+ };
200
+ const handleAppearance = {
201
+ translate: {
202
+ visual: translateVisualRoot,
203
+ material: translateMaterial,
204
+ geometryPairs: [
205
+ { mesh: shaft, normal: shaftGeometry, hovered: shaftHoverGeometry },
206
+ { mesh: cone, normal: coneGeometry, hovered: coneHoverGeometry },
207
+ ],
208
+ },
209
+ "rotate-x": {
210
+ visual: ringX,
211
+ material: rotateXMaterial,
212
+ geometryPairs: [
213
+ { mesh: ringX, normal: ringXGeometry, hovered: ringXHoverGeometry },
214
+ ],
215
+ },
216
+ "rotate-y": {
217
+ visual: ringY,
218
+ material: rotateYMaterial,
219
+ geometryPairs: [
220
+ { mesh: ringY, normal: ringYGeometry, hovered: ringYHoverGeometry },
221
+ ],
222
+ },
223
+ };
224
+
225
+ // Every geometry and material above - including the thickened hover variants
226
+ // that are swapped in and out of the meshes - is owned by this scene and
227
+ // nothing else. The caller disposes it exactly once, at teardown.
228
+ function dispose() {
229
+ for (const geometry of geometries) geometry.dispose();
230
+ for (const material of materials) material.dispose();
231
+ }
232
+
233
+ return {
234
+ group,
235
+ fill,
236
+ border,
237
+ handleRoot,
238
+ arcRoot,
239
+ handles,
240
+ handleVisuals,
241
+ handleAppearance,
242
+ dispose,
243
+ };
244
+ }