@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.
- package/README.md +54 -21
- package/dist/components/viewer/daylight-atmosphere.d.ts +9 -0
- package/dist/components/viewer/daylight-atmosphere.d.ts.map +1 -0
- package/dist/components/viewer/daylight-atmosphere.js +315 -0
- package/dist/components/viewer/drone-controller.d.ts +15 -0
- package/dist/components/viewer/drone-controller.d.ts.map +1 -0
- package/dist/components/viewer/drone-controller.js +194 -0
- package/dist/components/viewer/glb-interactive.d.ts +4 -18
- package/dist/components/viewer/glb-interactive.d.ts.map +1 -1
- package/dist/components/viewer/glb-interactive.js +32 -268
- package/dist/components/viewer/glb-replace-instances.d.ts +6 -3
- package/dist/components/viewer/glb-replace-instances.d.ts.map +1 -1
- package/dist/components/viewer/glb-replace-instances.js +5 -4
- package/dist/components/viewer/glb-scene-motion.d.ts +8 -0
- package/dist/components/viewer/glb-scene-motion.d.ts.map +1 -0
- package/dist/components/viewer/glb-scene-motion.js +57 -0
- package/dist/components/viewer/glb-scene.d.ts +5 -1
- package/dist/components/viewer/glb-scene.d.ts.map +1 -1
- package/dist/components/viewer/glb-scene.js +290 -49
- package/dist/components/viewer/glb-selection-callout.d.ts +17 -0
- package/dist/components/viewer/glb-selection-callout.d.ts.map +1 -0
- package/dist/components/viewer/glb-selection-callout.js +54 -0
- package/dist/components/viewer/glb-walkthrough-controller.d.ts.map +1 -1
- package/dist/components/viewer/glb-walkthrough-controller.js +6 -3
- package/dist/components/viewer/index.d.ts +5 -0
- package/dist/components/viewer/index.d.ts.map +1 -1
- package/dist/components/viewer/index.js +38 -10
- package/dist/components/viewer/lights.d.ts.map +1 -1
- package/dist/components/viewer/lights.js +192 -5
- package/dist/components/viewer/post-processing.d.ts.map +1 -1
- package/dist/components/viewer/post-processing.js +150 -94
- package/dist/components/viewer/registered-systems.d.ts.map +1 -1
- package/dist/components/viewer/registered-systems.js +2 -2
- package/dist/components/viewer/scene-environment.js +4 -5
- package/dist/components/viewer/selection-callout.d.ts +51 -0
- package/dist/components/viewer/selection-callout.d.ts.map +1 -0
- package/dist/components/viewer/selection-callout.js +197 -0
- package/dist/components/viewer/unsupported-gpu-fallback.d.ts.map +1 -1
- package/dist/components/viewer/unsupported-gpu-fallback.js +21 -1
- package/dist/components/viewer/use-daylight-rooms.d.ts +13 -0
- package/dist/components/viewer/use-daylight-rooms.d.ts.map +1 -0
- package/dist/components/viewer/use-daylight-rooms.js +278 -0
- package/dist/components/viewer/viewer-camera.d.ts.map +1 -1
- package/dist/components/viewer/viewer-camera.js +12 -1
- package/dist/hooks/use-gltf-ktx2.d.ts.map +1 -1
- package/dist/hooks/use-gltf-ktx2.js +2 -1
- package/dist/index.d.ts +26 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +28 -8
- package/dist/lib/asset-url.d.ts +0 -1
- package/dist/lib/asset-url.d.ts.map +1 -1
- package/dist/lib/asset-url.js +3 -3
- package/dist/lib/daylight/ground.d.ts +21 -0
- package/dist/lib/daylight/ground.d.ts.map +1 -0
- package/dist/lib/daylight/ground.js +53 -0
- package/dist/lib/daylight/location.d.ts +32 -0
- package/dist/lib/daylight/location.d.ts.map +1 -0
- package/dist/lib/daylight/location.js +74 -0
- package/dist/lib/daylight/look.d.ts +33 -0
- package/dist/lib/daylight/look.d.ts.map +1 -0
- package/dist/lib/daylight/look.js +13 -0
- package/dist/lib/daylight/material-hooks.d.ts +2 -0
- package/dist/lib/daylight/material-hooks.d.ts.map +1 -0
- package/dist/lib/daylight/material-hooks.js +150 -0
- package/dist/lib/daylight/room-context.d.ts +12 -0
- package/dist/lib/daylight/room-context.d.ts.map +1 -0
- package/dist/lib/daylight/room-context.js +54 -0
- package/dist/lib/daylight/room-map.d.ts +124 -0
- package/dist/lib/daylight/room-map.d.ts.map +1 -0
- package/dist/lib/daylight/room-map.js +630 -0
- package/dist/lib/daylight/room-scene.d.ts +56 -0
- package/dist/lib/daylight/room-scene.d.ts.map +1 -0
- package/dist/lib/daylight/room-scene.js +391 -0
- package/dist/lib/daylight/rooms.d.ts +292 -0
- package/dist/lib/daylight/rooms.d.ts.map +1 -0
- package/dist/lib/daylight/rooms.js +1268 -0
- package/dist/lib/daylight/runtime.d.ts +33 -0
- package/dist/lib/daylight/runtime.d.ts.map +1 -0
- package/dist/lib/daylight/runtime.js +42 -0
- package/dist/lib/daylight/sky.d.ts +26 -0
- package/dist/lib/daylight/sky.d.ts.map +1 -0
- package/dist/lib/daylight/sky.js +217 -0
- package/dist/lib/daylight/solar.d.ts +46 -0
- package/dist/lib/daylight/solar.d.ts.map +1 -0
- package/dist/lib/daylight/solar.js +156 -0
- package/dist/lib/device-tier.d.ts +29 -0
- package/dist/lib/device-tier.d.ts.map +1 -0
- package/dist/lib/device-tier.js +71 -0
- package/dist/lib/dispose-object3d.d.ts.map +1 -1
- package/dist/lib/dispose-object3d.js +4 -2
- package/dist/lib/drone-flight.d.ts +44 -0
- package/dist/lib/drone-flight.d.ts.map +1 -0
- package/dist/lib/drone-flight.js +121 -0
- package/dist/lib/editor-highlight.d.ts +16 -0
- package/dist/lib/editor-highlight.d.ts.map +1 -0
- package/dist/lib/editor-highlight.js +33 -0
- package/dist/lib/foundation-materials.d.ts.map +1 -1
- package/dist/lib/foundation-materials.js +2 -4
- package/dist/lib/glb-merge.d.ts +23 -0
- package/dist/lib/glb-merge.d.ts.map +1 -0
- package/dist/lib/glb-merge.js +184 -0
- package/dist/lib/hardware-geometry.d.ts +21 -0
- package/dist/lib/hardware-geometry.d.ts.map +1 -0
- package/dist/lib/hardware-geometry.js +283 -0
- package/dist/lib/ktx2-loader.d.ts.map +1 -1
- package/dist/lib/ktx2-loader.js +2 -1
- package/dist/lib/layer-pass.d.ts +2 -0
- package/dist/lib/layer-pass.d.ts.map +1 -1
- package/dist/lib/layer-pass.js +20 -0
- package/dist/lib/materials.d.ts.map +1 -1
- package/dist/lib/materials.js +50 -23
- package/dist/lib/measurement-label.d.ts +4 -0
- package/dist/lib/measurement-label.d.ts.map +1 -0
- package/dist/lib/measurement-label.js +54 -0
- package/dist/lib/measurement-presentation.d.ts +5 -0
- package/dist/lib/measurement-presentation.d.ts.map +1 -0
- package/dist/lib/measurement-presentation.js +8 -0
- package/dist/lib/merged-outline-node.d.ts.map +1 -1
- package/dist/lib/merged-outline-node.js +7 -1
- package/dist/lib/occlusion-culler.d.ts +34 -0
- package/dist/lib/occlusion-culler.d.ts.map +1 -0
- package/dist/lib/occlusion-culler.js +698 -0
- package/dist/lib/pointer-events.d.ts.map +1 -1
- package/dist/lib/pointer-events.js +3 -3
- package/dist/lib/post-processing-resources.d.ts +4 -0
- package/dist/lib/post-processing-resources.d.ts.map +1 -1
- package/dist/lib/post-processing-resources.js +5 -0
- package/dist/lib/readback-rows.d.ts +14 -0
- package/dist/lib/readback-rows.d.ts.map +1 -0
- package/dist/lib/readback-rows.js +31 -0
- package/dist/lib/reveal-pose.d.ts.map +1 -1
- package/dist/lib/reveal-pose.js +3 -0
- package/dist/lib/scene-matrices.d.ts +12 -0
- package/dist/lib/scene-matrices.d.ts.map +1 -0
- package/dist/lib/scene-matrices.js +287 -0
- package/dist/lib/scene-pass-mrt.d.ts +5 -2
- package/dist/lib/scene-pass-mrt.d.ts.map +1 -1
- package/dist/lib/scene-pass-mrt.js +7 -2
- package/dist/lib/scene-themes.d.ts +0 -2
- package/dist/lib/scene-themes.d.ts.map +1 -1
- package/dist/lib/scene-themes.js +0 -2
- package/dist/lib/scene-visibility.d.ts +2 -0
- package/dist/lib/scene-visibility.d.ts.map +1 -1
- package/dist/lib/scene-visibility.js +12 -3
- package/dist/lib/site-surfaces.d.ts +1 -8
- package/dist/lib/site-surfaces.d.ts.map +1 -1
- package/dist/lib/site-surfaces.js +0 -19
- package/dist/lib/snapshot-pipeline.d.ts.map +1 -1
- package/dist/lib/snapshot-pipeline.js +24 -40
- package/dist/lib/texture-reference.d.ts.map +1 -1
- package/dist/lib/texture-reference.js +13 -10
- package/dist/lib/underground-ambiance.d.ts +0 -2
- package/dist/lib/underground-ambiance.d.ts.map +1 -1
- package/dist/lib/underground-ambiance.js +0 -2
- package/dist/lib/viewer-config.d.ts +21 -0
- package/dist/lib/viewer-config.d.ts.map +1 -0
- package/dist/lib/viewer-config.js +21 -0
- package/dist/lib/viewer-preference-storage.d.ts +7 -0
- package/dist/lib/viewer-preference-storage.d.ts.map +1 -0
- package/dist/lib/viewer-preference-storage.js +23 -0
- package/dist/store/use-item-light-pool.d.ts +3 -6
- package/dist/store/use-item-light-pool.d.ts.map +1 -1
- package/dist/store/use-item-light-pool.js +12 -0
- package/dist/store/use-viewer.d.ts +62 -59
- package/dist/store/use-viewer.d.ts.map +1 -1
- package/dist/store/use-viewer.js +106 -19
- package/dist/systems/ceiling/ceiling-system.d.ts +5 -0
- package/dist/systems/ceiling/ceiling-system.d.ts.map +1 -1
- package/dist/systems/ceiling/ceiling-system.js +11 -1
- package/dist/systems/door/door-system.d.ts.map +1 -1
- package/dist/systems/door/door-system.js +501 -145
- package/dist/systems/door/leaf-profile.d.ts +11 -0
- package/dist/systems/door/leaf-profile.d.ts.map +1 -0
- package/dist/systems/door/leaf-profile.js +140 -0
- package/dist/systems/elevator/elevator-interaction-system.d.ts +16 -2
- package/dist/systems/elevator/elevator-interaction-system.d.ts.map +1 -1
- package/dist/systems/elevator/elevator-interaction-system.js +41 -8
- package/dist/systems/geometry/geometry-status.d.ts +8 -0
- package/dist/systems/geometry/geometry-status.d.ts.map +1 -0
- package/dist/systems/geometry/geometry-status.js +24 -0
- package/dist/systems/interactive/callout-actions.d.ts +72 -0
- package/dist/systems/interactive/callout-actions.d.ts.map +1 -0
- package/dist/systems/interactive/callout-actions.js +212 -0
- package/dist/systems/interactive/interactive-system.d.ts +5 -1
- package/dist/systems/interactive/interactive-system.d.ts.map +1 -1
- package/dist/systems/interactive/interactive-system.js +44 -121
- package/dist/systems/interactive/opening-settled.d.ts +16 -0
- package/dist/systems/interactive/opening-settled.d.ts.map +1 -0
- package/dist/systems/interactive/opening-settled.js +53 -0
- package/dist/systems/item-light/item-light-system.d.ts.map +1 -1
- package/dist/systems/item-light/item-light-system.js +22 -214
- package/dist/systems/item-light/pooled-lights.d.ts +70 -0
- package/dist/systems/item-light/pooled-lights.d.ts.map +1 -0
- package/dist/systems/item-light/pooled-lights.js +449 -0
- package/dist/systems/level/level-system.d.ts.map +1 -1
- package/dist/systems/level/level-system.js +19 -1
- package/dist/systems/roof/roof-material-slots.d.ts +14 -0
- package/dist/systems/roof/roof-material-slots.d.ts.map +1 -0
- package/dist/systems/roof/roof-material-slots.js +13 -0
- package/dist/systems/roof/roof-materials.d.ts +25 -2
- package/dist/systems/roof/roof-materials.d.ts.map +1 -1
- package/dist/systems/roof/roof-materials.js +85 -26
- package/dist/systems/roof/roof-system.d.ts +34 -1
- package/dist/systems/roof/roof-system.d.ts.map +1 -1
- package/dist/systems/roof/roof-system.js +582 -168
- package/dist/systems/wall/opening-casing-geometry.d.ts +14 -0
- package/dist/systems/wall/opening-casing-geometry.d.ts.map +1 -0
- package/dist/systems/wall/opening-casing-geometry.js +102 -0
- package/dist/systems/wall/opening-cutout-geometry.d.ts +9 -0
- package/dist/systems/wall/opening-cutout-geometry.d.ts.map +1 -1
- package/dist/systems/wall/opening-cutout-geometry.js +7 -3
- package/dist/systems/wall/wall-materials.d.ts.map +1 -1
- package/dist/systems/wall/wall-materials.js +28 -35
- package/dist/systems/wall/wall-system.d.ts +11 -0
- package/dist/systems/wall/wall-system.d.ts.map +1 -1
- package/dist/systems/wall/wall-system.js +86 -25
- package/dist/systems/wall/wall-t-junctions.d.ts +15 -0
- package/dist/systems/wall/wall-t-junctions.d.ts.map +1 -0
- package/dist/systems/wall/wall-t-junctions.js +149 -0
- package/dist/systems/window/window-animation-system.d.ts +3 -12
- package/dist/systems/window/window-animation-system.d.ts.map +1 -1
- package/dist/systems/window/window-animation-system.js +37 -122
- package/dist/systems/window/window-pose.d.ts +31 -0
- package/dist/systems/window/window-pose.d.ts.map +1 -0
- package/dist/systems/window/window-pose.js +152 -0
- package/dist/systems/window/window-roller-shutter.d.ts +8 -0
- package/dist/systems/window/window-roller-shutter.d.ts.map +1 -0
- package/dist/systems/window/window-roller-shutter.js +64 -0
- package/dist/systems/window/window-system.d.ts +2 -11
- package/dist/systems/window/window-system.d.ts.map +1 -1
- package/dist/systems/window/window-system.js +470 -303
- package/package.json +6 -6
- package/dist/systems/interactive/control-widget.d.ts +0 -10
- package/dist/systems/interactive/control-widget.d.ts.map +0 -1
- package/dist/systems/interactive/control-widget.js +0 -35
- package/dist/systems/interactive/procedural-controls.d.ts +0 -13
- package/dist/systems/interactive/procedural-controls.d.ts.map +0 -1
- 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/
|
|
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
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
|
54
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|