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