@selvajs/visualization 1.6.0-beta.9 → 1.6.1-beta.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.
package/dist/render.js CHANGED
@@ -1,14 +1,14 @@
1
- import { _ as setLogger, a as computeCombinedBoundingBox, c as EMISSIVE_LIGHT, d as OWN_ENV_MAP_INTENSITY, f as lookEnvMapIntensity, g as getLogger, h as enableDebugLogging, l as LOOK_PRESETS, m as materialAppearanceForLook, p as lookPreset, r as publishMaxAnisotropy, s as DEFAULT_LOOK, t as disposeObjectTree, v as ErrorCodes, y as VisualizationError } from "./gpu-dispose-Cl3RXm-v.js";
1
+ import { S as VisualizationError, _ as materialAppearanceForLook, b as setLogger, c as computeCombinedBoundingBox, d as EMISSIVE_LIGHT, f as LOOK_PRESETS, g as lookPreset, h as lookEnvMapIntensity, m as OWN_ENV_MAP_INTENSITY, o as publishMaxAnisotropy, r as hideGlass, t as disposeObjectTree, u as DEFAULT_LOOK, v as enableDebugLogging, x as ErrorCodes, y as getLogger } from "./gpu-dispose-Di_nxeXF.js";
2
2
  import { n as cachedSourceOf } from "./gpu-ownership-Bs9l7hrU.js";
3
3
  import { a as onMemberDrawChange, i as getHiddenMembers, n as cloneForTint, r as getBaseMaterial, s as setMemberHighlight, t as clearMemberHighlight } from "./merged-draw-CO4rr2Co.js";
4
4
  import * as THREE from "three";
5
5
  import { Line2 } from "three/addons/lines/Line2.js";
6
6
  import { LineGeometry } from "three/addons/lines/LineGeometry.js";
7
7
  import { LineMaterial } from "three/addons/lines/LineMaterial.js";
8
- import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js";
9
8
  import { LineSegments2 } from "three/addons/lines/LineSegments2.js";
10
9
  import { LineSegmentsGeometry } from "three/addons/lines/LineSegmentsGeometry.js";
11
10
  import { CSS2DObject, CSS2DRenderer } from "three/addons/renderers/CSS2DRenderer.js";
11
+ import { MeshBVH } from "three-mesh-bvh";
12
12
  import { ViewHelper } from "three/addons/helpers/ViewHelper.js";
13
13
  import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
14
14
  import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
@@ -16,8 +16,8 @@ import { ShaderPass } from "three/addons/postprocessing/ShaderPass.js";
16
16
  import { GTAOPass } from "three/addons/postprocessing/GTAOPass.js";
17
17
  import { SMAAPass } from "three/addons/postprocessing/SMAAPass.js";
18
18
  import { OutputPass } from "three/addons/postprocessing/OutputPass.js";
19
- import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js";
20
19
  import { FullScreenQuad, Pass } from "three/addons/postprocessing/Pass.js";
20
+ import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js";
21
21
  import { OrbitControls } from "three/addons/controls/OrbitControls.js";
22
22
  import { HDRLoader } from "three/addons/loaders/HDRLoader.js";
23
23
  // src/shared/types.ts
@@ -66,8 +66,84 @@ function isOwnedBy(object, id) {
66
66
  if (id === void 0) return isHostOwned(object);
67
67
  return object.userData?.source === appSource(id);
68
68
  }
69
+ // src/render/scene-setup/studio-environment.ts
70
+ /** What a softbox costs to set up, in radiance: only their ratio to the room matters to a reflection. */
71
+ const OVERHEAD = 2.5;
72
+ const KEY_STRIP = 11;
73
+ const FILL_STRIP = 4.5;
74
+ const RIM = 3.5;
75
+ const FLOOR = .05;
76
+ const WALL_LOW = .06;
77
+ const WALL_HORIZON = .65;
78
+ const WALL_HIGH = .8;
79
+ const ROOM_SIZE = 10;
80
+ function softbox(radiance, width, height, position) {
81
+ const panel = new THREE.Mesh(new THREE.PlaneGeometry(width, height), new THREE.MeshBasicMaterial({
82
+ color: new THREE.Color().setScalar(radiance),
83
+ side: THREE.DoubleSide
84
+ }));
85
+ panel.position.set(...position);
86
+ panel.lookAt(0, 0, 0);
87
+ return panel;
88
+ }
89
+ /**
90
+ * A photographer's table, for PMREM to bake into a reflection probe: softboxes on a dark room.
91
+ * Shape comes from the gaps between lights. A surface that reflects light everywhere has no contour,
92
+ * which is why three's `RoomEnvironment`, bright on all sides, leaves metal looking like flat paint.
93
+ * The floor stays dark so an underside reads as turned away from the light.
94
+ */
95
+ function createStudioScene() {
96
+ const scene = new THREE.Scene();
97
+ const room = new THREE.Mesh(gradedRoom(), new THREE.MeshBasicMaterial({
98
+ vertexColors: true,
99
+ side: THREE.BackSide
100
+ }));
101
+ const floor = new THREE.Mesh(new THREE.PlaneGeometry(ROOM_SIZE, ROOM_SIZE), new THREE.MeshBasicMaterial({ color: new THREE.Color().setScalar(FLOOR) }));
102
+ floor.rotation.x = -Math.PI / 2;
103
+ floor.position.y = -4;
104
+ scene.add(room, floor, softbox(OVERHEAD, 6.5, 4.5, [
105
+ 0,
106
+ 4.8,
107
+ .6
108
+ ]), softbox(KEY_STRIP, 1.4, 5.5, [
109
+ -4.7,
110
+ .8,
111
+ 1.6
112
+ ]), softbox(FILL_STRIP, .9, 5, [
113
+ 4.7,
114
+ .5,
115
+ 2.4
116
+ ]), softbox(RIM, 5, .8, [
117
+ .6,
118
+ 2.2,
119
+ -4.7
120
+ ]));
121
+ return scene;
122
+ }
123
+ function gradedRoom() {
124
+ const geometry = new THREE.BoxGeometry(ROOM_SIZE, ROOM_SIZE, ROOM_SIZE, 1, 16, 1);
125
+ const position = geometry.attributes.position;
126
+ const colors = new Float32Array(position.count * 3);
127
+ for (let i = 0; i < position.count; i++) {
128
+ const t = position.getY(i) / (ROOM_SIZE / 2);
129
+ const radiance = t < 0 ? THREE.MathUtils.lerp(WALL_HORIZON, WALL_LOW, -t) : THREE.MathUtils.lerp(WALL_HORIZON, WALL_HIGH, t);
130
+ colors.fill(radiance, i * 3, i * 3 + 3);
131
+ }
132
+ geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
133
+ return geometry;
134
+ }
135
+ /** Frees what {@link createStudioScene} built, once PMREM has baked it. */
136
+ function disposeStudioScene(scene) {
137
+ scene.traverse((object) => {
138
+ const mesh = object;
139
+ mesh.geometry?.dispose();
140
+ mesh.material?.dispose();
141
+ });
142
+ }
69
143
  // src/render/scene-setup/reflection-environment.ts
70
144
  const METAL = .5;
145
+ /** Above PMREM's blur for a mirror, so a softbox has an edge in chrome without showing as a shape. */
146
+ const STUDIO_BLUR = .03;
71
147
  const KEY = "selvaReflectionEnvironment";
72
148
  /**
73
149
  * Builds the studio probe once per scene. `rotation` turns its Y-up room to the scene's up, the same
@@ -75,9 +151,9 @@ const KEY = "selvaReflectionEnvironment";
75
151
  */
76
152
  function createReflectionEnvironment(scene, renderer, rotation) {
77
153
  const pmrem = new THREE.PMREMGenerator(renderer);
78
- const room = new RoomEnvironment();
79
- const texture = pmrem.fromScene(room, .04).texture;
80
- room.dispose();
154
+ const studio = createStudioScene();
155
+ const texture = pmrem.fromScene(studio, STUDIO_BLUR).texture;
156
+ disposeStudioScene(studio);
81
157
  pmrem.dispose();
82
158
  installReflectionEnvironment(scene, texture, rotation);
83
159
  }
@@ -293,6 +369,8 @@ function applyDefaults(options) {
293
369
  toneMappingExposure: options.render?.toneMappingExposure ?? preset.toneMappingExposure,
294
370
  preserveDrawingBuffer: options.render?.preserveDrawingBuffer ?? false,
295
371
  lowPower,
372
+ idleRefineFrames: options.render?.idleRefineFrames ?? (lowPower ? 0 : 32),
373
+ lightOrbitFrameMs: options.render?.lightOrbitFrameMs ?? 22,
296
374
  ambientOcclusion: options.render?.ambientOcclusion ?? preset.ambientOcclusion,
297
375
  aoIntensity: options.render?.aoIntensity ?? 1,
298
376
  bloom: options.render?.bloom ?? preset.bloom,
@@ -355,6 +433,108 @@ function applyDefaults(options) {
355
433
  onMaxAnisotropy: options.onMaxAnisotropy
356
434
  };
357
435
  }
436
+ // src/render/scene-setup/idle-refine.ts
437
+ /** Long enough to skip the pauses inside an orbit, or a drag would keep restarting the average. */
438
+ const SETTLE_MS = 150;
439
+ /** Ambient occlusion is the largest cost of a frame, and the only one that can fade without a pop. */
440
+ const AO_FADE_FRAMES = 8;
441
+ /** Enough moving frames that one hitch can't decide it. */
442
+ const ORBIT_SAMPLES = 12;
443
+ /** Low-discrepancy sequence in [0, 1): N samples cover the range far more evenly than random ones. */
444
+ function halton(index, base) {
445
+ let result = 0;
446
+ let fraction = 1 / base;
447
+ for (let i = index; i > 0; i = Math.floor(i / base)) {
448
+ result += i % base * fraction;
449
+ fraction /= base;
450
+ }
451
+ return result;
452
+ }
453
+ /** Counts what a forgotten `invalidate()` most likely changed: visibility and materials. */
454
+ function sceneStamp(scene) {
455
+ let stamp = 0;
456
+ scene.traverseVisible((object) => {
457
+ stamp += 1;
458
+ const { material } = object;
459
+ for (const each of Array.isArray(material) ? material : material ? [material] : []) stamp += each.version * 31;
460
+ });
461
+ return stamp;
462
+ }
463
+ /**
464
+ * Softens the picture while nothing moves. Each frame moves the camera by under a pixel and every
465
+ * light within its real size, and the average of those frames has true penumbras and clean edges at
466
+ * no cost to orbiting. The averaged image would be lost to the render loop's safety repaint, so
467
+ * while it holds, that repaint only checks whether the scene changed.
468
+ *
469
+ * It also keeps orbiting smooth on a slow GPU. Once frames run over `lightOrbitFrameMs`, ambient
470
+ * occlusion fades out while the camera moves and back in as it rests. Decided once, so a machine
471
+ * near the limit doesn't flip between the two, and a fast one never sees it.
472
+ */
473
+ function createIdleRefiner(params) {
474
+ const { scene, renderer, getPipeline, frames, lightOrbitFrameMs, jitterLights, restoreLights } = params;
475
+ let index = 0;
476
+ let changedAt = 0;
477
+ let stamp = 0;
478
+ const size = new THREE.Vector2();
479
+ let lightOrbit = frames > 0 && lightOrbitFrameMs <= 0;
480
+ let aoWeight = 1;
481
+ const orbitSamples = [];
482
+ const setAoWeight = (weight) => {
483
+ aoWeight = Math.min(1, Math.max(0, weight));
484
+ getPipeline()?.setAoWeight(aoWeight);
485
+ };
486
+ const measureOrbit = (deltaMs) => {
487
+ orbitSamples.push(deltaMs);
488
+ if (orbitSamples.length < ORBIT_SAMPLES) return;
489
+ orbitSamples.sort((a, b) => a - b);
490
+ lightOrbit = orbitSamples[6] > lightOrbitFrameMs;
491
+ orbitSamples.length = 0;
492
+ };
493
+ const jitterCamera = (camera, sample) => {
494
+ const lens = camera;
495
+ if (!lens.setViewOffset) return;
496
+ renderer.getDrawingBufferSize(size);
497
+ const x = sample ? halton(sample, 2) - .5 : 0;
498
+ const y = sample ? halton(sample, 3) - .5 : 0;
499
+ lens.setViewOffset(size.x, size.y, x, y, size.x, size.y);
500
+ };
501
+ return {
502
+ reset: (now) => {
503
+ index = 0;
504
+ changedAt = now;
505
+ },
506
+ moving: (deltaMs) => {
507
+ if (frames === 0) return;
508
+ if (lightOrbit) setAoWeight(aoWeight - 1 / AO_FADE_FRAMES);
509
+ else measureOrbit(deltaMs);
510
+ },
511
+ wants: (now) => (index < frames || aoWeight < 1) && now - changedAt >= SETTLE_MS && !!getPipeline(),
512
+ step: (camera) => {
513
+ const pipeline = getPipeline();
514
+ if (!pipeline) return;
515
+ if (aoWeight < 1) {
516
+ setAoWeight(aoWeight + 1 / AO_FADE_FRAMES);
517
+ pipeline.setCamera(camera);
518
+ pipeline.accumulate(0);
519
+ return;
520
+ }
521
+ if (index > 0) jitterLights(index);
522
+ jitterCamera(camera, index);
523
+ pipeline.setCamera(camera);
524
+ pipeline.accumulate(index);
525
+ camera.clearViewOffset?.();
526
+ if (index > 0) restoreLights();
527
+ index += 1;
528
+ if (index === frames) stamp = sceneStamp(scene);
529
+ },
530
+ present: () => {
531
+ if (frames === 0 || index < frames) return false;
532
+ getPipeline()?.presentAccumulated();
533
+ return !!getPipeline();
534
+ },
535
+ isHolding: () => frames > 0 && index >= frames && !!getPipeline() && stamp === sceneStamp(scene)
536
+ };
537
+ }
358
538
  // src/render/scene-setup/setup-lighting.ts
359
539
  const SHADOW_NORMAL_BIAS = .02;
360
540
  /**
@@ -364,6 +544,11 @@ const SHADOW_NORMAL_BIAS = .02;
364
544
  const SUN_SHADOW_BLUR = 6;
365
545
  /** Sky and bounce always fill a shadow in, so a fully dark one looks painted on. */
366
546
  const SUN_SHADOW_DENSITY = .62;
547
+ /**
548
+ * How far the sun moves while the view refines, as a tangent: about 1 degree. The real sun's 0.27
549
+ * looks hard on a model this size, and much past 1 a small part's shadow dissolves completely.
550
+ */
551
+ const SUN_DISC_RADIUS = .018;
367
552
  /** Survives a solve with the sun, which would otherwise aim at a target outside the scene. */
368
553
  const SUN_TARGET_ID = "sun-target";
369
554
  function setupLighting(scene, config) {
@@ -409,6 +594,19 @@ function setupLighting(scene, config) {
409
594
  sun: sunlight
410
595
  };
411
596
  }
597
+ /** Moves the sun within its disc and returns how to put it back. */
598
+ function jitterSun(light, index) {
599
+ const home = light.position.clone();
600
+ const toSun = home.clone().sub(light.target.position);
601
+ const side = new THREE.Vector3(0, 0, 1).cross(toSun);
602
+ if (side.lengthSq() === 0) side.set(1, 0, 0);
603
+ side.setLength(toSun.length() * SUN_DISC_RADIUS);
604
+ const across = toSun.clone().cross(side).setLength(side.length());
605
+ const angle = halton(index, 2) * Math.PI * 2;
606
+ const reach = Math.sqrt(halton(index, 3));
607
+ light.position.addScaledVector(side, Math.cos(angle) * reach).addScaledVector(across, Math.sin(angle) * reach);
608
+ return () => light.position.copy(home);
609
+ }
412
610
  /**
413
611
  * Sizes a directional light's orthographic shadow frustum to the scene content's bounding sphere —
414
612
  * the dominant lever on shadow crispness. No-op on an empty or zero-size box: the frustum would
@@ -1610,10 +1808,143 @@ function createLabelLayer(container, scene) {
1610
1808
  }
1611
1809
  };
1612
1810
  }
1811
+ // src/render/scene-pick.ts
1812
+ const bvhCache = /* @__PURE__ */ new WeakMap();
1813
+ function versionOf$1(attribute) {
1814
+ return attribute?.version ?? 0;
1815
+ }
1816
+ /** BVH for a mesh geometry, building it on first use. Null when the geometry can't take one. */
1817
+ function bvhFor(geometry) {
1818
+ const position = geometry.attributes.position;
1819
+ if (!position || position.count === 0) return null;
1820
+ const index = geometry.index;
1821
+ const key = index ?? position;
1822
+ const cached = bvhCache.get(key);
1823
+ if (cached === null) return null;
1824
+ if (cached && cached.position === position && cached.positionVersion === versionOf$1(position) && cached.indexVersion === versionOf$1(index)) return cached.bvh;
1825
+ const proxy = new THREE.BufferGeometry();
1826
+ proxy.setAttribute("position", position);
1827
+ if (index) proxy.setIndex(index);
1828
+ try {
1829
+ const bvh = new MeshBVH(proxy, { indirect: true });
1830
+ bvhCache.set(key, {
1831
+ position,
1832
+ positionVersion: versionOf$1(position),
1833
+ indexVersion: versionOf$1(index),
1834
+ bvh
1835
+ });
1836
+ return bvh;
1837
+ } catch {
1838
+ bvhCache.set(key, null);
1839
+ return null;
1840
+ }
1841
+ }
1842
+ function isBvhMesh(object) {
1843
+ const o = object;
1844
+ return o.isMesh === true && !o.isInstancedMesh && !o.isSkinnedMesh && !o.isBatchedMesh && !o.isLineSegments2 && !object.morphTargetInfluences?.length;
1845
+ }
1846
+ /**
1847
+ * Visible, pickable objects under `root`: hidden subtrees, viewer aids and edge overlays are
1848
+ * skipped. Overlays are skipped because they double the mesh's own edges and, as fat lines, cost a
1849
+ * linear scan of every segment per ray.
1850
+ */
1851
+ function collectPickable(root, out = []) {
1852
+ for (const child of root.children) {
1853
+ if (!child.visible || isViewerAidRoot(child) || isEdgeOverlay(child)) continue;
1854
+ out.push(child);
1855
+ collectPickable(child, out);
1856
+ }
1857
+ return out;
1858
+ }
1859
+ /**
1860
+ * Index-buffer ranges a merged mesh currently draws, or null when it draws everything. Hidden
1861
+ * members are the gaps between groups (`shared/merged-draw.ts`).
1862
+ */
1863
+ function drawnRanges(mesh) {
1864
+ const { groups } = mesh.geometry;
1865
+ if (!Array.isArray(mesh.material) || groups.length === 0) return null;
1866
+ const materials = mesh.material;
1867
+ return groups.filter((g) => materials[g.materialIndex ?? 0]?.visible !== false).map((g) => ({
1868
+ start: g.start,
1869
+ end: g.start + g.count
1870
+ }));
1871
+ }
1872
+ const _inverse = new THREE.Matrix4();
1873
+ const _localRay = new THREE.Ray();
1874
+ const _sphere = new THREE.Sphere();
1875
+ function raycastMeshBvh(mesh, bvh, raycaster) {
1876
+ _inverse.copy(mesh.matrixWorld).invert();
1877
+ _localRay.copy(raycaster.ray).applyMatrix4(_inverse);
1878
+ const ranges = drawnRanges(mesh);
1879
+ let best = null;
1880
+ if (!ranges) best = bvh.raycastFirst(_localRay, THREE.DoubleSide);
1881
+ else for (const hit of bvh.raycast(_localRay, THREE.DoubleSide)) {
1882
+ const at = (hit.faceIndex ?? 0) * 3;
1883
+ if (!ranges.some((r) => at >= r.start && at < r.end)) continue;
1884
+ if (!best || hit.distance < best.distance) best = hit;
1885
+ }
1886
+ if (!best) return null;
1887
+ best.point.applyMatrix4(mesh.matrixWorld);
1888
+ best.distance = best.point.distanceTo(raycaster.ray.origin);
1889
+ best.object = mesh;
1890
+ if (best.distance < raycaster.near || best.distance > raycaster.far) return null;
1891
+ return best;
1892
+ }
1893
+ /**
1894
+ * Every hit along `raycaster`'s ray over the visible content of `scene`, nearest first. Meshes use
1895
+ * the BVH; lines, points and instanced meshes fall back to their own `raycast`.
1896
+ */
1897
+ function raycastVisible(scene, raycaster) {
1898
+ const hits = [];
1899
+ for (const object of collectPickable(scene)) {
1900
+ if (!object.layers.test(raycaster.layers)) continue;
1901
+ if (isBvhMesh(object)) {
1902
+ const { geometry } = object;
1903
+ if (!geometry.boundingSphere) geometry.computeBoundingSphere();
1904
+ if (!geometry.boundingSphere) continue;
1905
+ _sphere.copy(geometry.boundingSphere).applyMatrix4(object.matrixWorld);
1906
+ if (!raycaster.ray.intersectsSphere(_sphere)) continue;
1907
+ const bvh = bvhFor(geometry);
1908
+ if (bvh) {
1909
+ const hit = raycastMeshBvh(object, bvh, raycaster);
1910
+ if (hit) hits.push(hit);
1911
+ continue;
1912
+ }
1913
+ }
1914
+ object.raycast(raycaster, hits);
1915
+ }
1916
+ return hits.sort((a, b) => a.distance - b.distance);
1917
+ }
1918
+ /**
1919
+ * Builds BVHs for the visible meshes under `root` one per idle slice, so the first hover over a
1920
+ * large model doesn't pay for all of them at once. Returns a cancel function.
1921
+ */
1922
+ function warmPickIndex(root) {
1923
+ const queue = collectPickable(root).filter(isBvhMesh);
1924
+ const idle = typeof requestIdleCallback === "function" ? (fn) => requestIdleCallback(fn, { timeout: 500 }) : (fn) => setTimeout(fn, 16);
1925
+ const cancelIdle = typeof cancelIdleCallback === "function" ? cancelIdleCallback : (id) => clearTimeout(id);
1926
+ let handle = 0;
1927
+ let cancelled = false;
1928
+ const step = () => {
1929
+ if (cancelled) return;
1930
+ const mesh = queue.shift();
1931
+ if (!mesh) return;
1932
+ bvhFor(mesh.geometry);
1933
+ handle = idle(step);
1934
+ };
1935
+ handle = idle(step);
1936
+ return () => {
1937
+ cancelled = true;
1938
+ cancelIdle(handle);
1939
+ };
1940
+ }
1613
1941
  // src/render/measure.ts
1614
1942
  const DEFAULT_SNAP_PIXELS = 12;
1615
1943
  const DEFAULT_COLOR = 16763904;
1616
1944
  const LINE_PICK_FRACTION = .015;
1945
+ const RENDER_ORDER_LINE = 998;
1946
+ const RENDER_ORDER_MARKER = 999;
1947
+ const RENDER_ORDER_HOVER = 1e3;
1617
1948
  const UNIT_DISPLAY = {
1618
1949
  Millimeters: {
1619
1950
  metersPerUnit: 1 / 1e3,
@@ -1636,10 +1967,33 @@ const UNIT_DISPLAY = {
1636
1967
  suffix: "ft"
1637
1968
  }
1638
1969
  };
1639
- /** @internal exported for tests */
1970
+ /**
1971
+ * Three significant figures below 1000, whole units above. `toPrecision(3)` alone switches to
1972
+ * exponent notation past 999 ("1.23e+4 mm"), which is unreadable on a building-scale model.
1973
+ *
1974
+ * @internal exported for tests
1975
+ */
1640
1976
  function makeFormatter(displayUnit) {
1641
1977
  const unit = displayUnit && UNIT_DISPLAY[displayUnit] || UNIT_DISPLAY.Meters;
1642
- return (meters) => `${(meters / unit.metersPerUnit).toPrecision(3)} ${unit.suffix}`;
1978
+ return (meters) => {
1979
+ const value = meters / unit.metersPerUnit;
1980
+ const abs = Math.abs(value);
1981
+ const decimals = abs === 0 ? 0 : Math.min(6, Math.max(0, 2 - Math.floor(Math.log10(abs))));
1982
+ return `${value.toFixed(decimals)} ${unit.suffix}`;
1983
+ };
1984
+ }
1985
+ /** @internal exported for tests */
1986
+ function makeDefaultFormat(fmt) {
1987
+ return (distance, delta) => {
1988
+ const eps = distance * 1e-6;
1989
+ const axes = [
1990
+ "x",
1991
+ "y",
1992
+ "z"
1993
+ ].filter((axis) => delta[axis] > eps);
1994
+ if (axes.length < 2) return fmt(distance);
1995
+ return `${fmt(distance)}\n${axes.map((axis) => `Δ${axis} ${fmt(delta[axis])}`).join(" ")}`;
1996
+ };
1643
1997
  }
1644
1998
  /**
1645
1999
  * Raycast threshold for picking lines/points, as a fixed fraction of view size so the grab band
@@ -1657,138 +2011,129 @@ function pickThreshold(camera, viewTarget) {
1657
2011
  return ((viewTarget ? camera.position.distanceTo(viewTarget) : camera.position.length()) || 1) * LINE_PICK_FRACTION;
1658
2012
  }
1659
2013
  /**
1660
- * Vertex indices to consider snapping to, by object type: Mesh → struck triangle's 3 vertices;
1661
- * Line/LineSegments → struck segment's 2 endpoints; Points → the struck vertex. Null when the hit
1662
- * carries no usable index (e.g. a fat `Line2`), so the caller keeps the raw hit point.
2014
+ * Local-space vertices to consider snapping to, by object type: Mesh → struck triangle's 3
2015
+ * vertices; Line/LineSegments and fat Line2/LineSegments2 → struck segment's 2 endpoints; Points →
2016
+ * the struck vertex. Null when the hit carries no usable index, so the caller keeps the raw point.
1663
2017
  */
1664
- function snapCandidateIndices(hit) {
2018
+ function snapCandidates(hit) {
1665
2019
  const obj = hit.object;
1666
- if (obj instanceof THREE.Mesh) return hit.face ? [
2020
+ const geometry = obj.geometry;
2021
+ if (!geometry) return null;
2022
+ if (obj.isLineSegments2) {
2023
+ const start = geometry.attributes.instanceStart;
2024
+ const end = geometry.attributes.instanceEnd;
2025
+ const i = hit.faceIndex;
2026
+ if (!start || !end || i == null || i >= start.count) return null;
2027
+ return [new THREE.Vector3().fromBufferAttribute(start, i), new THREE.Vector3().fromBufferAttribute(end, i)];
2028
+ }
2029
+ const pos = geometry.attributes.position;
2030
+ if (!pos) return null;
2031
+ const at = (indices) => indices.filter((i) => i < pos.count).map((i) => new THREE.Vector3().fromBufferAttribute(pos, i));
2032
+ if (obj instanceof THREE.Mesh) return hit.face ? at([
1667
2033
  hit.face.a,
1668
2034
  hit.face.b,
1669
2035
  hit.face.c
1670
- ] : null;
1671
- if (obj instanceof THREE.Points) return hit.index != null ? [hit.index] : null;
2036
+ ]) : null;
2037
+ if (obj instanceof THREE.Points) return hit.index != null ? at([hit.index]) : null;
1672
2038
  if (obj instanceof THREE.Line) {
1673
2039
  if (hit.index == null) return null;
1674
- const index = obj.geometry.index;
2040
+ const index = geometry.index;
1675
2041
  if (index) {
1676
2042
  if (hit.index + 1 >= index.count) return null;
1677
- return [index.getX(hit.index), index.getX(hit.index + 1)];
2043
+ return at([index.getX(hit.index), index.getX(hit.index + 1)]);
1678
2044
  }
1679
- return [hit.index, hit.index + 1];
2045
+ return at([hit.index, hit.index + 1]);
1680
2046
  }
1681
2047
  return null;
1682
2048
  }
1683
- /** Snap a raycast hit to the nearest geometry vertex within `snapPixels` on screen, else the raw hit point. */
1684
- function snapToVertex(hit, camera, screenSize, snapPixels) {
1685
- const raw = hit.point.clone();
1686
- const obj = hit.object;
1687
- const indices = snapCandidateIndices(hit);
1688
- if (!indices || !obj.geometry) return raw;
1689
- const pos = obj.geometry.attributes.position;
1690
- if (!pos) return raw;
1691
- const toScreen = (worldP) => {
1692
- const ndc = worldP.clone().project(camera);
1693
- return new THREE.Vector2((ndc.x + 1) / 2 * screenSize.width, (1 - ndc.y) / 2 * screenSize.height);
1694
- };
1695
- const rawScreen = toScreen(raw);
1696
- let best = raw;
2049
+ function toScreen(world, camera, screenSize) {
2050
+ const ndc = world.clone().project(camera);
2051
+ return new THREE.Vector2((ndc.x + 1) / 2 * screenSize.width, (1 - ndc.y) / 2 * screenSize.height);
2052
+ }
2053
+ /** Where a hit sits on screen: fat lines report the nearest point on the line separately. */
2054
+ function hitPoint(hit) {
2055
+ return hit.pointOnLine ?? hit.point;
2056
+ }
2057
+ function snapHit(hit, camera, screenSize, snapPixels) {
2058
+ const raw = hitPoint(hit).clone();
2059
+ const candidates = snapCandidates(hit);
2060
+ if (!candidates) return {
2061
+ point: raw,
2062
+ snapped: false
2063
+ };
2064
+ const rawScreen = toScreen(raw, camera, screenSize);
2065
+ let best = {
2066
+ point: raw,
2067
+ snapped: false
2068
+ };
1697
2069
  let bestPx = snapPixels;
1698
- for (const idx of indices) {
1699
- if (idx >= pos.count) continue;
1700
- const world = new THREE.Vector3().fromBufferAttribute(pos, idx).applyMatrix4(obj.matrixWorld);
1701
- const px = toScreen(world).distanceTo(rawScreen);
2070
+ for (const local of candidates) {
2071
+ const world = local.applyMatrix4(hit.object.matrixWorld);
2072
+ const px = toScreen(world, camera, screenSize).distanceTo(rawScreen);
1702
2073
  if (px < bestPx) {
1703
2074
  bestPx = px;
1704
- best = world;
2075
+ best = {
2076
+ point: world,
2077
+ snapped: true
2078
+ };
1705
2079
  }
1706
2080
  }
1707
2081
  return best;
1708
2082
  }
2083
+ /** Snap a raycast hit to the nearest geometry vertex within `snapPixels` on screen, else the raw hit point. */
2084
+ function snapToVertex(hit, camera, screenSize, snapPixels) {
2085
+ return snapHit(hit, camera, screenSize, snapPixels).point;
2086
+ }
1709
2087
  function createMeasureTool(deps) {
1710
2088
  const { canvas, scene, getActiveCamera, getViewTarget, labelLayer, options = {} } = deps;
2089
+ const requestRender = deps.requestRender ?? (() => {});
1711
2090
  const snapPixels = options.snapPixels ?? DEFAULT_SNAP_PIXELS;
1712
2091
  const color = new THREE.Color(options.color ?? DEFAULT_COLOR);
1713
- const fmt = makeFormatter(options.displayUnit);
1714
- const defaultFormat = (d, delta) => `${fmt(d)}\nΔx ${fmt(delta.x)} Δy ${fmt(delta.y)} Δz ${fmt(delta.z)}`;
1715
- const format = options.format ?? defaultFormat;
2092
+ const format = options.format ?? makeDefaultFormat(makeFormatter(options.displayUnit));
1716
2093
  const raycaster = new THREE.Raycaster();
1717
2094
  const pointer = new THREE.Vector2();
1718
2095
  let enabled = false;
1719
- const points = [];
1720
- const markers = [];
1721
- let line = null;
1722
- let label = null;
2096
+ let start = null;
2097
+ const measurements = [];
1723
2098
  const markerMaterial = new THREE.PointsMaterial({
1724
2099
  color,
1725
2100
  size: 8,
1726
2101
  sizeAttenuation: false,
1727
2102
  depthTest: false
1728
2103
  });
1729
- const hoverMaterial = new THREE.PointsMaterial({
2104
+ const hoverSnappedMaterial = new THREE.PointsMaterial({
2105
+ color,
2106
+ size: 12,
2107
+ sizeAttenuation: false,
2108
+ depthTest: false
2109
+ });
2110
+ const hoverFreeMaterial = new THREE.PointsMaterial({
1730
2111
  color,
1731
- size: 11,
2112
+ size: 7,
1732
2113
  sizeAttenuation: false,
1733
2114
  depthTest: false,
1734
2115
  transparent: true,
1735
- opacity: .5
2116
+ opacity: .6
1736
2117
  });
1737
- let hoverMarker = null;
1738
- const showHover = (p) => {
1739
- if (!p) {
1740
- if (hoverMarker) hoverMarker.visible = false;
1741
- return;
1742
- }
1743
- if (!hoverMarker) {
1744
- const geometry = new THREE.BufferGeometry();
1745
- geometry.setAttribute("position", new THREE.Float32BufferAttribute([
1746
- 0,
1747
- 0,
1748
- 0
1749
- ], 3));
1750
- hoverMarker = new THREE.Points(geometry, hoverMaterial);
1751
- hoverMarker.renderOrder = 1e3;
1752
- hoverMarker.userData.id = "measure";
1753
- hoverMarker.raycast = () => {};
1754
- scene.add(hoverMarker);
1755
- }
1756
- hoverMarker.position.copy(p);
1757
- hoverMarker.visible = true;
2118
+ const tag = (object, renderOrder) => {
2119
+ object.renderOrder = renderOrder;
2120
+ object.userData.id = "measure";
2121
+ object.raycast = () => {};
2122
+ object.frustumCulled = false;
2123
+ return object;
1758
2124
  };
1759
- const makeMarker = (p) => {
2125
+ const makePoints = (...ps) => {
1760
2126
  const geometry = new THREE.BufferGeometry();
1761
- geometry.setAttribute("position", new THREE.Float32BufferAttribute([
2127
+ geometry.setAttribute("position", new THREE.Float32BufferAttribute(ps.flatMap((p) => [
1762
2128
  p.x,
1763
2129
  p.y,
1764
2130
  p.z
1765
- ], 3));
1766
- const marker = new THREE.Points(geometry, markerMaterial);
1767
- marker.renderOrder = 999;
1768
- marker.userData.id = "measure";
1769
- marker.raycast = () => {};
1770
- scene.add(marker);
1771
- return marker;
2131
+ ]), 3));
2132
+ const points = tag(new THREE.Points(geometry, markerMaterial), RENDER_ORDER_MARKER);
2133
+ scene.add(points);
2134
+ return points;
1772
2135
  };
1773
- const clear = () => {
1774
- points.length = 0;
1775
- markers.forEach((m) => {
1776
- m.geometry.dispose();
1777
- m.removeFromParent();
1778
- });
1779
- markers.length = 0;
1780
- if (line) {
1781
- line.geometry.dispose();
1782
- line.material.dispose();
1783
- line.removeFromParent();
1784
- line = null;
1785
- }
1786
- label?.remove();
1787
- label = null;
1788
- };
1789
- const drawMeasurement = () => {
1790
- if (points.length !== 2) return;
1791
- const [a, b] = points;
2136
+ const makeLine = (a, b, opacity = 1) => {
1792
2137
  const geometry = new LineGeometry();
1793
2138
  geometry.setPositions([
1794
2139
  a.x,
@@ -1798,33 +2143,102 @@ function createMeasureTool(deps) {
1798
2143
  b.y,
1799
2144
  b.z
1800
2145
  ]);
1801
- const material = new LineMaterial({ color });
2146
+ const material = new LineMaterial({
2147
+ color,
2148
+ transparent: opacity < 1,
2149
+ opacity
2150
+ });
1802
2151
  material.linewidth = 2;
1803
2152
  material.depthTest = false;
1804
- line = new Line2(geometry, material);
1805
- line.renderOrder = 998;
1806
- line.userData.id = "measure";
1807
- line.raycast = () => {};
2153
+ const line = tag(new Line2(geometry, material), RENDER_ORDER_LINE);
1808
2154
  scene.add(line);
1809
- const mid = a.clone().add(b).multiplyScalar(.5);
2155
+ return line;
2156
+ };
2157
+ const moveLine = (line, a, b) => {
2158
+ const buffer = line.geometry.attributes.instanceStart.data;
2159
+ buffer.array.set([
2160
+ a.x,
2161
+ a.y,
2162
+ a.z,
2163
+ b.x,
2164
+ b.y,
2165
+ b.z
2166
+ ]);
2167
+ buffer.needsUpdate = true;
2168
+ };
2169
+ const disposeLine = (line) => {
2170
+ line.geometry.dispose();
2171
+ line.material.dispose();
2172
+ line.removeFromParent();
2173
+ };
2174
+ const disposePoints = (points) => {
2175
+ points.geometry.dispose();
2176
+ points.removeFromParent();
2177
+ };
2178
+ const labelText = (a, b) => {
1810
2179
  const delta = new THREE.Vector3(Math.abs(b.x - a.x), Math.abs(b.y - a.y), Math.abs(b.z - a.z));
1811
- label = labelLayer.addLabel(format(a.distanceTo(b), delta), mid, options.labelClassName);
2180
+ return format(a.distanceTo(b), delta);
2181
+ };
2182
+ const midpoint = (a, b) => a.clone().add(b).multiplyScalar(.5);
2183
+ let hoverMarker = null;
2184
+ let rubberBand = null;
2185
+ let rubberLabel = null;
2186
+ const hidePreview = () => {
2187
+ if (hoverMarker) hoverMarker.visible = false;
2188
+ if (rubberBand) rubberBand.visible = false;
2189
+ rubberLabel?.remove();
2190
+ rubberLabel = null;
1812
2191
  };
1813
- const pickPoint = (event) => {
2192
+ const showPreview = (snap) => {
2193
+ if (!snap) {
2194
+ hidePreview();
2195
+ return;
2196
+ }
2197
+ if (!hoverMarker) {
2198
+ hoverMarker = makePoints(new THREE.Vector3());
2199
+ hoverMarker.renderOrder = RENDER_ORDER_HOVER;
2200
+ }
2201
+ hoverMarker.material = snap.snapped ? hoverSnappedMaterial : hoverFreeMaterial;
2202
+ hoverMarker.position.copy(snap.point);
2203
+ hoverMarker.visible = true;
2204
+ if (!start) {
2205
+ if (rubberBand) rubberBand.visible = false;
2206
+ rubberLabel?.remove();
2207
+ rubberLabel = null;
2208
+ return;
2209
+ }
2210
+ if (!rubberBand) rubberBand = makeLine(start.point, snap.point, .7);
2211
+ moveLine(rubberBand, start.point, snap.point);
2212
+ rubberBand.visible = true;
2213
+ const text = labelText(start.point, snap.point);
2214
+ const mid = midpoint(start.point, snap.point);
2215
+ if (rubberLabel) {
2216
+ rubberLabel.setText(text);
2217
+ rubberLabel.setPosition(mid);
2218
+ } else rubberLabel = labelLayer.addLabel(text, mid, options.labelClassName);
2219
+ };
2220
+ const pick = (event) => {
1814
2221
  const rect = canvas.getBoundingClientRect();
1815
- pointer.x = (event.clientX - rect.left) / rect.width * 2 - 1;
1816
- pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
2222
+ const cursorX = event.clientX - rect.left;
2223
+ const cursorY = event.clientY - rect.top;
2224
+ pointer.x = cursorX / rect.width * 2 - 1;
2225
+ pointer.y = -(cursorY / rect.height) * 2 + 1;
1817
2226
  const camera = getActiveCamera();
1818
2227
  raycaster.setFromCamera(pointer, camera);
1819
- const threshold = pickThreshold(camera, getViewTarget?.());
2228
+ const threshold = pickThreshold(camera, getViewTarget?.()) * 2;
1820
2229
  raycaster.params.Line.threshold = threshold;
1821
2230
  raycaster.params.Points.threshold = threshold;
1822
- const hits = raycaster.intersectObjects(scene.children, true).filter((i) => i.object.userData.id !== "measure" && i.object.userData.id !== "grid");
1823
- if (hits.length === 0) return null;
1824
- return snapToVertex(hits[0], camera, {
2231
+ raycaster.params.Line2 = { threshold: snapPixels / 2 };
2232
+ const screen = {
1825
2233
  width: rect.width,
1826
2234
  height: rect.height
1827
- }, snapPixels);
2235
+ };
2236
+ const cursor = new THREE.Vector2(cursorX, cursorY);
2237
+ for (const hit of raycastVisible(scene, raycaster)) {
2238
+ if (!(hit.object.isMesh && hit.face != null) && toScreen(hitPoint(hit), camera, screen).distanceTo(cursor) > snapPixels) continue;
2239
+ return snapHit(hit, camera, screen, snapPixels);
2240
+ }
2241
+ return null;
1828
2242
  };
1829
2243
  let pendingMove = null;
1830
2244
  let moveRaf = 0;
@@ -1844,42 +2258,104 @@ function createMeasureTool(deps) {
1844
2258
  const latest = pendingMove;
1845
2259
  pendingMove = null;
1846
2260
  if (!enabled || !latest) return;
1847
- showHover(pickPoint(latest));
2261
+ showPreview(pick(latest));
2262
+ requestRender();
1848
2263
  });
1849
2264
  };
2265
+ const handleLeave = () => {
2266
+ cancelPendingMove();
2267
+ hidePreview();
2268
+ requestRender();
2269
+ };
2270
+ const dropStart = () => {
2271
+ if (!start) return;
2272
+ disposePoints(start.marker);
2273
+ start = null;
2274
+ };
2275
+ const clear = () => {
2276
+ dropStart();
2277
+ for (const m of measurements) {
2278
+ disposePoints(m.markers);
2279
+ disposeLine(m.line);
2280
+ m.label.remove();
2281
+ }
2282
+ measurements.length = 0;
2283
+ hidePreview();
2284
+ requestRender();
2285
+ };
2286
+ const cancel = () => {
2287
+ if (start) {
2288
+ dropStart();
2289
+ hidePreview();
2290
+ requestRender();
2291
+ } else clear();
2292
+ };
1850
2293
  const handleClick = (event) => {
1851
2294
  if (!enabled) return false;
1852
- if (points.length === 2) clear();
1853
- const point = pickPoint(event);
1854
- if (point === null) return true;
1855
- points.push(point);
1856
- markers.push(makeMarker(point));
1857
- if (points.length === 2) drawMeasurement();
2295
+ cancelPendingMove();
2296
+ const snap = pick(event);
2297
+ if (!snap) return true;
2298
+ if (!start) start = {
2299
+ point: snap.point,
2300
+ marker: makePoints(snap.point)
2301
+ };
2302
+ else if (!snap.point.equals(start.point)) {
2303
+ const a = start.point;
2304
+ const b = snap.point;
2305
+ dropStart();
2306
+ measurements.push({
2307
+ markers: makePoints(a, b),
2308
+ line: makeLine(a, b),
2309
+ label: labelLayer.addLabel(labelText(a, b), midpoint(a, b), options.labelClassName)
2310
+ });
2311
+ }
2312
+ showPreview(snap);
2313
+ requestRender();
1858
2314
  return true;
1859
2315
  };
2316
+ const handleKey = (event) => {
2317
+ if (event.key !== "Escape") return;
2318
+ if (event.target?.closest?.("input, textarea, select, [contenteditable]")) return;
2319
+ cancel();
2320
+ };
2321
+ let previousCursor = "";
2322
+ let cancelWarm = null;
2323
+ const setEnabled = (value) => {
2324
+ if (value === enabled) return;
2325
+ enabled = value;
2326
+ if (value) {
2327
+ previousCursor = canvas.style.cursor;
2328
+ canvas.style.cursor = "crosshair";
2329
+ canvas.addEventListener("mouseleave", handleLeave);
2330
+ window.addEventListener("keydown", handleKey);
2331
+ cancelWarm = warmPickIndex(scene);
2332
+ } else {
2333
+ canvas.style.cursor = previousCursor;
2334
+ canvas.removeEventListener("mouseleave", handleLeave);
2335
+ window.removeEventListener("keydown", handleKey);
2336
+ cancelWarm?.();
2337
+ cancelWarm = null;
2338
+ cancelPendingMove();
2339
+ clear();
2340
+ }
2341
+ };
1860
2342
  return {
1861
- setEnabled: (value) => {
1862
- enabled = value;
1863
- if (!value) {
1864
- cancelPendingMove();
1865
- clear();
1866
- showHover(null);
1867
- }
1868
- },
2343
+ setEnabled,
1869
2344
  isEnabled: () => enabled,
1870
2345
  handleClick,
1871
2346
  handleMove,
2347
+ cancel,
1872
2348
  clear,
1873
2349
  dispose: () => {
1874
- cancelPendingMove();
2350
+ setEnabled(false);
1875
2351
  clear();
1876
- if (hoverMarker) {
1877
- hoverMarker.geometry.dispose();
1878
- hoverMarker.removeFromParent();
1879
- hoverMarker = null;
1880
- }
2352
+ if (hoverMarker) disposePoints(hoverMarker);
2353
+ if (rubberBand) disposeLine(rubberBand);
2354
+ hoverMarker = null;
2355
+ rubberBand = null;
1881
2356
  markerMaterial.dispose();
1882
- hoverMaterial.dispose();
2357
+ hoverSnappedMaterial.dispose();
2358
+ hoverFreeMaterial.dispose();
1883
2359
  }
1884
2360
  };
1885
2361
  }
@@ -2302,7 +2778,7 @@ function createViewGizmo(deps) {
2302
2778
  };
2303
2779
  }
2304
2780
  // src/render/scene-setup/animation-loop.ts
2305
- function createAnimationLoop(renderer, scene, camera, getActiveCamera, cameraController, controls, getCanvasSize, getPixelRatio, onFrame, grid, gizmo, getRenderPipeline, labelLayer, nearFitter, onDemand = true) {
2781
+ function createAnimationLoop(renderer, scene, camera, getActiveCamera, cameraController, controls, getCanvasSize, getPixelRatio, onFrame, grid, gizmo, getRenderPipeline, labelLayer, nearFitter, onDemand = true, refiner) {
2306
2782
  let animationId = null;
2307
2783
  let lastTime = performance.now();
2308
2784
  let renderRequested = true;
@@ -2348,17 +2824,22 @@ function createAnimationLoop(renderer, scene, camera, getActiveCamera, cameraCon
2348
2824
  invalidate();
2349
2825
  }
2350
2826
  };
2827
+ const drawOverlays = (activeCamera) => {
2828
+ if (labelLayer) labelLayer.render(scene, activeCamera);
2829
+ if (gizmo) gizmo.render(renderer);
2830
+ };
2351
2831
  const drawFrame = (activeCamera, delta) => {
2352
2832
  const renderPipeline = getRenderPipeline?.();
2353
2833
  if (renderPipeline) {
2354
2834
  renderPipeline.setCamera(activeCamera);
2355
2835
  renderPipeline.render(delta);
2356
2836
  } else renderer.render(scene, activeCamera);
2357
- if (labelLayer) labelLayer.render(scene, activeCamera);
2358
- if (gizmo) gizmo.render(renderer);
2837
+ drawOverlays(activeCamera);
2359
2838
  };
2360
2839
  const renderNow = () => {
2361
- drawFrame(getActiveCamera(), 0);
2840
+ const activeCamera = getActiveCamera();
2841
+ if (refiner?.present()) drawOverlays(activeCamera);
2842
+ else drawFrame(activeCamera, 0);
2362
2843
  };
2363
2844
  const animate = function() {
2364
2845
  animationId = requestAnimationFrame(animate);
@@ -2372,9 +2853,20 @@ function createAnimationLoop(renderer, scene, camera, getActiveCamera, cameraCon
2372
2853
  onFrame?.(delta);
2373
2854
  const activeCamera = getActiveCamera();
2374
2855
  if (onDemand) {
2375
- if (!(renderRequested || cameraMoved(activeCamera) || now - lastRenderTime >= IDLE_REPAINT_INTERVAL_MS)) return;
2376
- renderRequested = false;
2856
+ const moved = cameraMoved(activeCamera);
2857
+ if (moved) refiner?.moving(delta * 1e3);
2858
+ const changed = renderRequested || moved;
2859
+ if (!changed && refiner?.wants(now)) {
2860
+ refiner.step(activeCamera);
2861
+ drawOverlays(activeCamera);
2862
+ return;
2863
+ }
2864
+ const repaint = now - lastRenderTime >= IDLE_REPAINT_INTERVAL_MS;
2865
+ if (!changed && !repaint) return;
2377
2866
  lastRenderTime = now;
2867
+ if (!changed && refiner?.isHolding()) return;
2868
+ renderRequested = false;
2869
+ refiner?.reset(now);
2378
2870
  }
2379
2871
  drawFrame(activeCamera, delta);
2380
2872
  };
@@ -2491,6 +2983,7 @@ function pickLightSources(sources, maxLights = DEFAULT_MAX_LIGHTS) {
2491
2983
  function createEmissiveLights(scene) {
2492
2984
  const lights = [];
2493
2985
  const silenced = /* @__PURE__ */ new Set();
2986
+ const offset = new THREE.Vector3();
2494
2987
  const addLight = (shadowed) => {
2495
2988
  const key = new THREE.PointLight(16777215, 1, 0, 2);
2496
2989
  const bounce = new THREE.PointLight(16777215, 1, 0, 1);
@@ -2502,7 +2995,9 @@ function createEmissiveLights(scene) {
2502
2995
  }
2503
2996
  const pair = {
2504
2997
  key,
2505
- bounce
2998
+ bounce,
2999
+ home: new THREE.Vector3(),
3000
+ size: new THREE.Vector3()
2506
3001
  };
2507
3002
  lights.push(pair);
2508
3003
  scene.add(key, bounce);
@@ -2543,12 +3038,14 @@ function createEmissiveLights(scene) {
2543
3038
  };
2544
3039
  const sources = pickLightSources(sourcesFrom(parts, maxLights), maxLights);
2545
3040
  sources.forEach((source, index) => {
2546
- const { key, bounce } = lights[index] ?? addLight(index < MAX_SHADOWED_LIGHTS);
3041
+ const { key, bounce, home, size } = lights[index] ?? addLight(index < MAX_SHADOWED_LIGHTS);
2547
3042
  const intensity = source.light ?? source.strength * INTENSITY_PER_STRENGTH * radius * radius;
2548
3043
  key.color.copy(source.color);
2549
3044
  const spread = source.bounds.getSize(new THREE.Vector3()).length() / 2 / radius;
2550
3045
  key.intensity = !source.light && spread > MAX_POINT_SOURCE_SPREAD ? 0 : intensity;
2551
3046
  source.bounds.getCenter(key.position);
3047
+ home.copy(key.position);
3048
+ source.bounds.getSize(size);
2552
3049
  bounce.color.copy(source.color);
2553
3050
  bounce.intensity = intensity * BOUNCE_SHARE / radius;
2554
3051
  bounce.position.copy(key.position);
@@ -2569,8 +3066,16 @@ function createEmissiveLights(scene) {
2569
3066
  }
2570
3067
  silence(parts, sources);
2571
3068
  };
3069
+ const place = (position) => {
3070
+ for (const { key, home, size } of lights) {
3071
+ key.position.copy(position(home, size));
3072
+ if (key.castShadow) key.shadow.needsUpdate = true;
3073
+ }
3074
+ };
2572
3075
  return {
2573
3076
  sync,
3077
+ jitter: (index) => place((home, size) => offset.set(halton(index, 2) - .5, halton(index, 3) - .5, halton(index, 5) - .5).multiply(size).add(home)),
3078
+ settle: () => place((home) => home),
2574
3079
  dispose: () => sync(false)
2575
3080
  };
2576
3081
  }
@@ -2748,6 +3253,8 @@ function createAppearanceController(params) {
2748
3253
  setAoIntensity,
2749
3254
  setLook,
2750
3255
  syncEmissiveLights,
3256
+ jitterEmissiveLights: emissiveLights.jitter,
3257
+ settleEmissiveLights: emissiveLights.settle,
2751
3258
  getMaterialAppearance: () => materialAppearanceForLook(activeLook)
2752
3259
  };
2753
3260
  }
@@ -2906,7 +3413,9 @@ var EdgeDetectionPass = class extends Pass {
2906
3413
  renderer.setClearColor(7829503, 1);
2907
3414
  renderer.autoClear = true;
2908
3415
  this.scene.overrideMaterial = this.normalMaterial;
3416
+ const showGlass = hideGlass(this.scene);
2909
3417
  renderer.render(this.scene, this.camera);
3418
+ showGlass();
2910
3419
  this.scene.overrideMaterial = previousOverride;
2911
3420
  renderer.setClearColor(previousClearColor, previousClearAlpha);
2912
3421
  renderer.autoClear = previousAutoClear;
@@ -2957,6 +3466,68 @@ const HDR_CLAMP_SHADER = {
2957
3466
  gl_FragColor = vec4(safe(color.r), safe(color.g), safe(color.b), color.a);
2958
3467
  }`
2959
3468
  };
3469
+ const AVERAGE_SHADER = {
3470
+ vertexShader: `
3471
+ varying vec2 vUv;
3472
+ void main() {
3473
+ vUv = uv;
3474
+ gl_Position = vec4(position.xy, 0.0, 1.0);
3475
+ }`,
3476
+ fragmentShader: `
3477
+ uniform sampler2D tFrame;
3478
+ varying vec2 vUv;
3479
+ void main() {
3480
+ gl_FragColor = texture2D(tFrame, vUv);
3481
+ }`
3482
+ };
3483
+ /**
3484
+ * Keeps the running average of the composer's finished frames. They're already tone mapped, which
3485
+ * suits what gets averaged here: shadows and edges, not HDR highlights. Half float, because 32
3486
+ * frames blended into 8 bits would band in a soft shadow.
3487
+ */
3488
+ function createAccumulator(renderer) {
3489
+ const target = new THREE.WebGLRenderTarget(1, 1, {
3490
+ type: THREE.HalfFloatType,
3491
+ depthBuffer: false
3492
+ });
3493
+ const material = new THREE.ShaderMaterial({
3494
+ ...AVERAGE_SHADER,
3495
+ uniforms: { tFrame: { value: null } },
3496
+ depthTest: false,
3497
+ depthWrite: false,
3498
+ toneMapped: false,
3499
+ blending: THREE.CustomBlending,
3500
+ blendSrc: THREE.ConstantAlphaFactor,
3501
+ blendDst: THREE.OneMinusConstantAlphaFactor
3502
+ });
3503
+ const quad = new FullScreenQuad(material);
3504
+ const draw = (texture, weight) => {
3505
+ material.uniforms.tFrame.value = texture;
3506
+ material.blendAlpha = weight;
3507
+ const autoClear = renderer.autoClear;
3508
+ renderer.autoClear = false;
3509
+ quad.render(renderer);
3510
+ renderer.autoClear = autoClear;
3511
+ };
3512
+ const present = () => {
3513
+ renderer.setRenderTarget(null);
3514
+ draw(target.texture, 1);
3515
+ };
3516
+ return {
3517
+ add: (frame, index, width, height) => {
3518
+ if (target.width !== width || target.height !== height) target.setSize(width, height);
3519
+ renderer.setRenderTarget(target);
3520
+ draw(frame, 1 / (index + 1));
3521
+ present();
3522
+ },
3523
+ present,
3524
+ dispose: () => {
3525
+ target.dispose();
3526
+ material.dispose();
3527
+ quad.dispose();
3528
+ }
3529
+ };
3530
+ }
2960
3531
  /**
2961
3532
  * Without MSAA a sub-pixel feature (a sheet's bright hem edge) comes out as a dashed line, which
2962
3533
  * SMAA can't rebuild. At 2× DPR each CSS pixel already holds four samples, and 4× MSAA on top
@@ -2965,6 +3536,20 @@ const HDR_CLAMP_SHADER = {
2965
3536
  function msaaSamplesFor(pixelRatio) {
2966
3537
  return pixelRatio >= 2 ? 0 : 4;
2967
3538
  }
3539
+ function skipGlass(pass) {
3540
+ const hooks = pass;
3541
+ const overrideVisibility = hooks._overrideVisibility.bind(pass);
3542
+ const restoreVisibility = hooks._restoreVisibility.bind(pass);
3543
+ let showGlass = () => {};
3544
+ hooks._overrideVisibility = () => {
3545
+ overrideVisibility();
3546
+ showGlass = hideGlass(pass.scene);
3547
+ };
3548
+ hooks._restoreVisibility = () => {
3549
+ showGlass();
3550
+ restoreVisibility();
3551
+ };
3552
+ }
2968
3553
  function createRenderPipeline(renderer, scene, camera, width, height, options) {
2969
3554
  const sceneTarget = new THREE.WebGLRenderTarget(width, height, { type: THREE.HalfFloatType });
2970
3555
  const composer = new EffectComposer(renderer, sceneTarget);
@@ -2981,6 +3566,7 @@ function createRenderPipeline(renderer, scene, camera, width, height, options) {
2981
3566
  gtaoPass = new GTAOPass(scene, camera, width, height);
2982
3567
  gtaoPass.blendIntensity = options.aoIntensity ?? 1;
2983
3568
  gtaoPass.updateGtaoMaterial({ screenSpaceRadius: true });
3569
+ skipGlass(gtaoPass);
2984
3570
  composer.addPass(gtaoPass);
2985
3571
  }
2986
3572
  let clampPass = null;
@@ -3001,6 +3587,7 @@ function createRenderPipeline(renderer, scene, camera, width, height, options) {
3001
3587
  composer.addPass(outputPass);
3002
3588
  renderer.toneMapping = options.toneMapping;
3003
3589
  renderer.toneMappingExposure = options.toneMappingExposure;
3590
+ let accumulator = null;
3004
3591
  const aoPixelRatioCap = options.aoPixelRatio ?? 1;
3005
3592
  let aoScale = 1;
3006
3593
  if (gtaoPass) {
@@ -3039,8 +3626,23 @@ function createRenderPipeline(renderer, scene, camera, width, height, options) {
3039
3626
  edgePass.enabled = enabled;
3040
3627
  },
3041
3628
  edgeDetectionEnabled: () => edgePass.enabled,
3629
+ accumulate: (index) => {
3630
+ composer.renderToScreen = false;
3631
+ renderFrame(0);
3632
+ composer.renderToScreen = true;
3633
+ accumulator ??= createAccumulator(renderer);
3634
+ const { texture, width, height } = composer.readBuffer;
3635
+ accumulator.add(texture, index, width, height);
3636
+ },
3637
+ presentAccumulated: () => accumulator?.present(),
3638
+ setAoWeight: (weight) => {
3639
+ if (!gtaoPass) return;
3640
+ gtaoPass.enabled = weight > 0;
3641
+ gtaoPass.blendIntensity = (options.aoIntensity ?? 1) * weight;
3642
+ },
3042
3643
  dispose: () => {
3043
3644
  composer.dispose();
3645
+ accumulator?.dispose();
3044
3646
  gtaoPass?.dispose();
3045
3647
  clampPass?.dispose();
3046
3648
  bloomPass?.dispose();
@@ -3207,6 +3809,15 @@ const SELECTION_MIN_OPACITY = 1;
3207
3809
  * color blindness, so what tells hover from selected there is brightness.
3208
3810
  */
3209
3811
  const HOVER_COLOR = "#ffb020";
3812
+ /**
3813
+ * A fingertip covers far more than the pixel under its centre, so a touch that lands just beside a
3814
+ * thin curve or a narrow part still picks it. Rings are sampled only when the centre misses.
3815
+ */
3816
+ const TOUCH_PICK_RADII_PX = [8, 16];
3817
+ const TOUCH_PICK_DIRECTIONS = 8;
3818
+ /** Two taps closer than this in time and space are a double-tap. */
3819
+ const DOUBLE_TAP_MS = 300;
3820
+ const DOUBLE_TAP_SLOP_PX = 30;
3210
3821
  /** Linear RGB distance. Closer than this, a tint barely changes the surface. */
3211
3822
  const MIN_TINT_DISTANCE = .35;
3212
3823
  /** Taken by a surface already close to the tint. Blue stays distinct under every common deficiency. */
@@ -3246,7 +3857,7 @@ function tintForSelection(material, color, isMesh) {
3246
3857
  }
3247
3858
  if ("color" in material) material.color = selectionColor.clone();
3248
3859
  }
3249
- function setupEventHandlers(canvas, scene, cameraController, config) {
3860
+ function setupEventHandlers(canvas, scene, cameraController, config, taps) {
3250
3861
  const selectedObjects = /* @__PURE__ */ new Set();
3251
3862
  const originalMaterials = /* @__PURE__ */ new Map();
3252
3863
  const memberHighlights = [];
@@ -3255,16 +3866,7 @@ function setupEventHandlers(canvas, scene, cameraController, config) {
3255
3866
  let selectedByEntries = false;
3256
3867
  const raycaster = new THREE.Raycaster();
3257
3868
  const mouse = new THREE.Vector2();
3258
- const mouseDownPosition = new THREE.Vector2();
3259
3869
  const getActiveCamera = () => cameraController.getActiveCamera();
3260
- const isFullyVisible = (object) => {
3261
- let current = object;
3262
- while (current) {
3263
- if (!current.visible) return false;
3264
- current = current.parent;
3265
- }
3266
- return true;
3267
- };
3268
3870
  const fitToView = () => {
3269
3871
  const box = computeContentBounds(scene);
3270
3872
  if (box.isEmpty()) {
@@ -3402,29 +4004,61 @@ function setupEventHandlers(canvas, scene, cameraController, config) {
3402
4004
  }
3403
4005
  raycaster.params.Points.threshold = cachedDiagonal * .01;
3404
4006
  };
3405
- /**
3406
- * Nearest visible, pickable object under the pointer, or null. Viewer aids are excluded: the grid
3407
- * is a huge camera-following plane, so double-clicking through a thin curve onto it would frame a
3408
- * box the size of the fade radius and fling the camera out.
3409
- */
3410
- const pickAt = (clientX, clientY) => {
4007
+ const setPointerNdc = (clientX, clientY) => {
3411
4008
  const rect = canvas.getBoundingClientRect();
3412
4009
  mouse.x = (clientX - rect.left) / rect.width * 2 - 1;
3413
4010
  mouse.y = -((clientY - rect.top) / rect.height) * 2 + 1;
4011
+ };
4012
+ /**
4013
+ * Nearest visible, pickable object under the pointer, or null. Goes through the BVH pick, which
4014
+ * also skips viewer aids: the grid is a huge camera-following plane, so double-clicking through
4015
+ * a thin curve onto it would frame a box the size of the fade radius and fling the camera out.
4016
+ */
4017
+ const pickAt = (clientX, clientY) => {
4018
+ setPointerNdc(clientX, clientY);
3414
4019
  updatePickThresholds();
3415
4020
  raycaster.setFromCamera(mouse, getActiveCamera());
3416
- for (const hit of raycaster.intersectObjects(scene.children, true)) if (isFullyVisible(hit.object) && !isViewerAid(hit.object)) return hit;
4021
+ return raycastVisible(scene, raycaster)[0] ?? null;
4022
+ };
4023
+ const pickNear = (clientX, clientY, touch) => {
4024
+ const centre = pickAt(clientX, clientY);
4025
+ if (centre || !touch) return centre;
4026
+ for (const radius of TOUCH_PICK_RADII_PX) {
4027
+ let nearest = null;
4028
+ for (let i = 0; i < TOUCH_PICK_DIRECTIONS; i++) {
4029
+ const angle = i / TOUCH_PICK_DIRECTIONS * Math.PI * 2;
4030
+ const hit = pickAt(clientX + Math.cos(angle) * radius, clientY + Math.sin(angle) * radius);
4031
+ if (hit && (!nearest || hit.distance < nearest.distance)) nearest = hit;
4032
+ }
4033
+ if (nearest) return nearest;
4034
+ }
3417
4035
  return null;
3418
4036
  };
3419
- const handleMouseDown = (event) => {
3420
- mouseDownPosition.set(event.clientX, event.clientY);
4037
+ let lastTap = null;
4038
+ /** iOS fires no `dblclick` for a double-tap, so touch pairs its own taps. */
4039
+ const isSecondTap = (event) => {
4040
+ const previous = lastTap;
4041
+ lastTap = {
4042
+ time: event.timeStamp,
4043
+ x: event.clientX,
4044
+ y: event.clientY
4045
+ };
4046
+ if (!previous) return false;
4047
+ const isDouble = event.timeStamp - previous.time <= DOUBLE_TAP_MS && Math.hypot(event.clientX - previous.x, event.clientY - previous.y) <= DOUBLE_TAP_SLOP_PX;
4048
+ if (isDouble) lastTap = null;
4049
+ return isDouble;
3421
4050
  };
3422
4051
  const handleCanvasClick = (event) => {
3423
- const currentMousePosition = new THREE.Vector2(event.clientX, event.clientY);
3424
- if (mouseDownPosition.distanceTo(currentMousePosition) > 5) return;
3425
- const hit = pickAt(event.clientX, event.clientY);
4052
+ if (!taps.wasTap()) return;
4053
+ const touch = taps.pointerType() !== "mouse";
4054
+ if (touch && isSecondTap(event)) {
4055
+ handleDoubleTap(event.clientX, event.clientY, true);
4056
+ return;
4057
+ }
4058
+ const hit = pickNear(event.clientX, event.clientY, touch);
3426
4059
  if (!hit) {
3427
4060
  clearSelection();
4061
+ setPointerNdc(event.clientX, event.clientY);
3428
4062
  config.events?.onBackgroundClicked?.({
3429
4063
  x: mouse.x,
3430
4064
  y: mouse.y
@@ -3451,7 +4085,11 @@ function setupEventHandlers(canvas, scene, cameraController, config) {
3451
4085
  else if (clickedObject instanceof THREE.Mesh && Object.keys(clickedObject.userData).length > 0) config.events?.onMeshMetadataClicked?.(clickedObject.userData);
3452
4086
  };
3453
4087
  const handleDoubleClick = (event) => {
3454
- const hit = pickAt(event.clientX, event.clientY);
4088
+ if (!taps.wasTap() || taps.pointerType() !== "mouse") return;
4089
+ handleDoubleTap(event.clientX, event.clientY, false);
4090
+ };
4091
+ const handleDoubleTap = (clientX, clientY, touch) => {
4092
+ const hit = pickNear(clientX, clientY, touch);
3455
4093
  if (!hit) {
3456
4094
  if (config.events?.enableDoubleClickZoom) cameraController.frameBounds(computeContentBounds(scene), true);
3457
4095
  return;
@@ -3481,7 +4119,6 @@ function setupEventHandlers(canvas, scene, cameraController, config) {
3481
4119
  }
3482
4120
  };
3483
4121
  if (config.events?.enableClickToFocus) {
3484
- canvas.addEventListener("mousedown", handleMouseDown);
3485
4122
  canvas.addEventListener("click", handleCanvasClick);
3486
4123
  canvas.addEventListener("dblclick", handleDoubleClick);
3487
4124
  }
@@ -3490,7 +4127,6 @@ function setupEventHandlers(canvas, scene, cameraController, config) {
3490
4127
  canvas.addEventListener("keydown", handleKeydown);
3491
4128
  }
3492
4129
  const dispose = () => {
3493
- canvas.removeEventListener("mousedown", handleMouseDown);
3494
4130
  canvas.removeEventListener("click", handleCanvasClick);
3495
4131
  canvas.removeEventListener("dblclick", handleDoubleClick);
3496
4132
  canvas.removeEventListener("keydown", handleKeydown);
@@ -3505,6 +4141,69 @@ function setupEventHandlers(canvas, scene, cameraController, config) {
3505
4141
  setHover
3506
4142
  };
3507
4143
  }
4144
+ // src/render/scene-setup/tap-gesture.ts
4145
+ /** A mouse press is precise; past this it was a drag to orbit or pan. */
4146
+ const MOUSE_SLOP_PX = 5;
4147
+ /** A fingertip wobbles a few pixels on a deliberate tap. */
4148
+ const TOUCH_SLOP_PX = 10;
4149
+ /** A touch held this long was the start of an orbit that never moved, not a tap. */
4150
+ const TOUCH_LONG_PRESS_MS = 500;
4151
+ function trackTaps(canvas) {
4152
+ const down = /* @__PURE__ */ new Map();
4153
+ let pointerType = "mouse";
4154
+ let startTime = 0;
4155
+ let maxPointers = 0;
4156
+ let maxTravel = 0;
4157
+ let cancelled = false;
4158
+ let lastWasTap = false;
4159
+ const travel = (event) => {
4160
+ const start = down.get(event.pointerId);
4161
+ if (!start) return;
4162
+ maxTravel = Math.max(maxTravel, Math.hypot(event.clientX - start.x, event.clientY - start.y));
4163
+ };
4164
+ const onDown = (event) => {
4165
+ if (down.size === 0) {
4166
+ pointerType = event.pointerType;
4167
+ startTime = event.timeStamp;
4168
+ maxPointers = 0;
4169
+ maxTravel = 0;
4170
+ cancelled = false;
4171
+ }
4172
+ down.set(event.pointerId, {
4173
+ x: event.clientX,
4174
+ y: event.clientY
4175
+ });
4176
+ maxPointers = Math.max(maxPointers, down.size);
4177
+ };
4178
+ const onMove = (event) => travel(event);
4179
+ const onEnd = (event) => {
4180
+ if (!down.has(event.pointerId)) return;
4181
+ travel(event);
4182
+ if (event.type === "pointercancel") cancelled = true;
4183
+ down.delete(event.pointerId);
4184
+ if (down.size > 0) return;
4185
+ const isMouse = pointerType === "mouse";
4186
+ lastWasTap = !cancelled && maxPointers === 1 && maxTravel <= (isMouse ? MOUSE_SLOP_PX : TOUCH_SLOP_PX) && (isMouse || event.timeStamp - startTime <= TOUCH_LONG_PRESS_MS);
4187
+ };
4188
+ const options = {
4189
+ capture: true,
4190
+ passive: true
4191
+ };
4192
+ canvas.addEventListener("pointerdown", onDown, options);
4193
+ canvas.addEventListener("pointermove", onMove, options);
4194
+ canvas.addEventListener("pointerup", onEnd, options);
4195
+ canvas.addEventListener("pointercancel", onEnd, options);
4196
+ return {
4197
+ wasTap: () => lastWasTap,
4198
+ pointerType: () => pointerType,
4199
+ dispose: () => {
4200
+ canvas.removeEventListener("pointerdown", onDown, options);
4201
+ canvas.removeEventListener("pointermove", onMove, options);
4202
+ canvas.removeEventListener("pointerup", onEnd, options);
4203
+ canvas.removeEventListener("pointercancel", onEnd, options);
4204
+ }
4205
+ };
4206
+ }
3508
4207
  // src/render/scene-setup/setup-renderer.ts
3509
4208
  function setupRenderer(canvas, config, pixelRatio) {
3510
4209
  const renderer = new THREE.WebGLRenderer({
@@ -3602,6 +4301,8 @@ const initThree = function(canvas, options) {
3602
4301
  canvas,
3603
4302
  scene,
3604
4303
  getActiveCamera,
4304
+ getViewTarget: () => controls.target,
4305
+ requestRender: () => requestRender(),
3605
4306
  labelLayer,
3606
4307
  options: {
3607
4308
  snapPixels: config.measure.snapPixels,
@@ -3611,7 +4312,8 @@ const initThree = function(canvas, options) {
3611
4312
  format: config.measure.format
3612
4313
  }
3613
4314
  }) : null;
3614
- const eventHandlers = config.events.enableEventHandlers !== false ? setupEventHandlers(canvas, scene, cameraController, config) : {
4315
+ const taps = trackTaps(canvas);
4316
+ const eventHandlers = config.events.enableEventHandlers !== false ? setupEventHandlers(canvas, scene, cameraController, config, taps) : {
3615
4317
  dispose: () => {},
3616
4318
  fitToView: () => {},
3617
4319
  clearSelection: () => {},
@@ -3629,19 +4331,10 @@ const initThree = function(canvas, options) {
3629
4331
  tool: gizmo,
3630
4332
  priority: -100
3631
4333
  });
3632
- const DRAG_SLOP_PX = 5;
3633
- let pressX = 0;
3634
- let pressY = 0;
3635
- const handlePointerDown = (event) => {
3636
- pressX = event.clientX;
3637
- pressY = event.clientY;
3638
- };
3639
- const wasDrag = (event) => Math.hypot(event.clientX - pressX, event.clientY - pressY) > DRAG_SLOP_PX;
3640
4334
  const handleToolClick = (event) => {
3641
- if (wasDrag(event)) return;
4335
+ if (!taps.wasTap()) return;
3642
4336
  if (tools.handleClick(event)) event.stopImmediatePropagation();
3643
4337
  };
3644
- canvas.addEventListener("mousedown", handlePointerDown, { capture: true });
3645
4338
  canvas.addEventListener("click", handleToolClick, { capture: true });
3646
4339
  const handleToolMove = (event) => tools.handleMove(event);
3647
4340
  canvas.addEventListener("mousemove", handleToolMove, { passive: true });
@@ -3702,7 +4395,23 @@ const initThree = function(canvas, options) {
3702
4395
  fullPixelRatio,
3703
4396
  requestRender: () => requestRender()
3704
4397
  });
3705
- const { animate, dispose: disposeAnimation, invalidate, renderNow } = createAnimationLoop(renderer, scene, camera, getActiveCamera, cameraController, controls, getCanvasSize, () => config.render.pixelRatio, config.events.onFrame, grid, gizmo, () => pipeline.get(), labelLayer, nearFitter, config.render.onDemand ?? true);
4398
+ let restoreSun;
4399
+ const refiner = createIdleRefiner({
4400
+ scene,
4401
+ renderer,
4402
+ getPipeline: () => pipeline.get(),
4403
+ frames: config.render.idleRefineFrames,
4404
+ lightOrbitFrameMs: config.render.lightOrbitFrameMs,
4405
+ jitterLights: (index) => {
4406
+ if (sunlight) restoreSun = jitterSun(sunlight, index);
4407
+ appearance.jitterEmissiveLights(index);
4408
+ },
4409
+ restoreLights: () => {
4410
+ restoreSun?.();
4411
+ appearance.settleEmissiveLights();
4412
+ }
4413
+ });
4414
+ const { animate, dispose: disposeAnimation, invalidate, renderNow } = createAnimationLoop(renderer, scene, camera, getActiveCamera, cameraController, controls, getCanvasSize, () => config.render.pixelRatio, config.events.onFrame, grid, gizmo, () => pipeline.get(), labelLayer, nearFitter, config.render.onDemand ?? true, refiner);
3706
4415
  requestRender = invalidate;
3707
4416
  animate();
3708
4417
  scene.up.set(sceneUp.x, sceneUp.y, sceneUp.z);
@@ -3734,7 +4443,7 @@ const initThree = function(canvas, options) {
3734
4443
  disposed = true;
3735
4444
  disposeAnimation();
3736
4445
  eventHandlers.dispose();
3737
- canvas.removeEventListener("mousedown", handlePointerDown, { capture: true });
4446
+ taps.dispose();
3738
4447
  canvas.removeEventListener("click", handleToolClick, { capture: true });
3739
4448
  canvas.removeEventListener("mousemove", handleToolMove);
3740
4449
  measureTool?.dispose();