@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.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-
|
|
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
|
|
81
|
-
const texture = pmrem.fromScene(
|
|
82
|
-
|
|
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
|
-
/**
|
|
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) =>
|
|
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
|
-
*
|
|
1663
|
-
* Line/LineSegments → struck segment's 2 endpoints; Points →
|
|
1664
|
-
* carries no usable index
|
|
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
|
|
2020
|
+
function snapCandidates(hit) {
|
|
1667
2021
|
const obj = hit.object;
|
|
1668
|
-
|
|
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 =
|
|
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
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
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
|
|
1701
|
-
|
|
1702
|
-
const
|
|
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 =
|
|
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
|
|
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
|
-
|
|
1722
|
-
const
|
|
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
|
|
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:
|
|
2114
|
+
size: 7,
|
|
1734
2115
|
sizeAttenuation: false,
|
|
1735
2116
|
depthTest: false,
|
|
1736
2117
|
transparent: true,
|
|
1737
|
-
opacity: .
|
|
2118
|
+
opacity: .6
|
|
1738
2119
|
});
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
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
|
|
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
|
|
1769
|
-
|
|
1770
|
-
|
|
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
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
2182
|
+
return format(a.distanceTo(b), delta);
|
|
2183
|
+
};
|
|
2184
|
+
const midpoint = (a, b) => a.clone().add(b).multiplyScalar(.5);
|
|
2185
|
+
let hoverMarker = null;
|
|
2186
|
+
let rubberBand = null;
|
|
2187
|
+
let rubberLabel = null;
|
|
2188
|
+
const hidePreview = () => {
|
|
2189
|
+
if (hoverMarker) hoverMarker.visible = false;
|
|
2190
|
+
if (rubberBand) rubberBand.visible = false;
|
|
2191
|
+
rubberLabel?.remove();
|
|
2192
|
+
rubberLabel = null;
|
|
1814
2193
|
};
|
|
1815
|
-
const
|
|
2194
|
+
const showPreview = (snap) => {
|
|
2195
|
+
if (!snap) {
|
|
2196
|
+
hidePreview();
|
|
2197
|
+
return;
|
|
2198
|
+
}
|
|
2199
|
+
if (!hoverMarker) {
|
|
2200
|
+
hoverMarker = makePoints(new three.Vector3());
|
|
2201
|
+
hoverMarker.renderOrder = RENDER_ORDER_HOVER;
|
|
2202
|
+
}
|
|
2203
|
+
hoverMarker.material = snap.snapped ? hoverSnappedMaterial : hoverFreeMaterial;
|
|
2204
|
+
hoverMarker.position.copy(snap.point);
|
|
2205
|
+
hoverMarker.visible = true;
|
|
2206
|
+
if (!start) {
|
|
2207
|
+
if (rubberBand) rubberBand.visible = false;
|
|
2208
|
+
rubberLabel?.remove();
|
|
2209
|
+
rubberLabel = null;
|
|
2210
|
+
return;
|
|
2211
|
+
}
|
|
2212
|
+
if (!rubberBand) rubberBand = makeLine(start.point, snap.point, .7);
|
|
2213
|
+
moveLine(rubberBand, start.point, snap.point);
|
|
2214
|
+
rubberBand.visible = true;
|
|
2215
|
+
const text = labelText(start.point, snap.point);
|
|
2216
|
+
const mid = midpoint(start.point, snap.point);
|
|
2217
|
+
if (rubberLabel) {
|
|
2218
|
+
rubberLabel.setText(text);
|
|
2219
|
+
rubberLabel.setPosition(mid);
|
|
2220
|
+
} else rubberLabel = labelLayer.addLabel(text, mid, options.labelClassName);
|
|
2221
|
+
};
|
|
2222
|
+
const pick = (event) => {
|
|
1816
2223
|
const rect = canvas.getBoundingClientRect();
|
|
1817
|
-
|
|
1818
|
-
|
|
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
|
-
|
|
1825
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
1855
|
-
const
|
|
1856
|
-
if (
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
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
|
|
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
|
-
|
|
2352
|
+
setEnabled(false);
|
|
1877
2353
|
clear();
|
|
1878
|
-
if (hoverMarker)
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
}
|
|
2354
|
+
if (hoverMarker) disposePoints(hoverMarker);
|
|
2355
|
+
if (rubberBand) disposeLine(rubberBand);
|
|
2356
|
+
hoverMarker = null;
|
|
2357
|
+
rubberBand = null;
|
|
1883
2358
|
markerMaterial.dispose();
|
|
1884
|
-
|
|
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
|
-
|
|
2360
|
-
if (gizmo) gizmo.render(renderer);
|
|
2839
|
+
drawOverlays(activeCamera);
|
|
2361
2840
|
};
|
|
2362
2841
|
const renderNow = () => {
|
|
2363
|
-
|
|
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
|
-
|
|
2378
|
-
|
|
2858
|
+
const moved = cameraMoved(activeCamera);
|
|
2859
|
+
if (moved) refiner?.moving(delta * 1e3);
|
|
2860
|
+
const changed = renderRequested || moved;
|
|
2861
|
+
if (!changed && refiner?.wants(now)) {
|
|
2862
|
+
refiner.step(activeCamera);
|
|
2863
|
+
drawOverlays(activeCamera);
|
|
2864
|
+
return;
|
|
2865
|
+
}
|
|
2866
|
+
const repaint = now - lastRenderTime >= IDLE_REPAINT_INTERVAL_MS;
|
|
2867
|
+
if (!changed && !repaint) return;
|
|
2379
2868
|
lastRenderTime = now;
|
|
2869
|
+
if (!changed && refiner?.isHolding()) return;
|
|
2870
|
+
renderRequested = false;
|
|
2871
|
+
refiner?.reset(now);
|
|
2380
2872
|
}
|
|
2381
2873
|
drawFrame(activeCamera, delta);
|
|
2382
2874
|
};
|
|
@@ -2493,6 +2985,7 @@ function pickLightSources(sources, maxLights = DEFAULT_MAX_LIGHTS) {
|
|
|
2493
2985
|
function createEmissiveLights(scene) {
|
|
2494
2986
|
const lights = [];
|
|
2495
2987
|
const silenced = /* @__PURE__ */ new Set();
|
|
2988
|
+
const offset = new three.Vector3();
|
|
2496
2989
|
const addLight = (shadowed) => {
|
|
2497
2990
|
const key = new three.PointLight(16777215, 1, 0, 2);
|
|
2498
2991
|
const bounce = new three.PointLight(16777215, 1, 0, 1);
|
|
@@ -2504,7 +2997,9 @@ function createEmissiveLights(scene) {
|
|
|
2504
2997
|
}
|
|
2505
2998
|
const pair = {
|
|
2506
2999
|
key,
|
|
2507
|
-
bounce
|
|
3000
|
+
bounce,
|
|
3001
|
+
home: new three.Vector3(),
|
|
3002
|
+
size: new three.Vector3()
|
|
2508
3003
|
};
|
|
2509
3004
|
lights.push(pair);
|
|
2510
3005
|
scene.add(key, bounce);
|
|
@@ -2545,12 +3040,14 @@ function createEmissiveLights(scene) {
|
|
|
2545
3040
|
};
|
|
2546
3041
|
const sources = pickLightSources(sourcesFrom(parts, maxLights), maxLights);
|
|
2547
3042
|
sources.forEach((source, index) => {
|
|
2548
|
-
const { key, bounce } = lights[index] ?? addLight(index < MAX_SHADOWED_LIGHTS);
|
|
3043
|
+
const { key, bounce, home, size } = lights[index] ?? addLight(index < MAX_SHADOWED_LIGHTS);
|
|
2549
3044
|
const intensity = source.light ?? source.strength * INTENSITY_PER_STRENGTH * radius * radius;
|
|
2550
3045
|
key.color.copy(source.color);
|
|
2551
3046
|
const spread = source.bounds.getSize(new three.Vector3()).length() / 2 / radius;
|
|
2552
3047
|
key.intensity = !source.light && spread > MAX_POINT_SOURCE_SPREAD ? 0 : intensity;
|
|
2553
3048
|
source.bounds.getCenter(key.position);
|
|
3049
|
+
home.copy(key.position);
|
|
3050
|
+
source.bounds.getSize(size);
|
|
2554
3051
|
bounce.color.copy(source.color);
|
|
2555
3052
|
bounce.intensity = intensity * BOUNCE_SHARE / radius;
|
|
2556
3053
|
bounce.position.copy(key.position);
|
|
@@ -2571,8 +3068,16 @@ function createEmissiveLights(scene) {
|
|
|
2571
3068
|
}
|
|
2572
3069
|
silence(parts, sources);
|
|
2573
3070
|
};
|
|
3071
|
+
const place = (position) => {
|
|
3072
|
+
for (const { key, home, size } of lights) {
|
|
3073
|
+
key.position.copy(position(home, size));
|
|
3074
|
+
if (key.castShadow) key.shadow.needsUpdate = true;
|
|
3075
|
+
}
|
|
3076
|
+
};
|
|
2574
3077
|
return {
|
|
2575
3078
|
sync,
|
|
3079
|
+
jitter: (index) => place((home, size) => offset.set(halton(index, 2) - .5, halton(index, 3) - .5, halton(index, 5) - .5).multiply(size).add(home)),
|
|
3080
|
+
settle: () => place((home) => home),
|
|
2576
3081
|
dispose: () => sync(false)
|
|
2577
3082
|
};
|
|
2578
3083
|
}
|
|
@@ -2750,6 +3255,8 @@ function createAppearanceController(params) {
|
|
|
2750
3255
|
setAoIntensity,
|
|
2751
3256
|
setLook,
|
|
2752
3257
|
syncEmissiveLights,
|
|
3258
|
+
jitterEmissiveLights: emissiveLights.jitter,
|
|
3259
|
+
settleEmissiveLights: emissiveLights.settle,
|
|
2753
3260
|
getMaterialAppearance: () => require_gpu_dispose.materialAppearanceForLook(activeLook)
|
|
2754
3261
|
};
|
|
2755
3262
|
}
|
|
@@ -2908,7 +3415,9 @@ var EdgeDetectionPass = class extends three_addons_postprocessing_Pass_js.Pass {
|
|
|
2908
3415
|
renderer.setClearColor(7829503, 1);
|
|
2909
3416
|
renderer.autoClear = true;
|
|
2910
3417
|
this.scene.overrideMaterial = this.normalMaterial;
|
|
3418
|
+
const showGlass = require_gpu_dispose.hideGlass(this.scene);
|
|
2911
3419
|
renderer.render(this.scene, this.camera);
|
|
3420
|
+
showGlass();
|
|
2912
3421
|
this.scene.overrideMaterial = previousOverride;
|
|
2913
3422
|
renderer.setClearColor(previousClearColor, previousClearAlpha);
|
|
2914
3423
|
renderer.autoClear = previousAutoClear;
|
|
@@ -2959,6 +3468,68 @@ const HDR_CLAMP_SHADER = {
|
|
|
2959
3468
|
gl_FragColor = vec4(safe(color.r), safe(color.g), safe(color.b), color.a);
|
|
2960
3469
|
}`
|
|
2961
3470
|
};
|
|
3471
|
+
const AVERAGE_SHADER = {
|
|
3472
|
+
vertexShader: `
|
|
3473
|
+
varying vec2 vUv;
|
|
3474
|
+
void main() {
|
|
3475
|
+
vUv = uv;
|
|
3476
|
+
gl_Position = vec4(position.xy, 0.0, 1.0);
|
|
3477
|
+
}`,
|
|
3478
|
+
fragmentShader: `
|
|
3479
|
+
uniform sampler2D tFrame;
|
|
3480
|
+
varying vec2 vUv;
|
|
3481
|
+
void main() {
|
|
3482
|
+
gl_FragColor = texture2D(tFrame, vUv);
|
|
3483
|
+
}`
|
|
3484
|
+
};
|
|
3485
|
+
/**
|
|
3486
|
+
* Keeps the running average of the composer's finished frames. They're already tone mapped, which
|
|
3487
|
+
* suits what gets averaged here: shadows and edges, not HDR highlights. Half float, because 32
|
|
3488
|
+
* frames blended into 8 bits would band in a soft shadow.
|
|
3489
|
+
*/
|
|
3490
|
+
function createAccumulator(renderer) {
|
|
3491
|
+
const target = new three.WebGLRenderTarget(1, 1, {
|
|
3492
|
+
type: three.HalfFloatType,
|
|
3493
|
+
depthBuffer: false
|
|
3494
|
+
});
|
|
3495
|
+
const material = new three.ShaderMaterial({
|
|
3496
|
+
...AVERAGE_SHADER,
|
|
3497
|
+
uniforms: { tFrame: { value: null } },
|
|
3498
|
+
depthTest: false,
|
|
3499
|
+
depthWrite: false,
|
|
3500
|
+
toneMapped: false,
|
|
3501
|
+
blending: three.CustomBlending,
|
|
3502
|
+
blendSrc: three.ConstantAlphaFactor,
|
|
3503
|
+
blendDst: three.OneMinusConstantAlphaFactor
|
|
3504
|
+
});
|
|
3505
|
+
const quad = new three_addons_postprocessing_Pass_js.FullScreenQuad(material);
|
|
3506
|
+
const draw = (texture, weight) => {
|
|
3507
|
+
material.uniforms.tFrame.value = texture;
|
|
3508
|
+
material.blendAlpha = weight;
|
|
3509
|
+
const autoClear = renderer.autoClear;
|
|
3510
|
+
renderer.autoClear = false;
|
|
3511
|
+
quad.render(renderer);
|
|
3512
|
+
renderer.autoClear = autoClear;
|
|
3513
|
+
};
|
|
3514
|
+
const present = () => {
|
|
3515
|
+
renderer.setRenderTarget(null);
|
|
3516
|
+
draw(target.texture, 1);
|
|
3517
|
+
};
|
|
3518
|
+
return {
|
|
3519
|
+
add: (frame, index, width, height) => {
|
|
3520
|
+
if (target.width !== width || target.height !== height) target.setSize(width, height);
|
|
3521
|
+
renderer.setRenderTarget(target);
|
|
3522
|
+
draw(frame, 1 / (index + 1));
|
|
3523
|
+
present();
|
|
3524
|
+
},
|
|
3525
|
+
present,
|
|
3526
|
+
dispose: () => {
|
|
3527
|
+
target.dispose();
|
|
3528
|
+
material.dispose();
|
|
3529
|
+
quad.dispose();
|
|
3530
|
+
}
|
|
3531
|
+
};
|
|
3532
|
+
}
|
|
2962
3533
|
/**
|
|
2963
3534
|
* Without MSAA a sub-pixel feature (a sheet's bright hem edge) comes out as a dashed line, which
|
|
2964
3535
|
* SMAA can't rebuild. At 2× DPR each CSS pixel already holds four samples, and 4× MSAA on top
|
|
@@ -2967,6 +3538,20 @@ const HDR_CLAMP_SHADER = {
|
|
|
2967
3538
|
function msaaSamplesFor(pixelRatio) {
|
|
2968
3539
|
return pixelRatio >= 2 ? 0 : 4;
|
|
2969
3540
|
}
|
|
3541
|
+
function skipGlass(pass) {
|
|
3542
|
+
const hooks = pass;
|
|
3543
|
+
const overrideVisibility = hooks._overrideVisibility.bind(pass);
|
|
3544
|
+
const restoreVisibility = hooks._restoreVisibility.bind(pass);
|
|
3545
|
+
let showGlass = () => {};
|
|
3546
|
+
hooks._overrideVisibility = () => {
|
|
3547
|
+
overrideVisibility();
|
|
3548
|
+
showGlass = require_gpu_dispose.hideGlass(pass.scene);
|
|
3549
|
+
};
|
|
3550
|
+
hooks._restoreVisibility = () => {
|
|
3551
|
+
showGlass();
|
|
3552
|
+
restoreVisibility();
|
|
3553
|
+
};
|
|
3554
|
+
}
|
|
2970
3555
|
function createRenderPipeline(renderer, scene, camera, width, height, options) {
|
|
2971
3556
|
const sceneTarget = new three.WebGLRenderTarget(width, height, { type: three.HalfFloatType });
|
|
2972
3557
|
const composer = new three_addons_postprocessing_EffectComposer_js.EffectComposer(renderer, sceneTarget);
|
|
@@ -2983,6 +3568,7 @@ function createRenderPipeline(renderer, scene, camera, width, height, options) {
|
|
|
2983
3568
|
gtaoPass = new three_addons_postprocessing_GTAOPass_js.GTAOPass(scene, camera, width, height);
|
|
2984
3569
|
gtaoPass.blendIntensity = options.aoIntensity ?? 1;
|
|
2985
3570
|
gtaoPass.updateGtaoMaterial({ screenSpaceRadius: true });
|
|
3571
|
+
skipGlass(gtaoPass);
|
|
2986
3572
|
composer.addPass(gtaoPass);
|
|
2987
3573
|
}
|
|
2988
3574
|
let clampPass = null;
|
|
@@ -3003,6 +3589,7 @@ function createRenderPipeline(renderer, scene, camera, width, height, options) {
|
|
|
3003
3589
|
composer.addPass(outputPass);
|
|
3004
3590
|
renderer.toneMapping = options.toneMapping;
|
|
3005
3591
|
renderer.toneMappingExposure = options.toneMappingExposure;
|
|
3592
|
+
let accumulator = null;
|
|
3006
3593
|
const aoPixelRatioCap = options.aoPixelRatio ?? 1;
|
|
3007
3594
|
let aoScale = 1;
|
|
3008
3595
|
if (gtaoPass) {
|
|
@@ -3041,8 +3628,23 @@ function createRenderPipeline(renderer, scene, camera, width, height, options) {
|
|
|
3041
3628
|
edgePass.enabled = enabled;
|
|
3042
3629
|
},
|
|
3043
3630
|
edgeDetectionEnabled: () => edgePass.enabled,
|
|
3631
|
+
accumulate: (index) => {
|
|
3632
|
+
composer.renderToScreen = false;
|
|
3633
|
+
renderFrame(0);
|
|
3634
|
+
composer.renderToScreen = true;
|
|
3635
|
+
accumulator ??= createAccumulator(renderer);
|
|
3636
|
+
const { texture, width, height } = composer.readBuffer;
|
|
3637
|
+
accumulator.add(texture, index, width, height);
|
|
3638
|
+
},
|
|
3639
|
+
presentAccumulated: () => accumulator?.present(),
|
|
3640
|
+
setAoWeight: (weight) => {
|
|
3641
|
+
if (!gtaoPass) return;
|
|
3642
|
+
gtaoPass.enabled = weight > 0;
|
|
3643
|
+
gtaoPass.blendIntensity = (options.aoIntensity ?? 1) * weight;
|
|
3644
|
+
},
|
|
3044
3645
|
dispose: () => {
|
|
3045
3646
|
composer.dispose();
|
|
3647
|
+
accumulator?.dispose();
|
|
3046
3648
|
gtaoPass?.dispose();
|
|
3047
3649
|
clampPass?.dispose();
|
|
3048
3650
|
bloomPass?.dispose();
|
|
@@ -3209,6 +3811,15 @@ const SELECTION_MIN_OPACITY = 1;
|
|
|
3209
3811
|
* color blindness, so what tells hover from selected there is brightness.
|
|
3210
3812
|
*/
|
|
3211
3813
|
const HOVER_COLOR = "#ffb020";
|
|
3814
|
+
/**
|
|
3815
|
+
* A fingertip covers far more than the pixel under its centre, so a touch that lands just beside a
|
|
3816
|
+
* thin curve or a narrow part still picks it. Rings are sampled only when the centre misses.
|
|
3817
|
+
*/
|
|
3818
|
+
const TOUCH_PICK_RADII_PX = [8, 16];
|
|
3819
|
+
const TOUCH_PICK_DIRECTIONS = 8;
|
|
3820
|
+
/** Two taps closer than this in time and space are a double-tap. */
|
|
3821
|
+
const DOUBLE_TAP_MS = 300;
|
|
3822
|
+
const DOUBLE_TAP_SLOP_PX = 30;
|
|
3212
3823
|
/** Linear RGB distance. Closer than this, a tint barely changes the surface. */
|
|
3213
3824
|
const MIN_TINT_DISTANCE = .35;
|
|
3214
3825
|
/** Taken by a surface already close to the tint. Blue stays distinct under every common deficiency. */
|
|
@@ -3248,7 +3859,7 @@ function tintForSelection(material, color, isMesh) {
|
|
|
3248
3859
|
}
|
|
3249
3860
|
if ("color" in material) material.color = selectionColor.clone();
|
|
3250
3861
|
}
|
|
3251
|
-
function setupEventHandlers(canvas, scene, cameraController, config) {
|
|
3862
|
+
function setupEventHandlers(canvas, scene, cameraController, config, taps) {
|
|
3252
3863
|
const selectedObjects = /* @__PURE__ */ new Set();
|
|
3253
3864
|
const originalMaterials = /* @__PURE__ */ new Map();
|
|
3254
3865
|
const memberHighlights = [];
|
|
@@ -3257,16 +3868,7 @@ function setupEventHandlers(canvas, scene, cameraController, config) {
|
|
|
3257
3868
|
let selectedByEntries = false;
|
|
3258
3869
|
const raycaster = new three.Raycaster();
|
|
3259
3870
|
const mouse = new three.Vector2();
|
|
3260
|
-
const mouseDownPosition = new three.Vector2();
|
|
3261
3871
|
const getActiveCamera = () => cameraController.getActiveCamera();
|
|
3262
|
-
const isFullyVisible = (object) => {
|
|
3263
|
-
let current = object;
|
|
3264
|
-
while (current) {
|
|
3265
|
-
if (!current.visible) return false;
|
|
3266
|
-
current = current.parent;
|
|
3267
|
-
}
|
|
3268
|
-
return true;
|
|
3269
|
-
};
|
|
3270
3872
|
const fitToView = () => {
|
|
3271
3873
|
const box = computeContentBounds(scene);
|
|
3272
3874
|
if (box.isEmpty()) {
|
|
@@ -3404,29 +4006,61 @@ function setupEventHandlers(canvas, scene, cameraController, config) {
|
|
|
3404
4006
|
}
|
|
3405
4007
|
raycaster.params.Points.threshold = cachedDiagonal * .01;
|
|
3406
4008
|
};
|
|
3407
|
-
|
|
3408
|
-
* Nearest visible, pickable object under the pointer, or null. Viewer aids are excluded: the grid
|
|
3409
|
-
* is a huge camera-following plane, so double-clicking through a thin curve onto it would frame a
|
|
3410
|
-
* box the size of the fade radius and fling the camera out.
|
|
3411
|
-
*/
|
|
3412
|
-
const pickAt = (clientX, clientY) => {
|
|
4009
|
+
const setPointerNdc = (clientX, clientY) => {
|
|
3413
4010
|
const rect = canvas.getBoundingClientRect();
|
|
3414
4011
|
mouse.x = (clientX - rect.left) / rect.width * 2 - 1;
|
|
3415
4012
|
mouse.y = -((clientY - rect.top) / rect.height) * 2 + 1;
|
|
4013
|
+
};
|
|
4014
|
+
/**
|
|
4015
|
+
* Nearest visible, pickable object under the pointer, or null. Goes through the BVH pick, which
|
|
4016
|
+
* also skips viewer aids: the grid is a huge camera-following plane, so double-clicking through
|
|
4017
|
+
* a thin curve onto it would frame a box the size of the fade radius and fling the camera out.
|
|
4018
|
+
*/
|
|
4019
|
+
const pickAt = (clientX, clientY) => {
|
|
4020
|
+
setPointerNdc(clientX, clientY);
|
|
3416
4021
|
updatePickThresholds();
|
|
3417
4022
|
raycaster.setFromCamera(mouse, getActiveCamera());
|
|
3418
|
-
|
|
4023
|
+
return raycastVisible(scene, raycaster)[0] ?? null;
|
|
4024
|
+
};
|
|
4025
|
+
const pickNear = (clientX, clientY, touch) => {
|
|
4026
|
+
const centre = pickAt(clientX, clientY);
|
|
4027
|
+
if (centre || !touch) return centre;
|
|
4028
|
+
for (const radius of TOUCH_PICK_RADII_PX) {
|
|
4029
|
+
let nearest = null;
|
|
4030
|
+
for (let i = 0; i < TOUCH_PICK_DIRECTIONS; i++) {
|
|
4031
|
+
const angle = i / TOUCH_PICK_DIRECTIONS * Math.PI * 2;
|
|
4032
|
+
const hit = pickAt(clientX + Math.cos(angle) * radius, clientY + Math.sin(angle) * radius);
|
|
4033
|
+
if (hit && (!nearest || hit.distance < nearest.distance)) nearest = hit;
|
|
4034
|
+
}
|
|
4035
|
+
if (nearest) return nearest;
|
|
4036
|
+
}
|
|
3419
4037
|
return null;
|
|
3420
4038
|
};
|
|
3421
|
-
|
|
3422
|
-
|
|
4039
|
+
let lastTap = null;
|
|
4040
|
+
/** iOS fires no `dblclick` for a double-tap, so touch pairs its own taps. */
|
|
4041
|
+
const isSecondTap = (event) => {
|
|
4042
|
+
const previous = lastTap;
|
|
4043
|
+
lastTap = {
|
|
4044
|
+
time: event.timeStamp,
|
|
4045
|
+
x: event.clientX,
|
|
4046
|
+
y: event.clientY
|
|
4047
|
+
};
|
|
4048
|
+
if (!previous) return false;
|
|
4049
|
+
const isDouble = event.timeStamp - previous.time <= DOUBLE_TAP_MS && Math.hypot(event.clientX - previous.x, event.clientY - previous.y) <= DOUBLE_TAP_SLOP_PX;
|
|
4050
|
+
if (isDouble) lastTap = null;
|
|
4051
|
+
return isDouble;
|
|
3423
4052
|
};
|
|
3424
4053
|
const handleCanvasClick = (event) => {
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
4054
|
+
if (!taps.wasTap()) return;
|
|
4055
|
+
const touch = taps.pointerType() !== "mouse";
|
|
4056
|
+
if (touch && isSecondTap(event)) {
|
|
4057
|
+
handleDoubleTap(event.clientX, event.clientY, true);
|
|
4058
|
+
return;
|
|
4059
|
+
}
|
|
4060
|
+
const hit = pickNear(event.clientX, event.clientY, touch);
|
|
3428
4061
|
if (!hit) {
|
|
3429
4062
|
clearSelection();
|
|
4063
|
+
setPointerNdc(event.clientX, event.clientY);
|
|
3430
4064
|
config.events?.onBackgroundClicked?.({
|
|
3431
4065
|
x: mouse.x,
|
|
3432
4066
|
y: mouse.y
|
|
@@ -3453,7 +4087,11 @@ function setupEventHandlers(canvas, scene, cameraController, config) {
|
|
|
3453
4087
|
else if (clickedObject instanceof three.Mesh && Object.keys(clickedObject.userData).length > 0) config.events?.onMeshMetadataClicked?.(clickedObject.userData);
|
|
3454
4088
|
};
|
|
3455
4089
|
const handleDoubleClick = (event) => {
|
|
3456
|
-
|
|
4090
|
+
if (!taps.wasTap() || taps.pointerType() !== "mouse") return;
|
|
4091
|
+
handleDoubleTap(event.clientX, event.clientY, false);
|
|
4092
|
+
};
|
|
4093
|
+
const handleDoubleTap = (clientX, clientY, touch) => {
|
|
4094
|
+
const hit = pickNear(clientX, clientY, touch);
|
|
3457
4095
|
if (!hit) {
|
|
3458
4096
|
if (config.events?.enableDoubleClickZoom) cameraController.frameBounds(computeContentBounds(scene), true);
|
|
3459
4097
|
return;
|
|
@@ -3483,7 +4121,6 @@ function setupEventHandlers(canvas, scene, cameraController, config) {
|
|
|
3483
4121
|
}
|
|
3484
4122
|
};
|
|
3485
4123
|
if (config.events?.enableClickToFocus) {
|
|
3486
|
-
canvas.addEventListener("mousedown", handleMouseDown);
|
|
3487
4124
|
canvas.addEventListener("click", handleCanvasClick);
|
|
3488
4125
|
canvas.addEventListener("dblclick", handleDoubleClick);
|
|
3489
4126
|
}
|
|
@@ -3492,7 +4129,6 @@ function setupEventHandlers(canvas, scene, cameraController, config) {
|
|
|
3492
4129
|
canvas.addEventListener("keydown", handleKeydown);
|
|
3493
4130
|
}
|
|
3494
4131
|
const dispose = () => {
|
|
3495
|
-
canvas.removeEventListener("mousedown", handleMouseDown);
|
|
3496
4132
|
canvas.removeEventListener("click", handleCanvasClick);
|
|
3497
4133
|
canvas.removeEventListener("dblclick", handleDoubleClick);
|
|
3498
4134
|
canvas.removeEventListener("keydown", handleKeydown);
|
|
@@ -3507,6 +4143,69 @@ function setupEventHandlers(canvas, scene, cameraController, config) {
|
|
|
3507
4143
|
setHover
|
|
3508
4144
|
};
|
|
3509
4145
|
}
|
|
4146
|
+
// src/render/scene-setup/tap-gesture.ts
|
|
4147
|
+
/** A mouse press is precise; past this it was a drag to orbit or pan. */
|
|
4148
|
+
const MOUSE_SLOP_PX = 5;
|
|
4149
|
+
/** A fingertip wobbles a few pixels on a deliberate tap. */
|
|
4150
|
+
const TOUCH_SLOP_PX = 10;
|
|
4151
|
+
/** A touch held this long was the start of an orbit that never moved, not a tap. */
|
|
4152
|
+
const TOUCH_LONG_PRESS_MS = 500;
|
|
4153
|
+
function trackTaps(canvas) {
|
|
4154
|
+
const down = /* @__PURE__ */ new Map();
|
|
4155
|
+
let pointerType = "mouse";
|
|
4156
|
+
let startTime = 0;
|
|
4157
|
+
let maxPointers = 0;
|
|
4158
|
+
let maxTravel = 0;
|
|
4159
|
+
let cancelled = false;
|
|
4160
|
+
let lastWasTap = false;
|
|
4161
|
+
const travel = (event) => {
|
|
4162
|
+
const start = down.get(event.pointerId);
|
|
4163
|
+
if (!start) return;
|
|
4164
|
+
maxTravel = Math.max(maxTravel, Math.hypot(event.clientX - start.x, event.clientY - start.y));
|
|
4165
|
+
};
|
|
4166
|
+
const onDown = (event) => {
|
|
4167
|
+
if (down.size === 0) {
|
|
4168
|
+
pointerType = event.pointerType;
|
|
4169
|
+
startTime = event.timeStamp;
|
|
4170
|
+
maxPointers = 0;
|
|
4171
|
+
maxTravel = 0;
|
|
4172
|
+
cancelled = false;
|
|
4173
|
+
}
|
|
4174
|
+
down.set(event.pointerId, {
|
|
4175
|
+
x: event.clientX,
|
|
4176
|
+
y: event.clientY
|
|
4177
|
+
});
|
|
4178
|
+
maxPointers = Math.max(maxPointers, down.size);
|
|
4179
|
+
};
|
|
4180
|
+
const onMove = (event) => travel(event);
|
|
4181
|
+
const onEnd = (event) => {
|
|
4182
|
+
if (!down.has(event.pointerId)) return;
|
|
4183
|
+
travel(event);
|
|
4184
|
+
if (event.type === "pointercancel") cancelled = true;
|
|
4185
|
+
down.delete(event.pointerId);
|
|
4186
|
+
if (down.size > 0) return;
|
|
4187
|
+
const isMouse = pointerType === "mouse";
|
|
4188
|
+
lastWasTap = !cancelled && maxPointers === 1 && maxTravel <= (isMouse ? MOUSE_SLOP_PX : TOUCH_SLOP_PX) && (isMouse || event.timeStamp - startTime <= TOUCH_LONG_PRESS_MS);
|
|
4189
|
+
};
|
|
4190
|
+
const options = {
|
|
4191
|
+
capture: true,
|
|
4192
|
+
passive: true
|
|
4193
|
+
};
|
|
4194
|
+
canvas.addEventListener("pointerdown", onDown, options);
|
|
4195
|
+
canvas.addEventListener("pointermove", onMove, options);
|
|
4196
|
+
canvas.addEventListener("pointerup", onEnd, options);
|
|
4197
|
+
canvas.addEventListener("pointercancel", onEnd, options);
|
|
4198
|
+
return {
|
|
4199
|
+
wasTap: () => lastWasTap,
|
|
4200
|
+
pointerType: () => pointerType,
|
|
4201
|
+
dispose: () => {
|
|
4202
|
+
canvas.removeEventListener("pointerdown", onDown, options);
|
|
4203
|
+
canvas.removeEventListener("pointermove", onMove, options);
|
|
4204
|
+
canvas.removeEventListener("pointerup", onEnd, options);
|
|
4205
|
+
canvas.removeEventListener("pointercancel", onEnd, options);
|
|
4206
|
+
}
|
|
4207
|
+
};
|
|
4208
|
+
}
|
|
3510
4209
|
// src/render/scene-setup/setup-renderer.ts
|
|
3511
4210
|
function setupRenderer(canvas, config, pixelRatio) {
|
|
3512
4211
|
const renderer = new three.WebGLRenderer({
|
|
@@ -3604,6 +4303,8 @@ const initThree = function(canvas, options) {
|
|
|
3604
4303
|
canvas,
|
|
3605
4304
|
scene,
|
|
3606
4305
|
getActiveCamera,
|
|
4306
|
+
getViewTarget: () => controls.target,
|
|
4307
|
+
requestRender: () => requestRender(),
|
|
3607
4308
|
labelLayer,
|
|
3608
4309
|
options: {
|
|
3609
4310
|
snapPixels: config.measure.snapPixels,
|
|
@@ -3613,7 +4314,8 @@ const initThree = function(canvas, options) {
|
|
|
3613
4314
|
format: config.measure.format
|
|
3614
4315
|
}
|
|
3615
4316
|
}) : null;
|
|
3616
|
-
const
|
|
4317
|
+
const taps = trackTaps(canvas);
|
|
4318
|
+
const eventHandlers = config.events.enableEventHandlers !== false ? setupEventHandlers(canvas, scene, cameraController, config, taps) : {
|
|
3617
4319
|
dispose: () => {},
|
|
3618
4320
|
fitToView: () => {},
|
|
3619
4321
|
clearSelection: () => {},
|
|
@@ -3631,19 +4333,10 @@ const initThree = function(canvas, options) {
|
|
|
3631
4333
|
tool: gizmo,
|
|
3632
4334
|
priority: -100
|
|
3633
4335
|
});
|
|
3634
|
-
const DRAG_SLOP_PX = 5;
|
|
3635
|
-
let pressX = 0;
|
|
3636
|
-
let pressY = 0;
|
|
3637
|
-
const handlePointerDown = (event) => {
|
|
3638
|
-
pressX = event.clientX;
|
|
3639
|
-
pressY = event.clientY;
|
|
3640
|
-
};
|
|
3641
|
-
const wasDrag = (event) => Math.hypot(event.clientX - pressX, event.clientY - pressY) > DRAG_SLOP_PX;
|
|
3642
4336
|
const handleToolClick = (event) => {
|
|
3643
|
-
if (
|
|
4337
|
+
if (!taps.wasTap()) return;
|
|
3644
4338
|
if (tools.handleClick(event)) event.stopImmediatePropagation();
|
|
3645
4339
|
};
|
|
3646
|
-
canvas.addEventListener("mousedown", handlePointerDown, { capture: true });
|
|
3647
4340
|
canvas.addEventListener("click", handleToolClick, { capture: true });
|
|
3648
4341
|
const handleToolMove = (event) => tools.handleMove(event);
|
|
3649
4342
|
canvas.addEventListener("mousemove", handleToolMove, { passive: true });
|
|
@@ -3704,7 +4397,23 @@ const initThree = function(canvas, options) {
|
|
|
3704
4397
|
fullPixelRatio,
|
|
3705
4398
|
requestRender: () => requestRender()
|
|
3706
4399
|
});
|
|
3707
|
-
|
|
4400
|
+
let restoreSun;
|
|
4401
|
+
const refiner = createIdleRefiner({
|
|
4402
|
+
scene,
|
|
4403
|
+
renderer,
|
|
4404
|
+
getPipeline: () => pipeline.get(),
|
|
4405
|
+
frames: config.render.idleRefineFrames,
|
|
4406
|
+
lightOrbitFrameMs: config.render.lightOrbitFrameMs,
|
|
4407
|
+
jitterLights: (index) => {
|
|
4408
|
+
if (sunlight) restoreSun = jitterSun(sunlight, index);
|
|
4409
|
+
appearance.jitterEmissiveLights(index);
|
|
4410
|
+
},
|
|
4411
|
+
restoreLights: () => {
|
|
4412
|
+
restoreSun?.();
|
|
4413
|
+
appearance.settleEmissiveLights();
|
|
4414
|
+
}
|
|
4415
|
+
});
|
|
4416
|
+
const { animate, dispose: disposeAnimation, invalidate, renderNow } = createAnimationLoop(renderer, scene, camera, getActiveCamera, cameraController, controls, getCanvasSize, () => config.render.pixelRatio, config.events.onFrame, grid, gizmo, () => pipeline.get(), labelLayer, nearFitter, config.render.onDemand ?? true, refiner);
|
|
3708
4417
|
requestRender = invalidate;
|
|
3709
4418
|
animate();
|
|
3710
4419
|
scene.up.set(sceneUp.x, sceneUp.y, sceneUp.z);
|
|
@@ -3736,7 +4445,7 @@ const initThree = function(canvas, options) {
|
|
|
3736
4445
|
disposed = true;
|
|
3737
4446
|
disposeAnimation();
|
|
3738
4447
|
eventHandlers.dispose();
|
|
3739
|
-
|
|
4448
|
+
taps.dispose();
|
|
3740
4449
|
canvas.removeEventListener("click", handleToolClick, { capture: true });
|
|
3741
4450
|
canvas.removeEventListener("mousemove", handleToolMove);
|
|
3742
4451
|
measureTool?.dispose();
|