@pascal-app/viewer 1.1.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (224) hide show
  1. package/README.md +157 -0
  2. package/dist/components/viewer/drone-controller.d.ts.map +1 -1
  3. package/dist/components/viewer/drone-controller.js +1 -5
  4. package/dist/components/viewer/first-person/build-collider-world.d.ts +14 -0
  5. package/dist/components/viewer/first-person/build-collider-world.d.ts.map +1 -0
  6. package/dist/components/viewer/first-person/build-collider-world.js +426 -0
  7. package/dist/components/viewer/first-person/ground-holes.d.ts +22 -0
  8. package/dist/components/viewer/first-person/ground-holes.d.ts.map +1 -0
  9. package/dist/components/viewer/first-person/ground-holes.js +116 -0
  10. package/dist/components/viewer/first-person/mechanism-targets.d.ts +27 -0
  11. package/dist/components/viewer/first-person/mechanism-targets.d.ts.map +1 -0
  12. package/dist/components/viewer/first-person/mechanism-targets.js +79 -0
  13. package/dist/components/viewer/first-person/terrain-collider.d.ts +32 -0
  14. package/dist/components/viewer/first-person/terrain-collider.d.ts.map +1 -0
  15. package/dist/components/viewer/first-person/terrain-collider.js +175 -0
  16. package/dist/components/viewer/glb-walkthrough-controller.d.ts +1 -1
  17. package/dist/components/viewer/glb-walkthrough-controller.d.ts.map +1 -1
  18. package/dist/components/viewer/glb-walkthrough-controller.js +3 -4
  19. package/dist/components/viewer/live-walkthrough-controller.d.ts +17 -0
  20. package/dist/components/viewer/live-walkthrough-controller.d.ts.map +1 -0
  21. package/dist/components/viewer/live-walkthrough-controller.js +1449 -0
  22. package/dist/components/viewer/post-processing.d.ts.map +1 -1
  23. package/dist/components/viewer/post-processing.js +22 -1
  24. package/dist/index.d.ts +3 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +3 -0
  27. package/dist/lib/daylight/room-map.d.ts +29 -4
  28. package/dist/lib/daylight/room-map.d.ts.map +1 -1
  29. package/dist/lib/daylight/room-map.js +219 -36
  30. package/dist/lib/daylight/room-scene.d.ts.map +1 -1
  31. package/dist/lib/daylight/room-scene.js +9 -3
  32. package/dist/lib/daylight/rooms.d.ts +15 -6
  33. package/dist/lib/daylight/rooms.d.ts.map +1 -1
  34. package/dist/lib/daylight/rooms.js +49 -9
  35. package/dist/lib/measurement-presentation.d.ts +2 -0
  36. package/dist/lib/measurement-presentation.d.ts.map +1 -1
  37. package/dist/lib/measurement-presentation.js +6 -0
  38. package/dist/lib/typing-target.d.ts +2 -0
  39. package/dist/lib/typing-target.d.ts.map +1 -0
  40. package/dist/lib/typing-target.js +7 -0
  41. package/dist/lib/viewer-config.d.ts +6 -0
  42. package/dist/lib/viewer-config.d.ts.map +1 -1
  43. package/dist/lib/viewer-config.js +2 -0
  44. package/dist/sfx/audio-settings.d.ts +81 -0
  45. package/dist/sfx/audio-settings.d.ts.map +1 -0
  46. package/dist/sfx/audio-settings.js +166 -0
  47. package/dist/sfx/index.d.ts +6 -0
  48. package/dist/sfx/index.d.ts.map +1 -0
  49. package/dist/sfx/index.js +5 -0
  50. package/dist/sfx/interaction-sounds.d.ts +7 -0
  51. package/dist/sfx/interaction-sounds.d.ts.map +1 -0
  52. package/dist/sfx/interaction-sounds.js +39 -0
  53. package/dist/sfx/player.d.ts +31 -0
  54. package/dist/sfx/player.d.ts.map +1 -0
  55. package/dist/sfx/player.js +255 -0
  56. package/dist/sfx/sounds.d.ts +60 -0
  57. package/dist/sfx/sounds.d.ts.map +1 -0
  58. package/dist/sfx/sounds.js +25 -0
  59. package/dist/sfx/use-viewer-ui-sounds.d.ts +8 -0
  60. package/dist/sfx/use-viewer-ui-sounds.d.ts.map +1 -0
  61. package/dist/sfx/use-viewer-ui-sounds.js +42 -0
  62. package/dist/store/use-first-person-hud.d.ts +13 -0
  63. package/dist/store/use-first-person-hud.d.ts.map +1 -0
  64. package/dist/store/use-first-person-hud.js +21 -0
  65. package/dist/systems/roof/roof-system.d.ts.map +1 -1
  66. package/dist/systems/roof/roof-system.js +27 -13
  67. package/dist/ui/components/action-button.d.ts +14 -0
  68. package/dist/ui/components/action-button.d.ts.map +1 -0
  69. package/dist/ui/components/action-button.js +21 -0
  70. package/dist/ui/components/action-menu-placement.d.ts +7 -0
  71. package/dist/ui/components/action-menu-placement.d.ts.map +1 -0
  72. package/dist/ui/components/action-menu-placement.js +10 -0
  73. package/dist/ui/components/floorplan-compass-button.d.ts +10 -0
  74. package/dist/ui/components/floorplan-compass-button.d.ts.map +1 -0
  75. package/dist/ui/components/floorplan-compass-button.js +22 -0
  76. package/dist/ui/components/follow-pascal-toggle.d.ts +6 -0
  77. package/dist/ui/components/follow-pascal-toggle.d.ts.map +1 -0
  78. package/dist/ui/components/follow-pascal-toggle.js +21 -0
  79. package/dist/ui/components/localized-html.d.ts +9 -0
  80. package/dist/ui/components/localized-html.d.ts.map +1 -0
  81. package/dist/ui/components/localized-html.js +16 -0
  82. package/dist/ui/components/render-settings-menu.d.ts +9 -0
  83. package/dist/ui/components/render-settings-menu.d.ts.map +1 -0
  84. package/dist/ui/components/render-settings-menu.js +145 -0
  85. package/dist/ui/components/rooms/room-highlight.d.ts +10 -0
  86. package/dist/ui/components/rooms/room-highlight.d.ts.map +1 -0
  87. package/dist/ui/components/rooms/room-highlight.js +56 -0
  88. package/dist/ui/components/rooms/room-labels.d.ts +278 -0
  89. package/dist/ui/components/rooms/room-labels.d.ts.map +1 -0
  90. package/dist/ui/components/rooms/room-labels.js +1295 -0
  91. package/dist/ui/components/rooms/viewer-rooms.d.ts +14 -0
  92. package/dist/ui/components/rooms/viewer-rooms.d.ts.map +1 -0
  93. package/dist/ui/components/rooms/viewer-rooms.js +123 -0
  94. package/dist/ui/components/scene-loader.d.ts +18 -0
  95. package/dist/ui/components/scene-loader.d.ts.map +1 -0
  96. package/dist/ui/components/scene-loader.js +29 -0
  97. package/dist/ui/components/sky-control.d.ts +37 -0
  98. package/dist/ui/components/sky-control.d.ts.map +1 -0
  99. package/dist/ui/components/sky-control.js +382 -0
  100. package/dist/ui/components/use-viewer-camera-navigation-sync.d.ts +4 -0
  101. package/dist/ui/components/use-viewer-camera-navigation-sync.d.ts.map +1 -0
  102. package/dist/ui/components/use-viewer-camera-navigation-sync.js +117 -0
  103. package/dist/ui/components/viewer-controls-bar.d.ts +20 -0
  104. package/dist/ui/components/viewer-controls-bar.d.ts.map +1 -0
  105. package/dist/ui/components/viewer-controls-bar.js +130 -0
  106. package/dist/ui/components/viewer-hover-label.d.ts +14 -0
  107. package/dist/ui/components/viewer-hover-label.d.ts.map +1 -0
  108. package/dist/ui/components/viewer-hover-label.js +28 -0
  109. package/dist/ui/components/viewer-scene-header.d.ts +34 -0
  110. package/dist/ui/components/viewer-scene-header.d.ts.map +1 -0
  111. package/dist/ui/components/viewer-scene-header.js +92 -0
  112. package/dist/ui/components/viewer-selection-manager.d.ts +7 -0
  113. package/dist/ui/components/viewer-selection-manager.d.ts.map +1 -0
  114. package/dist/ui/components/viewer-selection-manager.js +116 -0
  115. package/dist/ui/components/viewer-stage-modes.d.ts +7 -0
  116. package/dist/ui/components/viewer-stage-modes.d.ts.map +1 -0
  117. package/dist/ui/components/viewer-stage-modes.js +42 -0
  118. package/dist/ui/components/viewer-stage-switcher.d.ts +10 -0
  119. package/dist/ui/components/viewer-stage-switcher.d.ts.map +1 -0
  120. package/dist/ui/components/viewer-stage-switcher.js +16 -0
  121. package/dist/ui/components/viewer-stage.d.ts +25 -0
  122. package/dist/ui/components/viewer-stage.d.ts.map +1 -0
  123. package/dist/ui/components/viewer-stage.js +107 -0
  124. package/dist/ui/components/walkthrough-hud.d.ts +29 -0
  125. package/dist/ui/components/walkthrough-hud.d.ts.map +1 -0
  126. package/dist/ui/components/walkthrough-hud.js +55 -0
  127. package/dist/ui/floorplan/floorplan-camera-sync.d.ts +22 -0
  128. package/dist/ui/floorplan/floorplan-camera-sync.d.ts.map +1 -0
  129. package/dist/ui/floorplan/floorplan-camera-sync.js +157 -0
  130. package/dist/ui/floorplan/floorplan-dimension-renderer.d.ts +61 -0
  131. package/dist/ui/floorplan/floorplan-dimension-renderer.d.ts.map +1 -0
  132. package/dist/ui/floorplan/floorplan-dimension-renderer.js +280 -0
  133. package/dist/ui/floorplan/floorplan-geometry-renderer.d.ts +54 -0
  134. package/dist/ui/floorplan/floorplan-geometry-renderer.d.ts.map +1 -0
  135. package/dist/ui/floorplan/floorplan-geometry-renderer.js +317 -0
  136. package/dist/ui/floorplan/floorplan-label-angle.d.ts +24 -0
  137. package/dist/ui/floorplan/floorplan-label-angle.d.ts.map +1 -0
  138. package/dist/ui/floorplan/floorplan-label-angle.js +60 -0
  139. package/dist/ui/floorplan/floorplan-navigation-sync.d.ts +18 -0
  140. package/dist/ui/floorplan/floorplan-navigation-sync.d.ts.map +1 -0
  141. package/dist/ui/floorplan/floorplan-navigation-sync.js +41 -0
  142. package/dist/ui/floorplan/floorplan-preview-geometry.d.ts +26 -0
  143. package/dist/ui/floorplan/floorplan-preview-geometry.d.ts.map +1 -0
  144. package/dist/ui/floorplan/floorplan-preview-geometry.js +173 -0
  145. package/dist/ui/floorplan/floorplan-preview-navigation.d.ts +22 -0
  146. package/dist/ui/floorplan/floorplan-preview-navigation.d.ts.map +1 -0
  147. package/dist/ui/floorplan/floorplan-preview-navigation.js +41 -0
  148. package/dist/ui/floorplan/floorplan-preview-visibility.d.ts +8 -0
  149. package/dist/ui/floorplan/floorplan-preview-visibility.d.ts.map +1 -0
  150. package/dist/ui/floorplan/floorplan-preview-visibility.js +21 -0
  151. package/dist/ui/floorplan/floorplan-preview.d.ts +19 -0
  152. package/dist/ui/floorplan/floorplan-preview.d.ts.map +1 -0
  153. package/dist/ui/floorplan/floorplan-preview.js +700 -0
  154. package/dist/ui/hooks/use-mobile.d.ts +2 -0
  155. package/dist/ui/hooks/use-mobile.d.ts.map +1 -0
  156. package/dist/ui/hooks/use-mobile.js +14 -0
  157. package/dist/ui/index.d.ts +49 -0
  158. package/dist/ui/index.d.ts.map +1 -0
  159. package/dist/ui/index.js +48 -0
  160. package/dist/ui/lib/below-grade.d.ts +38 -0
  161. package/dist/ui/lib/below-grade.d.ts.map +1 -0
  162. package/dist/ui/lib/below-grade.js +102 -0
  163. package/dist/ui/lib/camera-pose.d.ts +19 -0
  164. package/dist/ui/lib/camera-pose.d.ts.map +1 -0
  165. package/dist/ui/lib/camera-pose.js +113 -0
  166. package/dist/ui/lib/cn.d.ts +3 -0
  167. package/dist/ui/lib/cn.d.ts.map +1 -0
  168. package/dist/ui/lib/cn.js +5 -0
  169. package/dist/ui/lib/level-name.d.ts +10 -0
  170. package/dist/ui/lib/level-name.d.ts.map +1 -0
  171. package/dist/ui/lib/level-name.js +33 -0
  172. package/dist/ui/lib/node-display-label.d.ts +8 -0
  173. package/dist/ui/lib/node-display-label.d.ts.map +1 -0
  174. package/dist/ui/lib/node-display-label.js +36 -0
  175. package/dist/ui/lib/room-area.d.ts +3 -0
  176. package/dist/ui/lib/room-area.d.ts.map +1 -0
  177. package/dist/ui/lib/room-area.js +8 -0
  178. package/dist/ui/lib/room-assembly-overlay.d.ts +46 -0
  179. package/dist/ui/lib/room-assembly-overlay.d.ts.map +1 -0
  180. package/dist/ui/lib/room-assembly-overlay.js +222 -0
  181. package/dist/ui/lib/room-name-messages.d.ts +28 -0
  182. package/dist/ui/lib/room-name-messages.d.ts.map +1 -0
  183. package/dist/ui/lib/room-name-messages.js +112 -0
  184. package/dist/ui/lib/room-selection.d.ts +58 -0
  185. package/dist/ui/lib/room-selection.d.ts.map +1 -0
  186. package/dist/ui/lib/room-selection.js +297 -0
  187. package/dist/ui/lib/scene-theme-name.d.ts +4 -0
  188. package/dist/ui/lib/scene-theme-name.d.ts.map +1 -0
  189. package/dist/ui/lib/scene-theme-name.js +22 -0
  190. package/dist/ui/lib/selection-modifiers.d.ts +20 -0
  191. package/dist/ui/lib/selection-modifiers.d.ts.map +1 -0
  192. package/dist/ui/lib/selection-modifiers.js +15 -0
  193. package/dist/ui/lib/viewer-selection.d.ts +132 -0
  194. package/dist/ui/lib/viewer-selection.d.ts.map +1 -0
  195. package/dist/ui/lib/viewer-selection.js +462 -0
  196. package/dist/ui/lib/zone-content.d.ts +6 -0
  197. package/dist/ui/lib/zone-content.d.ts.map +1 -0
  198. package/dist/ui/lib/zone-content.js +96 -0
  199. package/dist/ui/primitives/button.d.ts +11 -0
  200. package/dist/ui/primitives/button.d.ts.map +1 -0
  201. package/dist/ui/primitives/button.js +35 -0
  202. package/dist/ui/primitives/dropdown-menu.d.ts +26 -0
  203. package/dist/ui/primitives/dropdown-menu.d.ts.map +1 -0
  204. package/dist/ui/primitives/dropdown-menu.js +53 -0
  205. package/dist/ui/primitives/popover.d.ts +8 -0
  206. package/dist/ui/primitives/popover.d.ts.map +1 -0
  207. package/dist/ui/primitives/popover.js +17 -0
  208. package/dist/ui/primitives/switch.d.ts +5 -0
  209. package/dist/ui/primitives/switch.d.ts.map +1 -0
  210. package/dist/ui/primitives/switch.js +8 -0
  211. package/dist/ui/primitives/tooltip.d.ts +8 -0
  212. package/dist/ui/primitives/tooltip.d.ts.map +1 -0
  213. package/dist/ui/primitives/tooltip.js +17 -0
  214. package/dist/ui/store/camera-pose-store.d.ts +17 -0
  215. package/dist/ui/store/camera-pose-store.d.ts.map +1 -0
  216. package/dist/ui/store/camera-pose-store.js +15 -0
  217. package/dist/ui/store/navigation-sync-pose-store.d.ts +26 -0
  218. package/dist/ui/store/navigation-sync-pose-store.d.ts.map +1 -0
  219. package/dist/ui/store/navigation-sync-pose-store.js +16 -0
  220. package/dist/ui/store/use-follow-pascal.d.ts +30 -0
  221. package/dist/ui/store/use-follow-pascal.d.ts.map +1 -0
  222. package/dist/ui/store/use-follow-pascal.js +28 -0
  223. package/dist/ui/theme.css +339 -0
  224. package/package.json +34 -7
package/README.md CHANGED
@@ -86,6 +86,59 @@ rendering.
86
86
  See [`fixtures/viewer-live`](../../fixtures/viewer-live) for a complete small graph and the
87
87
  isolated packed-package dev/production smoke test.
88
88
 
89
+ ## Walkthrough for live scenes
90
+
91
+ `LiveWalkthroughController` is a 3D controller from the main `@pascal-app/viewer` entry.
92
+ Mount it inside `Viewer` alongside your live graph rendered with `renderPlugin`. It is dormant
93
+ until `useViewer.walkthroughMode` is true. `walkthroughMovement` chooses `walk` (gravity,
94
+ BVH collision, crouch, jump and door/window/item/elevator interaction) or `drone` (free flight).
95
+ No editor state, editing plugin or baked asset is required.
96
+
97
+ ```tsx
98
+ import { LiveWalkthroughController, useFirstPersonHud, useViewer, Viewer } from '@pascal-app/viewer'
99
+ import { WalkthroughHud } from '@pascal-app/viewer/ui'
100
+ import { OrbitControls } from '@react-three/drei'
101
+
102
+ function LiveViewer() {
103
+ const walking = useViewer(s => s.walkthroughMode)
104
+ const suspended = useViewer(s => s.walkthroughSuspended)
105
+ const floorLabel = useFirstPersonHud(s => s.floorLabel)
106
+ const zoneLabel = useFirstPersonHud(s => s.zoneLabel)
107
+ const interact = useFirstPersonHud(s => s.interact)
108
+ const exit = () => useViewer.getState().setWalkthroughMode(false)
109
+ return (
110
+ <div style={{ position: 'relative', width: '100vw', height: '100vh' }}>
111
+ <Viewer>
112
+ <OrbitControls enabled={!walking} makeDefault />
113
+ <LiveWalkthroughController />
114
+ </Viewer>
115
+ <button onClick={() => useViewer.getState().setWalkthroughMode(!walking)}>Walkthrough</button>
116
+ {walking && <WalkthroughHud floorLabel={floorLabel} zoneLabel={zoneLabel}
117
+ interact={interact} suspended={suspended} onExit={exit} />}
118
+ </div>
119
+ )
120
+ }
121
+ ```
122
+
123
+ Use the UI styling setup below for the HUD. Click the canvas to lock the pointer; WASD/arrows move,
124
+ Space jumps, Shift runs, Ctrl crouches, E/R or a locked-pointer click operates the targeted object,
125
+ and T closes it. P frees the cursor and click/P resumes. Escape or losing pointer lock exits.
126
+ A placed spawn controls entry position and heading; otherwise the controller derives a grounded
127
+ spawn from the camera and live collider world. Disable other camera controls while walking.
128
+
129
+ The controller publishes floor, room and interaction prompts to `useFirstPersonHud`. Its main and
130
+ `viewer/ui` exports reference the same store. Mount the HUD outside the canvas and pass those
131
+ values explicitly. Door and drawer cues emit core `interaction:sound`; opt into playback with
132
+ `initInteractionSounds` from `viewer/sfx`. Movement and interaction do not persist document edits.
133
+
134
+ Optional `LiveWalkthroughControllerProps` let a host own framing: `paused` freezes movement/look,
135
+ `externalFov` (a function, read when walkthrough starts and ends) leaves FOV application/restoration to the host while it returns true, `pointerInteraction={false}` reserves
136
+ locked-pointer clicks for the host, and `suspendOnUnlock` keeps walkthrough active when pointer lock
137
+ is lost (Escape with a free cursor exits). `onExit` runs after the controller clears
138
+ `walkthroughMode`. Props remain current during a session without releasing pointer lock.
139
+ For baked scenes, continue using `GlbWalkthroughController`; `DroneController` also works with
140
+ either scene source when only free flight is needed.
141
+
89
142
  ## Custom Camera Controls
90
143
 
91
144
  ```typescript
@@ -242,6 +295,110 @@ function ViewerControls() {
242
295
  }
243
296
  ```
244
297
 
298
+ ## Sound effects (`@pascal-app/viewer/sfx`)
299
+
300
+ Opt in to building interactions and selection/hover cues from any viewer or custom UI.
301
+ The default files come from `https://editor.pascal.app/audios/sfx`; hosts serving the files
302
+ locally should configure their own base URL before preloading. Importing the entry creates no
303
+ Howl instances or interaction subscriptions. Playback starts after the browser allows audio;
304
+ selection/hover cues stay silent until user activation and during walkthrough.
305
+
306
+ ```tsx
307
+ import { useEffect } from 'react'
308
+ import { configureViewer } from '@pascal-app/viewer'
309
+ import { initInteractionSounds, playSound, useAudioSettings, useViewerUiSounds } from '@pascal-app/viewer/sfx'
310
+ configureViewer({ sfxBaseUrl: '/audios/sfx' })
311
+ function SoundControls() {
312
+ useEffect(() => initInteractionSounds(), [])
313
+ useViewerUiSounds()
314
+ const toggleMute = useAudioSettings(s => s.toggleMute)
315
+ return <button onPointerEnter={() => playSound('menuHover')} onClick={() => { playSound('menuClick'); toggleMute() }}>Toggle sound</button>
316
+ }
317
+ ```
318
+
319
+ `SOUNDS` lists the built-in cue definitions. `playSound(name, options?)` supports gain
320
+ (`volumeMultiplier`) and stereo positioning. `preloadSounds()` warms files without playing;
321
+ `disposeSounds()` releases the shared cache when the host runtime shuts down. Host cues can use
322
+ `SoundDefinition` objects with the same player, including `startSoundLoop` / `stopSoundLoop`.
323
+ `useAudioSettings` shares master/effects volumes across tabs and keeps mute per tab, preserving
324
+ existing Pascal storage keys. Denied storage falls back to memory. Existing radio preferences
325
+ remain in the same settings record so editor consumers continue to share one store.
326
+
327
+ ## Viewer UI (`@pascal-app/viewer/ui`)
328
+
329
+ Pascal's read-only viewer chrome as React components: the 3D / 2D / Split stage (`ViewerStage`)
330
+ with its read-only floor plan (`FloorplanPreview`, kept in step with the 3D camera, with its compass
331
+ and floor picker), the controls bar (levels, walls, display, daylight, sky, walkthrough), the scene
332
+ header (project card, breadcrumb, floor list), the hover label, the room pills, room highlight and
333
+ selected-room callout (`ViewerRooms`), the walkthrough and drone HUDs, the loader, the selection
334
+ model behind them
335
+ (`applyViewerClick`, `ViewerSelectionManager`) and the primitives they are built from (`Button`,
336
+ `DropdownMenu*`, `Popover*`, `Tooltip*`, `Switch`). The entry never imports the editor or Next.
337
+
338
+ The components are styled with Tailwind v4 classes. Point Tailwind at the built entry and import
339
+ the theme (tokens, `shadow-elevation-*`, `corner-smooth`, the loader animations):
340
+
341
+ ```css
342
+ @import "tailwindcss";
343
+ @import "@pascal-app/viewer/ui/theme.css";
344
+ @source "../node_modules/@pascal-app/viewer/dist/ui";
345
+ ```
346
+
347
+ Override any token after the import (`:root { --radius: 0.5rem }`). The docks and menus scope
348
+ themselves with a `dark` class. Control icons load from `configureViewer({ iconsBaseUrl })` (default
349
+ `https://editor.pascal.app/icons`); button sounds play through `@pascal-app/viewer/sfx`.
350
+
351
+ ```tsx
352
+ import { LiveWalkthroughController, useViewer, Viewer } from '@pascal-app/viewer'
353
+ import {
354
+ ParametricViewerRooms,
355
+ SceneLoader,
356
+ ViewerControlsBar,
357
+ ViewerSceneHeader,
358
+ ViewerSelectionManager,
359
+ ViewerStage,
360
+ WalkthroughHud,
361
+ } from '@pascal-app/viewer/ui'
362
+
363
+ function ProjectViewer({ ready, name }: { ready: boolean; name: string }) {
364
+ const walking = useViewer((s) => s.walkthroughMode)
365
+ const toggleWalk = () => useViewer.getState().setWalkthroughMode(!walking)
366
+ return (
367
+ <div className="relative h-full w-full">
368
+ <ViewerStage>
369
+ <Viewer selectionManager="custom">
370
+ <LiveWalkthroughController />
371
+ <ViewerSelectionManager />
372
+ <ParametricViewerRooms />
373
+ </Viewer>
374
+ </ViewerStage>
375
+ <ViewerSceneHeader backHref={null} linkComponent={MyLink} projectName={name} />
376
+ <ViewerControlsBar onWalkthroughToggle={toggleWalk} walkthroughActive={walking} />
377
+ {walking && <WalkthroughHud onExit={toggleWalk} />}
378
+ {!ready && <SceneLoader />}
379
+ </div>
380
+ )
381
+ }
382
+ ```
383
+
384
+ `ViewerStage` shows its children (the 3D canvas) in 3D, the floor plan in 2D, and both side by side
385
+ in Split; the switcher picks among `modes` (all three by default) and Split falls back to 2D on
386
+ narrow screens. Panning, zooming or turning the plan moves the 3D camera and the reverse, through the
387
+ navigation pose store (`navigationSyncPoseStore`); on the 3D side, drive your drei `CameraControls`
388
+ through `useViewerCameraNavigationSync(controlsRef)`, as Pascal's camera does
389
+ (`synchronizeNavigation={false}` turns the link off). The plan draws each kind's `def.floorplan`,
390
+ which `renderPlugin` carries; without a `scene` prop it reads the live scene store, and a host with
391
+ another node source passes `scene={{ nodes, installedPlugins }}`. Picking a floor in 2D selects that
392
+ level in the viewer. Room names show as the plan's drawn tags. Control the mode with `mode` /
393
+ `onModeChange` or let the stage hold it (`defaultMode`).
394
+
395
+ `linkComponent` renders the header's links (a plain `<a>` by default); pass your router's link,
396
+ e.g. Next's `Link`. `ParametricViewerRooms` is `ViewerRooms` over the live scene store; a host with
397
+ another node source (a baked scene) passes `<ViewerRooms source={{ nodes, objectFor }} />`. While
398
+ mounted, zones draw no tinted volume: a room shows through its pill and highlight instead. The walkthrough toggle flips `walkthroughMode`; the mounted
399
+ `LiveWalkthroughController` moves the camera. Read `useFirstPersonHud` for the HUD's room and
400
+ interaction props, as in the live walkthrough example above.
401
+
245
402
  ## Asset CDN Helpers
246
403
 
247
404
  ```typescript
@@ -1 +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"}
1
+ {"version":3,"file":"drone-controller.d.ts","sourceRoot":"","sources":["../../../src/components/viewer/drone-controller.tsx"],"names":[],"mappings":"AA+BA,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"}
@@ -3,14 +3,10 @@ import { useFrame, useThree } from '@react-three/fiber';
3
3
  import { useEffect, useRef } from 'react';
4
4
  import { Vector3 } from 'three';
5
5
  import { createDroneInput, DRONE_SLOW_MULTIPLIER, droneInputForKey, readDroneInput, seedDroneLook, stepDroneFlight, turnFirstPersonLook, } from '../../lib/drone-flight';
6
+ import { isTypingTarget } from '../../lib/typing-target';
6
7
  import useViewer from '../../store/use-viewer';
7
8
  import { WALKTHROUGH_FOV } from './glb-walkthrough-controller';
8
9
  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
10
  function requestLock(canvas) {
15
11
  // The request can reject asynchronously (the browser's re-lock cooldown);
16
12
  // clicking the canvas asks again.
@@ -0,0 +1,14 @@
1
+ import * as THREE from 'three';
2
+ export declare const FIRST_PERSON_SPAWN_EYE_HEIGHT = 1.65;
3
+ export type FirstPersonColliderWorld = {
4
+ mesh: THREE.Mesh;
5
+ bounds: THREE.Box3 | null;
6
+ dispose: () => void;
7
+ };
8
+ export type FirstPersonSpawn = {
9
+ position: [number, number, number];
10
+ yaw: number;
11
+ };
12
+ export declare function buildFirstPersonColliderWorldFromRegistry(): FirstPersonColliderWorld | null;
13
+ export declare function deriveFirstPersonSpawn(camera: THREE.Camera, world: FirstPersonColliderWorld): FirstPersonSpawn;
14
+ //# sourceMappingURL=build-collider-world.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"build-collider-world.d.ts","sourceRoot":"","sources":["../../../../src/components/viewer/first-person/build-collider-world.ts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAqB9B,eAAO,MAAM,6BAA6B,OAAmB,CAAA;AAE7D,MAAM,MAAM,wBAAwB,GAAG;IACrC,IAAI,EAAE,KAAK,CAAC,IAAI,CAAA;IAChB,MAAM,EAAE,KAAK,CAAC,IAAI,GAAG,IAAI,CAAA;IACzB,OAAO,EAAE,MAAM,IAAI,CAAA;CACpB,CAAA;AAED,MAAM,MAAM,gBAAgB,GAAG;IAC7B,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAA;IAClC,GAAG,EAAE,MAAM,CAAA;CACZ,CAAA;AAwWD,wBAAgB,yCAAyC,IAAI,wBAAwB,GAAG,IAAI,CA6F3F;AAED,wBAAgB,sBAAsB,CACpC,MAAM,EAAE,KAAK,CAAC,MAAM,EACpB,KAAK,EAAE,wBAAwB,GAC9B,gBAAgB,CA0ClB"}
@@ -0,0 +1,426 @@
1
+ import { computeSceneBoundsXZ, coveredBelowGradeFootprints, getGarageVisibleOpeningRatio, getLevelElevations, isOperationDoorType, nodeMechanism, nodeRegistry, sceneRegistry, terrainFieldOf, useInteractive, useScene, } from '@pascal-app/core';
2
+ import * as THREE from 'three';
3
+ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
4
+ import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh';
5
+ import { cutGroundCollider } from './ground-holes';
6
+ import { createTerrainColliderGeometry } from './terrain-collider';
7
+ const SKIPPED_MESH_NAMES = new Set(['cutout', 'collision-mesh']);
8
+ const COLLIDER_NODE_CATEGORIES = new Set(['structure', 'furnish']);
9
+ const DEDICATED_COLLIDER_NODE_TYPES = new Set(['elevator']);
10
+ const COLLIDER_MATERIAL = new THREE.MeshBasicMaterial();
11
+ const DOWN = new THREE.Vector3(0, -1, 0);
12
+ const UP = new THREE.Vector3(0, 1, 0);
13
+ const SPAWN_EYE_HEIGHT = 1.65;
14
+ const RAYCAST_CLEARANCE = 25;
15
+ const DOOR_LEAF_COLLIDER_DEPTH = 0.06;
16
+ const OPERATION_DOOR_COLLIDER_OPEN_THRESHOLD = 0.85;
17
+ const LEVEL_FALLBACK_FLOOR_THICKNESS = 0.08;
18
+ const LEVEL_FALLBACK_FLOOR_PADDING = 2;
19
+ const LEVEL_FALLBACK_FLOOR_MIN_SIZE = 30;
20
+ const SITE_GROUND_COLLIDER_MIN_SIZE = 2000;
21
+ export const FIRST_PERSON_SPAWN_EYE_HEIGHT = SPAWN_EYE_HEIGHT;
22
+ function isMesh(object) {
23
+ return 'isMesh' in object && object.isMesh;
24
+ }
25
+ // Renderer-effective visibility: an invisible ancestor hides the whole
26
+ // subtree at render time even when the object's own flag is true. The
27
+ // collider world must match what's rendered — the roof keeps stale,
28
+ // UNCUT per-segment CSG inside its hidden `segments-wrapper` (full-edit
29
+ // exit hides the wrapper without stripping geometry), and cloning those
30
+ // meshes would block the walkthrough player at openings the visible
31
+ // merged shell has cut through.
32
+ function isEffectivelyVisible(object) {
33
+ let current = object;
34
+ while (current) {
35
+ if (!current.visible)
36
+ return false;
37
+ current = current.parent;
38
+ }
39
+ return true;
40
+ }
41
+ function isColliderMaterialVisible(material) {
42
+ return Array.isArray(material) ? material.some((entry) => entry.visible) : material.visible;
43
+ }
44
+ function isGenericColliderNode(node) {
45
+ if (node.visible === false)
46
+ return false;
47
+ if (DEDICATED_COLLIDER_NODE_TYPES.has(node.type))
48
+ return false;
49
+ const def = nodeRegistry.get(node.type);
50
+ // Ceilings are a transparent mount surface for fixtures (lights, fans), not a
51
+ // walkable or blocking structure — the walkthrough player must pass through
52
+ // them rather than be held up as if standing on a floor slab.
53
+ if (def?.surfaceRole === 'ceiling')
54
+ return false;
55
+ return COLLIDER_NODE_CATEGORIES.has(def?.category ?? '');
56
+ }
57
+ function createBoxColliderGeometry(width, height, depth) {
58
+ const sourceGeometry = new THREE.BoxGeometry(width, height, depth).toNonIndexed();
59
+ const geometry = new THREE.BufferGeometry();
60
+ geometry.setAttribute('position', sourceGeometry.getAttribute('position').clone());
61
+ geometry.setAttribute('normal', sourceGeometry.getAttribute('normal').clone());
62
+ sourceGeometry.dispose();
63
+ return geometry;
64
+ }
65
+ function getVisibleLevelChildren(level, nodes) {
66
+ return level.children
67
+ .map((childId) => nodes[childId])
68
+ .filter((child) => Boolean(child && child.visible !== false));
69
+ }
70
+ function createLevelFallbackFloorGeometry(level, nodes) {
71
+ if (level.visible === false)
72
+ return null;
73
+ const children = getVisibleLevelChildren(level, nodes);
74
+ if (children.some((child) => child.type === 'slab'))
75
+ return null;
76
+ const levelObject = sceneRegistry.nodes.get(level.id);
77
+ if (!levelObject?.visible)
78
+ return null;
79
+ const bounds = computeSceneBoundsXZ(children);
80
+ const [centerX, centerZ] = bounds?.center ?? [0, 0];
81
+ const [boundsWidth, boundsDepth] = bounds?.size ?? [0, 0];
82
+ const width = Math.max(boundsWidth + LEVEL_FALLBACK_FLOOR_PADDING * 2, LEVEL_FALLBACK_FLOOR_MIN_SIZE);
83
+ const depth = Math.max(boundsDepth + LEVEL_FALLBACK_FLOOR_PADDING * 2, LEVEL_FALLBACK_FLOOR_MIN_SIZE);
84
+ const geometry = createBoxColliderGeometry(width, LEVEL_FALLBACK_FLOOR_THICKNESS, depth);
85
+ levelObject.updateWorldMatrix(true, false);
86
+ geometry.applyMatrix4(new THREE.Matrix4().makeTranslation(centerX, -LEVEL_FALLBACK_FLOOR_THICKNESS / 2, centerZ));
87
+ geometry.applyMatrix4(levelObject.matrixWorld);
88
+ return geometry;
89
+ }
90
+ function collectLevelFallbackFloorGeometries(nodes) {
91
+ const geometries = [];
92
+ const levelElevations = getLevelElevations(nodes);
93
+ for (const levelId of sceneRegistry.byType.level) {
94
+ const node = nodes[levelId];
95
+ if (node?.type !== 'level')
96
+ continue;
97
+ if (levelElevations.get(node.id)?.baseY !== 0)
98
+ continue;
99
+ const geometry = createLevelFallbackFloorGeometry(node, nodes);
100
+ if (geometry)
101
+ geometries.push(geometry);
102
+ }
103
+ return geometries;
104
+ }
105
+ // The visible ground is the site node's ground mesh, but `site` is a `site`
106
+ // category node and therefore excluded from the generic collider sweep. Without
107
+ // a dedicated collider, a spawn on the bare ground (no slab, or not parented to
108
+ // a level that triggers the per-level fallback) has no floor to stand on and the
109
+ // walkthrough player falls through. Derive a thin ground slab from node data (not
110
+ // the rendered mesh) so it exists regardless of geometry-mount timing. The slab
111
+ // is effectively unbounded (not sized to the site polygon): the ground plane must
112
+ // keep holding the player up even after they step past the site boundary,
113
+ // otherwise they fall below the ground plane into the void.
114
+ //
115
+ // A sculpted site takes the terrain path instead: the flat slab would hold the
116
+ // player at the datum inside an excavation and bury them inside a hill. Derived
117
+ // from the field rather than the rendered terrain mesh for the same
118
+ // mount-timing reason as the flat slab.
119
+ //
120
+ // Either way the ground is cut open over what the house covers of its basement
121
+ // (`coveredBelowGradeFootprints`, the rings the ground is drawn open over): a sheet
122
+ // across the stairwell would hold the player at grade on the way down, with the
123
+ // stair and the basement's floor below it. The house covers that part, so it is cut
124
+ // whatever the view does with the ground; past it the ground is walked on as before.
125
+ function createSiteGroundColliderGeometry(site, nodes) {
126
+ if (site.visible === false)
127
+ return null;
128
+ const siteObject = sceneRegistry.nodes.get(site.id);
129
+ if (!siteObject?.visible)
130
+ return null;
131
+ const covered = coveredBelowGradeFootprints(nodes);
132
+ const field = terrainFieldOf(site);
133
+ if (field) {
134
+ const terrainGeometry = createTerrainColliderGeometry(field);
135
+ if (terrainGeometry) {
136
+ const cut = cutGroundCollider(terrainGeometry, covered);
137
+ siteObject.updateWorldMatrix(true, false);
138
+ cut.applyMatrix4(siteObject.matrixWorld);
139
+ return cut;
140
+ }
141
+ }
142
+ const bounds = computeSceneBoundsXZ(nodes);
143
+ const [centerX, centerZ] = bounds?.center ?? [0, 0];
144
+ const [boundsWidth, boundsDepth] = bounds?.size ?? [0, 0];
145
+ const width = Math.max(boundsWidth + LEVEL_FALLBACK_FLOOR_PADDING * 2, SITE_GROUND_COLLIDER_MIN_SIZE);
146
+ const depth = Math.max(boundsDepth + LEVEL_FALLBACK_FLOOR_PADDING * 2, SITE_GROUND_COLLIDER_MIN_SIZE);
147
+ const geometry = createBoxColliderGeometry(width, LEVEL_FALLBACK_FLOOR_THICKNESS, depth);
148
+ siteObject.updateWorldMatrix(true, false);
149
+ geometry.applyMatrix4(new THREE.Matrix4().makeTranslation(centerX, -LEVEL_FALLBACK_FLOOR_THICKNESS / 2, centerZ));
150
+ const cut = cutGroundCollider(geometry, covered);
151
+ cut.applyMatrix4(siteObject.matrixWorld);
152
+ return cut;
153
+ }
154
+ function collectSiteGroundColliderGeometries(nodes) {
155
+ const geometries = [];
156
+ for (const siteId of sceneRegistry.byType.site ?? []) {
157
+ const node = nodes[siteId];
158
+ if (node?.type !== 'site')
159
+ continue;
160
+ const geometry = createSiteGroundColliderGeometry(node, nodes);
161
+ if (geometry)
162
+ geometries.push(geometry);
163
+ }
164
+ return geometries;
165
+ }
166
+ // Decode any attribute (interleaved, quantized/normalized integer, Float64…) into a
167
+ // plain, non-normalized Float32Array BufferAttribute. mergeGeometries() requires every
168
+ // merged geometry to share the same typed-array constructor for matching attributes, so
169
+ // imported item GLBs using KHR_mesh_quantization or interleaved buffers must be coerced
170
+ // to Float32 to match wall/slab geometry.
171
+ function toFloat32Attribute(source) {
172
+ const itemSize = source.itemSize;
173
+ const array = new Float32Array(source.count * itemSize);
174
+ for (let i = 0; i < source.count; i++) {
175
+ const offset = i * itemSize;
176
+ array[offset] = source.getX(i);
177
+ if (itemSize > 1)
178
+ array[offset + 1] = source.getY(i);
179
+ if (itemSize > 2)
180
+ array[offset + 2] = source.getZ(i);
181
+ if (itemSize > 3)
182
+ array[offset + 3] = source.getW(i);
183
+ }
184
+ return new THREE.BufferAttribute(array, itemSize);
185
+ }
186
+ function cloneWorldGeometry(mesh) {
187
+ const sourceGeometry = mesh.geometry;
188
+ const position = sourceGeometry.getAttribute('position');
189
+ if (!position || position.count < 3)
190
+ return null;
191
+ const workingGeometry = sourceGeometry.index
192
+ ? sourceGeometry.toNonIndexed()
193
+ : sourceGeometry.clone();
194
+ const cleanGeometry = new THREE.BufferGeometry();
195
+ cleanGeometry.setAttribute('position', toFloat32Attribute(workingGeometry.getAttribute('position')));
196
+ const normal = workingGeometry.getAttribute('normal');
197
+ if (normal) {
198
+ cleanGeometry.setAttribute('normal', toFloat32Attribute(normal));
199
+ }
200
+ else {
201
+ cleanGeometry.computeVertexNormals();
202
+ }
203
+ cleanGeometry.applyMatrix4(mesh.matrixWorld);
204
+ workingGeometry.dispose();
205
+ const worldPosition = cleanGeometry.getAttribute('position');
206
+ if (!worldPosition || worldPosition.count < 3) {
207
+ cleanGeometry.dispose();
208
+ return null;
209
+ }
210
+ return cleanGeometry;
211
+ }
212
+ function shouldSkipColliderNode(node) {
213
+ if (node.type === 'window') {
214
+ return node.openingKind === 'opening';
215
+ }
216
+ if (node.type !== 'door')
217
+ return false;
218
+ if (!node.segments.length)
219
+ return true;
220
+ if (node.openingKind === 'opening')
221
+ return true;
222
+ return node.segments.every((segment) => segment.type === 'empty');
223
+ }
224
+ function createDoorLeafColliderGeometry(root, node) {
225
+ const hasLeafContent = node.segments.some((segment) => segment.type !== 'empty');
226
+ if (!hasLeafContent)
227
+ return null;
228
+ // A door built from a script opens by its model's `open` clip, which the mechanism runs through
229
+ // its own control: the native `operationState` below never moves for it, so open is read there.
230
+ if (node.source && nodeMechanism(node)?.isOn(node, useInteractive.getState()))
231
+ return null;
232
+ const leafW = node.width - 2 * node.frameThickness;
233
+ const leafH = node.height - node.frameThickness;
234
+ if (leafW <= 0 || leafH <= 0)
235
+ return null;
236
+ const leafCenterY = -node.frameThickness / 2;
237
+ const runtimeDoorState = useInteractive.getState().doors[node.id];
238
+ const operationState = runtimeDoorState?.operationState ?? node.operationState;
239
+ const swingAngle = runtimeDoorState?.swingAngle ?? node.swingAngle;
240
+ root.updateWorldMatrix(true, false);
241
+ if (node.doorType === 'garage-sectional' || node.doorType === 'garage-rollup') {
242
+ const openAmount = getGarageVisibleOpeningRatio(node.doorType, operationState);
243
+ const visibleHeight = leafH * (1 - openAmount);
244
+ if (visibleHeight <= 0.12)
245
+ return null;
246
+ const geometry = createBoxColliderGeometry(leafW, visibleHeight, DOOR_LEAF_COLLIDER_DEPTH);
247
+ const visibleCenterY = leafCenterY - leafH / 2 + visibleHeight / 2;
248
+ geometry.applyMatrix4(root.matrixWorld.clone().multiply(new THREE.Matrix4().makeTranslation(0, visibleCenterY, 0)));
249
+ return geometry;
250
+ }
251
+ if (isOperationDoorType(node.doorType) &&
252
+ (operationState ?? 0) >= OPERATION_DOOR_COLLIDER_OPEN_THRESHOLD) {
253
+ return null;
254
+ }
255
+ const hingeX = node.hingesSide === 'right' ? leafW / 2 : -leafW / 2;
256
+ const swingDirectionSign = node.swingDirection === 'inward' ? 1 : -1;
257
+ const hingeDirectionSign = node.hingesSide === 'right' ? 1 : -1;
258
+ const clampedSwingAngle = Math.max(0, Math.min(Math.PI / 2, swingAngle ?? 0));
259
+ const leafSwingRotation = clampedSwingAngle * swingDirectionSign * hingeDirectionSign;
260
+ const geometry = createBoxColliderGeometry(leafW, leafH, DOOR_LEAF_COLLIDER_DEPTH);
261
+ const matrix = root.matrixWorld
262
+ .clone()
263
+ .multiply(new THREE.Matrix4().makeTranslation(hingeX, 0, 0))
264
+ .multiply(new THREE.Matrix4().makeRotationY(leafSwingRotation))
265
+ .multiply(new THREE.Matrix4().makeTranslation(-hingeX, leafCenterY, 0));
266
+ geometry.applyMatrix4(matrix);
267
+ return geometry;
268
+ }
269
+ function buildRegisteredColliderNodeIds(nodes) {
270
+ const nodeIds = new Set();
271
+ for (const nodeId of sceneRegistry.nodes.keys()) {
272
+ const node = nodes[nodeId];
273
+ if (!node || !isGenericColliderNode(node))
274
+ continue;
275
+ if (shouldSkipColliderNode(node))
276
+ continue;
277
+ nodeIds.add(nodeId);
278
+ }
279
+ return nodeIds;
280
+ }
281
+ function collectColliderGeometriesFromNode(root, rootNodeId, visitedMeshes, registeredObjectIds, registeredColliderNodeIds) {
282
+ const geometries = [];
283
+ const visit = (object) => {
284
+ if (visitedMeshes.has(object))
285
+ return;
286
+ visitedMeshes.add(object);
287
+ // Prune hidden subtrees — children of an invisible group never render,
288
+ // so they must not collide either (see isEffectivelyVisible). An idle joint
289
+ // tree draws a merged rest pose that includes its moving parts; its hidden
290
+ // split group holds the same static parts apart from them.
291
+ if (object.userData.pascalProceduralRest)
292
+ return;
293
+ if (!object.visible && !object.userData.pascalProceduralSplit)
294
+ return;
295
+ if (object.userData.proceduralMotion)
296
+ return;
297
+ if (isMesh(object) &&
298
+ isColliderMaterialVisible(object.material) &&
299
+ !SKIPPED_MESH_NAMES.has(object.name)) {
300
+ const geometry = cloneWorldGeometry(object);
301
+ if (geometry) {
302
+ geometries.push(geometry);
303
+ }
304
+ }
305
+ for (const child of object.children) {
306
+ const childNodeId = registeredObjectIds.get(child);
307
+ if (childNodeId && childNodeId !== rootNodeId && registeredColliderNodeIds.has(childNodeId)) {
308
+ continue;
309
+ }
310
+ visit(child);
311
+ }
312
+ };
313
+ visit(root);
314
+ return geometries;
315
+ }
316
+ export function buildFirstPersonColliderWorldFromRegistry() {
317
+ const nodes = useScene.getState().nodes;
318
+ const geometries = [];
319
+ const visitedMeshes = new WeakSet();
320
+ const registeredColliderNodeIds = buildRegisteredColliderNodeIds(nodes);
321
+ const registeredObjectIds = new Map();
322
+ for (const [nodeId, object] of sceneRegistry.nodes) {
323
+ registeredObjectIds.set(object, nodeId);
324
+ }
325
+ for (const nodeId of registeredColliderNodeIds) {
326
+ const node = nodes[nodeId];
327
+ if (!node)
328
+ continue;
329
+ const root = sceneRegistry.nodes.get(nodeId);
330
+ if (!root)
331
+ continue;
332
+ // Registered objects can sit inside a hidden wrapper (roof segments
333
+ // under `segments-wrapper`) — the per-node traversal starts AT the
334
+ // object, so the ancestor chain must be checked here.
335
+ if (!isEffectivelyVisible(root))
336
+ continue;
337
+ if (node.type === 'door') {
338
+ const doorGeometry = createDoorLeafColliderGeometry(root, node);
339
+ if (doorGeometry) {
340
+ geometries.push(doorGeometry);
341
+ }
342
+ continue;
343
+ }
344
+ root.updateMatrixWorld(true);
345
+ geometries.push(...collectColliderGeometriesFromNode(root, nodeId, visitedMeshes, registeredObjectIds, registeredColliderNodeIds));
346
+ }
347
+ geometries.push(...collectLevelFallbackFloorGeometries(nodes));
348
+ geometries.push(...collectSiteGroundColliderGeometries(nodes));
349
+ if (geometries.length === 0) {
350
+ return null;
351
+ }
352
+ const mergedGeometry = mergeGeometries(geometries, false);
353
+ for (const geometry of geometries) {
354
+ geometry.dispose();
355
+ }
356
+ if (!mergedGeometry || mergedGeometry.getAttribute('position') == null) {
357
+ mergedGeometry?.dispose();
358
+ return null;
359
+ }
360
+ const bvhGeometry = mergedGeometry;
361
+ bvhGeometry.computeBoundsTree = computeBoundsTree;
362
+ bvhGeometry.disposeBoundsTree = disposeBoundsTree;
363
+ bvhGeometry.computeBoundsTree?.({
364
+ targetLeafSize: 12,
365
+ strategy: 0,
366
+ });
367
+ bvhGeometry.computeBoundingBox();
368
+ const mesh = new THREE.Mesh(bvhGeometry, COLLIDER_MATERIAL);
369
+ mesh.raycast = acceleratedRaycast;
370
+ mesh.visible = true;
371
+ mesh.userData = {
372
+ type: 'STATIC',
373
+ friction: 0.8,
374
+ restitution: 0.05,
375
+ excludeFloatHit: false,
376
+ excludeCollisionCheck: false,
377
+ };
378
+ mesh.updateMatrixWorld(true);
379
+ return {
380
+ mesh,
381
+ bounds: bvhGeometry.boundingBox?.clone() ?? null,
382
+ dispose: () => {
383
+ bvhGeometry.disposeBoundsTree?.();
384
+ bvhGeometry.dispose();
385
+ },
386
+ };
387
+ }
388
+ export function deriveFirstPersonSpawn(camera, world) {
389
+ const direction = new THREE.Vector3();
390
+ camera.getWorldDirection(direction);
391
+ direction.y = 0;
392
+ if (direction.lengthSq() < 1e-6) {
393
+ direction.set(0, 0, -1);
394
+ }
395
+ else {
396
+ direction.normalize();
397
+ }
398
+ const yaw = Math.atan2(-direction.x, -direction.z);
399
+ const raycaster = new THREE.Raycaster();
400
+ const candidates = [[camera.position.x, camera.position.z]];
401
+ const boundsCenter = world.bounds?.getCenter(new THREE.Vector3());
402
+ if (boundsCenter) {
403
+ candidates.push([boundsCenter.x, boundsCenter.z]);
404
+ }
405
+ for (const [x, z] of candidates) {
406
+ const topY = Math.max(world.bounds?.max.y ?? camera.position.y, camera.position.y) + RAYCAST_CLEARANCE;
407
+ raycaster.set(new THREE.Vector3(x, topY, z), DOWN);
408
+ const intersections = raycaster.intersectObject(world.mesh, false);
409
+ const hit = intersections.find((intersection) => {
410
+ if (!intersection.face)
411
+ return true;
412
+ const normal = intersection.face.normal.clone().transformDirection(world.mesh.matrixWorld);
413
+ return normal.dot(UP) > 0.2;
414
+ });
415
+ if (hit) {
416
+ return {
417
+ position: [hit.point.x, hit.point.y + SPAWN_EYE_HEIGHT, hit.point.z],
418
+ yaw,
419
+ };
420
+ }
421
+ }
422
+ return {
423
+ position: [camera.position.x, Math.max(camera.position.y, SPAWN_EYE_HEIGHT), camera.position.z],
424
+ yaw,
425
+ };
426
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Holes in the site ground's collision geometry.
3
+ *
4
+ * The walkthrough stands on one merged BVH mesh, and the site's ground (a flat slab, or the terrain)
5
+ * is a sheet across the whole lot. Where the house stands over a basement the ground is drawn open
6
+ * (`coveredBelowGradeFootprints`), but the sheet under the stairwell stayed: the player descending
7
+ * the stair met it at grade and could go no deeper. Cutting the collider by the same rings the
8
+ * ground is drawn open over lets the player follow the building's floors and stairs there.
9
+ *
10
+ * Every triangle that reaches a hole is cut against it in plan; the pieces keep the source
11
+ * triangle's plane (height and normal interpolated from its corners) and its facing, so the cut
12
+ * surface is exactly the one the uncut triangle was.
13
+ */
14
+ import { type Ring } from '@pascal-app/core';
15
+ import { BufferGeometry } from 'three';
16
+ /**
17
+ * `geometry` (non-indexed, `position` and `normal`, in the frame the rings are in) without the part
18
+ * of it that lies over `holes`. A triangle the clip fails on is kept whole: the ground closed is
19
+ * the safe side. The geometry is returned as it is when there are no holes.
20
+ */
21
+ export declare function cutGroundCollider(geometry: BufferGeometry, holes: readonly Ring[]): BufferGeometry;
22
+ //# sourceMappingURL=ground-holes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ground-holes.d.ts","sourceRoot":"","sources":["../../../../src/components/viewer/first-person/ground-holes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAc,KAAK,IAAI,EAAS,MAAM,kBAAkB,CAAA;AAC/D,OAAO,EAAmB,cAAc,EAAuB,MAAM,OAAO,CAAA;AAY5E;;;;GAIG;AACH,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,cAAc,EACxB,KAAK,EAAE,SAAS,IAAI,EAAE,GACrB,cAAc,CA8FhB"}