@pascal-app/editor 0.8.0 → 0.9.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 (223) hide show
  1. package/package.json +8 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
  3. package/src/components/editor/custom-camera-controls.tsx +754 -25
  4. package/src/components/editor/editor-layout-mobile.tsx +3 -3
  5. package/src/components/editor/editor-layout-v2.tsx +58 -51
  6. package/src/components/editor/export-manager.tsx +32 -3
  7. package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
  8. package/src/components/editor/first-person/build-collider-world.ts +201 -83
  9. package/src/components/editor/first-person/bvh-ecctrl.tsx +8 -8
  10. package/src/components/editor/first-person-controls.tsx +825 -20
  11. package/src/components/editor/floating-action-menu.tsx +287 -196
  12. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  13. package/src/components/editor/floorplan-panel.tsx +5829 -12005
  14. package/src/components/editor/grid.tsx +51 -17
  15. package/src/components/editor/group-move-handle.tsx +316 -0
  16. package/src/components/editor/group-rotate-handle.tsx +382 -0
  17. package/src/components/editor/group-transform-shared.test.ts +205 -0
  18. package/src/components/editor/group-transform-shared.ts +256 -0
  19. package/src/components/editor/handles/handle-arrow.tsx +523 -0
  20. package/src/components/editor/handles/use-handle-drag.ts +213 -0
  21. package/src/components/editor/index.tsx +178 -149
  22. package/src/components/editor/measurement-pill.tsx +75 -0
  23. package/src/components/editor/node-arrow-handles.tsx +1453 -0
  24. package/src/components/editor/selection-manager.tsx +433 -156
  25. package/src/components/editor/site-edge-labels.tsx +44 -20
  26. package/src/components/editor/slab-hole-highlights.tsx +466 -0
  27. package/src/components/editor/snapshot-capture-overlay.tsx +136 -62
  28. package/src/components/editor/thumbnail-generator.tsx +9 -3
  29. package/src/components/editor/use-floorplan-background-placement.ts +119 -32
  30. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  31. package/src/components/editor/use-floorplan-scene-data.ts +14 -2
  32. package/src/components/editor/wall-measurement-label.tsx +11 -6
  33. package/src/components/editor/wall-move-side-handles.tsx +855 -0
  34. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  35. package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
  36. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
  37. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  38. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  39. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
  41. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  42. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
  43. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  44. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  45. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
  46. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
  47. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
  48. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
  49. package/src/components/editor-2d/svg-paths.ts +10 -2
  50. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +26 -3
  51. package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
  52. package/src/components/systems/roof/roof-edit-system.tsx +86 -12
  53. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  54. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  55. package/src/components/tools/elevator/elevator-tool.tsx +349 -0
  56. package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
  57. package/src/components/tools/fence/fence-drafting.ts +64 -22
  58. package/src/components/tools/item/move-tool.tsx +41 -105
  59. package/src/components/tools/item/placement-math.test.ts +10 -0
  60. package/src/components/tools/item/placement-math.ts +31 -5
  61. package/src/components/tools/item/placement-strategies.ts +190 -15
  62. package/src/components/tools/item/placement-types.ts +10 -1
  63. package/src/components/tools/item/use-draft-node.ts +20 -1
  64. package/src/components/tools/item/use-placement-coordinator.tsx +694 -191
  65. package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
  66. package/src/components/tools/roof/roof-tool.tsx +108 -12
  67. package/src/components/tools/select/box-select-state.ts +77 -0
  68. package/src/components/tools/select/box-select-tool.tsx +324 -538
  69. package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
  70. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  71. package/src/components/tools/select/select-candidates.test.ts +109 -0
  72. package/src/components/tools/select/select-candidates.ts +85 -0
  73. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  74. package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
  75. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  76. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  77. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  78. package/src/components/tools/shared/placement-box.tsx +123 -0
  79. package/src/components/tools/shared/polygon-editor.tsx +617 -167
  80. package/src/components/tools/shared/segment-angle.ts +56 -0
  81. package/src/components/tools/site/site-boundary-editor.tsx +464 -12
  82. package/src/components/tools/site/site-flag-model.ts +18 -0
  83. package/src/components/tools/stair/stair-defaults.ts +1 -1
  84. package/src/components/tools/stair/stair-tool.tsx +269 -35
  85. package/src/components/tools/tool-manager.tsx +172 -56
  86. package/src/components/tools/wall/wall-drafting.ts +98 -98
  87. package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
  88. package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
  89. package/src/components/tools/zone/zone-tool.tsx +16 -6
  90. package/src/components/ui/action-menu/action-button.tsx +21 -1
  91. package/src/components/ui/action-menu/control-modes.tsx +5 -82
  92. package/src/components/ui/action-menu/index.tsx +1 -113
  93. package/src/components/ui/action-menu/structure-tools.tsx +13 -88
  94. package/src/components/ui/action-menu/view-toggles.tsx +243 -6
  95. package/src/components/ui/command-palette/editor-commands.tsx +18 -13
  96. package/src/components/ui/command-palette/index.tsx +2 -1
  97. package/src/components/ui/controls/material-paint-panel.tsx +83 -0
  98. package/src/components/ui/controls/material-picker.tsx +11 -32
  99. package/src/components/ui/controls/metric-control.tsx +33 -11
  100. package/src/components/ui/floating-level-selector.tsx +47 -10
  101. package/src/components/ui/helpers/helper-manager.tsx +16 -17
  102. package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
  103. package/src/components/ui/item-catalog/catalog-items.tsx +1 -0
  104. package/src/components/ui/item-catalog/item-catalog.tsx +3 -0
  105. package/src/components/ui/level-duplicate-dialog.tsx +2 -1
  106. package/src/components/ui/panels/node-display.ts +1 -0
  107. package/src/components/ui/panels/panel-manager.tsx +35 -47
  108. package/src/components/ui/panels/panel-wrapper.tsx +217 -12
  109. package/src/components/ui/panels/parametric-inspector.tsx +469 -0
  110. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  111. package/src/components/ui/scene-loader.tsx +1 -3
  112. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  113. package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
  114. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
  115. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
  116. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
  117. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  118. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
  119. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
  120. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
  121. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  122. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
  123. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
  124. package/src/components/ui/sidebar/panels/site-panel/index.tsx +4 -6
  125. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  126. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
  127. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
  128. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
  129. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
  130. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
  131. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
  132. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -36
  133. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
  134. package/src/components/ui/sidebar/tab-bar.tsx +61 -1
  135. package/src/components/viewer-overlay.tsx +169 -52
  136. package/src/components/viewer-zone-system.tsx +1 -1
  137. package/src/hooks/use-auto-save.ts +10 -3
  138. package/src/hooks/use-ceiling-events.ts +175 -0
  139. package/src/hooks/use-drag-action.ts +124 -0
  140. package/src/hooks/use-keyboard.ts +115 -62
  141. package/src/hooks/use-selection.ts +64 -0
  142. package/src/index.tsx +237 -5
  143. package/src/lib/constants.ts +6 -2
  144. package/src/lib/editor-api.ts +62 -0
  145. package/src/lib/elevator-support.ts +96 -0
  146. package/src/lib/floorplan/apply-alignment.ts +111 -0
  147. package/src/lib/floorplan/index.ts +7 -0
  148. package/src/lib/floorplan/selection-tool.ts +40 -0
  149. package/src/lib/floorplan/stairs.ts +12 -1
  150. package/src/lib/fresh-planar-placement.test.ts +102 -0
  151. package/src/lib/fresh-planar-placement.ts +63 -0
  152. package/src/lib/history.ts +2 -1
  153. package/src/lib/level-duplication.ts +54 -42
  154. package/src/lib/level-name.ts +11 -0
  155. package/src/lib/material-paint.ts +211 -19
  156. package/src/lib/placement-metadata.ts +29 -0
  157. package/src/lib/planar-cursor-placement.test.ts +43 -0
  158. package/src/lib/planar-cursor-placement.ts +42 -0
  159. package/src/lib/roof-duplication.ts +1 -1
  160. package/src/lib/scene-clipboard.ts +267 -0
  161. package/src/lib/scene.ts +13 -4
  162. package/src/lib/sfx-bus.ts +23 -1
  163. package/src/lib/sfx-player.ts +70 -22
  164. package/src/lib/site-boundary.ts +1 -0
  165. package/src/lib/stair-levels.test.ts +146 -0
  166. package/src/lib/stair-levels.ts +177 -0
  167. package/src/lib/world-grid-snap.ts +199 -0
  168. package/src/lib/zone-content.ts +130 -0
  169. package/src/store/use-alignment-guides.ts +21 -0
  170. package/src/store/use-editor.tsx +320 -37
  171. package/src/store/use-placement-preview.ts +31 -0
  172. package/src/store/use-wall-move-ghosts.ts +36 -0
  173. package/src/store/use-wall-snap-indicator.ts +32 -0
  174. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  175. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  176. package/src/components/tools/building/move-building-tool.tsx +0 -157
  177. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  178. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  179. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  180. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
  181. package/src/components/tools/column/column-tool.tsx +0 -97
  182. package/src/components/tools/column/move-column-tool.tsx +0 -105
  183. package/src/components/tools/door/door-math.ts +0 -110
  184. package/src/components/tools/door/door-tool.tsx +0 -324
  185. package/src/components/tools/door/move-door-tool.tsx +0 -412
  186. package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
  187. package/src/components/tools/fence/fence-tool.tsx +0 -346
  188. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
  189. package/src/components/tools/fence/move-fence-tool.tsx +0 -302
  190. package/src/components/tools/item/item-tool.tsx +0 -26
  191. package/src/components/tools/roof/move-roof-tool.tsx +0 -364
  192. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  193. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  194. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  195. package/src/components/tools/slab/slab-tool.tsx +0 -322
  196. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  197. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  198. package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
  199. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
  200. package/src/components/tools/wall/move-wall-tool.tsx +0 -358
  201. package/src/components/tools/wall/wall-tool.tsx +0 -332
  202. package/src/components/tools/window/move-window-tool.tsx +0 -447
  203. package/src/components/tools/window/window-math.ts +0 -117
  204. package/src/components/tools/window/window-tool.tsx +0 -332
  205. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  206. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  207. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  208. package/src/components/ui/panels/ceiling-panel.tsx +0 -249
  209. package/src/components/ui/panels/column-panel.tsx +0 -759
  210. package/src/components/ui/panels/door-panel.tsx +0 -1299
  211. package/src/components/ui/panels/fence-panel.tsx +0 -222
  212. package/src/components/ui/panels/item-panel.tsx +0 -306
  213. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  214. package/src/components/ui/panels/roof-panel.tsx +0 -280
  215. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  216. package/src/components/ui/panels/slab-panel.tsx +0 -250
  217. package/src/components/ui/panels/spawn-panel.tsx +0 -161
  218. package/src/components/ui/panels/stair-panel.tsx +0 -577
  219. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  220. package/src/components/ui/panels/wall-panel.tsx +0 -273
  221. package/src/components/ui/panels/window-panel.tsx +0 -968
  222. package/src/contexts/presets-context.tsx +0 -121
  223. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -1,8 +1,10 @@
1
1
  import {
2
+ type AnyNode,
2
3
  type AnyNodeId,
3
4
  type DoorNode,
4
5
  getGarageVisibleOpeningRatio,
5
6
  isOperationDoorType,
7
+ nodeRegistry,
6
8
  sceneRegistry,
7
9
  useInteractive,
8
10
  useScene,
@@ -10,21 +12,11 @@ import {
10
12
  import * as THREE from 'three'
11
13
  import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
12
14
  import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh'
13
-
14
- const COLLIDER_NODE_TYPES = [
15
- 'wall',
16
- 'fence',
17
- 'slab',
18
- 'stair',
19
- 'stair-segment',
20
- 'roof',
21
- 'roof-segment',
22
- 'door',
23
- 'window',
24
- 'item',
25
- ] as const
15
+ import { computeSceneBoundsXZ } from '../../../lib/scene-bounds'
26
16
 
27
17
  const SKIPPED_MESH_NAMES = new Set(['cutout', 'collision-mesh'])
18
+ const COLLIDER_NODE_CATEGORIES = new Set(['structure', 'furnish'])
19
+ const DEDICATED_COLLIDER_NODE_TYPES = new Set<AnyNode['type']>(['elevator'])
28
20
  const COLLIDER_MATERIAL = new THREE.MeshBasicMaterial()
29
21
  const DOWN = new THREE.Vector3(0, -1, 0)
30
22
  const UP = new THREE.Vector3(0, 1, 0)
@@ -32,6 +24,9 @@ const SPAWN_EYE_HEIGHT = 1.65
32
24
  const RAYCAST_CLEARANCE = 25
33
25
  const DOOR_LEAF_COLLIDER_DEPTH = 0.06
34
26
  const OPERATION_DOOR_COLLIDER_OPEN_THRESHOLD = 0.85
27
+ const LEVEL_FALLBACK_FLOOR_THICKNESS = 0.08
28
+ const LEVEL_FALLBACK_FLOOR_PADDING = 2
29
+ const LEVEL_FALLBACK_FLOOR_MIN_SIZE = 30
35
30
 
36
31
  export const FIRST_PERSON_SPAWN_EYE_HEIGHT = SPAWN_EYE_HEIGHT
37
32
 
@@ -46,7 +41,9 @@ export type FirstPersonSpawn = {
46
41
  yaw: number
47
42
  }
48
43
 
49
- type ColliderNodeType = (typeof COLLIDER_NODE_TYPES)[number]
44
+ type LevelNode = Extract<AnyNode, { type: 'level' }>
45
+ type SiteNode = Extract<AnyNode, { type: 'site' }>
46
+ type SceneNodes = ReturnType<typeof useScene.getState>['nodes']
50
47
 
51
48
  function isMesh(object: THREE.Object3D): object is THREE.Mesh {
52
49
  return 'isMesh' in object && (object as THREE.Mesh).isMesh
@@ -56,6 +53,144 @@ function isColliderMaterialVisible(material: THREE.Material | THREE.Material[])
56
53
  return Array.isArray(material) ? material.some((entry) => entry.visible) : material.visible
57
54
  }
58
55
 
56
+ function isGenericColliderNode(node: AnyNode) {
57
+ if (node.visible === false) return false
58
+ if (DEDICATED_COLLIDER_NODE_TYPES.has(node.type)) return false
59
+ const def = nodeRegistry.get(node.type)
60
+ // Ceilings are a transparent mount surface for fixtures (lights, fans), not a
61
+ // walkable or blocking structure — the walkthrough player must pass through
62
+ // them rather than be held up as if standing on a floor slab.
63
+ if (def?.surfaceRole === 'ceiling') return false
64
+ return COLLIDER_NODE_CATEGORIES.has(def?.category ?? '')
65
+ }
66
+
67
+ function createBoxColliderGeometry(width: number, height: number, depth: number) {
68
+ const sourceGeometry = new THREE.BoxGeometry(width, height, depth).toNonIndexed()
69
+ const geometry = new THREE.BufferGeometry()
70
+ geometry.setAttribute('position', sourceGeometry.getAttribute('position').clone())
71
+ geometry.setAttribute('normal', sourceGeometry.getAttribute('normal').clone())
72
+ sourceGeometry.dispose()
73
+ return geometry
74
+ }
75
+
76
+ function getVisibleLevelChildren(level: LevelNode, nodes: SceneNodes) {
77
+ return level.children
78
+ .map((childId) => nodes[childId as AnyNodeId])
79
+ .filter((child): child is AnyNode => Boolean(child && child.visible !== false))
80
+ }
81
+
82
+ function createLevelFallbackFloorGeometry(level: LevelNode, nodes: SceneNodes) {
83
+ if (level.visible === false) return null
84
+
85
+ const children = getVisibleLevelChildren(level, nodes)
86
+ if (children.some((child) => child.type === 'slab')) return null
87
+
88
+ const levelObject = sceneRegistry.nodes.get(level.id)
89
+ if (!levelObject?.visible) return null
90
+
91
+ const bounds = computeSceneBoundsXZ(children)
92
+ const [centerX, centerZ] = bounds?.center ?? [0, 0]
93
+ const [boundsWidth, boundsDepth] = bounds?.size ?? [0, 0]
94
+ const width = Math.max(
95
+ boundsWidth + LEVEL_FALLBACK_FLOOR_PADDING * 2,
96
+ LEVEL_FALLBACK_FLOOR_MIN_SIZE,
97
+ )
98
+ const depth = Math.max(
99
+ boundsDepth + LEVEL_FALLBACK_FLOOR_PADDING * 2,
100
+ LEVEL_FALLBACK_FLOOR_MIN_SIZE,
101
+ )
102
+
103
+ const geometry = createBoxColliderGeometry(width, LEVEL_FALLBACK_FLOOR_THICKNESS, depth)
104
+
105
+ levelObject.updateWorldMatrix(true, false)
106
+ geometry.applyMatrix4(
107
+ new THREE.Matrix4().makeTranslation(centerX, -LEVEL_FALLBACK_FLOOR_THICKNESS / 2, centerZ),
108
+ )
109
+ geometry.applyMatrix4(levelObject.matrixWorld)
110
+ return geometry
111
+ }
112
+
113
+ function collectLevelFallbackFloorGeometries(nodes: SceneNodes) {
114
+ const geometries: THREE.BufferGeometry[] = []
115
+
116
+ for (const levelId of sceneRegistry.byType.level!) {
117
+ const node = nodes[levelId as AnyNodeId]
118
+ if (node?.type !== 'level') continue
119
+
120
+ const geometry = createLevelFallbackFloorGeometry(node, nodes)
121
+ if (geometry) geometries.push(geometry)
122
+ }
123
+
124
+ return geometries
125
+ }
126
+
127
+ // The visible ground is the site node's ground mesh, but `site` is a `site`
128
+ // category node and therefore excluded from the generic collider sweep. Without
129
+ // a dedicated collider, a spawn on the bare ground (no slab, or not parented to
130
+ // a level that triggers the per-level fallback) has no floor to stand on and the
131
+ // 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.
134
+ function createSiteGroundColliderGeometry(site: SiteNode, nodes: SceneNodes) {
135
+ if (site.visible === false) return null
136
+
137
+ const siteObject = sceneRegistry.nodes.get(site.id)
138
+ if (!siteObject?.visible) return null
139
+
140
+ const bounds = computeSceneBoundsXZ(nodes)
141
+ const [centerX, centerZ] = bounds?.center ?? [0, 0]
142
+ const [boundsWidth, boundsDepth] = bounds?.size ?? [0, 0]
143
+ const width = Math.max(
144
+ boundsWidth + LEVEL_FALLBACK_FLOOR_PADDING * 2,
145
+ LEVEL_FALLBACK_FLOOR_MIN_SIZE,
146
+ )
147
+ const depth = Math.max(
148
+ boundsDepth + LEVEL_FALLBACK_FLOOR_PADDING * 2,
149
+ LEVEL_FALLBACK_FLOOR_MIN_SIZE,
150
+ )
151
+
152
+ const geometry = createBoxColliderGeometry(width, LEVEL_FALLBACK_FLOOR_THICKNESS, depth)
153
+
154
+ siteObject.updateWorldMatrix(true, false)
155
+ geometry.applyMatrix4(
156
+ new THREE.Matrix4().makeTranslation(centerX, -LEVEL_FALLBACK_FLOOR_THICKNESS / 2, centerZ),
157
+ )
158
+ geometry.applyMatrix4(siteObject.matrixWorld)
159
+ return geometry
160
+ }
161
+
162
+ function collectSiteGroundColliderGeometries(nodes: SceneNodes) {
163
+ const geometries: THREE.BufferGeometry[] = []
164
+
165
+ for (const siteId of sceneRegistry.byType.site ?? []) {
166
+ const node = nodes[siteId as AnyNodeId]
167
+ if (node?.type !== 'site') continue
168
+
169
+ const geometry = createSiteGroundColliderGeometry(node, nodes)
170
+ if (geometry) geometries.push(geometry)
171
+ }
172
+
173
+ return geometries
174
+ }
175
+
176
+ // Decode any attribute (interleaved, quantized/normalized integer, Float64…) into a
177
+ // plain, non-normalized Float32Array BufferAttribute. mergeGeometries() requires every
178
+ // merged geometry to share the same typed-array constructor for matching attributes, so
179
+ // imported item GLBs using KHR_mesh_quantization or interleaved buffers must be coerced
180
+ // to Float32 to match wall/slab geometry.
181
+ function toFloat32Attribute(source: THREE.BufferAttribute | THREE.InterleavedBufferAttribute) {
182
+ const itemSize = source.itemSize
183
+ const array = new Float32Array(source.count * itemSize)
184
+ for (let i = 0; i < source.count; i++) {
185
+ const offset = i * itemSize
186
+ array[offset] = source.getX(i)
187
+ if (itemSize > 1) array[offset + 1] = source.getY(i)
188
+ if (itemSize > 2) array[offset + 2] = source.getZ(i)
189
+ if (itemSize > 3) array[offset + 3] = source.getW(i)
190
+ }
191
+ return new THREE.BufferAttribute(array, itemSize)
192
+ }
193
+
59
194
  function cloneWorldGeometry(mesh: THREE.Mesh) {
60
195
  const sourceGeometry = mesh.geometry
61
196
  const position = sourceGeometry.getAttribute('position')
@@ -65,11 +200,14 @@ function cloneWorldGeometry(mesh: THREE.Mesh) {
65
200
  ? sourceGeometry.toNonIndexed()
66
201
  : sourceGeometry.clone()
67
202
  const cleanGeometry = new THREE.BufferGeometry()
68
- cleanGeometry.setAttribute('position', workingGeometry.getAttribute('position').clone())
203
+ cleanGeometry.setAttribute(
204
+ 'position',
205
+ toFloat32Attribute(workingGeometry.getAttribute('position')),
206
+ )
69
207
 
70
208
  const normal = workingGeometry.getAttribute('normal')
71
209
  if (normal) {
72
- cleanGeometry.setAttribute('normal', normal.clone())
210
+ cleanGeometry.setAttribute('normal', toFloat32Attribute(normal))
73
211
  } else {
74
212
  cleanGeometry.computeVertexNormals()
75
213
  }
@@ -86,16 +224,12 @@ function cloneWorldGeometry(mesh: THREE.Mesh) {
86
224
  return cleanGeometry
87
225
  }
88
226
 
89
- function shouldSkipColliderNode(nodeId: string, type: (typeof COLLIDER_NODE_TYPES)[number]) {
90
- if (type === 'window') {
91
- const node = useScene.getState().nodes[nodeId as AnyNodeId]
92
- return node?.type === 'window' && node.openingKind === 'opening'
227
+ function shouldSkipColliderNode(node: AnyNode) {
228
+ if (node.type === 'window') {
229
+ return node.openingKind === 'opening'
93
230
  }
94
231
 
95
- if (type !== 'door') return false
96
-
97
- const node = useScene.getState().nodes[nodeId as AnyNodeId]
98
- if (!node || node.type !== 'door') return false
232
+ if (node.type !== 'door') return false
99
233
 
100
234
  if (!node.segments.length) return true
101
235
 
@@ -124,15 +258,7 @@ function createDoorLeafColliderGeometry(root: THREE.Object3D, node: DoorNode) {
124
258
  const visibleHeight = leafH * (1 - openAmount)
125
259
  if (visibleHeight <= 0.12) return null
126
260
 
127
- const sourceGeometry = new THREE.BoxGeometry(
128
- leafW,
129
- visibleHeight,
130
- DOOR_LEAF_COLLIDER_DEPTH,
131
- ).toNonIndexed()
132
- const geometry = new THREE.BufferGeometry()
133
- geometry.setAttribute('position', sourceGeometry.getAttribute('position').clone())
134
- geometry.setAttribute('normal', sourceGeometry.getAttribute('normal').clone())
135
- sourceGeometry.dispose()
261
+ const geometry = createBoxColliderGeometry(leafW, visibleHeight, DOOR_LEAF_COLLIDER_DEPTH)
136
262
  const visibleCenterY = leafCenterY - leafH / 2 + visibleHeight / 2
137
263
  geometry.applyMatrix4(
138
264
  root.matrixWorld.clone().multiply(new THREE.Matrix4().makeTranslation(0, visibleCenterY, 0)),
@@ -153,15 +279,7 @@ function createDoorLeafColliderGeometry(root: THREE.Object3D, node: DoorNode) {
153
279
  const clampedSwingAngle = Math.max(0, Math.min(Math.PI / 2, swingAngle ?? 0))
154
280
  const leafSwingRotation = clampedSwingAngle * swingDirectionSign * hingeDirectionSign
155
281
 
156
- const sourceGeometry = new THREE.BoxGeometry(
157
- leafW,
158
- leafH,
159
- DOOR_LEAF_COLLIDER_DEPTH,
160
- ).toNonIndexed()
161
- const geometry = new THREE.BufferGeometry()
162
- geometry.setAttribute('position', sourceGeometry.getAttribute('position').clone())
163
- geometry.setAttribute('normal', sourceGeometry.getAttribute('normal').clone())
164
- sourceGeometry.dispose()
282
+ const geometry = createBoxColliderGeometry(leafW, leafH, DOOR_LEAF_COLLIDER_DEPTH)
165
283
  const matrix = root.matrixWorld
166
284
  .clone()
167
285
  .multiply(new THREE.Matrix4().makeTranslation(hingeX, 0, 0))
@@ -172,16 +290,17 @@ function createDoorLeafColliderGeometry(root: THREE.Object3D, node: DoorNode) {
172
290
  return geometry
173
291
  }
174
292
 
175
- function buildRegisteredNodeTypeLookup() {
176
- const nodeTypes = new Map<string, ColliderNodeType>()
293
+ function buildRegisteredColliderNodeIds(nodes: SceneNodes) {
294
+ const nodeIds = new Set<string>()
177
295
 
178
- for (const type of COLLIDER_NODE_TYPES) {
179
- for (const nodeId of sceneRegistry.byType[type]) {
180
- nodeTypes.set(nodeId, type)
181
- }
296
+ for (const nodeId of sceneRegistry.nodes.keys()) {
297
+ const node = nodes[nodeId as AnyNodeId]
298
+ if (!node || !isGenericColliderNode(node)) continue
299
+ if (shouldSkipColliderNode(node)) continue
300
+ nodeIds.add(nodeId)
182
301
  }
183
302
 
184
- return nodeTypes
303
+ return nodeIds
185
304
  }
186
305
 
187
306
  function collectColliderGeometriesFromNode(
@@ -189,7 +308,7 @@ function collectColliderGeometriesFromNode(
189
308
  rootNodeId: string,
190
309
  visitedMeshes: WeakSet<THREE.Object3D>,
191
310
  registeredObjectIds: Map<THREE.Object3D, string>,
192
- registeredNodeTypes: Map<string, ColliderNodeType>,
311
+ registeredColliderNodeIds: Set<string>,
193
312
  ): THREE.BufferGeometry[] {
194
313
  const geometries: THREE.BufferGeometry[] = []
195
314
 
@@ -211,11 +330,8 @@ function collectColliderGeometriesFromNode(
211
330
 
212
331
  for (const child of object.children) {
213
332
  const childNodeId = registeredObjectIds.get(child)
214
- if (childNodeId && childNodeId !== rootNodeId) {
215
- const childType = registeredNodeTypes.get(childNodeId)
216
- if (childType && COLLIDER_NODE_TYPES.includes(childType)) {
217
- continue
218
- }
333
+ if (childNodeId && childNodeId !== rootNodeId && registeredColliderNodeIds.has(childNodeId)) {
334
+ continue
219
335
  }
220
336
 
221
337
  visit(child)
@@ -228,52 +344,54 @@ function collectColliderGeometriesFromNode(
228
344
  }
229
345
 
230
346
  export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonColliderWorld | null {
347
+ const nodes = useScene.getState().nodes
231
348
  const geometries: THREE.BufferGeometry[] = []
232
349
  const visitedMeshes = new WeakSet<THREE.Object3D>()
233
- const registeredNodeTypes = buildRegisteredNodeTypeLookup()
350
+ const registeredColliderNodeIds = buildRegisteredColliderNodeIds(nodes)
234
351
  const registeredObjectIds = new Map<THREE.Object3D, string>()
235
352
 
236
353
  for (const [nodeId, object] of sceneRegistry.nodes) {
237
354
  registeredObjectIds.set(object, nodeId)
238
355
  }
239
356
 
240
- for (const type of COLLIDER_NODE_TYPES) {
241
- for (const nodeId of sceneRegistry.byType[type]) {
242
- if (shouldSkipColliderNode(nodeId, type)) continue
243
-
244
- const root = sceneRegistry.nodes.get(nodeId)
245
- if (!root) continue
357
+ for (const nodeId of registeredColliderNodeIds) {
358
+ const node = nodes[nodeId as AnyNodeId]
359
+ if (!node) continue
246
360
 
247
- if (type === 'door') {
248
- const node = useScene.getState().nodes[nodeId as AnyNodeId]
249
- if (node?.type !== 'door') continue
361
+ const root = sceneRegistry.nodes.get(nodeId)
362
+ if (!root) continue
250
363
 
251
- const doorGeometry = createDoorLeafColliderGeometry(root, node)
252
- if (doorGeometry) {
253
- geometries.push(doorGeometry)
254
- }
255
- continue
364
+ if (node.type === 'door') {
365
+ const doorGeometry = createDoorLeafColliderGeometry(root, node)
366
+ if (doorGeometry) {
367
+ geometries.push(doorGeometry)
256
368
  }
257
-
258
- root.updateMatrixWorld(true)
259
- geometries.push(
260
- ...collectColliderGeometriesFromNode(
261
- root,
262
- nodeId,
263
- visitedMeshes,
264
- registeredObjectIds,
265
- registeredNodeTypes,
266
- ),
267
- )
369
+ continue
268
370
  }
371
+
372
+ root.updateMatrixWorld(true)
373
+ geometries.push(
374
+ ...collectColliderGeometriesFromNode(
375
+ root,
376
+ nodeId,
377
+ visitedMeshes,
378
+ registeredObjectIds,
379
+ registeredColliderNodeIds,
380
+ ),
381
+ )
269
382
  }
270
383
 
384
+ geometries.push(...collectLevelFallbackFloorGeometries(nodes))
385
+ geometries.push(...collectSiteGroundColliderGeometries(nodes))
386
+
271
387
  if (geometries.length === 0) {
272
388
  return null
273
389
  }
274
390
 
275
391
  const mergedGeometry = mergeGeometries(geometries, false)
276
- geometries.forEach((geometry) => geometry.dispose())
392
+ for (const geometry of geometries) {
393
+ geometry.dispose()
394
+ }
277
395
 
278
396
  if (!mergedGeometry || mergedGeometry.getAttribute('position') == null) {
279
397
  mergedGeometry?.dispose()
@@ -288,7 +406,7 @@ export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonCollider
288
406
  ;(bvhGeometry as any).computeBoundsTree = computeBoundsTree
289
407
  ;(bvhGeometry as any).disposeBoundsTree = disposeBoundsTree
290
408
  bvhGeometry.computeBoundsTree?.({
291
- maxLeafTris: 12,
409
+ maxLeafSize: 12,
292
410
  strategy: 0,
293
411
  } as never)
294
412
  bvhGeometry.computeBoundingBox()
@@ -673,7 +673,7 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
673
673
  rotationDeltaPos.current.copy(totalPlatformDeltaPos.current)
674
674
  characterGroupRef.current.position.add(rotationDeltaPos.current)
675
675
  yawQuaternion.current.setFromUnitVectors(upAxis.current, floatHitNormal.current)
676
- }, [upAxis])
676
+ }, [])
677
677
 
678
678
  const updateCharacterAnimation = useCallback(
679
679
  (run: boolean, jump: boolean): CharacterAnimationStatus => {
@@ -756,7 +756,7 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
756
756
  moveDirRef.current?.position.copy(characterSegment.current.end)
757
757
  moveDirRef.current?.setDirection(currentLinVel.current)
758
758
  moveDirRef.current?.setLength(currentLinVel.current.length() / maxWalkSpeed)
759
- }, [characterSegment, maxWalkSpeed])
759
+ }, [maxWalkSpeed])
760
760
 
761
761
  useFrame((_, delta) => {
762
762
  elapsedRef.current += delta
@@ -764,12 +764,12 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
764
764
 
765
765
  const deltaTime = Math.min(1 / 45, delta) * slowMotionFactor
766
766
  const keys = getKeys() ?? presetKeys
767
- const forward = forwardState.current || keys.forward
768
- const backward = backwardState.current || keys.backward
769
- const leftward = leftwardState.current || keys.leftward
770
- const rightward = rightwardState.current || keys.rightward
771
- const run = runState.current || keys.run
772
- const jump = jumpState.current || keys.jump
767
+ const forward = forwardState.current || (keys.forward ?? false)
768
+ const backward = backwardState.current || (keys.backward ?? false)
769
+ const leftward = leftwardState.current || (keys.leftward ?? false)
770
+ const rightward = rightwardState.current || (keys.rightward ?? false)
771
+ const run = runState.current || (keys.run ?? false)
772
+ const jump = jumpState.current || (keys.jump ?? false)
773
773
 
774
774
  setInputDirection({
775
775
  forward,