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