@toolpath/viewer 0.3.1 → 0.4.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.
@@ -4,7 +4,7 @@ import { useEffect as useEffect5, useMemo as useMemo6 } from "react";
4
4
  // src/part-mesh.tsx
5
5
  import { useThree as useThree6 } from "@react-three/fiber";
6
6
  import { useCallback as useCallback3, useEffect as useEffect4, useLayoutEffect, useMemo as useMemo5, useRef as useRef5 } from "react";
7
- import { Vector3 as Vector38 } from "three";
7
+ import { Vector3 as Vector39 } 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,
@@ -272,6 +286,17 @@ 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
+ };
299
+ }
275
300
 
276
301
  // src/tap.tsx
277
302
  function useTapGuard() {
@@ -391,10 +416,11 @@ function buildRegionAttribute(model, texels, vertexCount) {
391
416
  function createPart(model, geometry, theme) {
392
417
  const position = geometry.getAttribute("position");
393
418
  const texels = buildRegionTexels(model);
394
- geometry.setAttribute(
395
- REGION_ATTRIBUTE,
396
- new Float32BufferAttribute2(buildRegionAttribute(model, texels, position.count), 1)
419
+ const regionAttribute = new Float32BufferAttribute2(
420
+ buildRegionAttribute(model, texels, position.count),
421
+ 1
397
422
  );
423
+ geometry.setAttribute(REGION_ATTRIBUTE, regionAttribute);
398
424
  const width = model.regions.length + 1;
399
425
  const state = new Uint8Array(width * 4);
400
426
  const stateTexture = new DataTexture(state, width, 1, RGBAFormat, UnsignedByteType);
@@ -430,7 +456,8 @@ function createPart(model, geometry, theme) {
430
456
  "#include <color_fragment>",
431
457
  `#include <color_fragment>
432
458
  regionState = texelFetch(uRegionState, ivec2(int(vRegion + 0.5), 0), 0);
433
- diffuseColor.rgb = mix(diffuseColor.rgb, regionState.rgb, regionState.a);`
459
+ diffuseColor.rgb = mix(diffuseColor.rgb, regionState.rgb, regionState.a);
460
+ `
434
461
  ).replace(
435
462
  "#include <emissivemap_fragment>",
436
463
  `#include <emissivemap_fragment>
@@ -520,7 +547,9 @@ function createPart(model, geometry, theme) {
520
547
  },
521
548
  dispose() {
522
549
  object.clear();
523
- geometry.deleteAttribute(REGION_ATTRIBUTE);
550
+ if (geometry.getAttribute(REGION_ATTRIBUTE) === regionAttribute) {
551
+ geometry.deleteAttribute(REGION_ATTRIBUTE);
552
+ }
524
553
  material.dispose();
525
554
  edgeGeometry.dispose();
526
555
  edgeMaterial.dispose();
@@ -529,6 +558,48 @@ function createPart(model, geometry, theme) {
529
558
  };
530
559
  }
531
560
 
561
+ // src/render/adjacency.ts
562
+ var GRID = 1e-4;
563
+ var keyFor = (x, y, z) => `${Math.round(x / GRID)},${Math.round(y / GRID)},${Math.round(z / GRID)}`;
564
+ function regionAdjacency(model, geometry) {
565
+ const position = geometry.getAttribute("position");
566
+ const adjacency = /* @__PURE__ */ new Map();
567
+ if (!position) return adjacency;
568
+ const owner = new Int32Array(Math.floor(position.count / 3)).fill(-1);
569
+ for (const region of model.regions) {
570
+ for (let at = region.triangles.start; at < region.triangles.end; at++) {
571
+ if (at >= 0 && at < owner.length) owner[at] = region.idx;
572
+ }
573
+ }
574
+ const edges = /* @__PURE__ */ new Map();
575
+ const join = (a, b) => {
576
+ if (a === b) return;
577
+ const mine = adjacency.get(a) ?? /* @__PURE__ */ new Set();
578
+ mine.add(b);
579
+ adjacency.set(a, mine);
580
+ const theirs = adjacency.get(b) ?? /* @__PURE__ */ new Set();
581
+ theirs.add(a);
582
+ adjacency.set(b, theirs);
583
+ };
584
+ for (let triangle = 0; triangle < owner.length; triangle++) {
585
+ const region = owner[triangle];
586
+ if (region === -1) continue;
587
+ const corners = [0, 1, 2].map((corner) => {
588
+ const at = triangle * 3 + corner;
589
+ return keyFor(position.getX(at), position.getY(at), position.getZ(at));
590
+ });
591
+ for (let corner = 0; corner < 3; corner++) {
592
+ const from = corners[corner];
593
+ const to = corners[(corner + 1) % 3];
594
+ const edge = from < to ? `${from}|${to}` : `${to}|${from}`;
595
+ const seen = edges.get(edge);
596
+ if (seen === void 0) edges.set(edge, region);
597
+ else join(seen, region);
598
+ }
599
+ }
600
+ return adjacency;
601
+ }
602
+
532
603
  // src/render/selection.ts
533
604
  var FEATURE_TYPE_RANKS = [
534
605
  ["blind_hole", "through_hole", "filleted_blind_hole"],
@@ -638,7 +709,7 @@ import {
638
709
  useMemo as useMemo3,
639
710
  useRef as useRef2
640
711
  } from "react";
641
- import { Box3 as Box33, Vector3 as Vector35 } from "three";
712
+ import { Box3 as Box33, Vector3 as Vector36 } from "three";
642
713
 
643
714
  // src/camera.tsx
644
715
  import { useFrame, useThree as useThree2 } from "@react-three/fiber";
@@ -990,7 +1061,8 @@ var ExtendedCameraControls = class extends CameraControls {
990
1061
  var CadCameraControls = ({
991
1062
  controlsRef,
992
1063
  scheme = "toolpath",
993
- freeOrbit = true
1064
+ freeOrbit = true,
1065
+ zoomTo = "cursor"
994
1066
  }) => {
995
1067
  const camera = useThree2((state) => state.camera);
996
1068
  const domElement = useThree2((state) => state.gl.domElement);
@@ -1029,12 +1101,255 @@ var CadCameraControls = ({
1029
1101
  useEffect2(() => {
1030
1102
  controls.setFreeOrbit(freeOrbit);
1031
1103
  }, [controls, freeOrbit]);
1104
+ useEffect2(() => {
1105
+ controls.dollyToCursor = zoomTo === "cursor";
1106
+ }, [controls, zoomTo]);
1032
1107
  useFrame((_, delta) => {
1033
1108
  controls.update(delta);
1034
1109
  });
1035
1110
  return null;
1036
1111
  };
1037
1112
 
1113
+ // src/render/view-cube.ts
1114
+ import {
1115
+ BufferGeometry,
1116
+ CanvasTexture,
1117
+ Float32BufferAttribute as Float32BufferAttribute3,
1118
+ SRGBColorSpace,
1119
+ Vector3 as Vector35
1120
+ } from "three";
1121
+ var VIEW_NAMES = [
1122
+ // The six faces.
1123
+ "top",
1124
+ "bottom",
1125
+ "front",
1126
+ "back",
1127
+ "left",
1128
+ "right",
1129
+ // The twelve edges.
1130
+ "top-front",
1131
+ "top-back",
1132
+ "top-left",
1133
+ "top-right",
1134
+ "bottom-front",
1135
+ "bottom-back",
1136
+ "bottom-left",
1137
+ "bottom-right",
1138
+ "front-left",
1139
+ "front-right",
1140
+ "back-left",
1141
+ "back-right",
1142
+ // The eight corners.
1143
+ "top-front-left",
1144
+ "top-front-right",
1145
+ "top-back-left",
1146
+ "top-back-right",
1147
+ "bottom-front-left",
1148
+ "bottom-front-right",
1149
+ "bottom-back-left",
1150
+ "bottom-back-right"
1151
+ ];
1152
+ var VIEW_SIGNS = {
1153
+ top: [0, 0, 1],
1154
+ bottom: [0, 0, -1],
1155
+ front: [0, -1, 0],
1156
+ back: [0, 1, 0],
1157
+ left: [-1, 0, 0],
1158
+ right: [1, 0, 0],
1159
+ "top-front": [0, -1, 1],
1160
+ "top-back": [0, 1, 1],
1161
+ "top-left": [-1, 0, 1],
1162
+ "top-right": [1, 0, 1],
1163
+ "bottom-front": [0, -1, -1],
1164
+ "bottom-back": [0, 1, -1],
1165
+ "bottom-left": [-1, 0, -1],
1166
+ "bottom-right": [1, 0, -1],
1167
+ "front-left": [-1, -1, 0],
1168
+ "front-right": [1, -1, 0],
1169
+ "back-left": [-1, 1, 0],
1170
+ "back-right": [1, 1, 0],
1171
+ "top-front-left": [-1, -1, 1],
1172
+ "top-front-right": [1, -1, 1],
1173
+ "top-back-left": [-1, 1, 1],
1174
+ "top-back-right": [1, 1, 1],
1175
+ "bottom-front-left": [-1, -1, -1],
1176
+ "bottom-front-right": [1, -1, -1],
1177
+ "bottom-back-left": [-1, 1, -1],
1178
+ "bottom-back-right": [1, 1, -1]
1179
+ };
1180
+ function viewKind(name) {
1181
+ const off = VIEW_SIGNS[name].filter((sign) => sign !== 0).length;
1182
+ if (off === 1) return "face";
1183
+ if (off === 2) return "edge";
1184
+ return "corner";
1185
+ }
1186
+ var DIRECTIONS = new Map(
1187
+ VIEW_NAMES.map((name) => {
1188
+ const [x, y, z] = VIEW_SIGNS[name];
1189
+ const length = Math.hypot(x, y, z);
1190
+ return [name, { x: x / length, y: y / length, z: z / length }];
1191
+ })
1192
+ );
1193
+ function viewVector(name) {
1194
+ return DIRECTIONS.get(name) ?? { x: 0, y: 0, z: 1 };
1195
+ }
1196
+ function viewUp(direction) {
1197
+ if (direction.x === 0 && direction.y === 0) {
1198
+ return { x: 0, y: direction.z > 0 ? 1 : -1, z: 0 };
1199
+ }
1200
+ return { x: 0, y: 0, z: 1 };
1201
+ }
1202
+ function squaredUp(direction, currentUp) {
1203
+ const forward = new Vector35(direction.x, direction.y, direction.z).normalize();
1204
+ const canonical = viewUp(direction);
1205
+ const zero = new Vector35(canonical.x, canonical.y, canonical.z);
1206
+ zero.addScaledVector(forward, -forward.dot(zero)).normalize();
1207
+ const quarter = new Vector35().crossVectors(forward, zero);
1208
+ const current = new Vector35(currentUp.x, currentUp.y, currentUp.z);
1209
+ const rolls = [zero, quarter, zero.clone().negate(), quarter.clone().negate()];
1210
+ const nearest = rolls.reduce(
1211
+ (best, roll) => roll.dot(current) > best.dot(current) ? roll : best
1212
+ );
1213
+ return vec(nearest);
1214
+ }
1215
+ var CHAMFER = 0.5834;
1216
+ function vec(v) {
1217
+ return { x: v.x, y: v.y, z: v.z };
1218
+ }
1219
+ function cubeZones(chamfer = CHAMFER) {
1220
+ const AXES = [0, 1, 2];
1221
+ return VIEW_NAMES.map((name) => {
1222
+ const kind = viewKind(name);
1223
+ const direction = viewVector(name);
1224
+ const [sx, sy, sz] = VIEW_SIGNS[name];
1225
+ const sign = (axis) => VIEW_SIGNS[name][axis] ?? 0;
1226
+ const normal = new Vector35(direction.x, direction.y, direction.z);
1227
+ let polygon;
1228
+ if (kind === "face") {
1229
+ const up = viewUp(direction);
1230
+ const upVector = new Vector35(up.x, up.y, up.z);
1231
+ const right = normal.clone().negate().cross(upVector).normalize();
1232
+ const square = [
1233
+ [-1, -1],
1234
+ [1, -1],
1235
+ [1, 1],
1236
+ [-1, 1]
1237
+ ];
1238
+ polygon = square.map(
1239
+ ([u, v]) => normal.clone().addScaledVector(right, u * chamfer).addScaledVector(upVector, v * chamfer)
1240
+ );
1241
+ } else if (kind === "edge") {
1242
+ const a = AXES.find((axis) => sign(axis) !== 0) ?? 0;
1243
+ const b = [...AXES].reverse().find((axis) => sign(axis) !== 0) ?? 0;
1244
+ const c = AXES.find((axis) => sign(axis) === 0) ?? 0;
1245
+ const rectangle = [
1246
+ [1, chamfer, -chamfer],
1247
+ [1, chamfer, chamfer],
1248
+ [chamfer, 1, chamfer],
1249
+ [chamfer, 1, -chamfer]
1250
+ ];
1251
+ polygon = rectangle.map(
1252
+ ([onA, onB, onC]) => new Vector35().setComponent(a, sign(a) * onA).setComponent(b, sign(b) * onB).setComponent(c, onC)
1253
+ );
1254
+ } else {
1255
+ const triangle = [
1256
+ [1, chamfer, chamfer],
1257
+ [chamfer, 1, chamfer],
1258
+ [chamfer, chamfer, 1]
1259
+ ];
1260
+ polygon = triangle.map(([onX, onY, onZ]) => new Vector35(sx * onX, sy * onY, sz * onZ));
1261
+ }
1262
+ const [first, second, third] = polygon;
1263
+ if (first && second && third) {
1264
+ const facing = new Vector35().subVectors(second, first).cross(new Vector35().subVectors(third, first));
1265
+ if (facing.dot(normal) < 0) polygon.reverse();
1266
+ }
1267
+ return { name, kind, direction, polygon: polygon.map(vec) };
1268
+ });
1269
+ }
1270
+ function panelGeometry(zone) {
1271
+ const positions = [];
1272
+ const normals = [];
1273
+ const [first] = zone.polygon;
1274
+ const geometry = new BufferGeometry();
1275
+ if (!first) return geometry;
1276
+ for (let index = 1; index + 1 < zone.polygon.length; index += 1) {
1277
+ const b = zone.polygon[index];
1278
+ const c = zone.polygon[index + 1];
1279
+ if (!b || !c) continue;
1280
+ positions.push(first.x, first.y, first.z, b.x, b.y, b.z, c.x, c.y, c.z);
1281
+ for (let vertex = 0; vertex < 3; vertex += 1) {
1282
+ normals.push(zone.direction.x, zone.direction.y, zone.direction.z);
1283
+ }
1284
+ }
1285
+ geometry.setAttribute("position", new Float32BufferAttribute3(positions, 3));
1286
+ geometry.setAttribute("normal", new Float32BufferAttribute3(normals, 3));
1287
+ return geometry;
1288
+ }
1289
+ function cubeOutlineGeometry(zones) {
1290
+ const positions = [];
1291
+ for (const zone of zones) {
1292
+ for (let index = 0; index < zone.polygon.length; index += 1) {
1293
+ const from = zone.polygon[index];
1294
+ const to = zone.polygon[(index + 1) % zone.polygon.length];
1295
+ if (!from || !to) continue;
1296
+ positions.push(from.x, from.y, from.z, to.x, to.y, to.z);
1297
+ }
1298
+ }
1299
+ const geometry = new BufferGeometry();
1300
+ geometry.setAttribute("position", new Float32BufferAttribute3(positions, 3));
1301
+ return geometry;
1302
+ }
1303
+ function labelGeometry(zone) {
1304
+ const positions = [];
1305
+ const uvs = [];
1306
+ const corners = [0, 1, 2, 0, 2, 3];
1307
+ const corner = [
1308
+ [0, 0],
1309
+ [1, 0],
1310
+ [1, 1],
1311
+ [0, 1]
1312
+ ];
1313
+ for (const index of corners) {
1314
+ const point = zone.polygon[index];
1315
+ const uv = corner[index];
1316
+ if (!point || !uv) continue;
1317
+ positions.push(
1318
+ point.x + zone.direction.x * 4e-3,
1319
+ point.y + zone.direction.y * 4e-3,
1320
+ point.z + zone.direction.z * 4e-3
1321
+ );
1322
+ uvs.push(uv[0] ?? 0, uv[1] ?? 0);
1323
+ }
1324
+ const geometry = new BufferGeometry();
1325
+ geometry.setAttribute("position", new Float32BufferAttribute3(positions, 3));
1326
+ geometry.setAttribute("uv", new Float32BufferAttribute3(uvs, 2));
1327
+ return geometry;
1328
+ }
1329
+ function labelTexture(label, color) {
1330
+ const size = 128;
1331
+ const canvas = document.createElement("canvas");
1332
+ canvas.width = size;
1333
+ canvas.height = size;
1334
+ const context = canvas.getContext("2d");
1335
+ if (context) {
1336
+ const text = label.toUpperCase();
1337
+ const hex = `#${color.toString(16).padStart(6, "0")}`;
1338
+ context.font = `600 ${size / 4}px system-ui, sans-serif`;
1339
+ const width = context.measureText("BOTTOM").width || 1;
1340
+ const scale = Math.min(1, size * 0.78 / width);
1341
+ context.font = `600 ${size / 4 * scale}px system-ui, sans-serif`;
1342
+ context.textAlign = "center";
1343
+ context.textBaseline = "middle";
1344
+ context.fillStyle = hex;
1345
+ context.fillText(text, size / 2, size / 2);
1346
+ }
1347
+ const texture = new CanvasTexture(canvas);
1348
+ texture.colorSpace = SRGBColorSpace;
1349
+ texture.needsUpdate = true;
1350
+ return texture;
1351
+ }
1352
+
1038
1353
  // src/viewer.tsx
1039
1354
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
1040
1355
  var ViewerControlsContext = createContext(null);
@@ -1049,26 +1364,29 @@ var ViewerScene = ({
1049
1364
  projection,
1050
1365
  scheme,
1051
1366
  freeOrbit,
1367
+ zoomTo,
1368
+ recentreOnDoubleClick,
1052
1369
  theme
1053
1370
  }) => {
1054
1371
  const camera = useThree3((state) => state.camera);
1055
1372
  const size = useThree3((state) => state.size);
1056
1373
  const invalidate = useThree3((state) => state.invalidate);
1374
+ const domElement = useThree3((state) => state.gl.domElement);
1057
1375
  const controlsRef = useRef2(null);
1058
1376
  const contentRef = useRef2(null);
1059
1377
  const lightsRef = useRef2(null);
1060
1378
  const initialFrameComplete = useRef2(false);
1061
1379
  const boundsRef = useRef2(defaultBounds());
1062
1380
  const scratchBox = useMemo3(() => new Box33(), []);
1063
- const scratchDirection = useMemo3(() => new Vector35(), []);
1064
- const scratchView = useMemo3(() => new Vector35(), []);
1381
+ const scratchDirection = useMemo3(() => new Vector36(), []);
1382
+ const scratchView = useMemo3(() => new Vector36(), []);
1065
1383
  const measure = useCallback(() => {
1066
1384
  const content = contentRef.current;
1067
1385
  boundsRef.current = content ? contentBounds(content, scratchBox) : defaultBounds();
1068
1386
  return boundsRef.current;
1069
1387
  }, [scratchBox]);
1070
1388
  const frame = useCallback(
1071
- (direction, transition = false) => {
1389
+ (direction, transition = false, up) => {
1072
1390
  const controls = controlsRef.current;
1073
1391
  if (!controls) return false;
1074
1392
  const bounds = measure();
@@ -1076,6 +1394,10 @@ var ViewerScene = ({
1076
1394
  const distance = fitDistance(projection, size, bounds, DEFAULT_FIT_MARGIN);
1077
1395
  const position = scratchDirection.copy(direction).normalize().multiplyScalar(distance);
1078
1396
  position.add(bounds.center);
1397
+ if (up) {
1398
+ camera.up.set(up.x, up.y, up.z);
1399
+ controls.updateCameraUp();
1400
+ }
1079
1401
  void controls.setLookAt(
1080
1402
  position.x,
1081
1403
  position.y,
@@ -1095,8 +1417,8 @@ var ViewerScene = ({
1095
1417
  (bounds) => {
1096
1418
  const controls = controlsRef.current;
1097
1419
  if (!controls) return false;
1098
- const target = controls.getTarget(new Vector35());
1099
- const direction = currentViewDirection(camera, target, new Vector35());
1420
+ const target = controls.getTarget(new Vector36());
1421
+ const direction = currentViewDirection(camera, target, new Vector36());
1100
1422
  const distance = fitDistance(projection, size, bounds, DEFAULT_FIT_MARGIN);
1101
1423
  const position = direction.multiplyScalar(distance).add(bounds.center);
1102
1424
  void controls.setLookAt(
@@ -1119,14 +1441,24 @@ var ViewerScene = ({
1119
1441
  const fitContent = useCallback(() => {
1120
1442
  const controls = controlsRef.current;
1121
1443
  if (!controls) return false;
1122
- const target = controls.getTarget(new Vector35());
1123
- return frame(currentViewDirection(camera, target, new Vector35()), true);
1444
+ const target = controls.getTarget(new Vector36());
1445
+ return frame(currentViewDirection(camera, target, new Vector36()), true);
1124
1446
  }, [camera, frame]);
1125
1447
  const resetContent = useCallback(() => {
1126
1448
  const bounds = measure();
1127
1449
  const start = startPosition(projection, size, bounds).sub(bounds.center);
1128
1450
  return frame(start, true);
1129
1451
  }, [frame, measure, projection, size]);
1452
+ useEffect3(() => {
1453
+ if (!recentreOnDoubleClick) return void 0;
1454
+ const doubles = trackDoubleTaps();
1455
+ const recentre = (event) => {
1456
+ if (event.button !== 1) return;
1457
+ if (doubles.isDouble(event)) fitContent();
1458
+ };
1459
+ domElement.addEventListener("auxclick", recentre);
1460
+ return () => domElement.removeEventListener("auxclick", recentre);
1461
+ }, [domElement, fitContent, recentreOnDoubleClick]);
1130
1462
  useEffect3(() => {
1131
1463
  setControls({
1132
1464
  fit: () => {
@@ -1136,16 +1468,20 @@ var ViewerScene = ({
1136
1468
  resetContent();
1137
1469
  },
1138
1470
  setView: (view) => {
1139
- frame(cadViewDirections[view], true);
1471
+ frame(cadViewDirections[view], true, squaredUp(cadViewDirections[view], camera.up));
1140
1472
  },
1141
1473
  setViewDirection: (direction) => {
1142
- frame(scratchView.set(direction.x, direction.y, direction.z), true);
1474
+ frame(
1475
+ scratchView.set(direction.x, direction.y, direction.z),
1476
+ true,
1477
+ squaredUp(direction, camera.up)
1478
+ );
1143
1479
  },
1144
1480
  frameBox: (box) => {
1145
1481
  frameBounds(boundsFromBox(box));
1146
1482
  }
1147
1483
  });
1148
- }, [fitContent, frame, frameBounds, resetContent, scratchView, setControls]);
1484
+ }, [camera, fitContent, frame, frameBounds, resetContent, scratchView, setControls]);
1149
1485
  useEffect3(() => {
1150
1486
  if (initialFrameComplete.current) resetContent();
1151
1487
  }, [projection]);
@@ -1176,7 +1512,15 @@ var ViewerScene = ({
1176
1512
  )
1177
1513
  ] }),
1178
1514
  /* @__PURE__ */ jsx("group", { ref: contentRef, children }),
1179
- /* @__PURE__ */ jsx(CadCameraControls, { controlsRef, scheme, freeOrbit })
1515
+ /* @__PURE__ */ jsx(
1516
+ CadCameraControls,
1517
+ {
1518
+ controlsRef,
1519
+ scheme,
1520
+ freeOrbit,
1521
+ zoomTo
1522
+ }
1523
+ )
1180
1524
  ] });
1181
1525
  };
1182
1526
  var Viewer = forwardRef(function Viewer2({
@@ -1186,6 +1530,8 @@ var Viewer = forwardRef(function Viewer2({
1186
1530
  projection = "perspective",
1187
1531
  controls = "toolpath",
1188
1532
  freeOrbit = true,
1533
+ zoomTo = "cursor",
1534
+ recentreOnDoubleClick = true,
1189
1535
  theme,
1190
1536
  onPointerMissed
1191
1537
  }, ref) {
@@ -1210,32 +1556,45 @@ var Viewer = forwardRef(function Viewer2({
1210
1556
  tracker.current?.dispose();
1211
1557
  tracker.current = element ? trackTaps(element) : null;
1212
1558
  }, []);
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,
1559
+ return /* @__PURE__ */ jsx(ViewerControlsContext.Provider, { value: proxy, children: /* @__PURE__ */ jsx(
1560
+ "div",
1215
1561
  {
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?.();
1562
+ className,
1563
+ ref: hold,
1564
+ onMouseDown: (event) => {
1565
+ if (event.target instanceof HTMLCanvasElement) event.preventDefault();
1224
1566
  },
1567
+ style: { height: "100%", width: "100%", ...style },
1225
1568
  children: /* @__PURE__ */ jsx(
1226
- ViewerScene,
1569
+ Canvas,
1227
1570
  {
1228
- setControls,
1229
- projection,
1230
- scheme: controls,
1231
- freeOrbit,
1232
- theme: resolved,
1233
- children
1234
- }
1571
+ orthographic: projection === "orthographic",
1572
+ camera: { fov: PERSPECTIVE_FOV, up: [0, 0, 1], position: [1, -1, 1] },
1573
+ dpr: [1, 2],
1574
+ frameloop: "demand",
1575
+ gl: { antialias: true, alpha: true, stencil: true, localClippingEnabled: true },
1576
+ onPointerMissed: (event) => {
1577
+ if (event.button !== 0) return;
1578
+ if (tracker.current?.isTap(event) ?? true) onPointerMissed?.();
1579
+ },
1580
+ children: /* @__PURE__ */ jsx(
1581
+ ViewerScene,
1582
+ {
1583
+ setControls,
1584
+ projection,
1585
+ scheme: controls,
1586
+ freeOrbit,
1587
+ zoomTo,
1588
+ recentreOnDoubleClick,
1589
+ theme: resolved,
1590
+ children
1591
+ }
1592
+ )
1593
+ },
1594
+ projection
1235
1595
  )
1236
- },
1237
- projection
1238
- ) }) });
1596
+ }
1597
+ ) });
1239
1598
  });
1240
1599
 
1241
1600
  // src/section-view.tsx
@@ -1254,22 +1613,22 @@ import {
1254
1613
  Raycaster as Raycaster2,
1255
1614
  ReplaceStencilOp,
1256
1615
  Vector2 as Vector22,
1257
- Vector3 as Vector37
1616
+ Vector3 as Vector38
1258
1617
  } from "three";
1259
1618
 
1260
1619
  // src/render/directions.ts
1261
- import { Vector3 as Vector36 } from "three";
1262
- var CONE_AXIS = new Vector36(0, 1, 0);
1620
+ import { Vector3 as Vector37 } from "three";
1621
+ var CONE_AXIS = new Vector37(0, 1, 0);
1263
1622
  var LENGTH = 0.45;
1264
1623
  var HEAD = 0.45;
1265
1624
  var HEAD_RADIUS = 0.3;
1266
1625
  var SHAFT_RADIUS = 0.09;
1267
1626
  var GAP = 0.2;
1268
1627
  function arrowPlacement(direction, box) {
1269
- const center = box.getCenter(new Vector36());
1270
- const half = box.getSize(new Vector36()).multiplyScalar(0.5);
1628
+ const center = box.getCenter(new Vector37());
1629
+ const half = box.getSize(new Vector37()).multiplyScalar(0.5);
1271
1630
  const radius = half.length() || 1;
1272
- const axis = new Vector36(direction.x, direction.y, direction.z);
1631
+ const axis = new Vector37(direction.x, direction.y, direction.z);
1273
1632
  if (axis.lengthSq() === 0) axis.set(0, 0, 1);
1274
1633
  axis.normalize();
1275
1634
  let exit = Number.POSITIVE_INFINITY;
@@ -1291,7 +1650,7 @@ var FURNITURE = { [EXCLUDE_FROM_FRAME]: true };
1291
1650
  function resolveSectionPlane(options, box) {
1292
1651
  if (!options?.enabled || box.isEmpty()) return null;
1293
1652
  const normal = options.plane?.normal ?? options.normal ?? DEFAULT_NORMAL;
1294
- const axis = new Vector37(normal.x, normal.y, normal.z);
1653
+ const axis = new Vector38(normal.x, normal.y, normal.z);
1295
1654
  if (axis.lengthSq() === 0) axis.set(0, 0, 1);
1296
1655
  axis.normalize();
1297
1656
  const bounds = sectionBounds(box, normal);
@@ -1328,15 +1687,15 @@ var SectionView = ({
1328
1687
  const handleRef = useRef3(null);
1329
1688
  const [hovered, setHovered] = useState(false);
1330
1689
  const dragging = useRef3(null);
1331
- const centre = useMemo4(() => box.getCenter(new Vector37()), [box]);
1332
- const span = useMemo4(() => box.getSize(new Vector37()).length(), [box]);
1690
+ const centre = useMemo4(() => box.getCenter(new Vector38()), [box]);
1691
+ const span = useMemo4(() => box.getSize(new Vector38()).length(), [box]);
1333
1692
  const clip = useMemo4(() => [plane], [plane]);
1334
1693
  const capPosition = useMemo4(() => {
1335
1694
  const point = centre.clone();
1336
1695
  return point.addScaledVector(plane.normal, -(plane.constant + plane.normal.dot(centre)));
1337
1696
  }, [centre, plane]);
1338
1697
  const capQuaternion = useMemo4(
1339
- () => new Quaternion2().setFromUnitVectors(new Vector37(0, 0, 1), plane.normal),
1698
+ () => new Quaternion2().setFromUnitVectors(new Vector38(0, 0, 1), plane.normal),
1340
1699
  [plane]
1341
1700
  );
1342
1701
  const handleQuaternion = useMemo4(
@@ -1364,7 +1723,7 @@ var SectionView = ({
1364
1723
  event.stopPropagation();
1365
1724
  const view = camera.position.clone().sub(capPosition).normalize();
1366
1725
  const surface = dragPlane(plane.normal.clone(), view, capPosition);
1367
- const hit = event.ray.intersectPlane(surface, new Vector37());
1726
+ const hit = event.ray.intersectPlane(surface, new Vector38());
1368
1727
  const start = {
1369
1728
  plane: surface,
1370
1729
  from: hit ? hit.dot(plane.normal) : 0,
@@ -1381,7 +1740,7 @@ var SectionView = ({
1381
1740
  -((native.clientY - rect.top) / rect.height) * 2 + 1
1382
1741
  );
1383
1742
  raycaster.setFromCamera(pointer, camera);
1384
- const point = raycaster.ray.intersectPlane(start.plane, new Vector37());
1743
+ const point = raycaster.ray.intersectPlane(start.plane, new Vector38());
1385
1744
  if (!point) return;
1386
1745
  onDrag(start.constant - (point.dot(plane.normal) - start.from));
1387
1746
  invalidate();
@@ -1528,6 +1887,7 @@ var PartMesh = ({
1528
1887
  activeDirection = null,
1529
1888
  section,
1530
1889
  onSectionChange,
1890
+ onAdjacency,
1531
1891
  focusFeature = null,
1532
1892
  onHover,
1533
1893
  onPick,
@@ -1569,6 +1929,10 @@ var PartMesh = ({
1569
1929
  invalidate();
1570
1930
  }, [invalidate, part]);
1571
1931
  useEffect4(() => () => part.dispose(), [part]);
1932
+ const adjacency = useMemo5(() => regionAdjacency(model, geometry), [model, geometry]);
1933
+ useEffect4(() => {
1934
+ onAdjacency?.(adjacency);
1935
+ }, [adjacency, onAdjacency]);
1572
1936
  const framed = useRef5(null);
1573
1937
  useEffect4(() => {
1574
1938
  if (focusFeature === null || focusFeature === framed.current) return;
@@ -1664,6 +2028,15 @@ var PartMesh = ({
1664
2028
  if (!isTap(event.nativeEvent)) return;
1665
2029
  const pick = pickFor(event);
1666
2030
  if (pick) onPick?.(pick);
2031
+ },
2032
+ onPointerUp: (event) => {
2033
+ if (event.nativeEvent.button !== 2) return;
2034
+ if (!isTap(event.nativeEvent)) return;
2035
+ const pick = pickFor(event);
2036
+ if (pick) onPick?.(pick);
2037
+ },
2038
+ onContextMenu: (event) => {
2039
+ if (pickFor(event)) event.nativeEvent.preventDefault();
1667
2040
  }
1668
2041
  }
1669
2042
  ),
@@ -1680,9 +2053,9 @@ var PartMesh = ({
1680
2053
  ) : null
1681
2054
  ] });
1682
2055
  };
1683
- var ORIGIN = new Vector38();
1684
- var UP = new Vector38(0, 0, 1);
1685
- var TARGET = new Vector38();
2056
+ var ORIGIN = new Vector39();
2057
+ var UP = new Vector39(0, 0, 1);
2058
+ var TARGET = new Vector39();
1686
2059
  function readTarget(controls) {
1687
2060
  if (controls && typeof controls.getTarget === "function") {
1688
2061
  return controls.getTarget(TARGET);
@@ -1817,7 +2190,7 @@ function disposeMaterials(scene) {
1817
2190
  }
1818
2191
 
1819
2192
  // src/engine/normals.ts
1820
- import { Float32BufferAttribute as Float32BufferAttribute3 } from "three";
2193
+ import { Float32BufferAttribute as Float32BufferAttribute4 } from "three";
1821
2194
  function smoothRegionNormals(geometry, regions) {
1822
2195
  const position = geometry.getAttribute("position");
1823
2196
  if (!position || geometry.index) return;
@@ -1883,7 +2256,7 @@ function smoothRegionNormals(geometry, regions) {
1883
2256
  normals[vertex * 3 + 1] = y / length;
1884
2257
  normals[vertex * 3 + 2] = z / length;
1885
2258
  }
1886
- geometry.setAttribute("normal", new Float32BufferAttribute3(normals, 3));
2259
+ geometry.setAttribute("normal", new Float32BufferAttribute4(normals, 3));
1887
2260
  }
1888
2261
 
1889
2262
  // src/engine/geometry-cache.ts
@@ -2250,12 +2623,12 @@ function readDirections(value, issues) {
2250
2623
  }
2251
2624
  const directions = [];
2252
2625
  for (const [i, raw] of value.entries()) {
2253
- const vec = readVec3(raw);
2254
- if (vec === null) {
2626
+ const vec2 = readVec3(raw);
2627
+ if (vec2 === null) {
2255
2628
  issues.push(`candidateDirections[${i}] is not a vector`);
2256
2629
  continue;
2257
2630
  }
2258
- directions.push(vec);
2631
+ directions.push(vec2);
2259
2632
  }
2260
2633
  return directions;
2261
2634
  }
@@ -2355,6 +2728,8 @@ export {
2355
2728
  TAP_SLOP,
2356
2729
  movedFar,
2357
2730
  trackTaps,
2731
+ DOUBLE_TAP_MS,
2732
+ trackDoubleTaps,
2358
2733
  useTapGuard,
2359
2734
  visualSurfaces,
2360
2735
  regionEdgesGeometry,
@@ -2362,6 +2737,7 @@ export {
2362
2737
  buildRegionTexels,
2363
2738
  buildRegionAttribute,
2364
2739
  createPart,
2740
+ regionAdjacency,
2365
2741
  FEATURE_TYPE_RANKS,
2366
2742
  featureTypeRank,
2367
2743
  rankOwners,
@@ -2388,6 +2764,18 @@ export {
2388
2764
  currentViewDirection,
2389
2765
  ExtendedCameraControls,
2390
2766
  CadCameraControls,
2767
+ VIEW_NAMES,
2768
+ VIEW_SIGNS,
2769
+ viewKind,
2770
+ viewVector,
2771
+ viewUp,
2772
+ squaredUp,
2773
+ CHAMFER,
2774
+ cubeZones,
2775
+ panelGeometry,
2776
+ cubeOutlineGeometry,
2777
+ labelGeometry,
2778
+ labelTexture,
2391
2779
  useViewerControls,
2392
2780
  Viewer,
2393
2781
  CONE_AXIS,