@pascal-app/viewer 0.9.1 → 1.0.0-beta.1

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 (206) hide show
  1. package/README.md +20 -5
  2. package/dist/components/error-boundary.d.ts +6 -0
  3. package/dist/components/error-boundary.d.ts.map +1 -1
  4. package/dist/components/error-boundary.js +6 -0
  5. package/dist/components/renderers/node-renderer.d.ts +5 -1
  6. package/dist/components/renderers/node-renderer.d.ts.map +1 -1
  7. package/dist/components/renderers/node-renderer.js +5 -2
  8. package/dist/components/renderers/parametric-node-renderer.d.ts.map +1 -1
  9. package/dist/components/renderers/parametric-node-renderer.js +7 -4
  10. package/dist/components/viewer/bvh-ecctrl.d.ts +77 -0
  11. package/dist/components/viewer/bvh-ecctrl.d.ts.map +1 -0
  12. package/dist/components/viewer/bvh-ecctrl.js +552 -0
  13. package/dist/components/viewer/frame-limiter.d.ts.map +1 -1
  14. package/dist/components/viewer/frame-limiter.js +59 -4
  15. package/dist/components/viewer/glb-interactive.d.ts +41 -0
  16. package/dist/components/viewer/glb-interactive.d.ts.map +1 -0
  17. package/dist/components/viewer/glb-interactive.js +394 -0
  18. package/dist/components/viewer/glb-reference-nodes.d.ts +29 -0
  19. package/dist/components/viewer/glb-reference-nodes.d.ts.map +1 -0
  20. package/dist/components/viewer/glb-reference-nodes.js +80 -0
  21. package/dist/components/viewer/glb-replace-instances.d.ts +18 -0
  22. package/dist/components/viewer/glb-replace-instances.d.ts.map +1 -0
  23. package/dist/components/viewer/glb-replace-instances.js +61 -0
  24. package/dist/components/viewer/glb-scene.d.ts +54 -0
  25. package/dist/components/viewer/glb-scene.d.ts.map +1 -0
  26. package/dist/components/viewer/glb-scene.js +920 -0
  27. package/dist/components/viewer/glb-walkthrough-controller.d.ts +22 -0
  28. package/dist/components/viewer/glb-walkthrough-controller.d.ts.map +1 -0
  29. package/dist/components/viewer/glb-walkthrough-controller.js +445 -0
  30. package/dist/components/viewer/index.d.ts +19 -2
  31. package/dist/components/viewer/index.d.ts.map +1 -1
  32. package/dist/components/viewer/index.js +113 -12
  33. package/dist/components/viewer/lights.d.ts.map +1 -1
  34. package/dist/components/viewer/lights.js +36 -5
  35. package/dist/components/viewer/perf-monitor.d.ts.map +1 -1
  36. package/dist/components/viewer/perf-monitor.js +17 -2
  37. package/dist/components/viewer/post-processing.d.ts +7 -1
  38. package/dist/components/viewer/post-processing.d.ts.map +1 -1
  39. package/dist/components/viewer/post-processing.js +180 -24
  40. package/dist/components/viewer/registered-systems.d.ts.map +1 -1
  41. package/dist/components/viewer/registered-systems.js +6 -4
  42. package/dist/components/viewer/scene-bvh.d.ts +2 -1
  43. package/dist/components/viewer/scene-bvh.d.ts.map +1 -1
  44. package/dist/components/viewer/scene-bvh.js +3 -0
  45. package/dist/components/viewer/scene-bvh.test.d.ts +2 -0
  46. package/dist/components/viewer/scene-bvh.test.d.ts.map +1 -0
  47. package/dist/components/viewer/scene-bvh.test.js +13 -0
  48. package/dist/components/viewer/scene-environment.d.ts +2 -0
  49. package/dist/components/viewer/scene-environment.d.ts.map +1 -0
  50. package/dist/components/viewer/scene-environment.js +79 -0
  51. package/dist/components/viewer/selection-manager.d.ts.map +1 -1
  52. package/dist/components/viewer/selection-manager.js +23 -12
  53. package/dist/hooks/use-gltf-ktx2.d.ts.map +1 -1
  54. package/dist/hooks/use-gltf-ktx2.js +2 -24
  55. package/dist/hooks/use-node-events.d.ts +1 -1
  56. package/dist/hooks/use-node-events.d.ts.map +1 -1
  57. package/dist/hooks/use-node-events.js +22 -14
  58. package/dist/index.d.ts +24 -10
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +29 -13
  61. package/dist/lib/backdrop.d.ts +33 -0
  62. package/dist/lib/backdrop.d.ts.map +1 -0
  63. package/dist/lib/backdrop.js +92 -0
  64. package/dist/lib/box-uv.d.ts +13 -0
  65. package/dist/lib/box-uv.d.ts.map +1 -0
  66. package/dist/lib/box-uv.js +32 -0
  67. package/dist/lib/csg-utils.d.ts +3 -9
  68. package/dist/lib/csg-utils.d.ts.map +1 -1
  69. package/dist/lib/csg-utils.js +65 -5
  70. package/dist/lib/csg-utils.test.d.ts +2 -0
  71. package/dist/lib/csg-utils.test.d.ts.map +1 -0
  72. package/dist/lib/csg-utils.test.js +45 -0
  73. package/dist/lib/edge-style.d.ts +1 -0
  74. package/dist/lib/edge-style.d.ts.map +1 -1
  75. package/dist/lib/edge-style.js +26 -6
  76. package/dist/lib/hero-pose.d.ts +47 -0
  77. package/dist/lib/hero-pose.d.ts.map +1 -0
  78. package/dist/lib/hero-pose.js +227 -0
  79. package/dist/lib/ink-edges.d.ts +1 -1
  80. package/dist/lib/ink-edges.d.ts.map +1 -1
  81. package/dist/lib/ink-edges.js +13 -7
  82. package/dist/lib/ktx2-loader.d.ts +46 -0
  83. package/dist/lib/ktx2-loader.d.ts.map +1 -0
  84. package/dist/lib/ktx2-loader.js +142 -0
  85. package/dist/lib/layers.d.ts +10 -0
  86. package/dist/lib/layers.d.ts.map +1 -1
  87. package/dist/lib/layers.js +10 -0
  88. package/dist/lib/materials.d.ts +16 -1
  89. package/dist/lib/materials.d.ts.map +1 -1
  90. package/dist/lib/materials.js +158 -34
  91. package/dist/lib/materials.test.d.ts +2 -0
  92. package/dist/lib/materials.test.d.ts.map +1 -0
  93. package/dist/lib/materials.test.js +25 -0
  94. package/dist/lib/merged-outline-node.test.d.ts +2 -0
  95. package/dist/lib/merged-outline-node.test.d.ts.map +1 -0
  96. package/dist/lib/merged-outline-node.test.js +22 -0
  97. package/dist/lib/scene-themes.d.ts +1 -0
  98. package/dist/lib/scene-themes.d.ts.map +1 -1
  99. package/dist/lib/scene-themes.js +25 -16
  100. package/dist/lib/shadow-only.d.ts +4 -0
  101. package/dist/lib/shadow-only.d.ts.map +1 -0
  102. package/dist/lib/shadow-only.js +36 -0
  103. package/dist/lib/snapshot-pipeline.d.ts +42 -0
  104. package/dist/lib/snapshot-pipeline.d.ts.map +1 -0
  105. package/dist/lib/snapshot-pipeline.js +254 -0
  106. package/dist/lib/terrain-perimeter-fill.d.ts +7 -0
  107. package/dist/lib/terrain-perimeter-fill.d.ts.map +1 -0
  108. package/dist/lib/terrain-perimeter-fill.js +75 -0
  109. package/dist/lib/texture-node-guard.d.ts +13 -0
  110. package/dist/lib/texture-node-guard.d.ts.map +1 -0
  111. package/dist/lib/texture-node-guard.js +39 -0
  112. package/dist/lib/texture-reference.d.ts +31 -0
  113. package/dist/lib/texture-reference.d.ts.map +1 -0
  114. package/dist/lib/texture-reference.js +139 -0
  115. package/dist/lib/texture-reference.test.d.ts +2 -0
  116. package/dist/lib/texture-reference.test.d.ts.map +1 -0
  117. package/dist/lib/texture-reference.test.js +86 -0
  118. package/dist/lib/tsl-compat.d.ts +12 -0
  119. package/dist/lib/tsl-compat.d.ts.map +1 -0
  120. package/dist/lib/tsl-compat.js +13 -0
  121. package/dist/store/use-viewer.d.ts +73 -18
  122. package/dist/store/use-viewer.d.ts.map +1 -1
  123. package/dist/store/use-viewer.js +191 -4
  124. package/dist/store/use-viewer.test.d.ts +2 -0
  125. package/dist/store/use-viewer.test.d.ts.map +1 -0
  126. package/dist/store/use-viewer.test.js +61 -0
  127. package/dist/systems/ceiling/ceiling-system.d.ts.map +1 -1
  128. package/dist/systems/ceiling/ceiling-system.js +12 -5
  129. package/dist/systems/door/door-animation.test.d.ts +2 -0
  130. package/dist/systems/door/door-animation.test.d.ts.map +1 -0
  131. package/dist/systems/door/door-animation.test.js +51 -0
  132. package/dist/systems/door/door-floor-alignment.test.d.ts +2 -0
  133. package/dist/systems/door/door-floor-alignment.test.d.ts.map +1 -0
  134. package/dist/systems/door/door-floor-alignment.test.js +50 -0
  135. package/dist/systems/door/door-system.d.ts +24 -0
  136. package/dist/systems/door/door-system.d.ts.map +1 -1
  137. package/dist/systems/door/door-system.js +494 -161
  138. package/dist/systems/fence/fence-system.d.ts +17 -0
  139. package/dist/systems/fence/fence-system.d.ts.map +1 -1
  140. package/dist/systems/fence/fence-system.js +221 -53
  141. package/dist/systems/floor-elevation/floor-elevation-system.d.ts.map +1 -1
  142. package/dist/systems/floor-elevation/floor-elevation-system.js +30 -7
  143. package/dist/systems/geometry/geometry-system.d.ts +7 -0
  144. package/dist/systems/geometry/geometry-system.d.ts.map +1 -1
  145. package/dist/systems/geometry/geometry-system.js +95 -26
  146. package/dist/systems/geometry/geometry-system.test.d.ts +2 -0
  147. package/dist/systems/geometry/geometry-system.test.d.ts.map +1 -0
  148. package/dist/systems/geometry/geometry-system.test.js +30 -0
  149. package/dist/systems/interactive/control-widget.d.ts +10 -0
  150. package/dist/systems/interactive/control-widget.d.ts.map +1 -0
  151. package/dist/systems/interactive/control-widget.js +35 -0
  152. package/dist/systems/interactive/interactive-system.d.ts.map +1 -1
  153. package/dist/systems/interactive/interactive-system.js +55 -56
  154. package/dist/systems/item/item-system.d.ts.map +1 -1
  155. package/dist/systems/item/item-system.js +20 -6
  156. package/dist/systems/level/level-system.d.ts.map +1 -1
  157. package/dist/systems/level/level-system.js +34 -9
  158. package/dist/systems/level/level-utils.d.ts +0 -3
  159. package/dist/systems/level/level-utils.d.ts.map +1 -1
  160. package/dist/systems/level/level-utils.js +7 -45
  161. package/dist/systems/roof/roof-materials.d.ts.map +1 -1
  162. package/dist/systems/roof/roof-materials.js +29 -8
  163. package/dist/systems/roof/roof-system.d.ts +21 -8
  164. package/dist/systems/roof/roof-system.d.ts.map +1 -1
  165. package/dist/systems/roof/roof-system.js +1079 -221
  166. package/dist/systems/slab/slab-system.d.ts +7 -4
  167. package/dist/systems/slab/slab-system.d.ts.map +1 -1
  168. package/dist/systems/slab/slab-system.js +31 -83
  169. package/dist/systems/slab/slab-system.test.js +54 -14
  170. package/dist/systems/stair/stair-system.js +7 -4
  171. package/dist/systems/wall/opening-cutout-geometry.d.ts +31 -0
  172. package/dist/systems/wall/opening-cutout-geometry.d.ts.map +1 -0
  173. package/dist/systems/wall/opening-cutout-geometry.js +167 -0
  174. package/dist/systems/wall/opening-cutout-geometry.test.d.ts +2 -0
  175. package/dist/systems/wall/opening-cutout-geometry.test.d.ts.map +1 -0
  176. package/dist/systems/wall/opening-cutout-geometry.test.js +172 -0
  177. package/dist/systems/wall/wall-cutout.d.ts.map +1 -1
  178. package/dist/systems/wall/wall-cutout.js +49 -17
  179. package/dist/systems/wall/wall-materials.d.ts +11 -7
  180. package/dist/systems/wall/wall-materials.d.ts.map +1 -1
  181. package/dist/systems/wall/wall-materials.js +244 -35
  182. package/dist/systems/wall/wall-opening-cutout.test.d.ts +2 -0
  183. package/dist/systems/wall/wall-opening-cutout.test.d.ts.map +1 -0
  184. package/dist/systems/wall/wall-opening-cutout.test.js +59 -0
  185. package/dist/systems/wall/wall-support-extension.test.d.ts +2 -0
  186. package/dist/systems/wall/wall-support-extension.test.d.ts.map +1 -0
  187. package/dist/systems/wall/wall-support-extension.test.js +116 -0
  188. package/dist/systems/wall/wall-system.d.ts +4 -8
  189. package/dist/systems/wall/wall-system.d.ts.map +1 -1
  190. package/dist/systems/wall/wall-system.js +397 -177
  191. package/dist/systems/wall/wall-terrain-bottom.test.d.ts +2 -0
  192. package/dist/systems/wall/wall-terrain-bottom.test.d.ts.map +1 -0
  193. package/dist/systems/wall/wall-terrain-bottom.test.js +34 -0
  194. package/dist/systems/window/window-animation-system.d.ts +12 -0
  195. package/dist/systems/window/window-animation-system.d.ts.map +1 -1
  196. package/dist/systems/window/window-animation-system.js +16 -5
  197. package/dist/systems/window/window-system.d.ts +7 -0
  198. package/dist/systems/window/window-system.d.ts.map +1 -1
  199. package/dist/systems/window/window-system.js +240 -14
  200. package/package.json +4 -4
  201. package/dist/components/viewer/ground-occluder.d.ts +0 -2
  202. package/dist/components/viewer/ground-occluder.d.ts.map +0 -1
  203. package/dist/components/viewer/ground-occluder.js +0 -67
  204. package/dist/components/viewer/walkthrough-controls.d.ts +0 -2
  205. package/dist/components/viewer/walkthrough-controls.d.ts.map +0 -1
  206. package/dist/components/viewer/walkthrough-controls.js +0 -131
@@ -2,12 +2,14 @@
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';
12
+ import { installTextureNodeNullGuard } from '../../lib/texture-node-guard';
11
13
  import useViewer, {} from '../../store/use-viewer';
12
14
  import { FloorElevationSystem } from '../../systems/floor-elevation/floor-elevation-system';
13
15
  import { GeometrySystem } from '../../systems/geometry/geometry-system';
@@ -21,6 +23,9 @@ import { RegisteredSystems } from './registered-systems';
21
23
  import { SceneBvh } from './scene-bvh';
22
24
  import { SelectionManager } from './selection-manager';
23
25
  import { ViewerCamera } from './viewer-camera';
26
+ // Must be in place before any node material builds — a null texture pulled by
27
+ // a shared override-material pass otherwise kills the render pass outright.
28
+ installTextureNodeNullGuard();
24
29
  extend(THREE);
25
30
  // R3F's <Canvas> useLayoutEffect has no deps, so any re-render (theme switch,
26
31
  // parent re-render, StrictMode double-mount) re-invokes `configure()`. With a
@@ -51,6 +56,27 @@ const DIRTY_BUILD_KINDS = new Set([
51
56
  'window',
52
57
  ]);
53
58
  const warnedEmptyDraw = process.env.NODE_ENV === 'production' ? null : new WeakSet();
59
+ function canCreateWebGLContext() {
60
+ if (typeof document === 'undefined')
61
+ return false;
62
+ const canvas = document.createElement('canvas');
63
+ try {
64
+ return Boolean(canvas.getContext('webgl2') ?? canvas.getContext('webgl'));
65
+ }
66
+ catch {
67
+ return false;
68
+ }
69
+ }
70
+ function canMountGpuViewer() {
71
+ if (typeof window === 'undefined')
72
+ return false;
73
+ if (!('gpu' in navigator) && !canCreateWebGLContext())
74
+ return false;
75
+ return true;
76
+ }
77
+ function UnsupportedGpuViewerFallback() {
78
+ 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." })] }) }));
79
+ }
54
80
  /**
55
81
  * Renderer-level safety net against the empty-vertex-buffer crash.
56
82
  *
@@ -83,6 +109,10 @@ function installEmptyDrawGuard(renderer) {
83
109
  function GPUDeviceWatcher() {
84
110
  const gl = useThree((s) => s.gl);
85
111
  useEffect(() => {
112
+ // Detect KTX2 transcode support as soon as the renderer exists, so catalog
113
+ // `.ktx2` finish textures load even in scenes with no GLB items (whose
114
+ // loader would otherwise be the only thing to call this).
115
+ ensureKtx2Support(gl);
86
116
  const backend = gl.backend;
87
117
  const device = backend?.device;
88
118
  if (!device) {
@@ -123,11 +153,23 @@ function ToneMappingExposure() {
123
153
  return null;
124
154
  }
125
155
  function hasPendingSceneBuildWork() {
126
- const { dirtyNodes, nodes } = useScene.getState();
156
+ const { dirtyNodes, nodes, rootNodeIds } = useScene.getState();
127
157
  for (const id of dirtyNodes) {
128
158
  const node = nodes[id];
129
159
  if (!node)
130
160
  continue;
161
+ // Unreachable nodes (orphaned by a broken detach: the parent doesn't list
162
+ // them in `children`, or no parent and not a root) never render — every
163
+ // renderer enumerates the parent's `children` array — so no system will
164
+ // ever build them or clear their mark. They must not hold scene-ready
165
+ // hostage (observed in prod scene data: two dangling windows kept every
166
+ // bake of that scene waiting out the full readiness cap).
167
+ const parent = node.parentId ? nodes[node.parentId] : undefined;
168
+ const reachable = parent
169
+ ? parent.children?.includes(id) === true
170
+ : rootNodeIds.includes(id);
171
+ if (!reachable)
172
+ continue;
131
173
  const def = nodeRegistry.get(node.type);
132
174
  if (def?.geometry || def?.capabilities?.floorPlaced || DIRTY_BUILD_KINDS.has(node.type)) {
133
175
  return true;
@@ -141,10 +183,12 @@ function hasCommittedSceneRoot() {
141
183
  return Object.keys(nodes).length === 0;
142
184
  return rootNodeIds.some((id) => sceneRegistry.nodes.has(id));
143
185
  }
144
- function SceneReadyTracker({ onSceneReadyChange, sceneReadyKey, }) {
186
+ function SceneReadyTracker({ onSceneReadyChange, sceneReadyKey, sceneReadyMaxWaitMs, }) {
187
+ const invalidate = useThree((state) => state.invalidate);
145
188
  const readyRef = useRef(false);
146
189
  const settledFramesRef = useRef(0);
147
190
  const waitedFramesRef = useRef(0);
191
+ const waitStartRef = useRef(null);
148
192
  const onSceneReadyChangeRef = useRef(onSceneReadyChange);
149
193
  useEffect(() => {
150
194
  onSceneReadyChangeRef.current = onSceneReadyChange;
@@ -154,26 +198,44 @@ function SceneReadyTracker({ onSceneReadyChange, sceneReadyKey, }) {
154
198
  readyRef.current = false;
155
199
  settledFramesRef.current = 0;
156
200
  waitedFramesRef.current = 0;
201
+ waitStartRef.current = null;
157
202
  onSceneReadyChangeRef.current?.(false);
158
- }, [sceneReadyKey]);
203
+ invalidate();
204
+ }, [invalidate, sceneReadyKey]);
159
205
  useFrame(() => {
160
206
  if (!(onSceneReadyChangeRef.current && !readyRef.current))
161
207
  return;
162
208
  waitedFramesRef.current += 1;
163
- if (waitedFramesRef.current < SCENE_READY_MAX_WAIT_FRAMES &&
164
- (!hasCommittedSceneRoot() || hasPendingSceneBuildWork())) {
209
+ waitStartRef.current ??= performance.now();
210
+ // Give-up cap so a permanently-dirty node can't block readiness forever.
211
+ // The frame-count default assumes display-rate frames; a host whose frame
212
+ // cadence is decoupled from wall time (the headless bake page's timer-driven
213
+ // loop runs 180 frames in 3.6s — faster than a cold item download) passes
214
+ // `sceneReadyMaxWaitMs` to make the cap wall-clock instead.
215
+ const capReached = sceneReadyMaxWaitMs
216
+ ? performance.now() - waitStartRef.current >= sceneReadyMaxWaitMs
217
+ : waitedFramesRef.current >= SCENE_READY_MAX_WAIT_FRAMES;
218
+ if (!capReached && (!hasCommittedSceneRoot() || hasPendingSceneBuildWork())) {
165
219
  settledFramesRef.current = 0;
220
+ invalidate();
166
221
  return;
167
222
  }
168
223
  settledFramesRef.current += 1;
169
- if (settledFramesRef.current < SCENE_READY_SETTLED_FRAMES)
224
+ if (settledFramesRef.current < SCENE_READY_SETTLED_FRAMES) {
225
+ invalidate();
170
226
  return;
227
+ }
171
228
  readyRef.current = true;
172
229
  onSceneReadyChangeRef.current(true);
173
230
  }, 10);
174
231
  return null;
175
232
  }
176
- const Viewer = forwardRef(function Viewer({ children, hoverStyles = DEFAULT_HOVER_STYLES, selectionManager = 'default', perf = false, useBvh = true, renderContext = 'editor', defaultRender, isolate, sceneReadyKey, onSceneReadyChange, }, ref) {
233
+ 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) {
234
+ useEffect(() => {
235
+ if (nodeRegistry.size === 0) {
236
+ console.warn('[viewer] Node registry is empty. Install @pascal-app/nodes and call await loadPlugin(builtinPlugin) before mounting <Viewer>.');
237
+ }
238
+ }, []);
177
239
  useImperativeHandle(ref, () => ({
178
240
  setIsolated: (ids) => applyIsolation(ids),
179
241
  }), []);
@@ -191,7 +253,34 @@ const Viewer = forwardRef(function Viewer({ children, hoverStyles = DEFAULT_HOVE
191
253
  clearIsolation();
192
254
  };
193
255
  }, [isolate]);
256
+ const [rendererInitFailed, setRendererInitFailed] = useState(false);
257
+ // Capability detection runs after mount. We start optimistic (true) so the
258
+ // server-rendered markup and the first client render agree (no hydration
259
+ // mismatch); the effect flips it to false only on environments that expose
260
+ // neither WebGPU nor WebGL.
261
+ const [canMountViewer, setCanMountViewer] = useState(true);
262
+ useEffect(() => {
263
+ if (!canMountGpuViewer())
264
+ setCanMountViewer(false);
265
+ }, []);
194
266
  const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark');
267
+ const transparentBackground = useViewer((state) => state.transparentBackground);
268
+ // The shadows toggle drives `renderer.shadowMap.enabled` (via the Canvas
269
+ // `shadows` prop) rather than the lights' `castShadow`: toggling castShadow
270
+ // off disposes the shadow map's GPU texture but three r184's WebGPU node
271
+ // cache keeps the shadows-on builder state that references it, so toggling
272
+ // back on reuses destroyed resources and every frame submit fails with a
273
+ // GPUValidationError. Disabling at the renderer level rebuilds materials
274
+ // without disposing anything, so the round-trip is safe.
275
+ const shadowsEnabled = useViewer((state) => state.shadows);
276
+ useLayoutEffect(() => {
277
+ if (transparent === undefined)
278
+ return;
279
+ useViewer.getState().setTransparentBackground(transparent);
280
+ return () => {
281
+ useViewer.getState().setTransparentBackground(false);
282
+ };
283
+ }, [transparent]);
195
284
  const defaultShading = defaultRender?.shading;
196
285
  const defaultTextures = defaultRender?.textures;
197
286
  const defaultColorPreset = defaultRender?.colorPreset;
@@ -228,14 +317,25 @@ const Viewer = forwardRef(function Viewer({ children, hoverStyles = DEFAULT_HOVE
228
317
  // fragment-shader cost down — saves another ~30% over 1.5x on high-DPI mobile.
229
318
  // Desktops (fine pointer) keep the original 1.5 cap.
230
319
  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) => {
320
+ const showGpuFallback = !canMountViewer || rendererInitFailed;
321
+ // When we can't mount the GPU canvas, the SceneReadyTracker never mounts and
322
+ // the host editor would otherwise wait on its scene-readiness timeout. Signal
323
+ // readiness explicitly so the host can drop its loader immediately.
324
+ useEffect(() => {
325
+ if (showGpuFallback)
326
+ onSceneReadyChange?.(true);
327
+ }, [showGpuFallback, onSceneReadyChange]);
328
+ if (showGpuFallback) {
329
+ return _jsx(UnsupportedGpuViewerFallback, {});
330
+ }
331
+ 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
332
  const canvas = props.canvas;
233
333
  const cached = canvas ? WEBGPU_RENDERER_CACHE.get(canvas) : undefined;
234
334
  if (cached)
235
335
  return cached;
236
336
  const promise = (async () => {
237
337
  try {
238
- const renderer = new THREE.WebGPURenderer(props);
338
+ const renderer = new THREE.WebGPURenderer({ ...props, alpha: true });
239
339
  renderer.toneMapping = THREE.ACESFilmicToneMapping;
240
340
  renderer.toneMappingExposure = getSceneTheme(useViewer.getState().sceneTheme).toneMappingExposure;
241
341
  await renderer.init();
@@ -249,6 +349,7 @@ const Viewer = forwardRef(function Viewer({ children, hoverStyles = DEFAULT_HOVE
249
349
  if (canvas)
250
350
  WEBGPU_RENDERER_CACHE.delete(canvas);
251
351
  console.error('[viewer] WebGPURenderer init failed', err);
352
+ setRendererInitFailed(true);
252
353
  throw err;
253
354
  }
254
355
  })();
@@ -259,8 +360,8 @@ const Viewer = forwardRef(function Viewer({ children, hoverStyles = DEFAULT_HOVE
259
360
  debounce: 100,
260
361
  }, shadows: {
261
362
  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] })] }));
363
+ enabled: shadowsEnabled,
364
+ }, 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
365
  });
265
366
  const DebugRenderer = () => {
266
367
  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"}
@@ -3,6 +3,7 @@ import { sceneRegistry } from '@pascal-app/core';
3
3
  import { useFrame } from '@react-three/fiber';
4
4
  import { useMemo, useRef } from 'react';
5
5
  import * as THREE from 'three/webgpu';
6
+ import { SHADOW_ONLY_LAYER } from '../../lib/layers';
6
7
  import { getSceneTheme } from '../../lib/scene-themes';
7
8
  import useViewer from '../../store/use-viewer';
8
9
  // Diagnostic toggle: `?disable=shadows` skips the shadow-map render pass
@@ -12,10 +13,27 @@ const SHADOWS_DISABLED = typeof window !== 'undefined' &&
12
13
  new Set((new URLSearchParams(window.location.search).get('disable') ?? '')
13
14
  .split(',')
14
15
  .map((s) => s.trim())).has('shadows');
16
+ // Diagnostic toggle: `?debug=shadowcamera` draws a CameraHelper for each
17
+ // shadow camera so the building-fit frustum (and thus shadow texel density)
18
+ // can be inspected while tuning margins/bias.
19
+ const SHADOW_CAMERA_DEBUG = typeof window !== 'undefined' &&
20
+ new Set((new URLSearchParams(window.location.search).get('debug') ?? '')
21
+ .split(',')
22
+ .map((s) => s.trim())).has('shadowcamera');
15
23
  // Shadow darkness for the bright key lights (themes drive most lights past
16
- // intensity 1). The aesthetic prototype runs these near-black (≈1.0); this is a
17
- // deliberate middle ground — present, but not the heavy contact shadow there.
18
- const MAX_SHADOW_INTENSITY = 0.55;
24
+ // intensity 1). Runs high so shadowed areas actually lose the sun's
25
+ // contribution — the ambient/hemisphere/IBL stack provides the fill. The old
26
+ // 0.55 clamp leaked 45% of the key light into shadow and flattened interiors;
27
+ // 0.9 read too heavy in review.
28
+ const MAX_SHADOW_INTENSITY = 0.75;
29
+ // `normalBias` is measured in world units. The previous 0.3 moved shadow
30
+ // lookups 30 cm off their surfaces, visibly detaching wall shadows at the
31
+ // floor; 0.07 and below still acnes on the building-fit 1024 map (large
32
+ // texels), so 0.08 is the smallest acne-free value at that resolution — even
33
+ // with the depth bias at -0.0005 (which is itself needed: 0.08 alone still
34
+ // showed faint acne at -0.0001).
35
+ const SHADOW_DEPTH_BIAS = -0.0005;
36
+ const SHADOW_NORMAL_BIAS = 0.08;
19
37
  // Shadow frustum framing. The frustum is fit to the BUILDING geometry (not the
20
38
  // camera): we union the bounds of all registered scene nodes, fit a sphere, and
21
39
  // size the directional light's ortho shadow camera to that sphere plus a margin.
@@ -54,6 +72,7 @@ export function Lights() {
54
72
  const boundsBox = useRef(new THREE.Box3()); // scratch: union AABB
55
73
  const boundsSphere = useRef(new THREE.Sphere()); // scratch: fitted sphere
56
74
  const lastBoundsTime = useRef(-1); // last refresh timestamp (-1 = never)
75
+ const shadowHelpers = useRef([]);
57
76
  const hemiRef = useRef(null);
58
77
  const ambientRef = useRef(null);
59
78
  const initialized = useRef(false);
@@ -129,6 +148,9 @@ export function Lights() {
129
148
  // the <orthographicCamera attach="shadow-camera"> below.
130
149
  const cam = light.shadow?.camera;
131
150
  if (cam) {
151
+ // Shadow-caster-only geometry (hidden roofs/levels in cutaway views)
152
+ // is visible to the shadow pass alone — see lib/shadow-only.ts.
153
+ cam.layers.enable(SHADOW_ONLY_LAYER);
132
154
  cam.left = -size;
133
155
  cam.right = size;
134
156
  cam.top = size;
@@ -136,6 +158,15 @@ export function Lights() {
136
158
  cam.near = near;
137
159
  cam.far = far;
138
160
  cam.updateProjectionMatrix();
161
+ if (SHADOW_CAMERA_DEBUG) {
162
+ let helper = shadowHelpers.current[index];
163
+ if (!helper) {
164
+ helper = new THREE.CameraHelper(cam);
165
+ shadowHelpers.current[index] = helper;
166
+ state.scene.add(helper);
167
+ }
168
+ helper.update();
169
+ }
139
170
  }
140
171
  }
141
172
  }
@@ -195,7 +226,7 @@ export function Lights() {
195
226
  ambientRef.current.color.lerp(targets.ambColor, dt);
196
227
  }
197
228
  });
198
- return (_jsxs(_Fragment, { children: [theme.lights.map((light, index) => (_jsx("directionalLight", { castShadow: Boolean(light.castShadow) && !SHADOWS_DISABLED && shadows, position: light.position, ref: (ref) => {
229
+ return (_jsxs(_Fragment, { children: [theme.lights.map((light, index) => (_jsx("directionalLight", { castShadow: Boolean(light.castShadow) && !SHADOWS_DISABLED, position: light.position, ref: (ref) => {
199
230
  lightRefs.current[index] = ref;
200
- }, "shadow-bias": -0.002, "shadow-mapSize": [1024, 1024], "shadow-normalBias": 0.3, "shadow-radius": 1.5, children: light.castShadow && !SHADOWS_DISABLED && shadows ? (_jsx("orthographicCamera", { attach: "shadow-camera", bottom: -shadowCameraSize, far: 400, left: -shadowCameraSize, near: 1, ref: shadowCamera, right: shadowCameraSize, top: shadowCameraSize })) : null }, `${index}-${light.position.join(',')}`))), theme.hemi ? _jsx("hemisphereLight", { ref: hemiRef }) : null, _jsx("ambientLight", { ref: ambientRef })] }));
231
+ }, "shadow-bias": SHADOW_DEPTH_BIAS, "shadow-mapSize": [1024, 1024], "shadow-normalBias": SHADOW_NORMAL_BIAS, "shadow-radius": 2, children: light.castShadow && !SHADOWS_DISABLED ? (_jsx("orthographicCamera", { attach: "shadow-camera", bottom: -shadowCameraSize, far: 400, left: -shadowCameraSize, near: 1, ref: shadowCamera, right: shadowCameraSize, top: shadowCameraSize })) : null }, `${index}-${light.position.join(',')}`))), theme.hemi ? _jsx("hemisphereLight", { ref: hemiRef }) : null, _jsx("ambientLight", { ref: ambientRef })] }));
201
232
  }
@@ -1 +1 @@
1
- {"version":3,"file":"perf-monitor.d.ts","sourceRoot":"","sources":["../../../src/components/viewer/perf-monitor.tsx"],"names":[],"mappings":"AAQA,eAAO,MAAM,WAAW,mCAuIvB,CAAA"}
1
+ {"version":3,"file":"perf-monitor.d.ts","sourceRoot":"","sources":["../../../src/components/viewer/perf-monitor.tsx"],"names":[],"mappings":"AAQA,eAAO,MAAM,WAAW,mCAsJvB,CAAA"}
@@ -14,6 +14,7 @@ export const PerfMonitor = () => {
14
14
  drawCalls: 0,
15
15
  triangles: 0,
16
16
  dirty: 0,
17
+ dirtyDetail: '',
17
18
  meshes: 0,
18
19
  lines: 0,
19
20
  sprites: 0,
@@ -57,7 +58,20 @@ export const PerfMonitor = () => {
57
58
  const drawCalls = Math.round(totalCalls / Math.max(1, frameCount.current));
58
59
  const triangles = totalTriangles / Math.max(1, frameCount.current);
59
60
  info.reset();
60
- const dirty = useScene.getState().dirtyNodes.size;
61
+ const sceneState = useScene.getState();
62
+ const dirty = sceneState.dirtyNodes.size;
63
+ let dirtyDetail = '';
64
+ if (dirty > 0) {
65
+ const counts = new Map();
66
+ for (const id of sceneState.dirtyNodes) {
67
+ const type = sceneState.nodes[id]?.type ?? 'missing';
68
+ counts.set(type, (counts.get(type) ?? 0) + 1);
69
+ }
70
+ dirtyDetail = [...counts.entries()]
71
+ .sort((a, b) => b[1] - a[1])
72
+ .map(([type, count]) => `${count} ${type}`)
73
+ .join(', ');
74
+ }
61
75
  // Count visible drawables by type so we can match scene contents
62
76
  // against the renderer's draw count and find hidden contributors.
63
77
  let meshes = 0;
@@ -99,6 +113,7 @@ export const PerfMonitor = () => {
99
113
  drawCalls,
100
114
  triangles,
101
115
  dirty,
116
+ dirtyDetail,
102
117
  meshes,
103
118
  lines,
104
119
  sprites,
@@ -122,7 +137,7 @@ export const PerfMonitor = () => {
122
137
  GPU ${stats.gpuMs > 0 ? `${stats.gpuMs.toFixed(1)}ms (max ${stats.gpuMaxMs.toFixed(1)})` : '—'}
123
138
  DRAW ${stats.drawCalls}
124
139
  TRI ${(stats.triangles / 1000).toFixed(1)}k
125
- DIRTY ${stats.dirty}
140
+ DIRTY ${stats.dirty}${stats.dirtyDetail ? ` (${stats.dirtyDetail})` : ''}
126
141
  MESH ${stats.meshes}
127
142
  LINE ${stats.lines}
128
143
  SPRITE ${stats.sprites}
@@ -1,3 +1,7 @@
1
+ export declare const GRADE_PARAMS: {
2
+ contrast: number;
3
+ saturation: number;
4
+ };
1
5
  export declare const SSGI_PARAMS: {
2
6
  enabled: boolean;
3
7
  sliceCount: number;
@@ -22,8 +26,10 @@ export type HoverStyles = {
22
26
  default: HoverStyle;
23
27
  } & Record<string, HoverStyle>;
24
28
  export declare const DEFAULT_HOVER_STYLES: HoverStyles;
25
- declare const PostProcessingPasses: ({ hoverStyles, }: {
29
+ declare const PostProcessingPasses: ({ hoverStyles, disablePostFx, }: {
26
30
  hoverStyles?: HoverStyles;
31
+ /** Host-controlled equivalent of `?disable=postFx` — see the Viewer prop. */
32
+ disablePostFx?: boolean;
27
33
  }) => null;
28
34
  export default PostProcessingPasses;
29
35
  //# sourceMappingURL=post-processing.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"post-processing.d.ts","sourceRoot":"","sources":["../../../src/components/viewer/post-processing.tsx"],"names":[],"mappings":"AAgCA,eAAO,MAAM,WAAW;;;;;;;;;;;;;CAavB,CAAA;AA0CD,MAAM,MAAM,UAAU,GAAG;IACvB,YAAY,EAAE,MAAM,CAAA;IACpB,WAAW,EAAE,MAAM,CAAA;IACnB,QAAQ,EAAE,MAAM,CAAA;IAChB,KAAK,EAAE,OAAO,CAAA;CACf,CAAA;AAED,MAAM,MAAM,WAAW,GAAG;IACxB,OAAO,EAAE,UAAU,CAAA;CACpB,GAAG,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAA;AAS9B,eAAO,MAAM,oBAAoB,EAAE,WAElC,CAAA;AAiBD,QAAA,MAAM,oBAAoB,GAAI,kBAE3B;IACD,WAAW,CAAC,EAAE,WAAW,CAAA;CAC1B,SAgdA,CAAA;AAED,eAAe,oBAAoB,CAAA"}
1
+ {"version":3,"file":"post-processing.d.ts","sourceRoot":"","sources":["../../../src/components/viewer/post-processing.tsx"],"names":[],"mappings":"AAwCA,eAAO,MAAM,YAAY;;;CAGxB,CAAA;AAGD,eAAO,MAAM,WAAW;;;;;;;;;;;;;CAavB,CAAA;AA0DD,MAAM,MAAM,UAAU,GAAG;IACvB,YAAY,EAAE,MAAM,CAAA;IACpB,WAAW,EAAE,MAAM,CAAA;IACnB,QAAQ,EAAE,MAAM,CAAA;IAChB,KAAK,EAAE,OAAO,CAAA;CACf,CAAA;AAED,MAAM,MAAM,WAAW,GAAG;IACxB,OAAO,EAAE,UAAU,CAAA;CACpB,GAAG,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAA;AAS9B,eAAO,MAAM,oBAAoB,EAAE,WAElC,CAAA;AAiBD,QAAA,MAAM,oBAAoB,GAAI,iCAG3B;IACD,WAAW,CAAC,EAAE,WAAW,CAAA;IACzB,6EAA6E;IAC7E,aAAa,CAAC,EAAE,OAAO,CAAA;CACxB,SAgnBA,CAAA;AAED,eAAe,oBAAoB,CAAA"}