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