@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,247 @@
1
+ 'use client'
2
+
3
+ import {
4
+ minBrushRadius,
5
+ raycastTerrain,
6
+ type SiteNode,
7
+ surfaceHeightAt,
8
+ type TerrainField,
9
+ terrainFieldOf,
10
+ useLiveTerrain,
11
+ } from '@pascal-app/core'
12
+ import { useViewer } from '@pascal-app/viewer'
13
+ import { Html } from '@react-three/drei'
14
+ import { useFrame, useThree } from '@react-three/fiber'
15
+ import { type MutableRefObject, useEffect, useMemo, useRef } from 'react'
16
+ import {
17
+ BufferGeometry,
18
+ Float32BufferAttribute,
19
+ type Group,
20
+ type Line,
21
+ Raycaster,
22
+ Vector2,
23
+ } from 'three'
24
+ import { EDITOR_LAYER } from '../../../lib/constants'
25
+ import { sculptFieldForSite, terrainPointInsideSite } from '../../../lib/terrain-sculpt'
26
+ import { brushRingColor } from '../../../lib/terrain-verb-color'
27
+ import useEditor from '../../../store/use-editor'
28
+ import { formatMeasurement } from '../../editor/measurement-pill'
29
+
30
+ /** Segments in the cursor ring. 64 reads as a circle at any usable brush size. */
31
+ const RING_SEGMENTS = 64
32
+
33
+ /**
34
+ * Lift above the surface, so the ring is not z-fighting the ground it traces.
35
+ *
36
+ * Two centimetres only works because the ring samples `surfaceHeightAt` — the
37
+ * plane of the triangle under each point — and not the bilinear `heightAt`. Those
38
+ * differ by up to a quarter of a cell's warp, which on steep ground is decimetres,
39
+ * so a bilinear ring would submerge on half of every cell no matter how large this
40
+ * gets. `depthTest={false}` hides that on screen but not in the exported GLB.
41
+ */
42
+ const RING_LIFT = 0.02
43
+
44
+ /** The ring is decoration; it must never intercept a pointer ray. */
45
+ const NO_RAYCAST = () => null
46
+
47
+ export type TerrainBrushFocus = {
48
+ radius: number
49
+ x: number
50
+ z: number
51
+ }
52
+
53
+ /**
54
+ * The brush ring, drawn *on the terrain surface* rather than as a flat disc.
55
+ *
56
+ * Following the surface is the whole point: a flat ring on sloped ground tells
57
+ * the user nothing about what the brush will touch, because the footprint is a
58
+ * cylinder in XZ and the interesting question is which part of the slope falls
59
+ * inside it. Sampling the ring's height per segment answers that directly.
60
+ *
61
+ * Updated in `useFrame` from a ref, never through React state — the ring tracks
62
+ * the pointer, and routing that through a re-render would re-run every hook in
63
+ * the tool subtree at pointer rate.
64
+ *
65
+ * On `EDITOR_LAYER`, non-negotiably: an overlay mesh left on the scene layer
66
+ * poisons the MRT scene pass and makes FrontSide geometry render see-through.
67
+ */
68
+ export const TerrainBrushCursor: React.FC<{
69
+ focusRef: MutableRefObject<TerrainBrushFocus | null>
70
+ site: SiteNode
71
+ }> = ({ focusRef, site }) => {
72
+ const { camera, gl } = useThree()
73
+ const radius = useEditor((state) => state.terrainBrush.radius)
74
+ const shape = useEditor((state) => state.terrainBrush.shape)
75
+ // The ring is the only thing at the point of action that can say what the next
76
+ // drag will do — its geometry is identical for all four verbs, so colour is the
77
+ // one channel left. Keep the mapping centralized in `lib/terrain-verb-color`.
78
+ const verb = useEditor((state) => state.terrainVerb)
79
+ const sampling = useEditor((state) => state.terrainSampling)
80
+ const color = brushRingColor(verb, sampling)
81
+ const unit = useViewer((state) => state.unit)
82
+
83
+ const lineRef = useRef<Line>(null)
84
+ const centerRef = useRef<Group>(null)
85
+ const heightRef = useRef<HTMLSpanElement>(null)
86
+ const lastHeightLabelRef = useRef('')
87
+ const pointerRef = useRef<{ x: number; y: number } | null>(null)
88
+ const raycaster = useRef(new Raycaster())
89
+ const ndc = useRef(new Vector2())
90
+
91
+ const geometry = useMemo(() => {
92
+ const positions = new Float32Array((RING_SEGMENTS + 1) * 3)
93
+ const buffer = new BufferGeometry()
94
+ buffer.setAttribute('position', new Float32BufferAttribute(positions, 3))
95
+ return buffer
96
+ }, [])
97
+ useEffect(() => () => geometry.dispose(), [geometry])
98
+
99
+ useEffect(() => {
100
+ const canvas = gl.domElement
101
+ const track = (event: PointerEvent) => {
102
+ const rect = canvas.getBoundingClientRect()
103
+ pointerRef.current = {
104
+ x: ((event.clientX - rect.left) / rect.width) * 2 - 1,
105
+ y: -((event.clientY - rect.top) / rect.height) * 2 + 1,
106
+ }
107
+ }
108
+ const freeze = () => {
109
+ pointerRef.current = null
110
+ }
111
+ canvas.addEventListener('pointermove', track)
112
+ canvas.addEventListener('pointerleave', freeze)
113
+ return () => {
114
+ canvas.removeEventListener('pointermove', track)
115
+ canvas.removeEventListener('pointerleave', freeze)
116
+ }
117
+ }, [gl])
118
+
119
+ useFrame(() => {
120
+ const line = lineRef.current
121
+ const center = centerRef.current
122
+ const pointer = pointerRef.current
123
+ if (!(line && center)) return
124
+ if (!(pointer || focusRef.current)) {
125
+ line.visible = false
126
+ center.visible = false
127
+ if (heightRef.current) heightRef.current.style.display = 'none'
128
+ return
129
+ }
130
+
131
+ // Two fields, deliberately. Aiming uses the stroke's *snapshot* so the ring
132
+ // sits exactly where the next dab will land — the tool raycasts the snapshot
133
+ // too, and a cursor that disagreed with the brush by even a few centimetres
134
+ // would read as lag. Drawing uses the live field so the ring drapes over the
135
+ // hill as it rises. Outside a stroke the two are the same field.
136
+ const stroke = useLiveTerrain.getState().strokeOf(site.id)
137
+ const surface: TerrainField = stroke?.field ?? terrainFieldOf(site) ?? sculptFieldForSite(site)
138
+ const aim: TerrainField = stroke?.snapshot ?? surface
139
+
140
+ let hit: { x: number; z: number } | null = null
141
+ if (pointer) {
142
+ ndc.current.set(pointer.x, pointer.y)
143
+ raycaster.current.setFromCamera(ndc.current, camera)
144
+ const { origin, direction } = raycaster.current.ray
145
+ hit = raycastTerrain(
146
+ aim,
147
+ [origin.x, origin.y, origin.z],
148
+ [direction.x, direction.y, direction.z],
149
+ )
150
+ if (hit && terrainPointInsideSite(site, hit.x, hit.z)) {
151
+ focusRef.current = { radius: Math.max(radius, minBrushRadius(aim)), x: hit.x, z: hit.z }
152
+ } else {
153
+ hit = null
154
+ focusRef.current = null
155
+ }
156
+ } else if (focusRef.current) {
157
+ hit = terrainPointInsideSite(site, focusRef.current.x, focusRef.current.z)
158
+ ? focusRef.current
159
+ : null
160
+ }
161
+ if (!hit) {
162
+ line.visible = false
163
+ center.visible = false
164
+ if (heightRef.current) heightRef.current.style.display = 'none'
165
+ return
166
+ }
167
+ line.visible = true
168
+ center.visible = true
169
+
170
+ const attribute = line.geometry.getAttribute('position')
171
+ const array = attribute.array as Float32Array
172
+ // The same floor the tool applies at pointer-down. Drawing the stored radius
173
+ // instead would draw a ring smaller than the footprint the next dab actually
174
+ // gets — on a coarse lot the ring would be the one thing insisting the brush
175
+ // is 2 m wide while the stroke is 1.5 spacings wide.
176
+ const drawn = Math.max(radius, minBrushRadius(aim))
177
+ focusRef.current = { radius: drawn, x: hit.x, z: hit.z }
178
+ for (let index = 0; index <= RING_SEGMENTS; index++) {
179
+ const angle = (index / RING_SEGMENTS) * Math.PI * 2
180
+ // The square brush uses Chebyshev distance, so its rim is a square: scale
181
+ // the unit circle out to the box so the ring shows the actual footprint
182
+ // rather than an inscribed circle that under-reports it.
183
+ const cos = Math.cos(angle)
184
+ const sin = Math.sin(angle)
185
+ const scale =
186
+ shape === 'square' ? 1 / Math.max(Math.abs(cos), Math.abs(sin), Number.EPSILON) : 1
187
+ const x = hit.x + cos * drawn * scale
188
+ const z = hit.z + sin * drawn * scale
189
+ array[index * 3] = x
190
+ array[index * 3 + 1] = surfaceHeightAt(surface, x, z) + RING_LIFT
191
+ array[index * 3 + 2] = z
192
+ }
193
+ attribute.needsUpdate = true
194
+ line.geometry.computeBoundingSphere()
195
+
196
+ const centerHeight = surfaceHeightAt(surface, hit.x, hit.z)
197
+ center.position.set(hit.x, centerHeight + RING_LIFT, hit.z)
198
+ const heightLabel = `H ${formatMeasurement(centerHeight, unit)}`
199
+ if (heightRef.current) {
200
+ heightRef.current.style.display = ''
201
+ if (heightLabel !== lastHeightLabelRef.current) {
202
+ heightRef.current.textContent = heightLabel
203
+ lastHeightLabelRef.current = heightLabel
204
+ }
205
+ }
206
+ })
207
+
208
+ return (
209
+ <>
210
+ <line
211
+ frustumCulled={false}
212
+ geometry={geometry}
213
+ layers={EDITOR_LAYER}
214
+ raycast={NO_RAYCAST}
215
+ // @ts-expect-error R3F <line> element conflicts with SVG <line> type
216
+ ref={lineRef}
217
+ renderOrder={13}
218
+ >
219
+ <lineBasicNodeMaterial color={color} depthTest={false} depthWrite={false} linewidth={2} />
220
+ </line>
221
+ <group ref={centerRef} visible={false}>
222
+ <mesh
223
+ layers={EDITOR_LAYER}
224
+ raycast={NO_RAYCAST}
225
+ renderOrder={14}
226
+ rotation={[-Math.PI / 2, 0, 0]}
227
+ >
228
+ <circleGeometry args={[0.055, 24]} />
229
+ <meshBasicMaterial color={color} depthTest={false} depthWrite={false} />
230
+ </mesh>
231
+ <Html
232
+ center
233
+ position={[0, 0.18, 0]}
234
+ style={{ pointerEvents: 'none', userSelect: 'none' }}
235
+ zIndexRange={[20, 0]}
236
+ >
237
+ <span
238
+ className="whitespace-nowrap rounded-full border border-border/60 bg-background/90 px-2 py-1 font-medium font-mono text-foreground text-xs shadow-sm backdrop-blur"
239
+ ref={heightRef}
240
+ />
241
+ </Html>
242
+ </group>
243
+ </>
244
+ )
245
+ }
246
+
247
+ export default TerrainBrushCursor
@@ -0,0 +1,192 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type HeightPatch,
5
+ type SiteNode,
6
+ type TerrainField,
7
+ terrainFieldOf,
8
+ useLiveTerrain,
9
+ useScene,
10
+ } from '@pascal-app/core'
11
+ import { GRID_LAYER, getSceneTheme, useViewer } from '@pascal-app/viewer'
12
+ import { useFrame } from '@react-three/fiber'
13
+ import { type MutableRefObject, useEffect, useMemo, useRef } from 'react'
14
+ import { BufferAttribute, BufferGeometry, type LineSegments, Vector2 } from 'three'
15
+ import { float, positionLocal, smoothstep, uniform } from 'three/tsl'
16
+ import { LineBasicNodeMaterial } from 'three/webgpu'
17
+ import { sculptFieldForSite, terrainPointInsideSite } from '../../../lib/terrain-sculpt'
18
+ import type { TerrainBrushFocus } from './terrain-brush-cursor'
19
+
20
+ const GRID_LIFT = 0.012
21
+ const NO_RAYCAST = () => null
22
+
23
+ function writeGridHeights(
24
+ attribute: BufferAttribute,
25
+ field: TerrainField,
26
+ patch: HeightPatch | null,
27
+ ): void {
28
+ const col0 = patch ? Math.max(0, patch.col0) : 0
29
+ const row0 = patch ? Math.max(0, patch.row0) : 0
30
+ const col1 = patch ? Math.min(field.cols, patch.col0 + patch.cols) : field.cols
31
+ const row1 = patch ? Math.min(field.rows, patch.row0 + patch.rows) : field.rows
32
+ if (col0 >= col1 || row0 >= row1) return
33
+
34
+ const positions = attribute.array as Float32Array
35
+ for (let row = row0; row < row1; row += 1) {
36
+ for (let col = col0; col < col1; col += 1) {
37
+ const sampleIndex = row * field.cols + col
38
+ positions[sampleIndex * 3 + 1] = (field.heights[sampleIndex] ?? 0) * field.step + GRID_LIFT
39
+ }
40
+ }
41
+
42
+ attribute.clearUpdateRanges()
43
+ const first = (row0 * field.cols + col0) * 3
44
+ const last = ((row1 - 1) * field.cols + (col1 - 1)) * 3 + 2
45
+ attribute.addUpdateRange(first, last - first + 1)
46
+ attribute.needsUpdate = true
47
+ }
48
+
49
+ function createGridGeometry(field: TerrainField, site: Pick<SiteNode, 'polygon'>): BufferGeometry {
50
+ const geometry = new BufferGeometry()
51
+ const positions = new Float32Array(field.cols * field.rows * 3)
52
+ const indices: number[] = []
53
+
54
+ for (let row = 0; row < field.rows; row += 1) {
55
+ for (let col = 0; col < field.cols; col += 1) {
56
+ const sampleIndex = row * field.cols + col
57
+ positions[sampleIndex * 3] = field.origin[0] + col * field.spacing
58
+ positions[sampleIndex * 3 + 2] = field.origin[1] + row * field.spacing
59
+ }
60
+ }
61
+
62
+ let indexOffset = 0
63
+ for (let row = 0; row < field.rows; row += 1) {
64
+ for (let col = 0; col < field.cols - 1; col += 1) {
65
+ const sampleIndex = row * field.cols + col
66
+ const x = field.origin[0] + col * field.spacing
67
+ const z = field.origin[1] + row * field.spacing
68
+ if (
69
+ terrainPointInsideSite(site, x, z) &&
70
+ terrainPointInsideSite(site, x + field.spacing, z)
71
+ ) {
72
+ indices[indexOffset++] = sampleIndex
73
+ indices[indexOffset++] = sampleIndex + 1
74
+ }
75
+ }
76
+ }
77
+ for (let col = 0; col < field.cols; col += 1) {
78
+ for (let row = 0; row < field.rows - 1; row += 1) {
79
+ const sampleIndex = row * field.cols + col
80
+ const x = field.origin[0] + col * field.spacing
81
+ const z = field.origin[1] + row * field.spacing
82
+ if (
83
+ terrainPointInsideSite(site, x, z) &&
84
+ terrainPointInsideSite(site, x, z + field.spacing)
85
+ ) {
86
+ indices[indexOffset++] = sampleIndex
87
+ indices[indexOffset++] = sampleIndex + field.cols
88
+ }
89
+ }
90
+ }
91
+
92
+ const positionAttribute = new BufferAttribute(positions, 3)
93
+ geometry.setAttribute('position', positionAttribute)
94
+ geometry.setIndex(indices)
95
+ writeGridHeights(positionAttribute, field, null)
96
+ geometry.computeBoundingSphere()
97
+ return geometry
98
+ }
99
+
100
+ /**
101
+ * A terrain-following lattice shown for the entire lifetime of sculpt mode.
102
+ *
103
+ * The ordinary editor grid is a planar snapping aid, so making it visible here
104
+ * would leave it cutting through hills and hiding below excavations. This grid
105
+ * shares the terrain samples instead: every row and column visibly bends with
106
+ * the surface, and live dabs rewrite only the affected height span. It stays on
107
+ * `GRID_LAYER` so scene geometry depth-occludes it instead of the overlay pass
108
+ * compositing it through walls and objects.
109
+ */
110
+ export function TerrainSculptGrid({
111
+ focusRef,
112
+ site,
113
+ }: {
114
+ focusRef: MutableRefObject<TerrainBrushFocus | null>
115
+ site: SiteNode
116
+ }) {
117
+ const appearance = useViewer((state) => getSceneTheme(state.sceneTheme).appearance)
118
+ const targetRef = useRef<LineSegments>(null)
119
+
120
+ const field = useMemo(() => sculptFieldForSite(site), [site])
121
+ const geometry = useMemo(() => createGridGeometry(field, site), [field, site])
122
+ useEffect(() => () => geometry.dispose(), [geometry])
123
+
124
+ const focusCenter = useMemo(() => uniform(new Vector2()), [])
125
+ const focusRadius = useMemo(() => uniform(1), [])
126
+ const focusVisible = useMemo(() => uniform(0), [])
127
+ const material = useMemo(() => {
128
+ const baseOpacity = appearance === 'dark' ? 0.07 : 0.05
129
+ const focusBoost = appearance === 'dark' ? 0.11 : 0.09
130
+ const distance = positionLocal.xz.sub(focusCenter).length()
131
+ const focus = float(1)
132
+ .sub(smoothstep(focusRadius.mul(1.05), focusRadius.mul(2.5), distance))
133
+ .mul(focusVisible)
134
+
135
+ return new LineBasicNodeMaterial({
136
+ color: appearance === 'dark' ? '#cbd5e1' : '#475569',
137
+ depthTest: true,
138
+ depthWrite: false,
139
+ opacityNode: float(baseOpacity).add(focus.mul(focusBoost)),
140
+ transparent: true,
141
+ })
142
+ }, [appearance, focusCenter, focusRadius, focusVisible])
143
+ useEffect(() => () => material.dispose(), [material])
144
+
145
+ useFrame(() => {
146
+ const focus = focusRef.current
147
+ focusVisible.value = focus ? 1 : 0
148
+ if (!focus) return
149
+ focusCenter.value.set(focus.x, focus.z)
150
+ focusRadius.value = focus.radius
151
+ })
152
+
153
+ useEffect(() => {
154
+ let lastPatch = useLiveTerrain.getState().strokeOf(site.id)?.lastPatch ?? null
155
+
156
+ return useLiveTerrain.subscribe((state) => {
157
+ const target = targetRef.current
158
+ if (!target) return
159
+ const attribute = target.geometry.getAttribute('position') as BufferAttribute
160
+ const stroke = state.strokes.get(site.id)
161
+
162
+ if (stroke?.lastPatch && stroke.lastPatch !== lastPatch) {
163
+ lastPatch = stroke.lastPatch
164
+ writeGridHeights(attribute, stroke.field, stroke.lastPatch)
165
+ target.geometry.computeBoundingSphere()
166
+ return
167
+ }
168
+
169
+ if (!stroke && lastPatch) {
170
+ lastPatch = null
171
+ const current = useScene.getState().nodes[site.id]
172
+ if (current?.type !== 'site') return
173
+ const restored = terrainFieldOf(current) ?? sculptFieldForSite(current)
174
+ if (restored.cols !== field.cols || restored.rows !== field.rows) return
175
+ writeGridHeights(attribute, restored, null)
176
+ target.geometry.computeBoundingSphere()
177
+ }
178
+ })
179
+ }, [field.cols, field.rows, site.id])
180
+
181
+ return (
182
+ <lineSegments
183
+ frustumCulled={false}
184
+ geometry={geometry}
185
+ layers={GRID_LAYER}
186
+ material={material}
187
+ raycast={NO_RAYCAST}
188
+ ref={targetRef}
189
+ renderOrder={11}
190
+ />
191
+ )
192
+ }