@selvajs/visualization 1.6.0-beta.9 → 1.6.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;
2193
+ };
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);
1814
2221
  };
1815
- const pickPoint = (event) => {
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();
@@ -3604,6 +4206,8 @@ const initThree = function(canvas, options) {
3604
4206
  canvas,
3605
4207
  scene,
3606
4208
  getActiveCamera,
4209
+ getViewTarget: () => controls.target,
4210
+ requestRender: () => requestRender(),
3607
4211
  labelLayer,
3608
4212
  options: {
3609
4213
  snapPixels: config.measure.snapPixels,
@@ -3704,7 +4308,23 @@ const initThree = function(canvas, options) {
3704
4308
  fullPixelRatio,
3705
4309
  requestRender: () => requestRender()
3706
4310
  });
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);
4311
+ let restoreSun;
4312
+ const refiner = createIdleRefiner({
4313
+ scene,
4314
+ renderer,
4315
+ getPipeline: () => pipeline.get(),
4316
+ frames: config.render.idleRefineFrames,
4317
+ lightOrbitFrameMs: config.render.lightOrbitFrameMs,
4318
+ jitterLights: (index) => {
4319
+ if (sunlight) restoreSun = jitterSun(sunlight, index);
4320
+ appearance.jitterEmissiveLights(index);
4321
+ },
4322
+ restoreLights: () => {
4323
+ restoreSun?.();
4324
+ appearance.settleEmissiveLights();
4325
+ }
4326
+ });
4327
+ 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
4328
  requestRender = invalidate;
3709
4329
  animate();
3710
4330
  scene.up.set(sceneUp.x, sceneUp.y, sceneUp.z);