@selvajs/visualization 1.6.0-beta.10 → 1.6.0-beta.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/render.js CHANGED
@@ -8,6 +8,7 @@ import { LineMaterial } from "three/addons/lines/LineMaterial.js";
8
8
  import { LineSegments2 } from "three/addons/lines/LineSegments2.js";
9
9
  import { LineSegmentsGeometry } from "three/addons/lines/LineSegmentsGeometry.js";
10
10
  import { CSS2DObject, CSS2DRenderer } from "three/addons/renderers/CSS2DRenderer.js";
11
+ import { MeshBVH } from "three-mesh-bvh";
11
12
  import { ViewHelper } from "three/addons/helpers/ViewHelper.js";
12
13
  import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
13
14
  import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
@@ -1793,10 +1794,143 @@ function createLabelLayer(container, scene) {
1793
1794
  }
1794
1795
  };
1795
1796
  }
1797
+ // src/render/scene-pick.ts
1798
+ const bvhCache = /* @__PURE__ */ new WeakMap();
1799
+ function versionOf$1(attribute) {
1800
+ return attribute?.version ?? 0;
1801
+ }
1802
+ /** BVH for a mesh geometry, building it on first use. Null when the geometry can't take one. */
1803
+ function bvhFor(geometry) {
1804
+ const position = geometry.attributes.position;
1805
+ if (!position || position.count === 0) return null;
1806
+ const index = geometry.index;
1807
+ const key = index ?? position;
1808
+ const cached = bvhCache.get(key);
1809
+ if (cached === null) return null;
1810
+ if (cached && cached.position === position && cached.positionVersion === versionOf$1(position) && cached.indexVersion === versionOf$1(index)) return cached.bvh;
1811
+ const proxy = new THREE.BufferGeometry();
1812
+ proxy.setAttribute("position", position);
1813
+ if (index) proxy.setIndex(index);
1814
+ try {
1815
+ const bvh = new MeshBVH(proxy, { indirect: true });
1816
+ bvhCache.set(key, {
1817
+ position,
1818
+ positionVersion: versionOf$1(position),
1819
+ indexVersion: versionOf$1(index),
1820
+ bvh
1821
+ });
1822
+ return bvh;
1823
+ } catch {
1824
+ bvhCache.set(key, null);
1825
+ return null;
1826
+ }
1827
+ }
1828
+ function isBvhMesh(object) {
1829
+ const o = object;
1830
+ return o.isMesh === true && !o.isInstancedMesh && !o.isSkinnedMesh && !o.isBatchedMesh && !o.isLineSegments2 && !object.morphTargetInfluences?.length;
1831
+ }
1832
+ /**
1833
+ * Visible, pickable objects under `root`: hidden subtrees, viewer aids and edge overlays are
1834
+ * skipped. Overlays are skipped because they double the mesh's own edges and, as fat lines, cost a
1835
+ * linear scan of every segment per ray.
1836
+ */
1837
+ function collectPickable(root, out = []) {
1838
+ for (const child of root.children) {
1839
+ if (!child.visible || isViewerAidRoot(child) || isEdgeOverlay(child)) continue;
1840
+ out.push(child);
1841
+ collectPickable(child, out);
1842
+ }
1843
+ return out;
1844
+ }
1845
+ /**
1846
+ * Index-buffer ranges a merged mesh currently draws, or null when it draws everything. Hidden
1847
+ * members are the gaps between groups (`shared/merged-draw.ts`).
1848
+ */
1849
+ function drawnRanges(mesh) {
1850
+ const { groups } = mesh.geometry;
1851
+ if (!Array.isArray(mesh.material) || groups.length === 0) return null;
1852
+ const materials = mesh.material;
1853
+ return groups.filter((g) => materials[g.materialIndex ?? 0]?.visible !== false).map((g) => ({
1854
+ start: g.start,
1855
+ end: g.start + g.count
1856
+ }));
1857
+ }
1858
+ const _inverse = new THREE.Matrix4();
1859
+ const _localRay = new THREE.Ray();
1860
+ const _sphere = new THREE.Sphere();
1861
+ function raycastMeshBvh(mesh, bvh, raycaster) {
1862
+ _inverse.copy(mesh.matrixWorld).invert();
1863
+ _localRay.copy(raycaster.ray).applyMatrix4(_inverse);
1864
+ const ranges = drawnRanges(mesh);
1865
+ let best = null;
1866
+ if (!ranges) best = bvh.raycastFirst(_localRay, THREE.DoubleSide);
1867
+ else for (const hit of bvh.raycast(_localRay, THREE.DoubleSide)) {
1868
+ const at = (hit.faceIndex ?? 0) * 3;
1869
+ if (!ranges.some((r) => at >= r.start && at < r.end)) continue;
1870
+ if (!best || hit.distance < best.distance) best = hit;
1871
+ }
1872
+ if (!best) return null;
1873
+ best.point.applyMatrix4(mesh.matrixWorld);
1874
+ best.distance = best.point.distanceTo(raycaster.ray.origin);
1875
+ best.object = mesh;
1876
+ if (best.distance < raycaster.near || best.distance > raycaster.far) return null;
1877
+ return best;
1878
+ }
1879
+ /**
1880
+ * Every hit along `raycaster`'s ray over the visible content of `scene`, nearest first. Meshes use
1881
+ * the BVH; lines, points and instanced meshes fall back to their own `raycast`.
1882
+ */
1883
+ function raycastVisible(scene, raycaster) {
1884
+ const hits = [];
1885
+ for (const object of collectPickable(scene)) {
1886
+ if (!object.layers.test(raycaster.layers)) continue;
1887
+ if (isBvhMesh(object)) {
1888
+ const { geometry } = object;
1889
+ if (!geometry.boundingSphere) geometry.computeBoundingSphere();
1890
+ if (!geometry.boundingSphere) continue;
1891
+ _sphere.copy(geometry.boundingSphere).applyMatrix4(object.matrixWorld);
1892
+ if (!raycaster.ray.intersectsSphere(_sphere)) continue;
1893
+ const bvh = bvhFor(geometry);
1894
+ if (bvh) {
1895
+ const hit = raycastMeshBvh(object, bvh, raycaster);
1896
+ if (hit) hits.push(hit);
1897
+ continue;
1898
+ }
1899
+ }
1900
+ object.raycast(raycaster, hits);
1901
+ }
1902
+ return hits.sort((a, b) => a.distance - b.distance);
1903
+ }
1904
+ /**
1905
+ * Builds BVHs for the visible meshes under `root` one per idle slice, so the first hover over a
1906
+ * large model doesn't pay for all of them at once. Returns a cancel function.
1907
+ */
1908
+ function warmPickIndex(root) {
1909
+ const queue = collectPickable(root).filter(isBvhMesh);
1910
+ const idle = typeof requestIdleCallback === "function" ? (fn) => requestIdleCallback(fn, { timeout: 500 }) : (fn) => setTimeout(fn, 16);
1911
+ const cancelIdle = typeof cancelIdleCallback === "function" ? cancelIdleCallback : (id) => clearTimeout(id);
1912
+ let handle = 0;
1913
+ let cancelled = false;
1914
+ const step = () => {
1915
+ if (cancelled) return;
1916
+ const mesh = queue.shift();
1917
+ if (!mesh) return;
1918
+ bvhFor(mesh.geometry);
1919
+ handle = idle(step);
1920
+ };
1921
+ handle = idle(step);
1922
+ return () => {
1923
+ cancelled = true;
1924
+ cancelIdle(handle);
1925
+ };
1926
+ }
1796
1927
  // src/render/measure.ts
1797
1928
  const DEFAULT_SNAP_PIXELS = 12;
1798
1929
  const DEFAULT_COLOR = 16763904;
1799
1930
  const LINE_PICK_FRACTION = .015;
1931
+ const RENDER_ORDER_LINE = 998;
1932
+ const RENDER_ORDER_MARKER = 999;
1933
+ const RENDER_ORDER_HOVER = 1e3;
1800
1934
  const UNIT_DISPLAY = {
1801
1935
  Millimeters: {
1802
1936
  metersPerUnit: 1 / 1e3,
@@ -1819,10 +1953,33 @@ const UNIT_DISPLAY = {
1819
1953
  suffix: "ft"
1820
1954
  }
1821
1955
  };
1822
- /** @internal exported for tests */
1956
+ /**
1957
+ * Three significant figures below 1000, whole units above. `toPrecision(3)` alone switches to
1958
+ * exponent notation past 999 ("1.23e+4 mm"), which is unreadable on a building-scale model.
1959
+ *
1960
+ * @internal exported for tests
1961
+ */
1823
1962
  function makeFormatter(displayUnit) {
1824
1963
  const unit = displayUnit && UNIT_DISPLAY[displayUnit] || UNIT_DISPLAY.Meters;
1825
- return (meters) => `${(meters / unit.metersPerUnit).toPrecision(3)} ${unit.suffix}`;
1964
+ return (meters) => {
1965
+ const value = meters / unit.metersPerUnit;
1966
+ const abs = Math.abs(value);
1967
+ const decimals = abs === 0 ? 0 : Math.min(6, Math.max(0, 2 - Math.floor(Math.log10(abs))));
1968
+ return `${value.toFixed(decimals)} ${unit.suffix}`;
1969
+ };
1970
+ }
1971
+ /** @internal exported for tests */
1972
+ function makeDefaultFormat(fmt) {
1973
+ return (distance, delta) => {
1974
+ const eps = distance * 1e-6;
1975
+ const axes = [
1976
+ "x",
1977
+ "y",
1978
+ "z"
1979
+ ].filter((axis) => delta[axis] > eps);
1980
+ if (axes.length < 2) return fmt(distance);
1981
+ return `${fmt(distance)}\n${axes.map((axis) => `Δ${axis} ${fmt(delta[axis])}`).join(" ")}`;
1982
+ };
1826
1983
  }
1827
1984
  /**
1828
1985
  * Raycast threshold for picking lines/points, as a fixed fraction of view size so the grab band
@@ -1840,138 +1997,129 @@ function pickThreshold(camera, viewTarget) {
1840
1997
  return ((viewTarget ? camera.position.distanceTo(viewTarget) : camera.position.length()) || 1) * LINE_PICK_FRACTION;
1841
1998
  }
1842
1999
  /**
1843
- * Vertex indices to consider snapping to, by object type: Mesh → struck triangle's 3 vertices;
1844
- * Line/LineSegments → struck segment's 2 endpoints; Points → the struck vertex. Null when the hit
1845
- * carries no usable index (e.g. a fat `Line2`), so the caller keeps the raw hit point.
2000
+ * Local-space vertices to consider snapping to, by object type: Mesh → struck triangle's 3
2001
+ * vertices; Line/LineSegments and fat Line2/LineSegments2 → struck segment's 2 endpoints; Points →
2002
+ * the struck vertex. Null when the hit carries no usable index, so the caller keeps the raw point.
1846
2003
  */
1847
- function snapCandidateIndices(hit) {
2004
+ function snapCandidates(hit) {
1848
2005
  const obj = hit.object;
1849
- if (obj instanceof THREE.Mesh) return hit.face ? [
2006
+ const geometry = obj.geometry;
2007
+ if (!geometry) return null;
2008
+ if (obj.isLineSegments2) {
2009
+ const start = geometry.attributes.instanceStart;
2010
+ const end = geometry.attributes.instanceEnd;
2011
+ const i = hit.faceIndex;
2012
+ if (!start || !end || i == null || i >= start.count) return null;
2013
+ return [new THREE.Vector3().fromBufferAttribute(start, i), new THREE.Vector3().fromBufferAttribute(end, i)];
2014
+ }
2015
+ const pos = geometry.attributes.position;
2016
+ if (!pos) return null;
2017
+ const at = (indices) => indices.filter((i) => i < pos.count).map((i) => new THREE.Vector3().fromBufferAttribute(pos, i));
2018
+ if (obj instanceof THREE.Mesh) return hit.face ? at([
1850
2019
  hit.face.a,
1851
2020
  hit.face.b,
1852
2021
  hit.face.c
1853
- ] : null;
1854
- if (obj instanceof THREE.Points) return hit.index != null ? [hit.index] : null;
2022
+ ]) : null;
2023
+ if (obj instanceof THREE.Points) return hit.index != null ? at([hit.index]) : null;
1855
2024
  if (obj instanceof THREE.Line) {
1856
2025
  if (hit.index == null) return null;
1857
- const index = obj.geometry.index;
2026
+ const index = geometry.index;
1858
2027
  if (index) {
1859
2028
  if (hit.index + 1 >= index.count) return null;
1860
- return [index.getX(hit.index), index.getX(hit.index + 1)];
2029
+ return at([index.getX(hit.index), index.getX(hit.index + 1)]);
1861
2030
  }
1862
- return [hit.index, hit.index + 1];
2031
+ return at([hit.index, hit.index + 1]);
1863
2032
  }
1864
2033
  return null;
1865
2034
  }
1866
- /** Snap a raycast hit to the nearest geometry vertex within `snapPixels` on screen, else the raw hit point. */
1867
- function snapToVertex(hit, camera, screenSize, snapPixels) {
1868
- const raw = hit.point.clone();
1869
- const obj = hit.object;
1870
- const indices = snapCandidateIndices(hit);
1871
- if (!indices || !obj.geometry) return raw;
1872
- const pos = obj.geometry.attributes.position;
1873
- if (!pos) return raw;
1874
- const toScreen = (worldP) => {
1875
- const ndc = worldP.clone().project(camera);
1876
- return new THREE.Vector2((ndc.x + 1) / 2 * screenSize.width, (1 - ndc.y) / 2 * screenSize.height);
1877
- };
1878
- const rawScreen = toScreen(raw);
1879
- let best = raw;
2035
+ function toScreen(world, camera, screenSize) {
2036
+ const ndc = world.clone().project(camera);
2037
+ return new THREE.Vector2((ndc.x + 1) / 2 * screenSize.width, (1 - ndc.y) / 2 * screenSize.height);
2038
+ }
2039
+ /** Where a hit sits on screen: fat lines report the nearest point on the line separately. */
2040
+ function hitPoint(hit) {
2041
+ return hit.pointOnLine ?? hit.point;
2042
+ }
2043
+ function snapHit(hit, camera, screenSize, snapPixels) {
2044
+ const raw = hitPoint(hit).clone();
2045
+ const candidates = snapCandidates(hit);
2046
+ if (!candidates) return {
2047
+ point: raw,
2048
+ snapped: false
2049
+ };
2050
+ const rawScreen = toScreen(raw, camera, screenSize);
2051
+ let best = {
2052
+ point: raw,
2053
+ snapped: false
2054
+ };
1880
2055
  let bestPx = snapPixels;
1881
- for (const idx of indices) {
1882
- if (idx >= pos.count) continue;
1883
- const world = new THREE.Vector3().fromBufferAttribute(pos, idx).applyMatrix4(obj.matrixWorld);
1884
- const px = toScreen(world).distanceTo(rawScreen);
2056
+ for (const local of candidates) {
2057
+ const world = local.applyMatrix4(hit.object.matrixWorld);
2058
+ const px = toScreen(world, camera, screenSize).distanceTo(rawScreen);
1885
2059
  if (px < bestPx) {
1886
2060
  bestPx = px;
1887
- best = world;
2061
+ best = {
2062
+ point: world,
2063
+ snapped: true
2064
+ };
1888
2065
  }
1889
2066
  }
1890
2067
  return best;
1891
2068
  }
2069
+ /** Snap a raycast hit to the nearest geometry vertex within `snapPixels` on screen, else the raw hit point. */
2070
+ function snapToVertex(hit, camera, screenSize, snapPixels) {
2071
+ return snapHit(hit, camera, screenSize, snapPixels).point;
2072
+ }
1892
2073
  function createMeasureTool(deps) {
1893
2074
  const { canvas, scene, getActiveCamera, getViewTarget, labelLayer, options = {} } = deps;
2075
+ const requestRender = deps.requestRender ?? (() => {});
1894
2076
  const snapPixels = options.snapPixels ?? DEFAULT_SNAP_PIXELS;
1895
2077
  const color = new THREE.Color(options.color ?? DEFAULT_COLOR);
1896
- const fmt = makeFormatter(options.displayUnit);
1897
- const defaultFormat = (d, delta) => `${fmt(d)}\nΔx ${fmt(delta.x)} Δy ${fmt(delta.y)} Δz ${fmt(delta.z)}`;
1898
- const format = options.format ?? defaultFormat;
2078
+ const format = options.format ?? makeDefaultFormat(makeFormatter(options.displayUnit));
1899
2079
  const raycaster = new THREE.Raycaster();
1900
2080
  const pointer = new THREE.Vector2();
1901
2081
  let enabled = false;
1902
- const points = [];
1903
- const markers = [];
1904
- let line = null;
1905
- let label = null;
2082
+ let start = null;
2083
+ const measurements = [];
1906
2084
  const markerMaterial = new THREE.PointsMaterial({
1907
2085
  color,
1908
2086
  size: 8,
1909
2087
  sizeAttenuation: false,
1910
2088
  depthTest: false
1911
2089
  });
1912
- const hoverMaterial = new THREE.PointsMaterial({
2090
+ const hoverSnappedMaterial = new THREE.PointsMaterial({
1913
2091
  color,
1914
- size: 11,
2092
+ size: 12,
2093
+ sizeAttenuation: false,
2094
+ depthTest: false
2095
+ });
2096
+ const hoverFreeMaterial = new THREE.PointsMaterial({
2097
+ color,
2098
+ size: 7,
1915
2099
  sizeAttenuation: false,
1916
2100
  depthTest: false,
1917
2101
  transparent: true,
1918
- opacity: .5
2102
+ opacity: .6
1919
2103
  });
1920
- let hoverMarker = null;
1921
- const showHover = (p) => {
1922
- if (!p) {
1923
- if (hoverMarker) hoverMarker.visible = false;
1924
- return;
1925
- }
1926
- if (!hoverMarker) {
1927
- const geometry = new THREE.BufferGeometry();
1928
- geometry.setAttribute("position", new THREE.Float32BufferAttribute([
1929
- 0,
1930
- 0,
1931
- 0
1932
- ], 3));
1933
- hoverMarker = new THREE.Points(geometry, hoverMaterial);
1934
- hoverMarker.renderOrder = 1e3;
1935
- hoverMarker.userData.id = "measure";
1936
- hoverMarker.raycast = () => {};
1937
- scene.add(hoverMarker);
1938
- }
1939
- hoverMarker.position.copy(p);
1940
- hoverMarker.visible = true;
1941
- };
1942
- const makeMarker = (p) => {
2104
+ const tag = (object, renderOrder) => {
2105
+ object.renderOrder = renderOrder;
2106
+ object.userData.id = "measure";
2107
+ object.raycast = () => {};
2108
+ object.frustumCulled = false;
2109
+ return object;
2110
+ };
2111
+ const makePoints = (...ps) => {
1943
2112
  const geometry = new THREE.BufferGeometry();
1944
- geometry.setAttribute("position", new THREE.Float32BufferAttribute([
2113
+ geometry.setAttribute("position", new THREE.Float32BufferAttribute(ps.flatMap((p) => [
1945
2114
  p.x,
1946
2115
  p.y,
1947
2116
  p.z
1948
- ], 3));
1949
- const marker = new THREE.Points(geometry, markerMaterial);
1950
- marker.renderOrder = 999;
1951
- marker.userData.id = "measure";
1952
- marker.raycast = () => {};
1953
- scene.add(marker);
1954
- return marker;
1955
- };
1956
- const clear = () => {
1957
- points.length = 0;
1958
- markers.forEach((m) => {
1959
- m.geometry.dispose();
1960
- m.removeFromParent();
1961
- });
1962
- markers.length = 0;
1963
- if (line) {
1964
- line.geometry.dispose();
1965
- line.material.dispose();
1966
- line.removeFromParent();
1967
- line = null;
1968
- }
1969
- label?.remove();
1970
- label = null;
2117
+ ]), 3));
2118
+ const points = tag(new THREE.Points(geometry, markerMaterial), RENDER_ORDER_MARKER);
2119
+ scene.add(points);
2120
+ return points;
1971
2121
  };
1972
- const drawMeasurement = () => {
1973
- if (points.length !== 2) return;
1974
- const [a, b] = points;
2122
+ const makeLine = (a, b, opacity = 1) => {
1975
2123
  const geometry = new LineGeometry();
1976
2124
  geometry.setPositions([
1977
2125
  a.x,
@@ -1981,33 +2129,102 @@ function createMeasureTool(deps) {
1981
2129
  b.y,
1982
2130
  b.z
1983
2131
  ]);
1984
- const material = new LineMaterial({ color });
2132
+ const material = new LineMaterial({
2133
+ color,
2134
+ transparent: opacity < 1,
2135
+ opacity
2136
+ });
1985
2137
  material.linewidth = 2;
1986
2138
  material.depthTest = false;
1987
- line = new Line2(geometry, material);
1988
- line.renderOrder = 998;
1989
- line.userData.id = "measure";
1990
- line.raycast = () => {};
2139
+ const line = tag(new Line2(geometry, material), RENDER_ORDER_LINE);
1991
2140
  scene.add(line);
1992
- const mid = a.clone().add(b).multiplyScalar(.5);
2141
+ return line;
2142
+ };
2143
+ const moveLine = (line, a, b) => {
2144
+ const buffer = line.geometry.attributes.instanceStart.data;
2145
+ buffer.array.set([
2146
+ a.x,
2147
+ a.y,
2148
+ a.z,
2149
+ b.x,
2150
+ b.y,
2151
+ b.z
2152
+ ]);
2153
+ buffer.needsUpdate = true;
2154
+ };
2155
+ const disposeLine = (line) => {
2156
+ line.geometry.dispose();
2157
+ line.material.dispose();
2158
+ line.removeFromParent();
2159
+ };
2160
+ const disposePoints = (points) => {
2161
+ points.geometry.dispose();
2162
+ points.removeFromParent();
2163
+ };
2164
+ const labelText = (a, b) => {
1993
2165
  const delta = new THREE.Vector3(Math.abs(b.x - a.x), Math.abs(b.y - a.y), Math.abs(b.z - a.z));
1994
- label = labelLayer.addLabel(format(a.distanceTo(b), delta), mid, options.labelClassName);
2166
+ return format(a.distanceTo(b), delta);
1995
2167
  };
1996
- const pickPoint = (event) => {
2168
+ const midpoint = (a, b) => a.clone().add(b).multiplyScalar(.5);
2169
+ let hoverMarker = null;
2170
+ let rubberBand = null;
2171
+ let rubberLabel = null;
2172
+ const hidePreview = () => {
2173
+ if (hoverMarker) hoverMarker.visible = false;
2174
+ if (rubberBand) rubberBand.visible = false;
2175
+ rubberLabel?.remove();
2176
+ rubberLabel = null;
2177
+ };
2178
+ const showPreview = (snap) => {
2179
+ if (!snap) {
2180
+ hidePreview();
2181
+ return;
2182
+ }
2183
+ if (!hoverMarker) {
2184
+ hoverMarker = makePoints(new THREE.Vector3());
2185
+ hoverMarker.renderOrder = RENDER_ORDER_HOVER;
2186
+ }
2187
+ hoverMarker.material = snap.snapped ? hoverSnappedMaterial : hoverFreeMaterial;
2188
+ hoverMarker.position.copy(snap.point);
2189
+ hoverMarker.visible = true;
2190
+ if (!start) {
2191
+ if (rubberBand) rubberBand.visible = false;
2192
+ rubberLabel?.remove();
2193
+ rubberLabel = null;
2194
+ return;
2195
+ }
2196
+ if (!rubberBand) rubberBand = makeLine(start.point, snap.point, .7);
2197
+ moveLine(rubberBand, start.point, snap.point);
2198
+ rubberBand.visible = true;
2199
+ const text = labelText(start.point, snap.point);
2200
+ const mid = midpoint(start.point, snap.point);
2201
+ if (rubberLabel) {
2202
+ rubberLabel.setText(text);
2203
+ rubberLabel.setPosition(mid);
2204
+ } else rubberLabel = labelLayer.addLabel(text, mid, options.labelClassName);
2205
+ };
2206
+ const pick = (event) => {
1997
2207
  const rect = canvas.getBoundingClientRect();
1998
- pointer.x = (event.clientX - rect.left) / rect.width * 2 - 1;
1999
- pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
2208
+ const cursorX = event.clientX - rect.left;
2209
+ const cursorY = event.clientY - rect.top;
2210
+ pointer.x = cursorX / rect.width * 2 - 1;
2211
+ pointer.y = -(cursorY / rect.height) * 2 + 1;
2000
2212
  const camera = getActiveCamera();
2001
2213
  raycaster.setFromCamera(pointer, camera);
2002
- const threshold = pickThreshold(camera, getViewTarget?.());
2214
+ const threshold = pickThreshold(camera, getViewTarget?.()) * 2;
2003
2215
  raycaster.params.Line.threshold = threshold;
2004
2216
  raycaster.params.Points.threshold = threshold;
2005
- const hits = raycaster.intersectObjects(scene.children, true).filter((i) => i.object.userData.id !== "measure" && i.object.userData.id !== "grid");
2006
- if (hits.length === 0) return null;
2007
- return snapToVertex(hits[0], camera, {
2217
+ raycaster.params.Line2 = { threshold: snapPixels / 2 };
2218
+ const screen = {
2008
2219
  width: rect.width,
2009
2220
  height: rect.height
2010
- }, snapPixels);
2221
+ };
2222
+ const cursor = new THREE.Vector2(cursorX, cursorY);
2223
+ for (const hit of raycastVisible(scene, raycaster)) {
2224
+ if (!(hit.object.isMesh && hit.face != null) && toScreen(hitPoint(hit), camera, screen).distanceTo(cursor) > snapPixels) continue;
2225
+ return snapHit(hit, camera, screen, snapPixels);
2226
+ }
2227
+ return null;
2011
2228
  };
2012
2229
  let pendingMove = null;
2013
2230
  let moveRaf = 0;
@@ -2027,42 +2244,104 @@ function createMeasureTool(deps) {
2027
2244
  const latest = pendingMove;
2028
2245
  pendingMove = null;
2029
2246
  if (!enabled || !latest) return;
2030
- showHover(pickPoint(latest));
2247
+ showPreview(pick(latest));
2248
+ requestRender();
2031
2249
  });
2032
2250
  };
2251
+ const handleLeave = () => {
2252
+ cancelPendingMove();
2253
+ hidePreview();
2254
+ requestRender();
2255
+ };
2256
+ const dropStart = () => {
2257
+ if (!start) return;
2258
+ disposePoints(start.marker);
2259
+ start = null;
2260
+ };
2261
+ const clear = () => {
2262
+ dropStart();
2263
+ for (const m of measurements) {
2264
+ disposePoints(m.markers);
2265
+ disposeLine(m.line);
2266
+ m.label.remove();
2267
+ }
2268
+ measurements.length = 0;
2269
+ hidePreview();
2270
+ requestRender();
2271
+ };
2272
+ const cancel = () => {
2273
+ if (start) {
2274
+ dropStart();
2275
+ hidePreview();
2276
+ requestRender();
2277
+ } else clear();
2278
+ };
2033
2279
  const handleClick = (event) => {
2034
2280
  if (!enabled) return false;
2035
- if (points.length === 2) clear();
2036
- const point = pickPoint(event);
2037
- if (point === null) return true;
2038
- points.push(point);
2039
- markers.push(makeMarker(point));
2040
- if (points.length === 2) drawMeasurement();
2281
+ cancelPendingMove();
2282
+ const snap = pick(event);
2283
+ if (!snap) return true;
2284
+ if (!start) start = {
2285
+ point: snap.point,
2286
+ marker: makePoints(snap.point)
2287
+ };
2288
+ else if (!snap.point.equals(start.point)) {
2289
+ const a = start.point;
2290
+ const b = snap.point;
2291
+ dropStart();
2292
+ measurements.push({
2293
+ markers: makePoints(a, b),
2294
+ line: makeLine(a, b),
2295
+ label: labelLayer.addLabel(labelText(a, b), midpoint(a, b), options.labelClassName)
2296
+ });
2297
+ }
2298
+ showPreview(snap);
2299
+ requestRender();
2041
2300
  return true;
2042
2301
  };
2302
+ const handleKey = (event) => {
2303
+ if (event.key !== "Escape") return;
2304
+ if (event.target?.closest?.("input, textarea, select, [contenteditable]")) return;
2305
+ cancel();
2306
+ };
2307
+ let previousCursor = "";
2308
+ let cancelWarm = null;
2309
+ const setEnabled = (value) => {
2310
+ if (value === enabled) return;
2311
+ enabled = value;
2312
+ if (value) {
2313
+ previousCursor = canvas.style.cursor;
2314
+ canvas.style.cursor = "crosshair";
2315
+ canvas.addEventListener("mouseleave", handleLeave);
2316
+ window.addEventListener("keydown", handleKey);
2317
+ cancelWarm = warmPickIndex(scene);
2318
+ } else {
2319
+ canvas.style.cursor = previousCursor;
2320
+ canvas.removeEventListener("mouseleave", handleLeave);
2321
+ window.removeEventListener("keydown", handleKey);
2322
+ cancelWarm?.();
2323
+ cancelWarm = null;
2324
+ cancelPendingMove();
2325
+ clear();
2326
+ }
2327
+ };
2043
2328
  return {
2044
- setEnabled: (value) => {
2045
- enabled = value;
2046
- if (!value) {
2047
- cancelPendingMove();
2048
- clear();
2049
- showHover(null);
2050
- }
2051
- },
2329
+ setEnabled,
2052
2330
  isEnabled: () => enabled,
2053
2331
  handleClick,
2054
2332
  handleMove,
2333
+ cancel,
2055
2334
  clear,
2056
2335
  dispose: () => {
2057
- cancelPendingMove();
2336
+ setEnabled(false);
2058
2337
  clear();
2059
- if (hoverMarker) {
2060
- hoverMarker.geometry.dispose();
2061
- hoverMarker.removeFromParent();
2062
- hoverMarker = null;
2063
- }
2338
+ if (hoverMarker) disposePoints(hoverMarker);
2339
+ if (rubberBand) disposeLine(rubberBand);
2340
+ hoverMarker = null;
2341
+ rubberBand = null;
2064
2342
  markerMaterial.dispose();
2065
- hoverMaterial.dispose();
2343
+ hoverSnappedMaterial.dispose();
2344
+ hoverFreeMaterial.dispose();
2066
2345
  }
2067
2346
  };
2068
2347
  }
@@ -3894,6 +4173,8 @@ const initThree = function(canvas, options) {
3894
4173
  canvas,
3895
4174
  scene,
3896
4175
  getActiveCamera,
4176
+ getViewTarget: () => controls.target,
4177
+ requestRender: () => requestRender(),
3897
4178
  labelLayer,
3898
4179
  options: {
3899
4180
  snapPixels: config.measure.snapPixels,