@pascal-app/viewer 0.9.1 → 0.9.2
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/components/error-boundary.d.ts +6 -0
- package/dist/components/error-boundary.d.ts.map +1 -1
- package/dist/components/error-boundary.js +6 -0
- package/dist/components/renderers/node-renderer.d.ts +5 -1
- package/dist/components/renderers/node-renderer.d.ts.map +1 -1
- package/dist/components/renderers/node-renderer.js +5 -2
- package/dist/components/renderers/parametric-node-renderer.d.ts.map +1 -1
- package/dist/components/renderers/parametric-node-renderer.js +7 -4
- package/dist/components/viewer/bvh-ecctrl.d.ts +77 -0
- package/dist/components/viewer/bvh-ecctrl.d.ts.map +1 -0
- package/dist/components/viewer/bvh-ecctrl.js +552 -0
- package/dist/components/viewer/frame-limiter.d.ts.map +1 -1
- package/dist/components/viewer/frame-limiter.js +39 -4
- package/dist/components/viewer/glb-interactive.d.ts +41 -0
- package/dist/components/viewer/glb-interactive.d.ts.map +1 -0
- package/dist/components/viewer/glb-interactive.js +394 -0
- package/dist/components/viewer/glb-reference-nodes.d.ts +29 -0
- package/dist/components/viewer/glb-reference-nodes.d.ts.map +1 -0
- package/dist/components/viewer/glb-reference-nodes.js +80 -0
- package/dist/components/viewer/glb-replace-instances.d.ts +18 -0
- package/dist/components/viewer/glb-replace-instances.d.ts.map +1 -0
- package/dist/components/viewer/glb-replace-instances.js +61 -0
- package/dist/components/viewer/glb-scene.d.ts +54 -0
- package/dist/components/viewer/glb-scene.d.ts.map +1 -0
- package/dist/components/viewer/glb-scene.js +920 -0
- package/dist/components/viewer/glb-walkthrough-controller.d.ts +12 -0
- package/dist/components/viewer/glb-walkthrough-controller.d.ts.map +1 -0
- package/dist/components/viewer/glb-walkthrough-controller.js +357 -0
- package/dist/components/viewer/index.d.ts +19 -2
- package/dist/components/viewer/index.d.ts.map +1 -1
- package/dist/components/viewer/index.js +104 -12
- package/dist/components/viewer/lights.d.ts.map +1 -1
- package/dist/components/viewer/lights.js +36 -5
- package/dist/components/viewer/perf-monitor.d.ts.map +1 -1
- package/dist/components/viewer/perf-monitor.js +17 -2
- package/dist/components/viewer/post-processing.d.ts +7 -1
- package/dist/components/viewer/post-processing.d.ts.map +1 -1
- package/dist/components/viewer/post-processing.js +179 -24
- package/dist/components/viewer/registered-systems.d.ts.map +1 -1
- package/dist/components/viewer/registered-systems.js +6 -4
- package/dist/components/viewer/scene-bvh.d.ts +2 -1
- package/dist/components/viewer/scene-bvh.d.ts.map +1 -1
- package/dist/components/viewer/scene-bvh.js +3 -0
- package/dist/components/viewer/scene-bvh.test.d.ts +2 -0
- package/dist/components/viewer/scene-bvh.test.d.ts.map +1 -0
- package/dist/components/viewer/scene-bvh.test.js +13 -0
- package/dist/components/viewer/scene-environment.d.ts +2 -0
- package/dist/components/viewer/scene-environment.d.ts.map +1 -0
- package/dist/components/viewer/scene-environment.js +79 -0
- package/dist/components/viewer/selection-manager.d.ts.map +1 -1
- package/dist/components/viewer/selection-manager.js +18 -10
- package/dist/components/viewer/walkthrough-controls.d.ts +1 -0
- package/dist/components/viewer/walkthrough-controls.d.ts.map +1 -1
- package/dist/components/viewer/walkthrough-controls.js +20 -0
- package/dist/hooks/use-gltf-ktx2.d.ts.map +1 -1
- package/dist/hooks/use-gltf-ktx2.js +3 -22
- package/dist/hooks/use-node-events.d.ts +1 -1
- package/dist/hooks/use-node-events.d.ts.map +1 -1
- package/dist/hooks/use-node-events.js +22 -14
- package/dist/index.d.ts +19 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +22 -8
- package/dist/lib/backdrop.d.ts +33 -0
- package/dist/lib/backdrop.d.ts.map +1 -0
- package/dist/lib/backdrop.js +92 -0
- package/dist/lib/box-uv.d.ts +13 -0
- package/dist/lib/box-uv.d.ts.map +1 -0
- package/dist/lib/box-uv.js +32 -0
- package/dist/lib/csg-utils.d.ts +3 -9
- package/dist/lib/csg-utils.d.ts.map +1 -1
- package/dist/lib/csg-utils.js +65 -5
- package/dist/lib/csg-utils.test.d.ts +2 -0
- package/dist/lib/csg-utils.test.d.ts.map +1 -0
- package/dist/lib/csg-utils.test.js +45 -0
- package/dist/lib/edge-style.d.ts +1 -0
- package/dist/lib/edge-style.d.ts.map +1 -1
- package/dist/lib/edge-style.js +26 -6
- package/dist/lib/ink-edges.d.ts +1 -1
- package/dist/lib/ink-edges.d.ts.map +1 -1
- package/dist/lib/ink-edges.js +13 -7
- package/dist/lib/ktx2-loader.d.ts +43 -0
- package/dist/lib/ktx2-loader.d.ts.map +1 -0
- package/dist/lib/ktx2-loader.js +136 -0
- package/dist/lib/layers.d.ts +10 -0
- package/dist/lib/layers.d.ts.map +1 -1
- package/dist/lib/layers.js +10 -0
- package/dist/lib/materials.d.ts +14 -1
- package/dist/lib/materials.d.ts.map +1 -1
- package/dist/lib/materials.js +103 -26
- package/dist/lib/scene-themes.d.ts +1 -0
- package/dist/lib/scene-themes.d.ts.map +1 -1
- package/dist/lib/scene-themes.js +25 -16
- package/dist/lib/shadow-only.d.ts +4 -0
- package/dist/lib/shadow-only.d.ts.map +1 -0
- package/dist/lib/shadow-only.js +36 -0
- package/dist/lib/tsl-compat.d.ts +12 -0
- package/dist/lib/tsl-compat.d.ts.map +1 -0
- package/dist/lib/tsl-compat.js +13 -0
- package/dist/store/use-viewer.d.ts +60 -18
- package/dist/store/use-viewer.d.ts.map +1 -1
- package/dist/store/use-viewer.js +175 -3
- package/dist/store/use-viewer.test.d.ts +2 -0
- package/dist/store/use-viewer.test.d.ts.map +1 -0
- package/dist/store/use-viewer.test.js +42 -0
- package/dist/systems/ceiling/ceiling-system.d.ts.map +1 -1
- package/dist/systems/ceiling/ceiling-system.js +4 -1
- package/dist/systems/door/door-animation.test.d.ts +2 -0
- package/dist/systems/door/door-animation.test.d.ts.map +1 -0
- package/dist/systems/door/door-animation.test.js +51 -0
- package/dist/systems/door/door-floor-alignment.test.d.ts +2 -0
- package/dist/systems/door/door-floor-alignment.test.d.ts.map +1 -0
- package/dist/systems/door/door-floor-alignment.test.js +50 -0
- package/dist/systems/door/door-system.d.ts +24 -0
- package/dist/systems/door/door-system.d.ts.map +1 -1
- package/dist/systems/door/door-system.js +494 -161
- package/dist/systems/fence/fence-system.d.ts +17 -0
- package/dist/systems/fence/fence-system.d.ts.map +1 -1
- package/dist/systems/fence/fence-system.js +221 -53
- package/dist/systems/geometry/geometry-system.d.ts +7 -0
- package/dist/systems/geometry/geometry-system.d.ts.map +1 -1
- package/dist/systems/geometry/geometry-system.js +95 -26
- package/dist/systems/geometry/geometry-system.test.d.ts +2 -0
- package/dist/systems/geometry/geometry-system.test.d.ts.map +1 -0
- package/dist/systems/geometry/geometry-system.test.js +30 -0
- package/dist/systems/interactive/control-widget.d.ts +10 -0
- package/dist/systems/interactive/control-widget.d.ts.map +1 -0
- package/dist/systems/interactive/control-widget.js +35 -0
- package/dist/systems/interactive/interactive-system.d.ts.map +1 -1
- package/dist/systems/interactive/interactive-system.js +55 -56
- package/dist/systems/item/item-system.d.ts.map +1 -1
- package/dist/systems/item/item-system.js +20 -6
- package/dist/systems/level/level-system.d.ts.map +1 -1
- package/dist/systems/level/level-system.js +28 -4
- package/dist/systems/level/level-utils.d.ts +0 -3
- package/dist/systems/level/level-utils.d.ts.map +1 -1
- package/dist/systems/level/level-utils.js +2 -41
- package/dist/systems/roof/roof-materials.d.ts.map +1 -1
- package/dist/systems/roof/roof-materials.js +29 -8
- package/dist/systems/roof/roof-system.d.ts +21 -8
- package/dist/systems/roof/roof-system.d.ts.map +1 -1
- package/dist/systems/roof/roof-system.js +1079 -221
- package/dist/systems/slab/slab-system.d.ts +5 -3
- package/dist/systems/slab/slab-system.d.ts.map +1 -1
- package/dist/systems/slab/slab-system.js +41 -23
- package/dist/systems/slab/slab-system.test.js +3 -2
- package/dist/systems/stair/stair-system.js +6 -3
- package/dist/systems/wall/opening-cutout-geometry.d.ts +31 -0
- package/dist/systems/wall/opening-cutout-geometry.d.ts.map +1 -0
- package/dist/systems/wall/opening-cutout-geometry.js +167 -0
- package/dist/systems/wall/opening-cutout-geometry.test.d.ts +2 -0
- package/dist/systems/wall/opening-cutout-geometry.test.d.ts.map +1 -0
- package/dist/systems/wall/opening-cutout-geometry.test.js +172 -0
- package/dist/systems/wall/wall-cutout.d.ts.map +1 -1
- package/dist/systems/wall/wall-cutout.js +46 -17
- package/dist/systems/wall/wall-materials.d.ts +11 -7
- package/dist/systems/wall/wall-materials.d.ts.map +1 -1
- package/dist/systems/wall/wall-materials.js +244 -35
- package/dist/systems/wall/wall-opening-cutout.test.d.ts +2 -0
- package/dist/systems/wall/wall-opening-cutout.test.d.ts.map +1 -0
- package/dist/systems/wall/wall-opening-cutout.test.js +59 -0
- package/dist/systems/wall/wall-support-extension.test.d.ts +2 -0
- package/dist/systems/wall/wall-support-extension.test.d.ts.map +1 -0
- package/dist/systems/wall/wall-support-extension.test.js +48 -0
- package/dist/systems/wall/wall-system.d.ts +2 -2
- package/dist/systems/wall/wall-system.d.ts.map +1 -1
- package/dist/systems/wall/wall-system.js +329 -174
- package/dist/systems/window/window-animation-system.d.ts +12 -0
- package/dist/systems/window/window-animation-system.d.ts.map +1 -1
- package/dist/systems/window/window-animation-system.js +16 -5
- package/dist/systems/window/window-system.d.ts +7 -0
- package/dist/systems/window/window-system.d.ts.map +1 -1
- package/dist/systems/window/window-system.js +240 -14
- package/package.json +4 -4
- package/dist/components/viewer/ground-occluder.d.ts +0 -2
- package/dist/components/viewer/ground-occluder.d.ts.map +0 -1
- package/dist/components/viewer/ground-occluder.js +0 -67
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-person walkthrough controller for the baked GLB. Reuses the editor's
|
|
3
|
+
* `BVHEcctrl` capsule character controller (gravity, jump, sprint, ground-float,
|
|
4
|
+
* mesh collision) fed a collider built from the artifact's own geometry — so the
|
|
5
|
+
* baked viewer walks the building with the same physics as the editor, without
|
|
6
|
+
* the parametric scene. Pointer-lock drives look; WASD moves; Space jumps; Shift
|
|
7
|
+
* sprints. Door/window interaction stays in `GlbScene` (its centre-ray HUD).
|
|
8
|
+
*/
|
|
9
|
+
export declare function GlbWalkthroughController({ url }: {
|
|
10
|
+
url: string;
|
|
11
|
+
}): import("react").JSX.Element | null;
|
|
12
|
+
//# sourceMappingURL=glb-walkthrough-controller.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"glb-walkthrough-controller.d.ts","sourceRoot":"","sources":["../../../src/components/viewer/glb-walkthrough-controller.tsx"],"names":[],"mappings":"AAgOA;;;;;;;GAOG;AACH,wBAAgB,wBAAwB,CAAC,EAAE,GAAG,EAAE,EAAE;IAAE,GAAG,EAAE,MAAM,CAAA;CAAE,sCAyLhE"}
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { KeyboardControls } from '@react-three/drei';
|
|
4
|
+
import { useFrame, useThree } from '@react-three/fiber';
|
|
5
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
6
|
+
import { Box3, BoxGeometry, BufferGeometry, Euler, Float32BufferAttribute, Matrix4, Mesh, MeshBasicMaterial, Quaternion, Vector3, } from 'three';
|
|
7
|
+
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
|
|
8
|
+
import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh';
|
|
9
|
+
import { useGLTFKTX2 } from '../../hooks/use-gltf-ktx2';
|
|
10
|
+
import { SCENE_LAYER } from '../../lib/layers';
|
|
11
|
+
import useViewer from '../../store/use-viewer';
|
|
12
|
+
import BVHEcctrl, {} from './bvh-ecctrl';
|
|
13
|
+
import { WALKTHROUGH_FOV } from './walkthrough-controls';
|
|
14
|
+
// Eye/capsule geometry mirrors the editor's first-person controller so the
|
|
15
|
+
// baked walkthrough feels identical. The capsule centre sits below the eye; the
|
|
16
|
+
// camera rides the capsule with a small offset and the controller floats it to
|
|
17
|
+
// the ground.
|
|
18
|
+
const CAMERA_EYE_OFFSET = 0.45;
|
|
19
|
+
const CONTROLLER_CENTER_FROM_EYE = 0.85;
|
|
20
|
+
const SPAWN_EYE_HEIGHT = 1.65;
|
|
21
|
+
const LOOK_SENSITIVITY = 0.002;
|
|
22
|
+
const VOID_FALL_RESPAWN_DEPTH = 12;
|
|
23
|
+
// Kinds that must not block the player: room helpers, the spawn marker, the
|
|
24
|
+
// ceiling/roof shell (you walk under them), and door/window leaves — excluding
|
|
25
|
+
// the latter lets you pass any doorway whether the leaf is open or shut (the
|
|
26
|
+
// wall already has the opening cut into its baked geometry).
|
|
27
|
+
const COLLIDER_EXCLUDED_KINDS = new Set(['zone', 'spawn', 'ceiling', 'roof', 'door', 'window']);
|
|
28
|
+
const colliderMaterial = new MeshBasicMaterial({ visible: false });
|
|
29
|
+
const keyboardMap = [
|
|
30
|
+
{ name: 'forward', keys: ['ArrowUp', 'KeyW'] },
|
|
31
|
+
{ name: 'backward', keys: ['ArrowDown', 'KeyS'] },
|
|
32
|
+
{ name: 'leftward', keys: ['ArrowLeft', 'KeyA'] },
|
|
33
|
+
{ name: 'rightward', keys: ['ArrowRight', 'KeyD'] },
|
|
34
|
+
{ name: 'jump', keys: ['Space'] },
|
|
35
|
+
{ name: 'run', keys: ['ShiftLeft', 'ShiftRight'] },
|
|
36
|
+
];
|
|
37
|
+
const cameraOffset = new Vector3(0, CAMERA_EYE_OFFSET, 0);
|
|
38
|
+
const cameraEuler = new Euler(0, 0, 0, 'YXZ');
|
|
39
|
+
const spawnQuat = new Quaternion();
|
|
40
|
+
const spawnEuler = new Euler(0, 0, 0, 'YXZ');
|
|
41
|
+
const spawnPos = new Vector3();
|
|
42
|
+
/** Effective visibility — an invisible ancestor hides the whole subtree. */
|
|
43
|
+
function isEffectivelyVisible(object) {
|
|
44
|
+
let current = object;
|
|
45
|
+
while (current) {
|
|
46
|
+
if (!current.visible)
|
|
47
|
+
return false;
|
|
48
|
+
current = current.parent;
|
|
49
|
+
}
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
function kindOf(object) {
|
|
53
|
+
let current = object;
|
|
54
|
+
while (current) {
|
|
55
|
+
const kind = current.userData.kind;
|
|
56
|
+
if (kind)
|
|
57
|
+
return kind;
|
|
58
|
+
current = current.parent;
|
|
59
|
+
}
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
// Coerce any position attribute (quantized/interleaved) to a plain Float32 one
|
|
63
|
+
// so mergeGeometries can combine geometries that don't share an array type.
|
|
64
|
+
function toFloat32Position(source) {
|
|
65
|
+
const array = new Float32Array(source.count * 3);
|
|
66
|
+
for (let i = 0; i < source.count; i++) {
|
|
67
|
+
array[i * 3] = source.getX(i);
|
|
68
|
+
array[i * 3 + 1] = source.getY(i);
|
|
69
|
+
array[i * 3 + 2] = source.getZ(i);
|
|
70
|
+
}
|
|
71
|
+
return new Float32BufferAttribute(array, 3);
|
|
72
|
+
}
|
|
73
|
+
const FALLBACK_THICKNESS = 0.08;
|
|
74
|
+
const GROUND_MIN = 2000;
|
|
75
|
+
/** A thin position-only floor box whose top face sits at `topY`. */
|
|
76
|
+
function boxFloorGeometry(cx, topY, cz, width, depth) {
|
|
77
|
+
const box = new BoxGeometry(width, FALLBACK_THICKNESS, depth).toNonIndexed();
|
|
78
|
+
const geometry = new BufferGeometry();
|
|
79
|
+
geometry.setAttribute('position', box.getAttribute('position').clone());
|
|
80
|
+
box.dispose();
|
|
81
|
+
geometry.applyMatrix4(new Matrix4().makeTranslation(cx, topY - FALLBACK_THICKNESS / 2, cz));
|
|
82
|
+
return geometry;
|
|
83
|
+
}
|
|
84
|
+
// Fallback ground so the player never falls into the void: a single large
|
|
85
|
+
// ground plane at the lowest level (level 0). Upper levels rely on their own
|
|
86
|
+
// baked slabs — a slab-less upper floor lets you fall down to the ground, which
|
|
87
|
+
// the ground plane catches. (Mirrors the editor walkthrough's site ground.)
|
|
88
|
+
function addFallbackFloors(scene, geometries) {
|
|
89
|
+
const sceneBounds = new Box3();
|
|
90
|
+
for (const geometry of geometries) {
|
|
91
|
+
geometry.computeBoundingBox();
|
|
92
|
+
if (geometry.boundingBox)
|
|
93
|
+
sceneBounds.union(geometry.boundingBox);
|
|
94
|
+
}
|
|
95
|
+
const center = new Vector3();
|
|
96
|
+
const levelPos = new Vector3();
|
|
97
|
+
let lowestLevelY = Number.POSITIVE_INFINITY;
|
|
98
|
+
scene.traverse((object) => {
|
|
99
|
+
if (object.userData.kind !== 'level')
|
|
100
|
+
return;
|
|
101
|
+
object.updateWorldMatrix(true, false);
|
|
102
|
+
object.getWorldPosition(levelPos);
|
|
103
|
+
lowestLevelY = Math.min(lowestLevelY, levelPos.y);
|
|
104
|
+
});
|
|
105
|
+
if (sceneBounds.isEmpty())
|
|
106
|
+
return;
|
|
107
|
+
sceneBounds.getCenter(center);
|
|
108
|
+
const groundY = Number.isFinite(lowestLevelY) ? lowestLevelY : sceneBounds.min.y;
|
|
109
|
+
geometries.push(boxFloorGeometry(center.x, groundY, center.z, GROUND_MIN, GROUND_MIN));
|
|
110
|
+
}
|
|
111
|
+
/** Merge the baked GLB's walkable/blocking meshes into one BVH collider. */
|
|
112
|
+
function buildGlbColliderWorld(scene) {
|
|
113
|
+
scene.updateWorldMatrix(true, true);
|
|
114
|
+
const geometries = [];
|
|
115
|
+
scene.traverse((object) => {
|
|
116
|
+
const mesh = object;
|
|
117
|
+
if (!mesh.isMesh)
|
|
118
|
+
return;
|
|
119
|
+
// Zone fills live on a separate layer (and never collide).
|
|
120
|
+
if (!mesh.layers.isEnabled(SCENE_LAYER))
|
|
121
|
+
return;
|
|
122
|
+
if (!isEffectivelyVisible(mesh))
|
|
123
|
+
return;
|
|
124
|
+
const kind = kindOf(mesh);
|
|
125
|
+
if (kind && COLLIDER_EXCLUDED_KINDS.has(kind))
|
|
126
|
+
return;
|
|
127
|
+
const position = mesh.geometry?.getAttribute('position');
|
|
128
|
+
if (!position || position.count < 3)
|
|
129
|
+
return;
|
|
130
|
+
const geometry = new BufferGeometry();
|
|
131
|
+
const source = mesh.geometry.index ? mesh.geometry.toNonIndexed() : mesh.geometry;
|
|
132
|
+
geometry.setAttribute('position', toFloat32Position(source.getAttribute('position')));
|
|
133
|
+
if (mesh.geometry.index)
|
|
134
|
+
source.dispose();
|
|
135
|
+
geometry.applyMatrix4(mesh.matrixWorld);
|
|
136
|
+
geometries.push(geometry);
|
|
137
|
+
});
|
|
138
|
+
if (geometries.length === 0)
|
|
139
|
+
return null;
|
|
140
|
+
addFallbackFloors(scene, geometries);
|
|
141
|
+
const merged = mergeGeometries(geometries, false);
|
|
142
|
+
for (const geometry of geometries)
|
|
143
|
+
geometry.dispose();
|
|
144
|
+
if (!merged || merged.getAttribute('position') == null) {
|
|
145
|
+
merged?.dispose();
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
;
|
|
149
|
+
merged.computeBoundsTree = computeBoundsTree;
|
|
150
|
+
merged.disposeBoundsTree = disposeBoundsTree;
|
|
151
|
+
merged.computeBoundsTree({ maxLeafSize: 12, strategy: 0 });
|
|
152
|
+
merged.computeBoundingBox();
|
|
153
|
+
const mesh = new Mesh(merged, colliderMaterial);
|
|
154
|
+
mesh.raycast = acceleratedRaycast;
|
|
155
|
+
mesh.visible = true;
|
|
156
|
+
mesh.userData = {
|
|
157
|
+
type: 'STATIC',
|
|
158
|
+
friction: 0.8,
|
|
159
|
+
restitution: 0.05,
|
|
160
|
+
excludeFloatHit: false,
|
|
161
|
+
excludeCollisionCheck: false,
|
|
162
|
+
};
|
|
163
|
+
mesh.updateMatrixWorld(true);
|
|
164
|
+
return {
|
|
165
|
+
mesh,
|
|
166
|
+
minY: merged.boundingBox?.min.y ?? 0,
|
|
167
|
+
dispose: () => {
|
|
168
|
+
;
|
|
169
|
+
merged.disposeBoundsTree?.();
|
|
170
|
+
merged.dispose();
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
/** The baked spawn marker's eye position + yaw, if the artifact carries one. */
|
|
175
|
+
function resolveGlbSpawn(scene) {
|
|
176
|
+
let spawn = null;
|
|
177
|
+
scene.traverse((object) => {
|
|
178
|
+
if (object.userData.kind === 'spawn')
|
|
179
|
+
spawn = object;
|
|
180
|
+
});
|
|
181
|
+
if (!spawn)
|
|
182
|
+
return null;
|
|
183
|
+
const node = spawn;
|
|
184
|
+
node.updateWorldMatrix(true, false);
|
|
185
|
+
node.getWorldPosition(spawnPos);
|
|
186
|
+
node.getWorldQuaternion(spawnQuat);
|
|
187
|
+
spawnEuler.setFromQuaternion(spawnQuat, 'YXZ');
|
|
188
|
+
return {
|
|
189
|
+
position: [spawnPos.x, spawnPos.y + SPAWN_EYE_HEIGHT, spawnPos.z],
|
|
190
|
+
yaw: spawnEuler.y,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* First-person walkthrough controller for the baked GLB. Reuses the editor's
|
|
195
|
+
* `BVHEcctrl` capsule character controller (gravity, jump, sprint, ground-float,
|
|
196
|
+
* mesh collision) fed a collider built from the artifact's own geometry — so the
|
|
197
|
+
* baked viewer walks the building with the same physics as the editor, without
|
|
198
|
+
* the parametric scene. Pointer-lock drives look; WASD moves; Space jumps; Shift
|
|
199
|
+
* sprints. Door/window interaction stays in `GlbScene` (its centre-ray HUD).
|
|
200
|
+
*/
|
|
201
|
+
export function GlbWalkthroughController({ url }) {
|
|
202
|
+
const { camera, gl } = useThree();
|
|
203
|
+
const gltf = useGLTFKTX2(url);
|
|
204
|
+
const worldRef = useRef(null);
|
|
205
|
+
const controllerRef = useRef(null);
|
|
206
|
+
const yawRef = useRef(0);
|
|
207
|
+
const pitchRef = useRef(0);
|
|
208
|
+
const [start, setStart] = useState(null);
|
|
209
|
+
const [world, setWorld] = useState(null);
|
|
210
|
+
// Build the collider on the first frame (priority after GlbScene's level loop,
|
|
211
|
+
// which snaps the floors to their stacked world positions in walkthrough) so it
|
|
212
|
+
// matches the rendered building rather than a mid-lerp / exploded layout.
|
|
213
|
+
const builtRef = useRef(false);
|
|
214
|
+
useFrame(() => {
|
|
215
|
+
if (builtRef.current)
|
|
216
|
+
return;
|
|
217
|
+
builtRef.current = true;
|
|
218
|
+
setWorld(buildGlbColliderWorld(gltf.scene));
|
|
219
|
+
}, 6);
|
|
220
|
+
// First-person needs a perspective camera — an orthographic projection has no
|
|
221
|
+
// foreshortening and makes the walkthrough unusable. Force perspective while
|
|
222
|
+
// walking and restore the prior projection on exit.
|
|
223
|
+
useEffect(() => {
|
|
224
|
+
const prevMode = useViewer.getState().cameraMode;
|
|
225
|
+
if (prevMode === 'orthographic')
|
|
226
|
+
useViewer.getState().setCameraMode('perspective');
|
|
227
|
+
return () => {
|
|
228
|
+
if (prevMode === 'orthographic')
|
|
229
|
+
useViewer.getState().setCameraMode('orthographic');
|
|
230
|
+
};
|
|
231
|
+
}, []);
|
|
232
|
+
// Widen FOV while walking; the baked walkthrough rides the default 50° orbit
|
|
233
|
+
// camera, which feels cramped on foot. Keyed on `camera` so it re-applies if
|
|
234
|
+
// the instance swaps (e.g. the ortho→perspective switch above), restoring the
|
|
235
|
+
// prior FOV on exit.
|
|
236
|
+
useEffect(() => {
|
|
237
|
+
const cam = camera;
|
|
238
|
+
if (!cam.isPerspectiveCamera)
|
|
239
|
+
return;
|
|
240
|
+
const prevFov = cam.fov;
|
|
241
|
+
cam.fov = WALKTHROUGH_FOV;
|
|
242
|
+
cam.updateProjectionMatrix();
|
|
243
|
+
return () => {
|
|
244
|
+
cam.fov = prevFov;
|
|
245
|
+
cam.updateProjectionMatrix();
|
|
246
|
+
};
|
|
247
|
+
}, [camera]);
|
|
248
|
+
useEffect(() => {
|
|
249
|
+
worldRef.current = world;
|
|
250
|
+
if (world) {
|
|
251
|
+
const triangles = world.mesh.geometry.getAttribute('position').count / 3;
|
|
252
|
+
console.warn('[glb-walkthrough] collider built', {
|
|
253
|
+
triangles,
|
|
254
|
+
minY: world.minY,
|
|
255
|
+
hasBoundsTree: !!world.mesh.geometry.boundsTree,
|
|
256
|
+
spawn: resolveGlbSpawn(gltf.scene),
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
console.warn('[glb-walkthrough] NO collider world (no eligible meshes)');
|
|
261
|
+
}
|
|
262
|
+
return () => {
|
|
263
|
+
world?.dispose();
|
|
264
|
+
worldRef.current = null;
|
|
265
|
+
};
|
|
266
|
+
}, [world, gltf.scene]);
|
|
267
|
+
// Resolve the spawn once the collider exists; the capsule centre sits below
|
|
268
|
+
// the eye, then the controller floats it onto the ground.
|
|
269
|
+
useEffect(() => {
|
|
270
|
+
if (!world || start)
|
|
271
|
+
return;
|
|
272
|
+
const spawn = resolveGlbSpawn(gltf.scene);
|
|
273
|
+
const eye = spawn?.position ?? [0, SPAWN_EYE_HEIGHT, 0];
|
|
274
|
+
yawRef.current = spawn?.yaw ?? 0;
|
|
275
|
+
pitchRef.current = 0;
|
|
276
|
+
setStart({ position: [eye[0], eye[1] - CONTROLLER_CENTER_FROM_EYE, eye[2]] });
|
|
277
|
+
}, [world, start, gltf.scene]);
|
|
278
|
+
// Pointer-lock look + click-to-lock fallback + Esc/unlock to exit. Once the
|
|
279
|
+
// pointer has been locked, releasing it (Esc — the browser swallows that
|
|
280
|
+
// keydown, so we can't rely on it; or any other unlock) leaves the walkthrough
|
|
281
|
+
// in a single press rather than just freeing the cursor.
|
|
282
|
+
useEffect(() => {
|
|
283
|
+
const canvas = gl.domElement;
|
|
284
|
+
let wasLocked = false;
|
|
285
|
+
const onMouseMove = (event) => {
|
|
286
|
+
if (document.pointerLockElement !== canvas)
|
|
287
|
+
return;
|
|
288
|
+
yawRef.current -= event.movementX * LOOK_SENSITIVITY;
|
|
289
|
+
pitchRef.current = Math.max(-(Math.PI / 2 - 0.05), Math.min(Math.PI / 2 - 0.05, pitchRef.current - event.movementY * LOOK_SENSITIVITY));
|
|
290
|
+
};
|
|
291
|
+
const onClick = () => {
|
|
292
|
+
if (document.pointerLockElement !== canvas)
|
|
293
|
+
canvas.requestPointerLock?.();
|
|
294
|
+
};
|
|
295
|
+
const onKeyDown = (event) => {
|
|
296
|
+
// When locked, the browser intercepts Esc to release the pointer and the
|
|
297
|
+
// pointerlockchange handler below exits; this only covers Esc while the
|
|
298
|
+
// pointer is already free (e.g. lock never engaged).
|
|
299
|
+
if (event.code === 'Escape' && document.pointerLockElement !== canvas) {
|
|
300
|
+
useViewer.getState().setWalkthroughMode(false);
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
const onPointerLockChange = () => {
|
|
304
|
+
if (document.pointerLockElement === canvas)
|
|
305
|
+
wasLocked = true;
|
|
306
|
+
else if (wasLocked)
|
|
307
|
+
useViewer.getState().setWalkthroughMode(false);
|
|
308
|
+
};
|
|
309
|
+
document.addEventListener('mousemove', onMouseMove);
|
|
310
|
+
canvas.addEventListener('click', onClick);
|
|
311
|
+
document.addEventListener('keydown', onKeyDown);
|
|
312
|
+
document.addEventListener('pointerlockchange', onPointerLockChange);
|
|
313
|
+
return () => {
|
|
314
|
+
document.removeEventListener('mousemove', onMouseMove);
|
|
315
|
+
canvas.removeEventListener('click', onClick);
|
|
316
|
+
document.removeEventListener('keydown', onKeyDown);
|
|
317
|
+
document.removeEventListener('pointerlockchange', onPointerLockChange);
|
|
318
|
+
if (document.pointerLockElement === canvas)
|
|
319
|
+
document.exitPointerLock();
|
|
320
|
+
};
|
|
321
|
+
}, [gl]);
|
|
322
|
+
// Lock the pointer the moment the walkthrough is ready, so the user doesn't
|
|
323
|
+
// have to click the canvas first. The walkthrough toggle is itself a user
|
|
324
|
+
// gesture; if the browser still rejects the request (no transient activation
|
|
325
|
+
// left), the click-to-lock fallback above covers it.
|
|
326
|
+
useEffect(() => {
|
|
327
|
+
if (!(world && start))
|
|
328
|
+
return;
|
|
329
|
+
const canvas = gl.domElement;
|
|
330
|
+
if (document.pointerLockElement === canvas)
|
|
331
|
+
return;
|
|
332
|
+
const result = canvas.requestPointerLock?.();
|
|
333
|
+
if (result && typeof result.catch === 'function')
|
|
334
|
+
result.catch(() => { });
|
|
335
|
+
}, [gl, world, start]);
|
|
336
|
+
const setControllerApi = useCallback((api) => {
|
|
337
|
+
controllerRef.current = api;
|
|
338
|
+
}, []);
|
|
339
|
+
// Drive the camera from the capsule each frame + respawn if it falls into void.
|
|
340
|
+
useFrame(() => {
|
|
341
|
+
const group = controllerRef.current?.group;
|
|
342
|
+
if (!group)
|
|
343
|
+
return;
|
|
344
|
+
if (start && world && group.position.y < world.minY - VOID_FALL_RESPAWN_DEPTH) {
|
|
345
|
+
group.position.set(start.position[0], start.position[1], start.position[2]);
|
|
346
|
+
controllerRef.current?.resetLinVel();
|
|
347
|
+
}
|
|
348
|
+
group.rotation.y = 0;
|
|
349
|
+
camera.position.copy(group.position).add(cameraOffset);
|
|
350
|
+
cameraEuler.set(pitchRef.current, yawRef.current, 0, 'YXZ');
|
|
351
|
+
camera.quaternion.setFromEuler(cameraEuler);
|
|
352
|
+
camera.updateMatrixWorld(true);
|
|
353
|
+
}, 2.5);
|
|
354
|
+
if (!(world && start))
|
|
355
|
+
return null;
|
|
356
|
+
return (_jsx(KeyboardControls, { map: keyboardMap, children: _jsx(BVHEcctrl, { acceleration: 26, airDragFactor: 0.3, colliderCapsuleArgs: [0.25, 0.8, 4, 8], colliderMeshes: [world.mesh], collisionCheckIteration: 3, collisionPushBackDamping: 0.1, collisionPushBackThreshold: 0.001, deceleration: 30, delay: 0, fallGravityFactor: 4, floatCheckType: "BOTH", floatDampingC: 36, floatHeight: 0.5, floatPullBackHeight: 0.35, floatSensorRadius: 0.15, floatSpringK: 1200, gravity: 9.81, jumpVel: 5, maxRunSpeed: 5, maxSlope: 1.2, maxWalkSpeed: 2, position: start.position, ref: setControllerApi }) }));
|
|
357
|
+
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { type AnyNodeId } from '@pascal-app/core';
|
|
2
|
-
import { type
|
|
2
|
+
import { type ThreeElement } from '@react-three/fiber';
|
|
3
3
|
import * as THREE from 'three/webgpu';
|
|
4
4
|
import type { ColorPreset, RenderShading } from '../../lib/materials';
|
|
5
5
|
import { type RenderContext } from '../../store/use-viewer';
|
|
6
6
|
import { type HoverStyles } from './post-processing';
|
|
7
7
|
declare module '@react-three/fiber' {
|
|
8
|
-
interface ThreeElements
|
|
8
|
+
interface ThreeElements {
|
|
9
|
+
lineBasicNodeMaterial: ThreeElement<typeof THREE.LineBasicNodeMaterial>;
|
|
9
10
|
}
|
|
10
11
|
}
|
|
11
12
|
interface ViewerProps {
|
|
@@ -15,6 +16,7 @@ interface ViewerProps {
|
|
|
15
16
|
perf?: boolean;
|
|
16
17
|
useBvh?: boolean;
|
|
17
18
|
renderContext?: RenderContext;
|
|
19
|
+
transparent?: boolean;
|
|
18
20
|
defaultRender?: {
|
|
19
21
|
shading?: RenderShading;
|
|
20
22
|
textures?: boolean;
|
|
@@ -37,6 +39,21 @@ interface ViewerProps {
|
|
|
37
39
|
*/
|
|
38
40
|
sceneReadyKey?: string | number | null;
|
|
39
41
|
onSceneReadyChange?: (ready: boolean) => void;
|
|
42
|
+
/**
|
|
43
|
+
* Wall-clock give-up cap for scene readiness, replacing the default
|
|
44
|
+
* frame-count cap. Set it on hosts whose frame cadence is decoupled from
|
|
45
|
+
* real time (the headless bake page's timer-driven loop runs the default
|
|
46
|
+
* 180-frame cap in ~3.6s — shorter than a cold item-model download).
|
|
47
|
+
*/
|
|
48
|
+
sceneReadyMaxWaitMs?: number;
|
|
49
|
+
/**
|
|
50
|
+
* Skip the TSL post-processing pipeline (SSGI/denoise/ink/outline) and render
|
|
51
|
+
* the scene directly. For headless/capture surfaces (the bake page) where
|
|
52
|
+
* frame quality is irrelevant: on a software-rasterised worker the pipeline
|
|
53
|
+
* consumes the whole CPU budget and bakes time out. Equivalent to the
|
|
54
|
+
* `?disable=postFx` diagnostic URL flag, but host-controlled.
|
|
55
|
+
*/
|
|
56
|
+
disablePostFx?: boolean;
|
|
40
57
|
}
|
|
41
58
|
/** Imperative handle exposed via `ref` on `<Viewer>`. */
|
|
42
59
|
export type ViewerHandle = {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/viewer/index.tsx"],"names":[],"mappings":"AAEA,OAAO,EACL,KAAK,SAAS,EAKf,MAAM,kBAAkB,CAAA;AACzB,OAAO,EAAkB,KAAK,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/viewer/index.tsx"],"names":[],"mappings":"AAEA,OAAO,EACL,KAAK,SAAS,EAKf,MAAM,kBAAkB,CAAA;AACzB,OAAO,EAAkB,KAAK,YAAY,EAAsB,MAAM,oBAAoB,CAAA;AAS1F,OAAO,KAAK,KAAK,MAAM,cAAc,CAAA;AAKrC,OAAO,KAAK,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAA;AAErE,OAAkB,EAAE,KAAK,aAAa,EAAE,MAAM,wBAAwB,CAAA;AAQtE,OAAuB,EAAwB,KAAK,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAM1F,OAAO,QAAQ,oBAAoB,CAAC;IAOlC,UAAU,aAAa;QACrB,qBAAqB,EAAE,YAAY,CAAC,OAAO,KAAK,CAAC,qBAAqB,CAAC,CAAA;KACxE;CACF;AAuSD,UAAU,WAAW;IACnB,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAA;IAC1B,WAAW,CAAC,EAAE,WAAW,CAAA;IACzB,gBAAgB,CAAC,EAAE,SAAS,GAAG,QAAQ,CAAA;IACvC,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,aAAa,CAAC,EAAE,aAAa,CAAA;IAC7B,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB,aAAa,CAAC,EAAE;QACd,OAAO,CAAC,EAAE,aAAa,CAAA;QACvB,QAAQ,CAAC,EAAE,OAAO,CAAA;QAClB,WAAW,CAAC,EAAE,WAAW,CAAA;KAC1B,CAAA;IACD;;;;;;;OAOG;IACH,OAAO,CAAC,EAAE,SAAS,EAAE,GAAG,IAAI,CAAA;IAC5B;;;;;OAKG;IACH,aAAa,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAA;IACtC,kBAAkB,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAA;IAC7C;;;;;OAKG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAA;IAC5B;;;;;;OAMG;IACH,aAAa,CAAC,EAAE,OAAO,CAAA;CACxB;AAED,yDAAyD;AACzD,MAAM,MAAM,YAAY,GAAG;IACzB;;;;;OAKG;IACH,WAAW,CAAC,GAAG,EAAE,SAAS,EAAE,GAAG,IAAI,GAAG,IAAI,CAAA;CAC3C,CAAA;AAED,QAAA,MAAM,MAAM,sGAoNV,CAAA;AAkBF,eAAe,MAAM,CAAA"}
|
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { nodeRegistry, StairOpeningSystem, sceneRegistry, useScene, } from '@pascal-app/core';
|
|
4
4
|
import { Canvas, extend, useFrame, useThree } from '@react-three/fiber';
|
|
5
|
-
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
|
|
5
|
+
import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState, } from 'react';
|
|
6
6
|
import * as THREE from 'three/webgpu';
|
|
7
7
|
import { hasDrawableGeometry } from '../../lib/drawable-geometry';
|
|
8
8
|
import { PERF_OVERLAY_ENABLED, pushGpuSample } from '../../lib/gpu-perf';
|
|
9
9
|
import { applyIsolation, clearIsolation } from '../../lib/isolation';
|
|
10
|
+
import { ensureKtx2Support } from '../../lib/ktx2-loader';
|
|
10
11
|
import { getSceneTheme } from '../../lib/scene-themes';
|
|
11
12
|
import useViewer, {} from '../../store/use-viewer';
|
|
12
13
|
import { FloorElevationSystem } from '../../systems/floor-elevation/floor-elevation-system';
|
|
@@ -51,6 +52,27 @@ const DIRTY_BUILD_KINDS = new Set([
|
|
|
51
52
|
'window',
|
|
52
53
|
]);
|
|
53
54
|
const warnedEmptyDraw = process.env.NODE_ENV === 'production' ? null : new WeakSet();
|
|
55
|
+
function canCreateWebGLContext() {
|
|
56
|
+
if (typeof document === 'undefined')
|
|
57
|
+
return false;
|
|
58
|
+
const canvas = document.createElement('canvas');
|
|
59
|
+
try {
|
|
60
|
+
return Boolean(canvas.getContext('webgl2') ?? canvas.getContext('webgl'));
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function canMountGpuViewer() {
|
|
67
|
+
if (typeof window === 'undefined')
|
|
68
|
+
return false;
|
|
69
|
+
if (!('gpu' in navigator) && !canCreateWebGLContext())
|
|
70
|
+
return false;
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
function UnsupportedGpuViewerFallback() {
|
|
74
|
+
return (_jsx("div", { className: "flex h-full min-h-64 w-full items-center justify-center bg-[#fafafa] p-6 text-center text-neutral-900", children: _jsxs("div", { className: "max-w-md rounded-2xl border border-neutral-200 bg-white p-6 shadow-sm", children: [_jsx("h2", { className: "font-semibold text-lg", children: "3D viewer unavailable" }), _jsx("p", { className: "mt-2 text-neutral-600 text-sm", children: "This browser or environment does not expose WebGPU or WebGL, so Pascal cannot render the 3D scene here. Try opening the editor in a browser with hardware acceleration enabled." })] }) }));
|
|
75
|
+
}
|
|
54
76
|
/**
|
|
55
77
|
* Renderer-level safety net against the empty-vertex-buffer crash.
|
|
56
78
|
*
|
|
@@ -83,6 +105,10 @@ function installEmptyDrawGuard(renderer) {
|
|
|
83
105
|
function GPUDeviceWatcher() {
|
|
84
106
|
const gl = useThree((s) => s.gl);
|
|
85
107
|
useEffect(() => {
|
|
108
|
+
// Detect KTX2 transcode support as soon as the renderer exists, so catalog
|
|
109
|
+
// `.ktx2` finish textures load even in scenes with no GLB items (whose
|
|
110
|
+
// loader would otherwise be the only thing to call this).
|
|
111
|
+
ensureKtx2Support(gl);
|
|
86
112
|
const backend = gl.backend;
|
|
87
113
|
const device = backend?.device;
|
|
88
114
|
if (!device) {
|
|
@@ -123,11 +149,23 @@ function ToneMappingExposure() {
|
|
|
123
149
|
return null;
|
|
124
150
|
}
|
|
125
151
|
function hasPendingSceneBuildWork() {
|
|
126
|
-
const { dirtyNodes, nodes } = useScene.getState();
|
|
152
|
+
const { dirtyNodes, nodes, rootNodeIds } = useScene.getState();
|
|
127
153
|
for (const id of dirtyNodes) {
|
|
128
154
|
const node = nodes[id];
|
|
129
155
|
if (!node)
|
|
130
156
|
continue;
|
|
157
|
+
// Unreachable nodes (orphaned by a broken detach: the parent doesn't list
|
|
158
|
+
// them in `children`, or no parent and not a root) never render — every
|
|
159
|
+
// renderer enumerates the parent's `children` array — so no system will
|
|
160
|
+
// ever build them or clear their mark. They must not hold scene-ready
|
|
161
|
+
// hostage (observed in prod scene data: two dangling windows kept every
|
|
162
|
+
// bake of that scene waiting out the full readiness cap).
|
|
163
|
+
const parent = node.parentId ? nodes[node.parentId] : undefined;
|
|
164
|
+
const reachable = parent
|
|
165
|
+
? parent.children?.includes(id) === true
|
|
166
|
+
: rootNodeIds.includes(id);
|
|
167
|
+
if (!reachable)
|
|
168
|
+
continue;
|
|
131
169
|
const def = nodeRegistry.get(node.type);
|
|
132
170
|
if (def?.geometry || def?.capabilities?.floorPlaced || DIRTY_BUILD_KINDS.has(node.type)) {
|
|
133
171
|
return true;
|
|
@@ -141,10 +179,12 @@ function hasCommittedSceneRoot() {
|
|
|
141
179
|
return Object.keys(nodes).length === 0;
|
|
142
180
|
return rootNodeIds.some((id) => sceneRegistry.nodes.has(id));
|
|
143
181
|
}
|
|
144
|
-
function SceneReadyTracker({ onSceneReadyChange, sceneReadyKey, }) {
|
|
182
|
+
function SceneReadyTracker({ onSceneReadyChange, sceneReadyKey, sceneReadyMaxWaitMs, }) {
|
|
183
|
+
const invalidate = useThree((state) => state.invalidate);
|
|
145
184
|
const readyRef = useRef(false);
|
|
146
185
|
const settledFramesRef = useRef(0);
|
|
147
186
|
const waitedFramesRef = useRef(0);
|
|
187
|
+
const waitStartRef = useRef(null);
|
|
148
188
|
const onSceneReadyChangeRef = useRef(onSceneReadyChange);
|
|
149
189
|
useEffect(() => {
|
|
150
190
|
onSceneReadyChangeRef.current = onSceneReadyChange;
|
|
@@ -154,26 +194,39 @@ function SceneReadyTracker({ onSceneReadyChange, sceneReadyKey, }) {
|
|
|
154
194
|
readyRef.current = false;
|
|
155
195
|
settledFramesRef.current = 0;
|
|
156
196
|
waitedFramesRef.current = 0;
|
|
197
|
+
waitStartRef.current = null;
|
|
157
198
|
onSceneReadyChangeRef.current?.(false);
|
|
158
|
-
|
|
199
|
+
invalidate();
|
|
200
|
+
}, [invalidate, sceneReadyKey]);
|
|
159
201
|
useFrame(() => {
|
|
160
202
|
if (!(onSceneReadyChangeRef.current && !readyRef.current))
|
|
161
203
|
return;
|
|
162
204
|
waitedFramesRef.current += 1;
|
|
163
|
-
|
|
164
|
-
|
|
205
|
+
waitStartRef.current ??= performance.now();
|
|
206
|
+
// Give-up cap so a permanently-dirty node can't block readiness forever.
|
|
207
|
+
// The frame-count default assumes display-rate frames; a host whose frame
|
|
208
|
+
// cadence is decoupled from wall time (the headless bake page's timer-driven
|
|
209
|
+
// loop runs 180 frames in 3.6s — faster than a cold item download) passes
|
|
210
|
+
// `sceneReadyMaxWaitMs` to make the cap wall-clock instead.
|
|
211
|
+
const capReached = sceneReadyMaxWaitMs
|
|
212
|
+
? performance.now() - waitStartRef.current >= sceneReadyMaxWaitMs
|
|
213
|
+
: waitedFramesRef.current >= SCENE_READY_MAX_WAIT_FRAMES;
|
|
214
|
+
if (!capReached && (!hasCommittedSceneRoot() || hasPendingSceneBuildWork())) {
|
|
165
215
|
settledFramesRef.current = 0;
|
|
216
|
+
invalidate();
|
|
166
217
|
return;
|
|
167
218
|
}
|
|
168
219
|
settledFramesRef.current += 1;
|
|
169
|
-
if (settledFramesRef.current < SCENE_READY_SETTLED_FRAMES)
|
|
220
|
+
if (settledFramesRef.current < SCENE_READY_SETTLED_FRAMES) {
|
|
221
|
+
invalidate();
|
|
170
222
|
return;
|
|
223
|
+
}
|
|
171
224
|
readyRef.current = true;
|
|
172
225
|
onSceneReadyChangeRef.current(true);
|
|
173
226
|
}, 10);
|
|
174
227
|
return null;
|
|
175
228
|
}
|
|
176
|
-
const Viewer = forwardRef(function Viewer({ children, hoverStyles = DEFAULT_HOVER_STYLES, selectionManager = 'default', perf = false, useBvh = true, renderContext = 'editor', defaultRender, isolate, sceneReadyKey, onSceneReadyChange, }, ref) {
|
|
229
|
+
const Viewer = forwardRef(function Viewer({ children, hoverStyles = DEFAULT_HOVER_STYLES, selectionManager = 'default', perf = false, useBvh = true, renderContext = 'editor', transparent, defaultRender, isolate, sceneReadyKey, onSceneReadyChange, sceneReadyMaxWaitMs, disablePostFx = false, }, ref) {
|
|
177
230
|
useImperativeHandle(ref, () => ({
|
|
178
231
|
setIsolated: (ids) => applyIsolation(ids),
|
|
179
232
|
}), []);
|
|
@@ -191,7 +244,34 @@ const Viewer = forwardRef(function Viewer({ children, hoverStyles = DEFAULT_HOVE
|
|
|
191
244
|
clearIsolation();
|
|
192
245
|
};
|
|
193
246
|
}, [isolate]);
|
|
247
|
+
const [rendererInitFailed, setRendererInitFailed] = useState(false);
|
|
248
|
+
// Capability detection runs after mount. We start optimistic (true) so the
|
|
249
|
+
// server-rendered markup and the first client render agree (no hydration
|
|
250
|
+
// mismatch); the effect flips it to false only on environments that expose
|
|
251
|
+
// neither WebGPU nor WebGL.
|
|
252
|
+
const [canMountViewer, setCanMountViewer] = useState(true);
|
|
253
|
+
useEffect(() => {
|
|
254
|
+
if (!canMountGpuViewer())
|
|
255
|
+
setCanMountViewer(false);
|
|
256
|
+
}, []);
|
|
194
257
|
const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark');
|
|
258
|
+
const transparentBackground = useViewer((state) => state.transparentBackground);
|
|
259
|
+
// The shadows toggle drives `renderer.shadowMap.enabled` (via the Canvas
|
|
260
|
+
// `shadows` prop) rather than the lights' `castShadow`: toggling castShadow
|
|
261
|
+
// off disposes the shadow map's GPU texture but three r184's WebGPU node
|
|
262
|
+
// cache keeps the shadows-on builder state that references it, so toggling
|
|
263
|
+
// back on reuses destroyed resources and every frame submit fails with a
|
|
264
|
+
// GPUValidationError. Disabling at the renderer level rebuilds materials
|
|
265
|
+
// without disposing anything, so the round-trip is safe.
|
|
266
|
+
const shadowsEnabled = useViewer((state) => state.shadows);
|
|
267
|
+
useLayoutEffect(() => {
|
|
268
|
+
if (transparent === undefined)
|
|
269
|
+
return;
|
|
270
|
+
useViewer.getState().setTransparentBackground(transparent);
|
|
271
|
+
return () => {
|
|
272
|
+
useViewer.getState().setTransparentBackground(false);
|
|
273
|
+
};
|
|
274
|
+
}, [transparent]);
|
|
195
275
|
const defaultShading = defaultRender?.shading;
|
|
196
276
|
const defaultTextures = defaultRender?.textures;
|
|
197
277
|
const defaultColorPreset = defaultRender?.colorPreset;
|
|
@@ -228,14 +308,25 @@ const Viewer = forwardRef(function Viewer({ children, hoverStyles = DEFAULT_HOVE
|
|
|
228
308
|
// fragment-shader cost down — saves another ~30% over 1.5x on high-DPI mobile.
|
|
229
309
|
// Desktops (fine pointer) keep the original 1.5 cap.
|
|
230
310
|
const maxDpr = typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches ? 1.25 : 1.5;
|
|
231
|
-
|
|
311
|
+
const showGpuFallback = !canMountViewer || rendererInitFailed;
|
|
312
|
+
// When we can't mount the GPU canvas, the SceneReadyTracker never mounts and
|
|
313
|
+
// the host editor would otherwise wait on its scene-readiness timeout. Signal
|
|
314
|
+
// readiness explicitly so the host can drop its loader immediately.
|
|
315
|
+
useEffect(() => {
|
|
316
|
+
if (showGpuFallback)
|
|
317
|
+
onSceneReadyChange?.(true);
|
|
318
|
+
}, [showGpuFallback, onSceneReadyChange]);
|
|
319
|
+
if (showGpuFallback) {
|
|
320
|
+
return _jsx(UnsupportedGpuViewerFallback, {});
|
|
321
|
+
}
|
|
322
|
+
return (_jsxs(Canvas, { camera: { position: [50, 50, 50], fov: 50 }, className: `transition-colors duration-700 ${transparentBackground ? 'bg-transparent' : isDark ? 'bg-[#1f2433]' : 'bg-[#fafafa]'}`, dpr: [1, maxDpr], frameloop: "never", gl: ((props) => {
|
|
232
323
|
const canvas = props.canvas;
|
|
233
324
|
const cached = canvas ? WEBGPU_RENDERER_CACHE.get(canvas) : undefined;
|
|
234
325
|
if (cached)
|
|
235
326
|
return cached;
|
|
236
327
|
const promise = (async () => {
|
|
237
328
|
try {
|
|
238
|
-
const renderer = new THREE.WebGPURenderer(props);
|
|
329
|
+
const renderer = new THREE.WebGPURenderer({ ...props, alpha: true });
|
|
239
330
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
240
331
|
renderer.toneMappingExposure = getSceneTheme(useViewer.getState().sceneTheme).toneMappingExposure;
|
|
241
332
|
await renderer.init();
|
|
@@ -249,6 +340,7 @@ const Viewer = forwardRef(function Viewer({ children, hoverStyles = DEFAULT_HOVE
|
|
|
249
340
|
if (canvas)
|
|
250
341
|
WEBGPU_RENDERER_CACHE.delete(canvas);
|
|
251
342
|
console.error('[viewer] WebGPURenderer init failed', err);
|
|
343
|
+
setRendererInitFailed(true);
|
|
252
344
|
throw err;
|
|
253
345
|
}
|
|
254
346
|
})();
|
|
@@ -259,8 +351,8 @@ const Viewer = forwardRef(function Viewer({ children, hoverStyles = DEFAULT_HOVE
|
|
|
259
351
|
debounce: 100,
|
|
260
352
|
}, shadows: {
|
|
261
353
|
type: THREE.PCFShadowMap,
|
|
262
|
-
enabled:
|
|
263
|
-
}, children: [_jsx(FrameLimiter, { fps: 50 }), _jsx(ViewerCamera, {}), _jsx(GPUDeviceWatcher, {}), _jsx(ToneMappingExposure, {}), _jsx(SceneReadyTracker, { onSceneReadyChange: onSceneReadyChange, sceneReadyKey: sceneReadyKey }), _jsxs(ErrorBoundary, { fallback: null, scope: "viewer-scene", children: [_jsx(Lights, {}), useBvh ? (_jsx(SceneBvh, { children: _jsx(SceneRenderer, {}) })) : (_jsx(SceneRenderer, {})), _jsx(FloorElevationSystem, {}), _jsx(GeometrySystem, {}), _jsx(StairOpeningSystem, {}), _jsx(RegisteredSystems, {}), _jsx(PostProcessing, { hoverStyles: hoverStyles }), selectionManager === 'default' && _jsx(SelectionManager, {}), (perf || PERF_OVERLAY_ENABLED) && _jsx(PerfMonitor, {}), children] })] }));
|
|
354
|
+
enabled: shadowsEnabled,
|
|
355
|
+
}, children: [_jsx(FrameLimiter, { fps: 50 }), _jsx(ViewerCamera, {}), _jsx(GPUDeviceWatcher, {}), _jsx(ToneMappingExposure, {}), _jsx(SceneReadyTracker, { onSceneReadyChange: onSceneReadyChange, sceneReadyKey: sceneReadyKey, sceneReadyMaxWaitMs: sceneReadyMaxWaitMs }), _jsxs(ErrorBoundary, { fallback: null, scope: "viewer-scene", children: [_jsx(Lights, {}), useBvh ? (_jsx(SceneBvh, { children: _jsx(SceneRenderer, {}) })) : (_jsx(SceneRenderer, {})), _jsx(FloorElevationSystem, {}), _jsx(GeometrySystem, {}), _jsx(StairOpeningSystem, {}), _jsx(RegisteredSystems, {}), _jsx(PostProcessing, { disablePostFx: disablePostFx, hoverStyles: hoverStyles }), selectionManager === 'default' && _jsx(SelectionManager, {}), (perf || PERF_OVERLAY_ENABLED) && _jsx(PerfMonitor, {}), children] })] }));
|
|
264
356
|
});
|
|
265
357
|
const DebugRenderer = () => {
|
|
266
358
|
useFrame(({ gl, scene, camera }) => {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"lights.d.ts","sourceRoot":"","sources":["../../../src/components/viewer/lights.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"lights.d.ts","sourceRoot":"","sources":["../../../src/components/viewer/lights.tsx"],"names":[],"mappings":"AA4EA,wBAAgB,MAAM,gCAgPrB"}
|