@toolpath/viewer 1.1.1 → 1.2.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,9 +1,16 @@
1
1
  // src/engine/engine-part.tsx
2
- import { useEffect as useEffect6, useMemo as useMemo7 } from "react";
2
+ import { useEffect as useEffect7, useMemo as useMemo7 } from "react";
3
3
 
4
4
  // src/part-mesh.tsx
5
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";
6
+ import {
7
+ useCallback as useCallback3,
8
+ useEffect as useEffect6,
9
+ useLayoutEffect as useLayoutEffect2,
10
+ useMemo as useMemo6,
11
+ useRef as useRef6,
12
+ useSyncExternalStore
13
+ } from "react";
7
14
  import { Vector3 as Vector310 } from "three";
8
15
 
9
16
  // src/model/directions.ts
@@ -120,10 +127,19 @@ var DEFAULT_THEME = {
120
127
  picked: 16347926,
121
128
  edge: 0,
122
129
  edgeOpacity: 0.5,
123
- sectionCap: 13093848,
130
+ sectionCap: 14672618,
131
+ sectionHatch: 9080998,
124
132
  sectionOutline: 7057587,
125
133
  sectionHandle: 15922167,
126
134
  sectionHandleOutline: 3948625,
135
+ /*
136
+ * A cool blue, and the snap a lighter one. Warm is spoken for — hover,
137
+ * picked and the selection are all on that side of the wheel — and the
138
+ * section tool's teal is the nearest neighbour, which a measurement line
139
+ * crossing a cutting plane has to stay legible against.
140
+ */
141
+ measure: 5213943,
142
+ measureSnap: 12572671,
127
143
  cube: 14080482,
128
144
  cubeEdge: 7435917,
129
145
  cubeLabel: 3948625
@@ -132,7 +148,7 @@ function resolveTheme(overrides) {
132
148
  return { ...DEFAULT_THEME, ...overrides };
133
149
  }
134
150
  function themesEqual(a, b) {
135
- return a.background === b.background && a.hemisphereSky === b.hemisphereSky && a.hemisphereGround === b.hemisphereGround && a.hemisphereIntensity === b.hemisphereIntensity && a.ambient === b.ambient && a.ambientIntensity === b.ambientIntensity && a.part === b.part && a.partEmissive === b.partEmissive && a.hover === b.hover && a.highlight === b.highlight && a.picked === b.picked && a.edge === b.edge && a.edgeOpacity === b.edgeOpacity && a.sectionCap === b.sectionCap && a.sectionOutline === b.sectionOutline && a.sectionHandle === b.sectionHandle && a.sectionHandleOutline === b.sectionHandleOutline && a.cube === b.cube && a.cubeEdge === b.cubeEdge && a.cubeLabel === b.cubeLabel;
151
+ return a.background === b.background && a.hemisphereSky === b.hemisphereSky && a.hemisphereGround === b.hemisphereGround && a.hemisphereIntensity === b.hemisphereIntensity && a.ambient === b.ambient && a.ambientIntensity === b.ambientIntensity && a.part === b.part && a.partEmissive === b.partEmissive && a.hover === b.hover && a.highlight === b.highlight && a.picked === b.picked && a.edge === b.edge && a.edgeOpacity === b.edgeOpacity && a.sectionCap === b.sectionCap && a.sectionHatch === b.sectionHatch && a.sectionOutline === b.sectionOutline && a.sectionHandle === b.sectionHandle && a.sectionHandleOutline === b.sectionHandleOutline && a.measure === b.measure && a.measureSnap === b.measureSnap && a.cube === b.cube && a.cubeEdge === b.cubeEdge && a.cubeLabel === b.cubeLabel;
136
152
  }
137
153
  function directionColor(index) {
138
154
  const wrapped = index % DIRECTION_COLORS.length;
@@ -178,19 +194,266 @@ function applyHighlightLayers(part, layers, theme) {
178
194
  }
179
195
 
180
196
  // src/render/section.ts
181
- import { Plane, Vector3 } from "three";
197
+ import { Plane, Vector3 as Vector32 } from "three";
198
+
199
+ // src/render/camera.ts
200
+ import { OrthographicCamera, Vector3 } from "three";
201
+ var PERSPECTIVE_FOV = 30;
202
+ var DEFAULT_FIT_MARGIN = 1.2;
203
+ var EXCLUDE_FROM_FRAME = "viewerExcludeFromFrame";
204
+ function defaultBounds() {
205
+ return { center: new Vector3(0, 0, 0), radius: 1 };
206
+ }
207
+ function aspectRatio(size) {
208
+ return size.width > 0 && size.height > 0 ? size.width / size.height : 1;
209
+ }
210
+ var SCREEN_LENGTH_EPSILON = 1e-9;
211
+ function screenLength(camera, point, viewport, pixels) {
212
+ const height = viewport.height > 0 ? viewport.height : 1;
213
+ const zoom = camera.zoom || 1;
214
+ 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;
215
+ return visible / height * pixels;
216
+ }
217
+ function boundsFromBox(box) {
218
+ if (box.isEmpty()) return defaultBounds();
219
+ const center = box.getCenter(new Vector3());
220
+ const radius = box.getSize(new Vector3()).length() / 2;
221
+ return { center, radius: radius > 0 ? radius : 1 };
222
+ }
223
+ function contentBounds(root, into) {
224
+ into.makeEmpty();
225
+ root.updateWorldMatrix(true, true);
226
+ root.traverse((object) => {
227
+ if (excludedFromFrame(object, root)) return;
228
+ if ("isMesh" in object || "isLine" in object || "isPoints" in object) {
229
+ into.expandByObject(object);
230
+ }
231
+ });
232
+ return boundsFromBox(into);
233
+ }
234
+ function excludedFromFrame(object, root) {
235
+ let current = object;
236
+ while (current && current !== root) {
237
+ if (current.userData[EXCLUDE_FROM_FRAME]) return true;
238
+ current = current.parent;
239
+ }
240
+ return false;
241
+ }
242
+ function perspectiveFitDistance(fovDegrees, aspect, radius) {
243
+ const verticalFov = fovDegrees * Math.PI / 180;
244
+ const horizontalFov = 2 * Math.atan(Math.tan(verticalFov / 2) * aspect);
245
+ return radius / Math.sin(Math.min(verticalFov, horizontalFov) / 2);
246
+ }
247
+ function orthographicHalfHeight(aspect, radius) {
248
+ return aspect >= 1 ? radius : radius / aspect;
249
+ }
250
+ function nearFar(projection, radius) {
251
+ if (projection === "orthographic") return { near: -radius * 100, far: radius * 100 };
252
+ return { near: radius / 100, far: radius * 100 };
253
+ }
254
+ var START_DIRECTION = {
255
+ orthographic: [1.2, -2.5, 3],
256
+ perspective: [2, 1.2, -2.5]
257
+ };
258
+ function fitDistance(projection, size, bounds, margin = DEFAULT_FIT_MARGIN) {
259
+ if (projection === "perspective") {
260
+ return perspectiveFitDistance(PERSPECTIVE_FOV, aspectRatio(size), bounds.radius * margin);
261
+ }
262
+ return bounds.radius * margin * 4;
263
+ }
264
+ function startPosition(projection, size, bounds, margin = DEFAULT_FIT_MARGIN) {
265
+ const [x, y, z] = START_DIRECTION[projection];
266
+ return new Vector3(x, y, z).normalize().multiplyScalar(fitDistance(projection, size, bounds, margin)).add(bounds.center);
267
+ }
268
+ var MIN_FRAME_RATIO = 0.25;
269
+ var MAX_FRAME_RATIO = 10;
270
+ var TARGET_BOUNDARY_RATIO = 4;
271
+ function cameraLimits(projection, size, bounds, margin = DEFAULT_FIT_MARGIN, framed) {
272
+ const fit = fitDistance(projection, size, bounds, margin);
273
+ const framedRadius = framed && framed.radius > 0 ? framed.radius : bounds.radius;
274
+ if (projection === "orthographic") {
275
+ const reach = bounds.radius > 0 ? bounds.radius / framedRadius : 1;
276
+ return {
277
+ minZoom: Math.min(MIN_FRAME_RATIO, MIN_FRAME_RATIO * reach),
278
+ maxZoom: Math.max(MAX_FRAME_RATIO, MAX_FRAME_RATIO * reach),
279
+ // Distance does not change apparent size under an orthographic camera, so
280
+ // these are not the zoom clamps in another form — they only keep the
281
+ // camera clear of the geometry it is looking at, and bounded.
282
+ minDistance: bounds.radius * margin,
283
+ maxDistance: fit / MIN_FRAME_RATIO
284
+ };
285
+ }
286
+ const framedFit = fitDistance(
287
+ projection,
288
+ size,
289
+ framedRadius === bounds.radius ? bounds : { ...bounds, radius: framedRadius },
290
+ margin
291
+ );
292
+ return {
293
+ // The wheel is `ACTION.DOLLY` under a perspective camera, so nothing here
294
+ // moves `camera.zoom`. They are set to the same pair anyway: a consumer
295
+ // that does zoom should not find the two cameras disagreeing about how far
296
+ // in is too far.
297
+ minZoom: MIN_FRAME_RATIO,
298
+ maxZoom: MAX_FRAME_RATIO,
299
+ minDistance: Math.min(fit, framedFit) / MAX_FRAME_RATIO,
300
+ maxDistance: Math.max(fit, framedFit) / MIN_FRAME_RATIO
301
+ };
302
+ }
303
+ var scratchBoundarySize = new Vector3();
304
+ function targetBoundary(bounds, into, margin = DEFAULT_FIT_MARGIN) {
305
+ const reach = bounds.radius * margin * TARGET_BOUNDARY_RATIO;
306
+ return into.setFromCenterAndSize(
307
+ bounds.center,
308
+ scratchBoundarySize.set(reach * 2, reach * 2, reach * 2)
309
+ );
310
+ }
311
+ function applyProjection(camera, size, bounds, margin = DEFAULT_FIT_MARGIN) {
312
+ const aspect = aspectRatio(size);
313
+ const radius = bounds.radius * margin;
314
+ if (camera instanceof OrthographicCamera) {
315
+ const halfHeight = orthographicHalfHeight(aspect, radius);
316
+ const halfWidth = halfHeight * aspect;
317
+ camera.left = -halfWidth;
318
+ camera.right = halfWidth;
319
+ camera.top = halfHeight;
320
+ camera.bottom = -halfHeight;
321
+ const { near, far } = nearFar("orthographic", radius);
322
+ camera.near = near;
323
+ camera.far = far;
324
+ } else {
325
+ camera.fov = PERSPECTIVE_FOV;
326
+ camera.aspect = aspect;
327
+ const { near, far } = nearFar("perspective", radius);
328
+ camera.near = near;
329
+ camera.far = far;
330
+ }
331
+ camera.updateProjectionMatrix();
332
+ }
333
+ var CAD_CAMERA_UP = new Vector3(0, 0, 1);
334
+ var scratchSide = new Vector3();
335
+ function adaptedUp(view, up, into) {
336
+ const side = scratchSide.crossVectors(view, up).normalize();
337
+ return into.crossVectors(side, view).normalize();
338
+ }
339
+ var cadViewDirections = {
340
+ front: new Vector3(0, -1, 0),
341
+ back: new Vector3(0, 1, 0),
342
+ left: new Vector3(-1, 0, 0),
343
+ right: new Vector3(1, 0, 0),
344
+ top: new Vector3(0, 0, 1),
345
+ bottom: new Vector3(0, 0, -1),
346
+ isometric: new Vector3(1, -1, 1).normalize()
347
+ };
348
+ function currentViewDirection(camera, target, into) {
349
+ into.subVectors(camera.position, target);
350
+ if (into.lengthSq() <= Number.EPSILON) return into.copy(cadViewDirections.isometric);
351
+ return into.normalize();
352
+ }
353
+
354
+ // src/render/section.ts
182
355
  var SECTION_RENDER_ORDER = {
183
356
  stencil: 1,
184
357
  cap: 2,
185
- handle: 6
358
+ handle: 6,
359
+ /** The tool's translucent plane and its outline, over everything but the handle. */
360
+ outline: 5
186
361
  };
187
362
  var START_DEPTH = 5e-3;
188
363
  var HANDLE_PIXELS = 78;
364
+ var CAP_HATCH = {
365
+ pitch: 9,
366
+ line: 1.25,
367
+ outline: 1.5
368
+ };
369
+ var AXES_PLANE_OFFSET = 0.55;
370
+ var AXES_PLANE_SCALE = 1.2;
371
+ var PREVIEW_SCALE = 0.125;
372
+ var OUTLINE_SCALE = 1.2;
189
373
  var EPSILON2 = 1e-9;
190
- var ARROW_AXIS = new Vector3(0, 1, 0);
374
+ var ARROW_AXIS = new Vector32(0, 1, 0);
375
+ var DEFAULT_SECTION_NORMAL = { x: 0, y: 0, z: 1 };
376
+ var DISABLED_SECTION = {
377
+ enabled: false,
378
+ normal: DEFAULT_SECTION_NORMAL,
379
+ offset: 0,
380
+ constant: 0,
381
+ plane: null,
382
+ depth: null,
383
+ depthRange: null
384
+ };
385
+ function sectionOptionsFromState(state) {
386
+ if (state.plane) {
387
+ return { enabled: state.enabled, plane: state.plane, depth: state.depth ?? 0 };
388
+ }
389
+ return { enabled: state.enabled, normal: state.normal, offset: state.offset };
390
+ }
391
+ function axesPlanes(box, eye) {
392
+ const centre = box.getCenter(new Vector32());
393
+ const extent = box.getSize(new Vector32());
394
+ const largest = Math.max(extent.x, extent.y, extent.z);
395
+ const size = largest * AXES_PLANE_SCALE;
396
+ const offset = largest * AXES_PLANE_OFFSET;
397
+ const side = (delta) => delta < 0 ? -1 : 1;
398
+ const sx = side(eye.x - centre.x);
399
+ const sy = side(eye.y - centre.y);
400
+ const sz = side(eye.z - centre.z);
401
+ return [
402
+ {
403
+ axis: "x",
404
+ sign: sx,
405
+ position: { x: centre.x - sx * offset, y: centre.y, z: centre.z },
406
+ size,
407
+ options: { enabled: true, normal: { x: -sx, y: 0, z: 0 }, offset: 0.5 }
408
+ },
409
+ {
410
+ axis: "y",
411
+ sign: sy,
412
+ position: { x: centre.x, y: centre.y - sy * offset, z: centre.z },
413
+ size,
414
+ options: { enabled: true, normal: { x: 0, y: -sy, z: 0 }, offset: 0.5 }
415
+ },
416
+ {
417
+ axis: "z",
418
+ sign: sz,
419
+ position: { x: centre.x, y: centre.y, z: centre.z - sz * offset },
420
+ size,
421
+ options: { enabled: true, normal: { x: 0, y: 0, z: -sz }, offset: 0.5 }
422
+ }
423
+ ];
424
+ }
425
+ function hitUnderRay(raycaster, root) {
426
+ for (const hit of raycaster.intersectObjects(root.children, true)) {
427
+ if (!("isMesh" in hit.object) || !hit.face) continue;
428
+ if (!hit.object.visible || excludedFromFrame(hit.object, root)) continue;
429
+ if (clippedAway(hit)) continue;
430
+ return hit;
431
+ }
432
+ return null;
433
+ }
434
+ function clippedAway(hit) {
435
+ const material = hit.object.material;
436
+ const materials = Array.isArray(material) ? material : [material];
437
+ for (const entry of materials) {
438
+ const planes = entry?.clippingPlanes;
439
+ if (!planes) continue;
440
+ for (const plane of planes) if (plane.distanceToPoint(hit.point) < 0) return true;
441
+ }
442
+ return false;
443
+ }
444
+ function surfaceUnderRay(raycaster, root) {
445
+ const hit = hitUnderRay(raycaster, root);
446
+ if (!hit?.face) return null;
447
+ const normal = hit.face.normal.clone().transformDirection(hit.object.matrixWorld);
448
+ return { point: hit.point, normal };
449
+ }
450
+ function sectionAnchor(box, plane, into = new Vector32()) {
451
+ const centre = box.getCenter(into);
452
+ return centre.addScaledVector(plane.normal, -(plane.constant + plane.normal.dot(centre)));
453
+ }
191
454
  var PICKED_SURFACE_LABEL = "Part surface";
192
455
  function unit(v) {
193
- const vector = new Vector3(v.x, v.y, v.z);
456
+ const vector = new Vector32(v.x, v.y, v.z);
194
457
  return vector.lengthSq() === 0 ? vector.set(0, 0, 1) : vector.normalize();
195
458
  }
196
459
  function clamp01(value) {
@@ -198,7 +461,7 @@ function clamp01(value) {
198
461
  }
199
462
  function sectionBounds(box, normal) {
200
463
  const axis = unit(normal);
201
- const corner = new Vector3();
464
+ const corner = new Vector32();
202
465
  let low = Number.POSITIVE_INFINITY;
203
466
  let high = Number.NEGATIVE_INFINITY;
204
467
  for (let i = 0; i < 8; i += 1) {
@@ -222,7 +485,7 @@ function sectionOffset(bounds, constant) {
222
485
  return span === 0 ? 0 : clamp01((constant - bounds.max) / span);
223
486
  }
224
487
  function sectionDepth(normal, anchor, constant) {
225
- return -unit(normal).dot(new Vector3(anchor.x, anchor.y, anchor.z)) - constant;
488
+ return -unit(normal).dot(new Vector32(anchor.x, anchor.y, anchor.z)) - constant;
226
489
  }
227
490
  function sectionDepthConstant(normal, anchor, depth) {
228
491
  return sectionDepth(normal, anchor, depth);
@@ -241,7 +504,7 @@ function sectionFromPick(surface, label = PICKED_SURFACE_LABEL) {
241
504
  };
242
505
  }
243
506
  function pickedStartDepth(box) {
244
- return box.getSize(new Vector3()).length() * START_DEPTH;
507
+ return box.getSize(new Vector32()).length() * START_DEPTH;
245
508
  }
246
509
  function sectionPlane(box, normal, offset, into = new Plane()) {
247
510
  const axis = unit(normal);
@@ -249,12 +512,12 @@ function sectionPlane(box, normal, offset, into = new Plane()) {
249
512
  }
250
513
  function dragPlane(axis, view, point, into = new Plane()) {
251
514
  const along = axis.lengthSq() === 0 ? ARROW_AXIS.clone() : axis.clone().normalize();
252
- const side = new Vector3().crossVectors(view, along);
515
+ const side = new Vector32().crossVectors(view, along);
253
516
  if (side.lengthSq() < EPSILON2) side.copy(perpendicular(along));
254
517
  return into.setFromNormalAndCoplanarPoint(side.cross(along).normalize(), point);
255
518
  }
256
519
  function perpendicular(axis) {
257
- const candidate = Math.abs(axis.x) < 0.9 ? new Vector3(1, 0, 0) : new Vector3(0, 1, 0);
520
+ const candidate = Math.abs(axis.x) < 0.9 ? new Vector32(1, 0, 0) : new Vector32(0, 1, 0);
258
521
  return candidate.cross(axis).normalize();
259
522
  }
260
523
 
@@ -338,7 +601,7 @@ import {
338
601
  MeshLambertMaterial,
339
602
  RGBAFormat,
340
603
  UnsignedByteType,
341
- Vector3 as Vector32
604
+ Vector3 as Vector33
342
605
  } from "three";
343
606
 
344
607
  // src/render/edges.ts
@@ -495,7 +758,7 @@ function createPart(model, geometry, theme) {
495
758
  const object = new Group();
496
759
  object.add(mesh, edges);
497
760
  const scratchColor = new Color();
498
- const scratchVector = new Vector32();
761
+ const scratchVector = new Vector33();
499
762
  const paintRegion = (region, color, weight) => {
500
763
  const column = texels.get(region);
501
764
  if (column === void 0) return;
@@ -728,158 +991,6 @@ import { Box3 as Box33, Vector3 as Vector37 } from "three";
728
991
  import { useFrame, useThree as useThree2 } from "@react-three/fiber";
729
992
  import { useEffect as useEffect2, useMemo as useMemo2 } from "react";
730
993
 
731
- // src/render/camera.ts
732
- import { OrthographicCamera, Vector3 as Vector33 } from "three";
733
- var PERSPECTIVE_FOV = 30;
734
- var DEFAULT_FIT_MARGIN = 1.2;
735
- var EXCLUDE_FROM_FRAME = "viewerExcludeFromFrame";
736
- function defaultBounds() {
737
- return { center: new Vector33(0, 0, 0), radius: 1 };
738
- }
739
- function aspectRatio(size) {
740
- return size.width > 0 && size.height > 0 ? size.width / size.height : 1;
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
- }
749
- function boundsFromBox(box) {
750
- if (box.isEmpty()) return defaultBounds();
751
- const center = box.getCenter(new Vector33());
752
- const radius = box.getSize(new Vector33()).length() / 2;
753
- return { center, radius: radius > 0 ? radius : 1 };
754
- }
755
- function contentBounds(root, into) {
756
- into.makeEmpty();
757
- root.updateWorldMatrix(true, true);
758
- root.traverse((object) => {
759
- if (object.userData[EXCLUDE_FROM_FRAME]) return;
760
- let ancestor = object.parent;
761
- while (ancestor && ancestor !== root) {
762
- if (ancestor.userData[EXCLUDE_FROM_FRAME]) return;
763
- ancestor = ancestor.parent;
764
- }
765
- if ("isMesh" in object || "isLine" in object || "isPoints" in object) {
766
- into.expandByObject(object);
767
- }
768
- });
769
- return boundsFromBox(into);
770
- }
771
- function perspectiveFitDistance(fovDegrees, aspect, radius) {
772
- const verticalFov = fovDegrees * Math.PI / 180;
773
- const horizontalFov = 2 * Math.atan(Math.tan(verticalFov / 2) * aspect);
774
- return radius / Math.sin(Math.min(verticalFov, horizontalFov) / 2);
775
- }
776
- function orthographicHalfHeight(aspect, radius) {
777
- return aspect >= 1 ? radius : radius / aspect;
778
- }
779
- function nearFar(projection, radius) {
780
- if (projection === "orthographic") return { near: -radius * 100, far: radius * 100 };
781
- return { near: radius / 100, far: radius * 100 };
782
- }
783
- var START_DIRECTION = {
784
- orthographic: [1.2, -2.5, 3],
785
- perspective: [2, 1.2, -2.5]
786
- };
787
- function fitDistance(projection, size, bounds, margin = DEFAULT_FIT_MARGIN) {
788
- if (projection === "perspective") {
789
- return perspectiveFitDistance(PERSPECTIVE_FOV, aspectRatio(size), bounds.radius * margin);
790
- }
791
- return bounds.radius * margin * 4;
792
- }
793
- function startPosition(projection, size, bounds, margin = DEFAULT_FIT_MARGIN) {
794
- const [x, y, z] = START_DIRECTION[projection];
795
- return new Vector33(x, y, z).normalize().multiplyScalar(fitDistance(projection, size, bounds, margin)).add(bounds.center);
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
- }
840
- function applyProjection(camera, size, bounds, margin = DEFAULT_FIT_MARGIN) {
841
- const aspect = aspectRatio(size);
842
- const radius = bounds.radius * margin;
843
- if (camera instanceof OrthographicCamera) {
844
- const halfHeight = orthographicHalfHeight(aspect, radius);
845
- const halfWidth = halfHeight * aspect;
846
- camera.left = -halfWidth;
847
- camera.right = halfWidth;
848
- camera.top = halfHeight;
849
- camera.bottom = -halfHeight;
850
- const { near, far } = nearFar("orthographic", radius);
851
- camera.near = near;
852
- camera.far = far;
853
- } else {
854
- camera.fov = PERSPECTIVE_FOV;
855
- camera.aspect = aspect;
856
- const { near, far } = nearFar("perspective", radius);
857
- camera.near = near;
858
- camera.far = far;
859
- }
860
- camera.updateProjectionMatrix();
861
- }
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
- }
868
- var cadViewDirections = {
869
- front: new Vector33(0, -1, 0),
870
- back: new Vector33(0, 1, 0),
871
- left: new Vector33(-1, 0, 0),
872
- right: new Vector33(1, 0, 0),
873
- top: new Vector33(0, 0, 1),
874
- bottom: new Vector33(0, 0, -1),
875
- isometric: new Vector33(1, -1, 1).normalize()
876
- };
877
- function currentViewDirection(camera, target, into) {
878
- into.subVectors(camera.position, target);
879
- if (into.lengthSq() <= Number.EPSILON) return into.copy(cadViewDirections.isometric);
880
- return into.normalize();
881
- }
882
-
883
994
  // src/render/controls.ts
884
995
  import CameraControls from "camera-controls";
885
996
  import {
@@ -1214,6 +1325,36 @@ function retargetPose(cameraPosition, currentTarget, hitPoint) {
1214
1325
  };
1215
1326
  }
1216
1327
 
1328
+ // src/render/section-store.ts
1329
+ function createSectionStore() {
1330
+ let current = null;
1331
+ let engaged = 0;
1332
+ const listeners = /* @__PURE__ */ new Set();
1333
+ const notify = () => {
1334
+ for (const listener of listeners) listener();
1335
+ };
1336
+ return {
1337
+ get: () => current,
1338
+ set: (next) => {
1339
+ if (next === current) return;
1340
+ current = next;
1341
+ notify();
1342
+ },
1343
+ isEngaged: () => engaged > 0,
1344
+ setEngaged: (next) => {
1345
+ const was = engaged > 0;
1346
+ engaged = Math.max(0, engaged + (next ? 1 : -1));
1347
+ if (engaged > 0 !== was) notify();
1348
+ },
1349
+ subscribe: (listener) => {
1350
+ listeners.add(listener);
1351
+ return () => {
1352
+ listeners.delete(listener);
1353
+ };
1354
+ }
1355
+ };
1356
+ }
1357
+
1217
1358
  // src/target-marker.tsx
1218
1359
  import { useFrame as useFrame2, useThree as useThree3 } from "@react-three/fiber";
1219
1360
  import { useEffect as useEffect3, useMemo as useMemo3, useRef as useRef2 } from "react";
@@ -1607,6 +1748,12 @@ var useViewerControls = () => {
1607
1748
  if (!controls) throw new Error("useViewerControls must be used inside <Viewer>");
1608
1749
  return controls;
1609
1750
  };
1751
+ var SectionStoreContext = createContext2(null);
1752
+ var useSectionStore = () => {
1753
+ const store = useContext2(SectionStoreContext);
1754
+ if (!store) throw new Error("useSectionStore must be used inside <Viewer>");
1755
+ return store;
1756
+ };
1610
1757
  var ViewerRetargetContext = createContext2(null);
1611
1758
  var useRetarget = () => useContext2(ViewerRetargetContext);
1612
1759
  var ViewerScene = ({
@@ -1759,6 +1906,9 @@ var ViewerScene = ({
1759
1906
  }, [domElement, fitContent, isTap, recentreOnDoubleClick]);
1760
1907
  useEffect4(() => {
1761
1908
  setControls({
1909
+ // Answered by the outer proxy, which owns the store; see `Viewer`.
1910
+ setSection: () => {
1911
+ },
1762
1912
  fit: () => {
1763
1913
  fitContent();
1764
1914
  },
@@ -1839,15 +1989,19 @@ var Viewer = forwardRef(function Viewer2({
1839
1989
  }, ref) {
1840
1990
  const actionsRef = useRef3(null);
1841
1991
  const resolved = useMemo4(() => resolveTheme(theme), [theme]);
1992
+ const sectionStore = useMemo4(() => createSectionStore(), []);
1842
1993
  const proxy = useMemo4(
1843
1994
  () => ({
1844
1995
  fit: () => actionsRef.current?.fit(),
1845
1996
  reset: () => actionsRef.current?.reset(),
1846
1997
  setView: (view) => actionsRef.current?.setView(view),
1847
1998
  setViewDirection: (direction) => actionsRef.current?.setViewDirection(direction),
1848
- frameBox: (box) => actionsRef.current?.frameBox(box)
1999
+ frameBox: (box) => actionsRef.current?.frameBox(box),
2000
+ // Straight to the store rather than through the scene: the store lives
2001
+ // here, so this works before the scene has mounted and after it is gone.
2002
+ setSection: (options) => sectionStore.set(options)
1849
2003
  }),
1850
- []
2004
+ [sectionStore]
1851
2005
  );
1852
2006
  useImperativeHandle(ref, () => proxy, [proxy]);
1853
2007
  const setControls = useCallback((next) => {
@@ -1858,7 +2012,7 @@ var Viewer = forwardRef(function Viewer2({
1858
2012
  tracker.current?.dispose();
1859
2013
  tracker.current = element ? trackTaps(element) : null;
1860
2014
  }, []);
1861
- return /* @__PURE__ */ jsx3(ViewerControlsContext.Provider, { value: proxy, children: /* @__PURE__ */ jsx3(
2015
+ return /* @__PURE__ */ jsx3(ViewerControlsContext.Provider, { value: proxy, children: /* @__PURE__ */ jsx3(SectionStoreContext.Provider, { value: sectionStore, children: /* @__PURE__ */ jsx3(
1862
2016
  "div",
1863
2017
  {
1864
2018
  className,
@@ -1898,25 +2052,21 @@ var Viewer = forwardRef(function Viewer2({
1898
2052
  projection
1899
2053
  )
1900
2054
  }
1901
- ) });
2055
+ ) }) });
1902
2056
  });
1903
2057
 
1904
2058
  // src/section-view.tsx
1905
2059
  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";
2060
+ import { useCallback as useCallback2, useEffect as useEffect5, useLayoutEffect, useMemo as useMemo5, useRef as useRef4, useState } from "react";
1907
2061
  import {
1908
- AlwaysStencilFunc,
1909
- BackSide,
1910
- DecrementWrapStencilOp,
1911
- DoubleSide as DoubleSide2,
1912
- FrontSide,
1913
- IncrementWrapStencilOp,
1914
- NotEqualStencilFunc,
2062
+ Color as Color3,
2063
+ Mesh as Mesh3,
1915
2064
  Plane as Plane2,
2065
+ PlaneGeometry,
1916
2066
  Quaternion as Quaternion2,
1917
2067
  Raycaster as Raycaster2,
1918
- ReplaceStencilOp,
1919
- Vector2 as Vector22,
2068
+ Scene,
2069
+ Vector2 as Vector23,
1920
2070
  Vector3 as Vector39
1921
2071
  } from "three";
1922
2072
 
@@ -1947,13 +2097,153 @@ function arrowPlacement(direction, box) {
1947
2097
  return { tip: center.addScaledVector(axis, exit + radius * GAP), length: radius * LENGTH };
1948
2098
  }
1949
2099
 
2100
+ // src/render/section-cap.ts
2101
+ import {
2102
+ AlwaysStencilFunc,
2103
+ BackSide,
2104
+ Color as Color2,
2105
+ DecrementWrapStencilOp,
2106
+ DoubleSide as DoubleSide2,
2107
+ FrontSide,
2108
+ IncrementWrapStencilOp,
2109
+ LinearFilter,
2110
+ MeshBasicMaterial as MeshBasicMaterial2,
2111
+ NotEqualStencilFunc,
2112
+ ReplaceStencilOp,
2113
+ ShaderMaterial,
2114
+ Vector2 as Vector22,
2115
+ WebGLRenderTarget
2116
+ } from "three";
2117
+ var VERTEX = (
2118
+ /* glsl */
2119
+ `
2120
+ void main() {
2121
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
2122
+ }
2123
+ `
2124
+ );
2125
+ var FRAGMENT = (
2126
+ /* glsl */
2127
+ `
2128
+ uniform sampler2D uMask;
2129
+ uniform vec2 uResolution;
2130
+ uniform vec3 uFill;
2131
+ uniform vec3 uHatch;
2132
+ uniform vec3 uOutline;
2133
+ uniform float uPitch;
2134
+ uniform float uLine;
2135
+ uniform float uThickness;
2136
+
2137
+ void main() {
2138
+ vec2 uv = gl_FragCoord.xy / uResolution;
2139
+ vec2 step = vec2(uThickness) / uResolution;
2140
+
2141
+ // The mask is 1 inside the cap and 0 outside; a change across a texel in any
2142
+ // direction is the cap's edge.
2143
+ float here = texture2D(uMask, uv).r;
2144
+ float edge =
2145
+ abs(here - texture2D(uMask, uv + vec2(step.x, 0.0)).r) +
2146
+ abs(here - texture2D(uMask, uv - vec2(step.x, 0.0)).r) +
2147
+ abs(here - texture2D(uMask, uv + vec2(0.0, step.y)).r) +
2148
+ abs(here - texture2D(uMask, uv - vec2(0.0, step.y)).r);
2149
+ edge = clamp(edge, 0.0, 1.0);
2150
+
2151
+ // Diagonal stripes at a fixed pitch on screen, anti-aliased over one pixel.
2152
+ float along = mod(gl_FragCoord.x + gl_FragCoord.y, uPitch);
2153
+ float stripe = 1.0 - smoothstep(uLine, uLine + 1.0, along);
2154
+
2155
+ vec3 color = mix(uFill, uHatch, stripe);
2156
+ color = mix(color, uOutline, edge);
2157
+ gl_FragColor = vec4(color, 1.0);
2158
+ #include <colorspace_fragment>
2159
+ }
2160
+ `
2161
+ );
2162
+ function createMaskTarget() {
2163
+ return new WebGLRenderTarget(1, 1, {
2164
+ minFilter: LinearFilter,
2165
+ magFilter: LinearFilter,
2166
+ depthBuffer: true,
2167
+ stencilBuffer: true
2168
+ });
2169
+ }
2170
+ function createCapMaterial(mask) {
2171
+ const uniforms = {
2172
+ uMask: { value: mask },
2173
+ uResolution: { value: new Vector22(1, 1) },
2174
+ uFill: { value: new Color2() },
2175
+ uHatch: { value: new Color2() },
2176
+ uOutline: { value: new Color2() },
2177
+ uPitch: { value: CAP_HATCH.pitch },
2178
+ uLine: { value: CAP_HATCH.line },
2179
+ uThickness: { value: CAP_HATCH.outline }
2180
+ };
2181
+ return new ShaderMaterial({
2182
+ vertexShader: VERTEX,
2183
+ fragmentShader: FRAGMENT,
2184
+ uniforms,
2185
+ side: DoubleSide2,
2186
+ stencilWrite: true,
2187
+ stencilRef: 0,
2188
+ stencilFunc: NotEqualStencilFunc,
2189
+ stencilFail: ReplaceStencilOp,
2190
+ stencilZFail: ReplaceStencilOp,
2191
+ stencilZPass: ReplaceStencilOp
2192
+ });
2193
+ }
2194
+ function applyCapTheme(material, theme, dpr) {
2195
+ material.uniforms.uFill.value.set(theme.sectionCap);
2196
+ material.uniforms.uHatch.value.set(theme.sectionHatch);
2197
+ material.uniforms.uOutline.value.set(theme.sectionHandleOutline);
2198
+ material.uniforms.uPitch.value = CAP_HATCH.pitch * dpr;
2199
+ material.uniforms.uLine.value = CAP_HATCH.line * dpr;
2200
+ material.uniforms.uThickness.value = CAP_HATCH.outline * dpr;
2201
+ }
2202
+ function createStencilMaterials(clip) {
2203
+ const shared = {
2204
+ depthWrite: false,
2205
+ depthTest: false,
2206
+ colorWrite: false,
2207
+ stencilWrite: true,
2208
+ stencilFunc: AlwaysStencilFunc,
2209
+ clippingPlanes: clip
2210
+ };
2211
+ return [
2212
+ new MeshBasicMaterial2({
2213
+ ...shared,
2214
+ side: BackSide,
2215
+ stencilFail: IncrementWrapStencilOp,
2216
+ stencilZFail: IncrementWrapStencilOp,
2217
+ stencilZPass: IncrementWrapStencilOp
2218
+ }),
2219
+ new MeshBasicMaterial2({
2220
+ ...shared,
2221
+ side: FrontSide,
2222
+ stencilFail: DecrementWrapStencilOp,
2223
+ stencilZFail: DecrementWrapStencilOp,
2224
+ stencilZPass: DecrementWrapStencilOp
2225
+ })
2226
+ ];
2227
+ }
2228
+ function createMaskMaterial() {
2229
+ return new MeshBasicMaterial2({
2230
+ color: 16777215,
2231
+ side: DoubleSide2,
2232
+ stencilWrite: true,
2233
+ stencilRef: 0,
2234
+ stencilFunc: NotEqualStencilFunc,
2235
+ stencilFail: ReplaceStencilOp,
2236
+ stencilZFail: ReplaceStencilOp,
2237
+ stencilZPass: ReplaceStencilOp
2238
+ });
2239
+ }
2240
+
1950
2241
  // src/section-view.tsx
1951
2242
  import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
1952
- var DEFAULT_NORMAL = { x: 0, y: 0, z: 1 };
1953
2243
  var FURNITURE2 = { [EXCLUDE_FROM_FRAME]: true };
1954
2244
  function resolveSectionPlane(options, box) {
1955
2245
  if (!options?.enabled || box.isEmpty()) return null;
1956
- const normal = options.plane?.normal ?? options.normal ?? DEFAULT_NORMAL;
2246
+ const normal = options.plane?.normal ?? options.normal ?? DEFAULT_SECTION_NORMAL;
1957
2247
  const axis = new Vector39(normal.x, normal.y, normal.z);
1958
2248
  if (axis.lengthSq() === 0) axis.set(0, 0, 1);
1959
2249
  axis.normalize();
@@ -1974,6 +2264,7 @@ function resolveSectionPlane(options, box) {
1974
2264
  }
1975
2265
  };
1976
2266
  }
2267
+ var Z = new Vector39(0, 0, 1);
1977
2268
  var SectionView = ({
1978
2269
  geometry,
1979
2270
  box,
@@ -1984,28 +2275,68 @@ var SectionView = ({
1984
2275
  }) => {
1985
2276
  const camera = useThree5((state) => state.camera);
1986
2277
  const size = useThree5((state) => state.size);
2278
+ const dpr = useThree5((state) => state.viewport.dpr);
2279
+ const gl = useThree5((state) => state.gl);
1987
2280
  const invalidate = useThree5((state) => state.invalidate);
1988
2281
  const controls = useThree5((state) => state.controls);
1989
2282
  const domElement = useThree5((state) => state.gl.domElement);
1990
- const capRef = useRef4(null);
1991
2283
  const handleRef = useRef4(null);
1992
2284
  const [hovered, setHovered] = useState(false);
1993
2285
  const dragging = useRef4(null);
1994
- const centre = useMemo5(() => box.getCenter(new Vector39()), [box]);
1995
2286
  const span = useMemo5(() => box.getSize(new Vector39()).length(), [box]);
1996
2287
  const clip = useMemo5(() => [plane], [plane]);
1997
- const capPosition = useMemo5(() => {
1998
- const point = centre.clone();
1999
- return point.addScaledVector(plane.normal, -(plane.constant + plane.normal.dot(centre)));
2000
- }, [centre, plane]);
2001
- const capQuaternion = useMemo5(
2002
- () => new Quaternion2().setFromUnitVectors(new Vector39(0, 0, 1), plane.normal),
2003
- [plane]
2004
- );
2288
+ const capPosition = useMemo5(() => sectionAnchor(box, plane), [box, plane]);
2289
+ const capQuaternion = useMemo5(() => new Quaternion2().setFromUnitVectors(Z, plane.normal), [plane]);
2005
2290
  const handleQuaternion = useMemo5(
2006
2291
  () => new Quaternion2().setFromUnitVectors(CONE_AXIS, plane.normal.clone().negate()),
2007
2292
  [plane]
2008
2293
  );
2294
+ const stencils = useMemo5(() => createStencilMaterials([]), []);
2295
+ useEffect5(() => () => stencils.forEach((material) => material.dispose()), [stencils]);
2296
+ useLayoutEffect(() => {
2297
+ for (const material of stencils) material.clippingPlanes = clip;
2298
+ }, [clip, stencils]);
2299
+ const mask = useMemo5(() => createMaskTarget(), []);
2300
+ useEffect5(() => () => mask.dispose(), [mask]);
2301
+ const cap = useMemo5(() => createCapMaterial(mask.texture), [mask]);
2302
+ useEffect5(() => () => cap.dispose(), [cap]);
2303
+ const quad = useMemo5(() => new PlaneGeometry(span * 1.5, span * 1.5), [span]);
2304
+ useEffect5(() => () => quad.dispose(), [quad]);
2305
+ const maskScene = useMemo5(() => {
2306
+ const scene = new Scene();
2307
+ const [back, front] = stencils;
2308
+ const backMesh = new Mesh3(geometry, back);
2309
+ backMesh.renderOrder = SECTION_RENDER_ORDER.stencil;
2310
+ const frontMesh = new Mesh3(geometry, front);
2311
+ frontMesh.renderOrder = SECTION_RENDER_ORDER.stencil;
2312
+ const maskMesh = new Mesh3(quad, createMaskMaterial());
2313
+ maskMesh.renderOrder = SECTION_RENDER_ORDER.cap;
2314
+ scene.add(backMesh, frontMesh, maskMesh);
2315
+ return { scene, maskMesh };
2316
+ }, [geometry, quad, stencils]);
2317
+ useEffect5(() => () => maskScene.maskMesh.material.dispose(), [maskScene]);
2318
+ useEffect5(() => {
2319
+ maskScene.maskMesh.position.copy(capPosition);
2320
+ maskScene.maskMesh.quaternion.copy(capQuaternion);
2321
+ }, [capPosition, capQuaternion, maskScene]);
2322
+ useLayoutEffect(() => {
2323
+ const buffer = gl.getDrawingBufferSize(new Vector23());
2324
+ mask.setSize(buffer.x, buffer.y);
2325
+ cap.uniforms.uResolution.value.copy(buffer);
2326
+ applyCapTheme(cap, theme, dpr);
2327
+ invalidate();
2328
+ }, [cap, dpr, gl, invalidate, mask, size, theme]);
2329
+ const clearColor = useMemo5(() => new Color3(), []);
2330
+ useFrame4(({ gl: renderer, camera: eye }) => {
2331
+ const previousAlpha = renderer.getClearAlpha();
2332
+ renderer.getClearColor(clearColor);
2333
+ renderer.setRenderTarget(mask);
2334
+ renderer.setClearColor(0, 1);
2335
+ renderer.clear();
2336
+ renderer.render(maskScene.scene, eye);
2337
+ renderer.setRenderTarget(null);
2338
+ renderer.setClearColor(clearColor, previousAlpha);
2339
+ }, -1);
2009
2340
  useFrame4(() => {
2010
2341
  const handle = handleRef.current;
2011
2342
  if (!handle) return;
@@ -2036,7 +2367,7 @@ var SectionView = ({
2036
2367
  dragging.current = start;
2037
2368
  setControlsEnabled(false);
2038
2369
  const raycaster = new Raycaster2();
2039
- const pointer = new Vector22();
2370
+ const pointer = new Vector23();
2040
2371
  const move = (native) => {
2041
2372
  const rect = domElement.getBoundingClientRect();
2042
2373
  pointer.set(
@@ -2077,52 +2408,35 @@ var SectionView = ({
2077
2408
  // the part's diagonal, so a Fit that measured it would frame the cut rather
2078
2409
  // than the part and leave the part a speck in the middle of it.
2079
2410
  /* @__PURE__ */ jsxs3("group", { userData: FURNITURE2, children: [
2080
- /* @__PURE__ */ jsx4("mesh", { geometry, renderOrder: SECTION_RENDER_ORDER.stencil, raycast: () => null, children: /* @__PURE__ */ jsx4(
2081
- "meshBasicMaterial",
2411
+ /* @__PURE__ */ jsx4(
2412
+ "mesh",
2082
2413
  {
2083
- side: BackSide,
2084
- depthWrite: false,
2085
- depthTest: false,
2086
- colorWrite: false,
2087
- stencilWrite: true,
2088
- stencilFunc: AlwaysStencilFunc,
2089
- stencilFail: IncrementWrapStencilOp,
2090
- stencilZFail: IncrementWrapStencilOp,
2091
- stencilZPass: IncrementWrapStencilOp,
2092
- clippingPlanes: clip
2414
+ geometry,
2415
+ material: stencils[0],
2416
+ renderOrder: SECTION_RENDER_ORDER.stencil,
2417
+ raycast: () => null
2093
2418
  }
2094
- ) }),
2095
- /* @__PURE__ */ jsx4("mesh", { geometry, renderOrder: SECTION_RENDER_ORDER.stencil, raycast: () => null, children: /* @__PURE__ */ jsx4(
2096
- "meshBasicMaterial",
2419
+ ),
2420
+ /* @__PURE__ */ jsx4(
2421
+ "mesh",
2097
2422
  {
2098
- side: FrontSide,
2099
- depthWrite: false,
2100
- depthTest: false,
2101
- colorWrite: false,
2102
- stencilWrite: true,
2103
- stencilFunc: AlwaysStencilFunc,
2104
- stencilFail: DecrementWrapStencilOp,
2105
- stencilZFail: DecrementWrapStencilOp,
2106
- stencilZPass: DecrementWrapStencilOp,
2107
- clippingPlanes: clip
2423
+ geometry,
2424
+ material: stencils[1],
2425
+ renderOrder: SECTION_RENDER_ORDER.stencil,
2426
+ raycast: () => null
2108
2427
  }
2109
- ) }),
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(
2113
- "meshBasicMaterial",
2114
- {
2115
- color: theme.sectionCap,
2116
- side: DoubleSide2,
2117
- stencilWrite: true,
2118
- stencilRef: 0,
2119
- stencilFunc: NotEqualStencilFunc,
2120
- stencilFail: ReplaceStencilOp,
2121
- stencilZFail: ReplaceStencilOp,
2122
- stencilZPass: ReplaceStencilOp
2123
- }
2124
- )
2125
- ] }) }),
2428
+ ),
2429
+ /* @__PURE__ */ jsx4(
2430
+ "mesh",
2431
+ {
2432
+ geometry: quad,
2433
+ material: cap,
2434
+ position: capPosition,
2435
+ quaternion: capQuaternion,
2436
+ renderOrder: SECTION_RENDER_ORDER.cap,
2437
+ raycast: () => null
2438
+ }
2439
+ ),
2126
2440
  showHandle && onDrag ? /* @__PURE__ */ jsxs3(
2127
2441
  "group",
2128
2442
  {
@@ -2206,7 +2520,13 @@ var PartMesh = ({
2206
2520
  const part = useMemo6(() => createPart(model, geometry, currentTheme.current), [geometry, model]);
2207
2521
  const hoverRegion = useRef6(null);
2208
2522
  const box = useContentBox();
2209
- const cut = useMemo6(() => resolveSectionPlane(section, box), [box, section]);
2523
+ const store = useSectionStore();
2524
+ const shared = useSyncExternalStore(store.subscribe, store.get, store.get);
2525
+ const controlled = section !== void 0;
2526
+ const options = controlled ? section : shared ?? void 0;
2527
+ const cut = useMemo6(() => resolveSectionPlane(options, box), [box, options]);
2528
+ const engaged = useSyncExternalStore(store.subscribe, store.isEngaged, store.isEngaged);
2529
+ const pressedWhileEngaged = useRef6(false);
2210
2530
  const layers = useRef6({
2211
2531
  selection,
2212
2532
  candidates,
@@ -2232,37 +2552,39 @@ var PartMesh = ({
2232
2552
  );
2233
2553
  invalidate();
2234
2554
  }, [invalidate, part]);
2235
- useEffect5(() => () => part.dispose(), [part]);
2555
+ useEffect6(() => () => part.dispose(), [part]);
2236
2556
  const adjacency = useMemo6(() => regionAdjacency(model, geometry), [model, geometry]);
2237
- useEffect5(() => {
2557
+ useEffect6(() => {
2238
2558
  onAdjacency?.(adjacency);
2239
2559
  }, [adjacency, onAdjacency]);
2240
2560
  const framed = useRef6(null);
2241
- useEffect5(() => {
2561
+ useEffect6(() => {
2242
2562
  if (focusFeature === null || focusFeature === framed.current) return;
2243
2563
  framed.current = focusFeature;
2244
2564
  const box2 = part.boxForFeature(focusFeature);
2245
2565
  if (box2) viewerControls.frameBox(box2);
2246
2566
  }, [focusFeature, part, viewerControls]);
2247
- useLayoutEffect(() => {
2567
+ useLayoutEffect2(() => {
2248
2568
  part.setTheme(resolved);
2249
2569
  repaint();
2250
2570
  }, [part, repaint, resolved]);
2251
- useLayoutEffect(() => {
2571
+ useLayoutEffect2(() => {
2252
2572
  part.edges.visible = showEdges;
2253
2573
  invalidate();
2254
2574
  }, [invalidate, part, showEdges]);
2255
- useLayoutEffect(() => {
2575
+ useLayoutEffect2(() => {
2256
2576
  part.setClippingPlanes(cut ? [cut.plane] : null);
2257
2577
  invalidate();
2258
2578
  }, [cut, invalidate, part]);
2259
2579
  const reportedSection = useRef6("");
2260
- useLayoutEffect(() => {
2580
+ useLayoutEffect2(() => {
2261
2581
  const state = cut?.state ?? null;
2262
2582
  const key = state ? `${state.constant}|${state.normal.x},${state.normal.y},${state.normal.z}` : "";
2263
2583
  if (key === reportedSection.current) return;
2584
+ const was = reportedSection.current;
2264
2585
  reportedSection.current = key;
2265
2586
  if (state) onSectionChange?.(state);
2587
+ else if (was !== "") onSectionChange?.(DISABLED_SECTION);
2266
2588
  }, [cut, onSectionChange]);
2267
2589
  const layerKey = [
2268
2590
  selection.join(" "),
@@ -2272,7 +2594,7 @@ var PartMesh = ({
2272
2594
  highlights.map((entry) => `${entry.tag}:${entry.color}:${entry.weight ?? ""}`).join(" "),
2273
2595
  regionHighlights.map((entry) => `${entry.region}:${entry.color}:${entry.weight ?? ""}`).join(" ")
2274
2596
  ].join("|");
2275
- useLayoutEffect(() => {
2597
+ useLayoutEffect2(() => {
2276
2598
  repaint();
2277
2599
  }, [layerKey, repaint]);
2278
2600
  const isTap = useTapGuard();
@@ -2311,30 +2633,46 @@ var PartMesh = ({
2311
2633
  repaint();
2312
2634
  onHover?.(next);
2313
2635
  };
2636
+ const onHoverRef = useRef6(onHover);
2637
+ onHoverRef.current = onHover;
2638
+ useLayoutEffect2(() => {
2639
+ if (!engaged || hoverRegion.current === null) return;
2640
+ hoverRegion.current = null;
2641
+ repaint();
2642
+ onHoverRef.current?.(null);
2643
+ }, [engaged, repaint]);
2314
2644
  const dragSection = useCallback3(
2315
2645
  (constant) => {
2316
2646
  if (!cut) return;
2317
- const anchor = section?.plane?.point;
2318
- onSectionChange?.({
2647
+ const anchor = options?.plane?.point;
2648
+ const state = {
2319
2649
  ...cut.state,
2320
2650
  constant,
2321
2651
  offset: sectionOffset(sectionBounds(box, cut.state.normal), constant),
2322
2652
  depth: anchor ? sectionDepth(cut.state.normal, anchor, constant) : null
2323
- });
2653
+ };
2654
+ if (!controlled) store.set(sectionOptionsFromState(state));
2655
+ onSectionChange?.(state);
2324
2656
  },
2325
- [box, cut, onSectionChange, section]
2657
+ [box, controlled, cut, onSectionChange, options, store]
2326
2658
  );
2327
2659
  return /* @__PURE__ */ jsxs4(Fragment, { children: [
2328
2660
  /* @__PURE__ */ jsx5(
2329
2661
  "primitive",
2330
2662
  {
2331
2663
  object: part.object,
2332
- onPointerMove: (event) => emitHover(pickFor(event)),
2664
+ onPointerDown: () => {
2665
+ pressedWhileEngaged.current = engaged;
2666
+ },
2667
+ onPointerMove: (event) => {
2668
+ if (!engaged) emitHover(pickFor(event));
2669
+ },
2333
2670
  onPointerOut: () => {
2334
2671
  emitHover(null);
2335
2672
  doubles.reset();
2336
2673
  },
2337
2674
  onClick: (event) => {
2675
+ if (engaged || pressedWhileEngaged.current) return;
2338
2676
  if (!isTap(event.nativeEvent)) {
2339
2677
  doubles.reset();
2340
2678
  return;
@@ -2345,6 +2683,7 @@ var PartMesh = ({
2345
2683
  if (pick) onPick?.(pick);
2346
2684
  },
2347
2685
  onPointerUp: (event) => {
2686
+ if (engaged || pressedWhileEngaged.current) return;
2348
2687
  if (event.nativeEvent.button !== 2) return;
2349
2688
  if (!isTap(event.nativeEvent)) return;
2350
2689
  const pick = pickFor(event);
@@ -2362,8 +2701,8 @@ var PartMesh = ({
2362
2701
  box,
2363
2702
  plane: cut.plane,
2364
2703
  theme: resolved,
2365
- showHandle: onSectionChange !== void 0,
2366
- onDrag: onSectionChange ? dragSection : void 0
2704
+ showHandle: !controlled || onSectionChange !== void 0,
2705
+ onDrag: !controlled || onSectionChange ? dragSection : void 0
2367
2706
  }
2368
2707
  ) : null
2369
2708
  ] });
@@ -2386,7 +2725,7 @@ function useStableTheme(theme) {
2386
2725
  }
2387
2726
 
2388
2727
  // src/engine/geometry.ts
2389
- import { Matrix4 as Matrix42, Mesh as Mesh3 } from "three";
2728
+ import { Matrix4 as Matrix42, Mesh as Mesh4 } from "three";
2390
2729
  import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
2391
2730
  import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js";
2392
2731
  var PartMeshError = class extends Error {
@@ -2461,7 +2800,7 @@ function parseStl(data, mesh) {
2461
2800
  function singleMesh(scene) {
2462
2801
  const meshes = [];
2463
2802
  scene.traverse((object) => {
2464
- if (object instanceof Mesh3) meshes.push(object);
2803
+ if (object instanceof Mesh4) meshes.push(object);
2465
2804
  });
2466
2805
  const [mesh] = meshes;
2467
2806
  if (mesh === void 0 || meshes.length !== 1) {
@@ -2497,7 +2836,7 @@ function assertCounts(geometry, expected) {
2497
2836
  }
2498
2837
  function disposeMaterials(scene) {
2499
2838
  scene.traverse((object) => {
2500
- if (!(object instanceof Mesh3)) return;
2839
+ if (!(object instanceof Mesh4)) return;
2501
2840
  for (const material of Array.isArray(object.material) ? object.material : [object.material]) {
2502
2841
  material.dispose();
2503
2842
  }
@@ -2998,7 +3337,7 @@ import { jsx as jsx6 } from "react/jsx-runtime";
2998
3337
  var EnginePart = ({ report, ...props }) => {
2999
3338
  const model = useMemo7(() => normalizePartReport(report), [report]);
3000
3339
  const resource = useGeometryResource(model);
3001
- useEffect6(() => {
3340
+ useEffect7(() => {
3002
3341
  engineGeometryCache.retain(resource);
3003
3342
  return () => engineGeometryCache.release(resource);
3004
3343
  }, [resource]);
@@ -3026,8 +3365,42 @@ export {
3026
3365
  CANDIDATE_WEIGHT,
3027
3366
  HOVER_WEIGHT,
3028
3367
  applyHighlightLayers,
3368
+ PERSPECTIVE_FOV,
3369
+ DEFAULT_FIT_MARGIN,
3370
+ EXCLUDE_FROM_FRAME,
3371
+ defaultBounds,
3372
+ aspectRatio,
3373
+ screenLength,
3374
+ boundsFromBox,
3375
+ contentBounds,
3376
+ excludedFromFrame,
3377
+ perspectiveFitDistance,
3378
+ orthographicHalfHeight,
3379
+ fitDistance,
3380
+ startPosition,
3381
+ MIN_FRAME_RATIO,
3382
+ MAX_FRAME_RATIO,
3383
+ cameraLimits,
3384
+ targetBoundary,
3385
+ applyProjection,
3386
+ CAD_CAMERA_UP,
3387
+ adaptedUp,
3388
+ cadViewDirections,
3389
+ currentViewDirection,
3029
3390
  SECTION_RENDER_ORDER,
3030
3391
  HANDLE_PIXELS,
3392
+ CAP_HATCH,
3393
+ AXES_PLANE_OFFSET,
3394
+ AXES_PLANE_SCALE,
3395
+ PREVIEW_SCALE,
3396
+ OUTLINE_SCALE,
3397
+ DEFAULT_SECTION_NORMAL,
3398
+ DISABLED_SECTION,
3399
+ sectionOptionsFromState,
3400
+ axesPlanes,
3401
+ hitUnderRay,
3402
+ surfaceUnderRay,
3403
+ sectionAnchor,
3031
3404
  PICKED_SURFACE_LABEL,
3032
3405
  sectionBounds,
3033
3406
  sectionConstant,
@@ -3061,30 +3434,10 @@ export {
3061
3434
  viewDirection,
3062
3435
  buildPick,
3063
3436
  focusForPick,
3064
- PERSPECTIVE_FOV,
3065
- DEFAULT_FIT_MARGIN,
3066
- EXCLUDE_FROM_FRAME,
3067
- defaultBounds,
3068
- aspectRatio,
3069
- screenLength,
3070
- boundsFromBox,
3071
- contentBounds,
3072
- perspectiveFitDistance,
3073
- orthographicHalfHeight,
3074
- fitDistance,
3075
- startPosition,
3076
- MIN_FRAME_RATIO,
3077
- MAX_FRAME_RATIO,
3078
- cameraLimits,
3079
- targetBoundary,
3080
- applyProjection,
3081
- CAD_CAMERA_UP,
3082
- adaptedUp,
3083
- cadViewDirections,
3084
- currentViewDirection,
3085
3437
  ExtendedCameraControls,
3086
3438
  CadCameraControls,
3087
3439
  retargetPose,
3440
+ createSectionStore,
3088
3441
  ORBIT_TARGET_PIXELS,
3089
3442
  ORBIT_TARGET_RING_PIXELS,
3090
3443
  ORBIT_TARGET_RING_WIDTH,
@@ -3107,6 +3460,7 @@ export {
3107
3460
  labelGeometry,
3108
3461
  labelTexture,
3109
3462
  useViewerControls,
3463
+ useSectionStore,
3110
3464
  useRetarget,
3111
3465
  Viewer,
3112
3466
  CONE_AXIS,
@@ -3114,6 +3468,11 @@ export {
3114
3468
  HEAD_RADIUS,
3115
3469
  SHAFT_RADIUS,
3116
3470
  arrowPlacement,
3471
+ createMaskTarget,
3472
+ createCapMaterial,
3473
+ applyCapTheme,
3474
+ createStencilMaterials,
3475
+ createMaskMaterial,
3117
3476
  resolveSectionPlane,
3118
3477
  SectionView,
3119
3478
  useContentBox,