react-os-shell 0.2.45 → 0.2.47

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.
@@ -410,6 +410,10 @@ function DxfPanel({ url, filename, onDownload, onEmail }) {
410
410
  const [layers, setLayers] = useState([]);
411
411
  const [showLayers, setShowLayers] = useState(false);
412
412
  const [showHint, setShowHint] = useState(true);
413
+ const [measureEnabled, setMeasureEnabled] = useState(false);
414
+ const [measureMode, setMeasureMode] = useState("point");
415
+ const [measureDistance, setMeasureDistance] = useState(null);
416
+ const measureRef = useRef(null);
413
417
  useEffect(() => {
414
418
  let cancelled = false;
415
419
  let viewer = null;
@@ -512,6 +516,348 @@ function DxfPanel({ url, filename, onDownload, onEmail }) {
512
516
  const t = setTimeout(() => setShowHint(false), 5e3);
513
517
  return () => clearTimeout(t);
514
518
  }, [showHint, loading]);
519
+ useEffect(() => {
520
+ const v = viewerRef.current;
521
+ if (loading || error || !v) return;
522
+ const scene = v.GetScene?.();
523
+ const camera = v.GetCamera?.();
524
+ const canvas = v.GetCanvas?.();
525
+ if (!scene || !camera || !canvas || !containerRef.current) return;
526
+ const teardown = () => {
527
+ const s = measureRef.current;
528
+ if (!s) return;
529
+ if (s.overlay && s.overlay.parentElement) s.overlay.parentElement.removeChild(s.overlay);
530
+ measureRef.current = null;
531
+ };
532
+ if (!measureEnabled) {
533
+ teardown();
534
+ setMeasureDistance(null);
535
+ return;
536
+ }
537
+ const overlay = document.createElement("div");
538
+ overlay.style.cssText = "position:absolute;inset:0;pointer-events:none;z-index:5;";
539
+ containerRef.current.appendChild(overlay);
540
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
541
+ svg.setAttribute("style", "position:absolute;inset:0;width:100%;height:100%;pointer-events:none;");
542
+ overlay.appendChild(svg);
543
+ const refLineEl = document.createElementNS("http://www.w3.org/2000/svg", "line");
544
+ refLineEl.setAttribute("stroke", "#ff8800");
545
+ refLineEl.setAttribute("stroke-width", "1");
546
+ refLineEl.setAttribute("stroke-dasharray", "6,4");
547
+ refLineEl.setAttribute("opacity", "0.55");
548
+ refLineEl.style.display = "none";
549
+ svg.appendChild(refLineEl);
550
+ const lineEl = document.createElementNS("http://www.w3.org/2000/svg", "line");
551
+ lineEl.setAttribute("stroke", "#ff8800");
552
+ lineEl.setAttribute("stroke-width", "1.5");
553
+ lineEl.setAttribute("stroke-linecap", "round");
554
+ lineEl.style.display = "none";
555
+ svg.appendChild(lineEl);
556
+ const snapEl = document.createElement("div");
557
+ snapEl.style.cssText = `position:absolute;width:14px;height:14px;border:2px solid #ff8800;background:rgba(255,255,255,0.7);transform:translate(-50%,-50%) rotate(45deg);box-sizing:border-box;display:none;`;
558
+ overlay.appendChild(snapEl);
559
+ measureRef.current = {
560
+ picks: [],
561
+ lineDir: null,
562
+ overlay,
563
+ svg,
564
+ line: lineEl,
565
+ refLine: refLineEl,
566
+ markers: [],
567
+ label: null,
568
+ snap: snapEl,
569
+ segments: []
570
+ };
571
+ let THREE = null;
572
+ let ready = false;
573
+ const pxFromScene = (sx, sy) => {
574
+ if (!THREE) return { x: 0, y: 0 };
575
+ const v3 = new THREE.Vector3(sx, sy, 0).project(camera);
576
+ const w = canvas.clientWidth, h = canvas.clientHeight;
577
+ return { x: (v3.x + 1) / 2 * w, y: (-v3.y + 1) / 2 * h };
578
+ };
579
+ (async () => {
580
+ try {
581
+ THREE = await import(
582
+ /* @vite-ignore */
583
+ 'three'
584
+ );
585
+ const segs = measureRef.current?.segments;
586
+ if (!segs) return;
587
+ const va = new THREE.Vector3(), vb = new THREE.Vector3();
588
+ scene.traverse((obj) => {
589
+ if (!obj?.isLineSegments) return;
590
+ const pos = obj.geometry?.attributes?.position;
591
+ if (!pos) return;
592
+ const arr = pos.array;
593
+ obj.updateMatrixWorld?.();
594
+ const m = obj.matrixWorld;
595
+ for (let i = 0; i < arr.length; i += 6) {
596
+ va.set(arr[i], arr[i + 1], arr[i + 2]).applyMatrix4(m);
597
+ vb.set(arr[i + 3], arr[i + 4], arr[i + 5]).applyMatrix4(m);
598
+ segs.push({ ax: va.x, ay: va.y, bx: vb.x, by: vb.y });
599
+ }
600
+ });
601
+ ready = true;
602
+ } catch {
603
+ }
604
+ })();
605
+ const SNAP_PX = 12;
606
+ const findSnap = (cx, cy) => {
607
+ const s = measureRef.current;
608
+ if (!s || !ready) return null;
609
+ let best = null;
610
+ let bestD2 = SNAP_PX * SNAP_PX;
611
+ for (const seg of s.segments) {
612
+ const ap = pxFromScene(seg.ax, seg.ay);
613
+ const bp = pxFromScene(seg.bx, seg.by);
614
+ const ldx = seg.bx - seg.ax, ldy = seg.by - seg.ay;
615
+ const llen = Math.sqrt(ldx * ldx + ldy * ldy) || 1;
616
+ const segDir = { dx: ldx / llen, dy: ldy / llen };
617
+ let dx = cx - ap.x, dy = cy - ap.y;
618
+ let d2 = dx * dx + dy * dy;
619
+ if (d2 < bestD2) {
620
+ best = { sx: seg.ax, sy: seg.ay, type: "endpoint", dir: segDir };
621
+ bestD2 = d2;
622
+ }
623
+ dx = cx - bp.x;
624
+ dy = cy - bp.y;
625
+ d2 = dx * dx + dy * dy;
626
+ if (d2 < bestD2) {
627
+ best = { sx: seg.bx, sy: seg.by, type: "endpoint", dir: segDir };
628
+ bestD2 = d2;
629
+ }
630
+ const sdx = bp.x - ap.x, sdy = bp.y - ap.y;
631
+ const len2 = sdx * sdx + sdy * sdy;
632
+ if (len2 > 0) {
633
+ const t = ((cx - ap.x) * sdx + (cy - ap.y) * sdy) / len2;
634
+ if (t > 0 && t < 1) {
635
+ const px = ap.x + t * sdx, py = ap.y + t * sdy;
636
+ dx = cx - px;
637
+ dy = cy - py;
638
+ d2 = dx * dx + dy * dy;
639
+ if (d2 < bestD2) {
640
+ const sx = seg.ax + t * (seg.bx - seg.ax);
641
+ const sy = seg.ay + t * (seg.by - seg.ay);
642
+ best = { sx, sy, type: "line", dir: segDir };
643
+ bestD2 = d2;
644
+ }
645
+ }
646
+ }
647
+ }
648
+ return best;
649
+ };
650
+ const makeMarker = () => {
651
+ const el = document.createElement("div");
652
+ el.style.cssText = `position:absolute;width:10px;height:10px;border-radius:50%;background:#ff8800;border:2px solid #fff;box-shadow:0 0 0 1px rgba(0,0,0,0.25);transform:translate(-50%,-50%);pointer-events:none;`;
653
+ overlay.appendChild(el);
654
+ return el;
655
+ };
656
+ const ensureLabel = () => {
657
+ const s = measureRef.current;
658
+ if (s.label) return s.label;
659
+ const el = document.createElement("div");
660
+ el.style.cssText = `position:absolute;transform:translate(-50%,-50%);padding:2px 6px;font-size:11px;font-weight:600;font-family:system-ui,-apple-system,sans-serif;background:rgba(255,136,0,0.95);color:#fff;border-radius:4px;white-space:nowrap;box-shadow:0 1px 4px rgba(0,0,0,0.25);pointer-events:none;`;
661
+ overlay.appendChild(el);
662
+ s.label = el;
663
+ return el;
664
+ };
665
+ const positionMarker = (el, x, y) => {
666
+ const p = pxFromScene(x, y);
667
+ el.style.left = `${p.x}px`;
668
+ el.style.top = `${p.y}px`;
669
+ };
670
+ const computeRenderedEnds = () => {
671
+ const s = measureRef.current;
672
+ const a = s.picks[0];
673
+ let b = s.picks[1];
674
+ if (measureMode === "perp" && s.lineDir) {
675
+ const d = s.lineDir;
676
+ const ax = a.x, ay = a.y;
677
+ const t = (b.x - ax) * d.dx + (b.y - ay) * d.dy;
678
+ const fx = ax + d.dx * t;
679
+ const fy = ay + d.dy * t;
680
+ return { from: { x: fx, y: fy }, to: { x: b.x, y: b.y } };
681
+ }
682
+ return { from: a, to: b };
683
+ };
684
+ const updateOverlay = () => {
685
+ const s = measureRef.current;
686
+ if (!s) return;
687
+ if (s.markers[0]) positionMarker(s.markers[0], s.picks[0].x, s.picks[0].y);
688
+ if (s.markers[1]) positionMarker(s.markers[1], s.picks[1].x, s.picks[1].y);
689
+ if (measureMode === "perp" && s.picks.length >= 1 && s.lineDir && s.refLine) {
690
+ const a = s.picks[0];
691
+ const w = canvas.clientWidth, h = canvas.clientHeight;
692
+ const screenSpan = Math.hypot(w, h) * 4;
693
+ const ap = pxFromScene(a.x, a.y);
694
+ const probe = pxFromScene(a.x + s.lineDir.dx, a.y + s.lineDir.dy);
695
+ const pxLen = Math.hypot(probe.x - ap.x, probe.y - ap.y) || 1;
696
+ const sceneStep = screenSpan / pxLen;
697
+ const x0 = a.x - s.lineDir.dx * sceneStep;
698
+ const y0 = a.y - s.lineDir.dy * sceneStep;
699
+ const x1 = a.x + s.lineDir.dx * sceneStep;
700
+ const y1 = a.y + s.lineDir.dy * sceneStep;
701
+ const p0 = pxFromScene(x0, y0);
702
+ const p1 = pxFromScene(x1, y1);
703
+ s.refLine.setAttribute("x1", String(p0.x));
704
+ s.refLine.setAttribute("y1", String(p0.y));
705
+ s.refLine.setAttribute("x2", String(p1.x));
706
+ s.refLine.setAttribute("y2", String(p1.y));
707
+ s.refLine.style.display = "";
708
+ } else if (s.refLine) {
709
+ s.refLine.style.display = "none";
710
+ }
711
+ if (s.picks.length === 2) {
712
+ const ends = computeRenderedEnds();
713
+ const fp = pxFromScene(ends.from.x, ends.from.y);
714
+ const tp = pxFromScene(ends.to.x, ends.to.y);
715
+ s.line.setAttribute("x1", String(fp.x));
716
+ s.line.setAttribute("y1", String(fp.y));
717
+ s.line.setAttribute("x2", String(tp.x));
718
+ s.line.setAttribute("y2", String(tp.y));
719
+ s.line.style.display = "";
720
+ if (s.label) {
721
+ s.label.style.left = `${(fp.x + tp.x) / 2}px`;
722
+ s.label.style.top = `${(fp.y + tp.y) / 2}px`;
723
+ }
724
+ } else {
725
+ s.line.style.display = "none";
726
+ }
727
+ };
728
+ const DRAG_TOL = 4;
729
+ const DRAG_TIME = 350;
730
+ let downX = 0, downY = 0, downTime = 0, downActive = false, dragging = false;
731
+ let lastSnap = null;
732
+ const handlePointerDown = (ev) => {
733
+ const d = ev?.detail;
734
+ if (!d || d.domEvent?.button !== 0) return;
735
+ downX = d.canvasCoord.x;
736
+ downY = d.canvasCoord.y;
737
+ downTime = performance.now();
738
+ dragging = false;
739
+ downActive = true;
740
+ };
741
+ const handlePointerMove = (ev) => {
742
+ const rect = canvas.getBoundingClientRect();
743
+ const cx = ev.clientX - rect.left;
744
+ const cy = ev.clientY - rect.top;
745
+ if (downActive) {
746
+ if ((cx - downX) ** 2 + (cy - downY) ** 2 > DRAG_TOL * DRAG_TOL) dragging = true;
747
+ }
748
+ const s = measureRef.current;
749
+ if (!s || !s.snap) return;
750
+ if (downActive && dragging) {
751
+ s.snap.style.display = "none";
752
+ return;
753
+ }
754
+ lastSnap = findSnap(cx, cy);
755
+ if (lastSnap) {
756
+ const p = pxFromScene(lastSnap.sx, lastSnap.sy);
757
+ s.snap.style.left = `${p.x}px`;
758
+ s.snap.style.top = `${p.y}px`;
759
+ s.snap.style.display = "";
760
+ } else {
761
+ s.snap.style.display = "none";
762
+ }
763
+ };
764
+ const handlePointerUp = (ev) => {
765
+ if (!downActive) return;
766
+ downActive = false;
767
+ const elapsed = performance.now() - downTime;
768
+ if (dragging || elapsed > DRAG_TIME) return;
769
+ const raw = ev?.detail?.position;
770
+ if (!raw) return;
771
+ const useSnap = lastSnap && Math.hypot(downX - canvas.getBoundingClientRect().width, 0) >= 0;
772
+ const picked = useSnap && lastSnap ? { x: lastSnap.sx, y: lastSnap.sy, snapType: lastSnap.type, lineDir: lastSnap.dir } : { x: raw.x, y: raw.y, lineDir: void 0 };
773
+ doPick(picked);
774
+ };
775
+ const doPick = (p) => {
776
+ const s = measureRef.current;
777
+ if (!s) return;
778
+ if (s.picks.length === 2) {
779
+ for (const m of s.markers) m.parentElement?.removeChild(m);
780
+ s.markers = [];
781
+ s.picks = [];
782
+ s.lineDir = null;
783
+ s.line.style.display = "none";
784
+ if (s.refLine) s.refLine.style.display = "none";
785
+ if (s.label) s.label.style.opacity = "0";
786
+ setMeasureDistance(null);
787
+ }
788
+ if (measureMode === "perp" && s.picks.length === 0) {
789
+ if (!p.lineDir) {
790
+ const label = ensureLabel();
791
+ label.style.opacity = "1";
792
+ label.style.left = `${pxFromScene(p.x, p.y).x}px`;
793
+ label.style.top = `${pxFromScene(p.x, p.y).y - 18}px`;
794
+ label.textContent = "\u22A5: snap to a line or corner first";
795
+ setTimeout(() => {
796
+ if (s.label && s.picks.length === 0) s.label.style.opacity = "0";
797
+ }, 1500);
798
+ return;
799
+ }
800
+ s.lineDir = p.lineDir;
801
+ }
802
+ s.picks.push({ x: p.x, y: p.y });
803
+ s.markers.push(makeMarker());
804
+ if (s.picks.length === 2) {
805
+ const a = s.picks[0], b = s.picks[1];
806
+ let dist;
807
+ let suffix = "";
808
+ if (measureMode === "perp" && s.lineDir) {
809
+ const dx = b.x - a.x, dy = b.y - a.y;
810
+ dist = Math.abs(dx * s.lineDir.dy - dy * s.lineDir.dx);
811
+ suffix = " \u22A5";
812
+ } else {
813
+ const dx = b.x - a.x, dy = b.y - a.y;
814
+ dist = Math.sqrt(dx * dx + dy * dy);
815
+ }
816
+ setMeasureDistance(dist);
817
+ const label = ensureLabel();
818
+ label.style.opacity = "1";
819
+ label.textContent = `${formatMeasureDistance(dist)}${suffix}`;
820
+ }
821
+ updateOverlay();
822
+ };
823
+ const onKeyDown = (ev) => {
824
+ if (ev.key === "Escape") setMeasureEnabled(false);
825
+ };
826
+ canvas.style.cursor = "crosshair";
827
+ try {
828
+ v.Subscribe?.("pointerdown", handlePointerDown);
829
+ } catch {
830
+ }
831
+ try {
832
+ v.Subscribe?.("pointerup", handlePointerUp);
833
+ } catch {
834
+ }
835
+ try {
836
+ v.Subscribe?.("viewChanged", updateOverlay);
837
+ } catch {
838
+ }
839
+ canvas.addEventListener("pointermove", handlePointerMove);
840
+ window.addEventListener("keydown", onKeyDown);
841
+ return () => {
842
+ canvas.style.cursor = "";
843
+ try {
844
+ v.Unsubscribe?.("pointerdown", handlePointerDown);
845
+ } catch {
846
+ }
847
+ try {
848
+ v.Unsubscribe?.("pointerup", handlePointerUp);
849
+ } catch {
850
+ }
851
+ try {
852
+ v.Unsubscribe?.("viewChanged", updateOverlay);
853
+ } catch {
854
+ }
855
+ canvas.removeEventListener("pointermove", handlePointerMove);
856
+ window.removeEventListener("keydown", onKeyDown);
857
+ teardown();
858
+ setMeasureDistance(null);
859
+ };
860
+ }, [measureEnabled, measureMode, loading, error]);
515
861
  const toggleLayer = (name) => {
516
862
  setLayers((prev) => prev.map((l) => {
517
863
  if (l.name !== name) return l;
@@ -586,6 +932,45 @@ function DxfPanel({ url, filename, onDownload, onEmail }) {
586
932
  }
587
933
  ),
588
934
  /* @__PURE__ */ jsx("button", { onClick: () => setShowHint((s) => !s), className: btn, title: "How to navigate", children: /* @__PURE__ */ jsx("svg", { className: "h-3.5 w-3.5", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", strokeWidth: 1.5, children: /* @__PURE__ */ jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z" }) }) }),
935
+ /* @__PURE__ */ jsxs(
936
+ "button",
937
+ {
938
+ onClick: () => setMeasureEnabled((m) => !m),
939
+ className: btn + (measureEnabled ? " bg-gray-200" : ""),
940
+ title: measureEnabled ? "Stop measuring (Esc)" : "Measure distance \u2014 click two points on the drawing",
941
+ children: [
942
+ /* @__PURE__ */ jsxs("svg", { className: "h-3.5 w-3.5", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", strokeWidth: 1.5, children: [
943
+ /* @__PURE__ */ jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M3.75 14.25l6-6 6 6 4.5-4.5M9.75 8.25v3M12.75 11.25v3M15.75 14.25v3" }),
944
+ /* @__PURE__ */ jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M2.25 18.75h19.5" })
945
+ ] }),
946
+ "Measure"
947
+ ]
948
+ }
949
+ ),
950
+ measureEnabled && /* @__PURE__ */ jsxs("div", { className: "flex items-stretch h-7 rounded border border-gray-200 overflow-hidden text-[11px] font-semibold", children: [
951
+ /* @__PURE__ */ jsx(
952
+ "button",
953
+ {
954
+ onClick: () => setMeasureMode("point"),
955
+ className: `px-2 transition-colors ${measureMode === "point" ? "bg-orange-500 text-white" : "bg-white text-gray-600 hover:bg-gray-50"}`,
956
+ title: "Point \u2014 straight-line distance between two picks",
957
+ children: "Point"
958
+ }
959
+ ),
960
+ /* @__PURE__ */ jsx(
961
+ "button",
962
+ {
963
+ onClick: () => setMeasureMode("perp"),
964
+ className: `px-2 transition-colors ${measureMode === "perp" ? "bg-orange-500 text-white" : "bg-white text-gray-600 hover:bg-gray-50"}`,
965
+ title: "Perpendicular \u2014 click on a line first, then pick a point. Reports the perpendicular distance.",
966
+ children: "\u22A5"
967
+ }
968
+ )
969
+ ] }),
970
+ measureEnabled && measureDistance !== null && /* @__PURE__ */ jsxs("div", { className: "px-2 py-1 text-[11px] font-mono font-semibold text-orange-600 bg-orange-50 border border-orange-200 rounded whitespace-nowrap", title: measureMode === "perp" ? "Perpendicular distance from second pick to first line" : "Straight-line distance between the two picked points", children: [
971
+ formatMeasureDistance(measureDistance),
972
+ measureMode === "perp" ? " \u22A5" : ""
973
+ ] }),
589
974
  /* @__PURE__ */ jsx("button", { onClick: handleResetView, className: btn, title: "Fit drawing to view", children: "Fit" }),
590
975
  /* @__PURE__ */ jsxs("button", { onClick: onDownload ?? handleDefaultDownload, className: btn, children: [
591
976
  /* @__PURE__ */ jsx("svg", { className: "h-3.5 w-3.5", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", strokeWidth: 1.5, children: /* @__PURE__ */ jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" }) }),
@@ -664,6 +1049,11 @@ function hexToRgb(OV, hex) {
664
1049
  const n = m ? parseInt(m[1], 16) : 0;
665
1050
  return new OV.RGBColor(n >> 16 & 255, n >> 8 & 255, n & 255);
666
1051
  }
1052
+ function formatMeasureDistance(mm) {
1053
+ if (mm >= 1e3) return `${(mm / 1e3).toFixed(2)} m`;
1054
+ if (mm >= 10) return `${mm.toFixed(1)} mm`;
1055
+ return `${mm.toFixed(2)} mm`;
1056
+ }
667
1057
  function StepPanel({ url, filename, onDownload, onEmail }) {
668
1058
  const containerRef = useRef(null);
669
1059
  const viewerRef = useRef(null);
@@ -680,15 +1070,21 @@ function StepPanel({ url, filename, onDownload, onEmail }) {
680
1070
  const [edgeThreshold, setEdgeThreshold] = useState(1);
681
1071
  const [showMeshes, setShowMeshes] = useState(false);
682
1072
  const [showSettings, setShowSettings] = useState(false);
683
- const [perspective, setPerspective] = useState(true);
1073
+ const [perspective, setPerspective] = useState(false);
684
1074
  const [sectionEnabled, setSectionEnabled] = useState(false);
685
- const [sectionAxis, setSectionAxis] = useState("z");
1075
+ const [sectionAxis, setSectionAxis] = useState("x");
686
1076
  const [sectionFlip, setSectionFlip] = useState(false);
687
1077
  const [sectionPosition, setSectionPosition] = useState(0.5);
1078
+ const [sectionAngle, setSectionAngle] = useState(0);
1079
+ const [measureEnabled, setMeasureEnabled] = useState(false);
1080
+ const [measureMode, setMeasureMode] = useState("point");
1081
+ const [measureDistance, setMeasureDistance] = useState(null);
1082
+ const measureRef = useRef(null);
688
1083
  const sectionRef = useRef(null);
689
1084
  useEffect(() => {
690
1085
  let cancelled = false;
691
1086
  let viewer = null;
1087
+ let resizeObserver = null;
692
1088
  setLoading(true);
693
1089
  setError(null);
694
1090
  setTree(null);
@@ -751,6 +1147,18 @@ function StepPanel({ url, filename, onDownload, onEmail }) {
751
1147
  viewerRef.current = viewer;
752
1148
  const inputFile = new OV.InputFile(filename, OV.FileSource.Url, url);
753
1149
  viewer.LoadModelFromInputFiles([inputFile]);
1150
+ if (containerRef.current && typeof ResizeObserver !== "undefined") {
1151
+ resizeObserver = new ResizeObserver(() => {
1152
+ try {
1153
+ const v = viewerRef.current;
1154
+ if (v?.Resize) v.Resize();
1155
+ else if (v?.viewer?.Resize) v.viewer.Resize();
1156
+ v?.viewer?.Render?.();
1157
+ } catch {
1158
+ }
1159
+ });
1160
+ resizeObserver.observe(containerRef.current);
1161
+ }
754
1162
  } catch (e) {
755
1163
  if (!cancelled) {
756
1164
  setError(e?.message || "Failed to load 3D model.");
@@ -760,6 +1168,10 @@ function StepPanel({ url, filename, onDownload, onEmail }) {
760
1168
  })();
761
1169
  return () => {
762
1170
  cancelled = true;
1171
+ try {
1172
+ resizeObserver?.disconnect();
1173
+ } catch {
1174
+ }
763
1175
  try {
764
1176
  viewer?.Destroy?.();
765
1177
  } catch {
@@ -1003,19 +1415,35 @@ function StepPanel({ url, filename, onDownload, onEmail }) {
1003
1415
  const axisIdx = sectionAxis === "x" ? 0 : sectionAxis === "y" ? 1 : 2;
1004
1416
  const min = [bbox.min.x, bbox.min.y, bbox.min.z][axisIdx];
1005
1417
  const max = [bbox.max.x, bbox.max.y, bbox.max.z][axisIdx];
1006
- const value = min + (max - min) * sectionPosition;
1007
- const dir = sectionFlip ? 1 : -1;
1008
- const nx = sectionAxis === "x" ? dir : 0;
1009
- const ny = sectionAxis === "y" ? dir : 0;
1010
- const nz = sectionAxis === "z" ? dir : 0;
1418
+ const t = min + (max - min) * sectionPosition;
1419
+ const \u03B8 = sectionAngle * Math.PI / 180;
1420
+ const cos\u03B8 = Math.cos(\u03B8), sin\u03B8 = Math.sin(\u03B8);
1421
+ const sign = sectionFlip ? -1 : 1;
1422
+ let nx = 0, ny = 0, nz = 0;
1423
+ if (sectionAxis === "x") {
1424
+ nx = sign * cos\u03B8;
1425
+ nz = sign * sin\u03B8;
1426
+ } else if (sectionAxis === "y") {
1427
+ ny = sign * cos\u03B8;
1428
+ nz = sign * sin\u03B8;
1429
+ } else {
1430
+ nx = sign * sin\u03B8;
1431
+ nz = sign * cos\u03B8;
1432
+ }
1433
+ const center = {
1434
+ x: (bbox.min.x + bbox.max.x) / 2,
1435
+ y: (bbox.min.y + bbox.max.y) / 2,
1436
+ z: (bbox.min.z + bbox.max.z) / 2
1437
+ };
1438
+ const Px = sectionAxis === "x" ? t : center.x;
1439
+ const Py = sectionAxis === "y" ? t : center.y;
1440
+ const Pz = sectionAxis === "z" ? t : center.z;
1011
1441
  s.plane.normal.x = nx;
1012
1442
  s.plane.normal.y = ny;
1013
1443
  s.plane.normal.z = nz;
1014
- s.plane.constant = -dir * value;
1444
+ s.plane.constant = -(nx * Px + ny * Py + nz * Pz);
1015
1445
  if (s.capMesh) {
1016
- const cx = (bbox.min.x + bbox.max.x) / 2;
1017
- const cy = (bbox.min.y + bbox.max.y) / 2;
1018
- const cz = (bbox.min.z + bbox.max.z) / 2;
1446
+ const cx = center.x, cy = center.y, cz = center.z;
1019
1447
  const dist = nx * cx + ny * cy + nz * cz + s.plane.constant;
1020
1448
  const px = cx - nx * dist;
1021
1449
  const py = cy - ny * dist;
@@ -1027,7 +1455,367 @@ function StepPanel({ url, filename, onDownload, onEmail }) {
1027
1455
  } catch (err) {
1028
1456
  console.warn("[Preview] section update failed", err);
1029
1457
  }
1030
- }, [sectionEnabled, sectionAxis, sectionFlip, sectionPosition]);
1458
+ }, [sectionEnabled, sectionAxis, sectionFlip, sectionPosition, sectionAngle]);
1459
+ useEffect(() => {
1460
+ const v = viewerRef.current;
1461
+ if (!v?.viewer || loading || !containerRef.current) return;
1462
+ const renderer = v.viewer.renderer;
1463
+ const scene = v.viewer.scene;
1464
+ const camera = v.viewer.camera;
1465
+ const canvas = renderer?.domElement;
1466
+ if (!renderer || !scene || !camera || !canvas) return;
1467
+ let sampleMesh = null;
1468
+ v.viewer.mainModel?.EnumerateMeshes?.((m) => {
1469
+ if (!sampleMesh && !m.userData?.__sectionHelper && !m.userData?.__measureHelper) sampleMesh = m;
1470
+ });
1471
+ if (!sampleMesh) return;
1472
+ const Vector3Ctor = sampleMesh.position.constructor;
1473
+ const MeshCtor = sampleMesh.constructor;
1474
+ const MaterialCtor = (Array.isArray(sampleMesh.material) ? sampleMesh.material[0] : sampleMesh.material)?.constructor;
1475
+ const GeometryCtor = sampleMesh.geometry.constructor;
1476
+ const BufferAttrCtor = sampleMesh.geometry.attributes?.position?.constructor;
1477
+ if (!Vector3Ctor || !MeshCtor || !MaterialCtor || !GeometryCtor || !BufferAttrCtor) return;
1478
+ const teardown = () => {
1479
+ const s = measureRef.current;
1480
+ if (!s) return;
1481
+ if (s.rafId !== null) cancelAnimationFrame(s.rafId);
1482
+ for (const m of s.markers) {
1483
+ scene.remove(m);
1484
+ m.geometry?.dispose?.();
1485
+ m.material?.dispose?.();
1486
+ }
1487
+ if (s.line) {
1488
+ scene.remove(s.line);
1489
+ s.line.geometry?.dispose?.();
1490
+ s.line.material?.dispose?.();
1491
+ }
1492
+ if (s.label && s.label.parentElement) s.label.parentElement.removeChild(s.label);
1493
+ measureRef.current = null;
1494
+ v.viewer.Render?.();
1495
+ };
1496
+ if (!measureEnabled) {
1497
+ teardown();
1498
+ setMeasureDistance(null);
1499
+ return;
1500
+ }
1501
+ measureRef.current = { points: [], normals: [], markers: [], line: null, label: null, rafId: null };
1502
+ let THREE = null;
1503
+ let raycaster = null;
1504
+ (async () => {
1505
+ try {
1506
+ THREE = await import(
1507
+ /* @vite-ignore */
1508
+ 'three'
1509
+ );
1510
+ raycaster = new THREE.Raycaster();
1511
+ } catch (err) {
1512
+ console.warn("[Preview] measure: failed to load three for raycaster", err);
1513
+ }
1514
+ })();
1515
+ const bbox = v.viewer.GetBoundingBox?.(() => true);
1516
+ const diag = bbox ? Math.sqrt(
1517
+ (bbox.max.x - bbox.min.x) ** 2 + (bbox.max.y - bbox.min.y) ** 2 + (bbox.max.z - bbox.min.z) ** 2
1518
+ ) : 100;
1519
+ const markerRadius = Math.max(diag * 5e-3, 0.2);
1520
+ const ndcFromEvent = (ev) => {
1521
+ const rect = canvas.getBoundingClientRect();
1522
+ return {
1523
+ x: (ev.clientX - rect.left) / rect.width * 2 - 1,
1524
+ y: -((ev.clientY - rect.top) / rect.height * 2 - 1)
1525
+ };
1526
+ };
1527
+ const collectTargets = () => {
1528
+ const out = [];
1529
+ v.viewer.mainModel?.EnumerateMeshes?.((m) => {
1530
+ if (m.userData?.__sectionHelper || m.userData?.__measureHelper) return;
1531
+ if (m.visible === false) return;
1532
+ out.push(m);
1533
+ });
1534
+ return out;
1535
+ };
1536
+ const makeMarker = (point) => {
1537
+ const widthSegs = 16, heightSegs = 12;
1538
+ const positions = [];
1539
+ const indices = [];
1540
+ for (let iy = 0; iy <= heightSegs; iy++) {
1541
+ const v2 = iy / heightSegs;
1542
+ const phi = v2 * Math.PI;
1543
+ for (let ix = 0; ix <= widthSegs; ix++) {
1544
+ const u = ix / widthSegs;
1545
+ const theta = u * Math.PI * 2;
1546
+ positions.push(
1547
+ markerRadius * Math.sin(phi) * Math.cos(theta),
1548
+ markerRadius * Math.cos(phi),
1549
+ markerRadius * Math.sin(phi) * Math.sin(theta)
1550
+ );
1551
+ }
1552
+ }
1553
+ for (let iy = 0; iy < heightSegs; iy++) {
1554
+ for (let ix = 0; ix < widthSegs; ix++) {
1555
+ const a = iy * (widthSegs + 1) + ix;
1556
+ const b = a + widthSegs + 1;
1557
+ indices.push(a, b, a + 1, b, b + 1, a + 1);
1558
+ }
1559
+ }
1560
+ const geom = new GeometryCtor();
1561
+ geom.setAttribute("position", new BufferAttrCtor(new Float32Array(positions), 3));
1562
+ geom.setIndex(indices);
1563
+ geom.computeVertexNormals?.();
1564
+ const mat = new MaterialCtor();
1565
+ mat.color?.setHex?.(16746496);
1566
+ mat.depthTest = false;
1567
+ mat.depthWrite = false;
1568
+ mat.transparent = true;
1569
+ mat.opacity = 0.95;
1570
+ const mesh = new MeshCtor(geom, mat);
1571
+ mesh.position.copy(point);
1572
+ mesh.renderOrder = 9999;
1573
+ mesh.userData.__measureHelper = true;
1574
+ scene.add(mesh);
1575
+ return mesh;
1576
+ };
1577
+ const buildFaceHighlight = (mesh, hit) => {
1578
+ const geom = mesh?.geometry;
1579
+ const posAttr = geom?.attributes?.position;
1580
+ if (!geom || !posAttr || !hit?.face?.normal) return null;
1581
+ const positions = posAttr.array;
1582
+ const indices = geom.index ? geom.index.array : null;
1583
+ const localPoint = new Vector3Ctor(hit.point.x, hit.point.y, hit.point.z);
1584
+ mesh.worldToLocal?.(localPoint);
1585
+ const ln = hit.face.normal;
1586
+ const planeC = -(ln.x * localPoint.x + ln.y * localPoint.y + ln.z * localPoint.z);
1587
+ geom.computeBoundingBox?.();
1588
+ const bb = geom.boundingBox;
1589
+ const diag2 = bb ? Math.sqrt((bb.max.x - bb.min.x) ** 2 + (bb.max.y - bb.min.y) ** 2 + (bb.max.z - bb.min.z) ** 2) : 100;
1590
+ const PLANE_TOL = Math.max(diag2 * 1e-3, 5e-3);
1591
+ const NORMAL_TOL = 0.9995;
1592
+ const triCount = indices ? indices.length / 3 : posAttr.count / 3;
1593
+ const matched = [];
1594
+ const tmpV = new Vector3Ctor();
1595
+ for (let t = 0; t < triCount; t++) {
1596
+ const ia = indices ? indices[t * 3] : t * 3;
1597
+ const ib = indices ? indices[t * 3 + 1] : t * 3 + 1;
1598
+ const ic = indices ? indices[t * 3 + 2] : t * 3 + 2;
1599
+ const ax = positions[ia * 3], ay = positions[ia * 3 + 1], az = positions[ia * 3 + 2];
1600
+ const bx = positions[ib * 3], by = positions[ib * 3 + 1], bz = positions[ib * 3 + 2];
1601
+ const cx = positions[ic * 3], cy = positions[ic * 3 + 1], cz = positions[ic * 3 + 2];
1602
+ const ux = bx - ax, uy = by - ay, uz = bz - az;
1603
+ const vx = cx - ax, vy = cy - ay, vz = cz - az;
1604
+ let nx = uy * vz - uz * vy;
1605
+ let ny = uz * vx - ux * vz;
1606
+ let nz = ux * vy - uy * vx;
1607
+ const nlen = Math.sqrt(nx * nx + ny * ny + nz * nz);
1608
+ if (nlen === 0) continue;
1609
+ nx /= nlen;
1610
+ ny /= nlen;
1611
+ nz /= nlen;
1612
+ const ndot = nx * ln.x + ny * ln.y + nz * ln.z;
1613
+ if (ndot < NORMAL_TOL) continue;
1614
+ const ccx = (ax + bx + cx) / 3, ccy = (ay + by + cy) / 3, ccz = (az + bz + cz) / 3;
1615
+ const dist = Math.abs(ln.x * ccx + ln.y * ccy + ln.z * ccz + planeC);
1616
+ if (dist > PLANE_TOL) continue;
1617
+ for (const [vx_, vy_, vz_] of [[ax, ay, az], [bx, by, bz], [cx, cy, cz]]) {
1618
+ tmpV.set?.(vx_, vy_, vz_);
1619
+ mesh.localToWorld?.(tmpV);
1620
+ matched.push(tmpV.x, tmpV.y, tmpV.z);
1621
+ }
1622
+ }
1623
+ if (matched.length === 0) return null;
1624
+ const hgeom = new GeometryCtor();
1625
+ hgeom.setAttribute("position", new BufferAttrCtor(new Float32Array(matched), 3));
1626
+ hgeom.computeVertexNormals?.();
1627
+ const hmat = new MaterialCtor();
1628
+ hmat.color?.setHex?.(16746496);
1629
+ hmat.transparent = true;
1630
+ hmat.opacity = 0.45;
1631
+ hmat.depthWrite = false;
1632
+ hmat.polygonOffset = true;
1633
+ hmat.polygonOffsetFactor = -2;
1634
+ hmat.polygonOffsetUnits = -2;
1635
+ const highlight = new MeshCtor(hgeom, hmat);
1636
+ highlight.renderOrder = 9998;
1637
+ highlight.userData.__measureHelper = true;
1638
+ scene.add(highlight);
1639
+ return highlight;
1640
+ };
1641
+ const drawLine = (a, b) => {
1642
+ const geom = new GeometryCtor();
1643
+ geom.setAttribute("position", new BufferAttrCtor(new Float32Array([a.x, a.y, a.z, b.x, b.y, b.z]), 3));
1644
+ const LineCtor = THREE?.Line ?? MeshCtor;
1645
+ const LineMatCtor = THREE?.LineBasicMaterial ?? MaterialCtor;
1646
+ const mat = new LineMatCtor({ color: 16746496, depthTest: false, depthWrite: false, transparent: true, opacity: 0.95 });
1647
+ const line = new LineCtor(geom, mat);
1648
+ line.renderOrder = 9998;
1649
+ line.userData.__measureHelper = true;
1650
+ scene.add(line);
1651
+ return line;
1652
+ };
1653
+ const ensureLabel = () => {
1654
+ const s = measureRef.current;
1655
+ if (s.label) return s.label;
1656
+ const el = document.createElement("div");
1657
+ el.style.position = "absolute";
1658
+ el.style.transform = "translate(-50%, -50%)";
1659
+ el.style.padding = "2px 6px";
1660
+ el.style.fontSize = "11px";
1661
+ el.style.fontWeight = "600";
1662
+ el.style.fontFamily = "system-ui, -apple-system, sans-serif";
1663
+ el.style.background = "rgba(255, 136, 0, 0.95)";
1664
+ el.style.color = "#fff";
1665
+ el.style.borderRadius = "4px";
1666
+ el.style.pointerEvents = "none";
1667
+ el.style.whiteSpace = "nowrap";
1668
+ el.style.boxShadow = "0 1px 4px rgba(0,0,0,0.25)";
1669
+ el.style.zIndex = "5";
1670
+ containerRef.current.appendChild(el);
1671
+ s.label = el;
1672
+ return el;
1673
+ };
1674
+ const updateLabel = () => {
1675
+ const s = measureRef.current;
1676
+ if (!s || s.points.length < 2 || !s.label) return;
1677
+ const a = s.points[0], b = s.points[1];
1678
+ const mid = new Vector3Ctor((a.x + b.x) / 2, (a.y + b.y) / 2, (a.z + b.z) / 2);
1679
+ const projected = mid.clone();
1680
+ projected.project?.(camera);
1681
+ const rect = canvas.getBoundingClientRect();
1682
+ const x = (projected.x + 1) / 2 * rect.width;
1683
+ const y = (-projected.y + 1) / 2 * rect.height;
1684
+ s.label.style.left = `${x}px`;
1685
+ s.label.style.top = `${y}px`;
1686
+ s.label.style.opacity = projected.z > 1 ? "0" : "1";
1687
+ };
1688
+ const tick = () => {
1689
+ const s = measureRef.current;
1690
+ if (!s) return;
1691
+ updateLabel();
1692
+ s.rafId = requestAnimationFrame(tick);
1693
+ };
1694
+ measureRef.current.rafId = requestAnimationFrame(tick);
1695
+ const DRAG_TOL = 4;
1696
+ const DRAG_TIME = 350;
1697
+ let downX = 0, downY = 0, downTime = 0, dragging = false, downActive = false;
1698
+ const worldNormalFromHit = (hit) => {
1699
+ const fn = hit?.face?.normal;
1700
+ const obj = hit?.object;
1701
+ if (!fn || !obj) return null;
1702
+ try {
1703
+ const local = new Vector3Ctor(fn.x, fn.y, fn.z);
1704
+ local.transformDirection?.(obj.matrixWorld);
1705
+ return { x: local.x, y: local.y, z: local.z };
1706
+ } catch {
1707
+ return { x: fn.x, y: fn.y, z: fn.z };
1708
+ }
1709
+ };
1710
+ const doMeasurePick = (ev) => {
1711
+ const targets = collectTargets();
1712
+ if (!targets.length || !raycaster) return;
1713
+ const ndc = ndcFromEvent(ev);
1714
+ raycaster.setFromCamera(ndc, camera);
1715
+ const hits = raycaster.intersectObjects(targets, false);
1716
+ if (!hits.length) return;
1717
+ const s = measureRef.current;
1718
+ const point = new Vector3Ctor(hits[0].point.x, hits[0].point.y, hits[0].point.z);
1719
+ const normal = worldNormalFromHit(hits[0]);
1720
+ if (s.points.length === 2) {
1721
+ for (const m of s.markers) {
1722
+ scene.remove(m);
1723
+ m.geometry?.dispose?.();
1724
+ m.material?.dispose?.();
1725
+ }
1726
+ if (s.line) {
1727
+ scene.remove(s.line);
1728
+ s.line.geometry?.dispose?.();
1729
+ s.line.material?.dispose?.();
1730
+ }
1731
+ if (s.label) s.label.style.opacity = "0";
1732
+ s.points = [];
1733
+ s.markers = [];
1734
+ s.line = null;
1735
+ s.normals = [];
1736
+ setMeasureDistance(null);
1737
+ }
1738
+ s.points.push(point);
1739
+ s.normals.push(normal);
1740
+ if (measureMode === "perp") {
1741
+ const highlight = buildFaceHighlight(hits[0].object, hits[0]);
1742
+ s.markers.push(highlight ?? makeMarker(point));
1743
+ } else {
1744
+ s.markers.push(makeMarker(point));
1745
+ }
1746
+ if (s.points.length === 2) {
1747
+ const a = s.points[0];
1748
+ let b = s.points[1];
1749
+ let dist;
1750
+ let suffix = "";
1751
+ if (measureMode === "perp") {
1752
+ const refN = s.normals[0] ?? s.normals[1];
1753
+ if (refN) {
1754
+ const len = Math.sqrt(refN.x * refN.x + refN.y * refN.y + refN.z * refN.z) || 1;
1755
+ const nx = refN.x / len, ny = refN.y / len, nz = refN.z / len;
1756
+ const dx = b.x - a.x, dy = b.y - a.y, dz = b.z - a.z;
1757
+ const proj = dx * nx + dy * ny + dz * nz;
1758
+ const projectedB = new Vector3Ctor(a.x + nx * proj, a.y + ny * proj, a.z + nz * proj);
1759
+ s.points[1] = projectedB;
1760
+ b = projectedB;
1761
+ dist = Math.abs(proj);
1762
+ suffix = " \u22A5";
1763
+ } else {
1764
+ const dx = b.x - a.x, dy = b.y - a.y, dz = b.z - a.z;
1765
+ dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
1766
+ }
1767
+ } else {
1768
+ const dx = b.x - a.x, dy = b.y - a.y, dz = b.z - a.z;
1769
+ dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
1770
+ }
1771
+ s.line = drawLine(a, b);
1772
+ setMeasureDistance(dist);
1773
+ const label = ensureLabel();
1774
+ label.textContent = `${formatMeasureDistance(dist)}${suffix}`;
1775
+ }
1776
+ v.viewer.Render?.();
1777
+ };
1778
+ const onPointerDown = (ev) => {
1779
+ if (ev.button !== 0) return;
1780
+ downX = ev.clientX;
1781
+ downY = ev.clientY;
1782
+ downTime = performance.now();
1783
+ dragging = false;
1784
+ downActive = true;
1785
+ };
1786
+ const onPointerMove = (ev) => {
1787
+ if (!downActive) return;
1788
+ const dx = ev.clientX - downX, dy = ev.clientY - downY;
1789
+ if (dx * dx + dy * dy > DRAG_TOL * DRAG_TOL) dragging = true;
1790
+ };
1791
+ const onPointerUp = (ev) => {
1792
+ if (ev.button !== 0 || !downActive) return;
1793
+ const elapsed = performance.now() - downTime;
1794
+ const wasClick = !dragging && elapsed < DRAG_TIME;
1795
+ downActive = false;
1796
+ if (!wasClick) return;
1797
+ doMeasurePick(ev);
1798
+ };
1799
+ const onKeyDown = (ev) => {
1800
+ if (ev.key === "Escape") setMeasureEnabled(false);
1801
+ };
1802
+ canvas.style.cursor = "crosshair";
1803
+ canvas.addEventListener("pointerdown", onPointerDown);
1804
+ canvas.addEventListener("pointermove", onPointerMove);
1805
+ canvas.addEventListener("pointerup", onPointerUp);
1806
+ canvas.addEventListener("pointercancel", onPointerUp);
1807
+ window.addEventListener("keydown", onKeyDown);
1808
+ return () => {
1809
+ canvas.style.cursor = "";
1810
+ canvas.removeEventListener("pointerdown", onPointerDown);
1811
+ canvas.removeEventListener("pointermove", onPointerMove);
1812
+ canvas.removeEventListener("pointerup", onPointerUp);
1813
+ canvas.removeEventListener("pointercancel", onPointerUp);
1814
+ window.removeEventListener("keydown", onKeyDown);
1815
+ teardown();
1816
+ setMeasureDistance(null);
1817
+ };
1818
+ }, [measureEnabled, measureMode, loading, tree]);
1031
1819
  useEffect(() => {
1032
1820
  if (!showHint || loading) return;
1033
1821
  const t = setTimeout(() => setShowHint(false), 5e3);
@@ -1130,9 +1918,10 @@ function StepPanel({ url, filename, onDownload, onEmail }) {
1130
1918
  setEdgeColor("#000000");
1131
1919
  setEdgeThreshold(1);
1132
1920
  setSectionEnabled(false);
1133
- setSectionAxis("z");
1921
+ setSectionAxis("x");
1134
1922
  setSectionFlip(false);
1135
1923
  setSectionPosition(0.5);
1924
+ setSectionAngle(0);
1136
1925
  };
1137
1926
  const handleDefaultDownload = () => {
1138
1927
  const a = document.createElement("a");
@@ -1191,6 +1980,8 @@ function StepPanel({ url, filename, onDownload, onEmail }) {
1191
1980
  };
1192
1981
  const tBtn = "h-8 w-8 shrink-0 flex items-center justify-center rounded text-gray-600 hover:bg-gray-200 hover:text-gray-900 transition-colors";
1193
1982
  const tBtnActive = "h-8 w-8 shrink-0 flex items-center justify-center rounded bg-gray-200 text-gray-900";
1983
+ const tBtnWide = "h-8 shrink-0 flex items-center justify-center px-2 rounded text-gray-600 hover:bg-gray-200 hover:text-gray-900 transition-colors";
1984
+ const tBtnWideActive = "h-8 shrink-0 flex items-center justify-center px-2 rounded bg-gray-200 text-gray-900";
1194
1985
  const tBtnSep = "h-5 w-px bg-gray-300 mx-1";
1195
1986
  return /* @__PURE__ */ jsxs("div", { className: "flex flex-col h-full bg-white", children: [
1196
1987
  /* @__PURE__ */ jsxs(PanelActions, { children: [
@@ -1200,6 +1991,15 @@ function StepPanel({ url, filename, onDownload, onEmail }) {
1200
1991
  /* @__PURE__ */ jsx("button", { onClick: () => setCameraPreset("top"), className: tBtn, title: "Top view", children: /* @__PURE__ */ jsx("span", { className: "text-[10px] font-semibold", children: "TOP" }) }),
1201
1992
  /* @__PURE__ */ jsx("button", { onClick: () => setCameraPreset("front"), className: tBtn, title: "Front view", children: /* @__PURE__ */ jsx("span", { className: "text-[10px] font-semibold", children: "FRT" }) }),
1202
1993
  /* @__PURE__ */ jsx("button", { onClick: () => setCameraPreset("side"), className: tBtn, title: "Side view", children: /* @__PURE__ */ jsx("span", { className: "text-[10px] font-semibold", children: "SDE" }) }),
1994
+ /* @__PURE__ */ jsx(
1995
+ "button",
1996
+ {
1997
+ onClick: () => setPerspective((p) => !p),
1998
+ className: perspective ? tBtnWideActive : tBtnWide,
1999
+ title: perspective ? "Switch to orthographic view" : "Switch to perspective view",
2000
+ children: /* @__PURE__ */ jsx("span", { className: "text-[11px] font-semibold whitespace-nowrap", children: perspective ? "Perspective" : "Orthographic" })
2001
+ }
2002
+ ),
1203
2003
  /* @__PURE__ */ jsx("div", { className: tBtnSep }),
1204
2004
  /* @__PURE__ */ jsx("button", { onClick: handleSnapshot, className: tBtn, title: "Save snapshot as PNG", children: /* @__PURE__ */ jsxs("svg", { className: "h-4 w-4", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", strokeWidth: 1.5, children: [
1205
2005
  /* @__PURE__ */ jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M6.827 6.175A2.31 2.31 0 015.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 00-1.134-.175 2.31 2.31 0 01-1.64-1.055l-.822-1.316a2.192 2.192 0 00-1.736-1.039 48.774 48.774 0 00-5.232 0 2.192 2.192 0 00-1.736 1.039l-.821 1.316z" }),
@@ -1209,12 +2009,39 @@ function StepPanel({ url, filename, onDownload, onEmail }) {
1209
2009
  /* @__PURE__ */ jsx(
1210
2010
  "button",
1211
2011
  {
1212
- onClick: () => setPerspective((p) => !p),
1213
- className: perspective ? tBtnActive : tBtn,
1214
- title: perspective ? "Switch to orthographic view" : "Switch to perspective view",
1215
- children: /* @__PURE__ */ jsx("span", { className: "text-[10px] font-semibold", children: perspective ? "PSP" : "ORT" })
2012
+ onClick: () => setMeasureEnabled((m) => !m),
2013
+ className: measureEnabled ? tBtnActive : tBtn,
2014
+ title: measureEnabled ? "Stop measuring (Esc)" : "Measure distance \u2014 click two points on the model",
2015
+ children: /* @__PURE__ */ jsxs("svg", { className: "h-4 w-4", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", strokeWidth: 1.5, children: [
2016
+ /* @__PURE__ */ jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M3.75 14.25l6-6 6 6 4.5-4.5M9.75 8.25v3M12.75 11.25v3M15.75 14.25v3" }),
2017
+ /* @__PURE__ */ jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M2.25 18.75h19.5" })
2018
+ ] })
1216
2019
  }
1217
2020
  ),
2021
+ measureEnabled && /* @__PURE__ */ jsxs("div", { className: "flex items-stretch h-8 rounded border border-gray-200 overflow-hidden", children: [
2022
+ /* @__PURE__ */ jsx(
2023
+ "button",
2024
+ {
2025
+ onClick: () => setMeasureMode("point"),
2026
+ className: `px-2 text-[11px] font-semibold transition-colors ${measureMode === "point" ? "bg-orange-500 text-white" : "bg-white text-gray-600 hover:bg-gray-50"}`,
2027
+ title: "Point \u2014 measure straight-line distance between two picked points",
2028
+ children: "Point"
2029
+ }
2030
+ ),
2031
+ /* @__PURE__ */ jsx(
2032
+ "button",
2033
+ {
2034
+ onClick: () => setMeasureMode("perp"),
2035
+ className: `px-2 text-[12px] font-semibold transition-colors ${measureMode === "perp" ? "bg-orange-500 text-white" : "bg-white text-gray-600 hover:bg-gray-50"}`,
2036
+ title: "Perpendicular \u2014 pick two surfaces and measure the perpendicular gap between them",
2037
+ children: "\u22A5"
2038
+ }
2039
+ )
2040
+ ] }),
2041
+ measureEnabled && measureDistance !== null && /* @__PURE__ */ jsxs("div", { className: "px-2 py-1 text-[11px] font-mono font-semibold text-orange-600 bg-orange-50 border border-orange-200 rounded whitespace-nowrap", title: measureMode === "perp" ? "Perpendicular distance between the two picked surfaces" : "Straight-line distance between the two picked points", children: [
2042
+ formatMeasureDistance(measureDistance),
2043
+ measureMode === "perp" ? " \u22A5" : ""
2044
+ ] }),
1218
2045
  /* @__PURE__ */ jsx("div", { className: tBtnSep }),
1219
2046
  /* @__PURE__ */ jsx("button", { onClick: () => setShowMeshes((s) => !s), className: showMeshes ? tBtnActive : tBtn, title: "Toggle meshes panel", children: /* @__PURE__ */ jsx("svg", { className: "h-4 w-4", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", strokeWidth: 1.5, children: /* @__PURE__ */ jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" }) }) }),
1220
2047
  /* @__PURE__ */ jsx("button", { onClick: () => setShowSettings((s) => !s), className: showSettings ? tBtnActive : tBtn, title: "Toggle display panel", children: /* @__PURE__ */ jsxs("svg", { className: "h-4 w-4", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", strokeWidth: 1.5, children: [
@@ -1322,6 +2149,27 @@ function StepPanel({ url, filename, onDownload, onEmail }) {
1322
2149
  )
1323
2150
  ] }),
1324
2151
  /* @__PURE__ */ jsxs("div", { className: sectionEnabled ? "mt-2 space-y-2" : "mt-2 space-y-2 opacity-40 pointer-events-none", children: [
2152
+ /* @__PURE__ */ jsxs("div", { children: [
2153
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2 mb-1", children: [
2154
+ /* @__PURE__ */ jsx("span", { children: "Angle" }),
2155
+ /* @__PURE__ */ jsxs("span", { className: "text-gray-500 tabular-nums", children: [
2156
+ sectionAngle,
2157
+ "\xB0"
2158
+ ] })
2159
+ ] }),
2160
+ /* @__PURE__ */ jsx(
2161
+ "input",
2162
+ {
2163
+ type: "range",
2164
+ min: 0,
2165
+ max: 180,
2166
+ step: 1,
2167
+ value: sectionAngle,
2168
+ onChange: (e) => setSectionAngle(Number(e.target.value)),
2169
+ className: "w-full accent-blue-500"
2170
+ }
2171
+ )
2172
+ ] }),
1325
2173
  /* @__PURE__ */ jsxs("div", { children: [
1326
2174
  /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2 mb-1", children: [
1327
2175
  /* @__PURE__ */ jsx("span", { children: "Axis" }),
@@ -1446,5 +2294,5 @@ function ImagePanel({ url, filename, onDownload, onEmail }) {
1446
2294
  }
1447
2295
 
1448
2296
  export { Preview, setPdfPreview };
1449
- //# sourceMappingURL=chunk-DUUANLLE.js.map
1450
- //# sourceMappingURL=chunk-DUUANLLE.js.map
2297
+ //# sourceMappingURL=chunk-G6WVMTJU.js.map
2298
+ //# sourceMappingURL=chunk-G6WVMTJU.js.map