partforge 0.17.0 → 0.20.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 (43) hide show
  1. package/docs/AUTHORING-PARTS.md +121 -1
  2. package/docs/ERROR-PATTERNS.md +21 -0
  3. package/package.json +3 -1
  4. package/src/app-bracket.js +9 -0
  5. package/src/app-nameplate.js +9 -0
  6. package/src/app-text-smoke.js +10 -0
  7. package/src/bracket-worker.js +3 -0
  8. package/src/framework/app.css +23 -6
  9. package/src/framework/cutaway-controls.js +155 -0
  10. package/src/framework/cutaway-gizmo.js +686 -0
  11. package/src/framework/cutaway-math.js +53 -0
  12. package/src/framework/cutaway-render.js +338 -0
  13. package/src/framework/cutaway.js +469 -0
  14. package/src/framework/fonts.js +36 -0
  15. package/src/framework/geometry/curve-fill.js +86 -0
  16. package/src/framework/geometry/fonts/Roboto-LICENSE.txt +93 -0
  17. package/src/framework/geometry/fonts/Roboto-Regular.ttf +0 -0
  18. package/src/framework/geometry/fonts/default-font.js +3 -0
  19. package/src/framework/geometry/kernel-front.js +57 -1
  20. package/src/framework/geometry/kernel.js +20 -2
  21. package/src/framework/geometry/manifold-backend.js +78 -7
  22. package/src/framework/geometry/occt-backend.js +95 -5
  23. package/src/framework/geometry/op-options.js +4 -0
  24. package/src/framework/geometry/shape2d-regions.js +134 -0
  25. package/src/framework/geometry/shape2d-sugar.js +11 -0
  26. package/src/framework/geometry/text2d.js +98 -0
  27. package/src/framework/geometry-service.js +21 -2
  28. package/src/framework/jobs.js +9 -0
  29. package/src/framework/mount.js +278 -223
  30. package/src/framework/selection/hover.js +102 -36
  31. package/src/framework/selection/raycast.js +4 -1
  32. package/src/framework/tooltip.js +282 -0
  33. package/src/framework/viewer-controls.js +25 -2
  34. package/src/framework/viewer-lighting.js +13 -0
  35. package/src/framework/viewer.js +83 -10
  36. package/src/nameplate-worker.js +3 -0
  37. package/src/parts/bracket.js +76 -0
  38. package/src/parts/demo.js +1 -1
  39. package/src/parts/nameplate.js +67 -0
  40. package/src/parts/text-smoke.js +21 -0
  41. package/src/testing/manifold.js +6 -2
  42. package/src/testing/occt.js +6 -2
  43. package/src/text-smoke-worker.js +3 -0
@@ -0,0 +1,686 @@
1
+ import * as THREE from "three";
2
+ import {
3
+ axisParameterFromRay,
4
+ signedAngleAroundAxis,
5
+ } from "./cutaway-math.js";
6
+ import { CUTAWAY_OVERLAY_RENDER_ORDER } from "./cutaway-render.js";
7
+
8
+ const THEMES = {
9
+ dark: {
10
+ fill: 0x65bff5,
11
+ border: 0xa8dcff,
12
+ translate: 0x36d399,
13
+ rotateX: 0xff6b7a,
14
+ rotateY: 0x5aa9ff,
15
+ },
16
+ light: {
17
+ fill: 0x1479b8,
18
+ border: 0x075985,
19
+ translate: 0x087f5b,
20
+ rotateX: 0xc92a3b,
21
+ rotateY: 0x1769aa,
22
+ },
23
+ };
24
+
25
+ const TRANSLATION_SCREEN_ALIGNMENT = 0.9;
26
+ const ROTATION_SCREEN_ALIGNMENT = 0.15;
27
+ // A 120 px perpendicular drag rotates the plane by 90 degrees.
28
+ const SCREEN_ROTATION_RADIANS_PER_PIXEL = Math.PI / 240;
29
+ const SCREEN_AXIS_EPSILON_SQ = 1e-8;
30
+ // Reserve the visually shared center for the end-on translation handle.
31
+ const TRANSLATE_CENTER_RADIUS_PX = 22;
32
+ const GIZMO_RENDER_ORDER = CUTAWAY_OVERLAY_RENDER_ORDER + 1;
33
+ const GHOST_OFFSET_FACTOR = 0.001;
34
+ const MIN_GHOST_OFFSET = 0.01;
35
+ const MAX_GHOST_OFFSET = 0.25;
36
+ const HANDLE_HOVER_THICKNESS = 1.6;
37
+ const HANDLE_HOVER_WHITE_MIX = 0.28;
38
+ const WHITE = new THREE.Color(0xffffff);
39
+
40
+ export function createCutawayGizmo({
41
+ scene,
42
+ overlayScene,
43
+ camera,
44
+ domElement,
45
+ orbitControls,
46
+ onPoseChange = () => {},
47
+ onActivity = () => {},
48
+ onHandleHoverChange = () => {},
49
+ pickHandle,
50
+ }) {
51
+ const group = new THREE.Group();
52
+ const geometries = new Set();
53
+ const materials = new Set();
54
+
55
+ const fill = new THREE.Mesh(
56
+ new THREE.PlaneGeometry(1, 1),
57
+ new THREE.MeshBasicMaterial({
58
+ color: 0x65bff5,
59
+ opacity: 0.18,
60
+ transparent: true,
61
+ depthTest: true,
62
+ depthWrite: false,
63
+ side: THREE.DoubleSide,
64
+ }),
65
+ );
66
+ geometries.add(fill.geometry);
67
+ materials.add(fill.material);
68
+
69
+ const borderGeometry = new THREE.BufferGeometry().setFromPoints([
70
+ new THREE.Vector3(-0.5, -0.5, 0),
71
+ new THREE.Vector3(0.5, -0.5, 0),
72
+ new THREE.Vector3(0.5, 0.5, 0),
73
+ new THREE.Vector3(-0.5, 0.5, 0),
74
+ ]);
75
+ const borderMaterial = new THREE.LineBasicMaterial({
76
+ color: 0xa8dcff,
77
+ opacity: 1,
78
+ transparent: true,
79
+ depthTest: false,
80
+ depthWrite: false,
81
+ });
82
+ const border = new THREE.LineLoop(borderGeometry, borderMaterial);
83
+ border.renderOrder = GIZMO_RENDER_ORDER;
84
+ geometries.add(borderGeometry);
85
+ materials.add(borderMaterial);
86
+
87
+ const handleRoot = new THREE.Group();
88
+ const translateVisualRoot = new THREE.Group();
89
+ const arcRoot = new THREE.Group();
90
+ const translateMaterial = new THREE.MeshBasicMaterial({
91
+ color: 0x36d399,
92
+ transparent: true,
93
+ depthTest: true,
94
+ depthWrite: true,
95
+ });
96
+ const rotateXMaterial = new THREE.MeshBasicMaterial({
97
+ color: 0xff6b7a,
98
+ transparent: true,
99
+ depthTest: true,
100
+ depthWrite: true,
101
+ });
102
+ const rotateYMaterial = new THREE.MeshBasicMaterial({
103
+ color: 0x5aa9ff,
104
+ transparent: true,
105
+ depthTest: true,
106
+ depthWrite: true,
107
+ });
108
+ materials.add(translateMaterial);
109
+ materials.add(rotateXMaterial);
110
+ materials.add(rotateYMaterial);
111
+
112
+ const shaftGeometry = new THREE.CylinderGeometry(0.025, 0.025, 0.58, 12);
113
+ const shaftHoverGeometry = new THREE.CylinderGeometry(
114
+ 0.025 * HANDLE_HOVER_THICKNESS,
115
+ 0.025 * HANDLE_HOVER_THICKNESS,
116
+ 0.58,
117
+ 12,
118
+ );
119
+ const shaft = new THREE.Mesh(shaftGeometry, translateMaterial);
120
+ shaft.rotation.x = Math.PI / 2;
121
+ shaft.position.z = 0.29;
122
+ const coneGeometry = new THREE.ConeGeometry(0.075, 0.2, 16);
123
+ const coneHoverGeometry = new THREE.ConeGeometry(
124
+ 0.075 * HANDLE_HOVER_THICKNESS,
125
+ 0.2,
126
+ 16,
127
+ );
128
+ const cone = new THREE.Mesh(coneGeometry, translateMaterial);
129
+ cone.rotation.x = Math.PI / 2;
130
+ cone.position.z = 0.68;
131
+ geometries.add(shaftGeometry);
132
+ geometries.add(shaftHoverGeometry);
133
+ geometries.add(coneGeometry);
134
+ geometries.add(coneHoverGeometry);
135
+
136
+ const ringXGeometry = new THREE.TorusGeometry(
137
+ 0.42,
138
+ 0.015,
139
+ 8,
140
+ 64,
141
+ Math.PI,
142
+ );
143
+ const ringX = new THREE.Mesh(ringXGeometry, rotateXMaterial);
144
+ const ringXHoverGeometry = new THREE.TorusGeometry(
145
+ 0.42,
146
+ 0.015 * HANDLE_HOVER_THICKNESS,
147
+ 8,
148
+ 64,
149
+ Math.PI,
150
+ );
151
+ ringX.quaternion
152
+ .setFromAxisAngle(new THREE.Vector3(1, 0, 0), -Math.PI / 2)
153
+ .multiply(new THREE.Quaternion().setFromAxisAngle(
154
+ new THREE.Vector3(0, 1, 0),
155
+ Math.PI / 2,
156
+ ));
157
+ const ringYGeometry = new THREE.TorusGeometry(
158
+ 0.42,
159
+ 0.015,
160
+ 8,
161
+ 64,
162
+ Math.PI,
163
+ );
164
+ const ringY = new THREE.Mesh(ringYGeometry, rotateYMaterial);
165
+ const ringYHoverGeometry = new THREE.TorusGeometry(
166
+ 0.42,
167
+ 0.015 * HANDLE_HOVER_THICKNESS,
168
+ 8,
169
+ 64,
170
+ Math.PI,
171
+ );
172
+ ringY.rotation.x = -Math.PI / 2;
173
+ geometries.add(ringXGeometry);
174
+ geometries.add(ringXHoverGeometry);
175
+ geometries.add(ringYGeometry);
176
+ geometries.add(ringYHoverGeometry);
177
+
178
+ const hitMaterial = new THREE.MeshBasicMaterial({
179
+ color: 0xffffff,
180
+ opacity: 0,
181
+ transparent: true,
182
+ depthWrite: false,
183
+ });
184
+ materials.add(hitMaterial);
185
+ const translateHitGeometry = new THREE.CylinderGeometry(0.1, 0.1, 0.95, 10);
186
+ const translateHit = new THREE.Mesh(translateHitGeometry, hitMaterial);
187
+ translateHit.rotation.x = Math.PI / 2;
188
+ translateHit.position.z = 0.38;
189
+ translateHit.userData.cutawayHandle = "translate";
190
+ const rotateXHitGeometry = new THREE.TorusGeometry(
191
+ 0.42,
192
+ 0.12,
193
+ 8,
194
+ 48,
195
+ Math.PI,
196
+ );
197
+ const rotateXHit = new THREE.Mesh(rotateXHitGeometry, hitMaterial);
198
+ rotateXHit.quaternion.copy(ringX.quaternion);
199
+ rotateXHit.userData.cutawayHandle = "rotate-x";
200
+ const rotateYHitGeometry = new THREE.TorusGeometry(
201
+ 0.42,
202
+ 0.12,
203
+ 8,
204
+ 48,
205
+ Math.PI,
206
+ );
207
+ const rotateYHit = new THREE.Mesh(rotateYHitGeometry, hitMaterial);
208
+ rotateYHit.quaternion.copy(ringY.quaternion);
209
+ rotateYHit.userData.cutawayHandle = "rotate-y";
210
+ geometries.add(translateHitGeometry);
211
+ geometries.add(rotateXHitGeometry);
212
+ geometries.add(rotateYHitGeometry);
213
+
214
+ translateVisualRoot.add(shaft, cone);
215
+ arcRoot.add(ringX, ringY, rotateXHit, rotateYHit);
216
+ handleRoot.add(translateVisualRoot, translateHit, arcRoot);
217
+ group.add(fill, border);
218
+ scene.add(group);
219
+ overlayScene.add(handleRoot);
220
+
221
+ const handles = {
222
+ translate: translateHit,
223
+ rotateX: rotateXHit,
224
+ rotateY: rotateYHit,
225
+ };
226
+ const handleVisuals = {
227
+ translate: translateVisualRoot,
228
+ rotateX: ringX,
229
+ rotateY: ringY,
230
+ };
231
+ const handleAppearance = {
232
+ translate: {
233
+ visual: translateVisualRoot,
234
+ material: translateMaterial,
235
+ geometryPairs: [
236
+ { mesh: shaft, normal: shaftGeometry, hovered: shaftHoverGeometry },
237
+ { mesh: cone, normal: coneGeometry, hovered: coneHoverGeometry },
238
+ ],
239
+ },
240
+ "rotate-x": {
241
+ visual: ringX,
242
+ material: rotateXMaterial,
243
+ geometryPairs: [
244
+ { mesh: ringX, normal: ringXGeometry, hovered: ringXHoverGeometry },
245
+ ],
246
+ },
247
+ "rotate-y": {
248
+ visual: ringY,
249
+ material: rotateYMaterial,
250
+ geometryPairs: [
251
+ { mesh: ringY, normal: ringYGeometry, hovered: ringYHoverGeometry },
252
+ ],
253
+ },
254
+ };
255
+
256
+ let disposed = false;
257
+ let poseSize = 1;
258
+ let flipped = false;
259
+ let drag = null;
260
+ let hoveredHandle = null;
261
+ let activeAppearance = true;
262
+ let themeMode = "dark";
263
+ const raycaster = new THREE.Raycaster();
264
+ const hitProxies = Object.values(handles);
265
+
266
+ function rayFromEvent(event) {
267
+ const rect = domElement.getBoundingClientRect();
268
+ if (rect.width <= 0 || rect.height <= 0) return null;
269
+ raycaster.setFromCamera({
270
+ x: ((event.clientX - rect.left) / rect.width) * 2 - 1,
271
+ y: -((event.clientY - rect.top) / rect.height) * 2 + 1,
272
+ }, camera);
273
+ return raycaster.ray;
274
+ }
275
+
276
+ function normalizeHandle(handle) {
277
+ return handle === "translate" || handle === "rotate-x" || handle === "rotate-y"
278
+ ? handle
279
+ : null;
280
+ }
281
+
282
+ function resolveHandle(picked) {
283
+ if (typeof picked === "string") return normalizeHandle(picked);
284
+ const object = picked?.object ?? picked;
285
+ return normalizeHandle(object?.userData?.cutawayHandle);
286
+ }
287
+
288
+ function pick(event, ray) {
289
+ if (pickHandle) return resolveHandle(pickHandle(event, handles, ray));
290
+ const center = projectToClient(group.position);
291
+ if (center) {
292
+ const dx = event.clientX - center.x;
293
+ const dy = event.clientY - center.y;
294
+ if (Number.isFinite(dx) && Number.isFinite(dy)
295
+ && Math.hypot(dx, dy) <= TRANSLATE_CENTER_RADIUS_PX) {
296
+ return "translate";
297
+ }
298
+ }
299
+ handleRoot.updateWorldMatrix(true, true);
300
+ const intersection = raycaster.intersectObjects(hitProxies, false)[0];
301
+ return resolveHandle(intersection);
302
+ }
303
+
304
+ function safeCapture(pointerId) {
305
+ try {
306
+ domElement.setPointerCapture?.(pointerId);
307
+ } catch {
308
+ // Capture can fail when the browser has already ended the pointer.
309
+ }
310
+ }
311
+
312
+ function safeRelease(pointerId) {
313
+ try {
314
+ domElement.releasePointerCapture?.(pointerId);
315
+ } catch {
316
+ // Releasing a pointer that was already lost is harmless.
317
+ }
318
+ }
319
+
320
+ function endDrag() {
321
+ if (!drag) return;
322
+ const ending = drag;
323
+ drag = null;
324
+ if (orbitControls) orbitControls.enabled = ending.orbitEnabled;
325
+ safeRelease(ending.pointerId);
326
+ }
327
+
328
+ function updateAppearance() {
329
+ const theme = THEMES[themeMode] ?? THEMES.dark;
330
+ fill.material.color.set(theme.fill);
331
+ fill.material.opacity = activeAppearance ? 0.18 : 0.055;
332
+ borderMaterial.color.set(theme.border);
333
+ borderMaterial.opacity = activeAppearance ? 1 : 0.72;
334
+
335
+ for (const [handle, { visual, material, geometryPairs }] of Object.entries(handleAppearance)) {
336
+ const hovered = handle === hoveredHandle;
337
+ const themeKey = handle === "rotate-x"
338
+ ? "rotateX"
339
+ : handle === "rotate-y"
340
+ ? "rotateY"
341
+ : "translate";
342
+ material.color.set(theme[themeKey]);
343
+ if (hovered) material.color.lerp(WHITE, HANDLE_HOVER_WHITE_MIX);
344
+ material.transparent = true;
345
+ material.opacity = hovered ? 1 : activeAppearance ? 1 : 0.48;
346
+ visual.scale.setScalar(1);
347
+ for (const pair of geometryPairs) {
348
+ pair.mesh.geometry = hovered ? pair.hovered : pair.normal;
349
+ }
350
+ }
351
+ }
352
+
353
+ function setHoveredHandle(handle) {
354
+ const normalized = normalizeHandle(handle);
355
+ if (normalized === hoveredHandle) return;
356
+ hoveredHandle = normalized;
357
+ updateAppearance();
358
+ onHandleHoverChange(normalized);
359
+ }
360
+
361
+ function notifyPose() {
362
+ onPoseChange({
363
+ position: group.position.clone(),
364
+ quaternion: group.quaternion.clone(),
365
+ size: poseSize,
366
+ });
367
+ }
368
+
369
+ function syncHandleTransform() {
370
+ handleRoot.position.copy(group.position);
371
+ handleRoot.quaternion.copy(group.quaternion);
372
+ }
373
+
374
+ function viewDirectionAt(position) {
375
+ if (camera.isPerspectiveCamera) {
376
+ const cameraPosition = camera.getWorldPosition(new THREE.Vector3());
377
+ const direction = position.clone().sub(cameraPosition);
378
+ if (direction.lengthSq() > 1e-12) return direction.normalize();
379
+ }
380
+ return camera.getWorldDirection(new THREE.Vector3()).normalize();
381
+ }
382
+
383
+ function projectToClient(point) {
384
+ const rect = domElement.getBoundingClientRect();
385
+ if (rect.width <= 0 || rect.height <= 0) return null;
386
+ const projected = point.clone().project(camera);
387
+ if (![projected.x, projected.y, projected.z].every(Number.isFinite)) return null;
388
+ if (projected.z < -1 || projected.z > 1) return null;
389
+ const client = new THREE.Vector2(
390
+ rect.left + (projected.x + 1) * 0.5 * rect.width,
391
+ rect.top + (1 - projected.y) * 0.5 * rect.height,
392
+ );
393
+ return Number.isFinite(client.x) && Number.isFinite(client.y) ? client : null;
394
+ }
395
+
396
+ function screenRotationDirection(center, axis) {
397
+ const centerClient = projectToClient(center);
398
+ const axisClient = projectToClient(center.clone().add(axis));
399
+ if (!centerClient || !axisClient) return null;
400
+ const screenAxis = axisClient.sub(centerClient);
401
+ if (screenAxis.lengthSq() < SCREEN_AXIS_EPSILON_SQ) return null;
402
+ screenAxis.normalize();
403
+ return new THREE.Vector2(-screenAxis.y, screenAxis.x);
404
+ }
405
+
406
+ function onPointerDown(event) {
407
+ if (disposed || drag || !group.visible || (event.button != null && event.button !== 0)) return;
408
+ const ray = rayFromEvent(event);
409
+ if (!ray) return;
410
+ const handle = pick(event, ray);
411
+ if (!handle) return;
412
+
413
+ const startPosition = group.position.clone();
414
+ const startQuaternion = group.quaternion.clone();
415
+ const localAxis = handle === "rotate-x"
416
+ ? new THREE.Vector3(1, 0, 0)
417
+ : handle === "rotate-y"
418
+ ? new THREE.Vector3(0, 1, 0)
419
+ : new THREE.Vector3(0, 0, 1);
420
+ const axis = localAxis.applyQuaternion(startQuaternion).normalize();
421
+ const viewDirection = viewDirectionAt(startPosition);
422
+ const alignment = Math.abs(axis.dot(viewDirection));
423
+ const nextDrag = {
424
+ pointerId: event.pointerId,
425
+ handle,
426
+ orbitEnabled: orbitControls?.enabled,
427
+ startPosition,
428
+ startQuaternion,
429
+ startClientX: event.clientX,
430
+ startClientY: event.clientY,
431
+ unitsPerPixel: worldUnitsPerPixelAt(startPosition),
432
+ axis,
433
+ mode: null,
434
+ startParameter: null,
435
+ rotationPlane: null,
436
+ startRadial: null,
437
+ screenRotationDirection: null,
438
+ };
439
+
440
+ if (handle === "translate") {
441
+ nextDrag.startParameter = axisParameterFromRay(ray, startPosition, axis);
442
+ nextDrag.mode = alignment > TRANSLATION_SCREEN_ALIGNMENT
443
+ || nextDrag.startParameter == null
444
+ ? "screen-translate"
445
+ : "axis-translate";
446
+ } else {
447
+ const useScreenRotation = alignment < ROTATION_SCREEN_ALIGNMENT;
448
+ if (!useScreenRotation) {
449
+ const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(axis, startPosition);
450
+ const point = ray.intersectPlane(plane, new THREE.Vector3());
451
+ const radial = point?.sub(startPosition);
452
+ if (radial && radial.lengthSq() >= 1e-12) {
453
+ nextDrag.mode = "plane-rotate";
454
+ nextDrag.rotationPlane = plane;
455
+ nextDrag.startRadial = radial.normalize();
456
+ }
457
+ }
458
+ if (nextDrag.mode !== "plane-rotate") {
459
+ nextDrag.screenRotationDirection = screenRotationDirection(startPosition, axis);
460
+ if (!nextDrag.screenRotationDirection) return;
461
+ nextDrag.mode = "screen-rotate";
462
+ }
463
+ }
464
+
465
+ setHoveredHandle(handle);
466
+ onActivity();
467
+ drag = nextDrag;
468
+ if (orbitControls) orbitControls.enabled = false;
469
+ safeCapture(event.pointerId);
470
+ event.preventDefault();
471
+ }
472
+
473
+ function onPointerMove(event) {
474
+ if (!disposed && group.visible) onActivity();
475
+ if (!drag) {
476
+ if (disposed || !group.visible || event.pointerType === "touch") return;
477
+ const ray = rayFromEvent(event);
478
+ if (!ray) return;
479
+ setHoveredHandle(pick(event, ray));
480
+ return;
481
+ }
482
+ if (event.pointerId !== drag.pointerId) return;
483
+ const ray = rayFromEvent(event);
484
+ if (!ray) return;
485
+
486
+ if (drag.handle === "translate") {
487
+ let delta;
488
+ if (drag.mode === "screen-translate") {
489
+ delta = (drag.startClientY - event.clientY) * drag.unitsPerPixel;
490
+ } else {
491
+ const parameter = axisParameterFromRay(ray, drag.startPosition, drag.axis);
492
+ if (parameter == null) return;
493
+ delta = parameter - drag.startParameter;
494
+ }
495
+ if (!Number.isFinite(delta)) return;
496
+ group.position.copy(drag.startPosition).addScaledVector(drag.axis, delta);
497
+ group.quaternion.copy(drag.startQuaternion);
498
+ syncHandleTransform();
499
+ notifyPose();
500
+ return;
501
+ }
502
+
503
+ if (drag.mode === "screen-rotate") {
504
+ const pointerDelta = new THREE.Vector2(
505
+ event.clientX - drag.startClientX,
506
+ event.clientY - drag.startClientY,
507
+ );
508
+ const angle = pointerDelta.dot(drag.screenRotationDirection)
509
+ * SCREEN_ROTATION_RADIANS_PER_PIXEL;
510
+ if (!Number.isFinite(angle)) return;
511
+ const delta = new THREE.Quaternion().setFromAxisAngle(drag.axis, angle);
512
+ group.quaternion.copy(delta.multiply(drag.startQuaternion)).normalize();
513
+ group.position.copy(drag.startPosition);
514
+ syncHandleTransform();
515
+ notifyPose();
516
+ return;
517
+ }
518
+
519
+ const point = ray.intersectPlane(drag.rotationPlane, new THREE.Vector3());
520
+ if (!point) return;
521
+ const radial = point.sub(drag.startPosition);
522
+ if (radial.lengthSq() < 1e-12) return;
523
+ radial.normalize();
524
+ const angle = signedAngleAroundAxis(drag.startRadial, radial, drag.axis);
525
+ if (!Number.isFinite(angle)) return;
526
+ const delta = new THREE.Quaternion().setFromAxisAngle(drag.axis, angle);
527
+ group.quaternion.copy(delta.multiply(drag.startQuaternion)).normalize();
528
+ group.position.copy(drag.startPosition);
529
+ syncHandleTransform();
530
+ notifyPose();
531
+ }
532
+
533
+ function onPointerUp(event) {
534
+ if (!drag || (event.pointerId != null && event.pointerId !== drag.pointerId)) return;
535
+ endDrag();
536
+ }
537
+
538
+ function onPointerCancel(event) {
539
+ if (drag && event.pointerId != null && event.pointerId !== drag.pointerId) return;
540
+ endDrag();
541
+ setHoveredHandle(null);
542
+ }
543
+
544
+ function onLostPointerCapture(event) {
545
+ if (!drag || (event.pointerId != null && event.pointerId !== drag.pointerId)) return;
546
+ endDrag();
547
+ setHoveredHandle(null);
548
+ }
549
+
550
+ function onPointerLeave(event) {
551
+ if (drag && event.pointerId != null && event.pointerId !== drag.pointerId) return;
552
+ endDrag();
553
+ setHoveredHandle(null);
554
+ }
555
+
556
+ function onWindowBlur() {
557
+ endDrag();
558
+ setHoveredHandle(null);
559
+ }
560
+
561
+ function onPassiveActivity() {
562
+ if (!disposed && group.visible) onActivity();
563
+ }
564
+
565
+ const listeners = [
566
+ [domElement, "pointerdown", onPointerDown, { capture: true }],
567
+ [domElement, "pointermove", onPointerMove],
568
+ [domElement, "pointerenter", onPassiveActivity],
569
+ [domElement, "focus", onPassiveActivity],
570
+ [domElement, "pointerup", onPointerUp],
571
+ [domElement, "pointercancel", onPointerCancel],
572
+ [domElement, "lostpointercapture", onLostPointerCapture],
573
+ [domElement, "pointerleave", onPointerLeave],
574
+ [window, "blur", onWindowBlur],
575
+ ];
576
+ for (const [target, type, listener, options] of listeners) {
577
+ target.addEventListener(type, listener, options);
578
+ }
579
+
580
+ function setPose({ position, quaternion, size }) {
581
+ group.position.copy(position);
582
+ group.quaternion.copy(quaternion);
583
+ syncHandleTransform();
584
+ poseSize = size;
585
+ fill.scale.setScalar(size);
586
+ border.scale.setScalar(size);
587
+ handleRoot.scale.setScalar(size * 0.15);
588
+ updateEmptySideVisuals();
589
+ }
590
+
591
+ function updateEmptySideVisuals() {
592
+ const emptySideSign = flipped ? 1 : -1;
593
+ const ghostOffset = THREE.MathUtils.clamp(
594
+ poseSize * GHOST_OFFSET_FACTOR,
595
+ MIN_GHOST_OFFSET,
596
+ MAX_GHOST_OFFSET,
597
+ );
598
+ fill.position.z = emptySideSign * ghostOffset;
599
+ border.position.z = emptySideSign * ghostOffset;
600
+ arcRoot.rotation.x = flipped ? Math.PI : 0;
601
+ }
602
+
603
+ function setFlipped(nextFlipped) {
604
+ flipped = Boolean(nextFlipped);
605
+ updateEmptySideVisuals();
606
+ }
607
+
608
+ function setVisible(on) {
609
+ if (!on) {
610
+ endDrag();
611
+ setHoveredHandle(null);
612
+ }
613
+ group.visible = Boolean(on);
614
+ handleRoot.visible = Boolean(on);
615
+ }
616
+
617
+ function setActiveAppearance(active) {
618
+ activeAppearance = Boolean(active);
619
+ updateAppearance();
620
+ }
621
+
622
+ function setTheme(mode) {
623
+ themeMode = THEMES[mode] ? mode : "dark";
624
+ updateAppearance();
625
+ }
626
+
627
+ function worldUnitsPerPixelAt(position) {
628
+ const height = Math.max(domElement.getBoundingClientRect().height, 1);
629
+ if (camera.isOrthographicCamera) {
630
+ return Math.abs(camera.top - camera.bottom) / Math.max(camera.zoom, 1e-6) / height;
631
+ }
632
+ const forward = camera.getWorldDirection(new THREE.Vector3());
633
+ const cameraPosition = camera.getWorldPosition(new THREE.Vector3());
634
+ const depth = Math.max(
635
+ Math.abs(position.clone().sub(cameraPosition).dot(forward)),
636
+ camera.near || 1e-3,
637
+ );
638
+ const effectiveFov = camera.getEffectiveFOV();
639
+ return 2 * depth
640
+ * Math.tan(THREE.MathUtils.degToRad(effectiveFov) / 2)
641
+ / height;
642
+ }
643
+
644
+ function updateForCamera() {
645
+ if (disposed) return;
646
+ const screenScale = worldUnitsPerPixelAt(group.position) * 72;
647
+ handleRoot.scale.setScalar(THREE.MathUtils.clamp(
648
+ screenScale,
649
+ poseSize * 0.06,
650
+ poseSize * 0.55,
651
+ ));
652
+ }
653
+
654
+ function dispose() {
655
+ if (disposed) return;
656
+ disposed = true;
657
+ try {
658
+ endDrag();
659
+ setHoveredHandle(null);
660
+ } finally {
661
+ for (const [target, type, listener, options] of listeners) {
662
+ target.removeEventListener(type, listener, options);
663
+ }
664
+ scene.remove(group);
665
+ overlayScene.remove(handleRoot);
666
+ for (const geometry of geometries) geometry.dispose();
667
+ for (const material of materials) material.dispose();
668
+ }
669
+ }
670
+
671
+ return {
672
+ group,
673
+ fill,
674
+ border,
675
+ handles,
676
+ handleVisuals,
677
+ handleRoot,
678
+ setPose,
679
+ setFlipped,
680
+ setVisible,
681
+ setActiveAppearance,
682
+ setTheme,
683
+ updateForCamera,
684
+ dispose,
685
+ };
686
+ }