@toolpath/viewer 0.3.1 → 1.0.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.
@@ -1,10 +1,10 @@
1
1
  // src/engine/engine-part.tsx
2
- import { useEffect as useEffect5, useMemo as useMemo6 } from "react";
2
+ import { useEffect as useEffect6, useMemo as useMemo7 } from "react";
3
3
 
4
4
  // src/part-mesh.tsx
5
- import { useThree as useThree6 } from "@react-three/fiber";
6
- import { useCallback as useCallback3, useEffect as useEffect4, useLayoutEffect, useMemo as useMemo5, useRef as useRef5 } from "react";
7
- import { Vector3 as Vector38 } from "three";
5
+ import { useThree as useThree7 } from "@react-three/fiber";
6
+ import { useCallback as useCallback3, useEffect as useEffect5, useLayoutEffect, useMemo as useMemo6, useRef as useRef6 } from "react";
7
+ import { Vector3 as Vector310 } from "three";
8
8
 
9
9
  // src/model/directions.ts
10
10
  var EPSILON = 1e-6;
@@ -54,34 +54,48 @@ var HIGHLIGHT_COLORS = {
54
54
  geometryIssue: 16711680
55
55
  };
56
56
  var DIRECTION_COLORS = [
57
+ /*
58
+ * Nine ways up, told apart at a glance.
59
+ *
60
+ * The palette is an **identity**, not a ranking — the same colour on the
61
+ * face, on the arrow and on the row, which is the whole point of it. Two
62
+ * entries that look alike make it a worse identity than eight that do not,
63
+ * and this list had three problems at once:
64
+ *
65
+ * - teal-500, cyan-500 and emerald-500 were neighbours in hue *and*
66
+ * lightness, so three ways up were one blue-green smear;
67
+ * - blue-500 and indigo-500 were the closest pair of all;
68
+ * - indigo-500 sat 0.06 from the violet an offer is painted in — the very
69
+ * clash that sent a purple out of this list once already, still here under
70
+ * a different name.
71
+ *
72
+ * The warm ramp belongs to difficulty, red to sharp corners, orange to the
73
+ * selection over this cycle, violet to an offer. There is no room to move
74
+ * anything to a fresh hue, so the spread is along **lightness** as much as
75
+ * hue: a pale cyan and a mid teal, a light green and a deep navy.
76
+ *
77
+ * `selection-colors.test.ts` measures every pair in Oklab and holds them all
78
+ * above 0.1 — including against the four colours the part can wear over this
79
+ * wash. The binding pair now is blue against an offer's violet, at 0.12.
80
+ */
57
81
  3900150,
58
82
  // blue
59
- 1357990,
83
+ 889992,
60
84
  // teal
61
85
  14239471,
62
86
  // fuchsia
63
- 440020,
64
- // cyan
65
- /*
66
- * Olive, where a purple used to be.
67
- *
68
- * The purple sat one step from the violet a proposed feature is painted in,
69
- * so an offer laid over work already assigned to this direction was a clash
70
- * between "suggested" and "decided" — the two states the part is read for.
71
- * Olive is the only hue with room: the warm ramp belongs to the difficulty
72
- * bands, red to sharp corners, orange to faces being picked, and green to
73
- * whatever is being looked at.
74
- */
87
+ 6809849,
88
+ // pale cyan
75
89
  6660877,
76
90
  // olive
77
91
  15485081,
78
92
  // pink
79
- 6583435,
80
- // slate
81
- 1096065,
82
- // emerald
83
- 6514417
84
- // indigo
93
+ 9741240,
94
+ // light slate
95
+ 4906624,
96
+ // light green
97
+ 1981066
98
+ // deep navy
85
99
  ];
86
100
  var DEFAULT_THEME = {
87
101
  background: null,
@@ -164,7 +178,7 @@ function applyHighlightLayers(part, layers, theme) {
164
178
  }
165
179
 
166
180
  // src/render/section.ts
167
- import { OrthographicCamera, Plane, Vector3 } from "three";
181
+ import { Plane, Vector3 } from "three";
168
182
  var SECTION_RENDER_ORDER = {
169
183
  stencil: 1,
170
184
  cap: 2,
@@ -233,12 +247,6 @@ function sectionPlane(box, normal, offset, into = new Plane()) {
233
247
  const axis = unit(normal);
234
248
  return into.set(axis, sectionConstant(sectionBounds(box, normal), offset));
235
249
  }
236
- function screenLength(camera, point, viewport, pixels) {
237
- const height = viewport.height > 0 ? viewport.height : 1;
238
- const zoom = camera.zoom || 1;
239
- const visible = camera instanceof OrthographicCamera ? (camera.top - camera.bottom) / zoom : 2 * Math.tan(camera.fov / 2 * Math.PI / 180) * Math.max(camera.position.distanceTo(point), EPSILON2) / zoom;
240
- return visible / height * pixels;
241
- }
242
250
  function dragPlane(axis, view, point, into = new Plane()) {
243
251
  const along = axis.lengthSq() === 0 ? ARROW_AXIS.clone() : axis.clone().normalize();
244
252
  const side = new Vector3().crossVectors(view, along);
@@ -252,7 +260,13 @@ function perpendicular(axis) {
252
260
 
253
261
  // src/tap.tsx
254
262
  import { useThree } from "@react-three/fiber";
255
- import { useEffect, useMemo, useRef } from "react";
263
+ import {
264
+ createContext,
265
+ useContext,
266
+ useEffect,
267
+ useMemo,
268
+ useRef
269
+ } from "react";
256
270
 
257
271
  // src/render/tap.ts
258
272
  var TAP_SLOP = 4;
@@ -272,20 +286,43 @@ function trackTaps(element) {
272
286
  dispose: () => element.removeEventListener("pointerdown", down, { capture: true })
273
287
  };
274
288
  }
289
+ var DOUBLE_TAP_MS = 400;
290
+ function trackDoubleTaps(within = DOUBLE_TAP_MS, slop = TAP_SLOP) {
291
+ let last = null;
292
+ return {
293
+ isDouble: (event) => {
294
+ const paired = last !== null && event.timeStamp - last.timeStamp <= within && !movedFar(last, event, slop);
295
+ last = paired ? null : event;
296
+ return paired;
297
+ },
298
+ reset: () => {
299
+ last = null;
300
+ }
301
+ };
302
+ }
275
303
 
276
304
  // src/tap.tsx
305
+ import { jsx } from "react/jsx-runtime";
306
+ var ViewerTapContext = createContext(null);
307
+ var ViewerTapProvider = ({ value, children }) => /* @__PURE__ */ jsx(ViewerTapContext.Provider, { value, children });
277
308
  function useTapGuard() {
309
+ const shared = useContext(ViewerTapContext);
278
310
  const domElement = useThree((state) => state.gl.domElement);
279
311
  const tracker = useRef(null);
280
312
  useEffect(() => {
313
+ if (shared) return;
281
314
  const tracked = trackTaps(domElement);
282
315
  tracker.current = tracked;
283
316
  return () => {
284
317
  tracked.dispose();
285
318
  if (tracker.current === tracked) tracker.current = null;
286
319
  };
287
- }, [domElement]);
288
- return useMemo(() => (event) => tracker.current?.isTap(event) ?? true, []);
320
+ }, [domElement, shared]);
321
+ const own = useMemo(
322
+ () => (event) => tracker.current?.isTap(event) ?? true,
323
+ []
324
+ );
325
+ return shared ?? own;
289
326
  }
290
327
 
291
328
  // src/render/part.ts
@@ -391,10 +428,11 @@ function buildRegionAttribute(model, texels, vertexCount) {
391
428
  function createPart(model, geometry, theme) {
392
429
  const position = geometry.getAttribute("position");
393
430
  const texels = buildRegionTexels(model);
394
- geometry.setAttribute(
395
- REGION_ATTRIBUTE,
396
- new Float32BufferAttribute2(buildRegionAttribute(model, texels, position.count), 1)
431
+ const regionAttribute = new Float32BufferAttribute2(
432
+ buildRegionAttribute(model, texels, position.count),
433
+ 1
397
434
  );
435
+ geometry.setAttribute(REGION_ATTRIBUTE, regionAttribute);
398
436
  const width = model.regions.length + 1;
399
437
  const state = new Uint8Array(width * 4);
400
438
  const stateTexture = new DataTexture(state, width, 1, RGBAFormat, UnsignedByteType);
@@ -430,7 +468,8 @@ function createPart(model, geometry, theme) {
430
468
  "#include <color_fragment>",
431
469
  `#include <color_fragment>
432
470
  regionState = texelFetch(uRegionState, ivec2(int(vRegion + 0.5), 0), 0);
433
- diffuseColor.rgb = mix(diffuseColor.rgb, regionState.rgb, regionState.a);`
471
+ diffuseColor.rgb = mix(diffuseColor.rgb, regionState.rgb, regionState.a);
472
+ `
434
473
  ).replace(
435
474
  "#include <emissivemap_fragment>",
436
475
  `#include <emissivemap_fragment>
@@ -520,7 +559,9 @@ function createPart(model, geometry, theme) {
520
559
  },
521
560
  dispose() {
522
561
  object.clear();
523
- geometry.deleteAttribute(REGION_ATTRIBUTE);
562
+ if (geometry.getAttribute(REGION_ATTRIBUTE) === regionAttribute) {
563
+ geometry.deleteAttribute(REGION_ATTRIBUTE);
564
+ }
524
565
  material.dispose();
525
566
  edgeGeometry.dispose();
526
567
  edgeMaterial.dispose();
@@ -529,6 +570,48 @@ function createPart(model, geometry, theme) {
529
570
  };
530
571
  }
531
572
 
573
+ // src/render/adjacency.ts
574
+ var GRID = 1e-4;
575
+ var keyFor = (x, y, z) => `${Math.round(x / GRID)},${Math.round(y / GRID)},${Math.round(z / GRID)}`;
576
+ function regionAdjacency(model, geometry) {
577
+ const position = geometry.getAttribute("position");
578
+ const adjacency = /* @__PURE__ */ new Map();
579
+ if (!position) return adjacency;
580
+ const owner = new Int32Array(Math.floor(position.count / 3)).fill(-1);
581
+ for (const region of model.regions) {
582
+ for (let at = region.triangles.start; at < region.triangles.end; at++) {
583
+ if (at >= 0 && at < owner.length) owner[at] = region.idx;
584
+ }
585
+ }
586
+ const edges = /* @__PURE__ */ new Map();
587
+ const join = (a, b) => {
588
+ if (a === b) return;
589
+ const mine = adjacency.get(a) ?? /* @__PURE__ */ new Set();
590
+ mine.add(b);
591
+ adjacency.set(a, mine);
592
+ const theirs = adjacency.get(b) ?? /* @__PURE__ */ new Set();
593
+ theirs.add(a);
594
+ adjacency.set(b, theirs);
595
+ };
596
+ for (let triangle = 0; triangle < owner.length; triangle++) {
597
+ const region = owner[triangle];
598
+ if (region === -1) continue;
599
+ const corners = [0, 1, 2].map((corner) => {
600
+ const at = triangle * 3 + corner;
601
+ return keyFor(position.getX(at), position.getY(at), position.getZ(at));
602
+ });
603
+ for (let corner = 0; corner < 3; corner++) {
604
+ const from = corners[corner];
605
+ const to = corners[(corner + 1) % 3];
606
+ const edge = from < to ? `${from}|${to}` : `${to}|${from}`;
607
+ const seen = edges.get(edge);
608
+ if (seen === void 0) edges.set(edge, region);
609
+ else join(seen, region);
610
+ }
611
+ }
612
+ return adjacency;
613
+ }
614
+
532
615
  // src/render/selection.ts
533
616
  var FEATURE_TYPE_RANKS = [
534
617
  ["blind_hole", "through_hole", "filleted_blind_hole"],
@@ -616,7 +699,8 @@ function buildPick(input) {
616
699
  triangleIndex: input.triangleIndex,
617
700
  point: input.point,
618
701
  normal: input.normal,
619
- modifiers: input.modifiers ?? NO_MODIFIERS
702
+ modifiers: input.modifiers ?? NO_MODIFIERS,
703
+ doubled: input.doubled ?? false
620
704
  };
621
705
  }
622
706
  function focusForPick(pick, previousRegion, previousFocus) {
@@ -627,25 +711,25 @@ function focusForPick(pick, previousRegion, previousFocus) {
627
711
  }
628
712
 
629
713
  // src/viewer.tsx
630
- import { Canvas, useFrame as useFrame2, useThree as useThree3 } from "@react-three/fiber";
714
+ import { Canvas, useFrame as useFrame3, useThree as useThree4 } from "@react-three/fiber";
631
715
  import {
632
- createContext,
716
+ createContext as createContext2,
633
717
  forwardRef,
634
718
  useCallback,
635
- useContext,
636
- useEffect as useEffect3,
719
+ useContext as useContext2,
720
+ useEffect as useEffect4,
637
721
  useImperativeHandle,
638
- useMemo as useMemo3,
639
- useRef as useRef2
722
+ useMemo as useMemo4,
723
+ useRef as useRef3
640
724
  } from "react";
641
- import { Box3 as Box33, Vector3 as Vector35 } from "three";
725
+ import { Box3 as Box33, Vector3 as Vector37 } from "three";
642
726
 
643
727
  // src/camera.tsx
644
728
  import { useFrame, useThree as useThree2 } from "@react-three/fiber";
645
729
  import { useEffect as useEffect2, useMemo as useMemo2 } from "react";
646
730
 
647
731
  // src/render/camera.ts
648
- import { OrthographicCamera as OrthographicCamera2, Vector3 as Vector33 } from "three";
732
+ import { OrthographicCamera, Vector3 as Vector33 } from "three";
649
733
  var PERSPECTIVE_FOV = 30;
650
734
  var DEFAULT_FIT_MARGIN = 1.2;
651
735
  var EXCLUDE_FROM_FRAME = "viewerExcludeFromFrame";
@@ -655,6 +739,13 @@ function defaultBounds() {
655
739
  function aspectRatio(size) {
656
740
  return size.width > 0 && size.height > 0 ? size.width / size.height : 1;
657
741
  }
742
+ var SCREEN_LENGTH_EPSILON = 1e-9;
743
+ function screenLength(camera, point, viewport, pixels) {
744
+ const height = viewport.height > 0 ? viewport.height : 1;
745
+ const zoom = camera.zoom || 1;
746
+ const visible = camera instanceof OrthographicCamera ? (camera.top - camera.bottom) / zoom : 2 * Math.tan(camera.fov / 2 * Math.PI / 180) * Math.max(camera.position.distanceTo(point), SCREEN_LENGTH_EPSILON) / zoom;
747
+ return visible / height * pixels;
748
+ }
658
749
  function boundsFromBox(box) {
659
750
  if (box.isEmpty()) return defaultBounds();
660
751
  const center = box.getCenter(new Vector33());
@@ -703,10 +794,53 @@ function startPosition(projection, size, bounds, margin = DEFAULT_FIT_MARGIN) {
703
794
  const [x, y, z] = START_DIRECTION[projection];
704
795
  return new Vector33(x, y, z).normalize().multiplyScalar(fitDistance(projection, size, bounds, margin)).add(bounds.center);
705
796
  }
797
+ var MIN_FRAME_RATIO = 0.25;
798
+ var MAX_FRAME_RATIO = 10;
799
+ var TARGET_BOUNDARY_RATIO = 4;
800
+ function cameraLimits(projection, size, bounds, margin = DEFAULT_FIT_MARGIN, framed) {
801
+ const fit = fitDistance(projection, size, bounds, margin);
802
+ const framedRadius = framed && framed.radius > 0 ? framed.radius : bounds.radius;
803
+ if (projection === "orthographic") {
804
+ const reach = bounds.radius > 0 ? bounds.radius / framedRadius : 1;
805
+ return {
806
+ minZoom: Math.min(MIN_FRAME_RATIO, MIN_FRAME_RATIO * reach),
807
+ maxZoom: Math.max(MAX_FRAME_RATIO, MAX_FRAME_RATIO * reach),
808
+ // Distance does not change apparent size under an orthographic camera, so
809
+ // these are not the zoom clamps in another form — they only keep the
810
+ // camera clear of the geometry it is looking at, and bounded.
811
+ minDistance: bounds.radius * margin,
812
+ maxDistance: fit / MIN_FRAME_RATIO
813
+ };
814
+ }
815
+ const framedFit = fitDistance(
816
+ projection,
817
+ size,
818
+ framedRadius === bounds.radius ? bounds : { ...bounds, radius: framedRadius },
819
+ margin
820
+ );
821
+ return {
822
+ // The wheel is `ACTION.DOLLY` under a perspective camera, so nothing here
823
+ // moves `camera.zoom`. They are set to the same pair anyway: a consumer
824
+ // that does zoom should not find the two cameras disagreeing about how far
825
+ // in is too far.
826
+ minZoom: MIN_FRAME_RATIO,
827
+ maxZoom: MAX_FRAME_RATIO,
828
+ minDistance: Math.min(fit, framedFit) / MAX_FRAME_RATIO,
829
+ maxDistance: Math.max(fit, framedFit) / MIN_FRAME_RATIO
830
+ };
831
+ }
832
+ var scratchBoundarySize = new Vector33();
833
+ function targetBoundary(bounds, into, margin = DEFAULT_FIT_MARGIN) {
834
+ const reach = bounds.radius * margin * TARGET_BOUNDARY_RATIO;
835
+ return into.setFromCenterAndSize(
836
+ bounds.center,
837
+ scratchBoundarySize.set(reach * 2, reach * 2, reach * 2)
838
+ );
839
+ }
706
840
  function applyProjection(camera, size, bounds, margin = DEFAULT_FIT_MARGIN) {
707
841
  const aspect = aspectRatio(size);
708
842
  const radius = bounds.radius * margin;
709
- if (camera instanceof OrthographicCamera2) {
843
+ if (camera instanceof OrthographicCamera) {
710
844
  const halfHeight = orthographicHalfHeight(aspect, radius);
711
845
  const halfWidth = halfHeight * aspect;
712
846
  camera.left = -halfWidth;
@@ -726,6 +860,11 @@ function applyProjection(camera, size, bounds, margin = DEFAULT_FIT_MARGIN) {
726
860
  camera.updateProjectionMatrix();
727
861
  }
728
862
  var CAD_CAMERA_UP = new Vector33(0, 0, 1);
863
+ var scratchSide = new Vector33();
864
+ function adaptedUp(view, up, into) {
865
+ const side = scratchSide.crossVectors(view, up).normalize();
866
+ return into.crossVectors(side, view).normalize();
867
+ }
729
868
  var cadViewDirections = {
730
869
  front: new Vector33(0, -1, 0),
731
870
  back: new Vector33(0, 1, 0),
@@ -746,7 +885,7 @@ import CameraControls from "camera-controls";
746
885
  import {
747
886
  Box3 as Box32,
748
887
  Matrix4,
749
- OrthographicCamera as OrthographicCamera3,
888
+ OrthographicCamera as OrthographicCamera2,
750
889
  Quaternion,
751
890
  Raycaster,
752
891
  Sphere,
@@ -773,6 +912,8 @@ var FUSION_PINCH_ZOOM_SCALE = 0.04;
773
912
  var FUSION_ROTATE_SCALE = 2.2;
774
913
  var FUSION_TRUCK_SCALE = 0.33;
775
914
  var DEFAULT_SMOOTH_TIME = 1e-3;
915
+ var DOLLY_SPEED = 1.15;
916
+ var REST_THRESHOLD = 5e-3;
776
917
  var ExtendedCameraControls = class extends CameraControls {
777
918
  #domElement;
778
919
  #freeOrbit;
@@ -787,13 +928,34 @@ var ExtendedCameraControls = class extends CameraControls {
787
928
  #scratchA = new Vector34();
788
929
  #scratchB = new Vector34();
789
930
  #scratchC = new Vector34();
790
- #scratchD = new Vector34();
791
931
  constructor(camera, domElement, options = {}) {
792
932
  super(camera, domElement);
793
933
  this.#domElement = domElement;
794
934
  this.#freeOrbit = options.freeOrbit ?? true;
795
935
  this.azimuthRotateSpeed = 0;
796
936
  this.polarRotateSpeed = 0;
937
+ this.dollySpeed = DOLLY_SPEED;
938
+ this.restThreshold = REST_THRESHOLD;
939
+ }
940
+ /**
941
+ * Bounds how far the wheel may travel, and where the orbit target may go.
942
+ *
943
+ * Re-applied whenever the scene is re-measured rather than set once: the
944
+ * limits are derived from the part's bounds, and a viewer that loads a second
945
+ * part would otherwise keep the first part's idea of far.
946
+ *
947
+ * The boundary confines the *target*. Zoom-to-cursor moves it, and goes on
948
+ * moving it after the zoom clamp bites — which is a runaway no zoom clamp can
949
+ * catch. `boundaryEnclosesCamera` stays off: under an orthographic camera the
950
+ * camera sits well outside the framing it is looking at, so enclosing it would
951
+ * drag the target back in on every frame.
952
+ */
953
+ applyLimits(limits, boundary) {
954
+ this.minZoom = limits.minZoom;
955
+ this.maxZoom = limits.maxZoom;
956
+ this.minDistance = limits.minDistance;
957
+ this.maxDistance = limits.maxDistance;
958
+ this.setBoundary(boundary);
797
959
  }
798
960
  get freeOrbit() {
799
961
  return this.#freeOrbit;
@@ -863,7 +1025,7 @@ var ExtendedCameraControls = class extends CameraControls {
863
1025
  this.mouseButtons.left = CameraControls.ACTION.ROTATE;
864
1026
  this.mouseButtons.right = CameraControls.ACTION.TRUCK;
865
1027
  this.mouseButtons.middle = CameraControls.ACTION.TRUCK;
866
- this.mouseButtons.wheel = this.camera instanceof OrthographicCamera3 ? CameraControls.ACTION.ZOOM : CameraControls.ACTION.DOLLY;
1028
+ this.mouseButtons.wheel = this.camera instanceof OrthographicCamera2 ? CameraControls.ACTION.ZOOM : CameraControls.ACTION.DOLLY;
867
1029
  }
868
1030
  setFreeOrbit(freeOrbit) {
869
1031
  if (this.#freeOrbit === freeOrbit) {
@@ -921,9 +1083,7 @@ var ExtendedCameraControls = class extends CameraControls {
921
1083
  #adaptUpVector = () => {
922
1084
  const target = this.getTarget(this.#scratchA);
923
1085
  const view = this.#scratchB.subVectors(target, this.camera.position).normalize();
924
- const side = this.#scratchC.crossVectors(view, this.camera.up).normalize();
925
- const up = this.#scratchD.crossVectors(side, view).normalize();
926
- this.camera.up.copy(up);
1086
+ this.camera.up.copy(adaptedUp(view, this.camera.up, this.#scratchC));
927
1087
  const position = this.getPosition(this.#scratchA);
928
1088
  this.updateCameraUp();
929
1089
  void this.setPosition(position.x, position.y, position.z, false);
@@ -990,7 +1150,8 @@ var ExtendedCameraControls = class extends CameraControls {
990
1150
  var CadCameraControls = ({
991
1151
  controlsRef,
992
1152
  scheme = "toolpath",
993
- freeOrbit = true
1153
+ freeOrbit = true,
1154
+ zoomTo = "cursor"
994
1155
  }) => {
995
1156
  const camera = useThree2((state) => state.camera);
996
1157
  const domElement = useThree2((state) => state.gl.domElement);
@@ -1029,46 +1190,470 @@ var CadCameraControls = ({
1029
1190
  useEffect2(() => {
1030
1191
  controls.setFreeOrbit(freeOrbit);
1031
1192
  }, [controls, freeOrbit]);
1193
+ useEffect2(() => {
1194
+ controls.dollyToCursor = zoomTo === "cursor";
1195
+ }, [controls, zoomTo]);
1032
1196
  useFrame((_, delta) => {
1033
1197
  controls.update(delta);
1034
1198
  });
1035
1199
  return null;
1036
1200
  };
1037
1201
 
1202
+ // src/render/retarget.ts
1203
+ function retargetPose(cameraPosition, currentTarget, hitPoint) {
1204
+ const dx = hitPoint.x - currentTarget.x;
1205
+ const dy = hitPoint.y - currentTarget.y;
1206
+ const dz = hitPoint.z - currentTarget.z;
1207
+ return {
1208
+ position: {
1209
+ x: cameraPosition.x + dx,
1210
+ y: cameraPosition.y + dy,
1211
+ z: cameraPosition.z + dz
1212
+ },
1213
+ target: { x: hitPoint.x, y: hitPoint.y, z: hitPoint.z }
1214
+ };
1215
+ }
1216
+
1217
+ // src/target-marker.tsx
1218
+ import { useFrame as useFrame2, useThree as useThree3 } from "@react-three/fiber";
1219
+ import { useEffect as useEffect3, useMemo as useMemo3, useRef as useRef2 } from "react";
1220
+ import {
1221
+ DoubleSide,
1222
+ RingGeometry,
1223
+ SphereGeometry,
1224
+ Vector3 as Vector35
1225
+ } from "three";
1226
+
1227
+ // src/render/target.ts
1228
+ var ORBIT_TARGET_PIXELS = 3;
1229
+ var ORBIT_TARGET_RING_PIXELS = 14;
1230
+ var ORBIT_TARGET_RING_WIDTH = 0.16;
1231
+ var ORBIT_TARGET_COLOR = 3948625;
1232
+ var ORBIT_TARGET_RING_COLOR = 7057587;
1233
+ var ORBIT_TARGET_RING_OPACITY = 0.9;
1234
+ var ORBIT_TARGET_FLASH_MS = 100;
1235
+ var ORBIT_TARGET_FADE_MS = 1e3;
1236
+ function orbitTargetOpacity(sinceHold, fade = ORBIT_TARGET_FADE_MS) {
1237
+ if (sinceHold <= 0) return 1;
1238
+ if (fade <= 0 || sinceHold >= fade) return 0;
1239
+ return 1 - sinceHold / fade;
1240
+ }
1241
+
1242
+ // src/target-marker.tsx
1243
+ import { jsx as jsx2, jsxs } from "react/jsx-runtime";
1244
+ var FURNITURE = { [EXCLUDE_FROM_FRAME]: true };
1245
+ var TargetMarker = ({ controlsRef }) => {
1246
+ const camera = useThree3((state) => state.camera);
1247
+ const size = useThree3((state) => state.size);
1248
+ const invalidate = useThree3((state) => state.invalidate);
1249
+ const registered = useThree3((state) => state.controls);
1250
+ const group = useRef2(null);
1251
+ const ring = useRef2(null);
1252
+ const dotMaterial = useRef2(null);
1253
+ const ringMaterial = useRef2(null);
1254
+ const dotGeometry = useMemo3(() => new SphereGeometry(1, 16, 12), []);
1255
+ const ringGeometry = useMemo3(() => new RingGeometry(1 - ORBIT_TARGET_RING_WIDTH, 1, 48), []);
1256
+ useEffect3(
1257
+ () => () => {
1258
+ dotGeometry.dispose();
1259
+ ringGeometry.dispose();
1260
+ },
1261
+ [dotGeometry, ringGeometry]
1262
+ );
1263
+ const dragging = useRef2(false);
1264
+ const heldUntil = useRef2(0);
1265
+ const painted = useRef2(-1);
1266
+ const placed = useRef2(false);
1267
+ const lastTarget = useMemo3(() => new Vector35(), []);
1268
+ const scratch = useMemo3(() => new Vector35(), []);
1269
+ useEffect3(() => {
1270
+ const controls = controlsRef.current;
1271
+ if (!controls) return void 0;
1272
+ const start = () => {
1273
+ dragging.current = true;
1274
+ invalidate();
1275
+ };
1276
+ const end = () => {
1277
+ dragging.current = false;
1278
+ heldUntil.current = performance.now();
1279
+ invalidate();
1280
+ };
1281
+ controls.addEventListener("controlstart", start);
1282
+ controls.addEventListener("controlend", end);
1283
+ return () => {
1284
+ controls.removeEventListener("controlstart", start);
1285
+ controls.removeEventListener("controlend", end);
1286
+ };
1287
+ }, [controlsRef, invalidate, registered]);
1288
+ useFrame2(() => {
1289
+ const controls = controlsRef.current;
1290
+ const node = group.current;
1291
+ if (!controls || !node) return;
1292
+ const now = performance.now();
1293
+ const target = controls.getTarget(scratch);
1294
+ if (!placed.current) {
1295
+ placed.current = true;
1296
+ lastTarget.copy(target);
1297
+ node.position.copy(target);
1298
+ } else if (!target.equals(lastTarget)) {
1299
+ lastTarget.copy(target);
1300
+ node.position.copy(target);
1301
+ heldUntil.current = Math.max(heldUntil.current, now + ORBIT_TARGET_FLASH_MS);
1302
+ }
1303
+ const opacity = dragging.current ? 1 : orbitTargetOpacity(now - heldUntil.current);
1304
+ if (opacity !== painted.current) {
1305
+ painted.current = opacity;
1306
+ node.visible = opacity > 0;
1307
+ if (dotMaterial.current) dotMaterial.current.opacity = opacity;
1308
+ if (ringMaterial.current) ringMaterial.current.opacity = opacity * ORBIT_TARGET_RING_OPACITY;
1309
+ invalidate();
1310
+ }
1311
+ if (node.visible) {
1312
+ node.scale.setScalar(screenLength(camera, target, size, 1));
1313
+ ring.current?.quaternion.copy(camera.quaternion);
1314
+ }
1315
+ });
1316
+ return /* @__PURE__ */ jsxs("group", { ref: group, visible: false, userData: FURNITURE, children: [
1317
+ /* @__PURE__ */ jsx2(
1318
+ "mesh",
1319
+ {
1320
+ ref: ring,
1321
+ geometry: ringGeometry,
1322
+ scale: ORBIT_TARGET_RING_PIXELS,
1323
+ renderOrder: 998,
1324
+ raycast: () => null,
1325
+ children: /* @__PURE__ */ jsx2(
1326
+ "meshBasicMaterial",
1327
+ {
1328
+ ref: ringMaterial,
1329
+ color: ORBIT_TARGET_RING_COLOR,
1330
+ transparent: true,
1331
+ opacity: 0,
1332
+ depthTest: false,
1333
+ depthWrite: false,
1334
+ side: DoubleSide
1335
+ }
1336
+ )
1337
+ }
1338
+ ),
1339
+ /* @__PURE__ */ jsx2(
1340
+ "mesh",
1341
+ {
1342
+ geometry: dotGeometry,
1343
+ scale: ORBIT_TARGET_PIXELS,
1344
+ renderOrder: 999,
1345
+ raycast: () => null,
1346
+ children: /* @__PURE__ */ jsx2(
1347
+ "meshBasicMaterial",
1348
+ {
1349
+ ref: dotMaterial,
1350
+ color: ORBIT_TARGET_COLOR,
1351
+ transparent: true,
1352
+ opacity: 0,
1353
+ depthTest: false,
1354
+ depthWrite: false
1355
+ }
1356
+ )
1357
+ }
1358
+ )
1359
+ ] });
1360
+ };
1361
+
1362
+ // src/render/view-cube.ts
1363
+ import {
1364
+ BufferGeometry,
1365
+ CanvasTexture,
1366
+ Float32BufferAttribute as Float32BufferAttribute3,
1367
+ SRGBColorSpace,
1368
+ Vector3 as Vector36
1369
+ } from "three";
1370
+ var VIEW_NAMES = [
1371
+ // The six faces.
1372
+ "top",
1373
+ "bottom",
1374
+ "front",
1375
+ "back",
1376
+ "left",
1377
+ "right",
1378
+ // The twelve edges.
1379
+ "top-front",
1380
+ "top-back",
1381
+ "top-left",
1382
+ "top-right",
1383
+ "bottom-front",
1384
+ "bottom-back",
1385
+ "bottom-left",
1386
+ "bottom-right",
1387
+ "front-left",
1388
+ "front-right",
1389
+ "back-left",
1390
+ "back-right",
1391
+ // The eight corners.
1392
+ "top-front-left",
1393
+ "top-front-right",
1394
+ "top-back-left",
1395
+ "top-back-right",
1396
+ "bottom-front-left",
1397
+ "bottom-front-right",
1398
+ "bottom-back-left",
1399
+ "bottom-back-right"
1400
+ ];
1401
+ var VIEW_SIGNS = {
1402
+ top: [0, 0, 1],
1403
+ bottom: [0, 0, -1],
1404
+ front: [0, -1, 0],
1405
+ back: [0, 1, 0],
1406
+ left: [-1, 0, 0],
1407
+ right: [1, 0, 0],
1408
+ "top-front": [0, -1, 1],
1409
+ "top-back": [0, 1, 1],
1410
+ "top-left": [-1, 0, 1],
1411
+ "top-right": [1, 0, 1],
1412
+ "bottom-front": [0, -1, -1],
1413
+ "bottom-back": [0, 1, -1],
1414
+ "bottom-left": [-1, 0, -1],
1415
+ "bottom-right": [1, 0, -1],
1416
+ "front-left": [-1, -1, 0],
1417
+ "front-right": [1, -1, 0],
1418
+ "back-left": [-1, 1, 0],
1419
+ "back-right": [1, 1, 0],
1420
+ "top-front-left": [-1, -1, 1],
1421
+ "top-front-right": [1, -1, 1],
1422
+ "top-back-left": [-1, 1, 1],
1423
+ "top-back-right": [1, 1, 1],
1424
+ "bottom-front-left": [-1, -1, -1],
1425
+ "bottom-front-right": [1, -1, -1],
1426
+ "bottom-back-left": [-1, 1, -1],
1427
+ "bottom-back-right": [1, 1, -1]
1428
+ };
1429
+ function viewKind(name) {
1430
+ const off = VIEW_SIGNS[name].filter((sign) => sign !== 0).length;
1431
+ if (off === 1) return "face";
1432
+ if (off === 2) return "edge";
1433
+ return "corner";
1434
+ }
1435
+ var DIRECTIONS = new Map(
1436
+ VIEW_NAMES.map((name) => {
1437
+ const [x, y, z] = VIEW_SIGNS[name];
1438
+ const length = Math.hypot(x, y, z);
1439
+ return [name, { x: x / length, y: y / length, z: z / length }];
1440
+ })
1441
+ );
1442
+ function viewVector(name) {
1443
+ return DIRECTIONS.get(name) ?? { x: 0, y: 0, z: 1 };
1444
+ }
1445
+ function viewUp(direction) {
1446
+ if (direction.x === 0 && direction.y === 0) {
1447
+ return { x: 0, y: direction.z > 0 ? 1 : -1, z: 0 };
1448
+ }
1449
+ return { x: 0, y: 0, z: 1 };
1450
+ }
1451
+ function squaredUp(direction, currentUp) {
1452
+ const forward = new Vector36(direction.x, direction.y, direction.z).normalize();
1453
+ const canonical = viewUp(direction);
1454
+ const zero = new Vector36(canonical.x, canonical.y, canonical.z);
1455
+ zero.addScaledVector(forward, -forward.dot(zero)).normalize();
1456
+ const quarter = new Vector36().crossVectors(forward, zero);
1457
+ const current = new Vector36(currentUp.x, currentUp.y, currentUp.z);
1458
+ const rolls = [zero, quarter, zero.clone().negate(), quarter.clone().negate()];
1459
+ const nearest = rolls.reduce(
1460
+ (best, roll) => roll.dot(current) > best.dot(current) ? roll : best
1461
+ );
1462
+ return vec(nearest);
1463
+ }
1464
+ var CHAMFER = 0.5834;
1465
+ function vec(v) {
1466
+ return { x: v.x, y: v.y, z: v.z };
1467
+ }
1468
+ function cubeZones(chamfer = CHAMFER) {
1469
+ const AXES = [0, 1, 2];
1470
+ return VIEW_NAMES.map((name) => {
1471
+ const kind = viewKind(name);
1472
+ const direction = viewVector(name);
1473
+ const [sx, sy, sz] = VIEW_SIGNS[name];
1474
+ const sign = (axis) => VIEW_SIGNS[name][axis] ?? 0;
1475
+ const normal = new Vector36(direction.x, direction.y, direction.z);
1476
+ let polygon;
1477
+ if (kind === "face") {
1478
+ const up = viewUp(direction);
1479
+ const upVector = new Vector36(up.x, up.y, up.z);
1480
+ const right = normal.clone().negate().cross(upVector).normalize();
1481
+ const square = [
1482
+ [-1, -1],
1483
+ [1, -1],
1484
+ [1, 1],
1485
+ [-1, 1]
1486
+ ];
1487
+ polygon = square.map(
1488
+ ([u, v]) => normal.clone().addScaledVector(right, u * chamfer).addScaledVector(upVector, v * chamfer)
1489
+ );
1490
+ } else if (kind === "edge") {
1491
+ const a = AXES.find((axis) => sign(axis) !== 0) ?? 0;
1492
+ const b = [...AXES].reverse().find((axis) => sign(axis) !== 0) ?? 0;
1493
+ const c = AXES.find((axis) => sign(axis) === 0) ?? 0;
1494
+ const rectangle = [
1495
+ [1, chamfer, -chamfer],
1496
+ [1, chamfer, chamfer],
1497
+ [chamfer, 1, chamfer],
1498
+ [chamfer, 1, -chamfer]
1499
+ ];
1500
+ polygon = rectangle.map(
1501
+ ([onA, onB, onC]) => new Vector36().setComponent(a, sign(a) * onA).setComponent(b, sign(b) * onB).setComponent(c, onC)
1502
+ );
1503
+ } else {
1504
+ const triangle = [
1505
+ [1, chamfer, chamfer],
1506
+ [chamfer, 1, chamfer],
1507
+ [chamfer, chamfer, 1]
1508
+ ];
1509
+ polygon = triangle.map(([onX, onY, onZ]) => new Vector36(sx * onX, sy * onY, sz * onZ));
1510
+ }
1511
+ const [first, second, third] = polygon;
1512
+ if (first && second && third) {
1513
+ const facing = new Vector36().subVectors(second, first).cross(new Vector36().subVectors(third, first));
1514
+ if (facing.dot(normal) < 0) polygon.reverse();
1515
+ }
1516
+ return { name, kind, direction, polygon: polygon.map(vec) };
1517
+ });
1518
+ }
1519
+ function panelGeometry(zone) {
1520
+ const positions = [];
1521
+ const normals = [];
1522
+ const [first] = zone.polygon;
1523
+ const geometry = new BufferGeometry();
1524
+ if (!first) return geometry;
1525
+ for (let index = 1; index + 1 < zone.polygon.length; index += 1) {
1526
+ const b = zone.polygon[index];
1527
+ const c = zone.polygon[index + 1];
1528
+ if (!b || !c) continue;
1529
+ positions.push(first.x, first.y, first.z, b.x, b.y, b.z, c.x, c.y, c.z);
1530
+ for (let vertex = 0; vertex < 3; vertex += 1) {
1531
+ normals.push(zone.direction.x, zone.direction.y, zone.direction.z);
1532
+ }
1533
+ }
1534
+ geometry.setAttribute("position", new Float32BufferAttribute3(positions, 3));
1535
+ geometry.setAttribute("normal", new Float32BufferAttribute3(normals, 3));
1536
+ return geometry;
1537
+ }
1538
+ function cubeOutlineGeometry(zones) {
1539
+ const positions = [];
1540
+ for (const zone of zones) {
1541
+ for (let index = 0; index < zone.polygon.length; index += 1) {
1542
+ const from = zone.polygon[index];
1543
+ const to = zone.polygon[(index + 1) % zone.polygon.length];
1544
+ if (!from || !to) continue;
1545
+ positions.push(from.x, from.y, from.z, to.x, to.y, to.z);
1546
+ }
1547
+ }
1548
+ const geometry = new BufferGeometry();
1549
+ geometry.setAttribute("position", new Float32BufferAttribute3(positions, 3));
1550
+ return geometry;
1551
+ }
1552
+ function labelGeometry(zone) {
1553
+ const positions = [];
1554
+ const uvs = [];
1555
+ const corners = [0, 1, 2, 0, 2, 3];
1556
+ const corner = [
1557
+ [0, 0],
1558
+ [1, 0],
1559
+ [1, 1],
1560
+ [0, 1]
1561
+ ];
1562
+ for (const index of corners) {
1563
+ const point = zone.polygon[index];
1564
+ const uv = corner[index];
1565
+ if (!point || !uv) continue;
1566
+ positions.push(
1567
+ point.x + zone.direction.x * 4e-3,
1568
+ point.y + zone.direction.y * 4e-3,
1569
+ point.z + zone.direction.z * 4e-3
1570
+ );
1571
+ uvs.push(uv[0] ?? 0, uv[1] ?? 0);
1572
+ }
1573
+ const geometry = new BufferGeometry();
1574
+ geometry.setAttribute("position", new Float32BufferAttribute3(positions, 3));
1575
+ geometry.setAttribute("uv", new Float32BufferAttribute3(uvs, 2));
1576
+ return geometry;
1577
+ }
1578
+ function labelTexture(label, color) {
1579
+ const size = 128;
1580
+ const canvas = document.createElement("canvas");
1581
+ canvas.width = size;
1582
+ canvas.height = size;
1583
+ const context = canvas.getContext("2d");
1584
+ if (context) {
1585
+ const text = label.toUpperCase();
1586
+ const hex = `#${color.toString(16).padStart(6, "0")}`;
1587
+ context.font = `600 ${size / 4}px system-ui, sans-serif`;
1588
+ const width = context.measureText("BOTTOM").width || 1;
1589
+ const scale = Math.min(1, size * 0.78 / width);
1590
+ context.font = `600 ${size / 4 * scale}px system-ui, sans-serif`;
1591
+ context.textAlign = "center";
1592
+ context.textBaseline = "middle";
1593
+ context.fillStyle = hex;
1594
+ context.fillText(text, size / 2, size / 2);
1595
+ }
1596
+ const texture = new CanvasTexture(canvas);
1597
+ texture.colorSpace = SRGBColorSpace;
1598
+ texture.needsUpdate = true;
1599
+ return texture;
1600
+ }
1601
+
1038
1602
  // src/viewer.tsx
1039
- import { Fragment, jsx, jsxs } from "react/jsx-runtime";
1040
- var ViewerControlsContext = createContext(null);
1603
+ import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
1604
+ var ViewerControlsContext = createContext2(null);
1041
1605
  var useViewerControls = () => {
1042
- const controls = useContext(ViewerControlsContext);
1606
+ const controls = useContext2(ViewerControlsContext);
1043
1607
  if (!controls) throw new Error("useViewerControls must be used inside <Viewer>");
1044
1608
  return controls;
1045
1609
  };
1610
+ var ViewerRetargetContext = createContext2(null);
1611
+ var useRetarget = () => useContext2(ViewerRetargetContext);
1046
1612
  var ViewerScene = ({
1047
1613
  children,
1048
1614
  setControls,
1049
1615
  projection,
1050
1616
  scheme,
1051
1617
  freeOrbit,
1618
+ zoomTo,
1619
+ recentreOnDoubleClick,
1620
+ retargetOnDoubleClick,
1621
+ showOrbitTarget,
1052
1622
  theme
1053
1623
  }) => {
1054
- const camera = useThree3((state) => state.camera);
1055
- const size = useThree3((state) => state.size);
1056
- const invalidate = useThree3((state) => state.invalidate);
1057
- const controlsRef = useRef2(null);
1058
- const contentRef = useRef2(null);
1059
- const lightsRef = useRef2(null);
1060
- const initialFrameComplete = useRef2(false);
1061
- const boundsRef = useRef2(defaultBounds());
1062
- const scratchBox = useMemo3(() => new Box33(), []);
1063
- const scratchDirection = useMemo3(() => new Vector35(), []);
1064
- const scratchView = useMemo3(() => new Vector35(), []);
1624
+ const camera = useThree4((state) => state.camera);
1625
+ const size = useThree4((state) => state.size);
1626
+ const invalidate = useThree4((state) => state.invalidate);
1627
+ const domElement = useThree4((state) => state.gl.domElement);
1628
+ const isTap = useTapGuard();
1629
+ const controlsRef = useRef3(null);
1630
+ const contentRef = useRef3(null);
1631
+ const lightsRef = useRef3(null);
1632
+ const initialFrameComplete = useRef3(false);
1633
+ const boundsRef = useRef3(defaultBounds());
1634
+ const framedRef = useRef3(null);
1635
+ const scratchBox = useMemo4(() => new Box33(), []);
1636
+ const scratchBoundary = useMemo4(() => new Box33(), []);
1637
+ const scratchDirection = useMemo4(() => new Vector37(), []);
1638
+ const scratchView = useMemo4(() => new Vector37(), []);
1639
+ const scratchTarget = useMemo4(() => new Vector37(), []);
1640
+ const scratchPosition = useMemo4(() => new Vector37(), []);
1641
+ const applyLimits = useCallback(
1642
+ (bounds, framed) => {
1643
+ controlsRef.current?.applyLimits(
1644
+ cameraLimits(projection, size, bounds, DEFAULT_FIT_MARGIN, framed),
1645
+ targetBoundary(bounds, scratchBoundary, DEFAULT_FIT_MARGIN)
1646
+ );
1647
+ },
1648
+ [projection, scratchBoundary, size]
1649
+ );
1065
1650
  const measure = useCallback(() => {
1066
1651
  const content = contentRef.current;
1067
1652
  boundsRef.current = content ? contentBounds(content, scratchBox) : defaultBounds();
1068
1653
  return boundsRef.current;
1069
1654
  }, [scratchBox]);
1070
1655
  const frame = useCallback(
1071
- (direction, transition = false) => {
1656
+ (direction, transition = false, up) => {
1072
1657
  const controls = controlsRef.current;
1073
1658
  if (!controls) return false;
1074
1659
  const bounds = measure();
@@ -1076,6 +1661,10 @@ var ViewerScene = ({
1076
1661
  const distance = fitDistance(projection, size, bounds, DEFAULT_FIT_MARGIN);
1077
1662
  const position = scratchDirection.copy(direction).normalize().multiplyScalar(distance);
1078
1663
  position.add(bounds.center);
1664
+ if (up) {
1665
+ camera.up.set(up.x, up.y, up.z);
1666
+ controls.updateCameraUp();
1667
+ }
1079
1668
  void controls.setLookAt(
1080
1669
  position.x,
1081
1670
  position.y,
@@ -1086,17 +1675,19 @@ var ViewerScene = ({
1086
1675
  transition
1087
1676
  );
1088
1677
  if (projection === "orthographic") void controls.zoomTo(1, transition);
1678
+ framedRef.current = null;
1679
+ applyLimits(bounds);
1089
1680
  invalidate();
1090
1681
  return true;
1091
1682
  },
1092
- [camera, invalidate, measure, projection, scratchDirection, size]
1683
+ [applyLimits, camera, invalidate, measure, projection, scratchDirection, size]
1093
1684
  );
1094
1685
  const frameBounds = useCallback(
1095
1686
  (bounds) => {
1096
1687
  const controls = controlsRef.current;
1097
1688
  if (!controls) return false;
1098
- const target = controls.getTarget(new Vector35());
1099
- const direction = currentViewDirection(camera, target, new Vector35());
1689
+ const target = controls.getTarget(new Vector37());
1690
+ const direction = currentViewDirection(camera, target, new Vector37());
1100
1691
  const distance = fitDistance(projection, size, bounds, DEFAULT_FIT_MARGIN);
1101
1692
  const position = direction.multiplyScalar(distance).add(bounds.center);
1102
1693
  void controls.setLookAt(
@@ -1108,26 +1699,65 @@ var ViewerScene = ({
1108
1699
  bounds.center.z,
1109
1700
  true
1110
1701
  );
1702
+ framedRef.current = bounds;
1703
+ applyLimits(boundsRef.current, bounds);
1111
1704
  if (projection === "orthographic") {
1112
1705
  void controls.zoomTo(boundsRef.current.radius / bounds.radius, true);
1113
1706
  }
1114
1707
  invalidate();
1115
1708
  return true;
1116
1709
  },
1117
- [camera, invalidate, projection, size]
1710
+ [applyLimits, camera, invalidate, projection, size]
1118
1711
  );
1119
1712
  const fitContent = useCallback(() => {
1120
1713
  const controls = controlsRef.current;
1121
1714
  if (!controls) return false;
1122
- const target = controls.getTarget(new Vector35());
1123
- return frame(currentViewDirection(camera, target, new Vector35()), true);
1715
+ const target = controls.getTarget(new Vector37());
1716
+ return frame(currentViewDirection(camera, target, new Vector37()), true);
1124
1717
  }, [camera, frame]);
1125
1718
  const resetContent = useCallback(() => {
1126
1719
  const bounds = measure();
1127
1720
  const start = startPosition(projection, size, bounds).sub(bounds.center);
1128
- return frame(start, true);
1721
+ return frame(start, true, CAD_CAMERA_UP);
1129
1722
  }, [frame, measure, projection, size]);
1130
- useEffect3(() => {
1723
+ const retarget = useCallback(
1724
+ (point) => {
1725
+ const controls = controlsRef.current;
1726
+ if (!controls) return;
1727
+ const pose = retargetPose(
1728
+ controls.getPosition(scratchPosition),
1729
+ controls.getTarget(scratchTarget),
1730
+ point
1731
+ );
1732
+ void controls.setLookAt(
1733
+ pose.position.x,
1734
+ pose.position.y,
1735
+ pose.position.z,
1736
+ pose.target.x,
1737
+ pose.target.y,
1738
+ pose.target.z,
1739
+ true
1740
+ );
1741
+ invalidate();
1742
+ },
1743
+ [invalidate, scratchPosition, scratchTarget]
1744
+ );
1745
+ const retargetOn = useMemo4(
1746
+ () => retargetOnDoubleClick ? retarget : null,
1747
+ [retarget, retargetOnDoubleClick]
1748
+ );
1749
+ useEffect4(() => {
1750
+ if (!recentreOnDoubleClick) return void 0;
1751
+ const doubles = trackDoubleTaps();
1752
+ const recentre = (event) => {
1753
+ if (event.button !== 1) return;
1754
+ if (!isTap(event)) return;
1755
+ if (doubles.isDouble(event)) fitContent();
1756
+ };
1757
+ domElement.addEventListener("auxclick", recentre);
1758
+ return () => domElement.removeEventListener("auxclick", recentre);
1759
+ }, [domElement, fitContent, isTap, recentreOnDoubleClick]);
1760
+ useEffect4(() => {
1131
1761
  setControls({
1132
1762
  fit: () => {
1133
1763
  fitContent();
@@ -1136,62 +1766,80 @@ var ViewerScene = ({
1136
1766
  resetContent();
1137
1767
  },
1138
1768
  setView: (view) => {
1139
- frame(cadViewDirections[view], true);
1769
+ frame(cadViewDirections[view], true, squaredUp(cadViewDirections[view], camera.up));
1140
1770
  },
1141
1771
  setViewDirection: (direction) => {
1142
- frame(scratchView.set(direction.x, direction.y, direction.z), true);
1772
+ frame(
1773
+ scratchView.set(direction.x, direction.y, direction.z),
1774
+ true,
1775
+ squaredUp(direction, camera.up)
1776
+ );
1143
1777
  },
1144
1778
  frameBox: (box) => {
1145
1779
  frameBounds(boundsFromBox(box));
1146
1780
  }
1147
1781
  });
1148
- }, [fitContent, frame, frameBounds, resetContent, scratchView, setControls]);
1149
- useEffect3(() => {
1782
+ }, [camera, fitContent, frame, frameBounds, resetContent, scratchView, setControls]);
1783
+ useEffect4(() => {
1150
1784
  if (initialFrameComplete.current) resetContent();
1151
1785
  }, [projection]);
1152
- useEffect3(() => {
1786
+ useEffect4(() => {
1153
1787
  applyProjection(camera, size, boundsRef.current, DEFAULT_FIT_MARGIN);
1788
+ if (initialFrameComplete.current) applyLimits(boundsRef.current, framedRef.current ?? void 0);
1154
1789
  invalidate();
1155
- }, [camera, invalidate, size]);
1156
- useFrame2(() => {
1790
+ }, [applyLimits, camera, invalidate, size]);
1791
+ useFrame3(() => {
1157
1792
  const lights = lightsRef.current;
1158
1793
  if (lights && !lights.quaternion.equals(camera.quaternion)) {
1159
1794
  lights.quaternion.copy(camera.quaternion);
1160
1795
  invalidate();
1161
1796
  }
1162
1797
  });
1163
- useFrame2(() => {
1798
+ useFrame3(() => {
1164
1799
  if (!initialFrameComplete.current && contentRef.current?.children.length) {
1165
1800
  initialFrameComplete.current = resetContent();
1166
1801
  }
1167
1802
  });
1168
- return /* @__PURE__ */ jsxs(Fragment, { children: [
1169
- /* @__PURE__ */ jsxs("group", { ref: lightsRef, children: [
1170
- /* @__PURE__ */ jsx("ambientLight", { color: theme.ambient, intensity: theme.ambientIntensity }),
1171
- /* @__PURE__ */ jsx(
1803
+ return /* @__PURE__ */ jsx3(ViewerTapProvider, { value: isTap, children: /* @__PURE__ */ jsxs2(ViewerRetargetContext.Provider, { value: retargetOn, children: [
1804
+ /* @__PURE__ */ jsxs2("group", { ref: lightsRef, children: [
1805
+ /* @__PURE__ */ jsx3("ambientLight", { color: theme.ambient, intensity: theme.ambientIntensity }),
1806
+ /* @__PURE__ */ jsx3(
1172
1807
  "hemisphereLight",
1173
1808
  {
1174
1809
  args: [theme.hemisphereSky, theme.hemisphereGround, theme.hemisphereIntensity]
1175
1810
  }
1176
1811
  )
1177
1812
  ] }),
1178
- /* @__PURE__ */ jsx("group", { ref: contentRef, children }),
1179
- /* @__PURE__ */ jsx(CadCameraControls, { controlsRef, scheme, freeOrbit })
1180
- ] });
1813
+ /* @__PURE__ */ jsx3("group", { ref: contentRef, children }),
1814
+ /* @__PURE__ */ jsx3(
1815
+ CadCameraControls,
1816
+ {
1817
+ controlsRef,
1818
+ scheme,
1819
+ freeOrbit,
1820
+ zoomTo
1821
+ }
1822
+ ),
1823
+ showOrbitTarget ? /* @__PURE__ */ jsx3(TargetMarker, { controlsRef }) : null
1824
+ ] }) });
1181
1825
  };
1182
1826
  var Viewer = forwardRef(function Viewer2({
1183
1827
  children,
1184
1828
  className,
1185
1829
  style,
1186
- projection = "perspective",
1830
+ projection = "orthographic",
1187
1831
  controls = "toolpath",
1188
1832
  freeOrbit = true,
1833
+ zoomTo = "cursor",
1834
+ recentreOnDoubleClick = true,
1835
+ retargetOnDoubleClick = true,
1836
+ showOrbitTarget = false,
1189
1837
  theme,
1190
1838
  onPointerMissed
1191
1839
  }, ref) {
1192
- const actionsRef = useRef2(null);
1193
- const resolved = useMemo3(() => resolveTheme(theme), [theme]);
1194
- const proxy = useMemo3(
1840
+ const actionsRef = useRef3(null);
1841
+ const resolved = useMemo4(() => resolveTheme(theme), [theme]);
1842
+ const proxy = useMemo4(
1195
1843
  () => ({
1196
1844
  fit: () => actionsRef.current?.fit(),
1197
1845
  reset: () => actionsRef.current?.reset(),
@@ -1205,47 +1853,62 @@ var Viewer = forwardRef(function Viewer2({
1205
1853
  const setControls = useCallback((next) => {
1206
1854
  actionsRef.current = next;
1207
1855
  }, []);
1208
- const tracker = useRef2(null);
1856
+ const tracker = useRef3(null);
1209
1857
  const hold = useCallback((element) => {
1210
1858
  tracker.current?.dispose();
1211
1859
  tracker.current = element ? trackTaps(element) : null;
1212
1860
  }, []);
1213
- return /* @__PURE__ */ jsx(ViewerControlsContext.Provider, { value: proxy, children: /* @__PURE__ */ jsx("div", { className, ref: hold, style: { height: "100%", width: "100%", ...style }, children: /* @__PURE__ */ jsx(
1214
- Canvas,
1861
+ return /* @__PURE__ */ jsx3(ViewerControlsContext.Provider, { value: proxy, children: /* @__PURE__ */ jsx3(
1862
+ "div",
1215
1863
  {
1216
- orthographic: projection === "orthographic",
1217
- camera: { fov: PERSPECTIVE_FOV, up: [0, 0, 1], position: [1, -1, 1] },
1218
- dpr: [1, 2],
1219
- frameloop: "demand",
1220
- gl: { antialias: true, alpha: true, stencil: true, localClippingEnabled: true },
1221
- onPointerMissed: (event) => {
1222
- if (event.button !== 0) return;
1223
- if (tracker.current?.isTap(event) ?? true) onPointerMissed?.();
1864
+ className,
1865
+ ref: hold,
1866
+ onMouseDown: (event) => {
1867
+ if (event.target instanceof HTMLCanvasElement) event.preventDefault();
1224
1868
  },
1225
- children: /* @__PURE__ */ jsx(
1226
- ViewerScene,
1869
+ style: { height: "100%", width: "100%", ...style },
1870
+ children: /* @__PURE__ */ jsx3(
1871
+ Canvas,
1227
1872
  {
1228
- setControls,
1229
- projection,
1230
- scheme: controls,
1231
- freeOrbit,
1232
- theme: resolved,
1233
- children
1234
- }
1873
+ orthographic: projection === "orthographic",
1874
+ camera: { fov: PERSPECTIVE_FOV, up: [0, 0, 1], position: [1, -1, 1] },
1875
+ dpr: [1, 2],
1876
+ frameloop: "demand",
1877
+ gl: { antialias: true, alpha: true, stencil: true, localClippingEnabled: true },
1878
+ onPointerMissed: (event) => {
1879
+ if (event.button !== 0) return;
1880
+ if (tracker.current?.isTap(event) ?? true) onPointerMissed?.();
1881
+ },
1882
+ children: /* @__PURE__ */ jsx3(
1883
+ ViewerScene,
1884
+ {
1885
+ setControls,
1886
+ projection,
1887
+ scheme: controls,
1888
+ freeOrbit,
1889
+ zoomTo,
1890
+ recentreOnDoubleClick,
1891
+ retargetOnDoubleClick,
1892
+ showOrbitTarget,
1893
+ theme: resolved,
1894
+ children
1895
+ }
1896
+ )
1897
+ },
1898
+ projection
1235
1899
  )
1236
- },
1237
- projection
1238
- ) }) });
1900
+ }
1901
+ ) });
1239
1902
  });
1240
1903
 
1241
1904
  // src/section-view.tsx
1242
- import { useFrame as useFrame3, useThree as useThree4 } from "@react-three/fiber";
1243
- import { useCallback as useCallback2, useMemo as useMemo4, useRef as useRef3, useState } from "react";
1905
+ import { useFrame as useFrame4, useThree as useThree5 } from "@react-three/fiber";
1906
+ import { useCallback as useCallback2, useMemo as useMemo5, useRef as useRef4, useState } from "react";
1244
1907
  import {
1245
1908
  AlwaysStencilFunc,
1246
1909
  BackSide,
1247
1910
  DecrementWrapStencilOp,
1248
- DoubleSide,
1911
+ DoubleSide as DoubleSide2,
1249
1912
  FrontSide,
1250
1913
  IncrementWrapStencilOp,
1251
1914
  NotEqualStencilFunc,
@@ -1254,22 +1917,22 @@ import {
1254
1917
  Raycaster as Raycaster2,
1255
1918
  ReplaceStencilOp,
1256
1919
  Vector2 as Vector22,
1257
- Vector3 as Vector37
1920
+ Vector3 as Vector39
1258
1921
  } from "three";
1259
1922
 
1260
1923
  // src/render/directions.ts
1261
- import { Vector3 as Vector36 } from "three";
1262
- var CONE_AXIS = new Vector36(0, 1, 0);
1924
+ import { Vector3 as Vector38 } from "three";
1925
+ var CONE_AXIS = new Vector38(0, 1, 0);
1263
1926
  var LENGTH = 0.45;
1264
1927
  var HEAD = 0.45;
1265
1928
  var HEAD_RADIUS = 0.3;
1266
1929
  var SHAFT_RADIUS = 0.09;
1267
1930
  var GAP = 0.2;
1268
1931
  function arrowPlacement(direction, box) {
1269
- const center = box.getCenter(new Vector36());
1270
- const half = box.getSize(new Vector36()).multiplyScalar(0.5);
1932
+ const center = box.getCenter(new Vector38());
1933
+ const half = box.getSize(new Vector38()).multiplyScalar(0.5);
1271
1934
  const radius = half.length() || 1;
1272
- const axis = new Vector36(direction.x, direction.y, direction.z);
1935
+ const axis = new Vector38(direction.x, direction.y, direction.z);
1273
1936
  if (axis.lengthSq() === 0) axis.set(0, 0, 1);
1274
1937
  axis.normalize();
1275
1938
  let exit = Number.POSITIVE_INFINITY;
@@ -1285,13 +1948,13 @@ function arrowPlacement(direction, box) {
1285
1948
  }
1286
1949
 
1287
1950
  // src/section-view.tsx
1288
- import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
1951
+ import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
1289
1952
  var DEFAULT_NORMAL = { x: 0, y: 0, z: 1 };
1290
- var FURNITURE = { [EXCLUDE_FROM_FRAME]: true };
1953
+ var FURNITURE2 = { [EXCLUDE_FROM_FRAME]: true };
1291
1954
  function resolveSectionPlane(options, box) {
1292
1955
  if (!options?.enabled || box.isEmpty()) return null;
1293
1956
  const normal = options.plane?.normal ?? options.normal ?? DEFAULT_NORMAL;
1294
- const axis = new Vector37(normal.x, normal.y, normal.z);
1957
+ const axis = new Vector39(normal.x, normal.y, normal.z);
1295
1958
  if (axis.lengthSq() === 0) axis.set(0, 0, 1);
1296
1959
  axis.normalize();
1297
1960
  const bounds = sectionBounds(box, normal);
@@ -1319,31 +1982,31 @@ var SectionView = ({
1319
1982
  showHandle,
1320
1983
  onDrag
1321
1984
  }) => {
1322
- const camera = useThree4((state) => state.camera);
1323
- const size = useThree4((state) => state.size);
1324
- const invalidate = useThree4((state) => state.invalidate);
1325
- const controls = useThree4((state) => state.controls);
1326
- const domElement = useThree4((state) => state.gl.domElement);
1327
- const capRef = useRef3(null);
1328
- const handleRef = useRef3(null);
1985
+ const camera = useThree5((state) => state.camera);
1986
+ const size = useThree5((state) => state.size);
1987
+ const invalidate = useThree5((state) => state.invalidate);
1988
+ const controls = useThree5((state) => state.controls);
1989
+ const domElement = useThree5((state) => state.gl.domElement);
1990
+ const capRef = useRef4(null);
1991
+ const handleRef = useRef4(null);
1329
1992
  const [hovered, setHovered] = useState(false);
1330
- const dragging = useRef3(null);
1331
- const centre = useMemo4(() => box.getCenter(new Vector37()), [box]);
1332
- const span = useMemo4(() => box.getSize(new Vector37()).length(), [box]);
1333
- const clip = useMemo4(() => [plane], [plane]);
1334
- const capPosition = useMemo4(() => {
1993
+ const dragging = useRef4(null);
1994
+ const centre = useMemo5(() => box.getCenter(new Vector39()), [box]);
1995
+ const span = useMemo5(() => box.getSize(new Vector39()).length(), [box]);
1996
+ const clip = useMemo5(() => [plane], [plane]);
1997
+ const capPosition = useMemo5(() => {
1335
1998
  const point = centre.clone();
1336
1999
  return point.addScaledVector(plane.normal, -(plane.constant + plane.normal.dot(centre)));
1337
2000
  }, [centre, plane]);
1338
- const capQuaternion = useMemo4(
1339
- () => new Quaternion2().setFromUnitVectors(new Vector37(0, 0, 1), plane.normal),
2001
+ const capQuaternion = useMemo5(
2002
+ () => new Quaternion2().setFromUnitVectors(new Vector39(0, 0, 1), plane.normal),
1340
2003
  [plane]
1341
2004
  );
1342
- const handleQuaternion = useMemo4(
2005
+ const handleQuaternion = useMemo5(
1343
2006
  () => new Quaternion2().setFromUnitVectors(CONE_AXIS, plane.normal.clone().negate()),
1344
2007
  [plane]
1345
2008
  );
1346
- useFrame3(() => {
2009
+ useFrame4(() => {
1347
2010
  const handle = handleRef.current;
1348
2011
  if (!handle) return;
1349
2012
  const length = screenLength(camera, capPosition, size, HANDLE_PIXELS);
@@ -1364,7 +2027,7 @@ var SectionView = ({
1364
2027
  event.stopPropagation();
1365
2028
  const view = camera.position.clone().sub(capPosition).normalize();
1366
2029
  const surface = dragPlane(plane.normal.clone(), view, capPosition);
1367
- const hit = event.ray.intersectPlane(surface, new Vector37());
2030
+ const hit = event.ray.intersectPlane(surface, new Vector39());
1368
2031
  const start = {
1369
2032
  plane: surface,
1370
2033
  from: hit ? hit.dot(plane.normal) : 0,
@@ -1381,7 +2044,7 @@ var SectionView = ({
1381
2044
  -((native.clientY - rect.top) / rect.height) * 2 + 1
1382
2045
  );
1383
2046
  raycaster.setFromCamera(pointer, camera);
1384
- const point = raycaster.ray.intersectPlane(start.plane, new Vector37());
2047
+ const point = raycaster.ray.intersectPlane(start.plane, new Vector39());
1385
2048
  if (!point) return;
1386
2049
  onDrag(start.constant - (point.dot(plane.normal) - start.from));
1387
2050
  invalidate();
@@ -1413,8 +2076,8 @@ var SectionView = ({
1413
2076
  // Excluded from framing, all of it. The cap is a quad half again as wide as
1414
2077
  // the part's diagonal, so a Fit that measured it would frame the cut rather
1415
2078
  // than the part and leave the part a speck in the middle of it.
1416
- /* @__PURE__ */ jsxs2("group", { userData: FURNITURE, children: [
1417
- /* @__PURE__ */ jsx2("mesh", { geometry, renderOrder: SECTION_RENDER_ORDER.stencil, raycast: () => null, children: /* @__PURE__ */ jsx2(
2079
+ /* @__PURE__ */ jsxs3("group", { userData: FURNITURE2, children: [
2080
+ /* @__PURE__ */ jsx4("mesh", { geometry, renderOrder: SECTION_RENDER_ORDER.stencil, raycast: () => null, children: /* @__PURE__ */ jsx4(
1418
2081
  "meshBasicMaterial",
1419
2082
  {
1420
2083
  side: BackSide,
@@ -1429,7 +2092,7 @@ var SectionView = ({
1429
2092
  clippingPlanes: clip
1430
2093
  }
1431
2094
  ) }),
1432
- /* @__PURE__ */ jsx2("mesh", { geometry, renderOrder: SECTION_RENDER_ORDER.stencil, raycast: () => null, children: /* @__PURE__ */ jsx2(
2095
+ /* @__PURE__ */ jsx4("mesh", { geometry, renderOrder: SECTION_RENDER_ORDER.stencil, raycast: () => null, children: /* @__PURE__ */ jsx4(
1433
2096
  "meshBasicMaterial",
1434
2097
  {
1435
2098
  side: FrontSide,
@@ -1444,13 +2107,13 @@ var SectionView = ({
1444
2107
  clippingPlanes: clip
1445
2108
  }
1446
2109
  ) }),
1447
- /* @__PURE__ */ jsx2("group", { ref: capRef, position: capPosition, quaternion: capQuaternion, children: /* @__PURE__ */ jsxs2("mesh", { renderOrder: SECTION_RENDER_ORDER.cap, raycast: () => null, children: [
1448
- /* @__PURE__ */ jsx2("planeGeometry", { args: [span * 1.5, span * 1.5] }),
1449
- /* @__PURE__ */ jsx2(
2110
+ /* @__PURE__ */ jsx4("group", { ref: capRef, position: capPosition, quaternion: capQuaternion, children: /* @__PURE__ */ jsxs3("mesh", { renderOrder: SECTION_RENDER_ORDER.cap, raycast: () => null, children: [
2111
+ /* @__PURE__ */ jsx4("planeGeometry", { args: [span * 1.5, span * 1.5] }),
2112
+ /* @__PURE__ */ jsx4(
1450
2113
  "meshBasicMaterial",
1451
2114
  {
1452
2115
  color: theme.sectionCap,
1453
- side: DoubleSide,
2116
+ side: DoubleSide2,
1454
2117
  stencilWrite: true,
1455
2118
  stencilRef: 0,
1456
2119
  stencilFunc: NotEqualStencilFunc,
@@ -1460,7 +2123,7 @@ var SectionView = ({
1460
2123
  }
1461
2124
  )
1462
2125
  ] }) }),
1463
- showHandle && onDrag ? /* @__PURE__ */ jsxs2(
2126
+ showHandle && onDrag ? /* @__PURE__ */ jsxs3(
1464
2127
  "group",
1465
2128
  {
1466
2129
  ref: handleRef,
@@ -1468,9 +2131,9 @@ var SectionView = ({
1468
2131
  quaternion: handleQuaternion,
1469
2132
  renderOrder: SECTION_RENDER_ORDER.handle,
1470
2133
  children: [
1471
- /* @__PURE__ */ jsxs2("mesh", { position: [0, 0.28, 0], ...grab, children: [
1472
- /* @__PURE__ */ jsx2("coneGeometry", { args: [0.16, 0.34, 20] }),
1473
- /* @__PURE__ */ jsx2(
2134
+ /* @__PURE__ */ jsxs3("mesh", { position: [0, 0.28, 0], ...grab, children: [
2135
+ /* @__PURE__ */ jsx4("coneGeometry", { args: [0.16, 0.34, 20] }),
2136
+ /* @__PURE__ */ jsx4(
1474
2137
  "meshBasicMaterial",
1475
2138
  {
1476
2139
  color: hovered ? theme.hover : theme.sectionHandle,
@@ -1478,9 +2141,9 @@ var SectionView = ({
1478
2141
  }
1479
2142
  )
1480
2143
  ] }),
1481
- /* @__PURE__ */ jsxs2("mesh", { position: [0, 0.08, 0], ...grab, children: [
1482
- /* @__PURE__ */ jsx2("cylinderGeometry", { args: [0.045, 0.045, 0.4, 12] }),
1483
- /* @__PURE__ */ jsx2(
2144
+ /* @__PURE__ */ jsxs3("mesh", { position: [0, 0.08, 0], ...grab, children: [
2145
+ /* @__PURE__ */ jsx4("cylinderGeometry", { args: [0.045, 0.045, 0.4, 12] }),
2146
+ /* @__PURE__ */ jsx4(
1484
2147
  "meshBasicMaterial",
1485
2148
  {
1486
2149
  color: hovered ? theme.hover : theme.sectionHandle,
@@ -1496,14 +2159,14 @@ var SectionView = ({
1496
2159
  };
1497
2160
 
1498
2161
  // src/content-box.ts
1499
- import { useFrame as useFrame4, useThree as useThree5 } from "@react-three/fiber";
1500
- import { useRef as useRef4, useState as useState2 } from "react";
2162
+ import { useFrame as useFrame5, useThree as useThree6 } from "@react-three/fiber";
2163
+ import { useRef as useRef5, useState as useState2 } from "react";
1501
2164
  import { Box3 as Box34 } from "three";
1502
2165
  function useContentBox() {
1503
- const scene = useThree5((state) => state.scene);
2166
+ const scene = useThree6((state) => state.scene);
1504
2167
  const [box, setBox] = useState2(() => new Box34());
1505
- const measured = useRef4(false);
1506
- useFrame4(() => {
2168
+ const measured = useRef5(false);
2169
+ useFrame5(() => {
1507
2170
  if (measured.current) return;
1508
2171
  const next = new Box34();
1509
2172
  contentBounds(scene, next);
@@ -1515,7 +2178,7 @@ function useContentBox() {
1515
2178
  }
1516
2179
 
1517
2180
  // src/part-mesh.tsx
1518
- import { Fragment as Fragment2, jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
2181
+ import { Fragment, jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
1519
2182
  var PartMesh = ({
1520
2183
  model,
1521
2184
  geometry,
@@ -1528,22 +2191,23 @@ var PartMesh = ({
1528
2191
  activeDirection = null,
1529
2192
  section,
1530
2193
  onSectionChange,
2194
+ onAdjacency,
1531
2195
  focusFeature = null,
1532
2196
  onHover,
1533
2197
  onPick,
1534
2198
  theme,
1535
2199
  showEdges = true
1536
2200
  }) => {
1537
- const { camera, controls, invalidate } = useThree6();
2201
+ const { camera, controls, invalidate } = useThree7();
1538
2202
  const viewerControls = useViewerControls();
1539
2203
  const resolved = useStableTheme(theme);
1540
- const currentTheme = useRef5(resolved);
2204
+ const currentTheme = useRef6(resolved);
1541
2205
  currentTheme.current = resolved;
1542
- const part = useMemo5(() => createPart(model, geometry, currentTheme.current), [geometry, model]);
1543
- const hoverRegion = useRef5(null);
2206
+ const part = useMemo6(() => createPart(model, geometry, currentTheme.current), [geometry, model]);
2207
+ const hoverRegion = useRef6(null);
1544
2208
  const box = useContentBox();
1545
- const cut = useMemo5(() => resolveSectionPlane(section, box), [box, section]);
1546
- const layers = useRef5({
2209
+ const cut = useMemo6(() => resolveSectionPlane(section, box), [box, section]);
2210
+ const layers = useRef6({
1547
2211
  selection,
1548
2212
  candidates,
1549
2213
  highlights,
@@ -1568,9 +2232,13 @@ var PartMesh = ({
1568
2232
  );
1569
2233
  invalidate();
1570
2234
  }, [invalidate, part]);
1571
- useEffect4(() => () => part.dispose(), [part]);
1572
- const framed = useRef5(null);
1573
- useEffect4(() => {
2235
+ useEffect5(() => () => part.dispose(), [part]);
2236
+ const adjacency = useMemo6(() => regionAdjacency(model, geometry), [model, geometry]);
2237
+ useEffect5(() => {
2238
+ onAdjacency?.(adjacency);
2239
+ }, [adjacency, onAdjacency]);
2240
+ const framed = useRef6(null);
2241
+ useEffect5(() => {
1574
2242
  if (focusFeature === null || focusFeature === framed.current) return;
1575
2243
  framed.current = focusFeature;
1576
2244
  const box2 = part.boxForFeature(focusFeature);
@@ -1588,7 +2256,7 @@ var PartMesh = ({
1588
2256
  part.setClippingPlanes(cut ? [cut.plane] : null);
1589
2257
  invalidate();
1590
2258
  }, [cut, invalidate, part]);
1591
- const reportedSection = useRef5("");
2259
+ const reportedSection = useRef6("");
1592
2260
  useLayoutEffect(() => {
1593
2261
  const state = cut?.state ?? null;
1594
2262
  const key = state ? `${state.constant}|${state.normal.x},${state.normal.y},${state.normal.z}` : "";
@@ -1608,7 +2276,9 @@ var PartMesh = ({
1608
2276
  repaint();
1609
2277
  }, [layerKey, repaint]);
1610
2278
  const isTap = useTapGuard();
1611
- const pickFor = (event) => {
2279
+ const retarget = useRetarget();
2280
+ const doubles = useMemo6(() => trackDoubleTaps(), []);
2281
+ const pickFor = (event, doubled = false) => {
1612
2282
  const triangleIndex = event.faceIndex;
1613
2283
  if (triangleIndex == null) return null;
1614
2284
  const region = model.regionIndex.regionForTriangle(triangleIndex);
@@ -1623,6 +2293,7 @@ var PartMesh = ({
1623
2293
  point: [event.point.x, event.point.y, event.point.z],
1624
2294
  normal: [normal.x, normal.y, normal.z],
1625
2295
  activeDirection,
2296
+ doubled,
1626
2297
  viewDirection: viewDirection(camera, target),
1627
2298
  modifiers: {
1628
2299
  alt: source.altKey,
@@ -1653,21 +2324,38 @@ var PartMesh = ({
1653
2324
  },
1654
2325
  [box, cut, onSectionChange, section]
1655
2326
  );
1656
- return /* @__PURE__ */ jsxs3(Fragment2, { children: [
1657
- /* @__PURE__ */ jsx3(
2327
+ return /* @__PURE__ */ jsxs4(Fragment, { children: [
2328
+ /* @__PURE__ */ jsx5(
1658
2329
  "primitive",
1659
2330
  {
1660
2331
  object: part.object,
1661
2332
  onPointerMove: (event) => emitHover(pickFor(event)),
1662
- onPointerOut: () => emitHover(null),
2333
+ onPointerOut: () => {
2334
+ emitHover(null);
2335
+ doubles.reset();
2336
+ },
1663
2337
  onClick: (event) => {
2338
+ if (!isTap(event.nativeEvent)) {
2339
+ doubles.reset();
2340
+ return;
2341
+ }
2342
+ const doubled = doubles.isDouble(event.nativeEvent);
2343
+ const pick = pickFor(event, doubled);
2344
+ if (doubled && retarget) retarget(event.point);
2345
+ if (pick) onPick?.(pick);
2346
+ },
2347
+ onPointerUp: (event) => {
2348
+ if (event.nativeEvent.button !== 2) return;
1664
2349
  if (!isTap(event.nativeEvent)) return;
1665
2350
  const pick = pickFor(event);
1666
2351
  if (pick) onPick?.(pick);
2352
+ },
2353
+ onContextMenu: (event) => {
2354
+ if (pickFor(event)) event.nativeEvent.preventDefault();
1667
2355
  }
1668
2356
  }
1669
2357
  ),
1670
- cut ? /* @__PURE__ */ jsx3(
2358
+ cut ? /* @__PURE__ */ jsx5(
1671
2359
  SectionView,
1672
2360
  {
1673
2361
  geometry,
@@ -1680,9 +2368,9 @@ var PartMesh = ({
1680
2368
  ) : null
1681
2369
  ] });
1682
2370
  };
1683
- var ORIGIN = new Vector38();
1684
- var UP = new Vector38(0, 0, 1);
1685
- var TARGET = new Vector38();
2371
+ var ORIGIN = new Vector310();
2372
+ var UP = new Vector310(0, 0, 1);
2373
+ var TARGET = new Vector310();
1686
2374
  function readTarget(controls) {
1687
2375
  if (controls && typeof controls.getTarget === "function") {
1688
2376
  return controls.getTarget(TARGET);
@@ -1691,14 +2379,14 @@ function readTarget(controls) {
1691
2379
  return target ?? ORIGIN;
1692
2380
  }
1693
2381
  function useStableTheme(theme) {
1694
- const held = useRef5(null);
2382
+ const held = useRef6(null);
1695
2383
  const next = resolveTheme(theme);
1696
2384
  if (held.current === null || !themesEqual(held.current, next)) held.current = next;
1697
2385
  return held.current;
1698
2386
  }
1699
2387
 
1700
2388
  // src/engine/geometry.ts
1701
- import { Matrix4 as Matrix42, Mesh as Mesh2 } from "three";
2389
+ import { Matrix4 as Matrix42, Mesh as Mesh3 } from "three";
1702
2390
  import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
1703
2391
  import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js";
1704
2392
  var PartMeshError = class extends Error {
@@ -1773,7 +2461,7 @@ function parseStl(data, mesh) {
1773
2461
  function singleMesh(scene) {
1774
2462
  const meshes = [];
1775
2463
  scene.traverse((object) => {
1776
- if (object instanceof Mesh2) meshes.push(object);
2464
+ if (object instanceof Mesh3) meshes.push(object);
1777
2465
  });
1778
2466
  const [mesh] = meshes;
1779
2467
  if (mesh === void 0 || meshes.length !== 1) {
@@ -1809,7 +2497,7 @@ function assertCounts(geometry, expected) {
1809
2497
  }
1810
2498
  function disposeMaterials(scene) {
1811
2499
  scene.traverse((object) => {
1812
- if (!(object instanceof Mesh2)) return;
2500
+ if (!(object instanceof Mesh3)) return;
1813
2501
  for (const material of Array.isArray(object.material) ? object.material : [object.material]) {
1814
2502
  material.dispose();
1815
2503
  }
@@ -1817,7 +2505,7 @@ function disposeMaterials(scene) {
1817
2505
  }
1818
2506
 
1819
2507
  // src/engine/normals.ts
1820
- import { Float32BufferAttribute as Float32BufferAttribute3 } from "three";
2508
+ import { Float32BufferAttribute as Float32BufferAttribute4 } from "three";
1821
2509
  function smoothRegionNormals(geometry, regions) {
1822
2510
  const position = geometry.getAttribute("position");
1823
2511
  if (!position || geometry.index) return;
@@ -1883,7 +2571,7 @@ function smoothRegionNormals(geometry, regions) {
1883
2571
  normals[vertex * 3 + 1] = y / length;
1884
2572
  normals[vertex * 3 + 2] = z / length;
1885
2573
  }
1886
- geometry.setAttribute("normal", new Float32BufferAttribute3(normals, 3));
2574
+ geometry.setAttribute("normal", new Float32BufferAttribute4(normals, 3));
1887
2575
  }
1888
2576
 
1889
2577
  // src/engine/geometry-cache.ts
@@ -2250,12 +2938,12 @@ function readDirections(value, issues) {
2250
2938
  }
2251
2939
  const directions = [];
2252
2940
  for (const [i, raw] of value.entries()) {
2253
- const vec = readVec3(raw);
2254
- if (vec === null) {
2941
+ const vec2 = readVec3(raw);
2942
+ if (vec2 === null) {
2255
2943
  issues.push(`candidateDirections[${i}] is not a vector`);
2256
2944
  continue;
2257
2945
  }
2258
- directions.push(vec);
2946
+ directions.push(vec2);
2259
2947
  }
2260
2948
  return directions;
2261
2949
  }
@@ -2306,15 +2994,15 @@ function compareVersions(a, b) {
2306
2994
  }
2307
2995
 
2308
2996
  // src/engine/engine-part.tsx
2309
- import { jsx as jsx4 } from "react/jsx-runtime";
2997
+ import { jsx as jsx6 } from "react/jsx-runtime";
2310
2998
  var EnginePart = ({ report, ...props }) => {
2311
- const model = useMemo6(() => normalizePartReport(report), [report]);
2999
+ const model = useMemo7(() => normalizePartReport(report), [report]);
2312
3000
  const resource = useGeometryResource(model);
2313
- useEffect5(() => {
3001
+ useEffect6(() => {
2314
3002
  engineGeometryCache.retain(resource);
2315
3003
  return () => engineGeometryCache.release(resource);
2316
3004
  }, [resource]);
2317
- return /* @__PURE__ */ jsx4(PartMesh, { model, geometry: resource.geometry, ...props });
3005
+ return /* @__PURE__ */ jsx6(PartMesh, { model, geometry: resource.geometry, ...props });
2318
3006
  };
2319
3007
  function useGeometryResource(part) {
2320
3008
  const resource = engineGeometryCache.get(part);
@@ -2350,11 +3038,12 @@ export {
2350
3038
  sectionFromPick,
2351
3039
  pickedStartDepth,
2352
3040
  sectionPlane,
2353
- screenLength,
2354
3041
  dragPlane,
2355
3042
  TAP_SLOP,
2356
3043
  movedFar,
2357
3044
  trackTaps,
3045
+ DOUBLE_TAP_MS,
3046
+ trackDoubleTaps,
2358
3047
  useTapGuard,
2359
3048
  visualSurfaces,
2360
3049
  regionEdgesGeometry,
@@ -2362,6 +3051,7 @@ export {
2362
3051
  buildRegionTexels,
2363
3052
  buildRegionAttribute,
2364
3053
  createPart,
3054
+ regionAdjacency,
2365
3055
  FEATURE_TYPE_RANKS,
2366
3056
  featureTypeRank,
2367
3057
  rankOwners,
@@ -2376,19 +3066,48 @@ export {
2376
3066
  EXCLUDE_FROM_FRAME,
2377
3067
  defaultBounds,
2378
3068
  aspectRatio,
3069
+ screenLength,
2379
3070
  boundsFromBox,
2380
3071
  contentBounds,
2381
3072
  perspectiveFitDistance,
2382
3073
  orthographicHalfHeight,
2383
3074
  fitDistance,
2384
3075
  startPosition,
3076
+ MIN_FRAME_RATIO,
3077
+ MAX_FRAME_RATIO,
3078
+ cameraLimits,
3079
+ targetBoundary,
2385
3080
  applyProjection,
2386
3081
  CAD_CAMERA_UP,
3082
+ adaptedUp,
2387
3083
  cadViewDirections,
2388
3084
  currentViewDirection,
2389
3085
  ExtendedCameraControls,
2390
3086
  CadCameraControls,
3087
+ retargetPose,
3088
+ ORBIT_TARGET_PIXELS,
3089
+ ORBIT_TARGET_RING_PIXELS,
3090
+ ORBIT_TARGET_RING_WIDTH,
3091
+ ORBIT_TARGET_COLOR,
3092
+ ORBIT_TARGET_RING_COLOR,
3093
+ ORBIT_TARGET_RING_OPACITY,
3094
+ ORBIT_TARGET_FLASH_MS,
3095
+ ORBIT_TARGET_FADE_MS,
3096
+ orbitTargetOpacity,
3097
+ VIEW_NAMES,
3098
+ VIEW_SIGNS,
3099
+ viewKind,
3100
+ viewVector,
3101
+ viewUp,
3102
+ squaredUp,
3103
+ CHAMFER,
3104
+ cubeZones,
3105
+ panelGeometry,
3106
+ cubeOutlineGeometry,
3107
+ labelGeometry,
3108
+ labelTexture,
2391
3109
  useViewerControls,
3110
+ useRetarget,
2392
3111
  Viewer,
2393
3112
  CONE_AXIS,
2394
3113
  HEAD,