@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.
Files changed (176) hide show
  1. package/dist/components/error-boundary.d.ts +6 -0
  2. package/dist/components/error-boundary.d.ts.map +1 -1
  3. package/dist/components/error-boundary.js +6 -0
  4. package/dist/components/renderers/node-renderer.d.ts +5 -1
  5. package/dist/components/renderers/node-renderer.d.ts.map +1 -1
  6. package/dist/components/renderers/node-renderer.js +5 -2
  7. package/dist/components/renderers/parametric-node-renderer.d.ts.map +1 -1
  8. package/dist/components/renderers/parametric-node-renderer.js +7 -4
  9. package/dist/components/viewer/bvh-ecctrl.d.ts +77 -0
  10. package/dist/components/viewer/bvh-ecctrl.d.ts.map +1 -0
  11. package/dist/components/viewer/bvh-ecctrl.js +552 -0
  12. package/dist/components/viewer/frame-limiter.d.ts.map +1 -1
  13. package/dist/components/viewer/frame-limiter.js +39 -4
  14. package/dist/components/viewer/glb-interactive.d.ts +41 -0
  15. package/dist/components/viewer/glb-interactive.d.ts.map +1 -0
  16. package/dist/components/viewer/glb-interactive.js +394 -0
  17. package/dist/components/viewer/glb-reference-nodes.d.ts +29 -0
  18. package/dist/components/viewer/glb-reference-nodes.d.ts.map +1 -0
  19. package/dist/components/viewer/glb-reference-nodes.js +80 -0
  20. package/dist/components/viewer/glb-replace-instances.d.ts +18 -0
  21. package/dist/components/viewer/glb-replace-instances.d.ts.map +1 -0
  22. package/dist/components/viewer/glb-replace-instances.js +61 -0
  23. package/dist/components/viewer/glb-scene.d.ts +54 -0
  24. package/dist/components/viewer/glb-scene.d.ts.map +1 -0
  25. package/dist/components/viewer/glb-scene.js +920 -0
  26. package/dist/components/viewer/glb-walkthrough-controller.d.ts +12 -0
  27. package/dist/components/viewer/glb-walkthrough-controller.d.ts.map +1 -0
  28. package/dist/components/viewer/glb-walkthrough-controller.js +357 -0
  29. package/dist/components/viewer/index.d.ts +19 -2
  30. package/dist/components/viewer/index.d.ts.map +1 -1
  31. package/dist/components/viewer/index.js +104 -12
  32. package/dist/components/viewer/lights.d.ts.map +1 -1
  33. package/dist/components/viewer/lights.js +36 -5
  34. package/dist/components/viewer/perf-monitor.d.ts.map +1 -1
  35. package/dist/components/viewer/perf-monitor.js +17 -2
  36. package/dist/components/viewer/post-processing.d.ts +7 -1
  37. package/dist/components/viewer/post-processing.d.ts.map +1 -1
  38. package/dist/components/viewer/post-processing.js +179 -24
  39. package/dist/components/viewer/registered-systems.d.ts.map +1 -1
  40. package/dist/components/viewer/registered-systems.js +6 -4
  41. package/dist/components/viewer/scene-bvh.d.ts +2 -1
  42. package/dist/components/viewer/scene-bvh.d.ts.map +1 -1
  43. package/dist/components/viewer/scene-bvh.js +3 -0
  44. package/dist/components/viewer/scene-bvh.test.d.ts +2 -0
  45. package/dist/components/viewer/scene-bvh.test.d.ts.map +1 -0
  46. package/dist/components/viewer/scene-bvh.test.js +13 -0
  47. package/dist/components/viewer/scene-environment.d.ts +2 -0
  48. package/dist/components/viewer/scene-environment.d.ts.map +1 -0
  49. package/dist/components/viewer/scene-environment.js +79 -0
  50. package/dist/components/viewer/selection-manager.d.ts.map +1 -1
  51. package/dist/components/viewer/selection-manager.js +18 -10
  52. package/dist/components/viewer/walkthrough-controls.d.ts +1 -0
  53. package/dist/components/viewer/walkthrough-controls.d.ts.map +1 -1
  54. package/dist/components/viewer/walkthrough-controls.js +20 -0
  55. package/dist/hooks/use-gltf-ktx2.d.ts.map +1 -1
  56. package/dist/hooks/use-gltf-ktx2.js +3 -22
  57. package/dist/hooks/use-node-events.d.ts +1 -1
  58. package/dist/hooks/use-node-events.d.ts.map +1 -1
  59. package/dist/hooks/use-node-events.js +22 -14
  60. package/dist/index.d.ts +19 -8
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/index.js +22 -8
  63. package/dist/lib/backdrop.d.ts +33 -0
  64. package/dist/lib/backdrop.d.ts.map +1 -0
  65. package/dist/lib/backdrop.js +92 -0
  66. package/dist/lib/box-uv.d.ts +13 -0
  67. package/dist/lib/box-uv.d.ts.map +1 -0
  68. package/dist/lib/box-uv.js +32 -0
  69. package/dist/lib/csg-utils.d.ts +3 -9
  70. package/dist/lib/csg-utils.d.ts.map +1 -1
  71. package/dist/lib/csg-utils.js +65 -5
  72. package/dist/lib/csg-utils.test.d.ts +2 -0
  73. package/dist/lib/csg-utils.test.d.ts.map +1 -0
  74. package/dist/lib/csg-utils.test.js +45 -0
  75. package/dist/lib/edge-style.d.ts +1 -0
  76. package/dist/lib/edge-style.d.ts.map +1 -1
  77. package/dist/lib/edge-style.js +26 -6
  78. package/dist/lib/ink-edges.d.ts +1 -1
  79. package/dist/lib/ink-edges.d.ts.map +1 -1
  80. package/dist/lib/ink-edges.js +13 -7
  81. package/dist/lib/ktx2-loader.d.ts +43 -0
  82. package/dist/lib/ktx2-loader.d.ts.map +1 -0
  83. package/dist/lib/ktx2-loader.js +136 -0
  84. package/dist/lib/layers.d.ts +10 -0
  85. package/dist/lib/layers.d.ts.map +1 -1
  86. package/dist/lib/layers.js +10 -0
  87. package/dist/lib/materials.d.ts +14 -1
  88. package/dist/lib/materials.d.ts.map +1 -1
  89. package/dist/lib/materials.js +103 -26
  90. package/dist/lib/scene-themes.d.ts +1 -0
  91. package/dist/lib/scene-themes.d.ts.map +1 -1
  92. package/dist/lib/scene-themes.js +25 -16
  93. package/dist/lib/shadow-only.d.ts +4 -0
  94. package/dist/lib/shadow-only.d.ts.map +1 -0
  95. package/dist/lib/shadow-only.js +36 -0
  96. package/dist/lib/tsl-compat.d.ts +12 -0
  97. package/dist/lib/tsl-compat.d.ts.map +1 -0
  98. package/dist/lib/tsl-compat.js +13 -0
  99. package/dist/store/use-viewer.d.ts +60 -18
  100. package/dist/store/use-viewer.d.ts.map +1 -1
  101. package/dist/store/use-viewer.js +175 -3
  102. package/dist/store/use-viewer.test.d.ts +2 -0
  103. package/dist/store/use-viewer.test.d.ts.map +1 -0
  104. package/dist/store/use-viewer.test.js +42 -0
  105. package/dist/systems/ceiling/ceiling-system.d.ts.map +1 -1
  106. package/dist/systems/ceiling/ceiling-system.js +4 -1
  107. package/dist/systems/door/door-animation.test.d.ts +2 -0
  108. package/dist/systems/door/door-animation.test.d.ts.map +1 -0
  109. package/dist/systems/door/door-animation.test.js +51 -0
  110. package/dist/systems/door/door-floor-alignment.test.d.ts +2 -0
  111. package/dist/systems/door/door-floor-alignment.test.d.ts.map +1 -0
  112. package/dist/systems/door/door-floor-alignment.test.js +50 -0
  113. package/dist/systems/door/door-system.d.ts +24 -0
  114. package/dist/systems/door/door-system.d.ts.map +1 -1
  115. package/dist/systems/door/door-system.js +494 -161
  116. package/dist/systems/fence/fence-system.d.ts +17 -0
  117. package/dist/systems/fence/fence-system.d.ts.map +1 -1
  118. package/dist/systems/fence/fence-system.js +221 -53
  119. package/dist/systems/geometry/geometry-system.d.ts +7 -0
  120. package/dist/systems/geometry/geometry-system.d.ts.map +1 -1
  121. package/dist/systems/geometry/geometry-system.js +95 -26
  122. package/dist/systems/geometry/geometry-system.test.d.ts +2 -0
  123. package/dist/systems/geometry/geometry-system.test.d.ts.map +1 -0
  124. package/dist/systems/geometry/geometry-system.test.js +30 -0
  125. package/dist/systems/interactive/control-widget.d.ts +10 -0
  126. package/dist/systems/interactive/control-widget.d.ts.map +1 -0
  127. package/dist/systems/interactive/control-widget.js +35 -0
  128. package/dist/systems/interactive/interactive-system.d.ts.map +1 -1
  129. package/dist/systems/interactive/interactive-system.js +55 -56
  130. package/dist/systems/item/item-system.d.ts.map +1 -1
  131. package/dist/systems/item/item-system.js +20 -6
  132. package/dist/systems/level/level-system.d.ts.map +1 -1
  133. package/dist/systems/level/level-system.js +28 -4
  134. package/dist/systems/level/level-utils.d.ts +0 -3
  135. package/dist/systems/level/level-utils.d.ts.map +1 -1
  136. package/dist/systems/level/level-utils.js +2 -41
  137. package/dist/systems/roof/roof-materials.d.ts.map +1 -1
  138. package/dist/systems/roof/roof-materials.js +29 -8
  139. package/dist/systems/roof/roof-system.d.ts +21 -8
  140. package/dist/systems/roof/roof-system.d.ts.map +1 -1
  141. package/dist/systems/roof/roof-system.js +1079 -221
  142. package/dist/systems/slab/slab-system.d.ts +5 -3
  143. package/dist/systems/slab/slab-system.d.ts.map +1 -1
  144. package/dist/systems/slab/slab-system.js +41 -23
  145. package/dist/systems/slab/slab-system.test.js +3 -2
  146. package/dist/systems/stair/stair-system.js +6 -3
  147. package/dist/systems/wall/opening-cutout-geometry.d.ts +31 -0
  148. package/dist/systems/wall/opening-cutout-geometry.d.ts.map +1 -0
  149. package/dist/systems/wall/opening-cutout-geometry.js +167 -0
  150. package/dist/systems/wall/opening-cutout-geometry.test.d.ts +2 -0
  151. package/dist/systems/wall/opening-cutout-geometry.test.d.ts.map +1 -0
  152. package/dist/systems/wall/opening-cutout-geometry.test.js +172 -0
  153. package/dist/systems/wall/wall-cutout.d.ts.map +1 -1
  154. package/dist/systems/wall/wall-cutout.js +46 -17
  155. package/dist/systems/wall/wall-materials.d.ts +11 -7
  156. package/dist/systems/wall/wall-materials.d.ts.map +1 -1
  157. package/dist/systems/wall/wall-materials.js +244 -35
  158. package/dist/systems/wall/wall-opening-cutout.test.d.ts +2 -0
  159. package/dist/systems/wall/wall-opening-cutout.test.d.ts.map +1 -0
  160. package/dist/systems/wall/wall-opening-cutout.test.js +59 -0
  161. package/dist/systems/wall/wall-support-extension.test.d.ts +2 -0
  162. package/dist/systems/wall/wall-support-extension.test.d.ts.map +1 -0
  163. package/dist/systems/wall/wall-support-extension.test.js +48 -0
  164. package/dist/systems/wall/wall-system.d.ts +2 -2
  165. package/dist/systems/wall/wall-system.d.ts.map +1 -1
  166. package/dist/systems/wall/wall-system.js +329 -174
  167. package/dist/systems/window/window-animation-system.d.ts +12 -0
  168. package/dist/systems/window/window-animation-system.d.ts.map +1 -1
  169. package/dist/systems/window/window-animation-system.js +16 -5
  170. package/dist/systems/window/window-system.d.ts +7 -0
  171. package/dist/systems/window/window-system.d.ts.map +1 -1
  172. package/dist/systems/window/window-system.js +240 -14
  173. package/package.json +4 -4
  174. package/dist/components/viewer/ground-occluder.d.ts +0 -2
  175. package/dist/components/viewer/ground-occluder.d.ts.map +0 -1
  176. 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 ThreeToJSXElements } from '@react-three/fiber';
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 extends ThreeToJSXElements<typeof THREE> {
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,kBAAkB,EAAsB,MAAM,oBAAoB,CAAA;AAEhG,OAAO,KAAK,KAAK,MAAM,cAAc,CAAA;AAIrC,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;IAClC,UAAU,aAAc,SAAQ,kBAAkB,CAAC,OAAO,KAAK,CAAC;KAAG;CACpE;AAuOD,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,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;CAC9C;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,sGAmKV,CAAA;AAkBF,eAAe,MAAM,CAAA"}
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
- }, [sceneReadyKey]);
199
+ invalidate();
200
+ }, [invalidate, sceneReadyKey]);
159
201
  useFrame(() => {
160
202
  if (!(onSceneReadyChangeRef.current && !readyRef.current))
161
203
  return;
162
204
  waitedFramesRef.current += 1;
163
- if (waitedFramesRef.current < SCENE_READY_MAX_WAIT_FRAMES &&
164
- (!hasCommittedSceneRoot() || hasPendingSceneBuildWork())) {
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
- return (_jsxs(Canvas, { camera: { position: [50, 50, 50], fov: 50 }, className: `transition-colors duration-700 ${isDark ? 'bg-[#1f2433]' : 'bg-[#fafafa]'}`, dpr: [1, maxDpr], frameloop: "never", gl: ((props) => {
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: true,
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":"AAqDA,wBAAgB,MAAM,gCA2NrB"}
1
+ {"version":3,"file":"lights.d.ts","sourceRoot":"","sources":["../../../src/components/viewer/lights.tsx"],"names":[],"mappings":"AA4EA,wBAAgB,MAAM,gCAgPrB"}