@toolpath/viewer 2.3.1 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  // src/engine/engine-part.tsx
2
- import { useEffect as useEffect8, useMemo as useMemo8 } from "react";
2
+ import { useEffect as useEffect8 } from "react";
3
3
 
4
4
  // src/part-mesh.tsx
5
5
  import { useThree as useThree8 } from "@react-three/fiber";
@@ -9,9 +9,9 @@ import {
9
9
  useLayoutEffect as useLayoutEffect3,
10
10
  useMemo as useMemo7,
11
11
  useRef as useRef6,
12
- useSyncExternalStore
12
+ useSyncExternalStore as useSyncExternalStore2
13
13
  } from "react";
14
- import { Vector3 as Vector312 } from "three";
14
+ import { Vector3 as Vector313 } from "three";
15
15
 
16
16
  // src/part-context.tsx
17
17
  import { createContext, useContext } from "react";
@@ -863,6 +863,7 @@ import {
863
863
  DataTexture,
864
864
  DoubleSide as DoubleSide2,
865
865
  Float32BufferAttribute as Float32BufferAttribute3,
866
+ FloatType,
866
867
  Group as Group2,
867
868
  LineBasicMaterial,
868
869
  LineSegments,
@@ -871,7 +872,7 @@ import {
871
872
  RedFormat,
872
873
  RGBAFormat,
873
874
  UnsignedByteType,
874
- Vector3 as Vector35
875
+ Vector3 as Vector36
875
876
  } from "three";
876
877
 
877
878
  // src/render/edges.ts
@@ -1488,9 +1489,147 @@ function emptyCreases() {
1488
1489
  };
1489
1490
  }
1490
1491
 
1492
+ // src/render/threads.ts
1493
+ import {
1494
+ Vector3 as Vector35
1495
+ } from "three";
1496
+ var THREAD_TEXTURE_ROWS = 2;
1497
+ var MAX_AXIAL_NORMAL = 0.2;
1498
+ var MIN_AXIS_CROSS = 0.1;
1499
+ var DIAMETER_SLACK = 1.1;
1500
+ function facetsOf(position, ranges) {
1501
+ const facets = [];
1502
+ const a = new Vector35();
1503
+ const b = new Vector35();
1504
+ const c = new Vector35();
1505
+ for (const range of ranges) {
1506
+ for (let triangle = range.start; triangle < range.end; triangle += 1) {
1507
+ const first = triangle * 3;
1508
+ if (first + 2 >= position.count) break;
1509
+ a.fromBufferAttribute(position, first);
1510
+ b.fromBufferAttribute(position, first + 1);
1511
+ c.fromBufferAttribute(position, first + 2);
1512
+ const normal = new Vector35().subVectors(b, a).cross(new Vector35().subVectors(c, a));
1513
+ const doubleArea = normal.length();
1514
+ if (doubleArea === 0) continue;
1515
+ facets.push({
1516
+ centre: new Vector35().add(a).add(b).add(c).divideScalar(3),
1517
+ normal: normal.divideScalar(doubleArea),
1518
+ area: doubleArea / 2
1519
+ });
1520
+ }
1521
+ }
1522
+ return facets;
1523
+ }
1524
+ function axisOf(facets) {
1525
+ const first = facets[0];
1526
+ if (!first) return null;
1527
+ let furthest = first;
1528
+ for (const facet of facets) {
1529
+ if (Math.abs(facet.normal.dot(first.normal)) < Math.abs(furthest.normal.dot(first.normal))) {
1530
+ furthest = facet;
1531
+ }
1532
+ }
1533
+ const axis = new Vector35().crossVectors(first.normal, furthest.normal);
1534
+ if (axis.length() < MIN_AXIS_CROSS) return null;
1535
+ axis.normalize();
1536
+ const allSquare = facets.every((facet) => Math.abs(facet.normal.dot(axis)) <= MAX_AXIAL_NORMAL);
1537
+ return allSquare ? axis : null;
1538
+ }
1539
+ function fitBore(position, ranges) {
1540
+ const facets = facetsOf(position, ranges);
1541
+ const axis = axisOf(facets);
1542
+ if (!axis) return null;
1543
+ const u = new Vector35().crossVectors(axis, Math.abs(axis.x) < 0.9 ? new Vector35(1, 0, 0) : new Vector35(0, 1, 0)).normalize();
1544
+ const v = new Vector35().crossVectors(axis, u);
1545
+ let xx = 0;
1546
+ let xy = 0;
1547
+ let yy = 0;
1548
+ let rx = 0;
1549
+ let ry = 0;
1550
+ let along = 0;
1551
+ let total = 0;
1552
+ for (const { centre, normal, area } of facets) {
1553
+ const mx = normal.dot(u);
1554
+ const my = normal.dot(v);
1555
+ const length = Math.hypot(mx, my);
1556
+ const nx = mx / length;
1557
+ const ny = my / length;
1558
+ const qx = centre.dot(u);
1559
+ const qy = centre.dot(v);
1560
+ const pxx = 1 - nx * nx;
1561
+ const pxy = -nx * ny;
1562
+ const pyy = 1 - ny * ny;
1563
+ xx += area * pxx;
1564
+ xy += area * pxy;
1565
+ yy += area * pyy;
1566
+ rx += area * (pxx * qx + pxy * qy);
1567
+ ry += area * (pxy * qx + pyy * qy);
1568
+ along += area * centre.dot(axis);
1569
+ total += area;
1570
+ }
1571
+ const determinant = xx * yy - xy * xy;
1572
+ if (Math.abs(determinant) < 1e-9 * total * total) return null;
1573
+ const cx = (yy * rx - xy * ry) / determinant;
1574
+ const cy = (xx * ry - xy * rx) / determinant;
1575
+ let radius = 0;
1576
+ for (const { centre, area } of facets) {
1577
+ radius += area * Math.hypot(centre.dot(u) - cx, centre.dot(v) - cy);
1578
+ }
1579
+ return {
1580
+ origin: new Vector35().addScaledVector(u, cx).addScaledVector(v, cy).addScaledVector(axis, along / total),
1581
+ axis,
1582
+ radius: radius / total
1583
+ };
1584
+ }
1585
+ function threadedRanges(position, model, mark) {
1586
+ const regions = [];
1587
+ const ranges = [];
1588
+ for (const region of mark.regions) {
1589
+ const range = model.regionIndex.rangeForRegion(region);
1590
+ if (!range) continue;
1591
+ const bore = fitBore(position, [range]);
1592
+ if (!bore || bore.radius * 2 > mark.majorDiameter * DIAMETER_SLACK) continue;
1593
+ regions.push(region);
1594
+ ranges.push(range);
1595
+ }
1596
+ return { regions, ranges };
1597
+ }
1598
+ function writeThreadTexels(data, width, geometry, model, texels, options) {
1599
+ data.fill(0);
1600
+ if (!options) return;
1601
+ const position = geometry.getAttribute("position");
1602
+ for (const mark of options.threads) {
1603
+ if (!Number.isFinite(mark.pitch) || mark.pitch === 0) continue;
1604
+ const { regions, ranges } = threadedRanges(position, model, mark);
1605
+ const bore = fitBore(position, ranges);
1606
+ if (!bore) continue;
1607
+ for (const region of regions) {
1608
+ const column = texels.get(region);
1609
+ if (column === void 0) continue;
1610
+ const first = column * 4;
1611
+ const second = (width + column) * 4;
1612
+ data[first] = bore.origin.x;
1613
+ data[first + 1] = bore.origin.y;
1614
+ data[first + 2] = bore.origin.z;
1615
+ data[first + 3] = mark.pitch;
1616
+ data[second] = bore.axis.x;
1617
+ data[second + 1] = bore.axis.y;
1618
+ data[second + 2] = bore.axis.z;
1619
+ }
1620
+ }
1621
+ }
1622
+ function threadKey(options) {
1623
+ if (!options) return "";
1624
+ return options.threads.map((mark) => `${mark.regions.join(",")}:${mark.pitch}:${mark.majorDiameter}`).join("|");
1625
+ }
1626
+
1491
1627
  // src/render/part.ts
1492
1628
  var REGION_ATTRIBUTE = "aRegion";
1493
1629
  var EMISSIVE_MIX = 0.4;
1630
+ var THREAD_TILT = Math.tan(Math.PI / 4);
1631
+ var THREAD_ROOT_SHADE = 0.45;
1632
+ var THREAD_CREST_LIGHT = 1.15;
1494
1633
  function buildRegionTexels(model) {
1495
1634
  return new Map(model.regions.map((region, column) => [region.idx, column]));
1496
1635
  }
@@ -1522,6 +1661,10 @@ function createPart(model, geometry, theme) {
1522
1661
  const opacity = new Uint8Array(width).fill(255);
1523
1662
  const opacityTexture = new DataTexture(opacity, width, 1, RedFormat, UnsignedByteType);
1524
1663
  opacityTexture.needsUpdate = true;
1664
+ const threads = new Float32Array(width * THREAD_TEXTURE_ROWS * 4);
1665
+ const threadTexture = new DataTexture(threads, width, THREAD_TEXTURE_ROWS, RGBAFormat, FloatType);
1666
+ threadTexture.needsUpdate = true;
1667
+ let threadsKey = "";
1525
1668
  const focusState = { value: false };
1526
1669
  const wireframe = { value: false };
1527
1670
  let focused = false;
@@ -1547,15 +1690,26 @@ function createPart(model, geometry, theme) {
1547
1690
  shader.uniforms["uWireframe"] = wireframe;
1548
1691
  shader.uniforms["uFocus"] = focusState;
1549
1692
  shader.uniforms["uOpaqueFocusPass"] = { value: opaqueFocusPass };
1693
+ shader.uniforms["uThreads"] = { value: threadTexture };
1550
1694
  shader.vertexShader = shader.vertexShader.replace(
1551
1695
  "#include <common>",
1552
1696
  `#include <common>
1553
1697
  attribute float ${REGION_ATTRIBUTE};
1554
- varying float vRegion;`
1698
+ uniform sampler2D uThreads;
1699
+ varying float vRegion;
1700
+ varying vec3 vThreadPosition;
1701
+ varying vec4 vThreadOrigin;
1702
+ varying vec3 vThreadAxis;
1703
+ varying vec3 vThreadAxisView;`
1555
1704
  ).replace(
1556
1705
  "#include <begin_vertex>",
1557
1706
  `#include <begin_vertex>
1558
- vRegion = ${REGION_ATTRIBUTE};`
1707
+ vRegion = ${REGION_ATTRIBUTE};
1708
+ // Every vertex of a triangle is in one region, so these are flat across it.
1709
+ vThreadOrigin = texelFetch(uThreads, ivec2(int(vRegion + 0.5), 0), 0);
1710
+ vThreadAxis = texelFetch(uThreads, ivec2(int(vRegion + 0.5), 1), 0).xyz;
1711
+ vThreadAxisView = normalMatrix * vThreadAxis;
1712
+ vThreadPosition = position;`
1559
1713
  );
1560
1714
  shader.fragmentShader = shader.fragmentShader.replace(
1561
1715
  "#include <common>",
@@ -1566,6 +1720,10 @@ function createPart(model, geometry, theme) {
1566
1720
  uniform bool uFocus;
1567
1721
  uniform bool uOpaqueFocusPass;
1568
1722
  varying float vRegion;
1723
+ varying vec3 vThreadPosition;
1724
+ varying vec4 vThreadOrigin;
1725
+ varying vec3 vThreadAxis;
1726
+ varying vec3 vThreadAxisView;
1569
1727
  vec4 regionState;`
1570
1728
  ).replace(
1571
1729
  "#include <color_fragment>",
@@ -1578,6 +1736,46 @@ function createPart(model, geometry, theme) {
1578
1736
  diffuseColor.a *= regionOpacity;
1579
1737
  if (uWireframe) diffuseColor.a *= regionState.a;
1580
1738
  `
1739
+ ).replace(
1740
+ "#include <normal_fragment_maps>",
1741
+ `#include <normal_fragment_maps>
1742
+ // Worked out for every pixel, threaded or not, and applied only where
1743
+ // there is a thread: derivatives are undefined inside a branch that
1744
+ // differs between neighbouring pixels, as it does along a region's edge.
1745
+ bool threaded = vThreadOrigin.w != 0.0;
1746
+ float threadPitch = threaded ? vThreadOrigin.w : 1.0;
1747
+ vec3 threadAxis = threaded ? normalize(vThreadAxis) : vec3(0.0, 0.0, 1.0);
1748
+ vec3 threadOffset = vThreadPosition - vThreadOrigin.xyz;
1749
+ float threadAlong = dot(threadOffset, threadAxis);
1750
+ vec3 threadU = normalize(cross(
1751
+ threadAxis,
1752
+ abs(threadAxis.x) < 0.9 ? vec3(1.0, 0.0, 0.0) : vec3(0.0, 1.0, 0.0)
1753
+ ));
1754
+ vec3 threadV = cross(threadAxis, threadU);
1755
+ float threadX = dot(threadOffset, threadU);
1756
+ float threadY = dot(threadOffset, threadV);
1757
+ float threadTurn = atan(threadY, threadX) / PI2;
1758
+ float threadPhase = threadAlong / threadPitch - threadTurn;
1759
+ // Two angle seams on opposite sides, so the blur estimate has none.
1760
+ float threadBlur = fwidth(threadAlong / threadPitch) + min(
1761
+ fwidth(threadTurn),
1762
+ fwidth(atan(-threadY, -threadX) / PI2)
1763
+ );
1764
+ if (threaded) {
1765
+ float threadStrength = 1.0 - smoothstep(0.2, 0.45, threadBlur);
1766
+ // The V of the thread: 1 at its root (phase 0), 0 at its crest (phase \xBD).
1767
+ float threadDepth = abs(fract(threadPhase) - 0.5) * 2.0;
1768
+ float threadSlope = clamp(sin(PI2 * threadPhase) * 3.0, -1.0, 1.0) * sign(threadPitch);
1769
+ normal = normalize(
1770
+ normal - ${THREAD_TILT.toFixed(4)} * threadStrength * threadSlope * normalize(vThreadAxisView)
1771
+ );
1772
+ float threadShade = mix(
1773
+ ${THREAD_CREST_LIGHT.toFixed(2)},
1774
+ ${THREAD_ROOT_SHADE.toFixed(2)},
1775
+ smoothstep(0.0, 1.0, threadDepth)
1776
+ );
1777
+ diffuseColor.rgb *= mix(1.0, threadShade, threadStrength);
1778
+ }`
1581
1779
  ).replace(
1582
1780
  "#include <emissivemap_fragment>",
1583
1781
  `#include <emissivemap_fragment>
@@ -1629,7 +1827,7 @@ function createPart(model, geometry, theme) {
1629
1827
  const object = new Group2();
1630
1828
  object.add(focusMesh, mesh, edges);
1631
1829
  const scratchColor = new Color2();
1632
- const scratchVector = new Vector35();
1830
+ const scratchVector = new Vector36();
1633
1831
  const paintRegion = (region, color, weight) => {
1634
1832
  const column = texels.get(region);
1635
1833
  if (column === void 0) return;
@@ -1758,6 +1956,13 @@ function createPart(model, geometry, theme) {
1758
1956
  setHoveredSharpCorner(index) {
1759
1957
  sharpLines?.setHovered(index);
1760
1958
  },
1959
+ setThreads(options) {
1960
+ const key = threadKey(options);
1961
+ if (key === threadsKey) return;
1962
+ threadsKey = key;
1963
+ writeThreadTexels(threads, width, geometry, model, texels, options);
1964
+ threadTexture.needsUpdate = true;
1965
+ },
1761
1966
  dispose() {
1762
1967
  object.clear();
1763
1968
  if (geometry.getAttribute(REGION_ATTRIBUTE) === regionAttribute) {
@@ -1770,6 +1975,7 @@ function createPart(model, geometry, theme) {
1770
1975
  sharpLines?.dispose();
1771
1976
  stateTexture.dispose();
1772
1977
  opacityTexture.dispose();
1978
+ threadTexture.dispose();
1773
1979
  }
1774
1980
  };
1775
1981
  }
@@ -1883,9 +2089,10 @@ import {
1883
2089
  useEffect as useEffect4,
1884
2090
  useImperativeHandle,
1885
2091
  useMemo as useMemo4,
1886
- useRef as useRef3
2092
+ useRef as useRef3,
2093
+ useSyncExternalStore
1887
2094
  } from "react";
1888
- import { Box3 as Box33, Vector3 as Vector39 } from "three";
2095
+ import { Box3 as Box33, Vector3 as Vector310 } from "three";
1889
2096
 
1890
2097
  // src/camera.tsx
1891
2098
  import { useFrame, useThree as useThree2 } from "@react-three/fiber";
@@ -1902,7 +2109,7 @@ import {
1902
2109
  Sphere,
1903
2110
  Spherical,
1904
2111
  Vector2 as Vector23,
1905
- Vector3 as Vector36,
2112
+ Vector3 as Vector37,
1906
2113
  Vector4 as Vector42
1907
2114
  } from "three";
1908
2115
 
@@ -2000,7 +2207,7 @@ CameraControls.install({
2000
2207
  Sphere,
2001
2208
  Spherical,
2002
2209
  Vector2: Vector23,
2003
- Vector3: Vector36,
2210
+ Vector3: Vector37,
2004
2211
  Vector4: Vector42
2005
2212
  }
2006
2213
  });
@@ -2023,9 +2230,9 @@ var ExtendedCameraControls = class extends CameraControls {
2023
2230
  // Scratch objects — `#onPointerMove` and `#adaptUpVector` run at pointer and
2024
2231
  // frame rate respectively, so neither may allocate.
2025
2232
  #spherical = new Spherical();
2026
- #scratchA = new Vector36();
2027
- #scratchB = new Vector36();
2028
- #scratchC = new Vector36();
2233
+ #scratchA = new Vector37();
2234
+ #scratchB = new Vector37();
2235
+ #scratchC = new Vector37();
2029
2236
  constructor(camera, domElement, options = {}) {
2030
2237
  super(camera, domElement);
2031
2238
  this.#domElement = domElement;
@@ -2363,6 +2570,22 @@ function counted(notify) {
2363
2570
  };
2364
2571
  }
2365
2572
 
2573
+ // src/render/webgl.ts
2574
+ var isWebGLAvailable = () => {
2575
+ if (typeof document === "undefined") {
2576
+ return false;
2577
+ }
2578
+ try {
2579
+ const context = document.createElement("canvas").getContext("webgl2");
2580
+ context?.getExtension("WEBGL_lose_context")?.loseContext();
2581
+ return context !== null;
2582
+ } catch {
2583
+ return false;
2584
+ }
2585
+ };
2586
+ var probed;
2587
+ var webGLAvailableSnapshot = () => probed ??= isWebGLAvailable();
2588
+
2366
2589
  // src/target-marker.tsx
2367
2590
  import { useFrame as useFrame2, useThree as useThree3 } from "@react-three/fiber";
2368
2591
  import { useEffect as useEffect3, useMemo as useMemo3, useRef as useRef2 } from "react";
@@ -2370,7 +2593,7 @@ import {
2370
2593
  DoubleSide as DoubleSide3,
2371
2594
  RingGeometry,
2372
2595
  SphereGeometry,
2373
- Vector3 as Vector37
2596
+ Vector3 as Vector38
2374
2597
  } from "three";
2375
2598
 
2376
2599
  // src/render/target.ts
@@ -2413,8 +2636,8 @@ var TargetMarker = ({ controlsRef }) => {
2413
2636
  const heldUntil = useRef2(0);
2414
2637
  const painted = useRef2(-1);
2415
2638
  const placed = useRef2(false);
2416
- const lastTarget = useMemo3(() => new Vector37(), []);
2417
- const scratch = useMemo3(() => new Vector37(), []);
2639
+ const lastTarget = useMemo3(() => new Vector38(), []);
2640
+ const scratch = useMemo3(() => new Vector38(), []);
2418
2641
  useEffect3(() => {
2419
2642
  const controls = controlsRef.current;
2420
2643
  if (!controls) return void 0;
@@ -2508,13 +2731,41 @@ var TargetMarker = ({ controlsRef }) => {
2508
2731
  ] });
2509
2732
  };
2510
2733
 
2734
+ // src/webgl-unavailable.tsx
2735
+ import { Fragment, jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
2736
+ var STYLE = {
2737
+ display: "flex",
2738
+ flexDirection: "column",
2739
+ alignItems: "center",
2740
+ justifyContent: "center",
2741
+ gap: "0.5rem",
2742
+ height: "100%",
2743
+ padding: "1rem",
2744
+ boxSizing: "border-box",
2745
+ textAlign: "center"
2746
+ };
2747
+ var DEFAULT_MESSAGE = /* @__PURE__ */ jsxs2(Fragment, { children: [
2748
+ /* @__PURE__ */ jsx3("strong", { children: "3D view unavailable" }),
2749
+ /* @__PURE__ */ jsx3("span", { children: "WebGL is blocked in this browser. Turn on graphics (hardware) acceleration in your browser settings, then restart the browser." })
2750
+ ] });
2751
+ var WebGLUnavailable = ({ children = DEFAULT_MESSAGE }) => /* @__PURE__ */ jsx3(
2752
+ "div",
2753
+ {
2754
+ className: "viewer-webgl-unavailable",
2755
+ "data-viewer-webgl-unavailable": "true",
2756
+ role: "alert",
2757
+ style: STYLE,
2758
+ children
2759
+ }
2760
+ );
2761
+
2511
2762
  // src/render/view-cube.ts
2512
2763
  import {
2513
2764
  BufferGeometry as BufferGeometry2,
2514
2765
  CanvasTexture,
2515
2766
  Float32BufferAttribute as Float32BufferAttribute4,
2516
2767
  SRGBColorSpace,
2517
- Vector3 as Vector38
2768
+ Vector3 as Vector39
2518
2769
  } from "three";
2519
2770
  var VIEW_NAMES = [
2520
2771
  // The six faces.
@@ -2598,12 +2849,12 @@ function viewUp(direction) {
2598
2849
  return { x: 0, y: 0, z: 1 };
2599
2850
  }
2600
2851
  function squaredUp(direction, currentUp) {
2601
- const forward = new Vector38(direction.x, direction.y, direction.z).normalize();
2852
+ const forward = new Vector39(direction.x, direction.y, direction.z).normalize();
2602
2853
  const canonical = viewUp(direction);
2603
- const zero = new Vector38(canonical.x, canonical.y, canonical.z);
2854
+ const zero = new Vector39(canonical.x, canonical.y, canonical.z);
2604
2855
  zero.addScaledVector(forward, -forward.dot(zero)).normalize();
2605
- const quarter = new Vector38().crossVectors(forward, zero);
2606
- const current = new Vector38(currentUp.x, currentUp.y, currentUp.z);
2856
+ const quarter = new Vector39().crossVectors(forward, zero);
2857
+ const current = new Vector39(currentUp.x, currentUp.y, currentUp.z);
2607
2858
  const rolls = [zero, quarter, zero.clone().negate(), quarter.clone().negate()];
2608
2859
  const nearest = rolls.reduce(
2609
2860
  (best, roll) => roll.dot(current) > best.dot(current) ? roll : best
@@ -2621,11 +2872,11 @@ function cubeZones(chamfer = CHAMFER) {
2621
2872
  const direction = viewVector(name);
2622
2873
  const [sx, sy, sz] = VIEW_SIGNS[name];
2623
2874
  const sign = (axis) => VIEW_SIGNS[name][axis] ?? 0;
2624
- const normal = new Vector38(direction.x, direction.y, direction.z);
2875
+ const normal = new Vector39(direction.x, direction.y, direction.z);
2625
2876
  let polygon;
2626
2877
  if (kind === "face") {
2627
2878
  const up = viewUp(direction);
2628
- const upVector = new Vector38(up.x, up.y, up.z);
2879
+ const upVector = new Vector39(up.x, up.y, up.z);
2629
2880
  const right = normal.clone().negate().cross(upVector).normalize();
2630
2881
  const square = [
2631
2882
  [-1, -1],
@@ -2647,7 +2898,7 @@ function cubeZones(chamfer = CHAMFER) {
2647
2898
  [chamfer, 1, -chamfer]
2648
2899
  ];
2649
2900
  polygon = rectangle.map(
2650
- ([onA, onB, onC]) => new Vector38().setComponent(a, sign(a) * onA).setComponent(b, sign(b) * onB).setComponent(c, onC)
2901
+ ([onA, onB, onC]) => new Vector39().setComponent(a, sign(a) * onA).setComponent(b, sign(b) * onB).setComponent(c, onC)
2651
2902
  );
2652
2903
  } else {
2653
2904
  const triangle = [
@@ -2655,11 +2906,11 @@ function cubeZones(chamfer = CHAMFER) {
2655
2906
  [chamfer, 1, chamfer],
2656
2907
  [chamfer, chamfer, 1]
2657
2908
  ];
2658
- polygon = triangle.map(([onX, onY, onZ]) => new Vector38(sx * onX, sy * onY, sz * onZ));
2909
+ polygon = triangle.map(([onX, onY, onZ]) => new Vector39(sx * onX, sy * onY, sz * onZ));
2659
2910
  }
2660
2911
  const [first, second, third] = polygon;
2661
2912
  if (first && second && third) {
2662
- const facing = new Vector38().subVectors(second, first).cross(new Vector38().subVectors(third, first));
2913
+ const facing = new Vector39().subVectors(second, first).cross(new Vector39().subVectors(third, first));
2663
2914
  if (facing.dot(normal) < 0) polygon.reverse();
2664
2915
  }
2665
2916
  return { name, kind, direction, polygon: polygon.map(vec) };
@@ -2749,7 +3000,7 @@ function labelTexture(label, color) {
2749
3000
  }
2750
3001
 
2751
3002
  // src/viewer.tsx
2752
- import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
3003
+ import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
2753
3004
  var ViewerControlsContext = createContext3(null);
2754
3005
  var useViewerControls = () => {
2755
3006
  const controls = useContext3(ViewerControlsContext);
@@ -2789,10 +3040,10 @@ var ViewerScene = ({
2789
3040
  const framedRef = useRef3(null);
2790
3041
  const scratchBox = useMemo4(() => new Box33(), []);
2791
3042
  const scratchBoundary = useMemo4(() => new Box33(), []);
2792
- const scratchDirection = useMemo4(() => new Vector39(), []);
2793
- const scratchView = useMemo4(() => new Vector39(), []);
2794
- const scratchTarget = useMemo4(() => new Vector39(), []);
2795
- const scratchPosition = useMemo4(() => new Vector39(), []);
3043
+ const scratchDirection = useMemo4(() => new Vector310(), []);
3044
+ const scratchView = useMemo4(() => new Vector310(), []);
3045
+ const scratchTarget = useMemo4(() => new Vector310(), []);
3046
+ const scratchPosition = useMemo4(() => new Vector310(), []);
2796
3047
  const applyLimits = useCallback(
2797
3048
  (bounds, framed) => {
2798
3049
  controlsRef.current?.applyLimits(
@@ -2841,8 +3092,8 @@ var ViewerScene = ({
2841
3092
  (bounds) => {
2842
3093
  const controls = controlsRef.current;
2843
3094
  if (!controls) return false;
2844
- const target = controls.getTarget(new Vector39());
2845
- const direction = currentViewDirection(camera, target, new Vector39());
3095
+ const target = controls.getTarget(new Vector310());
3096
+ const direction = currentViewDirection(camera, target, new Vector310());
2846
3097
  const distance = fitDistance(projection, size, bounds, DEFAULT_FIT_MARGIN);
2847
3098
  const position = direction.multiplyScalar(distance).add(bounds.center);
2848
3099
  void controls.setLookAt(
@@ -2867,8 +3118,8 @@ var ViewerScene = ({
2867
3118
  const fitContent = useCallback(() => {
2868
3119
  const controls = controlsRef.current;
2869
3120
  if (!controls) return false;
2870
- const target = controls.getTarget(new Vector39());
2871
- return frame(currentViewDirection(camera, target, new Vector39()), true);
3121
+ const target = controls.getTarget(new Vector310());
3122
+ return frame(currentViewDirection(camera, target, new Vector310()), true);
2872
3123
  }, [camera, frame]);
2873
3124
  const resetContent = useCallback(() => {
2874
3125
  const bounds = measure();
@@ -2958,18 +3209,18 @@ var ViewerScene = ({
2958
3209
  initialFrameComplete.current = resetContent();
2959
3210
  }
2960
3211
  });
2961
- return /* @__PURE__ */ jsx3(ViewerTapProvider, { value: isTap, children: /* @__PURE__ */ jsxs2(ViewerRetargetContext.Provider, { value: retargetOn, children: [
2962
- /* @__PURE__ */ jsxs2("group", { ref: lightsRef, children: [
2963
- /* @__PURE__ */ jsx3("ambientLight", { color: theme.ambient, intensity: theme.ambientIntensity }),
2964
- /* @__PURE__ */ jsx3(
3212
+ return /* @__PURE__ */ jsx4(ViewerTapProvider, { value: isTap, children: /* @__PURE__ */ jsxs3(ViewerRetargetContext.Provider, { value: retargetOn, children: [
3213
+ /* @__PURE__ */ jsxs3("group", { ref: lightsRef, children: [
3214
+ /* @__PURE__ */ jsx4("ambientLight", { color: theme.ambient, intensity: theme.ambientIntensity }),
3215
+ /* @__PURE__ */ jsx4(
2965
3216
  "hemisphereLight",
2966
3217
  {
2967
3218
  args: [theme.hemisphereSky, theme.hemisphereGround, theme.hemisphereIntensity]
2968
3219
  }
2969
3220
  )
2970
3221
  ] }),
2971
- /* @__PURE__ */ jsx3("group", { ref: contentRef, children }),
2972
- /* @__PURE__ */ jsx3(
3222
+ /* @__PURE__ */ jsx4("group", { ref: contentRef, children }),
3223
+ /* @__PURE__ */ jsx4(
2973
3224
  CadCameraControls,
2974
3225
  {
2975
3226
  controlsRef,
@@ -2978,9 +3229,11 @@ var ViewerScene = ({
2978
3229
  zoomTo
2979
3230
  }
2980
3231
  ),
2981
- showOrbitTarget ? /* @__PURE__ */ jsx3(TargetMarker, { controlsRef }) : null
3232
+ showOrbitTarget ? /* @__PURE__ */ jsx4(TargetMarker, { controlsRef }) : null
2982
3233
  ] }) });
2983
3234
  };
3235
+ var subscribeNever = () => () => {
3236
+ };
2984
3237
  var Viewer = forwardRef(function Viewer2({
2985
3238
  children,
2986
3239
  className,
@@ -2993,8 +3246,10 @@ var Viewer = forwardRef(function Viewer2({
2993
3246
  retargetOnDoubleClick = true,
2994
3247
  showOrbitTarget = false,
2995
3248
  theme,
2996
- onPointerMissed
3249
+ onPointerMissed,
3250
+ webglUnavailable
2997
3251
  }, ref) {
3252
+ const webglAvailable = useSyncExternalStore(subscribeNever, webGLAvailableSnapshot, () => true);
2998
3253
  const actionsRef = useRef3(null);
2999
3254
  const resolved = useMemo4(() => resolveTheme(theme), [theme]);
3000
3255
  const sectionStore = useMemo4(() => createSectionStore(), []);
@@ -3020,7 +3275,7 @@ var Viewer = forwardRef(function Viewer2({
3020
3275
  tracker.current?.dispose();
3021
3276
  tracker.current = element ? trackTaps(element) : null;
3022
3277
  }, []);
3023
- return /* @__PURE__ */ jsx3(ViewerControlsContext.Provider, { value: proxy, children: /* @__PURE__ */ jsx3(SectionStoreContext.Provider, { value: sectionStore, children: /* @__PURE__ */ jsx3(
3278
+ return /* @__PURE__ */ jsx4(ViewerControlsContext.Provider, { value: proxy, children: /* @__PURE__ */ jsx4(SectionStoreContext.Provider, { value: sectionStore, children: /* @__PURE__ */ jsx4(
3024
3279
  "div",
3025
3280
  {
3026
3281
  className: ["viewer-root", className].filter(Boolean).join(" "),
@@ -3030,7 +3285,7 @@ var Viewer = forwardRef(function Viewer2({
3030
3285
  if (event.target instanceof HTMLCanvasElement) event.preventDefault();
3031
3286
  },
3032
3287
  style: { height: "100%", width: "100%", ...style },
3033
- children: /* @__PURE__ */ jsx3(
3288
+ children: webglAvailable ? /* @__PURE__ */ jsx4(
3034
3289
  Canvas,
3035
3290
  {
3036
3291
  className: "viewer-canvas-container",
@@ -3051,7 +3306,7 @@ var Viewer = forwardRef(function Viewer2({
3051
3306
  if (event.button !== 0) return;
3052
3307
  if (tracker.current?.isTap(event) ?? true) onPointerMissed?.();
3053
3308
  },
3054
- children: /* @__PURE__ */ jsx3(
3309
+ children: /* @__PURE__ */ jsx4(
3055
3310
  ViewerScene,
3056
3311
  {
3057
3312
  setControls,
@@ -3068,7 +3323,7 @@ var Viewer = forwardRef(function Viewer2({
3068
3323
  )
3069
3324
  },
3070
3325
  projection
3071
- )
3326
+ ) : /* @__PURE__ */ jsx4(WebGLUnavailable, { children: webglUnavailable })
3072
3327
  }
3073
3328
  ) }) });
3074
3329
  });
@@ -3087,7 +3342,7 @@ import {
3087
3342
  Raycaster as Raycaster2,
3088
3343
  Scene,
3089
3344
  Vector2 as Vector26,
3090
- Vector3 as Vector311
3345
+ Vector3 as Vector312
3091
3346
  } from "three";
3092
3347
 
3093
3348
  // src/arrow-mesh.tsx
@@ -3178,7 +3433,7 @@ function createDepthResetMaterial() {
3178
3433
  }
3179
3434
 
3180
3435
  // src/arrow-mesh.tsx
3181
- import { Fragment, jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
3436
+ import { Fragment as Fragment2, jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
3182
3437
  var ArrowMesh = ({ geometry, color, opacity, onTop = false, events }) => {
3183
3438
  const invalidate = useThree5((state) => state.invalidate);
3184
3439
  const material = useMemo5(() => createArrowMaterial(), []);
@@ -3191,16 +3446,16 @@ var ArrowMesh = ({ geometry, color, opacity, onTop = false, events }) => {
3191
3446
  applyArrowColor(material, color, opacity);
3192
3447
  invalidate();
3193
3448
  }, [color, invalidate, material, opacity]);
3194
- return /* @__PURE__ */ jsxs3(Fragment, { children: [
3195
- reset ? /* @__PURE__ */ jsx4("mesh", { geometry, material: reset, renderOrder: 0, raycast: () => null }) : null,
3196
- /* @__PURE__ */ jsx4("mesh", { geometry, material: depth, renderOrder: 1, raycast: () => null }),
3197
- /* @__PURE__ */ jsx4("mesh", { geometry, material, renderOrder: 2, ...events })
3449
+ return /* @__PURE__ */ jsxs4(Fragment2, { children: [
3450
+ reset ? /* @__PURE__ */ jsx5("mesh", { geometry, material: reset, renderOrder: 0, raycast: () => null }) : null,
3451
+ /* @__PURE__ */ jsx5("mesh", { geometry, material: depth, renderOrder: 1, raycast: () => null }),
3452
+ /* @__PURE__ */ jsx5("mesh", { geometry, material, renderOrder: 2, ...events })
3198
3453
  ] });
3199
3454
  };
3200
3455
 
3201
3456
  // src/render/arrow.ts
3202
- import { LatheGeometry, Vector2 as Vector24, Vector3 as Vector310 } from "three";
3203
- var ARROW_AXIS2 = new Vector310(0, 1, 0);
3457
+ import { LatheGeometry, Vector2 as Vector24, Vector3 as Vector311 } from "three";
3458
+ var ARROW_AXIS2 = new Vector311(0, 1, 0);
3204
3459
  var ARROW_ROUND = 32;
3205
3460
  var HEAD_LENGTH = 0.415;
3206
3461
  var HEAD_RADIUS = 0.153;
@@ -3426,12 +3681,12 @@ function createMaskMaterial() {
3426
3681
  }
3427
3682
 
3428
3683
  // src/section-view.tsx
3429
- import { Fragment as Fragment2, jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
3684
+ import { Fragment as Fragment3, jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
3430
3685
  var FURNITURE2 = { [EXCLUDE_FROM_FRAME]: true };
3431
3686
  function resolveSectionPlane(options, box) {
3432
3687
  if (!options?.enabled || box.isEmpty()) return null;
3433
3688
  const normal = options.plane?.normal ?? options.normal ?? DEFAULT_SECTION_NORMAL;
3434
- const axis = new Vector311(normal.x, normal.y, normal.z);
3689
+ const axis = new Vector312(normal.x, normal.y, normal.z);
3435
3690
  if (axis.lengthSq() === 0) axis.set(0, 0, 1);
3436
3691
  axis.normalize();
3437
3692
  const bounds = sectionBounds(box, normal);
@@ -3452,7 +3707,7 @@ function resolveSectionPlane(options, box) {
3452
3707
  }
3453
3708
  };
3454
3709
  }
3455
- var Z = new Vector311(0, 0, 1);
3710
+ var Z = new Vector312(0, 0, 1);
3456
3711
  var SectionView = ({
3457
3712
  geometry,
3458
3713
  box,
@@ -3472,7 +3727,7 @@ var SectionView = ({
3472
3727
  const handleRef = useRef4(null);
3473
3728
  const [hovered, setHovered] = useState(false);
3474
3729
  const dragging = useRef4(null);
3475
- const span = useMemo6(() => box.getSize(new Vector311()).length(), [box]);
3730
+ const span = useMemo6(() => box.getSize(new Vector312()).length(), [box]);
3476
3731
  const frameSize = span * SECTION_GIZMO_FRAME_SCALE;
3477
3732
  const directionColor2 = useMemo6(() => sectionDirectionColor(plane.normal), [plane]);
3478
3733
  const resolvedHandleColor = sectionHandleColor(handleColor, directionColor2);
@@ -3556,7 +3811,7 @@ var SectionView = ({
3556
3811
  event.stopPropagation();
3557
3812
  const view = camera.position.clone().sub(capPosition).normalize();
3558
3813
  const surface = dragPlane(plane.normal.clone(), view, capPosition);
3559
- const hit = event.ray.intersectPlane(surface, new Vector311());
3814
+ const hit = event.ray.intersectPlane(surface, new Vector312());
3560
3815
  const start = {
3561
3816
  plane: surface,
3562
3817
  from: hit ? hit.dot(plane.normal) : 0,
@@ -3573,7 +3828,7 @@ var SectionView = ({
3573
3828
  -((native.clientY - rect.top) / rect.height) * 2 + 1
3574
3829
  );
3575
3830
  raycaster.setFromCamera(pointer, camera);
3576
- const point = raycaster.ray.intersectPlane(start.plane, new Vector311());
3831
+ const point = raycaster.ray.intersectPlane(start.plane, new Vector312());
3577
3832
  if (!point) return;
3578
3833
  onDrag(start.constant - (point.dot(plane.normal) - start.from));
3579
3834
  invalidate();
@@ -3605,8 +3860,8 @@ var SectionView = ({
3605
3860
  // Excluded from framing, all of it. The cap is a quad half again as wide as
3606
3861
  // the part's diagonal, so a Fit that measured it would frame the cut rather
3607
3862
  // than the part and leave the part a speck in the middle of it.
3608
- /* @__PURE__ */ jsxs4("group", { userData: FURNITURE2, children: [
3609
- /* @__PURE__ */ jsx5(
3863
+ /* @__PURE__ */ jsxs5("group", { userData: FURNITURE2, children: [
3864
+ /* @__PURE__ */ jsx6(
3610
3865
  "mesh",
3611
3866
  {
3612
3867
  geometry,
@@ -3615,7 +3870,7 @@ var SectionView = ({
3615
3870
  raycast: () => null
3616
3871
  }
3617
3872
  ),
3618
- /* @__PURE__ */ jsx5(
3873
+ /* @__PURE__ */ jsx6(
3619
3874
  "mesh",
3620
3875
  {
3621
3876
  geometry,
@@ -3624,7 +3879,7 @@ var SectionView = ({
3624
3879
  raycast: () => null
3625
3880
  }
3626
3881
  ),
3627
- /* @__PURE__ */ jsx5(
3882
+ /* @__PURE__ */ jsx6(
3628
3883
  "mesh",
3629
3884
  {
3630
3885
  geometry: quad,
@@ -3635,15 +3890,15 @@ var SectionView = ({
3635
3890
  raycast: () => null
3636
3891
  }
3637
3892
  ),
3638
- showHandle && onDrag ? /* @__PURE__ */ jsxs4(Fragment2, { children: [
3639
- /* @__PURE__ */ jsxs4(
3893
+ showHandle && onDrag ? /* @__PURE__ */ jsxs5(Fragment3, { children: [
3894
+ /* @__PURE__ */ jsxs5(
3640
3895
  "group",
3641
3896
  {
3642
3897
  position: capPosition,
3643
3898
  quaternion: capQuaternion,
3644
3899
  renderOrder: SECTION_RENDER_ORDER.outline,
3645
3900
  children: [
3646
- /* @__PURE__ */ jsx5("mesh", { geometry: frameSurface, raycast: () => null, children: /* @__PURE__ */ jsx5(
3901
+ /* @__PURE__ */ jsx6("mesh", { geometry: frameSurface, raycast: () => null, children: /* @__PURE__ */ jsx6(
3647
3902
  "meshBasicMaterial",
3648
3903
  {
3649
3904
  color: directionColor2,
@@ -3654,18 +3909,18 @@ var SectionView = ({
3654
3909
  depthWrite: false
3655
3910
  }
3656
3911
  ) }),
3657
- /* @__PURE__ */ jsx5("lineSegments", { geometry: frameEdges, raycast: () => null, children: /* @__PURE__ */ jsx5("lineBasicMaterial", { color: directionColor2, depthTest: false, depthWrite: false }) })
3912
+ /* @__PURE__ */ jsx6("lineSegments", { geometry: frameEdges, raycast: () => null, children: /* @__PURE__ */ jsx6("lineBasicMaterial", { color: directionColor2, depthTest: false, depthWrite: false }) })
3658
3913
  ]
3659
3914
  }
3660
3915
  ),
3661
- /* @__PURE__ */ jsx5(
3916
+ /* @__PURE__ */ jsx6(
3662
3917
  "group",
3663
3918
  {
3664
3919
  ref: handleRef,
3665
3920
  position: capPosition,
3666
3921
  quaternion: handleQuaternion,
3667
3922
  renderOrder: SECTION_RENDER_ORDER.handle,
3668
- children: /* @__PURE__ */ jsx5(
3923
+ children: /* @__PURE__ */ jsx6(
3669
3924
  ArrowMesh,
3670
3925
  {
3671
3926
  geometry: handleShape,
@@ -3702,7 +3957,7 @@ function useContentBox() {
3702
3957
  }
3703
3958
 
3704
3959
  // src/part-mesh.tsx
3705
- import { Fragment as Fragment3, jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
3960
+ import { Fragment as Fragment4, jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
3706
3961
  var PartMesh = ({
3707
3962
  model,
3708
3963
  geometry,
@@ -3724,6 +3979,7 @@ var PartMesh = ({
3724
3979
  theme,
3725
3980
  showEdges = true,
3726
3981
  sharpCorners = null,
3982
+ threads = null,
3727
3983
  display = "solid",
3728
3984
  children
3729
3985
  }) => {
@@ -3740,11 +3996,11 @@ var PartMesh = ({
3740
3996
  onHoverRef.current = onHover;
3741
3997
  const box = useContentBox();
3742
3998
  const store = useSectionStore();
3743
- const shared = useSyncExternalStore(store.subscribe, store.get, store.get);
3999
+ const shared = useSyncExternalStore2(store.subscribe, store.get, store.get);
3744
4000
  const controlled = section !== void 0;
3745
4001
  const options = controlled ? section : shared ?? void 0;
3746
4002
  const cut = useMemo7(() => resolveSectionPlane(options, box), [box, options]);
3747
- const engaged = useSyncExternalStore(store.subscribe, store.isEngaged, store.isEngaged);
4003
+ const engaged = useSyncExternalStore2(store.subscribe, store.isEngaged, store.isEngaged);
3748
4004
  const pressedWhileEngaged = useRef6(false);
3749
4005
  const layers = useRef6({
3750
4006
  selection,
@@ -3825,6 +4081,13 @@ var PartMesh = ({
3825
4081
  if (hoverCorner.current !== null) dropHover();
3826
4082
  invalidate();
3827
4083
  }, [dropHover, invalidate, part, sharpKey]);
4084
+ const threadsRef = useRef6(threads);
4085
+ threadsRef.current = threads;
4086
+ const threadsKey = threadKey(threads);
4087
+ useLayoutEffect3(() => {
4088
+ part.setThreads(threadsRef.current);
4089
+ invalidate();
4090
+ }, [invalidate, part, threadsKey]);
3828
4091
  const reportedSection = useRef6("");
3829
4092
  useLayoutEffect3(() => {
3830
4093
  const state = cut?.state ?? null;
@@ -3914,8 +4177,8 @@ var PartMesh = ({
3914
4177
  },
3915
4178
  [box, controlled, cut, onSectionChange, options, store]
3916
4179
  );
3917
- return /* @__PURE__ */ jsxs5(Fragment3, { children: [
3918
- /* @__PURE__ */ jsx6(
4180
+ return /* @__PURE__ */ jsxs6(Fragment4, { children: [
4181
+ /* @__PURE__ */ jsx7(
3919
4182
  "primitive",
3920
4183
  {
3921
4184
  object: part.object,
@@ -3952,7 +4215,7 @@ var PartMesh = ({
3952
4215
  }
3953
4216
  }
3954
4217
  ),
3955
- cut ? /* @__PURE__ */ jsx6(
4218
+ cut ? /* @__PURE__ */ jsx7(
3956
4219
  SectionView,
3957
4220
  {
3958
4221
  geometry,
@@ -3964,12 +4227,12 @@ var PartMesh = ({
3964
4227
  onDrag: !controlled || onSectionChange ? dragSection : void 0
3965
4228
  }
3966
4229
  ) : null,
3967
- children ? /* @__PURE__ */ jsx6(PartContext.Provider, { value: context, children }) : null
4230
+ children ? /* @__PURE__ */ jsx7(PartContext.Provider, { value: context, children }) : null
3968
4231
  ] });
3969
4232
  };
3970
- var ORIGIN = new Vector312();
3971
- var UP = new Vector312(0, 0, 1);
3972
- var TARGET = new Vector312();
4233
+ var ORIGIN = new Vector313();
4234
+ var UP = new Vector313(0, 0, 1);
4235
+ var TARGET = new Vector313();
3973
4236
  function readTarget(controls) {
3974
4237
  if (controls && typeof controls.getTarget === "function") {
3975
4238
  return controls.getTarget(TARGET);
@@ -4183,6 +4446,8 @@ function createEngineGeometryCache(loadGeometry = async (part) => {
4183
4446
  return geometry;
4184
4447
  }, maximumEntries = 8) {
4185
4448
  const resources = /* @__PURE__ */ new Map();
4449
+ const requests = /* @__PURE__ */ new WeakMap();
4450
+ let failures = /* @__PURE__ */ new WeakMap();
4186
4451
  let accessSequence = 0;
4187
4452
  const touch = (resource) => {
4188
4453
  resource.lastAccess = ++accessSequence;
@@ -4202,11 +4467,31 @@ function createEngineGeometryCache(loadGeometry = async (part) => {
4202
4467
  oldest[1].geometry?.dispose();
4203
4468
  }
4204
4469
  };
4470
+ const rememberFailure = (part, requestKey, resource) => {
4471
+ const attempts = failures;
4472
+ void resource.promise.then(() => {
4473
+ if (resource.status === "rejected" && requests.get(resource) === requestKey) {
4474
+ attempts.set(part, resource);
4475
+ }
4476
+ });
4477
+ };
4205
4478
  return {
4206
4479
  get(part) {
4207
4480
  const key = engineGeometryResourceKey(part.mesh);
4208
4481
  const existing = resources.get(key);
4482
+ if (existing?.status === "fulfilled") {
4483
+ failures.delete(part);
4484
+ touch(existing);
4485
+ return existing;
4486
+ }
4487
+ const requestKey = JSON.stringify([part.mesh.glbUrl, part.mesh.stlUrl]);
4488
+ const failed = failures.get(part);
4489
+ if (failed && requests.get(failed) === requestKey) {
4490
+ touch(failed);
4491
+ return failed;
4492
+ }
4209
4493
  if (existing) {
4494
+ rememberFailure(part, requestKey, existing);
4210
4495
  touch(existing);
4211
4496
  return existing;
4212
4497
  }
@@ -4216,6 +4501,7 @@ function createEngineGeometryCache(loadGeometry = async (part) => {
4216
4501
  references: 0,
4217
4502
  lastAccess: ++accessSequence
4218
4503
  };
4504
+ requests.set(resource, requestKey);
4219
4505
  resource.promise = loadGeometry(part).then(
4220
4506
  (geometry) => {
4221
4507
  resource.status = "fulfilled";
@@ -4228,6 +4514,7 @@ function createEngineGeometryCache(loadGeometry = async (part) => {
4228
4514
  }
4229
4515
  );
4230
4516
  resources.set(key, resource);
4517
+ rememberFailure(part, requestKey, resource);
4231
4518
  return resource;
4232
4519
  },
4233
4520
  retain(resource) {
@@ -4242,6 +4529,7 @@ function createEngineGeometryCache(loadGeometry = async (part) => {
4242
4529
  clear() {
4243
4530
  for (const resource of resources.values()) resource.geometry?.dispose();
4244
4531
  resources.clear();
4532
+ failures = /* @__PURE__ */ new WeakMap();
4245
4533
  }
4246
4534
  };
4247
4535
  }
@@ -4593,15 +4881,28 @@ function compareVersions(a, b) {
4593
4881
  }
4594
4882
 
4595
4883
  // src/engine/engine-part.tsx
4596
- import { jsx as jsx7 } from "react/jsx-runtime";
4884
+ import { jsx as jsx8 } from "react/jsx-runtime";
4885
+ var models = /* @__PURE__ */ new WeakMap();
4886
+ var modelForReport = (report) => {
4887
+ if (typeof report !== "object" || report === null) {
4888
+ return normalizePartReport(report);
4889
+ }
4890
+ const existing = models.get(report);
4891
+ if (existing) {
4892
+ return existing;
4893
+ }
4894
+ const model = normalizePartReport(report);
4895
+ models.set(report, model);
4896
+ return model;
4897
+ };
4597
4898
  var EnginePart = ({ report, ...props }) => {
4598
- const model = useMemo8(() => normalizePartReport(report), [report]);
4899
+ const model = modelForReport(report);
4599
4900
  const resource = useGeometryResource(model);
4600
4901
  useEffect8(() => {
4601
4902
  engineGeometryCache.retain(resource);
4602
4903
  return () => engineGeometryCache.release(resource);
4603
4904
  }, [resource]);
4604
- return /* @__PURE__ */ jsx7(PartMesh, { model, geometry: resource.geometry, ...props });
4905
+ return /* @__PURE__ */ jsx8(PartMesh, { model, geometry: resource.geometry, ...props });
4605
4906
  };
4606
4907
  function useGeometryResource(part) {
4607
4908
  const resource = engineGeometryCache.get(part);
@@ -4610,6 +4911,23 @@ function useGeometryResource(part) {
4610
4911
  return resource;
4611
4912
  }
4612
4913
 
4914
+ // src/engine/threads.ts
4915
+ function threadMark(feature, datasheet) {
4916
+ if (!isRecord(datasheet) || !isRecord(datasheet["facts"])) return null;
4917
+ const facts = datasheet["facts"];
4918
+ if (facts["kind"] !== "Hole" || !isRecord(facts["threading"])) return null;
4919
+ const spec = facts["threading"]["spec"];
4920
+ if (!isRecord(spec)) return null;
4921
+ const pitch = spec["threadPitch"];
4922
+ const majorDiameter = spec["basicDiameter"];
4923
+ if (!isFiniteNumber(pitch) || pitch <= 0 || !isFiniteNumber(majorDiameter)) return null;
4924
+ return {
4925
+ regions: feature.regionIdxs,
4926
+ pitch: spec["handedness"] === "Left" ? -pitch : pitch,
4927
+ majorDiameter
4928
+ };
4929
+ }
4930
+
4613
4931
  export {
4614
4932
  usePartContext,
4615
4933
  sameDirection,
@@ -4784,5 +5102,6 @@ export {
4784
5102
  MIN_KERNEL_VERSION,
4785
5103
  normalizePartReport,
4786
5104
  assertSupportedKernelVersion,
4787
- EnginePart
5105
+ EnginePart,
5106
+ threadMark
4788
5107
  };