react-os-shell 0.2.45 → 0.2.46

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.
@@ -664,6 +664,11 @@ function hexToRgb(OV, hex) {
664
664
  const n = m ? parseInt(m[1], 16) : 0;
665
665
  return new OV.RGBColor(n >> 16 & 255, n >> 8 & 255, n & 255);
666
666
  }
667
+ function formatMeasureDistance(mm) {
668
+ if (mm >= 1e3) return `${(mm / 1e3).toFixed(2)} m`;
669
+ if (mm >= 10) return `${mm.toFixed(1)} mm`;
670
+ return `${mm.toFixed(2)} mm`;
671
+ }
667
672
  function StepPanel({ url, filename, onDownload, onEmail }) {
668
673
  const containerRef = useRef(null);
669
674
  const viewerRef = useRef(null);
@@ -680,15 +685,21 @@ function StepPanel({ url, filename, onDownload, onEmail }) {
680
685
  const [edgeThreshold, setEdgeThreshold] = useState(1);
681
686
  const [showMeshes, setShowMeshes] = useState(false);
682
687
  const [showSettings, setShowSettings] = useState(false);
683
- const [perspective, setPerspective] = useState(true);
688
+ const [perspective, setPerspective] = useState(false);
684
689
  const [sectionEnabled, setSectionEnabled] = useState(false);
685
- const [sectionAxis, setSectionAxis] = useState("z");
690
+ const [sectionAxis, setSectionAxis] = useState("x");
686
691
  const [sectionFlip, setSectionFlip] = useState(false);
687
692
  const [sectionPosition, setSectionPosition] = useState(0.5);
693
+ const [sectionAngle, setSectionAngle] = useState(0);
694
+ const [measureEnabled, setMeasureEnabled] = useState(false);
695
+ const [measureMode, setMeasureMode] = useState("point");
696
+ const [measureDistance, setMeasureDistance] = useState(null);
697
+ const measureRef = useRef(null);
688
698
  const sectionRef = useRef(null);
689
699
  useEffect(() => {
690
700
  let cancelled = false;
691
701
  let viewer = null;
702
+ let resizeObserver = null;
692
703
  setLoading(true);
693
704
  setError(null);
694
705
  setTree(null);
@@ -751,6 +762,18 @@ function StepPanel({ url, filename, onDownload, onEmail }) {
751
762
  viewerRef.current = viewer;
752
763
  const inputFile = new OV.InputFile(filename, OV.FileSource.Url, url);
753
764
  viewer.LoadModelFromInputFiles([inputFile]);
765
+ if (containerRef.current && typeof ResizeObserver !== "undefined") {
766
+ resizeObserver = new ResizeObserver(() => {
767
+ try {
768
+ const v = viewerRef.current;
769
+ if (v?.Resize) v.Resize();
770
+ else if (v?.viewer?.Resize) v.viewer.Resize();
771
+ v?.viewer?.Render?.();
772
+ } catch {
773
+ }
774
+ });
775
+ resizeObserver.observe(containerRef.current);
776
+ }
754
777
  } catch (e) {
755
778
  if (!cancelled) {
756
779
  setError(e?.message || "Failed to load 3D model.");
@@ -760,6 +783,10 @@ function StepPanel({ url, filename, onDownload, onEmail }) {
760
783
  })();
761
784
  return () => {
762
785
  cancelled = true;
786
+ try {
787
+ resizeObserver?.disconnect();
788
+ } catch {
789
+ }
763
790
  try {
764
791
  viewer?.Destroy?.();
765
792
  } catch {
@@ -1003,19 +1030,35 @@ function StepPanel({ url, filename, onDownload, onEmail }) {
1003
1030
  const axisIdx = sectionAxis === "x" ? 0 : sectionAxis === "y" ? 1 : 2;
1004
1031
  const min = [bbox.min.x, bbox.min.y, bbox.min.z][axisIdx];
1005
1032
  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;
1033
+ const t = min + (max - min) * sectionPosition;
1034
+ const \u03B8 = sectionAngle * Math.PI / 180;
1035
+ const cos\u03B8 = Math.cos(\u03B8), sin\u03B8 = Math.sin(\u03B8);
1036
+ const sign = sectionFlip ? -1 : 1;
1037
+ let nx = 0, ny = 0, nz = 0;
1038
+ if (sectionAxis === "x") {
1039
+ nx = sign * cos\u03B8;
1040
+ nz = sign * sin\u03B8;
1041
+ } else if (sectionAxis === "y") {
1042
+ ny = sign * cos\u03B8;
1043
+ nz = sign * sin\u03B8;
1044
+ } else {
1045
+ nx = sign * sin\u03B8;
1046
+ nz = sign * cos\u03B8;
1047
+ }
1048
+ const center = {
1049
+ x: (bbox.min.x + bbox.max.x) / 2,
1050
+ y: (bbox.min.y + bbox.max.y) / 2,
1051
+ z: (bbox.min.z + bbox.max.z) / 2
1052
+ };
1053
+ const Px = sectionAxis === "x" ? t : center.x;
1054
+ const Py = sectionAxis === "y" ? t : center.y;
1055
+ const Pz = sectionAxis === "z" ? t : center.z;
1011
1056
  s.plane.normal.x = nx;
1012
1057
  s.plane.normal.y = ny;
1013
1058
  s.plane.normal.z = nz;
1014
- s.plane.constant = -dir * value;
1059
+ s.plane.constant = -(nx * Px + ny * Py + nz * Pz);
1015
1060
  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;
1061
+ const cx = center.x, cy = center.y, cz = center.z;
1019
1062
  const dist = nx * cx + ny * cy + nz * cz + s.plane.constant;
1020
1063
  const px = cx - nx * dist;
1021
1064
  const py = cy - ny * dist;
@@ -1027,7 +1070,367 @@ function StepPanel({ url, filename, onDownload, onEmail }) {
1027
1070
  } catch (err) {
1028
1071
  console.warn("[Preview] section update failed", err);
1029
1072
  }
1030
- }, [sectionEnabled, sectionAxis, sectionFlip, sectionPosition]);
1073
+ }, [sectionEnabled, sectionAxis, sectionFlip, sectionPosition, sectionAngle]);
1074
+ useEffect(() => {
1075
+ const v = viewerRef.current;
1076
+ if (!v?.viewer || loading || !containerRef.current) return;
1077
+ const renderer = v.viewer.renderer;
1078
+ const scene = v.viewer.scene;
1079
+ const camera = v.viewer.camera;
1080
+ const canvas = renderer?.domElement;
1081
+ if (!renderer || !scene || !camera || !canvas) return;
1082
+ let sampleMesh = null;
1083
+ v.viewer.mainModel?.EnumerateMeshes?.((m) => {
1084
+ if (!sampleMesh && !m.userData?.__sectionHelper && !m.userData?.__measureHelper) sampleMesh = m;
1085
+ });
1086
+ if (!sampleMesh) return;
1087
+ const Vector3Ctor = sampleMesh.position.constructor;
1088
+ const MeshCtor = sampleMesh.constructor;
1089
+ const MaterialCtor = (Array.isArray(sampleMesh.material) ? sampleMesh.material[0] : sampleMesh.material)?.constructor;
1090
+ const GeometryCtor = sampleMesh.geometry.constructor;
1091
+ const BufferAttrCtor = sampleMesh.geometry.attributes?.position?.constructor;
1092
+ if (!Vector3Ctor || !MeshCtor || !MaterialCtor || !GeometryCtor || !BufferAttrCtor) return;
1093
+ const teardown = () => {
1094
+ const s = measureRef.current;
1095
+ if (!s) return;
1096
+ if (s.rafId !== null) cancelAnimationFrame(s.rafId);
1097
+ for (const m of s.markers) {
1098
+ scene.remove(m);
1099
+ m.geometry?.dispose?.();
1100
+ m.material?.dispose?.();
1101
+ }
1102
+ if (s.line) {
1103
+ scene.remove(s.line);
1104
+ s.line.geometry?.dispose?.();
1105
+ s.line.material?.dispose?.();
1106
+ }
1107
+ if (s.label && s.label.parentElement) s.label.parentElement.removeChild(s.label);
1108
+ measureRef.current = null;
1109
+ v.viewer.Render?.();
1110
+ };
1111
+ if (!measureEnabled) {
1112
+ teardown();
1113
+ setMeasureDistance(null);
1114
+ return;
1115
+ }
1116
+ measureRef.current = { points: [], normals: [], markers: [], line: null, label: null, rafId: null };
1117
+ let THREE = null;
1118
+ let raycaster = null;
1119
+ (async () => {
1120
+ try {
1121
+ THREE = await import(
1122
+ /* @vite-ignore */
1123
+ 'three'
1124
+ );
1125
+ raycaster = new THREE.Raycaster();
1126
+ } catch (err) {
1127
+ console.warn("[Preview] measure: failed to load three for raycaster", err);
1128
+ }
1129
+ })();
1130
+ const bbox = v.viewer.GetBoundingBox?.(() => true);
1131
+ const diag = bbox ? Math.sqrt(
1132
+ (bbox.max.x - bbox.min.x) ** 2 + (bbox.max.y - bbox.min.y) ** 2 + (bbox.max.z - bbox.min.z) ** 2
1133
+ ) : 100;
1134
+ const markerRadius = Math.max(diag * 5e-3, 0.2);
1135
+ const ndcFromEvent = (ev) => {
1136
+ const rect = canvas.getBoundingClientRect();
1137
+ return {
1138
+ x: (ev.clientX - rect.left) / rect.width * 2 - 1,
1139
+ y: -((ev.clientY - rect.top) / rect.height * 2 - 1)
1140
+ };
1141
+ };
1142
+ const collectTargets = () => {
1143
+ const out = [];
1144
+ v.viewer.mainModel?.EnumerateMeshes?.((m) => {
1145
+ if (m.userData?.__sectionHelper || m.userData?.__measureHelper) return;
1146
+ if (m.visible === false) return;
1147
+ out.push(m);
1148
+ });
1149
+ return out;
1150
+ };
1151
+ const makeMarker = (point) => {
1152
+ const widthSegs = 16, heightSegs = 12;
1153
+ const positions = [];
1154
+ const indices = [];
1155
+ for (let iy = 0; iy <= heightSegs; iy++) {
1156
+ const v2 = iy / heightSegs;
1157
+ const phi = v2 * Math.PI;
1158
+ for (let ix = 0; ix <= widthSegs; ix++) {
1159
+ const u = ix / widthSegs;
1160
+ const theta = u * Math.PI * 2;
1161
+ positions.push(
1162
+ markerRadius * Math.sin(phi) * Math.cos(theta),
1163
+ markerRadius * Math.cos(phi),
1164
+ markerRadius * Math.sin(phi) * Math.sin(theta)
1165
+ );
1166
+ }
1167
+ }
1168
+ for (let iy = 0; iy < heightSegs; iy++) {
1169
+ for (let ix = 0; ix < widthSegs; ix++) {
1170
+ const a = iy * (widthSegs + 1) + ix;
1171
+ const b = a + widthSegs + 1;
1172
+ indices.push(a, b, a + 1, b, b + 1, a + 1);
1173
+ }
1174
+ }
1175
+ const geom = new GeometryCtor();
1176
+ geom.setAttribute("position", new BufferAttrCtor(new Float32Array(positions), 3));
1177
+ geom.setIndex(indices);
1178
+ geom.computeVertexNormals?.();
1179
+ const mat = new MaterialCtor();
1180
+ mat.color?.setHex?.(16746496);
1181
+ mat.depthTest = false;
1182
+ mat.depthWrite = false;
1183
+ mat.transparent = true;
1184
+ mat.opacity = 0.95;
1185
+ const mesh = new MeshCtor(geom, mat);
1186
+ mesh.position.copy(point);
1187
+ mesh.renderOrder = 9999;
1188
+ mesh.userData.__measureHelper = true;
1189
+ scene.add(mesh);
1190
+ return mesh;
1191
+ };
1192
+ const buildFaceHighlight = (mesh, hit) => {
1193
+ const geom = mesh?.geometry;
1194
+ const posAttr = geom?.attributes?.position;
1195
+ if (!geom || !posAttr || !hit?.face?.normal) return null;
1196
+ const positions = posAttr.array;
1197
+ const indices = geom.index ? geom.index.array : null;
1198
+ const localPoint = new Vector3Ctor(hit.point.x, hit.point.y, hit.point.z);
1199
+ mesh.worldToLocal?.(localPoint);
1200
+ const ln = hit.face.normal;
1201
+ const planeC = -(ln.x * localPoint.x + ln.y * localPoint.y + ln.z * localPoint.z);
1202
+ geom.computeBoundingBox?.();
1203
+ const bb = geom.boundingBox;
1204
+ 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;
1205
+ const PLANE_TOL = Math.max(diag2 * 1e-3, 5e-3);
1206
+ const NORMAL_TOL = 0.9995;
1207
+ const triCount = indices ? indices.length / 3 : posAttr.count / 3;
1208
+ const matched = [];
1209
+ const tmpV = new Vector3Ctor();
1210
+ for (let t = 0; t < triCount; t++) {
1211
+ const ia = indices ? indices[t * 3] : t * 3;
1212
+ const ib = indices ? indices[t * 3 + 1] : t * 3 + 1;
1213
+ const ic = indices ? indices[t * 3 + 2] : t * 3 + 2;
1214
+ const ax = positions[ia * 3], ay = positions[ia * 3 + 1], az = positions[ia * 3 + 2];
1215
+ const bx = positions[ib * 3], by = positions[ib * 3 + 1], bz = positions[ib * 3 + 2];
1216
+ const cx = positions[ic * 3], cy = positions[ic * 3 + 1], cz = positions[ic * 3 + 2];
1217
+ const ux = bx - ax, uy = by - ay, uz = bz - az;
1218
+ const vx = cx - ax, vy = cy - ay, vz = cz - az;
1219
+ let nx = uy * vz - uz * vy;
1220
+ let ny = uz * vx - ux * vz;
1221
+ let nz = ux * vy - uy * vx;
1222
+ const nlen = Math.sqrt(nx * nx + ny * ny + nz * nz);
1223
+ if (nlen === 0) continue;
1224
+ nx /= nlen;
1225
+ ny /= nlen;
1226
+ nz /= nlen;
1227
+ const ndot = nx * ln.x + ny * ln.y + nz * ln.z;
1228
+ if (ndot < NORMAL_TOL) continue;
1229
+ const ccx = (ax + bx + cx) / 3, ccy = (ay + by + cy) / 3, ccz = (az + bz + cz) / 3;
1230
+ const dist = Math.abs(ln.x * ccx + ln.y * ccy + ln.z * ccz + planeC);
1231
+ if (dist > PLANE_TOL) continue;
1232
+ for (const [vx_, vy_, vz_] of [[ax, ay, az], [bx, by, bz], [cx, cy, cz]]) {
1233
+ tmpV.set?.(vx_, vy_, vz_);
1234
+ mesh.localToWorld?.(tmpV);
1235
+ matched.push(tmpV.x, tmpV.y, tmpV.z);
1236
+ }
1237
+ }
1238
+ if (matched.length === 0) return null;
1239
+ const hgeom = new GeometryCtor();
1240
+ hgeom.setAttribute("position", new BufferAttrCtor(new Float32Array(matched), 3));
1241
+ hgeom.computeVertexNormals?.();
1242
+ const hmat = new MaterialCtor();
1243
+ hmat.color?.setHex?.(16746496);
1244
+ hmat.transparent = true;
1245
+ hmat.opacity = 0.45;
1246
+ hmat.depthWrite = false;
1247
+ hmat.polygonOffset = true;
1248
+ hmat.polygonOffsetFactor = -2;
1249
+ hmat.polygonOffsetUnits = -2;
1250
+ const highlight = new MeshCtor(hgeom, hmat);
1251
+ highlight.renderOrder = 9998;
1252
+ highlight.userData.__measureHelper = true;
1253
+ scene.add(highlight);
1254
+ return highlight;
1255
+ };
1256
+ const drawLine = (a, b) => {
1257
+ const geom = new GeometryCtor();
1258
+ geom.setAttribute("position", new BufferAttrCtor(new Float32Array([a.x, a.y, a.z, b.x, b.y, b.z]), 3));
1259
+ const LineCtor = THREE?.Line ?? MeshCtor;
1260
+ const LineMatCtor = THREE?.LineBasicMaterial ?? MaterialCtor;
1261
+ const mat = new LineMatCtor({ color: 16746496, depthTest: false, depthWrite: false, transparent: true, opacity: 0.95 });
1262
+ const line = new LineCtor(geom, mat);
1263
+ line.renderOrder = 9998;
1264
+ line.userData.__measureHelper = true;
1265
+ scene.add(line);
1266
+ return line;
1267
+ };
1268
+ const ensureLabel = () => {
1269
+ const s = measureRef.current;
1270
+ if (s.label) return s.label;
1271
+ const el = document.createElement("div");
1272
+ el.style.position = "absolute";
1273
+ el.style.transform = "translate(-50%, -50%)";
1274
+ el.style.padding = "2px 6px";
1275
+ el.style.fontSize = "11px";
1276
+ el.style.fontWeight = "600";
1277
+ el.style.fontFamily = "system-ui, -apple-system, sans-serif";
1278
+ el.style.background = "rgba(255, 136, 0, 0.95)";
1279
+ el.style.color = "#fff";
1280
+ el.style.borderRadius = "4px";
1281
+ el.style.pointerEvents = "none";
1282
+ el.style.whiteSpace = "nowrap";
1283
+ el.style.boxShadow = "0 1px 4px rgba(0,0,0,0.25)";
1284
+ el.style.zIndex = "5";
1285
+ containerRef.current.appendChild(el);
1286
+ s.label = el;
1287
+ return el;
1288
+ };
1289
+ const updateLabel = () => {
1290
+ const s = measureRef.current;
1291
+ if (!s || s.points.length < 2 || !s.label) return;
1292
+ const a = s.points[0], b = s.points[1];
1293
+ const mid = new Vector3Ctor((a.x + b.x) / 2, (a.y + b.y) / 2, (a.z + b.z) / 2);
1294
+ const projected = mid.clone();
1295
+ projected.project?.(camera);
1296
+ const rect = canvas.getBoundingClientRect();
1297
+ const x = (projected.x + 1) / 2 * rect.width;
1298
+ const y = (-projected.y + 1) / 2 * rect.height;
1299
+ s.label.style.left = `${x}px`;
1300
+ s.label.style.top = `${y}px`;
1301
+ s.label.style.opacity = projected.z > 1 ? "0" : "1";
1302
+ };
1303
+ const tick = () => {
1304
+ const s = measureRef.current;
1305
+ if (!s) return;
1306
+ updateLabel();
1307
+ s.rafId = requestAnimationFrame(tick);
1308
+ };
1309
+ measureRef.current.rafId = requestAnimationFrame(tick);
1310
+ const DRAG_TOL = 4;
1311
+ const DRAG_TIME = 350;
1312
+ let downX = 0, downY = 0, downTime = 0, dragging = false, downActive = false;
1313
+ const worldNormalFromHit = (hit) => {
1314
+ const fn = hit?.face?.normal;
1315
+ const obj = hit?.object;
1316
+ if (!fn || !obj) return null;
1317
+ try {
1318
+ const local = new Vector3Ctor(fn.x, fn.y, fn.z);
1319
+ local.transformDirection?.(obj.matrixWorld);
1320
+ return { x: local.x, y: local.y, z: local.z };
1321
+ } catch {
1322
+ return { x: fn.x, y: fn.y, z: fn.z };
1323
+ }
1324
+ };
1325
+ const doMeasurePick = (ev) => {
1326
+ const targets = collectTargets();
1327
+ if (!targets.length || !raycaster) return;
1328
+ const ndc = ndcFromEvent(ev);
1329
+ raycaster.setFromCamera(ndc, camera);
1330
+ const hits = raycaster.intersectObjects(targets, false);
1331
+ if (!hits.length) return;
1332
+ const s = measureRef.current;
1333
+ const point = new Vector3Ctor(hits[0].point.x, hits[0].point.y, hits[0].point.z);
1334
+ const normal = worldNormalFromHit(hits[0]);
1335
+ if (s.points.length === 2) {
1336
+ for (const m of s.markers) {
1337
+ scene.remove(m);
1338
+ m.geometry?.dispose?.();
1339
+ m.material?.dispose?.();
1340
+ }
1341
+ if (s.line) {
1342
+ scene.remove(s.line);
1343
+ s.line.geometry?.dispose?.();
1344
+ s.line.material?.dispose?.();
1345
+ }
1346
+ if (s.label) s.label.style.opacity = "0";
1347
+ s.points = [];
1348
+ s.markers = [];
1349
+ s.line = null;
1350
+ s.normals = [];
1351
+ setMeasureDistance(null);
1352
+ }
1353
+ s.points.push(point);
1354
+ s.normals.push(normal);
1355
+ if (measureMode === "perp") {
1356
+ const highlight = buildFaceHighlight(hits[0].object, hits[0]);
1357
+ s.markers.push(highlight ?? makeMarker(point));
1358
+ } else {
1359
+ s.markers.push(makeMarker(point));
1360
+ }
1361
+ if (s.points.length === 2) {
1362
+ const a = s.points[0];
1363
+ let b = s.points[1];
1364
+ let dist;
1365
+ let suffix = "";
1366
+ if (measureMode === "perp") {
1367
+ const refN = s.normals[0] ?? s.normals[1];
1368
+ if (refN) {
1369
+ const len = Math.sqrt(refN.x * refN.x + refN.y * refN.y + refN.z * refN.z) || 1;
1370
+ const nx = refN.x / len, ny = refN.y / len, nz = refN.z / len;
1371
+ const dx = b.x - a.x, dy = b.y - a.y, dz = b.z - a.z;
1372
+ const proj = dx * nx + dy * ny + dz * nz;
1373
+ const projectedB = new Vector3Ctor(a.x + nx * proj, a.y + ny * proj, a.z + nz * proj);
1374
+ s.points[1] = projectedB;
1375
+ b = projectedB;
1376
+ dist = Math.abs(proj);
1377
+ suffix = " \u22A5";
1378
+ } else {
1379
+ const dx = b.x - a.x, dy = b.y - a.y, dz = b.z - a.z;
1380
+ dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
1381
+ }
1382
+ } else {
1383
+ const dx = b.x - a.x, dy = b.y - a.y, dz = b.z - a.z;
1384
+ dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
1385
+ }
1386
+ s.line = drawLine(a, b);
1387
+ setMeasureDistance(dist);
1388
+ const label = ensureLabel();
1389
+ label.textContent = `${formatMeasureDistance(dist)}${suffix}`;
1390
+ }
1391
+ v.viewer.Render?.();
1392
+ };
1393
+ const onPointerDown = (ev) => {
1394
+ if (ev.button !== 0) return;
1395
+ downX = ev.clientX;
1396
+ downY = ev.clientY;
1397
+ downTime = performance.now();
1398
+ dragging = false;
1399
+ downActive = true;
1400
+ };
1401
+ const onPointerMove = (ev) => {
1402
+ if (!downActive) return;
1403
+ const dx = ev.clientX - downX, dy = ev.clientY - downY;
1404
+ if (dx * dx + dy * dy > DRAG_TOL * DRAG_TOL) dragging = true;
1405
+ };
1406
+ const onPointerUp = (ev) => {
1407
+ if (ev.button !== 0 || !downActive) return;
1408
+ const elapsed = performance.now() - downTime;
1409
+ const wasClick = !dragging && elapsed < DRAG_TIME;
1410
+ downActive = false;
1411
+ if (!wasClick) return;
1412
+ doMeasurePick(ev);
1413
+ };
1414
+ const onKeyDown = (ev) => {
1415
+ if (ev.key === "Escape") setMeasureEnabled(false);
1416
+ };
1417
+ canvas.style.cursor = "crosshair";
1418
+ canvas.addEventListener("pointerdown", onPointerDown);
1419
+ canvas.addEventListener("pointermove", onPointerMove);
1420
+ canvas.addEventListener("pointerup", onPointerUp);
1421
+ canvas.addEventListener("pointercancel", onPointerUp);
1422
+ window.addEventListener("keydown", onKeyDown);
1423
+ return () => {
1424
+ canvas.style.cursor = "";
1425
+ canvas.removeEventListener("pointerdown", onPointerDown);
1426
+ canvas.removeEventListener("pointermove", onPointerMove);
1427
+ canvas.removeEventListener("pointerup", onPointerUp);
1428
+ canvas.removeEventListener("pointercancel", onPointerUp);
1429
+ window.removeEventListener("keydown", onKeyDown);
1430
+ teardown();
1431
+ setMeasureDistance(null);
1432
+ };
1433
+ }, [measureEnabled, measureMode, loading, tree]);
1031
1434
  useEffect(() => {
1032
1435
  if (!showHint || loading) return;
1033
1436
  const t = setTimeout(() => setShowHint(false), 5e3);
@@ -1130,9 +1533,10 @@ function StepPanel({ url, filename, onDownload, onEmail }) {
1130
1533
  setEdgeColor("#000000");
1131
1534
  setEdgeThreshold(1);
1132
1535
  setSectionEnabled(false);
1133
- setSectionAxis("z");
1536
+ setSectionAxis("x");
1134
1537
  setSectionFlip(false);
1135
1538
  setSectionPosition(0.5);
1539
+ setSectionAngle(0);
1136
1540
  };
1137
1541
  const handleDefaultDownload = () => {
1138
1542
  const a = document.createElement("a");
@@ -1191,6 +1595,8 @@ function StepPanel({ url, filename, onDownload, onEmail }) {
1191
1595
  };
1192
1596
  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
1597
  const tBtnActive = "h-8 w-8 shrink-0 flex items-center justify-center rounded bg-gray-200 text-gray-900";
1598
+ 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";
1599
+ const tBtnWideActive = "h-8 shrink-0 flex items-center justify-center px-2 rounded bg-gray-200 text-gray-900";
1194
1600
  const tBtnSep = "h-5 w-px bg-gray-300 mx-1";
1195
1601
  return /* @__PURE__ */ jsxs("div", { className: "flex flex-col h-full bg-white", children: [
1196
1602
  /* @__PURE__ */ jsxs(PanelActions, { children: [
@@ -1200,6 +1606,15 @@ function StepPanel({ url, filename, onDownload, onEmail }) {
1200
1606
  /* @__PURE__ */ jsx("button", { onClick: () => setCameraPreset("top"), className: tBtn, title: "Top view", children: /* @__PURE__ */ jsx("span", { className: "text-[10px] font-semibold", children: "TOP" }) }),
1201
1607
  /* @__PURE__ */ jsx("button", { onClick: () => setCameraPreset("front"), className: tBtn, title: "Front view", children: /* @__PURE__ */ jsx("span", { className: "text-[10px] font-semibold", children: "FRT" }) }),
1202
1608
  /* @__PURE__ */ jsx("button", { onClick: () => setCameraPreset("side"), className: tBtn, title: "Side view", children: /* @__PURE__ */ jsx("span", { className: "text-[10px] font-semibold", children: "SDE" }) }),
1609
+ /* @__PURE__ */ jsx(
1610
+ "button",
1611
+ {
1612
+ onClick: () => setPerspective((p) => !p),
1613
+ className: perspective ? tBtnWideActive : tBtnWide,
1614
+ title: perspective ? "Switch to orthographic view" : "Switch to perspective view",
1615
+ children: /* @__PURE__ */ jsx("span", { className: "text-[11px] font-semibold whitespace-nowrap", children: perspective ? "Perspective" : "Orthographic" })
1616
+ }
1617
+ ),
1203
1618
  /* @__PURE__ */ jsx("div", { className: tBtnSep }),
1204
1619
  /* @__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
1620
  /* @__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 +1624,39 @@ function StepPanel({ url, filename, onDownload, onEmail }) {
1209
1624
  /* @__PURE__ */ jsx(
1210
1625
  "button",
1211
1626
  {
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" })
1627
+ onClick: () => setMeasureEnabled((m) => !m),
1628
+ className: measureEnabled ? tBtnActive : tBtn,
1629
+ title: measureEnabled ? "Stop measuring (Esc)" : "Measure distance \u2014 click two points on the model",
1630
+ children: /* @__PURE__ */ jsxs("svg", { className: "h-4 w-4", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", strokeWidth: 1.5, children: [
1631
+ /* @__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" }),
1632
+ /* @__PURE__ */ jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M2.25 18.75h19.5" })
1633
+ ] })
1216
1634
  }
1217
1635
  ),
1636
+ measureEnabled && /* @__PURE__ */ jsxs("div", { className: "flex items-stretch h-8 rounded border border-gray-200 overflow-hidden", children: [
1637
+ /* @__PURE__ */ jsx(
1638
+ "button",
1639
+ {
1640
+ onClick: () => setMeasureMode("point"),
1641
+ 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"}`,
1642
+ title: "Point \u2014 measure straight-line distance between two picked points",
1643
+ children: "Point"
1644
+ }
1645
+ ),
1646
+ /* @__PURE__ */ jsx(
1647
+ "button",
1648
+ {
1649
+ onClick: () => setMeasureMode("perp"),
1650
+ 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"}`,
1651
+ title: "Perpendicular \u2014 pick two surfaces and measure the perpendicular gap between them",
1652
+ children: "\u22A5"
1653
+ }
1654
+ )
1655
+ ] }),
1656
+ 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: [
1657
+ formatMeasureDistance(measureDistance),
1658
+ measureMode === "perp" ? " \u22A5" : ""
1659
+ ] }),
1218
1660
  /* @__PURE__ */ jsx("div", { className: tBtnSep }),
1219
1661
  /* @__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
1662
  /* @__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 +1764,27 @@ function StepPanel({ url, filename, onDownload, onEmail }) {
1322
1764
  )
1323
1765
  ] }),
1324
1766
  /* @__PURE__ */ jsxs("div", { className: sectionEnabled ? "mt-2 space-y-2" : "mt-2 space-y-2 opacity-40 pointer-events-none", children: [
1767
+ /* @__PURE__ */ jsxs("div", { children: [
1768
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2 mb-1", children: [
1769
+ /* @__PURE__ */ jsx("span", { children: "Angle" }),
1770
+ /* @__PURE__ */ jsxs("span", { className: "text-gray-500 tabular-nums", children: [
1771
+ sectionAngle,
1772
+ "\xB0"
1773
+ ] })
1774
+ ] }),
1775
+ /* @__PURE__ */ jsx(
1776
+ "input",
1777
+ {
1778
+ type: "range",
1779
+ min: 0,
1780
+ max: 180,
1781
+ step: 1,
1782
+ value: sectionAngle,
1783
+ onChange: (e) => setSectionAngle(Number(e.target.value)),
1784
+ className: "w-full accent-blue-500"
1785
+ }
1786
+ )
1787
+ ] }),
1325
1788
  /* @__PURE__ */ jsxs("div", { children: [
1326
1789
  /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2 mb-1", children: [
1327
1790
  /* @__PURE__ */ jsx("span", { children: "Axis" }),
@@ -1446,5 +1909,5 @@ function ImagePanel({ url, filename, onDownload, onEmail }) {
1446
1909
  }
1447
1910
 
1448
1911
  export { Preview, setPdfPreview };
1449
- //# sourceMappingURL=chunk-DUUANLLE.js.map
1450
- //# sourceMappingURL=chunk-DUUANLLE.js.map
1912
+ //# sourceMappingURL=chunk-QBH7KERS.js.map
1913
+ //# sourceMappingURL=chunk-QBH7KERS.js.map