@toolpath/viewer 1.2.0 → 1.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.
package/README.md CHANGED
@@ -594,6 +594,16 @@ convert, so pass `format` to show anything else:
594
594
  <MeasureTool format={(mm) => `${(mm / MM_PER_INCH).toFixed(3)} in`} />
595
595
  ```
596
596
 
597
+ **Measuring a cut part.** Mount the measure tool with a section cut in place, or beside
598
+ `<SectionTool>`, and it measures what is on screen: the half a cut has removed is not there to snap
599
+ to, the part's edges stop at the plane, the outline where the plane passes through solid material
600
+ is an edge with corners of its own, and the capped face is a surface a point can land on. The
601
+ sample follows the cut — drag the handle or set a depth and the next pointer move sees the new cut.
602
+ It reads the plane off the part's own material, so a controlled `section` prop works the same way.
603
+ Beside a `<SectionTool>` that has no cut yet, the measure tool waits: it offers no snap and places
604
+ no point until a cut is chosen or the section tool is unmounted, so the click that picks the cut is
605
+ not also the first point of a measurement. Measurements already made stay up meanwhile.
606
+
597
607
  Hold Shift to hold the next point to the X, Y or Z line through the last one — whichever axis the
598
608
  pointer is furthest along — so a length along an edge is measured square rather than slightly across
599
609
  it. The line wears that axis's colour while it is held, and the finished distance has no legs to
@@ -1328,11 +1328,12 @@ function retargetPose(cameraPosition, currentTarget, hitPoint) {
1328
1328
  // src/render/section-store.ts
1329
1329
  function createSectionStore() {
1330
1330
  let current = null;
1331
- let engaged = 0;
1332
1331
  const listeners = /* @__PURE__ */ new Set();
1333
1332
  const notify = () => {
1334
1333
  for (const listener of listeners) listener();
1335
1334
  };
1335
+ const engaged = counted(notify);
1336
+ const picking = counted(notify);
1336
1337
  return {
1337
1338
  get: () => current,
1338
1339
  set: (next) => {
@@ -1340,12 +1341,10 @@ function createSectionStore() {
1340
1341
  current = next;
1341
1342
  notify();
1342
1343
  },
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
- },
1344
+ isEngaged: engaged.is,
1345
+ setEngaged: engaged.set,
1346
+ isPicking: picking.is,
1347
+ setPicking: picking.set,
1349
1348
  subscribe: (listener) => {
1350
1349
  listeners.add(listener);
1351
1350
  return () => {
@@ -1354,6 +1353,17 @@ function createSectionStore() {
1354
1353
  }
1355
1354
  };
1356
1355
  }
1356
+ function counted(notify) {
1357
+ let held = 0;
1358
+ return {
1359
+ is: () => held > 0,
1360
+ set: (next) => {
1361
+ const was = held > 0;
1362
+ held = Math.max(0, held + (next ? 1 : -1));
1363
+ if (held > 0 !== was) notify();
1364
+ }
1365
+ };
1366
+ }
1357
1367
 
1358
1368
  // src/target-marker.tsx
1359
1369
  import { useFrame as useFrame2, useThree as useThree3 } from "@react-three/fiber";
@@ -12,7 +12,7 @@ import {
12
12
  parsePartGeometry,
13
13
  partMeshAssets,
14
14
  smoothRegionNormals
15
- } from "../chunk-HWAEUJ4H.js";
15
+ } from "../chunk-HH3W5T42.js";
16
16
  export {
17
17
  EnginePart,
18
18
  MIN_KERNEL_VERSION,
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { P as PartModel, V as Vec3, d as ViewerTheme, S as SectionOptions, e as SectionState, f as PartModelRegion, T as TriangleRange, F as FeatureTag, R as RegionIndex, g as PartModelFeature, h as FeatureType } from './normalize-DHOSNiuO.js';
2
2
  export { A as AXES_PLANE_OFFSET, i as AXES_PLANE_SCALE, j as AxesPlane, B as BuildPickInput, C as CANDIDATE_WEIGHT, k as CAP_HATCH, D as DEFAULT_SECTION_NORMAL, l as DEFAULT_THEME, m as DIRECTION_COLORS, o as DISABLED_SECTION, E as EnginePart, p as FeatureHighlight, H as HANDLE_PIXELS, q as HIGHLIGHT_COLORS, r as HIGHLIGHT_WEIGHT, t as HOVER_WEIGHT, u as HighlightLayers, K as KnownFeatureType, v as KnownShapeKind, N as NO_MODIFIERS, O as OUTLINE_SCALE, w as PICKED_SURFACE_LABEL, x as PREVIEW_SCALE, y as PartMesh, z as PartMeshProps, a as PartMeshRefs, G as PartObject, I as PartPick, J as PickModifiers, L as REGION_ATTRIBUTE, Q as RegionHighlight, U as RegionPaint, W as SECTION_RENDER_ORDER, X as SectionAnchor, Y as SectionBounds, Z as SectionPlacement, _ as ShapeKind, $ as SurfaceHit, a0 as applyHighlightLayers, a1 as axesPlanes, a2 as buildPick, a3 as buildRegionAttribute, a4 as buildRegionTexels, a5 as createPart, a6 as directionColor, a7 as dragPlane, a8 as focusForPick, a9 as hitUnderRay, n as normalizePartReport, aa as pickedStartDepth, ab as resolveTheme, ac as sectionAnchor, ad as sectionBounds, ae as sectionConstant, af as sectionDepth, ag as sectionDepthConstant, ah as sectionDepthRange, ai as sectionFromPick, aj as sectionOffset, ak as sectionOptionsFromState, al as sectionPlane, s as smoothRegionNormals, am as surfaceUnderRay, an as themesEqual, ao as viewDirection } from './normalize-DHOSNiuO.js';
3
- import { BufferGeometry, CanvasTexture, Box3, Plane, Vector3, OrthographicCamera, PerspectiveCamera, Object3D, Matrix4, Vector2, ShaderMaterial, Texture, Color, MeshBasicMaterial, WebGLRenderTarget } from 'three';
3
+ import { BufferGeometry, CanvasTexture, Box3, Plane, Vector3, OrthographicCamera, PerspectiveCamera, Object3D, Matrix4, Vector2, Ray, ShaderMaterial, Texture, Color, MeshBasicMaterial, WebGLRenderTarget } from 'three';
4
4
  import * as react from 'react';
5
5
  import { RefObject, ReactNode, CSSProperties } from 'react';
6
6
  import CameraControls from 'camera-controls';
@@ -245,7 +245,9 @@ interface SectionToolProps {
245
245
  * the way to set or clear it from outside the canvas. While this is mounted
246
246
  * the part reports no hovers or picks: a click on a face cuts through it
247
247
  * without also selecting it, and a cut part is looked at rather than picked
248
- * at. Unmounting it hands the pointer back.
248
+ * at. Unmounting it hands the pointer back. While it is offering a cut — up,
249
+ * with none placed yet — a `<MeasureTool>` beside it waits too, so the click
250
+ * that chooses the cut does not also place a measurement point.
249
251
  */
250
252
  declare const SectionTool: ({ theme }: SectionToolProps) => react.JSX.Element | null;
251
253
 
@@ -687,7 +689,12 @@ interface MeasureToolProps {
687
689
  *
688
690
  * Nothing needs wiring. While it is mounted the part reports no hovers or
689
691
  * picks — a click that places a point is not a click that selects a face — and
690
- * unmounting it hands the pointer back. It snaps to the edges the part draws,
692
+ * unmounting it hands the pointer back. Beside a `<SectionTool>` that has no
693
+ * cut yet it waits: no snap is offered and no click places a point until the
694
+ * cut is chosen or the section tool is unmounted, and the click that chooses
695
+ * the cut — the one it wakes on — places nothing either: a click counts only
696
+ * when it was pressed as well as released while the tool was taking points.
697
+ * Measurements already made stay up meanwhile. It snaps to the edges the part draws,
691
698
  * which are the boundaries between analytic surfaces rather than the mesh's
692
699
  * triangles, so a corner is a corner of the part and not of a facet.
693
700
  *
@@ -723,6 +730,12 @@ declare const MeasureTool: ({ mode, measurements, onChange, showDeltas, format,
723
730
  * on a face is not a request to select the face — which is why the flag is a
724
731
  * count rather than a boolean: two tools up at once let go one at a time.
725
732
  *
733
+ * And whether a tool is **picking** — `<SectionTool>` is up with no cut in
734
+ * place, so the next click on the part chooses where the cut goes. That click
735
+ * is the section tool's alone: `<MeasureTool>` beside it offers no snap and
736
+ * places no point until the cut is chosen or the section tool is unmounted.
737
+ * Counted for the same reason as `engaged`.
738
+ *
726
739
  * A subscription rather than React state, so a drag re-renders the two
727
740
  * subscribers and not everything under the canvas.
728
741
  */
@@ -733,11 +746,80 @@ interface SectionStore {
733
746
  isEngaged(): boolean;
734
747
  /** Counted: a tool engages on mount and disengages on unmount, and the part waits for the last. */
735
748
  setEngaged(engaged: boolean): void;
736
- /** Notified on every change to either the cut or the engaged flag. */
749
+ /** Whether a section tool is waiting for a click to say where the cut goes. */
750
+ isPicking(): boolean;
751
+ /** Counted, as `setEngaged` is: raised while a section tool offers a cut, dropped once one is placed. */
752
+ setPicking(picking: boolean): void;
753
+ /** Notified on every change to the cut, the engaged flag, or the picking flag. */
737
754
  subscribe(listener: () => void): () => void;
738
755
  }
739
756
  declare function createSectionStore(): SectionStore;
740
757
 
758
+ /**
759
+ * A cut part, re-sampled for measuring.
760
+ *
761
+ * A clipping plane hides half of a part without changing a triangle, so the
762
+ * mesh and its edges still describe the whole thing — and a tool that snaps
763
+ * to them would offer corners nobody can see, and nothing at all on the cut.
764
+ * This is what the measure tool works against instead: the part's edges
765
+ * trimmed to the half that is left, the outline where the plane passes
766
+ * through solid material, and a test for whether a point on the plane is on
767
+ * the capped face. It is rebuilt whenever the plane moves; see
768
+ * `measure-tool.tsx`.
769
+ *
770
+ * Everything here is in one space — the mesh's own — and the plane is
771
+ * expected in it too. Six floats per segment throughout, the layout
772
+ * `regionEdgesGeometry` uses.
773
+ */
774
+ interface SectionSample {
775
+ /** The edges of the part on the kept side, plus the cut's outline. */
776
+ readonly edges: Float32Array;
777
+ /** The cut's outline alone: where the plane crosses solid material. */
778
+ readonly contour: Float32Array;
779
+ }
780
+ /**
781
+ * Trims segments to the half-space a plane keeps.
782
+ *
783
+ * A segment wholly on the removed side goes; one that crosses is cut at the
784
+ * plane and its kept part stays. `Plane` keeps the side its normal points
785
+ * into — where the signed distance is positive — as the part's own clipping
786
+ * does.
787
+ */
788
+ declare function clipSegments(positions: ArrayLike<number>, plane: Plane): Float32Array;
789
+ /**
790
+ * Where a plane passes through a mesh: one segment per triangle it crosses,
791
+ * then straight runs inside one region stitched into one segment each.
792
+ *
793
+ * The stitching is what makes the outline snappable. A flat face is two
794
+ * triangles or two hundred, and the plane crosses each of them separately,
795
+ * so before stitching every triangle boundary on the face was a corner the
796
+ * pointer would snap to. After it, a corner is where the outline turns —
797
+ * where the plane leaves one surface for the next — and on a curved surface
798
+ * the facets stay, as they do in the part's own edges.
799
+ *
800
+ * `regionOf` says which surface each triangle belongs to, indexed by
801
+ * triangle; without it every triangle is one surface, which stitches a
802
+ * straight run across a fold only if the fold is perfectly flat, which it is
803
+ * not.
804
+ */
805
+ declare function sectionContour(positions: ArrayLike<number>, regionOf: ArrayLike<number> | null, plane: Plane): Float32Array;
806
+ /**
807
+ * Whether a point on the plane lies inside the cut's outline — on the capped
808
+ * face rather than in the air beside it.
809
+ *
810
+ * Even-odd against every outline segment, in the plane's own two axes, so a
811
+ * part cut through a bore is inside the outer loop and outside the hole's.
812
+ */
813
+ declare function pointInContour(point: Vector3, plane: Plane, contour: ArrayLike<number>): boolean;
814
+ /**
815
+ * Where a ray meets the capped face, or `null` when it misses the cap — a
816
+ * ray through the open half that lands beside the part, or one that reaches
817
+ * the plane from behind.
818
+ */
819
+ declare function capHit(ray: Ray, plane: Plane, contour: ArrayLike<number>): Vector3 | null;
820
+ /** The edges and outline of a part cut by `plane`, everything in the mesh's own space. */
821
+ declare function sampleSection(meshPositions: ArrayLike<number>, regionOf: ArrayLike<number> | null, edgePositions: ArrayLike<number> | null, plane: Plane): SectionSample;
822
+
741
823
  type CapUniforms = {
742
824
  uMask: {
743
825
  value: Texture | null;
@@ -1510,4 +1592,4 @@ declare const ORBIT_TARGET_FADE_MS = 1000;
1510
1592
  */
1511
1593
  declare function orbitTargetOpacity(sinceHold: number, fade?: number): number;
1512
1594
 
1513
- export { ANGLE_ARC_FRACTION, ANGLE_ARC_SEGMENTS, AXIS_COLORS, type AngleMeasurement, type ArrowPlacement, Axes, type AxesProps, type Axis, type AxisLock, CAD_CAMERA_UP, CHAMFER, CadCameraControls, type CadCameraControlsProps, type CameraLimits, type CapMaterial, type CapUniforms, type ControlScheme, type CubeZone, DEFAULT_FIT_MARGIN, DELTA_DASH_PIXELS, DOUBLE_TAP_MS, type DeltaLeg, DirectionArrows, type DirectionArrowsProps, type DirectionGroup, type DistanceMeasurement, type DoubleTapPoint, type DoubleTapTracker, EXCLUDE_FROM_FRAME, ExtendedCameraControls, type ExtendedCameraControlsOptions, FEATURE_TYPE_RANKS, FeatureTag, FeatureType, Grid, type GridProps, type GridSpec, MAX_FRAME_RATIO, MEASURE_LABEL_CLASS, MEASURE_LINE_PIXELS, MEASURE_MARKER_PIXELS, MEASURE_RENDER_ORDER, MIN_FRAME_RATIO, type MeasureLabelKind, type MeasureMode, MeasureTool, type MeasureToolProps, type Measurement, type NamedDirection, ORBIT_TARGET_COLOR, ORBIT_TARGET_FADE_MS, ORBIT_TARGET_FLASH_MS, ORBIT_TARGET_PIXELS, ORBIT_TARGET_RING_COLOR, ORBIT_TARGET_RING_OPACITY, ORBIT_TARGET_RING_PIXELS, ORBIT_TARGET_RING_WIDTH, PERSPECTIVE_FOV, POINTS_PER_MEASUREMENT, PartModel, PartModelFeature, PartModelRegion, PartReportFormatError, type Projection, type RankingContext, RegionIndex, type Retarget, type RetargetPose, SNAP_MARKER_PIXELS, SNAP_PIXELS, type SceneBounds, SectionOptions, SectionState, type SectionStore, SectionTool, type SectionToolProps, SectionView, type Snap, type SnapEdges, type SnapHit, type SnapKind, type SurfaceOf, TAP_SLOP, type TapPoint, type TapTracker, TriangleRange, UnsupportedKernelVersionError, VIEW_NAMES, VIEW_SIGNS, Vec3, ViewCube, type ViewCubeProps, type ViewKind, type ViewName, Viewer, type ViewerCamera, type ViewerControls, type ViewerHandle, type ViewerProps, ViewerTheme, type ViewerView, type ViewportSize, adaptedUp, angleArc, angleArcRadius, angleAt, applyCapTheme, applyProjection, arrowPlacement, aspectRatio, bestOwner, boundsFromBox, buildRegionIndex, cadViewDirections, cameraLimits, contentBounds, createCapMaterial, createMaskMaterial, createMaskTarget, createSectionStore, createStencilMaterials, cubeOutlineGeometry, cubeZones, currentViewDirection, cycleOwner, defaultBounds, deltaBetween, deltaLegs, directionIndexOf, directionLabel, distanceBetween, excludedFromFrame, featureTypeRank, fitDistance, formatDegrees, formatMillimetres, gridGeometry, gridSpec, groupByDirection, labelGeometry, labelTexture, lockToAxis, measurementFromPoints, measurementLabel, measurementLabelAnchor, midpoint, movedFar, nextMeasurementId, orbitTargetOpacity, orthographicHalfHeight, panelGeometry, perspectiveFitDistance, rankOwners, regionAdjacency, regionEdgesGeometry, regionNormals, resolveSectionPlane, retargetPose, sameDirection, screenLength, screenPoint, snapAt, squaredUp, startPosition, targetBoundary, trackDoubleTaps, trackTaps, useContentBox, useRetarget, useSectionStore, useTapGuard, useViewerControls, viewKind, viewUp, viewVector, visualSurfaces };
1595
+ export { ANGLE_ARC_FRACTION, ANGLE_ARC_SEGMENTS, AXIS_COLORS, type AngleMeasurement, type ArrowPlacement, Axes, type AxesProps, type Axis, type AxisLock, CAD_CAMERA_UP, CHAMFER, CadCameraControls, type CadCameraControlsProps, type CameraLimits, type CapMaterial, type CapUniforms, type ControlScheme, type CubeZone, DEFAULT_FIT_MARGIN, DELTA_DASH_PIXELS, DOUBLE_TAP_MS, type DeltaLeg, DirectionArrows, type DirectionArrowsProps, type DirectionGroup, type DistanceMeasurement, type DoubleTapPoint, type DoubleTapTracker, EXCLUDE_FROM_FRAME, ExtendedCameraControls, type ExtendedCameraControlsOptions, FEATURE_TYPE_RANKS, FeatureTag, FeatureType, Grid, type GridProps, type GridSpec, MAX_FRAME_RATIO, MEASURE_LABEL_CLASS, MEASURE_LINE_PIXELS, MEASURE_MARKER_PIXELS, MEASURE_RENDER_ORDER, MIN_FRAME_RATIO, type MeasureLabelKind, type MeasureMode, MeasureTool, type MeasureToolProps, type Measurement, type NamedDirection, ORBIT_TARGET_COLOR, ORBIT_TARGET_FADE_MS, ORBIT_TARGET_FLASH_MS, ORBIT_TARGET_PIXELS, ORBIT_TARGET_RING_COLOR, ORBIT_TARGET_RING_OPACITY, ORBIT_TARGET_RING_PIXELS, ORBIT_TARGET_RING_WIDTH, PERSPECTIVE_FOV, POINTS_PER_MEASUREMENT, PartModel, PartModelFeature, PartModelRegion, PartReportFormatError, type Projection, type RankingContext, RegionIndex, type Retarget, type RetargetPose, SNAP_MARKER_PIXELS, SNAP_PIXELS, type SceneBounds, SectionOptions, type SectionSample, SectionState, type SectionStore, SectionTool, type SectionToolProps, SectionView, type Snap, type SnapEdges, type SnapHit, type SnapKind, type SurfaceOf, TAP_SLOP, type TapPoint, type TapTracker, TriangleRange, UnsupportedKernelVersionError, VIEW_NAMES, VIEW_SIGNS, Vec3, ViewCube, type ViewCubeProps, type ViewKind, type ViewName, Viewer, type ViewerCamera, type ViewerControls, type ViewerHandle, type ViewerProps, ViewerTheme, type ViewerView, type ViewportSize, adaptedUp, angleArc, angleArcRadius, angleAt, applyCapTheme, applyProjection, arrowPlacement, aspectRatio, bestOwner, boundsFromBox, buildRegionIndex, cadViewDirections, cameraLimits, capHit, clipSegments, contentBounds, createCapMaterial, createMaskMaterial, createMaskTarget, createSectionStore, createStencilMaterials, cubeOutlineGeometry, cubeZones, currentViewDirection, cycleOwner, defaultBounds, deltaBetween, deltaLegs, directionIndexOf, directionLabel, distanceBetween, excludedFromFrame, featureTypeRank, fitDistance, formatDegrees, formatMillimetres, gridGeometry, gridSpec, groupByDirection, labelGeometry, labelTexture, lockToAxis, measurementFromPoints, measurementLabel, measurementLabelAnchor, midpoint, movedFar, nextMeasurementId, orbitTargetOpacity, orthographicHalfHeight, panelGeometry, perspectiveFitDistance, pointInContour, rankOwners, regionAdjacency, regionEdgesGeometry, regionNormals, resolveSectionPlane, retargetPose, sameDirection, sampleSection, screenLength, screenPoint, sectionContour, snapAt, squaredUp, startPosition, targetBoundary, trackDoubleTaps, trackTaps, useContentBox, useRetarget, useSectionStore, useTapGuard, useViewerControls, viewKind, viewUp, viewVector, visualSurfaces };
package/dist/index.js CHANGED
@@ -131,7 +131,7 @@ import {
131
131
  viewUp,
132
132
  viewVector,
133
133
  visualSurfaces
134
- } from "./chunk-HWAEUJ4H.js";
134
+ } from "./chunk-HH3W5T42.js";
135
135
 
136
136
  // src/primitives.tsx
137
137
  import { GizmoHelper } from "@react-three/drei";
@@ -644,6 +644,10 @@ var Picker = ({ box, theme }) => {
644
644
  const span = useMemo3(() => box.getSize(new Vector34()).length(), [box]);
645
645
  const centre = useMemo3(() => box.getCenter(new Vector34()), [box]);
646
646
  const previewSize = span * PREVIEW_SCALE;
647
+ useEffect2(() => {
648
+ store.setPicking(true);
649
+ return () => store.setPicking(false);
650
+ }, [store]);
647
651
  useEffect2(() => {
648
652
  const raycaster = new Raycaster();
649
653
  const pointer = new Vector22();
@@ -840,13 +844,194 @@ var CutSheet = ({ box, section, theme }) => {
840
844
  // src/measure-tool.tsx
841
845
  import { Html, Line } from "@react-three/drei";
842
846
  import { useFrame as useFrame2, useThree as useThree4 } from "@react-three/fiber";
843
- import { useEffect as useEffect3, useLayoutEffect, useMemo as useMemo4, useRef as useRef3, useState as useState3 } from "react";
844
847
  import {
848
+ useEffect as useEffect3,
849
+ useLayoutEffect,
850
+ useMemo as useMemo4,
851
+ useRef as useRef3,
852
+ useState as useState3,
853
+ useSyncExternalStore as useSyncExternalStore2
854
+ } from "react";
855
+ import {
856
+ Matrix4,
845
857
  Raycaster as Raycaster2,
846
858
  SphereGeometry,
847
859
  Vector2 as Vector23,
848
- Vector3 as Vector35
860
+ Vector3 as Vector36
849
861
  } from "three";
862
+
863
+ // src/render/resample.ts
864
+ import { Vector3 as Vector35 } from "three";
865
+ var ON_PLANE = 1e-7;
866
+ var STITCH = 1e-5;
867
+ var COLLINEAR = 1e-6;
868
+ function clipSegments(positions, plane) {
869
+ const out = [];
870
+ const a = new Vector35();
871
+ const b = new Vector35();
872
+ const count = Math.floor(positions.length / 6);
873
+ for (let i = 0; i < count; i += 1) {
874
+ a.set(positions[i * 6], positions[i * 6 + 1], positions[i * 6 + 2]);
875
+ b.set(positions[i * 6 + 3], positions[i * 6 + 4], positions[i * 6 + 5]);
876
+ const da = plane.distanceToPoint(a);
877
+ const db = plane.distanceToPoint(b);
878
+ if (da < -ON_PLANE && db < -ON_PLANE) continue;
879
+ if (da < -ON_PLANE) a.lerp(b, da / (da - db));
880
+ else if (db < -ON_PLANE) b.lerp(a, db / (db - da));
881
+ out.push(a.x, a.y, a.z, b.x, b.y, b.z);
882
+ }
883
+ return Float32Array.from(out);
884
+ }
885
+ function sectionContour(positions, regionOf, plane) {
886
+ const a = new Vector35();
887
+ const b = new Vector35();
888
+ const c = new Vector35();
889
+ const p = new Vector35();
890
+ const q = new Vector35();
891
+ const byRegion = /* @__PURE__ */ new Map();
892
+ const triangleCount = Math.floor(positions.length / 9);
893
+ for (let t = 0; t < triangleCount; t += 1) {
894
+ a.set(positions[t * 9], positions[t * 9 + 1], positions[t * 9 + 2]);
895
+ b.set(positions[t * 9 + 3], positions[t * 9 + 4], positions[t * 9 + 5]);
896
+ c.set(positions[t * 9 + 6], positions[t * 9 + 7], positions[t * 9 + 8]);
897
+ const crossings = triangleCrossings(a, b, c, plane, p, q);
898
+ if (!crossings) continue;
899
+ const region = regionOf ? regionOf[t] ?? 0 : 0;
900
+ const list = byRegion.get(region);
901
+ const entry = [p.x, p.y, p.z, q.x, q.y, q.z];
902
+ if (list) list.push(...entry);
903
+ else byRegion.set(region, entry);
904
+ }
905
+ const out = [];
906
+ for (const segments of byRegion.values()) stitch(segments, out);
907
+ return Float32Array.from(out);
908
+ }
909
+ function triangleCrossings(a, b, c, plane, p, q) {
910
+ const da = plane.distanceToPoint(a);
911
+ const db = plane.distanceToPoint(b);
912
+ const dc = plane.distanceToPoint(c);
913
+ const sa = da < -ON_PLANE;
914
+ const sb = db < -ON_PLANE;
915
+ const sc = dc < -ON_PLANE;
916
+ if (sa === sb && sb === sc) return false;
917
+ const found = [];
918
+ const edge = (u, v, du, dv, su, sv) => {
919
+ if (su === sv) return;
920
+ found.push(u.clone().lerp(v, du / (du - dv)));
921
+ };
922
+ edge(a, b, da, db, sa, sb);
923
+ edge(b, c, db, dc, sb, sc);
924
+ edge(c, a, dc, da, sc, sa);
925
+ if (found.length < 2) return false;
926
+ p.copy(found[0]);
927
+ q.copy(found[1]);
928
+ return p.distanceToSquared(q) > ON_PLANE * ON_PLANE;
929
+ }
930
+ function stitch(segments, out) {
931
+ const count = segments.length / 6;
932
+ const key = (x, y, z) => `${Math.round(x / STITCH)},${Math.round(y / STITCH)},${Math.round(z / STITCH)}`;
933
+ const ends = /* @__PURE__ */ new Map();
934
+ for (let i = 0; i < count; i += 1) {
935
+ for (const end of [0, 1]) {
936
+ const o = i * 6 + end * 3;
937
+ const k = key(segments[o], segments[o + 1], segments[o + 2]);
938
+ const list = ends.get(k);
939
+ if (list) list.push(i * 2 + end);
940
+ else ends.set(k, [i * 2 + end]);
941
+ }
942
+ }
943
+ const point = (i, end, into) => {
944
+ const o = i * 6 + end * 3;
945
+ return into.set(segments[o], segments[o + 1], segments[o + 2]);
946
+ };
947
+ const direction = (i, into) => {
948
+ const s = new Vector35();
949
+ const e = new Vector35();
950
+ point(i, 0, s);
951
+ point(i, 1, e);
952
+ return into.subVectors(e, s).normalize();
953
+ };
954
+ const consumed = new Uint8Array(count);
955
+ const d = new Vector35();
956
+ const n = new Vector35();
957
+ const next = (i, end) => {
958
+ const at = point(i, end, new Vector35());
959
+ const list = ends.get(key(at.x, at.y, at.z)) ?? [];
960
+ if (list.length !== 2) return null;
961
+ const other = list.find((entry) => entry >> 1 !== i);
962
+ if (other === void 0) return null;
963
+ const j = other >> 1;
964
+ if (consumed[j]) return null;
965
+ direction(i, d);
966
+ direction(j, n);
967
+ if (Math.abs(Math.abs(d.dot(n)) - 1) > COLLINEAR) return null;
968
+ return { index: j, end: other & 1 };
969
+ };
970
+ for (let i = 0; i < count; i += 1) {
971
+ if (consumed[i]) continue;
972
+ consumed[i] = 1;
973
+ const walk = (end) => {
974
+ let at = { index: i, end };
975
+ let steps = 0;
976
+ for (; ; ) {
977
+ const step = next(at.index, at.end);
978
+ if (!step || steps > count) break;
979
+ consumed[step.index] = 1;
980
+ at = { index: step.index, end: 1 - step.end };
981
+ steps += 1;
982
+ }
983
+ return at;
984
+ };
985
+ const back = walk(0);
986
+ const forward = walk(1);
987
+ const s = point(back.index, back.end, new Vector35());
988
+ const e = point(forward.index, forward.end, new Vector35());
989
+ out.push(s.x, s.y, s.z, e.x, e.y, e.z);
990
+ }
991
+ }
992
+ function pointInContour(point, plane, contour) {
993
+ const [u, v] = planeAxes(plane);
994
+ const px = point.dot(u);
995
+ const py = point.dot(v);
996
+ const a = new Vector35();
997
+ const b = new Vector35();
998
+ let inside = false;
999
+ const count = Math.floor(contour.length / 6);
1000
+ for (let i = 0; i < count; i += 1) {
1001
+ a.set(contour[i * 6], contour[i * 6 + 1], contour[i * 6 + 2]);
1002
+ b.set(contour[i * 6 + 3], contour[i * 6 + 4], contour[i * 6 + 5]);
1003
+ const ax = a.dot(u);
1004
+ const ay = a.dot(v);
1005
+ const bx = b.dot(u);
1006
+ const by = b.dot(v);
1007
+ if (ay > py === by > py) continue;
1008
+ const x = ax + (py - ay) / (by - ay) * (bx - ax);
1009
+ if (px < x) inside = !inside;
1010
+ }
1011
+ return inside;
1012
+ }
1013
+ function capHit(ray, plane, contour) {
1014
+ const point = ray.intersectPlane(plane, new Vector35());
1015
+ if (!point) return null;
1016
+ return pointInContour(point, plane, contour) ? point : null;
1017
+ }
1018
+ function planeAxes(plane) {
1019
+ const n = plane.normal;
1020
+ const seed = Math.abs(n.x) < 0.9 ? new Vector35(1, 0, 0) : new Vector35(0, 1, 0);
1021
+ const u = new Vector35().crossVectors(n, seed).normalize();
1022
+ const v = new Vector35().crossVectors(n, u).normalize();
1023
+ return [u, v];
1024
+ }
1025
+ function sampleSection(meshPositions, regionOf, edgePositions, plane) {
1026
+ const contour = sectionContour(meshPositions, regionOf, plane);
1027
+ const kept = edgePositions ? clipSegments(edgePositions, plane) : new Float32Array();
1028
+ const edges = new Float32Array(kept.length + contour.length);
1029
+ edges.set(kept, 0);
1030
+ edges.set(contour, kept.length);
1031
+ return { edges, contour };
1032
+ }
1033
+
1034
+ // src/measure-tool.tsx
850
1035
  import { Fragment as Fragment2, jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
851
1036
  var FURNITURE4 = { [EXCLUDE_FROM_FRAME]: true };
852
1037
  var MEASURE_LABEL_CLASS = "toolpath-measure-label";
@@ -862,6 +1047,7 @@ var MeasureTool = ({
862
1047
  const store = useSectionStore();
863
1048
  const box = useContentBox();
864
1049
  const resolved = useMemo4(() => resolveTheme(theme), [theme]);
1050
+ const picking = useSyncExternalStore2(store.subscribe, store.isPicking, store.isPicking);
865
1051
  const [held, setHeld] = useState3([]);
866
1052
  const controlled = measurements !== void 0;
867
1053
  const list = controlled ? measurements : held;
@@ -908,7 +1094,7 @@ var MeasureTool = ({
908
1094
  if (box.isEmpty()) return null;
909
1095
  const label = className(labelClassName);
910
1096
  return /* @__PURE__ */ jsxs4("group", { userData: FURNITURE4, children: [
911
- /* @__PURE__ */ jsx4(
1097
+ picking ? null : /* @__PURE__ */ jsx4(
912
1098
  Snapper,
913
1099
  {
914
1100
  mode,
@@ -943,7 +1129,7 @@ var MeasureTool = ({
943
1129
  ] });
944
1130
  };
945
1131
  var NO_POINTER = { pointerEvents: "none" };
946
- var NOWHERE = [new Vector35(), new Vector35()];
1132
+ var NOWHERE = [new Vector36(), new Vector36()];
947
1133
  var Snapper = ({ mode, draft, onPlace, theme, format, label }) => {
948
1134
  const camera = useThree4((state) => state.camera);
949
1135
  const scene = useThree4((state) => state.scene);
@@ -967,6 +1153,7 @@ var Snapper = ({ mode, draft, onPlace, theme, format, label }) => {
967
1153
  useEffect3(() => {
968
1154
  const raycaster = new Raycaster2();
969
1155
  const pointer = new Vector23();
1156
+ const samples = new SampleCache();
970
1157
  const hide = () => {
971
1158
  if (!snap.current) return;
972
1159
  snap.current = null;
@@ -986,22 +1173,16 @@ var Snapper = ({ mode, draft, onPlace, theme, format, label }) => {
986
1173
  -((clientY - rect.top) / rect.height) * 2 + 1
987
1174
  );
988
1175
  raycaster.setFromCamera(pointer, camera);
989
- const hit = hitUnderRay(raycaster, scene);
990
- if (!hit?.face) {
1176
+ const target = targetUnderRay(raycaster, scene, samples);
1177
+ if (!target) {
991
1178
  hide();
992
1179
  return;
993
1180
  }
994
- const normal = hit.face.normal.clone().transformDirection(hit.object.matrixWorld);
995
1181
  const { size: viewport, draft: placed, mode: kind, format: write } = current.current;
996
- const snapped = snapAt(
997
- { point: hit.point, normal },
998
- edgesBeside(hit.object),
999
- camera,
1000
- viewport
1001
- );
1182
+ const snapped = snapAt(target, target.edges, camera, viewport);
1002
1183
  const from = placed[placed.length - 1];
1003
1184
  const lock = shift && from ? lockToAxis(from, snapped.point) : null;
1004
- const found = lock ? { ...snapped, point: new Vector35(lock.point.x, lock.point.y, lock.point.z), edge: null } : snapped;
1185
+ const found = lock ? { ...snapped, point: new Vector36(lock.point.x, lock.point.y, lock.point.z), edge: null } : snapped;
1005
1186
  snap.current = found;
1006
1187
  const node = marker.current;
1007
1188
  if (node) {
@@ -1043,8 +1224,14 @@ var Snapper = ({ mode, draft, onPlace, theme, format, label }) => {
1043
1224
  if (event.key !== "Shift" || !last || !snap.current) return;
1044
1225
  update(last.x, last.y, event.type === "keydown");
1045
1226
  };
1227
+ let pressed = false;
1228
+ const down = () => {
1229
+ pressed = true;
1230
+ };
1046
1231
  const up = (event) => {
1047
- if (event.button !== 0 || !isTap(event)) return;
1232
+ const seen = pressed;
1233
+ pressed = false;
1234
+ if (!seen || event.button !== 0 || !isTap(event)) return;
1048
1235
  update(event.clientX, event.clientY, event.shiftKey);
1049
1236
  const found = snap.current;
1050
1237
  if (!found) return;
@@ -1056,12 +1243,14 @@ var Snapper = ({ mode, draft, onPlace, theme, format, label }) => {
1056
1243
  };
1057
1244
  domElement.addEventListener("pointermove", move);
1058
1245
  domElement.addEventListener("pointerleave", leave);
1246
+ domElement.addEventListener("pointerdown", down);
1059
1247
  domElement.addEventListener("pointerup", up);
1060
1248
  window.addEventListener("keydown", key);
1061
1249
  window.addEventListener("keyup", key);
1062
1250
  return () => {
1063
1251
  domElement.removeEventListener("pointermove", move);
1064
1252
  domElement.removeEventListener("pointerleave", leave);
1253
+ domElement.removeEventListener("pointerdown", down);
1065
1254
  domElement.removeEventListener("pointerup", up);
1066
1255
  window.removeEventListener("keydown", key);
1067
1256
  window.removeEventListener("keyup", key);
@@ -1144,17 +1333,124 @@ function liveLabel(mode, placed, at, format) {
1144
1333
  }
1145
1334
  return null;
1146
1335
  }
1336
+ function targetUnderRay(raycaster, scene, samples) {
1337
+ let best = null;
1338
+ const hit = hitUnderRay(raycaster, scene);
1339
+ if (hit?.face) {
1340
+ best = {
1341
+ point: hit.point,
1342
+ normal: hit.face.normal.clone().transformDirection(hit.object.matrixWorld),
1343
+ distance: hit.distance,
1344
+ edges: samples.edgesFor(hit.object)
1345
+ };
1346
+ }
1347
+ const local = new Matrix4();
1348
+ for (const mesh of cutMeshes(scene)) {
1349
+ const planes = clippingPlanesOf(mesh);
1350
+ const sample = samples.for(mesh, planes);
1351
+ local.copy(mesh.matrixWorld).invert();
1352
+ const ray = raycaster.ray.clone().applyMatrix4(local);
1353
+ for (const [index, plane] of planes.entries()) {
1354
+ const contour = sample.contours[index];
1355
+ if (!contour) continue;
1356
+ const point = capHit(ray, plane.clone().applyMatrix4(local), contour);
1357
+ if (!point) continue;
1358
+ const world = point.applyMatrix4(mesh.matrixWorld);
1359
+ if (planes.some((other, i) => i !== index && other.distanceToPoint(world) < -1e-7)) continue;
1360
+ const distance = world.distanceTo(raycaster.ray.origin);
1361
+ if (best && distance >= best.distance) continue;
1362
+ best = {
1363
+ point: world,
1364
+ // The cap faces the half that was taken away.
1365
+ normal: plane.normal.clone().negate(),
1366
+ distance,
1367
+ edges: { positions: sample.edges, matrixWorld: mesh.matrixWorld }
1368
+ };
1369
+ }
1370
+ }
1371
+ return best;
1372
+ }
1373
+ function cutMeshes(scene) {
1374
+ const found = [];
1375
+ scene.traverse((object) => {
1376
+ if (!("isMesh" in object) || !object.visible || excludedFromFrame(object, scene)) return;
1377
+ if (clippingPlanesOf(object).length) found.push(object);
1378
+ });
1379
+ return found;
1380
+ }
1381
+ function clippingPlanesOf(mesh) {
1382
+ const material = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material;
1383
+ return material?.clippingPlanes ?? [];
1384
+ }
1147
1385
  function edgesBeside(object) {
1148
1386
  const siblings = object.parent?.children ?? [];
1149
1387
  for (const sibling of siblings) {
1150
1388
  if (!("isLineSegments" in sibling)) continue;
1151
- const geometry = sibling.geometry;
1152
- const positions = geometry.getAttribute("position");
1153
- if (!positions) continue;
1154
- return { positions: positions.array, matrixWorld: sibling.matrixWorld };
1389
+ const positions = sibling.geometry.getAttribute("position");
1390
+ if (positions) return positions.array;
1155
1391
  }
1156
1392
  return null;
1157
1393
  }
1394
+ var SampleCache = class {
1395
+ held = /* @__PURE__ */ new Map();
1396
+ regions = /* @__PURE__ */ new Map();
1397
+ /** The edges to snap to on `mesh`: its own, or the trimmed set with the cut's outline. */
1398
+ edgesFor(mesh) {
1399
+ const planes = clippingPlanesOf(mesh);
1400
+ if (planes.length === 0) {
1401
+ const positions = edgesBeside(mesh);
1402
+ return positions ? { positions, matrixWorld: mesh.matrixWorld } : null;
1403
+ }
1404
+ return { positions: this.for(mesh, planes).edges, matrixWorld: mesh.matrixWorld };
1405
+ }
1406
+ for(mesh, planes) {
1407
+ const key = planes.map((plane) => `${plane.normal.x},${plane.normal.y},${plane.normal.z},${plane.constant}`).join("|");
1408
+ const cached = this.held.get(mesh);
1409
+ if (cached && cached.key === key) return cached;
1410
+ const local = new Matrix4().copy(mesh.matrixWorld).invert();
1411
+ const localPlanes = planes.map((plane) => plane.clone().applyMatrix4(local));
1412
+ const positions = mesh.geometry.getAttribute("position")?.array ?? [];
1413
+ const regionOf = this.regionsOf(mesh.geometry);
1414
+ const contours = localPlanes.map(
1415
+ (plane, index) => localPlanes.reduce(
1416
+ (outline, other, i) => i === index ? outline : clipSegments(outline, other),
1417
+ sectionContour(positions, regionOf, plane)
1418
+ )
1419
+ );
1420
+ const own = edgesBeside(mesh) ?? [];
1421
+ const trimmed = localPlanes.reduce(
1422
+ (kept, plane) => clipSegments(kept, plane),
1423
+ own
1424
+ );
1425
+ const total = contours.reduce((sum, contour) => sum + contour.length, trimmed.length);
1426
+ const edges = new Float32Array(total);
1427
+ edges.set(trimmed, 0);
1428
+ let offset = trimmed.length;
1429
+ for (const contour of contours) {
1430
+ edges.set(contour, offset);
1431
+ offset += contour.length;
1432
+ }
1433
+ const sample = { key, contours, edges };
1434
+ this.held.set(mesh, sample);
1435
+ return sample;
1436
+ }
1437
+ /**
1438
+ * Which surface each triangle belongs to, read off the region attribute
1439
+ * `createPart` gives the mesh. A mesh without one is sampled as a single
1440
+ * surface, which still stitches a flat face's outline into one run.
1441
+ */
1442
+ regionsOf(geometry) {
1443
+ if (this.regions.has(geometry)) return this.regions.get(geometry) ?? null;
1444
+ const attribute = geometry.getAttribute(REGION_ATTRIBUTE);
1445
+ let regions = null;
1446
+ if (attribute && !geometry.index) {
1447
+ regions = new Int32Array(Math.floor(attribute.count / 3));
1448
+ for (let t = 0; t < regions.length; t += 1) regions[t] = attribute.getX(t * 3);
1449
+ }
1450
+ this.regions.set(geometry, regions);
1451
+ return regions;
1452
+ }
1453
+ };
1158
1454
  var MeasurementView = ({
1159
1455
  measurement,
1160
1456
  theme,
@@ -1194,7 +1490,7 @@ var DeltaLegs = ({ a, b, id, format, label }) => {
1194
1490
  const lines = useRef3([]);
1195
1491
  const centre = useMemo4(() => {
1196
1492
  const at = midpoint(a, b);
1197
- return new Vector35(at.x, at.y, at.z);
1493
+ return new Vector36(at.x, at.y, at.z);
1198
1494
  }, [a, b]);
1199
1495
  useFrame2(() => {
1200
1496
  const dash = screenLength(camera, centre, size, DELTA_DASH_PIXELS);
@@ -1216,8 +1512,8 @@ var DeltaLegs = ({ a, b, id, format, label }) => {
1216
1512
  lines.current[index] = line;
1217
1513
  },
1218
1514
  points: [
1219
- new Vector35(leg.from.x, leg.from.y, leg.from.z),
1220
- new Vector35(leg.to.x, leg.to.y, leg.to.z)
1515
+ new Vector36(leg.from.x, leg.from.y, leg.from.z),
1516
+ new Vector36(leg.to.x, leg.to.y, leg.to.z)
1221
1517
  ],
1222
1518
  color: AXIS_COLORS[leg.axis],
1223
1519
  lineWidth: MEASURE_LINE_PIXELS.leg,
@@ -1253,7 +1549,7 @@ var ScreenDot = ({ point, pixels, color }) => {
1253
1549
  const invalidate = useThree4((state) => state.invalidate);
1254
1550
  const node = useRef3(null);
1255
1551
  const dot = useDot();
1256
- const position = useMemo4(() => new Vector35(point.x, point.y, point.z), [point]);
1552
+ const position = useMemo4(() => new Vector36(point.x, point.y, point.z), [point]);
1257
1553
  useFrame2(() => {
1258
1554
  const group = node.current;
1259
1555
  if (!group) return;
@@ -1268,7 +1564,7 @@ var ScreenDot = ({ point, pixels, color }) => {
1268
1564
  var Segment = ({ points, color }) => {
1269
1565
  const [a, b] = points;
1270
1566
  const ends = useMemo4(
1271
- () => [new Vector35(a.x, a.y, a.z), new Vector35(b.x, b.y, b.z)],
1567
+ () => [new Vector36(a.x, a.y, a.z), new Vector36(b.x, b.y, b.z)],
1272
1568
  // Keyed on the numbers: a draft's tuple is a fresh array on every render,
1273
1569
  // and a rebuilt array would rebuild the line's geometry with it.
1274
1570
  [a.x, a.y, a.z, b.x, b.y, b.z]
@@ -1419,6 +1715,8 @@ export {
1419
1715
  buildRegionTexels,
1420
1716
  cadViewDirections,
1421
1717
  cameraLimits,
1718
+ capHit,
1719
+ clipSegments,
1422
1720
  contentBounds,
1423
1721
  createCapMaterial,
1424
1722
  createMaskMaterial,
@@ -1463,6 +1761,7 @@ export {
1463
1761
  panelGeometry,
1464
1762
  perspectiveFitDistance,
1465
1763
  pickedStartDepth,
1764
+ pointInContour,
1466
1765
  rankOwners,
1467
1766
  regionAdjacency,
1468
1767
  regionEdgesGeometry,
@@ -1471,11 +1770,13 @@ export {
1471
1770
  resolveTheme,
1472
1771
  retargetPose,
1473
1772
  sameDirection,
1773
+ sampleSection,
1474
1774
  screenLength,
1475
1775
  screenPoint,
1476
1776
  sectionAnchor,
1477
1777
  sectionBounds,
1478
1778
  sectionConstant,
1779
+ sectionContour,
1479
1780
  sectionDepth,
1480
1781
  sectionDepthConstant,
1481
1782
  sectionDepthRange,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toolpath/viewer",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "React Three Fiber viewer for Toolpath Engine part reports",
5
5
  "license": "MIT",
6
6
  "engines": {