@pascal-app/viewer 1.0.6 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (238) hide show
  1. package/README.md +54 -21
  2. package/dist/components/viewer/daylight-atmosphere.d.ts +9 -0
  3. package/dist/components/viewer/daylight-atmosphere.d.ts.map +1 -0
  4. package/dist/components/viewer/daylight-atmosphere.js +315 -0
  5. package/dist/components/viewer/drone-controller.d.ts +15 -0
  6. package/dist/components/viewer/drone-controller.d.ts.map +1 -0
  7. package/dist/components/viewer/drone-controller.js +194 -0
  8. package/dist/components/viewer/glb-interactive.d.ts +4 -18
  9. package/dist/components/viewer/glb-interactive.d.ts.map +1 -1
  10. package/dist/components/viewer/glb-interactive.js +32 -268
  11. package/dist/components/viewer/glb-replace-instances.d.ts +6 -3
  12. package/dist/components/viewer/glb-replace-instances.d.ts.map +1 -1
  13. package/dist/components/viewer/glb-replace-instances.js +5 -4
  14. package/dist/components/viewer/glb-scene-motion.d.ts +8 -0
  15. package/dist/components/viewer/glb-scene-motion.d.ts.map +1 -0
  16. package/dist/components/viewer/glb-scene-motion.js +57 -0
  17. package/dist/components/viewer/glb-scene.d.ts +5 -1
  18. package/dist/components/viewer/glb-scene.d.ts.map +1 -1
  19. package/dist/components/viewer/glb-scene.js +290 -49
  20. package/dist/components/viewer/glb-selection-callout.d.ts +17 -0
  21. package/dist/components/viewer/glb-selection-callout.d.ts.map +1 -0
  22. package/dist/components/viewer/glb-selection-callout.js +54 -0
  23. package/dist/components/viewer/glb-walkthrough-controller.d.ts.map +1 -1
  24. package/dist/components/viewer/glb-walkthrough-controller.js +6 -3
  25. package/dist/components/viewer/index.d.ts +5 -0
  26. package/dist/components/viewer/index.d.ts.map +1 -1
  27. package/dist/components/viewer/index.js +38 -10
  28. package/dist/components/viewer/lights.d.ts.map +1 -1
  29. package/dist/components/viewer/lights.js +192 -5
  30. package/dist/components/viewer/post-processing.d.ts.map +1 -1
  31. package/dist/components/viewer/post-processing.js +150 -94
  32. package/dist/components/viewer/registered-systems.d.ts.map +1 -1
  33. package/dist/components/viewer/registered-systems.js +2 -2
  34. package/dist/components/viewer/scene-environment.js +4 -5
  35. package/dist/components/viewer/selection-callout.d.ts +51 -0
  36. package/dist/components/viewer/selection-callout.d.ts.map +1 -0
  37. package/dist/components/viewer/selection-callout.js +197 -0
  38. package/dist/components/viewer/unsupported-gpu-fallback.d.ts.map +1 -1
  39. package/dist/components/viewer/unsupported-gpu-fallback.js +21 -1
  40. package/dist/components/viewer/use-daylight-rooms.d.ts +13 -0
  41. package/dist/components/viewer/use-daylight-rooms.d.ts.map +1 -0
  42. package/dist/components/viewer/use-daylight-rooms.js +278 -0
  43. package/dist/components/viewer/viewer-camera.d.ts.map +1 -1
  44. package/dist/components/viewer/viewer-camera.js +12 -1
  45. package/dist/hooks/use-gltf-ktx2.d.ts.map +1 -1
  46. package/dist/hooks/use-gltf-ktx2.js +2 -1
  47. package/dist/index.d.ts +26 -8
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +28 -8
  50. package/dist/lib/asset-url.d.ts +0 -1
  51. package/dist/lib/asset-url.d.ts.map +1 -1
  52. package/dist/lib/asset-url.js +3 -3
  53. package/dist/lib/daylight/ground.d.ts +21 -0
  54. package/dist/lib/daylight/ground.d.ts.map +1 -0
  55. package/dist/lib/daylight/ground.js +53 -0
  56. package/dist/lib/daylight/location.d.ts +32 -0
  57. package/dist/lib/daylight/location.d.ts.map +1 -0
  58. package/dist/lib/daylight/location.js +74 -0
  59. package/dist/lib/daylight/look.d.ts +33 -0
  60. package/dist/lib/daylight/look.d.ts.map +1 -0
  61. package/dist/lib/daylight/look.js +13 -0
  62. package/dist/lib/daylight/material-hooks.d.ts +2 -0
  63. package/dist/lib/daylight/material-hooks.d.ts.map +1 -0
  64. package/dist/lib/daylight/material-hooks.js +150 -0
  65. package/dist/lib/daylight/room-context.d.ts +12 -0
  66. package/dist/lib/daylight/room-context.d.ts.map +1 -0
  67. package/dist/lib/daylight/room-context.js +54 -0
  68. package/dist/lib/daylight/room-map.d.ts +124 -0
  69. package/dist/lib/daylight/room-map.d.ts.map +1 -0
  70. package/dist/lib/daylight/room-map.js +630 -0
  71. package/dist/lib/daylight/room-scene.d.ts +56 -0
  72. package/dist/lib/daylight/room-scene.d.ts.map +1 -0
  73. package/dist/lib/daylight/room-scene.js +391 -0
  74. package/dist/lib/daylight/rooms.d.ts +292 -0
  75. package/dist/lib/daylight/rooms.d.ts.map +1 -0
  76. package/dist/lib/daylight/rooms.js +1268 -0
  77. package/dist/lib/daylight/runtime.d.ts +33 -0
  78. package/dist/lib/daylight/runtime.d.ts.map +1 -0
  79. package/dist/lib/daylight/runtime.js +42 -0
  80. package/dist/lib/daylight/sky.d.ts +26 -0
  81. package/dist/lib/daylight/sky.d.ts.map +1 -0
  82. package/dist/lib/daylight/sky.js +217 -0
  83. package/dist/lib/daylight/solar.d.ts +46 -0
  84. package/dist/lib/daylight/solar.d.ts.map +1 -0
  85. package/dist/lib/daylight/solar.js +156 -0
  86. package/dist/lib/device-tier.d.ts +29 -0
  87. package/dist/lib/device-tier.d.ts.map +1 -0
  88. package/dist/lib/device-tier.js +71 -0
  89. package/dist/lib/dispose-object3d.d.ts.map +1 -1
  90. package/dist/lib/dispose-object3d.js +4 -2
  91. package/dist/lib/drone-flight.d.ts +44 -0
  92. package/dist/lib/drone-flight.d.ts.map +1 -0
  93. package/dist/lib/drone-flight.js +121 -0
  94. package/dist/lib/editor-highlight.d.ts +16 -0
  95. package/dist/lib/editor-highlight.d.ts.map +1 -0
  96. package/dist/lib/editor-highlight.js +33 -0
  97. package/dist/lib/foundation-materials.d.ts.map +1 -1
  98. package/dist/lib/foundation-materials.js +2 -4
  99. package/dist/lib/glb-merge.d.ts +23 -0
  100. package/dist/lib/glb-merge.d.ts.map +1 -0
  101. package/dist/lib/glb-merge.js +184 -0
  102. package/dist/lib/hardware-geometry.d.ts +21 -0
  103. package/dist/lib/hardware-geometry.d.ts.map +1 -0
  104. package/dist/lib/hardware-geometry.js +283 -0
  105. package/dist/lib/ktx2-loader.d.ts.map +1 -1
  106. package/dist/lib/ktx2-loader.js +2 -1
  107. package/dist/lib/layer-pass.d.ts +2 -0
  108. package/dist/lib/layer-pass.d.ts.map +1 -1
  109. package/dist/lib/layer-pass.js +20 -0
  110. package/dist/lib/materials.d.ts.map +1 -1
  111. package/dist/lib/materials.js +50 -23
  112. package/dist/lib/measurement-label.d.ts +4 -0
  113. package/dist/lib/measurement-label.d.ts.map +1 -0
  114. package/dist/lib/measurement-label.js +54 -0
  115. package/dist/lib/measurement-presentation.d.ts +5 -0
  116. package/dist/lib/measurement-presentation.d.ts.map +1 -0
  117. package/dist/lib/measurement-presentation.js +8 -0
  118. package/dist/lib/merged-outline-node.d.ts.map +1 -1
  119. package/dist/lib/merged-outline-node.js +7 -1
  120. package/dist/lib/occlusion-culler.d.ts +34 -0
  121. package/dist/lib/occlusion-culler.d.ts.map +1 -0
  122. package/dist/lib/occlusion-culler.js +698 -0
  123. package/dist/lib/pointer-events.d.ts.map +1 -1
  124. package/dist/lib/pointer-events.js +3 -3
  125. package/dist/lib/post-processing-resources.d.ts +4 -0
  126. package/dist/lib/post-processing-resources.d.ts.map +1 -1
  127. package/dist/lib/post-processing-resources.js +5 -0
  128. package/dist/lib/readback-rows.d.ts +14 -0
  129. package/dist/lib/readback-rows.d.ts.map +1 -0
  130. package/dist/lib/readback-rows.js +31 -0
  131. package/dist/lib/reveal-pose.d.ts.map +1 -1
  132. package/dist/lib/reveal-pose.js +3 -0
  133. package/dist/lib/scene-matrices.d.ts +12 -0
  134. package/dist/lib/scene-matrices.d.ts.map +1 -0
  135. package/dist/lib/scene-matrices.js +287 -0
  136. package/dist/lib/scene-pass-mrt.d.ts +5 -2
  137. package/dist/lib/scene-pass-mrt.d.ts.map +1 -1
  138. package/dist/lib/scene-pass-mrt.js +7 -2
  139. package/dist/lib/scene-themes.d.ts +0 -2
  140. package/dist/lib/scene-themes.d.ts.map +1 -1
  141. package/dist/lib/scene-themes.js +0 -2
  142. package/dist/lib/scene-visibility.d.ts +2 -0
  143. package/dist/lib/scene-visibility.d.ts.map +1 -1
  144. package/dist/lib/scene-visibility.js +12 -3
  145. package/dist/lib/site-surfaces.d.ts +1 -8
  146. package/dist/lib/site-surfaces.d.ts.map +1 -1
  147. package/dist/lib/site-surfaces.js +0 -19
  148. package/dist/lib/snapshot-pipeline.d.ts.map +1 -1
  149. package/dist/lib/snapshot-pipeline.js +24 -40
  150. package/dist/lib/texture-reference.d.ts.map +1 -1
  151. package/dist/lib/texture-reference.js +13 -10
  152. package/dist/lib/underground-ambiance.d.ts +0 -2
  153. package/dist/lib/underground-ambiance.d.ts.map +1 -1
  154. package/dist/lib/underground-ambiance.js +0 -2
  155. package/dist/lib/viewer-config.d.ts +21 -0
  156. package/dist/lib/viewer-config.d.ts.map +1 -0
  157. package/dist/lib/viewer-config.js +21 -0
  158. package/dist/lib/viewer-preference-storage.d.ts +7 -0
  159. package/dist/lib/viewer-preference-storage.d.ts.map +1 -0
  160. package/dist/lib/viewer-preference-storage.js +23 -0
  161. package/dist/store/use-item-light-pool.d.ts +3 -6
  162. package/dist/store/use-item-light-pool.d.ts.map +1 -1
  163. package/dist/store/use-item-light-pool.js +12 -0
  164. package/dist/store/use-viewer.d.ts +62 -59
  165. package/dist/store/use-viewer.d.ts.map +1 -1
  166. package/dist/store/use-viewer.js +106 -19
  167. package/dist/systems/ceiling/ceiling-system.d.ts +5 -0
  168. package/dist/systems/ceiling/ceiling-system.d.ts.map +1 -1
  169. package/dist/systems/ceiling/ceiling-system.js +11 -1
  170. package/dist/systems/door/door-system.d.ts.map +1 -1
  171. package/dist/systems/door/door-system.js +501 -145
  172. package/dist/systems/door/leaf-profile.d.ts +11 -0
  173. package/dist/systems/door/leaf-profile.d.ts.map +1 -0
  174. package/dist/systems/door/leaf-profile.js +140 -0
  175. package/dist/systems/elevator/elevator-interaction-system.d.ts +16 -2
  176. package/dist/systems/elevator/elevator-interaction-system.d.ts.map +1 -1
  177. package/dist/systems/elevator/elevator-interaction-system.js +41 -8
  178. package/dist/systems/geometry/geometry-status.d.ts +8 -0
  179. package/dist/systems/geometry/geometry-status.d.ts.map +1 -0
  180. package/dist/systems/geometry/geometry-status.js +24 -0
  181. package/dist/systems/interactive/callout-actions.d.ts +72 -0
  182. package/dist/systems/interactive/callout-actions.d.ts.map +1 -0
  183. package/dist/systems/interactive/callout-actions.js +212 -0
  184. package/dist/systems/interactive/interactive-system.d.ts +5 -1
  185. package/dist/systems/interactive/interactive-system.d.ts.map +1 -1
  186. package/dist/systems/interactive/interactive-system.js +44 -121
  187. package/dist/systems/interactive/opening-settled.d.ts +16 -0
  188. package/dist/systems/interactive/opening-settled.d.ts.map +1 -0
  189. package/dist/systems/interactive/opening-settled.js +53 -0
  190. package/dist/systems/item-light/item-light-system.d.ts.map +1 -1
  191. package/dist/systems/item-light/item-light-system.js +22 -214
  192. package/dist/systems/item-light/pooled-lights.d.ts +70 -0
  193. package/dist/systems/item-light/pooled-lights.d.ts.map +1 -0
  194. package/dist/systems/item-light/pooled-lights.js +449 -0
  195. package/dist/systems/level/level-system.d.ts.map +1 -1
  196. package/dist/systems/level/level-system.js +19 -1
  197. package/dist/systems/roof/roof-material-slots.d.ts +14 -0
  198. package/dist/systems/roof/roof-material-slots.d.ts.map +1 -0
  199. package/dist/systems/roof/roof-material-slots.js +13 -0
  200. package/dist/systems/roof/roof-materials.d.ts +25 -2
  201. package/dist/systems/roof/roof-materials.d.ts.map +1 -1
  202. package/dist/systems/roof/roof-materials.js +85 -26
  203. package/dist/systems/roof/roof-system.d.ts +34 -1
  204. package/dist/systems/roof/roof-system.d.ts.map +1 -1
  205. package/dist/systems/roof/roof-system.js +582 -168
  206. package/dist/systems/wall/opening-casing-geometry.d.ts +14 -0
  207. package/dist/systems/wall/opening-casing-geometry.d.ts.map +1 -0
  208. package/dist/systems/wall/opening-casing-geometry.js +102 -0
  209. package/dist/systems/wall/opening-cutout-geometry.d.ts +9 -0
  210. package/dist/systems/wall/opening-cutout-geometry.d.ts.map +1 -1
  211. package/dist/systems/wall/opening-cutout-geometry.js +7 -3
  212. package/dist/systems/wall/wall-materials.d.ts.map +1 -1
  213. package/dist/systems/wall/wall-materials.js +28 -35
  214. package/dist/systems/wall/wall-system.d.ts +11 -0
  215. package/dist/systems/wall/wall-system.d.ts.map +1 -1
  216. package/dist/systems/wall/wall-system.js +86 -25
  217. package/dist/systems/wall/wall-t-junctions.d.ts +15 -0
  218. package/dist/systems/wall/wall-t-junctions.d.ts.map +1 -0
  219. package/dist/systems/wall/wall-t-junctions.js +149 -0
  220. package/dist/systems/window/window-animation-system.d.ts +3 -12
  221. package/dist/systems/window/window-animation-system.d.ts.map +1 -1
  222. package/dist/systems/window/window-animation-system.js +37 -122
  223. package/dist/systems/window/window-pose.d.ts +31 -0
  224. package/dist/systems/window/window-pose.d.ts.map +1 -0
  225. package/dist/systems/window/window-pose.js +152 -0
  226. package/dist/systems/window/window-roller-shutter.d.ts +8 -0
  227. package/dist/systems/window/window-roller-shutter.d.ts.map +1 -0
  228. package/dist/systems/window/window-roller-shutter.js +64 -0
  229. package/dist/systems/window/window-system.d.ts +2 -11
  230. package/dist/systems/window/window-system.d.ts.map +1 -1
  231. package/dist/systems/window/window-system.js +470 -303
  232. package/package.json +6 -6
  233. package/dist/systems/interactive/control-widget.d.ts +0 -10
  234. package/dist/systems/interactive/control-widget.d.ts.map +0 -1
  235. package/dist/systems/interactive/control-widget.js +0 -35
  236. package/dist/systems/interactive/procedural-controls.d.ts +0 -13
  237. package/dist/systems/interactive/procedural-controls.d.ts.map +0 -1
  238. package/dist/systems/interactive/procedural-controls.js +0 -17
package/README.md CHANGED
@@ -5,13 +5,13 @@
5
5
  ## Installation
6
6
 
7
7
  ```bash
8
- npm install @pascal-app/core @pascal-app/viewer @pascal-app/editor @pascal-app/nodes
8
+ npm install @pascal-app/core @pascal-app/viewer @pascal-app/nodes
9
9
  ```
10
10
 
11
11
  ## Peer Dependencies
12
12
 
13
13
  ```bash
14
- npm install next react react-dom three @react-three/fiber @react-three/drei lucide-react zustand
14
+ npm install react react-dom three @react-three/fiber @react-three/drei lucide-react zustand
15
15
  ```
16
16
 
17
17
  ## What's Included
@@ -25,33 +25,66 @@ npm install next react react-dom three @react-three/fiber @react-three/drei luci
25
25
 
26
26
  ## Usage
27
27
 
28
- ```typescript
29
- import { loadPlugin } from '@pascal-app/core'
30
- import { builtinPlugin } from '@pascal-app/nodes'
31
- import { Viewer } from '@pascal-app/viewer'
32
- import { useEffect, useState } from 'react'
28
+ The live render entry rebuilds geometry from a scene graph in ordinary React applications,
29
+ including Vite. It requires neither Next.js, Tailwind, nor `@pascal-app/editor`.
33
30
 
34
- const registryReady = loadPlugin(builtinPlugin)
31
+ ```tsx
32
+ import { acquireSceneReadOnlyLease, loadPlugin, useScene } from '@pascal-app/core'
33
+ import { renderPlugin } from '@pascal-app/nodes/render'
34
+ import { configureViewer, Viewer } from '@pascal-app/viewer'
35
+ import { OrbitControls } from '@react-three/drei'
36
+ import scene from './scene.json'
37
+
38
+ configureViewer({
39
+ assetsBaseUrl: 'https://editor.pascal.app',
40
+ storageUrl: 'https://your-storage.example',
41
+ // Optional self-hosted decoder directories (include the trailing slash):
42
+ // decoders: { dracoPath: '/decoders/draco/', basisPath: '/decoders/basis/' },
43
+ })
44
+ await loadPlugin(renderPlugin)
45
+ const releaseReadOnly = acquireSceneReadOnlyLease()
46
+ useScene.getState().setScene(scene.nodes, scene.rootNodeIds, {
47
+ materials: scene.materials,
48
+ collections: scene.collections,
49
+ installedPlugins: scene.installedPlugins,
50
+ })
35
51
 
36
52
  function App() {
37
- const [ready, setReady] = useState(false)
38
-
39
- useEffect(() => {
40
- void registryReady.then(() => setReady(true))
41
- }, [])
42
-
43
- if (!ready) return null
44
-
45
53
  return (
46
54
  <div style={{ width: '100vw', height: '100vh' }}>
47
- <Viewer />
55
+ <Viewer>
56
+ <OrbitControls makeDefault target={[0, 1, 0]} />
57
+ </Viewer>
48
58
  </div>
49
59
  )
50
60
  }
61
+
62
+ // On teardown, unmount App before releasing the lease:
63
+ // releaseReadOnly()
51
64
  ```
52
65
 
53
- Load the built-in plugin once, before mounting any viewer. Without it, the registry has no node
54
- definitions and scene nodes cannot render. Host-provided plugins use the same `loadPlugin` API.
66
+ Load exactly one built-in plugin per runtime: `renderPlugin` for a viewer, or the full
67
+ `builtinPlugin` from `@pascal-app/nodes` for an editor. Without registration, nodes cannot render.
68
+ The read-only lease suppresses document-writing systems while the live geometry systems rebuild
69
+ from the supplied graph. Keep it until the viewer unmounts.
70
+
71
+ **One active scene per page/package instance.** `useScene`, `useViewer`, the node registry,
72
+ `sceneRegistry`, loader caches and viewer systems are shared singletons. Concurrent viewers cannot
73
+ own independent graphs. Unmount the previous viewer and release its read-only lease and host
74
+ subscriptions before switching projects; hydrate the new graph and reset the host's viewer
75
+ selection/project presentation before remounting. Use a separate page/iframe for independent graphs.
76
+
77
+ `configureViewer` resolves asset and storage origins at call time. Without configuration, assets
78
+ use `https://editor.pascal.app`, with the existing `NEXT_PUBLIC_ASSETS_CDN_URL` and
79
+ `NEXT_PUBLIC_SUPABASE_URL` environments used when available. Next hosts should pass those values
80
+ explicitly at bootstrap. Draco defaults to version 1.5.5 and Basis to the Three.js 0.187.0
81
+ transcoder. Configure before loading assets: changing paths does not unload cached models or
82
+ already initialized decoder workers. KTX2 support is detected per renderer and misaligned textures
83
+ retain the RGBA fallback. Denied browser storage disables preference persistence without blocking
84
+ rendering.
85
+
86
+ See [`fixtures/viewer-live`](../../fixtures/viewer-live) for a complete small graph and the
87
+ isolated packed-package dev/production smoke test.
55
88
 
56
89
  ## Custom Camera Controls
57
90
 
@@ -212,11 +245,11 @@ function ViewerControls() {
212
245
  ## Asset CDN Helpers
213
246
 
214
247
  ```typescript
215
- import { resolveCdnUrl, ASSETS_CDN_URL } from '@pascal-app/viewer'
248
+ import { resolveCdnUrl } from '@pascal-app/viewer'
216
249
 
217
250
  // Resolves relative paths to CDN URLs
218
251
  const url = resolveCdnUrl('/items/chair/model.glb')
219
- // → 'https://pascal-cdn.wawasensei.dev/items/chair/model.glb'
252
+ // → 'https://editor.pascal.app/items/chair/model.glb'
220
253
 
221
254
  // Handles external URLs and asset:// protocol
222
255
  const externalUrl = resolveCdnUrl('https://example.com/model.glb')
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The colored appearance's built-in sun and sky: real sun position for the site (or a fallback
3
+ * location), a time-of-day preset and a weather preset. One shadowed key (the sun, or a cool moon
4
+ * at night), the sky as a PMREM environment, metered exposure and eye-like white balance.
5
+ * Installed through the generic `SceneAtmosphere` seam, so a plugin atmosphere mounted later
6
+ * still takes precedence.
7
+ */
8
+ export declare function DaylightAtmosphere(): import("react").JSX.Element;
9
+ //# sourceMappingURL=daylight-atmosphere.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"daylight-atmosphere.d.ts","sourceRoot":"","sources":["../../../src/components/viewer/daylight-atmosphere.tsx"],"names":[],"mappings":"AAgFA;;;;;;GAMG;AACH,wBAAgB,kBAAkB,gCAySjC"}
@@ -0,0 +1,315 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { useInteractive, useScene } from '@pascal-app/core';
4
+ import { useFrame, useThree } from '@react-three/fiber';
5
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
6
+ import { BackSide, Color, MathUtils, Mesh, Scene, SphereGeometry, Vector3 } from 'three';
7
+ import { pmremTexture, positionWorldDirection, vec3 } from 'three/tsl';
8
+ import { MeshBasicNodeMaterial, PMREMGenerator, } from 'three/webgpu';
9
+ import { resolveLocation } from '../../lib/daylight/location';
10
+ import { installDaylightMaterialHooks } from '../../lib/daylight/material-hooks';
11
+ import { restoreLampEmitters } from '../../lib/daylight/room-scene';
12
+ import { daylightRooms } from '../../lib/daylight/rooms';
13
+ import { daylightUniforms, setDaylightRenderer } from '../../lib/daylight/runtime';
14
+ import { createDaylightSky, WEATHER_COVER } from '../../lib/daylight/sky';
15
+ import { presetInstant, solarClockInstant, solarLight, } from '../../lib/daylight/solar';
16
+ import useViewer, {} from '../../store/use-viewer';
17
+ import { SceneAtmosphere } from './scene-atmosphere';
18
+ import { useDaylightRooms } from './use-daylight-rooms';
19
+ /** Irradiance that renders at exposure 1 (≈ a clear afternoon exterior). */
20
+ const METER_REF = 3.2;
21
+ /** Image-based lighting gain on the sky environment. */
22
+ const ENV_INTENSITY = 1.15;
23
+ const MOON_COLOR = new Color('#9fb4ff');
24
+ /** Ground fog range (m), capped by the camera's far plane (the grounds add a horizon haze). */
25
+ const FOG_START = 250;
26
+ const FOG_END = 2600;
27
+ /** Eye-like chromatic adaptation strength (0 = off, 1 = full neutralisation). */
28
+ const WHITE_BALANCE = 0.75;
29
+ /**
30
+ * How much of its extra exposure lamp light seen from outside gives back, relative to the rooms'
31
+ * indoor exposure (0 none … 1 all; a little is kept, so lit windows still read as glowing).
32
+ */
33
+ const LAMP_VIEW_COMP = 0.8;
34
+ /** Maximum metered exposure boost with the camera inside a room. */
35
+ const INTERIOR_EV = 2.4;
36
+ /**
37
+ * Indoors the eye opens up a little past the meter's partial adaptation (EV): rooms read airy
38
+ * rather than dim, by day and by lamplight alike.
39
+ */
40
+ const INTERIOR_BIAS_EV = 0.35;
41
+ /** Sun shadow map per quality tier. */
42
+ const SHADOW_MAP = { high: 4096, medium: 2048, low: 1024 };
43
+ /** EON rough diffuse per quality tier. */
44
+ const EON = { high: 0.6, medium: 0.6, low: 0 };
45
+ const luminance = (c) => 0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b;
46
+ /** Cosine-weighted hemisphere probes for the sky irradiance colour. */
47
+ const HEMISPHERE = [
48
+ new Vector3(0, 1, 0),
49
+ ...Array.from({ length: 6 }, (_, i) => new Vector3(Math.cos((i * Math.PI) / 3), 0.6, Math.sin((i * Math.PI) / 3)).normalize()),
50
+ ...Array.from({ length: 6 }, (_, i) => new Vector3(Math.cos((i * Math.PI) / 3 + 0.5), 0.15, Math.sin((i * Math.PI) / 3 + 0.5)).normalize()),
51
+ ];
52
+ const UP = new Vector3(0, 1, 0);
53
+ /**
54
+ * The colored appearance's built-in sun and sky: real sun position for the site (or a fallback
55
+ * location), a time-of-day preset and a weather preset. One shadowed key (the sun, or a cool moon
56
+ * at night), the sky as a PMREM environment, metered exposure and eye-like white balance.
57
+ * Installed through the generic `SceneAtmosphere` seam, so a plugin atmosphere mounted later
58
+ * still takes precedence.
59
+ */
60
+ export function DaylightAtmosphere() {
61
+ const renderer = useThree((s) => s.gl);
62
+ const timeOfDay = useViewer((s) => s.timeOfDay);
63
+ const timeOfDayMinutes = useViewer((s) => s.timeOfDayMinutes);
64
+ const skyLive = useViewer((s) => s.skyLive);
65
+ const weather = useViewer((s) => s.weather);
66
+ const deviceLocation = useViewer((s) => s.deviceLocation);
67
+ const transparent = useViewer((s) => s.transparentBackground);
68
+ const quality = useViewer((s) => s.renderQuality);
69
+ const site = useScene((state) => {
70
+ for (const id of state.rootNodeIds) {
71
+ const node = state.nodes[id];
72
+ if (node?.type === 'site')
73
+ return node;
74
+ }
75
+ return undefined;
76
+ });
77
+ const sky = useMemo(() => createDaylightSky(), []);
78
+ // The environment: a PMREM of the visible sky (clouds and aureole, no sun disc — the key light
79
+ // is the sun), re-captured into the same target when the sun or the weather changes, so the
80
+ // environment node installed once stays valid.
81
+ const environment = useMemo(() => {
82
+ const scene = new Scene();
83
+ const material = new MeshBasicNodeMaterial({ side: BackSide, depthWrite: false });
84
+ material.colorNode = sky.skyRadiance(positionWorldDirection, { disc: false });
85
+ // Radiance, not an unlit surface: it keeps the exposure (see the material hooks).
86
+ material.userData.daylightSky = true;
87
+ const dome = new Mesh(new SphereGeometry(10, 48, 24), material);
88
+ scene.add(dome);
89
+ // The viewer mounts its scene once the GPU renderer is initialized; anything else (a test
90
+ // double) renders without the sky environment.
91
+ const ready = renderer.hasInitialized?.() === true;
92
+ if (!ready)
93
+ return { dome, pmrem: null, target: null, capture: () => { } };
94
+ const pmrem = new PMREMGenerator(renderer);
95
+ const capture = (into) => pmrem.fromScene(scene, 0, 0.1, 100, {
96
+ size: 256,
97
+ renderTarget: into,
98
+ });
99
+ const target = capture(null);
100
+ return { dome, pmrem, target, capture: () => capture(target) };
101
+ }, [renderer, sky]);
102
+ const source = useMemo(() => ({
103
+ skyRadiance: (dir) => sky.skyRadiance(dir),
104
+ reflectionRadiance: (dir) => sky.skyRadiance(dir, { disc: false }).mul(ENV_INTENSITY),
105
+ // Just above the horizon in the view's heading: what the ground fog and the backdrop meet at.
106
+ fogRadiance: (dir) => sky.horizonRadiance(vec3(dir.x, 0.004, dir.z)),
107
+ environmentNode: (environment.target
108
+ ? pmremTexture(environment.target.texture).mul(ENV_INTENSITY)
109
+ : vec3(0)),
110
+ sunDirection: new Vector3(0, 1, 0),
111
+ sunColor: new Color(1, 1, 1),
112
+ sunIntensity: 3,
113
+ moonDirection: new Vector3(0, 1, 0),
114
+ moonColor: MOON_COLOR.clone(),
115
+ moonIntensity: 0,
116
+ skyColor: new Color(1, 1, 1),
117
+ groundColor: new Color(0.5, 0.5, 0.5),
118
+ hemisphereIntensity: 0,
119
+ ambientIntensity: 0,
120
+ exposure: 1,
121
+ fogStart: FOG_START,
122
+ fogEnd: FOG_END,
123
+ whiteBalance: new Vector3(1, 1, 1),
124
+ shadow: { mapSize: SHADOW_MAP.high, bias: -0.0002, normalBias: 0.035, radius: 2 },
125
+ elevation: 45,
126
+ }), [environment, sky]);
127
+ const solar = useRef(null);
128
+ const meter = useRef({ ev: 0, keyE: 0, night: 0, skyLum: 0, outdoor: new Color(1, 1, 1) });
129
+ const wbTarget = useMemo(() => new Vector3(1, 1, 1), []);
130
+ useDaylightRooms();
131
+ // Materials attach their daylight hooks for this renderer only (thumbnail canvases elsewhere
132
+ // keep their plain look); the uniforms go back to neutral when the look leaves.
133
+ useLayoutEffect(() => {
134
+ installDaylightMaterialHooks();
135
+ setDaylightRenderer(renderer, true);
136
+ daylightUniforms.albedoClamp.value = 1;
137
+ return () => {
138
+ restoreLampEmitters();
139
+ setDaylightRenderer(renderer, false);
140
+ daylightUniforms.albedoClamp.value = 0;
141
+ daylightUniforms.eon.value = 0;
142
+ daylightUniforms.lampLevel.value = 0;
143
+ daylightUniforms.glowComp.value = 1;
144
+ daylightUniforms.emitterExpInv.value = 1;
145
+ daylightRooms.lampView.value = 1;
146
+ };
147
+ }, [renderer]);
148
+ useEffect(() => {
149
+ daylightUniforms.eon.value = EON[quality];
150
+ source.shadow.mapSize = SHADOW_MAP[quality];
151
+ // The Low tier drops the soft room map (openings) and the sky patch.
152
+ daylightRooms.softRooms.value = quality === 'low' ? 0 : 1;
153
+ daylightRooms.skyPatch.value = quality === 'low' ? 0 : 3;
154
+ daylightRooms.refresh();
155
+ }, [quality, source]);
156
+ // The live sky moves on with the clock, a minute at a time.
157
+ const [clockTick, setClockTick] = useState(0);
158
+ useEffect(() => {
159
+ if (!skyLive || transparent)
160
+ return;
161
+ const timer = setInterval(() => setClockTick((tick) => tick + 1), 60_000);
162
+ return () => clearInterval(timer);
163
+ }, [skyLive, transparent]);
164
+ // Lamp switches follow the sun only when it crosses into or out of the evening, so the
165
+ // person's own switches survive weather changes and the live clock's ticks.
166
+ const lampsLit = useRef(null);
167
+ // Sun, sky and lights follow the time of day: the live clock, an exact time, or a preset
168
+ // (pinned to a neutral fair midday for transparent captures).
169
+ // biome-ignore lint/correctness/useExhaustiveDependencies: clockTick re-runs the live sky.
170
+ useEffect(() => {
171
+ const cover = transparent ? WEATHER_COVER.fair : WEATHER_COVER[weather];
172
+ const { location } = resolveLocation(site, deviceLocation);
173
+ const today = new Date();
174
+ const when = transparent
175
+ ? presetInstant(today, location.lat, location.lng, 'midday')
176
+ : skyLive
177
+ ? today
178
+ : timeOfDayMinutes !== null
179
+ ? solarClockInstant(today, location.lng, timeOfDayMinutes)
180
+ : presetInstant(today, location.lat, location.lng, timeOfDay);
181
+ const sun = solarLight(when, location.lat, location.lng, site?.northRotation ?? 0, solar.current ?? undefined);
182
+ solar.current = sun;
183
+ sky.update(sun, cover);
184
+ source.elevation = sun.elevation;
185
+ // One key light: the sun, or below 1° a cool, dim moon high on the other side of the sky.
186
+ // Clouds dim the sun statistically (no cloud shadows) and soften its shadows.
187
+ if (sun.elevation < 1) {
188
+ source.sunDirection
189
+ .set(-sun.direction.x, Math.max(0.5, Math.abs(sun.direction.y)), -sun.direction.z)
190
+ .normalize();
191
+ source.sunColor.copy(MOON_COLOR);
192
+ source.sunIntensity = 0.18 * sun.night;
193
+ }
194
+ else {
195
+ source.sunDirection.copy(sun.direction);
196
+ source.sunColor.copy(sun.color);
197
+ source.sunIntensity = 3.4 * sun.strength * (1 - 0.5 * cover ** 1.6);
198
+ }
199
+ source.moonIntensity = 0;
200
+ source.shadow.radius = 1.6 + 2.2 * (1 - MathUtils.smoothstep(sun.elevation, 2, 30)) + cover * 3;
201
+ meter.current.keyE = source.sunIntensity * Math.max(0.15, sun.direction.y);
202
+ meter.current.night = sun.night;
203
+ // The outdoor illuminant (sky irradiance + the key light), for the white balance.
204
+ const skyIrr = new Color(0, 0, 0);
205
+ let weight = 0;
206
+ for (const d of HEMISPHERE) {
207
+ skyIrr.add(sky.radianceCpu(d).multiplyScalar(d.y));
208
+ weight += d.y;
209
+ }
210
+ skyIrr.multiplyScalar((Math.PI / weight) * ENV_INTENSITY);
211
+ source.skyColor.copy(skyIrr).multiplyScalar(1 / Math.PI);
212
+ meter.current.skyLum = luminance(skyIrr);
213
+ meter.current.outdoor
214
+ .copy(skyIrr)
215
+ .add(source.sunColor.clone().multiplyScalar(meter.current.keyE));
216
+ // Windows let this sky (and sun) into the rooms.
217
+ daylightRooms.updateSky((dir) => sky.radianceCpu(dir).multiplyScalar(ENV_INTENSITY), sun.direction, sun.color, source.sunIntensity);
218
+ // Lamps follow the light: a smooth level for the room lamps, on/off for the lamp switches.
219
+ daylightUniforms.lampLevel.value = 1 - MathUtils.smoothstep(sun.elevation, -5, 6);
220
+ // Blue hour and night (the presets aim at −4° and −14°; golden hour at +6° stays unlit).
221
+ const lit = sun.elevation < -2;
222
+ if (lit !== lampsLit.current) {
223
+ lampsLit.current = lit;
224
+ useInteractive.getState().setLampDefault(lit, true);
225
+ }
226
+ // Re-capture the sky environment.
227
+ environment.capture();
228
+ }, [
229
+ clockTick,
230
+ deviceLocation,
231
+ environment,
232
+ site,
233
+ skyLive,
234
+ sky,
235
+ source,
236
+ timeOfDay,
237
+ timeOfDayMinutes,
238
+ transparent,
239
+ weather,
240
+ ]);
241
+ // Lamps loaded after the preset applied (items mount late, and an authored "on" default would
242
+ // light them by day) join the time of day; the person's own switches are left alone after.
243
+ useEffect(() => {
244
+ const known = new Set(Object.keys(useInteractive.getState().lampItems));
245
+ return useInteractive.subscribe((state) => {
246
+ // A lamp that leaves (its item unmounts) is new again when it comes back.
247
+ for (const id of known)
248
+ if (!(id in state.lampItems))
249
+ known.delete(id);
250
+ for (const [id, indices] of Object.entries(state.lampItems)) {
251
+ if (known.has(id))
252
+ continue;
253
+ known.add(id);
254
+ for (const index of indices) {
255
+ if (state.items[id]?.controlValues[index] !== state.lampDefault)
256
+ state.setControlValue(id, index, state.lampDefault);
257
+ }
258
+ }
259
+ });
260
+ }, []);
261
+ useEffect(() => () => {
262
+ environment.target?.dispose();
263
+ environment.pmrem?.dispose();
264
+ environment.dome.geometry.dispose();
265
+ environment.dome.material.dispose();
266
+ }, [environment]);
267
+ useFrame(({ camera }, delta) => {
268
+ const dt = Math.min(delta, 0.1);
269
+ // The ground must be fully fogged before the far plane clips it (no hard horizon line).
270
+ source.fogEnd = Math.min(FOG_END, camera.far * 0.9);
271
+ source.fogStart = Math.min(FOG_START, source.fogEnd * 0.1);
272
+ sky.cloudTime.value += dt;
273
+ // Metered exposure: the irradiance where the camera stands — outside: sun + sky; inside a
274
+ // room: its windows + bounce + lamps — exposed towards mid-grey with partial adaptation (dark
275
+ // scenes stay a bit dark), up to +2 EV indoors.
276
+ const { room, inside } = daylightRooms.isEnclosedAt(camera.position);
277
+ daylightUniforms.cameraOutside.value = inside ? 0 : 1;
278
+ const skyE = Math.PI * 0.45 * luminance(sky.radianceCpu(UP)) * ENV_INTENSITY;
279
+ // Outside, the lit rooms in view count too: a cutaway of lamplit rooms exposes for them.
280
+ const viewed = inside
281
+ ? { sceneE: daylightRooms.meter(room, skyE), roomsE: 0, illuminant: null }
282
+ : daylightRooms.viewedMeter(camera, meter.current.keyE + skyE, skyE, meter.current.outdoor, meter.current.skyLum);
283
+ const meterEV = (E, max, bias = 0) => MathUtils.clamp(Math.log2(METER_REF / Math.max(E, 1e-4)) * 0.6 + bias, -0.5, max);
284
+ const indoorEV = (E) => meterEV(E, INTERIOR_EV, INTERIOR_BIAS_EV);
285
+ const target = inside ? indoorEV(viewed.sceneE) : meterEV(viewed.sceneE, 2.2);
286
+ meter.current.ev += (target - meter.current.ev) * Math.min(1, dt * 3);
287
+ source.exposure = 2 ** meter.current.ev;
288
+ // Glows (window panes, lamp shades) stay out of the night exposure lift.
289
+ daylightUniforms.glowComp.value = 1 / 2 ** Math.max(0, meter.current.ev - 0.5);
290
+ daylightUniforms.emitterExpInv.value = 1 / source.exposure;
291
+ // Lamp light seen from outside gives back most of the exposure it gets above what those rooms
292
+ // meter at from inside (back to 1 indoors), so lit rooms look from out there as they do in them.
293
+ const lampView = inside || viewed.roomsE <= 0
294
+ ? 1
295
+ : 2 ** (-LAMP_VIEW_COMP * Math.max(0, meter.current.ev - indoorEV(viewed.roomsE)));
296
+ const shown = daylightRooms.lampView.value;
297
+ daylightRooms.lampView.value = shown + (lampView - shown) * Math.min(1, dt * 3);
298
+ // Eye-like chromatic adaptation (von Kries): divide out the illuminant where the camera is,
299
+ // partially (golden hour and lamplight keep some warmth), luminance-preserving; a bit less
300
+ // at night (moonlight stays blue-ish).
301
+ // Outside, the eye adapts towards the lit rooms in view too (by their pull on the meter).
302
+ const illum = (inside
303
+ ? daylightRooms.roomIlluminant(room, meter.current.outdoor, meter.current.skyLum)
304
+ : viewed.illuminant) ?? meter.current.outdoor;
305
+ const L = luminance(illum);
306
+ if (L > 1e-5) {
307
+ const k = WHITE_BALANCE * (1 - 0.35 * meter.current.night);
308
+ wbTarget.set(MathUtils.lerp(1, L / Math.max(illum.r, 1e-4), k), MathUtils.lerp(1, L / Math.max(illum.g, 1e-4), k), MathUtils.lerp(1, L / Math.max(illum.b, 1e-4), k));
309
+ wbTarget.clampScalar(0.5, 2);
310
+ wbTarget.divideScalar(0.2126 * wbTarget.x + 0.7152 * wbTarget.y + 0.0722 * wbTarget.z);
311
+ }
312
+ source.whiteBalance.lerp(wbTarget, Math.min(1, dt * 2));
313
+ }, -2);
314
+ return _jsx(SceneAtmosphere, { source: source });
315
+ }
@@ -0,0 +1,15 @@
1
+ export type DroneControllerProps = {
2
+ /** Freezes flight and look, e.g. while the host renders a still. */
3
+ paused?: boolean;
4
+ /** Leaves the drone: Esc, or losing the pointer lock it held. Ends the walkthrough by default. */
5
+ onExit?: () => void;
6
+ };
7
+ /**
8
+ * The free-flying drone camera for any scene, parametric or baked: no gravity,
9
+ * floor or collision, so it needs no collider. Picks up from the current camera
10
+ * pose. Pointer lock drives look (click the canvas to lock); WASD / arrows move
11
+ * along the view, Space or E rises, Q or Ctrl sinks, Shift boosts, Alt slows,
12
+ * P frees the cursor (click or P resumes) and Esc leaves.
13
+ */
14
+ export declare function DroneController({ paused, onExit }: DroneControllerProps): null;
15
+ //# sourceMappingURL=drone-controller.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"drone-controller.d.ts","sourceRoot":"","sources":["../../../src/components/viewer/drone-controller.tsx"],"names":[],"mappings":"AAsCA,MAAM,MAAM,oBAAoB,GAAG;IACjC,oEAAoE;IACpE,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,kGAAkG;IAClG,MAAM,CAAC,EAAE,MAAM,IAAI,CAAA;CACpB,CAAA;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,EAAE,MAAc,EAAE,MAAM,EAAE,EAAE,oBAAoB,QAyK/E"}
@@ -0,0 +1,194 @@
1
+ 'use client';
2
+ import { useFrame, useThree } from '@react-three/fiber';
3
+ import { useEffect, useRef } from 'react';
4
+ import { Vector3 } from 'three';
5
+ import { createDroneInput, DRONE_SLOW_MULTIPLIER, droneInputForKey, readDroneInput, seedDroneLook, stepDroneFlight, turnFirstPersonLook, } from '../../lib/drone-flight';
6
+ import useViewer from '../../store/use-viewer';
7
+ import { WALKTHROUGH_FOV } from './glb-walkthrough-controller';
8
+ import { applyWalkthroughCameraClipping } from './viewer-camera';
9
+ const TYPING_TARGET = 'input, textarea, select, [contenteditable]:not([contenteditable="false"]), [role="combobox"], [role="textbox"], [role="dialog"], [role="alertdialog"]';
10
+ function isTypingTarget(target) {
11
+ const element = target;
12
+ return !!element && (element.isContentEditable === true || !!element.closest?.(TYPING_TARGET));
13
+ }
14
+ function requestLock(canvas) {
15
+ // The request can reject asynchronously (the browser's re-lock cooldown);
16
+ // clicking the canvas asks again.
17
+ const result = canvas.requestPointerLock?.();
18
+ if (result && typeof result.catch === 'function')
19
+ result.catch(() => { });
20
+ }
21
+ function exitWalkthrough() {
22
+ useViewer.getState().setWalkthroughMode(false);
23
+ }
24
+ /**
25
+ * The free-flying drone camera for any scene, parametric or baked: no gravity,
26
+ * floor or collision, so it needs no collider. Picks up from the current camera
27
+ * pose. Pointer lock drives look (click the canvas to lock); WASD / arrows move
28
+ * along the view, Space or E rises, Q or Ctrl sinks, Shift boosts, Alt slows,
29
+ * P frees the cursor (click or P resumes) and Esc leaves.
30
+ */
31
+ export function DroneController({ paused = false, onExit }) {
32
+ const camera = useThree((state) => state.camera);
33
+ const gl = useThree((state) => state.gl);
34
+ const lookRef = useRef({ yaw: 0, pitch: 0 });
35
+ const heldKeysRef = useRef(new Set());
36
+ const inputRef = useRef(createDroneInput());
37
+ const velocityRef = useRef(new Vector3());
38
+ const suspendRef = useRef(false);
39
+ const pausedRef = useRef(paused);
40
+ const onExitRef = useRef(onExit);
41
+ const entryPoseRef = useRef(null);
42
+ useEffect(() => {
43
+ pausedRef.current = paused;
44
+ }, [paused]);
45
+ useEffect(() => {
46
+ onExitRef.current = onExit;
47
+ }, [onExit]);
48
+ // First person needs foreshortening: fly a perspective camera, and give the
49
+ // projection back on exit.
50
+ useEffect(() => {
51
+ const previousMode = useViewer.getState().cameraMode;
52
+ if (previousMode === 'orthographic')
53
+ useViewer.getState().setCameraMode('perspective');
54
+ return () => {
55
+ if (previousMode === 'orthographic')
56
+ useViewer.getState().setCameraMode('orthographic');
57
+ };
58
+ }, []);
59
+ // Take off from the pose the camera had on entry. Leaving orthographic swaps
60
+ // in a fresh perspective camera, which inherits that pose.
61
+ useEffect(() => {
62
+ const entry = entryPoseRef.current;
63
+ if (!entry) {
64
+ entryPoseRef.current = {
65
+ camera,
66
+ position: camera.position.clone(),
67
+ quaternion: camera.quaternion.clone(),
68
+ };
69
+ }
70
+ else if (entry.camera !== camera) {
71
+ camera.position.copy(entry.position);
72
+ camera.quaternion.copy(entry.quaternion);
73
+ camera.updateMatrixWorld(true);
74
+ }
75
+ seedDroneLook(lookRef.current, camera.quaternion);
76
+ velocityRef.current.set(0, 0, 0);
77
+ }, [camera]);
78
+ useEffect(() => {
79
+ const perspective = camera;
80
+ if (!perspective.isPerspectiveCamera)
81
+ return;
82
+ const previousFov = perspective.fov;
83
+ perspective.fov = WALKTHROUGH_FOV;
84
+ perspective.updateProjectionMatrix();
85
+ const restoreClipping = applyWalkthroughCameraClipping(perspective);
86
+ return () => {
87
+ restoreClipping();
88
+ perspective.fov = previousFov;
89
+ perspective.updateProjectionMatrix();
90
+ };
91
+ }, [camera]);
92
+ useEffect(() => {
93
+ const canvas = gl.domElement;
94
+ const heldKeys = heldKeysRef.current;
95
+ let wasLocked = false;
96
+ const exit = () => (onExitRef.current ?? exitWalkthrough)();
97
+ // Keyboard focus may still sit on the button that started the flight, where
98
+ // Space would press it again.
99
+ const activeElement = document.activeElement;
100
+ if (activeElement instanceof HTMLElement && !canvas.contains(activeElement)) {
101
+ activeElement.blur();
102
+ }
103
+ const onMouseMove = (event) => {
104
+ if (document.pointerLockElement !== canvas || pausedRef.current)
105
+ return;
106
+ turnFirstPersonLook(lookRef.current, event.movementX, event.movementY, heldKeys.has('AltLeft') || heldKeys.has('AltRight') ? DRONE_SLOW_MULTIPLIER : 1);
107
+ };
108
+ const onClick = () => {
109
+ if (document.pointerLockElement !== canvas)
110
+ requestLock(canvas);
111
+ };
112
+ const onKeyDown = (event) => {
113
+ if (isTypingTarget(event.target))
114
+ return;
115
+ if (event.code === 'Escape') {
116
+ event.preventDefault();
117
+ event.stopPropagation();
118
+ exit();
119
+ return;
120
+ }
121
+ // P toggles a cursor pause: frees the pointer without leaving the drone —
122
+ // e.g. for an OS screenshot, which needs a movable cursor.
123
+ if (event.code === 'KeyP') {
124
+ event.preventDefault();
125
+ event.stopPropagation();
126
+ if (document.pointerLockElement === canvas) {
127
+ suspendRef.current = true;
128
+ heldKeys.clear();
129
+ useViewer.getState().setWalkthroughSuspended(true);
130
+ document.exitPointerLock();
131
+ }
132
+ else if (suspendRef.current) {
133
+ requestLock(canvas);
134
+ }
135
+ return;
136
+ }
137
+ if (!droneInputForKey(event.code))
138
+ return;
139
+ event.preventDefault();
140
+ event.stopPropagation();
141
+ // Paused, or a system shortcut (⌘⇧4 screenshot): the drone holds still.
142
+ if (suspendRef.current || event.metaKey)
143
+ return;
144
+ heldKeys.add(event.code);
145
+ };
146
+ const onKeyUp = (event) => {
147
+ heldKeys.delete(event.code);
148
+ };
149
+ const onBlur = () => heldKeys.clear();
150
+ const onPointerLockChange = () => {
151
+ if (document.pointerLockElement === canvas) {
152
+ wasLocked = true;
153
+ suspendRef.current = false;
154
+ useViewer.getState().setWalkthroughSuspended(false);
155
+ }
156
+ else if (suspendRef.current) {
157
+ // Deliberately released (P) — keep flying once resumed.
158
+ }
159
+ else if (wasLocked) {
160
+ // Esc while locked: the browser releases the pointer and swallows the key.
161
+ exit();
162
+ }
163
+ };
164
+ document.addEventListener('mousemove', onMouseMove);
165
+ canvas.addEventListener('click', onClick);
166
+ document.addEventListener('keydown', onKeyDown, true);
167
+ document.addEventListener('keyup', onKeyUp, true);
168
+ window.addEventListener('blur', onBlur);
169
+ document.addEventListener('pointerlockchange', onPointerLockChange);
170
+ // The toggle that mounted the drone is a user gesture, so the lock is
171
+ // usually granted straight away; the canvas click covers a refusal.
172
+ if (document.pointerLockElement !== canvas)
173
+ requestLock(canvas);
174
+ return () => {
175
+ document.removeEventListener('mousemove', onMouseMove);
176
+ canvas.removeEventListener('click', onClick);
177
+ document.removeEventListener('keydown', onKeyDown, true);
178
+ document.removeEventListener('keyup', onKeyUp, true);
179
+ window.removeEventListener('blur', onBlur);
180
+ document.removeEventListener('pointerlockchange', onPointerLockChange);
181
+ heldKeys.clear();
182
+ useViewer.getState().setWalkthroughSuspended(false);
183
+ if (document.pointerLockElement === canvas)
184
+ document.exitPointerLock();
185
+ };
186
+ }, [gl]);
187
+ useFrame((_, delta) => {
188
+ if (pausedRef.current)
189
+ return;
190
+ readDroneInput(heldKeysRef.current, inputRef.current);
191
+ stepDroneFlight(camera, lookRef.current, inputRef.current, velocityRef.current, delta);
192
+ }, 2.5);
193
+ return null;
194
+ }