@pascal-app/editor 0.9.1 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (262) hide show
  1. package/package.json +12 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +63 -25
  5. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  6. package/src/components/editor/editor-layout-v2.tsx +18 -0
  7. package/src/components/editor/export-manager.tsx +71 -63
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +36 -3
  10. package/src/components/editor/first-person/build-collider-world.ts +32 -5
  11. package/src/components/editor/first-person-controls.tsx +118 -7
  12. package/src/components/editor/floating-action-menu.tsx +410 -33
  13. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  14. package/src/components/editor/floorplan-background-selection.test.ts +58 -0
  15. package/src/components/editor/floorplan-background-selection.ts +3 -2
  16. package/src/components/editor/floorplan-panel.tsx +1804 -849
  17. package/src/components/editor/grid.tsx +166 -39
  18. package/src/components/editor/group-actions.ts +457 -0
  19. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  20. package/src/components/editor/group-move-3d.ts +397 -0
  21. package/src/components/editor/group-rotate-handle.tsx +102 -64
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +192 -1
  24. package/src/components/editor/group-transform-shared.ts +240 -7
  25. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  26. package/src/components/editor/handles/use-handle-drag.ts +37 -4
  27. package/src/components/editor/index.tsx +248 -27
  28. package/src/components/editor/measurement-pill.tsx +62 -22
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +366 -173
  31. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  32. package/src/components/editor/quick-measurement-card.tsx +70 -0
  33. package/src/components/editor/quick-measurement-hud.tsx +28 -0
  34. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  35. package/src/components/editor/selection-manager.tsx +812 -466
  36. package/src/components/editor/site-edge-labels.tsx +4 -4
  37. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  38. package/src/components/editor/snapshot-capture-overlay.tsx +255 -115
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +136 -43
  41. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  42. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  43. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  44. package/src/components/editor/wall-measurement-label.tsx +3 -13
  45. package/src/components/editor/wall-move-side-handles.tsx +36 -21
  46. package/src/components/editor/wall-snap-beacon-layer.tsx +160 -6
  47. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  48. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +1 -1
  49. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  50. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  51. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  52. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  53. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  54. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +169 -4
  55. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +300 -59
  56. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  57. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  58. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  59. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +4 -1
  60. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  61. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1704 -342
  62. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  63. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +419 -60
  64. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  65. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  66. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  67. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  68. package/src/components/systems/zone/zone-system.tsx +42 -13
  69. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  70. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  71. package/src/components/tools/fence/fence-drafting.ts +80 -8
  72. package/src/components/tools/item/move-tool.tsx +17 -13
  73. package/src/components/tools/item/placement-math.test.ts +13 -1
  74. package/src/components/tools/item/placement-math.ts +28 -2
  75. package/src/components/tools/item/placement-strategies.ts +246 -5
  76. package/src/components/tools/item/placement-types.ts +13 -1
  77. package/src/components/tools/item/use-draft-node.ts +31 -1
  78. package/src/components/tools/item/use-placement-coordinator.tsx +761 -121
  79. package/src/components/tools/registry/move-registry-node-tool.tsx +561 -99
  80. package/src/components/tools/roof/roof-tool.tsx +320 -94
  81. package/src/components/tools/select/box-select-state.ts +18 -7
  82. package/src/components/tools/select/box-select-tool.tsx +176 -40
  83. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  84. package/src/components/tools/select/marquee-geometry.ts +155 -0
  85. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  86. package/src/components/tools/select/select-candidates.ts +1 -1
  87. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  88. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  89. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  90. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  91. package/src/components/tools/shared/placement-box.tsx +183 -1
  92. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  93. package/src/components/tools/site/site-boundary-editor.tsx +88 -36
  94. package/src/components/tools/stair/stair-defaults.ts +1 -0
  95. package/src/components/tools/stair/stair-tool.tsx +82 -11
  96. package/src/components/tools/tool-manager.tsx +86 -25
  97. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  98. package/src/components/tools/wall/wall-drafting.ts +180 -103
  99. package/src/components/tools/wall/wall-snap-geometry.test.ts +44 -0
  100. package/src/components/tools/wall/wall-snap-geometry.ts +67 -6
  101. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  102. package/src/components/tools/zone/zone-tool.tsx +82 -88
  103. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  104. package/src/components/ui/action-menu/control-modes.tsx +40 -93
  105. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  106. package/src/components/ui/action-menu/index.tsx +2 -4
  107. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  108. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  109. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  110. package/src/components/ui/command-palette/editor-commands.tsx +2 -2
  111. package/src/components/ui/command-palette/index.tsx +4 -3
  112. package/src/components/ui/controls/material-paint-panel.tsx +86 -17
  113. package/src/components/ui/controls/material-picker.tsx +83 -152
  114. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  115. package/src/components/ui/controls/metric-control.tsx +118 -44
  116. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  117. package/src/components/ui/controls/slider-control.tsx +88 -38
  118. package/src/components/ui/floating-level-selector.tsx +1 -1
  119. package/src/components/ui/helpers/building-helper.tsx +10 -23
  120. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  121. package/src/components/ui/helpers/helper-manager.tsx +227 -14
  122. package/src/components/ui/helpers/item-helper.tsx +28 -32
  123. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  124. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  125. package/src/components/ui/icon-ref.tsx +47 -0
  126. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  127. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  128. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  129. package/src/components/ui/panels/node-display.ts +17 -17
  130. package/src/components/ui/panels/panel-manager.tsx +22 -13
  131. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  132. package/src/components/ui/panels/parametric-inspector.tsx +15 -2
  133. package/src/components/ui/panels/reference-panel.tsx +54 -19
  134. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  135. package/src/components/ui/primitives/sidebar.tsx +1 -0
  136. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  137. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  138. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  139. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +71 -40
  140. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +60 -6
  141. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  142. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  143. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  144. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  145. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  146. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  147. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  148. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  149. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  150. package/src/components/ui/sidebar/panels/site-panel/index.tsx +40 -17
  151. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  152. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  153. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  154. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  155. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  156. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  157. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  158. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  159. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  160. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  161. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  162. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  163. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  164. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  165. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  166. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  167. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  168. package/src/components/ui/snap-target-badge.tsx +88 -0
  169. package/src/components/viewer-overlay.tsx +85 -10
  170. package/src/components/viewer-zone-system.tsx +6 -1
  171. package/src/hooks/use-auto-save.test.ts +14 -0
  172. package/src/hooks/use-auto-save.ts +54 -7
  173. package/src/hooks/use-ceiling-events.ts +3 -2
  174. package/src/hooks/use-drag-action.ts +4 -1
  175. package/src/hooks/use-keyboard.ts +385 -40
  176. package/src/index.tsx +219 -6
  177. package/src/lib/active-placement-surface.ts +35 -0
  178. package/src/lib/ceiling-plan-snap.ts +24 -0
  179. package/src/lib/contextual-help.test.ts +112 -0
  180. package/src/lib/contextual-help.ts +144 -0
  181. package/src/lib/continuation.ts +64 -0
  182. package/src/lib/direct-manipulation.test.ts +192 -0
  183. package/src/lib/direct-manipulation.ts +109 -0
  184. package/src/lib/editor-api.ts +23 -35
  185. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  186. package/src/lib/floorplan/apply-alignment.ts +18 -12
  187. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  188. package/src/lib/floorplan/geometry.ts +53 -0
  189. package/src/lib/floorplan/index.ts +3 -0
  190. package/src/lib/floorplan/items.ts +5 -2
  191. package/src/lib/floorplan/plan-coords.ts +21 -0
  192. package/src/lib/fresh-planar-placement.test.ts +240 -3
  193. package/src/lib/fresh-planar-placement.ts +68 -1
  194. package/src/lib/glb-export.test.ts +441 -0
  195. package/src/lib/glb-export.ts +874 -0
  196. package/src/lib/history.ts +9 -0
  197. package/src/lib/interaction/hot-set.test.ts +133 -0
  198. package/src/lib/interaction/hot-set.ts +67 -0
  199. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  200. package/src/lib/interaction/overlay-policy.ts +59 -0
  201. package/src/lib/interaction/scope.ts +211 -0
  202. package/src/lib/level-selection.ts +2 -2
  203. package/src/lib/material-paint.ts +36 -49
  204. package/src/lib/measurement-kind.test.ts +30 -0
  205. package/src/lib/measurement-kind.ts +19 -0
  206. package/src/lib/measurement-label.test.ts +47 -0
  207. package/src/lib/measurement-label.ts +68 -0
  208. package/src/lib/measurement-parser.ts +116 -0
  209. package/src/lib/measurements.test.ts +183 -0
  210. package/src/lib/measurements.ts +199 -0
  211. package/src/lib/paint-scope.test.ts +454 -0
  212. package/src/lib/paint-scope.ts +461 -0
  213. package/src/lib/placement-drag-release.ts +23 -0
  214. package/src/lib/planar-cursor-placement.test.ts +57 -0
  215. package/src/lib/plugin-panels.test.ts +19 -0
  216. package/src/lib/plugin-panels.ts +91 -0
  217. package/src/lib/quick-measurement.test.ts +77 -0
  218. package/src/lib/quick-measurement.ts +109 -0
  219. package/src/lib/roof-duplication.ts +6 -6
  220. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  221. package/src/lib/roof-wall-hit.ts +164 -0
  222. package/src/lib/scene-clipboard.test.ts +196 -0
  223. package/src/lib/scene-clipboard.ts +73 -8
  224. package/src/lib/scene.ts +28 -5
  225. package/src/lib/selection-routing.test.ts +298 -0
  226. package/src/lib/selection-routing.ts +175 -0
  227. package/src/lib/sfx/index.ts +1 -0
  228. package/src/lib/sfx/movement-tick.test.ts +65 -0
  229. package/src/lib/sfx/movement-tick.ts +18 -0
  230. package/src/lib/sfx-bus.ts +50 -13
  231. package/src/lib/sfx-player.test.ts +124 -0
  232. package/src/lib/sfx-player.ts +111 -66
  233. package/src/lib/slab-plan-snap.test.ts +93 -0
  234. package/src/lib/slab-plan-snap.ts +108 -0
  235. package/src/lib/snapping-mode.test.ts +138 -0
  236. package/src/lib/snapping-mode.ts +179 -0
  237. package/src/lib/stair-duplication.ts +4 -4
  238. package/src/lib/surface-plan-snap.test.ts +71 -0
  239. package/src/lib/surface-plan-snap.ts +256 -0
  240. package/src/lib/use-linear-display.ts +40 -0
  241. package/src/lib/world-grid-snap.ts +37 -5
  242. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  243. package/src/store/use-editor.tsx +484 -114
  244. package/src/store/use-facing-pose.ts +44 -0
  245. package/src/store/use-fence-curve-draft.ts +21 -0
  246. package/src/store/use-floorplan-draft-preview.ts +101 -0
  247. package/src/store/use-floorplan-marquee.ts +50 -0
  248. package/src/store/use-interaction-scope.test.ts +186 -0
  249. package/src/store/use-interaction-scope.ts +132 -0
  250. package/src/store/use-measurement-draft.test.ts +530 -0
  251. package/src/store/use-measurement-draft.ts +543 -0
  252. package/src/store/use-opening-guides.ts +41 -0
  253. package/src/store/use-placement-preview.ts +11 -3
  254. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  255. package/src/store/use-quick-measurement-hud.ts +77 -0
  256. package/src/store/use-segment-draft-chain.ts +28 -0
  257. package/src/store/use-stair-build-preview.ts +43 -0
  258. package/src/store/use-wall-snap-indicator.ts +2 -0
  259. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  260. package/src/components/editor/group-move-handle.tsx +0 -316
  261. package/src/components/ui/panels/paint-panel.tsx +0 -163
  262. package/src/lib/level-name.ts +0 -11
@@ -105,6 +105,36 @@ describe('buildFirstPersonColliderWorldFromRegistry', () => {
105
105
  world?.dispose()
106
106
  })
107
107
 
108
+ test('skips meshes hidden by an invisible ancestor (stale roof segment CSG)', () => {
109
+ registerColliderDefinition('column', ColumnNode, 'structure')
110
+
111
+ // Mirror the roof's segments-wrapper shape: the registered mesh's own
112
+ // visible flag stays true while a hidden wrapper hides it at render
113
+ // time. The collider must match the render, not the own-flag.
114
+ const column = ColumnNode.parse({ id: 'column_test' })
115
+ const visibleColumn = ColumnNode.parse({ id: 'column_visible', position: [3, 0, 0] })
116
+ setSceneNodes([column, visibleColumn])
117
+
118
+ const wrapper = new Group()
119
+ wrapper.visible = false
120
+ const hiddenMesh = new Mesh(new BoxGeometry(10, 2, 10), new MeshBasicMaterial())
121
+ wrapper.add(hiddenMesh)
122
+ wrapper.updateMatrixWorld(true)
123
+ sceneRegistry.nodes.set(column.id, hiddenMesh)
124
+ sceneRegistry.byType[column.type]!.add(column.id)
125
+
126
+ mountNode(visibleColumn, [1, 2, 1], [3, 1, 0])
127
+
128
+ const world = buildFirstPersonColliderWorldFromRegistry()
129
+
130
+ expect(world).not.toBeNull()
131
+ // Bounds reflect only the visible 1×1 column at x = 3; the 10×10 mesh
132
+ // under the hidden wrapper contributed no geometry.
133
+ expect(world?.bounds?.min.x).toBeCloseTo(2.5)
134
+ expect(world?.bounds?.max.x).toBeCloseTo(3.5)
135
+ world?.dispose()
136
+ })
137
+
108
138
  test('leaves elevators to their dedicated dynamic collider meshes', () => {
109
139
  registerColliderDefinition('elevator', ElevatorNode, 'structure')
110
140
 
@@ -141,9 +171,12 @@ describe('buildFirstPersonColliderWorldFromRegistry', () => {
141
171
  // Ground slab sits just below the site ground plane (y = 0).
142
172
  expect(world?.bounds?.min.y).toBeCloseTo(-0.08)
143
173
  expect(world?.bounds?.max.y).toBeCloseTo(0)
144
- // Default site footprint falls back to the 30 m minimum size.
145
- expect(world?.bounds?.min.x).toBeCloseTo(-15)
146
- expect(world?.bounds?.max.x).toBeCloseTo(15)
174
+ // The ground collider extends far past the site polygon so stepping out of
175
+ // the site boundary never drops the player below the ground plane.
176
+ expect(world?.bounds?.min.x).toBeCloseTo(-1000)
177
+ expect(world?.bounds?.max.x).toBeCloseTo(1000)
178
+ expect(world?.bounds?.min.z).toBeCloseTo(-1000)
179
+ expect(world?.bounds?.max.z).toBeCloseTo(1000)
147
180
  world?.dispose()
148
181
  })
149
182
  })
@@ -27,6 +27,7 @@ const OPERATION_DOOR_COLLIDER_OPEN_THRESHOLD = 0.85
27
27
  const LEVEL_FALLBACK_FLOOR_THICKNESS = 0.08
28
28
  const LEVEL_FALLBACK_FLOOR_PADDING = 2
29
29
  const LEVEL_FALLBACK_FLOOR_MIN_SIZE = 30
30
+ const SITE_GROUND_COLLIDER_MIN_SIZE = 2000
30
31
 
31
32
  export const FIRST_PERSON_SPAWN_EYE_HEIGHT = SPAWN_EYE_HEIGHT
32
33
 
@@ -49,6 +50,22 @@ function isMesh(object: THREE.Object3D): object is THREE.Mesh {
49
50
  return 'isMesh' in object && (object as THREE.Mesh).isMesh
50
51
  }
51
52
 
53
+ // Renderer-effective visibility: an invisible ancestor hides the whole
54
+ // subtree at render time even when the object's own flag is true. The
55
+ // collider world must match what's rendered — the roof keeps stale,
56
+ // UNCUT per-segment CSG inside its hidden `segments-wrapper` (full-edit
57
+ // exit hides the wrapper without stripping geometry), and cloning those
58
+ // meshes would block the walkthrough player at openings the visible
59
+ // merged shell has cut through.
60
+ function isEffectivelyVisible(object: THREE.Object3D) {
61
+ let current: THREE.Object3D | null = object
62
+ while (current) {
63
+ if (!current.visible) return false
64
+ current = current.parent
65
+ }
66
+ return true
67
+ }
68
+
52
69
  function isColliderMaterialVisible(material: THREE.Material | THREE.Material[]) {
53
70
  return Array.isArray(material) ? material.some((entry) => entry.visible) : material.visible
54
71
  }
@@ -129,8 +146,10 @@ function collectLevelFallbackFloorGeometries(nodes: SceneNodes) {
129
146
  // a dedicated collider, a spawn on the bare ground (no slab, or not parented to
130
147
  // a level that triggers the per-level fallback) has no floor to stand on and the
131
148
  // walkthrough player falls through. Derive a thin ground slab from node data (not
132
- // the rendered mesh) so it exists regardless of geometry-mount timing, sized to
133
- // cover the whole scene footprint at the site's ground plane.
149
+ // the rendered mesh) so it exists regardless of geometry-mount timing. The slab
150
+ // is effectively unbounded (not sized to the site polygon): the ground plane must
151
+ // keep holding the player up even after they step past the site boundary,
152
+ // otherwise they fall below the ground plane into the void.
134
153
  function createSiteGroundColliderGeometry(site: SiteNode, nodes: SceneNodes) {
135
154
  if (site.visible === false) return null
136
155
 
@@ -142,11 +161,11 @@ function createSiteGroundColliderGeometry(site: SiteNode, nodes: SceneNodes) {
142
161
  const [boundsWidth, boundsDepth] = bounds?.size ?? [0, 0]
143
162
  const width = Math.max(
144
163
  boundsWidth + LEVEL_FALLBACK_FLOOR_PADDING * 2,
145
- LEVEL_FALLBACK_FLOOR_MIN_SIZE,
164
+ SITE_GROUND_COLLIDER_MIN_SIZE,
146
165
  )
147
166
  const depth = Math.max(
148
167
  boundsDepth + LEVEL_FALLBACK_FLOOR_PADDING * 2,
149
- LEVEL_FALLBACK_FLOOR_MIN_SIZE,
168
+ SITE_GROUND_COLLIDER_MIN_SIZE,
150
169
  )
151
170
 
152
171
  const geometry = createBoxColliderGeometry(width, LEVEL_FALLBACK_FLOOR_THICKNESS, depth)
@@ -316,9 +335,12 @@ function collectColliderGeometriesFromNode(
316
335
  if (visitedMeshes.has(object)) return
317
336
  visitedMeshes.add(object)
318
337
 
338
+ // Prune hidden subtrees — children of an invisible group never render,
339
+ // so they must not collide either (see isEffectivelyVisible).
340
+ if (!object.visible) return
341
+
319
342
  if (
320
343
  isMesh(object) &&
321
- object.visible &&
322
344
  isColliderMaterialVisible(object.material) &&
323
345
  !SKIPPED_MESH_NAMES.has(object.name)
324
346
  ) {
@@ -361,6 +383,11 @@ export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonCollider
361
383
  const root = sceneRegistry.nodes.get(nodeId)
362
384
  if (!root) continue
363
385
 
386
+ // Registered objects can sit inside a hidden wrapper (roof segments
387
+ // under `segments-wrapper`) — the per-node traversal starts AT the
388
+ // object, so the ancestor chain must be checked here.
389
+ if (!isEffectivelyVisible(root)) continue
390
+
364
391
  if (node.type === 'door') {
365
392
  const doorGeometry = createDoorLeafColliderGeometry(root, node)
366
393
  if (doorGeometry) {
@@ -45,6 +45,7 @@ import {
45
45
  } from 'three'
46
46
  import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh'
47
47
  import '../../three-types'
48
+ import { BVHEcctrl, type BVHEcctrlApi, type MovementInput } from '@pascal-app/viewer'
48
49
  import {
49
50
  closeDoorOpenState,
50
51
  DOOR_SWING_OPEN_ANGLE,
@@ -64,8 +65,6 @@ import {
64
65
  type FirstPersonColliderWorld,
65
66
  type FirstPersonSpawn,
66
67
  } from './first-person/build-collider-world'
67
- import type { BVHEcctrlApi } from './first-person/bvh-ecctrl'
68
- import BVHEcctrl from './first-person/bvh-ecctrl'
69
68
 
70
69
  const CAMERA_EYE_OFFSET = 0.45
71
70
  const LOOK_SENSITIVITY = 0.002
@@ -78,7 +77,11 @@ const ELEVATOR_COLLIDER_FLOOR_THICKNESS = 0.08
78
77
  const ELEVATOR_COLLIDER_DOOR_DEPTH = 0.12
79
78
  const ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD = 0.72
80
79
  const DEFAULT_ELEVATOR_LEVEL_HEIGHT = 2.5
81
- const keyboardMap = [
80
+ const VOID_FALL_RESPAWN_DEPTH = 12
81
+
82
+ type MovementKeyName = Exclude<keyof MovementInput, 'joystick'>
83
+
84
+ const movementKeyboardBindings: Array<{ name: MovementKeyName; keys: string[] }> = [
82
85
  { name: 'forward', keys: ['ArrowUp', 'KeyW'] },
83
86
  { name: 'backward', keys: ['ArrowDown', 'KeyS'] },
84
87
  { name: 'leftward', keys: ['ArrowLeft', 'KeyA'] },
@@ -86,6 +89,36 @@ const keyboardMap = [
86
89
  { name: 'jump', keys: ['Space'] },
87
90
  { name: 'run', keys: ['ShiftLeft', 'ShiftRight'] },
88
91
  ]
92
+ const keyboardMap = movementKeyboardBindings
93
+ const movementKeyToName = new Map<string, MovementKeyName>(
94
+ movementKeyboardBindings.flatMap(({ name, keys }) => keys.map((key) => [key, name] as const)),
95
+ )
96
+
97
+ const inactiveMovementInput: MovementInput = {
98
+ backward: false,
99
+ forward: false,
100
+ jump: false,
101
+ leftward: false,
102
+ rightward: false,
103
+ run: false,
104
+ }
105
+
106
+ function getMovementInputForKey(code: string, active: boolean): MovementInput | null {
107
+ const name = movementKeyToName.get(code)
108
+ return name ? ({ [name]: active } as MovementInput) : null
109
+ }
110
+
111
+ function focusFirstPersonCanvas(canvas: HTMLCanvasElement) {
112
+ const activeElement = document.activeElement
113
+ if (activeElement instanceof HTMLElement && !canvas.contains(activeElement)) {
114
+ activeElement.blur()
115
+ }
116
+
117
+ if (!canvas.hasAttribute('tabindex')) {
118
+ canvas.tabIndex = -1
119
+ }
120
+ canvas.focus({ preventScroll: true })
121
+ }
89
122
 
90
123
  const cameraOffset = new Vector3(0, CAMERA_EYE_OFFSET, 0)
91
124
  const cameraEuler = new Euler(0, 0, 0, 'YXZ')
@@ -535,6 +568,8 @@ export const FirstPersonControls = () => {
535
568
  const selectedLevelId = useViewer((state) => state.selection.levelId)
536
569
  const placedSpawnNode = useScene((state) => resolvePlacedSpawnNode(state.nodes, selectedLevelId))
537
570
  const controllerRef = useRef<BVHEcctrlApi | null>(null)
571
+ const movementInputRef = useRef<MovementInput>({ ...inactiveMovementInput })
572
+ const hadPointerLockRef = useRef(false)
538
573
  const yawRef = useRef(0)
539
574
  const pitchRef = useRef(0)
540
575
  const interactableTargetRef = useRef<FirstPersonInteractableTarget | null>(null)
@@ -577,6 +612,13 @@ export const FirstPersonControls = () => {
577
612
  setIsElevatorRideLocked(locked)
578
613
  }, [])
579
614
 
615
+ const setControllerApi = useCallback((api: BVHEcctrlApi | null) => {
616
+ controllerRef.current = api
617
+ if (api) {
618
+ api.setMovement(movementInputRef.current)
619
+ }
620
+ }, [])
621
+
580
622
  const resolveInteractableDoorId = useCallback((): AnyNodeId | null => {
581
623
  const nodes = useScene.getState().nodes
582
624
  camera.updateMatrixWorld(true)
@@ -915,6 +957,14 @@ export const FirstPersonControls = () => {
915
957
  })
916
958
  }, [camera, controllerStart, placedSpawn, world])
917
959
 
960
+ useEffect(() => {
961
+ const canvas = gl.domElement
962
+ focusFirstPersonCanvas(canvas)
963
+
964
+ const frame = window.requestAnimationFrame(() => focusFirstPersonCanvas(canvas))
965
+ return () => window.cancelAnimationFrame(frame)
966
+ }, [gl])
967
+
918
968
  useEffect(() => {
919
969
  const canvas = gl.domElement
920
970
  const handleMouseMove = (e: MouseEvent) => {
@@ -945,14 +995,29 @@ export const FirstPersonControls = () => {
945
995
  toggleInteractableTarget()
946
996
  }
947
997
 
998
+ const handlePointerLockChange = () => {
999
+ const isLocked = document.pointerLockElement === canvas
1000
+ if (isLocked) {
1001
+ hadPointerLockRef.current = true
1002
+ return
1003
+ }
1004
+
1005
+ if (hadPointerLockRef.current && useEditor.getState().isFirstPersonMode) {
1006
+ useEditor.getState().setFirstPersonMode(false)
1007
+ }
1008
+ }
1009
+
1010
+ handlePointerLockChange()
948
1011
  document.addEventListener('mousemove', handleMouseMove)
949
1012
  document.addEventListener('click', handleClick)
950
1013
  document.addEventListener('mousedown', handleMouseDown, true)
1014
+ document.addEventListener('pointerlockchange', handlePointerLockChange)
951
1015
 
952
1016
  return () => {
953
1017
  document.removeEventListener('mousemove', handleMouseMove)
954
1018
  document.removeEventListener('click', handleClick)
955
1019
  document.removeEventListener('mousedown', handleMouseDown, true)
1020
+ document.removeEventListener('pointerlockchange', handlePointerLockChange)
956
1021
  if (document.pointerLockElement === canvas) {
957
1022
  document.exitPointerLock()
958
1023
  }
@@ -962,7 +1027,24 @@ export const FirstPersonControls = () => {
962
1027
  useEffect(() => {
963
1028
  const canvas = gl.domElement
964
1029
 
1030
+ const applyMovementKey = (event: KeyboardEvent, active: boolean) => {
1031
+ if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
1032
+ return false
1033
+ }
1034
+
1035
+ const movement = getMovementInputForKey(event.code, active)
1036
+ if (!movement) return false
1037
+
1038
+ event.preventDefault()
1039
+ Object.assign(movementInputRef.current, movement)
1040
+ controllerRef.current?.setMovement(movement)
1041
+ return true
1042
+ }
1043
+
965
1044
  const handleKeyDown = (event: KeyboardEvent) => {
1045
+ const handledMovement = applyMovementKey(event, true)
1046
+ if (handledMovement) return
1047
+
966
1048
  if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
967
1049
  return
968
1050
  }
@@ -985,9 +1067,15 @@ export const FirstPersonControls = () => {
985
1067
  }
986
1068
  }
987
1069
 
1070
+ const handleKeyUp = (event: KeyboardEvent) => {
1071
+ applyMovementKey(event, false)
1072
+ }
1073
+
988
1074
  document.addEventListener('keydown', handleKeyDown, true)
1075
+ document.addEventListener('keyup', handleKeyUp, true)
989
1076
  return () => {
990
1077
  document.removeEventListener('keydown', handleKeyDown, true)
1078
+ document.removeEventListener('keyup', handleKeyUp, true)
991
1079
  }
992
1080
  }, [closeInteractableTarget, gl, toggleInteractableTarget])
993
1081
 
@@ -1217,6 +1305,29 @@ export const FirstPersonControls = () => {
1217
1305
  if (!controllerRef.current?.group) return
1218
1306
 
1219
1307
  const group = controllerRef.current.group
1308
+
1309
+ // The site ground collider is effectively unbounded, but scenes without a
1310
+ // site node only have finite fallback floors — if the controller still ends
1311
+ // up below every collider it can never land, so put it back at the spawn.
1312
+ // Prefer the live spawn node over the mount-time start position so a spawn
1313
+ // moved mid-walkthrough doesn't respawn the player at stale coordinates.
1314
+ const worldBounds = worldRef.current?.bounds
1315
+ if (worldBounds && group.position.y < worldBounds.min.y - VOID_FALL_RESPAWN_DEPTH) {
1316
+ const respawnPosition = placedSpawn
1317
+ ? [
1318
+ placedSpawn.position[0],
1319
+ placedSpawn.position[1] - CONTROLLER_CENTER_FROM_EYE,
1320
+ placedSpawn.position[2],
1321
+ ]
1322
+ : controllerStart?.position
1323
+ if (respawnPosition) {
1324
+ group.position.set(respawnPosition[0]!, respawnPosition[1]!, respawnPosition[2]!)
1325
+ controllerRef.current.resetLinVel()
1326
+ ridingElevatorRef.current = null
1327
+ setElevatorRideLocked(false)
1328
+ }
1329
+ }
1330
+
1220
1331
  group.rotation.y = 0
1221
1332
  camera.position.copy(group.position).add(cameraOffset)
1222
1333
  cameraEuler.set(pitchRef.current, yawRef.current, 0, 'YXZ')
@@ -1277,14 +1388,14 @@ export const FirstPersonControls = () => {
1277
1388
  floatSensorRadius={0.15}
1278
1389
  floatSpringK={1200}
1279
1390
  gravity={9.81}
1280
- jumpVel={6}
1391
+ jumpVel={5}
1281
1392
  key="first-person-controller"
1282
- maxRunSpeed={5.5}
1393
+ maxRunSpeed={5}
1283
1394
  maxSlope={1.2}
1284
- maxWalkSpeed={4}
1395
+ maxWalkSpeed={2}
1285
1396
  paused={isElevatorRideLocked}
1286
1397
  position={controllerStart.position}
1287
- ref={controllerRef}
1398
+ ref={setControllerApi}
1288
1399
  />
1289
1400
  </KeyboardControls>
1290
1401
  )}