@pascal-app/editor 0.9.2 → 1.0.0-beta.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 (203) hide show
  1. package/package.json +9 -7
  2. package/src/components/editor/bake-exporter.tsx +3 -1
  3. package/src/components/editor/bake-thumbnail.tsx +95 -0
  4. package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
  5. package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
  6. package/src/components/editor/custom-camera-controls.tsx +307 -212
  7. package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
  8. package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +66 -2
  10. package/src/components/editor/first-person/build-collider-world.ts +20 -0
  11. package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
  12. package/src/components/editor/first-person/terrain-collider.ts +212 -0
  13. package/src/components/editor/first-person-controls.tsx +295 -132
  14. package/src/components/editor/floating-action-menu.tsx +59 -65
  15. package/src/components/editor/floorplan-background-selection.test.ts +16 -2
  16. package/src/components/editor/floorplan-background-selection.ts +4 -18
  17. package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
  18. package/src/components/editor/floorplan-camera-sync.ts +213 -0
  19. package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
  20. package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
  21. package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
  22. package/src/components/editor/floorplan-panel.tsx +931 -861
  23. package/src/components/editor/group-actions.ts +155 -15
  24. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  25. package/src/components/editor/group-rotate-handle.tsx +1 -1
  26. package/src/components/editor/group-selection-box-3d.tsx +1 -1
  27. package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
  28. package/src/components/editor/handles/handle-drag-history.ts +14 -0
  29. package/src/components/editor/handles/preview-overrides.test.ts +26 -0
  30. package/src/components/editor/handles/preview-overrides.ts +13 -0
  31. package/src/components/editor/handles/resize-snap.test.ts +52 -0
  32. package/src/components/editor/handles/resize-snap.ts +23 -0
  33. package/src/components/editor/handles/use-handle-drag.ts +17 -7
  34. package/src/components/editor/index.tsx +42 -9
  35. package/src/components/editor/measurement-pill.tsx +6 -1
  36. package/src/components/editor/node-arrow-handles.tsx +51 -15
  37. package/src/components/editor/quick-measurement-card.tsx +9 -3
  38. package/src/components/editor/quick-measurement-hud.tsx +7 -1
  39. package/src/components/editor/selection-manager.tsx +3 -1
  40. package/src/components/editor/site-edge-labels.tsx +24 -2
  41. package/src/components/editor/snapshot-capture-overlay.tsx +4 -1
  42. package/src/components/editor/thumbnail-generator.tsx +66 -294
  43. package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
  44. package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
  45. package/src/components/editor/use-mesh-settle-epoch.ts +1 -0
  46. package/src/components/editor/wall-measurement-label.tsx +8 -16
  47. package/src/components/editor/wall-move-side-handles.tsx +193 -18
  48. package/src/components/editor/wall-opening-highlights.tsx +63 -32
  49. package/src/components/editor/wall-snap-beacon-layer.tsx +6 -4
  50. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
  51. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +39 -8
  52. package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
  53. package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
  54. package/src/components/editor-2d/floorplan-group-move.tsx +47 -4
  55. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +21 -5
  56. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
  57. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +93 -59
  58. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +27 -0
  59. package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
  60. package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
  61. package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
  62. package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
  63. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
  64. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
  65. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
  66. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
  67. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +75 -1
  68. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +92 -0
  69. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +93 -28
  70. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +279 -0
  71. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +621 -281
  72. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
  73. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +12 -7
  74. package/src/components/tools/fence/fence-drafting.ts +27 -0
  75. package/src/components/tools/item/use-draft-node.ts +131 -72
  76. package/src/components/tools/item/use-placement-coordinator.tsx +52 -4
  77. package/src/components/tools/registry/move-registry-node-tool.tsx +75 -4
  78. package/src/components/tools/roof/roof-tool.tsx +16 -0
  79. package/src/components/tools/shared/floor-stack-preview.ts +4 -0
  80. package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
  81. package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
  82. package/src/components/tools/shared/pointer-support-cap.ts +273 -0
  83. package/src/components/tools/site/site-boundary-editor.tsx +13 -6
  84. package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
  85. package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
  86. package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
  87. package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
  88. package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
  89. package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
  90. package/src/components/tools/stair/stair-click-guard.ts +72 -0
  91. package/src/components/tools/stair/stair-tool.tsx +98 -47
  92. package/src/components/tools/tool-manager.tsx +67 -9
  93. package/src/components/tools/wall/wall-drafting.test.ts +118 -1
  94. package/src/components/tools/wall/wall-drafting.ts +128 -9
  95. package/src/components/tools/wall/wall-snap-geometry.test.ts +8 -0
  96. package/src/components/tools/wall/wall-snap-geometry.ts +70 -3
  97. package/src/components/tools/zone/zone-tool.tsx +16 -0
  98. package/src/components/ui/action-menu/control-modes.tsx +4 -1
  99. package/src/components/ui/action-menu/measurement-control.tsx +107 -28
  100. package/src/components/ui/command-palette/editor-commands.tsx +14 -1
  101. package/src/components/ui/controls/material-paint-panel.tsx +7 -1
  102. package/src/components/ui/controls/material-picker.tsx +86 -7
  103. package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
  104. package/src/components/ui/floating-level-selector.tsx +73 -19
  105. package/src/components/ui/helpers/helper-manager.tsx +46 -0
  106. package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
  107. package/src/components/ui/panels/panel-manager.tsx +14 -1
  108. package/src/components/ui/panels/parametric-inspector.tsx +4 -2
  109. package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
  110. package/src/components/ui/panels/selection-breakdown.ts +20 -0
  111. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +14 -4
  112. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +16 -0
  113. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
  114. package/src/components/ui/sidebar/panels/site-panel/index.tsx +2 -0
  115. package/src/components/viewer/viewer-controls-bar.tsx +466 -0
  116. package/src/components/viewer/viewer-scene-header.tsx +235 -0
  117. package/src/components/viewer-overlay.tsx +20 -655
  118. package/src/components/walkthrough-hud.tsx +111 -0
  119. package/src/hooks/use-grid-events.ts +24 -0
  120. package/src/hooks/use-keyboard.ts +53 -30
  121. package/src/index.tsx +137 -4
  122. package/src/lib/active-placement-surface.test.ts +24 -0
  123. package/src/lib/active-placement-surface.ts +8 -1
  124. package/src/lib/camera-pose.test.ts +237 -0
  125. package/src/lib/camera-pose.ts +172 -0
  126. package/src/lib/direct-manipulation.test.ts +83 -0
  127. package/src/lib/direct-manipulation.ts +9 -0
  128. package/src/lib/door-interaction.ts +1 -1
  129. package/src/lib/elevation-guides.test.ts +107 -0
  130. package/src/lib/elevation-guides.ts +265 -0
  131. package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
  132. package/src/lib/floorplan/annotation-visibility.ts +133 -0
  133. package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
  134. package/src/lib/floorplan/drawing-coordination.ts +13 -0
  135. package/src/lib/floorplan/floorplan-export.test.ts +319 -0
  136. package/src/lib/floorplan/floorplan-export.tsx +745 -131
  137. package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
  138. package/src/lib/floorplan/floorplan-extension.ts +181 -0
  139. package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
  140. package/src/lib/floorplan/floorplan-mode.ts +73 -0
  141. package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
  142. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
  143. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
  144. package/src/lib/floorplan/geometry.ts +2 -3
  145. package/src/lib/fresh-planar-placement.test.ts +44 -1
  146. package/src/lib/fresh-planar-placement.ts +21 -4
  147. package/src/lib/glb-export.test.ts +116 -1
  148. package/src/lib/glb-export.ts +204 -7
  149. package/src/lib/ground-surface.ts +95 -0
  150. package/src/lib/history.test.ts +96 -0
  151. package/src/lib/history.ts +22 -0
  152. package/src/lib/interaction/overlay-policy.test.ts +2 -1
  153. package/src/lib/interaction/scope.ts +40 -8
  154. package/src/lib/level-duplication.test.ts +2 -1
  155. package/src/lib/measurements.test.ts +5 -0
  156. package/src/lib/measurements.ts +12 -1
  157. package/src/lib/quick-action-feedback.ts +33 -0
  158. package/src/lib/quick-action-nodes.test.ts +94 -0
  159. package/src/lib/quick-action-nodes.ts +47 -0
  160. package/src/lib/scene-clipboard.test.ts +130 -1
  161. package/src/lib/scene-clipboard.ts +237 -13
  162. package/src/lib/selection-routing.test.ts +16 -1
  163. package/src/lib/selection-routing.ts +5 -0
  164. package/src/lib/sfx-bus.ts +33 -2
  165. package/src/lib/sfx-player.test.ts +148 -6
  166. package/src/lib/sfx-player.ts +156 -8
  167. package/src/lib/site-boundary.test.ts +53 -0
  168. package/src/lib/site-boundary.ts +27 -0
  169. package/src/lib/snapping-mode.test.ts +29 -2
  170. package/src/lib/snapping-mode.ts +15 -3
  171. package/src/lib/stair-duplication.test.ts +103 -0
  172. package/src/lib/stair-duplication.ts +57 -85
  173. package/src/lib/stair-levels.ts +2 -0
  174. package/src/lib/terrain-sculpt.test.ts +429 -0
  175. package/src/lib/terrain-sculpt.ts +301 -0
  176. package/src/lib/terrain-verb-color.test.ts +51 -0
  177. package/src/lib/terrain-verb-color.ts +58 -0
  178. package/src/lib/touch-gesture-priority.test.ts +45 -0
  179. package/src/lib/touch-gesture-priority.ts +30 -0
  180. package/src/lib/window-interaction.ts +1 -1
  181. package/src/store/camera-pose-store.test.ts +29 -0
  182. package/src/store/camera-pose-store.ts +27 -0
  183. package/src/store/live-draft-preview-stores.test.ts +136 -0
  184. package/src/store/navigation-sync-pose-store.test.ts +34 -0
  185. package/src/store/navigation-sync-pose-store.ts +27 -0
  186. package/src/store/terrain-sculpt-mode.test.ts +223 -0
  187. package/src/store/use-delete-confirmation.ts +27 -0
  188. package/src/store/use-drawing-view.test.ts +23 -0
  189. package/src/store/use-drawing-view.ts +84 -0
  190. package/src/store/use-editor.tsx +184 -15
  191. package/src/store/use-elevation-guides.ts +40 -0
  192. package/src/store/use-fence-curve-draft.ts +38 -8
  193. package/src/store/use-first-person-hud.ts +40 -0
  194. package/src/store/use-floorplan-annotation-visibility.ts +60 -0
  195. package/src/store/use-floorplan-draft-preview.ts +54 -4
  196. package/src/store/use-floorplan-mode.ts +110 -0
  197. package/src/store/use-interaction-scope.test.ts +23 -3
  198. package/src/store/use-interaction-scope.ts +8 -0
  199. package/src/store/use-measurement-draft.test.ts +29 -0
  200. package/src/store/use-measurement-draft.ts +33 -6
  201. package/src/store/use-path-draft-preview.ts +90 -0
  202. package/src/store/use-stair-build-preview.ts +12 -2
  203. package/src/lib/floorplan/selection-tool.ts +0 -271
@@ -0,0 +1,54 @@
1
+ 'use client'
2
+
3
+ import { useEffect } from 'react'
4
+ import useDeleteConfirmation from '../../store/use-delete-confirmation'
5
+ import {
6
+ Dialog,
7
+ DialogContent,
8
+ DialogDescription,
9
+ DialogFooter,
10
+ DialogHeader,
11
+ DialogTitle,
12
+ } from '../ui/primitives/dialog'
13
+
14
+ export function DeleteConfirmationDialog() {
15
+ const request = useDeleteConfirmation((state) => state.request)
16
+ const cancel = useDeleteConfirmation((state) => state.cancel)
17
+ const confirm = useDeleteConfirmation((state) => state.confirm)
18
+
19
+ useEffect(() => cancel, [cancel])
20
+
21
+ return (
22
+ <Dialog onOpenChange={(open) => !open && cancel()} open={request !== null}>
23
+ <DialogContent
24
+ className="border-border/70 bg-background/95 shadow-2xl backdrop-blur-xl sm:max-w-md"
25
+ data-delete-confirmation-dialog
26
+ showCloseButton={false}
27
+ >
28
+ <DialogHeader>
29
+ <DialogTitle>Delete {request?.count ?? 0} elements?</DialogTitle>
30
+ <DialogDescription>
31
+ This removes every selected element. You can undo the deletion while it remains in the
32
+ editor history.
33
+ </DialogDescription>
34
+ </DialogHeader>
35
+ <DialogFooter>
36
+ <button
37
+ className="rounded-full border border-border px-4 py-2 text-sm transition-colors hover:bg-accent"
38
+ onClick={cancel}
39
+ type="button"
40
+ >
41
+ Cancel
42
+ </button>
43
+ <button
44
+ className="rounded-full bg-red-600 px-4 py-2 text-sm text-white transition-colors hover:bg-red-700"
45
+ onClick={confirm}
46
+ type="button"
47
+ >
48
+ Delete
49
+ </button>
50
+ </DialogFooter>
51
+ </DialogContent>
52
+ </Dialog>
53
+ )
54
+ }
@@ -0,0 +1,120 @@
1
+ 'use client'
2
+
3
+ import { sceneRegistry } from '@pascal-app/core'
4
+ import { useViewer } from '@pascal-app/viewer'
5
+ import { Html } from '@react-three/drei'
6
+ import { useFrame } from '@react-three/fiber'
7
+ import { memo, useMemo, useRef } from 'react'
8
+ import { BoxGeometry, CircleGeometry, type Group } from 'three'
9
+ import { MeshBasicNodeMaterial } from 'three/webgpu'
10
+ import { EDITOR_LAYER } from '../../lib/constants'
11
+ import useElevationGuides from '../../store/use-elevation-guides'
12
+ import { formatMeasurement } from './measurement-pill'
13
+
14
+ const LINE_COLOR = 0x81_8c_f8
15
+ const PILL_COLOR = '#6366f1'
16
+ const GUIDE_LIFT = 0.025
17
+ const GUIDE_LENGTH = 2.4
18
+ const DASH_LENGTH = 0.16
19
+ const DASH_GAP = 0.1
20
+ const LINE_WIDTH = 0.035
21
+ const DOT_RADIUS = 0.07
22
+
23
+ const guideMaterial = new MeshBasicNodeMaterial({
24
+ color: LINE_COLOR,
25
+ depthTest: false,
26
+ depthWrite: false,
27
+ toneMapped: false,
28
+ transparent: true,
29
+ opacity: 0.9,
30
+ })
31
+ const dotMaterial = new MeshBasicNodeMaterial({
32
+ color: LINE_COLOR,
33
+ depthTest: false,
34
+ depthWrite: false,
35
+ toneMapped: false,
36
+ transparent: true,
37
+ opacity: 0.95,
38
+ })
39
+ const DASH_GEOMETRY = new BoxGeometry(1, 1, 1)
40
+ const DOT_GEOMETRY = new CircleGeometry(1, 24)
41
+
42
+ type Vec3 = [number, number, number]
43
+
44
+ export const Elevation3DGuideLayer = memo(function Elevation3DGuideLayer() {
45
+ const guide = useElevationGuides((state) => state.guide)
46
+ const unit = useViewer((state) => state.unit)
47
+ const groupRef = useRef<Group>(null)
48
+
49
+ useFrame(() => {
50
+ const group = groupRef.current
51
+ if (!(group && guide)) return
52
+ const levelObject = sceneRegistry.nodes.get(guide.levelId)
53
+ group.position.y = (levelObject?.position.y ?? 0) + guide.elevation + GUIDE_LIFT
54
+ })
55
+
56
+ const dashes = useMemo(() => {
57
+ if (!guide) return [] as Vec3[]
58
+ const [cx, cz] = guide.center
59
+ const [dx, dz] = guide.direction
60
+ const start = -GUIDE_LENGTH / 2
61
+ const period = DASH_LENGTH + DASH_GAP
62
+ const centers: Vec3[] = []
63
+ for (let offset = start + DASH_LENGTH / 2; offset < GUIDE_LENGTH / 2; offset += period) {
64
+ centers.push([cx + dx * offset, 0, cz + dz * offset])
65
+ }
66
+ return centers
67
+ }, [guide])
68
+
69
+ if (!guide) return null
70
+
71
+ const angleY = -Math.atan2(guide.direction[1], guide.direction[0])
72
+ const labelPosition: Vec3 = [
73
+ guide.center[0] - guide.direction[1] * 0.65,
74
+ 0.16,
75
+ guide.center[1] + guide.direction[0] * 0.65,
76
+ ]
77
+ const elevationText =
78
+ Math.abs(guide.elevation) < 1e-6
79
+ ? formatMeasurement(0, unit)
80
+ : `${guide.elevation < 0 ? '-' : '+'}${formatMeasurement(Math.abs(guide.elevation), unit)}`
81
+
82
+ return (
83
+ <group ref={groupRef}>
84
+ {dashes.map((position, index) => (
85
+ <mesh
86
+ geometry={DASH_GEOMETRY}
87
+ key={index}
88
+ layers={EDITOR_LAYER}
89
+ material={guideMaterial}
90
+ position={position}
91
+ renderOrder={1010}
92
+ rotation={[0, angleY, 0]}
93
+ scale={[DASH_LENGTH, 0.002, LINE_WIDTH]}
94
+ />
95
+ ))}
96
+ <mesh
97
+ geometry={DOT_GEOMETRY}
98
+ layers={EDITOR_LAYER}
99
+ material={dotMaterial}
100
+ position={[guide.center[0], 0.002, guide.center[1]]}
101
+ renderOrder={1011}
102
+ rotation={[-Math.PI / 2, 0, 0]}
103
+ scale={[DOT_RADIUS, DOT_RADIUS, DOT_RADIUS]}
104
+ />
105
+ <Html
106
+ center
107
+ position={labelPosition}
108
+ style={{ pointerEvents: 'none', userSelect: 'none' }}
109
+ zIndexRange={[25, 0]}
110
+ >
111
+ <div
112
+ className="whitespace-nowrap rounded-[3px] px-[5px] py-[2px] font-medium font-sans text-[11px] text-white"
113
+ style={{ backgroundColor: PILL_COLOR }}
114
+ >
115
+ {`${guide.label} · Y ${elevationText}`}
116
+ </div>
117
+ </Html>
118
+ </group>
119
+ )
120
+ })
@@ -2,9 +2,14 @@ import { afterEach, describe, expect, test } from 'bun:test'
2
2
  import {
3
3
  type AnyNode,
4
4
  type AnyNodeDefinition,
5
+ applyHeightPatch,
6
+ BuildingNode,
5
7
  CeilingNode,
6
8
  ColumnNode,
9
+ createTerrainField,
7
10
  ElevatorNode,
11
+ encodeTerrainField,
12
+ flattenPatch,
8
13
  LevelNode,
9
14
  nodeRegistry,
10
15
  registerNode,
@@ -13,7 +18,7 @@ import {
13
18
  sceneRegistry,
14
19
  useScene,
15
20
  } from '@pascal-app/core'
16
- import { BoxGeometry, Group, Mesh, MeshBasicMaterial } from 'three'
21
+ import { BoxGeometry, Group, Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three'
17
22
  import { buildFirstPersonColliderWorldFromRegistry } from './build-collider-world'
18
23
 
19
24
  function registerColliderDefinition(
@@ -46,8 +51,9 @@ function mountNode(
46
51
  sceneRegistry.byType[node.type]!.add(node.id)
47
52
  }
48
53
 
49
- function mountRegistryGroup(node: AnyNode) {
54
+ function mountRegistryGroup(node: AnyNode, position: [number, number, number] = [0, 0, 0]) {
50
55
  const group = new Group()
56
+ group.position.set(position[0], position[1], position[2])
51
57
  group.updateMatrixWorld(true)
52
58
  sceneRegistry.nodes.set(node.id, group)
53
59
  sceneRegistry.byType[node.type]!.add(node.id)
@@ -160,6 +166,34 @@ describe('buildFirstPersonColliderWorldFromRegistry', () => {
160
166
  world?.dispose()
161
167
  })
162
168
 
169
+ test('adds a fallback floor only for the lowest slab-less level in a building', () => {
170
+ const building = BuildingNode.parse({
171
+ id: 'building_test',
172
+ children: ['level_ground', 'level_upper'],
173
+ })
174
+ const groundLevel = LevelNode.parse({
175
+ id: 'level_ground',
176
+ parentId: building.id,
177
+ level: 0,
178
+ height: 3,
179
+ })
180
+ const upperLevel = LevelNode.parse({
181
+ id: 'level_upper',
182
+ parentId: building.id,
183
+ level: 1,
184
+ })
185
+ setSceneNodes([building, groundLevel, upperLevel])
186
+ mountRegistryGroup(groundLevel)
187
+ mountRegistryGroup(upperLevel, [0, 3, 0])
188
+
189
+ const world = buildFirstPersonColliderWorldFromRegistry()
190
+
191
+ expect(world).not.toBeNull()
192
+ expect(world?.bounds?.min.y).toBeCloseTo(-0.08)
193
+ expect(world?.bounds?.max.y).toBeCloseTo(0)
194
+ world?.dispose()
195
+ })
196
+
163
197
  test('adds a site ground collider so a spawn on bare ground has a floor', () => {
164
198
  const site = SiteNode.parse({ id: 'site_test' })
165
199
  setSceneNodes([site])
@@ -179,4 +213,34 @@ describe('buildFirstPersonColliderWorldFromRegistry', () => {
179
213
  expect(world?.bounds?.max.z).toBeCloseTo(1000)
180
214
  world?.dispose()
181
215
  })
216
+
217
+ test('a sculpted site walks on its terrain instead of the flat ground slab', () => {
218
+ const base = createTerrainField({ cols: 17, rows: 17, spacing: 1, origin: [-8, -8] })
219
+ const patch = flattenPatch(base, { minX: 2, minZ: 2, maxX: 5, maxZ: 5 }, 2.5)
220
+ const field = applyHeightPatch(base, patch as never)
221
+ const site = SiteNode.parse({ id: 'site_test', terrain: encodeTerrainField(field) })
222
+ setSceneNodes([site])
223
+ mountRegistryGroup(site)
224
+
225
+ const world = buildFirstPersonColliderWorldFromRegistry()
226
+ expect(world).not.toBeNull()
227
+ if (!world) return
228
+
229
+ // The hill is in the collider: a flat slab would top out at 0.
230
+ expect(world.bounds?.max.y).toBeCloseTo(2.5)
231
+ // And it still reaches past the site so stepping out does not drop the player.
232
+ expect(world.bounds?.min.x).toBeCloseTo(-1008)
233
+ expect(world.bounds?.max.x).toBeCloseTo(1008)
234
+
235
+ // What the player actually stands on, on the plateau and off it.
236
+ const raycaster = new Raycaster()
237
+ const standOn = (x: number, z: number) => {
238
+ raycaster.set(new Vector3(x, 500, z), new Vector3(0, -1, 0))
239
+ return raycaster.intersectObject(world.mesh, false)[0]?.point.y ?? null
240
+ }
241
+ expect(standOn(3, 3)).toBeCloseTo(2.5, 4)
242
+ expect(standOn(-3, -3)).toBeCloseTo(0, 4)
243
+
244
+ world.dispose()
245
+ })
182
246
  })
@@ -3,9 +3,11 @@ import {
3
3
  type AnyNodeId,
4
4
  type DoorNode,
5
5
  getGarageVisibleOpeningRatio,
6
+ getLevelElevations,
6
7
  isOperationDoorType,
7
8
  nodeRegistry,
8
9
  sceneRegistry,
10
+ terrainFieldOf,
9
11
  useInteractive,
10
12
  useScene,
11
13
  } from '@pascal-app/core'
@@ -13,6 +15,7 @@ import * as THREE from 'three'
13
15
  import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
14
16
  import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh'
15
17
  import { computeSceneBoundsXZ } from '../../../lib/scene-bounds'
18
+ import { createTerrainColliderGeometry } from './terrain-collider'
16
19
 
17
20
  const SKIPPED_MESH_NAMES = new Set(['cutout', 'collision-mesh'])
18
21
  const COLLIDER_NODE_CATEGORIES = new Set(['structure', 'furnish'])
@@ -129,10 +132,12 @@ function createLevelFallbackFloorGeometry(level: LevelNode, nodes: SceneNodes) {
129
132
 
130
133
  function collectLevelFallbackFloorGeometries(nodes: SceneNodes) {
131
134
  const geometries: THREE.BufferGeometry[] = []
135
+ const levelElevations = getLevelElevations(nodes)
132
136
 
133
137
  for (const levelId of sceneRegistry.byType.level!) {
134
138
  const node = nodes[levelId as AnyNodeId]
135
139
  if (node?.type !== 'level') continue
140
+ if (levelElevations.get(node.id)?.baseY !== 0) continue
136
141
 
137
142
  const geometry = createLevelFallbackFloorGeometry(node, nodes)
138
143
  if (geometry) geometries.push(geometry)
@@ -150,12 +155,27 @@ function collectLevelFallbackFloorGeometries(nodes: SceneNodes) {
150
155
  // is effectively unbounded (not sized to the site polygon): the ground plane must
151
156
  // keep holding the player up even after they step past the site boundary,
152
157
  // otherwise they fall below the ground plane into the void.
158
+ //
159
+ // A sculpted site takes the terrain path instead: the flat slab would hold the
160
+ // player at the datum inside an excavation and bury them inside a hill. Derived
161
+ // from the field rather than the rendered terrain mesh for the same
162
+ // mount-timing reason as the flat slab.
153
163
  function createSiteGroundColliderGeometry(site: SiteNode, nodes: SceneNodes) {
154
164
  if (site.visible === false) return null
155
165
 
156
166
  const siteObject = sceneRegistry.nodes.get(site.id)
157
167
  if (!siteObject?.visible) return null
158
168
 
169
+ const field = terrainFieldOf(site)
170
+ if (field) {
171
+ const terrainGeometry = createTerrainColliderGeometry(field)
172
+ if (terrainGeometry) {
173
+ siteObject.updateWorldMatrix(true, false)
174
+ terrainGeometry.applyMatrix4(siteObject.matrixWorld)
175
+ return terrainGeometry
176
+ }
177
+ }
178
+
159
179
  const bounds = computeSceneBoundsXZ(nodes)
160
180
  const [centerX, centerZ] = bounds?.center ?? [0, 0]
161
181
  const [boundsWidth, boundsDepth] = bounds?.size ?? [0, 0]
@@ -0,0 +1,132 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ applyHeightPatch,
4
+ createTerrainField,
5
+ flattenPatch,
6
+ heightAt,
7
+ type TerrainField,
8
+ } from '@pascal-app/core'
9
+ import { Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three'
10
+ import { createTerrainColliderGeometry } from './terrain-collider'
11
+
12
+ const DOWN = new Vector3(0, -1, 0)
13
+ const UP = new Vector3(0, 1, 0)
14
+
15
+ /** A 2.5 m plateau over x,z ∈ [2,5] on an otherwise flat field. */
16
+ function plateauField(): TerrainField {
17
+ const base = createTerrainField({ cols: 17, rows: 17, spacing: 1, origin: [-8, -8] })
18
+ const patch = flattenPatch(base, { minX: 2, minZ: 2, maxX: 5, maxZ: 5 }, 2.5)
19
+ return applyHeightPatch(base, patch as never)
20
+ }
21
+
22
+ /** A field sloping 0.1 m per metre along +x, so every sample differs. */
23
+ function slopedField(): TerrainField {
24
+ const field = createTerrainField({ cols: 9, rows: 9, spacing: 1, origin: [-4, -4] })
25
+ const heights = new Int16Array(field.heights)
26
+ for (let r = 0; r < field.rows; r++) {
27
+ for (let c = 0; c < field.cols; c++) {
28
+ heights[r * field.cols + c] = Math.round((c * 0.1) /* m */ / field.step)
29
+ }
30
+ }
31
+ return { ...field, heights }
32
+ }
33
+
34
+ function colliderMesh(field: TerrainField) {
35
+ const geometry = createTerrainColliderGeometry(field)
36
+ if (!geometry) throw new Error('no collider geometry')
37
+ const mesh = new Mesh(geometry, new MeshBasicMaterial())
38
+ mesh.updateMatrixWorld(true)
39
+ return mesh
40
+ }
41
+
42
+ /** The height a downward ray finds — what the walkthrough player stands on. */
43
+ function groundUnder(mesh: Mesh, x: number, z: number): number | null {
44
+ const raycaster = new Raycaster()
45
+ raycaster.set(new Vector3(x, 500, z), DOWN)
46
+ const hits = raycaster.intersectObject(mesh, false)
47
+ const floor = hits.find((hit) => {
48
+ if (!hit.face) return true
49
+ return hit.face.normal.clone().transformDirection(mesh.matrixWorld).dot(UP) > 0.2
50
+ })
51
+ return floor ? floor.point.y : null
52
+ }
53
+
54
+ describe('createTerrainColliderGeometry', () => {
55
+ test('the walkable surface is the field, not a plane', () => {
56
+ const field = plateauField()
57
+ const mesh = colliderMesh(field)
58
+
59
+ // On the plateau, off it, and on the slope between: the collider has to agree
60
+ // with `heightAt` at each, or what you see is not what you stand on.
61
+ for (const [x, z] of [
62
+ [3, 3],
63
+ [-3, -3],
64
+ [1.5, 3],
65
+ [4.5, 4.5],
66
+ ] as Array<[number, number]>) {
67
+ expect(groundUnder(mesh, x, z)).toBeCloseTo(heightAt(field, x, z), 4)
68
+ }
69
+ })
70
+
71
+ test('a sloped field is sampled per vertex, not averaged', () => {
72
+ const field = slopedField()
73
+ const mesh = colliderMesh(field)
74
+
75
+ // Sample midpoints, where a flat or decimated collider would disagree most.
76
+ for (const x of [-3.5, -0.5, 1.5, 3.5]) {
77
+ expect(groundUnder(mesh, x, 0)).toBeCloseTo(heightAt(field, x, 0), 4)
78
+ }
79
+ })
80
+
81
+ test('the skirt keeps holding the player far past the field', () => {
82
+ const field = slopedField()
83
+ const mesh = colliderMesh(field)
84
+
85
+ // Past every edge and past a corner: `heightAt` clamps to the border there, so
86
+ // the collider must too. Without the skirt these are null and the player falls.
87
+ for (const [x, z] of [
88
+ [400, 0],
89
+ [-400, 0],
90
+ [0, 400],
91
+ [0, -400],
92
+ [400, 400],
93
+ [-400, -400],
94
+ ] as Array<[number, number]>) {
95
+ expect(groundUnder(mesh, x, z)).toBeCloseTo(heightAt(field, x, z), 4)
96
+ }
97
+ })
98
+
99
+ test('every triangle carries a normal, and the surface faces up', () => {
100
+ const geometry = createTerrainColliderGeometry(plateauField())
101
+ if (!geometry) throw new Error('no collider geometry')
102
+
103
+ const position = geometry.getAttribute('position')
104
+ const normal = geometry.getAttribute('normal')
105
+ expect(normal).toBeDefined()
106
+ expect(normal.count).toBe(position.count)
107
+ // Non-indexed triangles — `mergeGeometries` needs every contributor to match.
108
+ expect(geometry.index).toBeNull()
109
+ expect(position.count % 3).toBe(0)
110
+
111
+ for (let i = 0; i < normal.count; i++) {
112
+ expect(normal.getY(i)).toBeGreaterThan(0)
113
+ }
114
+ })
115
+
116
+ test('decimates rather than exploding on an absurd field', () => {
117
+ // 1025² is inside the schema's decode-bomb guard but 1M cells — an exact
118
+ // collider would be 2M triangles for a surface nobody can walk that far across.
119
+ const field = createTerrainField({ cols: 1025, rows: 1025, spacing: 0.5 })
120
+ const geometry = createTerrainColliderGeometry(field)
121
+ if (!geometry) throw new Error('no collider geometry')
122
+
123
+ const triangles = geometry.getAttribute('position').count / 3
124
+ expect(triangles).toBeLessThan(200_000)
125
+ // Still a real surface, not an empty one.
126
+ expect(triangles).toBeGreaterThan(1000)
127
+ })
128
+
129
+ test('returns null for a degenerate field', () => {
130
+ expect(createTerrainColliderGeometry(createTerrainField({ cols: 1, rows: 1 }))).toBeNull()
131
+ })
132
+ })
@@ -0,0 +1,212 @@
1
+ /**
2
+ * The sculpted ground as walkthrough collision geometry.
3
+ *
4
+ * The first-person collider world is a single merged BVH mesh, and before terrain
5
+ * the ground contributed one flat 2 km box to it. That box is wrong in both
6
+ * directions on a sculpted site: it holds the player up at the datum inside an
7
+ * excavation, and buries them inside a hill. This module replaces it.
8
+ *
9
+ * Two properties it has to hold, because the whole point is that walking agrees
10
+ * with every other consumer of the field:
11
+ *
12
+ * - **The surface is the field**, sampled at every vertex — the same
13
+ * `heightAtSample`/`normalAt` the rendered mesh uses (`terrain-geometry.ts`),
14
+ * so what you see is what you stand on. It is deliberately not a decimated
15
+ * proxy at realistic field sizes.
16
+ * - **It extends past the field**, because `heightAt` clamps to the border
17
+ * outside it. A collider that stopped at the field edge would drop the player
18
+ * into the void one step past the site, which is exactly the bug the flat box
19
+ * existed to prevent. The skirt extrudes each border sample outward at its own
20
+ * height, so the collider and `heightAt` agree everywhere, not just inside.
21
+ *
22
+ * Non-indexed triangles with position + normal, matching what the other collider
23
+ * contributors emit — `mergeGeometries` requires every input to agree on both.
24
+ */
25
+
26
+ import { heightAtSample, normalAt, type TerrainField } from '@pascal-app/core'
27
+ import * as THREE from 'three'
28
+
29
+ /**
30
+ * How far past the field the ground keeps holding the player, in metres. Matches
31
+ * the flat box's half-extent so a site's collider bounds do not change size when
32
+ * it gains terrain.
33
+ */
34
+ const SKIRT_REACH = 1000
35
+
36
+ /**
37
+ * Cell budget before the collider decimates.
38
+ *
39
+ * 66k cells is a 257² field — the largest anything realistic produces, and ~131k
40
+ * triangles, which the BVH builds in well under a frame's worth of budget. The
41
+ * schema allows up to 4097² (a decode-bomb guard, not a real terrain); at that
42
+ * size an exact collider would be 100M triangles, so past the budget this steps
43
+ * the sample grid and the collider becomes a faithful-but-coarser version of the
44
+ * same surface rather than an out-of-memory crash.
45
+ */
46
+ const MAX_COLLIDER_CELLS = 66_000
47
+
48
+ function colliderStride(field: TerrainField): number {
49
+ const cells = Math.max(1, (field.cols - 1) * (field.rows - 1))
50
+ if (cells <= MAX_COLLIDER_CELLS) return 1
51
+ return Math.ceil(Math.sqrt(cells / MAX_COLLIDER_CELLS))
52
+ }
53
+
54
+ /** The sample indices the collider walks: every `stride`-th, always including the border. */
55
+ function sampleLine(count: number, stride: number): number[] {
56
+ const line: number[] = []
57
+ for (let i = 0; i < count - 1; i += stride) line.push(i)
58
+ line.push(count - 1)
59
+ return line
60
+ }
61
+
62
+ type Vertex = { x: number; y: number; z: number }
63
+
64
+ class TriangleSink {
65
+ private readonly positions: number[] = []
66
+ private readonly normals: number[] = []
67
+
68
+ constructor(private readonly field: TerrainField) {}
69
+
70
+ private push(v: Vertex): void {
71
+ this.positions.push(v.x, v.y, v.z)
72
+ const [nx, ny, nz] = normalAt(this.field, v.x, v.z)
73
+ this.normals.push(nx, ny, nz)
74
+ }
75
+
76
+ /**
77
+ * One cell, given its four corners named by axis order. Winding matches
78
+ * `buildTerrainMesh`: `(p00, p01, p10)` and `(p10, p01, p11)` faces +Y, which is
79
+ * what the spawn resolver's `normal.dot(UP) > 0.2` floor test needs.
80
+ */
81
+ quad(p00: Vertex, p10: Vertex, p01: Vertex, p11: Vertex): void {
82
+ this.push(p00)
83
+ this.push(p01)
84
+ this.push(p10)
85
+ this.push(p10)
86
+ this.push(p01)
87
+ this.push(p11)
88
+ }
89
+
90
+ build(): THREE.BufferGeometry | null {
91
+ if (this.positions.length === 0) return null
92
+ const geometry = new THREE.BufferGeometry()
93
+ geometry.setAttribute(
94
+ 'position',
95
+ new THREE.BufferAttribute(new Float32Array(this.positions), 3),
96
+ )
97
+ geometry.setAttribute('normal', new THREE.BufferAttribute(new Float32Array(this.normals), 3))
98
+ return geometry
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Terrain collision geometry in the site's own frame — the caller applies the
104
+ * site's world matrix, exactly as it did for the flat box it replaces.
105
+ */
106
+ export function createTerrainColliderGeometry(field: TerrainField): THREE.BufferGeometry | null {
107
+ if (field.cols < 2 || field.rows < 2) return null
108
+
109
+ const stride = colliderStride(field)
110
+ const cols = sampleLine(field.cols, stride)
111
+ const rows = sampleLine(field.rows, stride)
112
+ const sink = new TriangleSink(field)
113
+
114
+ const xAt = (col: number) => field.origin[0] + col * field.spacing
115
+ const zAt = (row: number) => field.origin[1] + row * field.spacing
116
+ const at = (col: number, row: number): Vertex => ({
117
+ x: xAt(col),
118
+ y: heightAtSample(field, col, row),
119
+ z: zAt(row),
120
+ })
121
+
122
+ for (let ri = 0; ri < rows.length - 1; ri++) {
123
+ const r0 = rows[ri] as number
124
+ const r1 = rows[ri + 1] as number
125
+ for (let ci = 0; ci < cols.length - 1; ci++) {
126
+ const c0 = cols[ci] as number
127
+ const c1 = cols[ci + 1] as number
128
+ sink.quad(at(c0, r0), at(c1, r0), at(c0, r1), at(c1, r1))
129
+ }
130
+ }
131
+
132
+ // The skirt: each border sample extruded outward at its own height, so stepping
133
+ // off the field lands on the border height rather than on nothing. This is the
134
+ // collider spelling of `heightAt`'s edge clamp.
135
+ const firstCol = 0
136
+ const lastCol = field.cols - 1
137
+ const firstRow = 0
138
+ const lastRow = field.rows - 1
139
+ const westX = xAt(firstCol) - SKIRT_REACH
140
+ const eastX = xAt(lastCol) + SKIRT_REACH
141
+ const northZ = zAt(firstRow) - SKIRT_REACH
142
+ const southZ = zAt(lastRow) + SKIRT_REACH
143
+
144
+ for (let ci = 0; ci < cols.length - 1; ci++) {
145
+ const c0 = cols[ci] as number
146
+ const c1 = cols[ci + 1] as number
147
+ const north0 = at(c0, firstRow)
148
+ const north1 = at(c1, firstRow)
149
+ sink.quad(
150
+ { x: north0.x, y: north0.y, z: northZ },
151
+ { x: north1.x, y: north1.y, z: northZ },
152
+ north0,
153
+ north1,
154
+ )
155
+ const south0 = at(c0, lastRow)
156
+ const south1 = at(c1, lastRow)
157
+ sink.quad(
158
+ south0,
159
+ south1,
160
+ { x: south0.x, y: south0.y, z: southZ },
161
+ {
162
+ x: south1.x,
163
+ y: south1.y,
164
+ z: southZ,
165
+ },
166
+ )
167
+ }
168
+
169
+ for (let ri = 0; ri < rows.length - 1; ri++) {
170
+ const r0 = rows[ri] as number
171
+ const r1 = rows[ri + 1] as number
172
+ const west0 = at(firstCol, r0)
173
+ const west1 = at(firstCol, r1)
174
+ sink.quad(
175
+ { x: westX, y: west0.y, z: west0.z },
176
+ west0,
177
+ { x: westX, y: west1.y, z: west1.z },
178
+ west1,
179
+ )
180
+ const east0 = at(lastCol, r0)
181
+ const east1 = at(lastCol, r1)
182
+ sink.quad(east0, { x: eastX, y: east0.y, z: east0.z }, east1, {
183
+ x: eastX,
184
+ y: east1.y,
185
+ z: east1.z,
186
+ })
187
+ }
188
+
189
+ // Corner quads, flat at the corner sample's height — the diagonal region no edge
190
+ // strip covers, and where `heightAt` also reports the corner value.
191
+ const corners: Array<[Vertex, number, number]> = [
192
+ [at(firstCol, firstRow), westX, northZ],
193
+ [at(lastCol, firstRow), eastX, northZ],
194
+ [at(firstCol, lastRow), westX, southZ],
195
+ [at(lastCol, lastRow), eastX, southZ],
196
+ ]
197
+ for (const [corner, farX, farZ] of corners) {
198
+ const minX = Math.min(corner.x, farX)
199
+ const maxX = Math.max(corner.x, farX)
200
+ const minZ = Math.min(corner.z, farZ)
201
+ const maxZ = Math.max(corner.z, farZ)
202
+ const y = corner.y
203
+ sink.quad(
204
+ { x: minX, y, z: minZ },
205
+ { x: maxX, y, z: minZ },
206
+ { x: minX, y, z: maxZ },
207
+ { x: maxX, y, z: maxZ },
208
+ )
209
+ }
210
+
211
+ return sink.build()
212
+ }