@pascal-app/editor 0.9.2 → 1.0.0-beta.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 (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 +236 -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,356 @@
1
+ 'use client'
2
+
3
+ import {
4
+ advanceStroke,
5
+ applyHeightPatch,
6
+ beginStroke,
7
+ detachStrokeAnchor,
8
+ emitter,
9
+ minBrushRadius,
10
+ raycastTerrain,
11
+ type SiteNode,
12
+ type TerrainField,
13
+ type TerrainStroke,
14
+ useLiveTerrain,
15
+ useScene,
16
+ } from '@pascal-app/core'
17
+ import { useViewer } from '@pascal-app/viewer'
18
+ import { useThree } from '@react-three/fiber'
19
+ import { useEffect, useRef } from 'react'
20
+ import { Raycaster, Vector2 } from 'three'
21
+ import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
22
+ import { sfxEmitter } from '../../../lib/sfx-bus'
23
+ import {
24
+ clipTerrainPatchToSite,
25
+ commitStroke,
26
+ resolveFlattenTarget,
27
+ sculptFieldForSite,
28
+ terrainPointInsideSite,
29
+ } from '../../../lib/terrain-sculpt'
30
+ import useEditor from '../../../store/use-editor'
31
+ import { isBoxSelectPointerSuppressed } from '../select/box-select-state'
32
+ import { strokePointerAction } from './stroke-pointer-ownership'
33
+ import { TerrainBrushCursor, type TerrainBrushFocus } from './terrain-brush-cursor'
34
+ import { TerrainSculptGrid } from './terrain-sculpt-grid'
35
+
36
+ /**
37
+ * The sculpt tool: pointer motion → terrain, while `mode === 'terrain-sculpt'`.
38
+ *
39
+ * Pointer handling is DOM-level on the canvas rather than R3F mesh events, for
40
+ * the same reason `useGridEvents` is: a mesh handler can be swallowed by any
41
+ * geometry that calls `stopPropagation`, and the ground is precisely the surface
42
+ * that everything else sits on top of. Sculpting must reach it even with a wall
43
+ * under the cursor.
44
+ *
45
+ * Four properties are what keep this from fighting the rest of the editor:
46
+ *
47
+ * - It is a *mode*, so it is mutually exclusive with select/build/delete/paint
48
+ * by construction. Nothing else can be armed at the same time.
49
+ * - The selection managers all early-return unless `mode === 'select'`, so while
50
+ * the brush is armed a click cannot select, move, or reshape a node — no
51
+ * matter what geometry is under it.
52
+ * - The `sculpting` interaction scope is held by the mode, not by this
53
+ * component's drag, so every other object's handles and the floating action
54
+ * menu stay stepped back between dabs rather than flickering back.
55
+ * - Camera drags are respected: `cameraDragging` suppresses dabs at pointer-down
56
+ * *and* per dab, so neither starting a drag as an orbit nor zooming mid-stroke
57
+ * carves the terrain. The stroke commits once, as one undo step, on pointer-up.
58
+ */
59
+ export const TerrainSculptTool: React.FC = () => {
60
+ const { camera, gl } = useThree()
61
+ const brushFocusRef = useRef<TerrainBrushFocus | null>(null)
62
+ const nodes = useScene((state) => state.nodes)
63
+ const rootNodeIds = useScene((state) => state.rootNodeIds)
64
+ const verb = useEditor((state) => state.terrainVerb)
65
+ const brush = useEditor((state) => state.terrainBrush)
66
+ const flattenTarget = useEditor((state) => state.terrainFlattenTarget)
67
+ const sampling = useEditor((state) => state.terrainSampling)
68
+
69
+ const siteNode = rootNodeIds[0] ? nodes[rootNodeIds[0]] : null
70
+ const site = siteNode?.type === 'site' ? (siteNode as SiteNode) : null
71
+
72
+ // Everything the pointer handlers need, mirrored into refs so the listener
73
+ // effect depends only on the canvas and camera. Re-attaching listeners because
74
+ // the brush radius changed would drop an in-flight stroke.
75
+ const latest = useRef({ site, verb, brush, flattenTarget, sampling })
76
+ latest.current = { site, verb, brush, flattenTarget, sampling }
77
+
78
+ const strokeRef = useRef<{
79
+ stroke: TerrainStroke
80
+ field: TerrainField
81
+ siteId: string
82
+ /** The pointer that owns this stroke; every other one is ignored. */
83
+ pointerId: number
84
+ } | null>(null)
85
+ const raycaster = useRef(new Raycaster())
86
+ const pointer = useRef(new Vector2())
87
+
88
+ useEffect(() => {
89
+ const canvas = gl.domElement
90
+
91
+ /**
92
+ * Where the pointer meets the ground, through the terrain raycast.
93
+ *
94
+ * `field` is passed in rather than read from the store so a stroke keeps
95
+ * hitting the surface it started on. Using the live (already-sculpted) field
96
+ * would make the cursor climb the hill it is building — raise the ground and
97
+ * the aim point walks toward the camera, which reads as the brush sliding
98
+ * out from under the mouse.
99
+ */
100
+ const groundPoint = (
101
+ event: PointerEvent | MouseEvent,
102
+ field: TerrainField,
103
+ ): [number, number] | null => {
104
+ const rect = canvas.getBoundingClientRect()
105
+ pointer.current.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
106
+ pointer.current.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
107
+ raycaster.current.setFromCamera(pointer.current, camera)
108
+ const { origin, direction } = raycaster.current.ray
109
+ const hit = raycastTerrain(
110
+ field,
111
+ [origin.x, origin.y, origin.z],
112
+ [direction.x, direction.y, direction.z],
113
+ )
114
+ const target = latest.current.site
115
+ return hit && target && terrainPointInsideSite(target, hit.x, hit.z) ? [hit.x, hit.z] : null
116
+ }
117
+
118
+ /** Which pointer owns the stroke in flight, for `strokePointerAction`. */
119
+ const ownerPointerId = () => strokeRef.current?.pointerId ?? null
120
+
121
+ const handlePointerDown = (event: PointerEvent) => {
122
+ if (event.button !== 0) return
123
+ if (useEditor.getState().mode !== 'terrain-sculpt') return
124
+ // Polygon handles run through R3F on this same canvas. They mark the
125
+ // pointer before this DOM listener sees it, so a flag/midpoint press can
126
+ // switch cleanly into boundary editing without also depositing a dab.
127
+ if (isBoxSelectPointerSuppressed(event)) return
128
+ // A second pointer means the gesture was navigation, not sculpting: one
129
+ // finger paints (`editorOwnsOneFingerDrag` gives the editor one-finger
130
+ // priority in this mode) but two fingers always pinch-zoom, and by the time
131
+ // the second lands the first has already deposited a dab. Abandoning is what
132
+ // makes "pinch to look closer" free of an accidental bump — the dabs so far
133
+ // were never committed, so dropping the live stroke restores the ground
134
+ // exactly, the same as Escape.
135
+ if (
136
+ strokePointerAction({
137
+ event: 'down',
138
+ pointerId: event.pointerId,
139
+ ownerPointerId: ownerPointerId(),
140
+ }) === 'abandon'
141
+ ) {
142
+ abandonStroke()
143
+ return
144
+ }
145
+ // Orbiting over the ground must not carve it. `cameraDragging` is the same
146
+ // flag the grid events and node events gate on, so a middle/right-drag or a
147
+ // modifier orbit behaves identically here.
148
+ if (useViewer.getState().cameraDragging) return
149
+ const {
150
+ site: target,
151
+ verb: activeVerb,
152
+ brush: settings,
153
+ flattenTarget: explicit,
154
+ } = latest.current
155
+ if (!target) return
156
+
157
+ const field = sculptFieldForSite(target)
158
+ const point = groundPoint(event, field)
159
+ if (!point) return
160
+
161
+ // Eyedropper: sample and consume the click. Sampling *is* the gesture, so
162
+ // it must not also deposit a dab.
163
+ if (latest.current.sampling) {
164
+ useEditor.getState().setTerrainFlattenTarget(resolveFlattenTarget(field, null, ...point))
165
+ return
166
+ }
167
+
168
+ // Last line of defence on the radius. The controls clamp to the same floor,
169
+ // but the *stored default* is 2 m and the floor scales with the field's
170
+ // spacing — so a coarse lot (`fieldExtentForSite` stretches it past 1.3 m
171
+ // once a lot passes ~300 m) would meet a first-ever stroke that resolves to
172
+ // no samples and paints nothing, before the user has touched any control.
173
+ // Clamping against the field in hand rather than the node means this is
174
+ // right even for an imported DEM at its own resolution.
175
+ const stroke = beginStroke({
176
+ field,
177
+ verb: activeVerb,
178
+ settings: { ...settings, radius: Math.max(settings.radius, minBrushRadius(field)) },
179
+ target:
180
+ activeVerb === 'flatten' ? resolveFlattenTarget(field, explicit, ...point) : undefined,
181
+ })
182
+ strokeRef.current = {
183
+ stroke,
184
+ field,
185
+ siteId: target.id,
186
+ pointerId: event.pointerId,
187
+ }
188
+ useLiveTerrain.getState().begin(target.id, field)
189
+ sfxEmitter.emit('sfx:terrain-sculpt-start', activeVerb)
190
+
191
+ // Capture so a stroke that leaves the canvas still ends. Without this a
192
+ // drag released outside the viewport would leave the live stroke armed and
193
+ // the terrain uncommitted.
194
+ canvas.setPointerCapture(event.pointerId)
195
+ applyDab(event)
196
+ }
197
+
198
+ const applyDab = (event: PointerEvent) => {
199
+ const active = strokeRef.current
200
+ if (!active) return
201
+ // Re-checked per dab, not just at pointer-down: a wheel or trackpad zoom
202
+ // mid-stroke sets `cameraDragging` and releases it on a 500 ms timer
203
+ // (`scheduleEnd`), all while the pointer is still down and painting. The
204
+ // camera moves under a held brush, so every dab in that window lands
205
+ // somewhere the user did not aim — a smear across the lot from one scroll.
206
+ // Skipping the dab rather than ending the stroke keeps zoom-then-continue
207
+ // working, which is how anyone sculpts a slope they need to see closer.
208
+ //
209
+ // Dropping the anchor is the other half, and skipping alone would be worse
210
+ // than not skipping: dab spacing is an arc length, so `advanceStroke` would
211
+ // bridge from the pre-zoom centre to wherever the pointer resurfaced and
212
+ // paint the entire smear in one call.
213
+ if (useViewer.getState().cameraDragging) {
214
+ detachStrokeAnchor(active.stroke)
215
+ return
216
+ }
217
+ const point = groundPoint(event, active.field)
218
+ if (!point) return
219
+ const brushPatch = advanceStroke(active.stroke, point[0], point[1])
220
+ if (!brushPatch) return
221
+ const target = latest.current.site
222
+ if (!target || target.id !== active.siteId) return
223
+ const patch = clipTerrainPatchToSite(active.field, brushPatch, target)
224
+ const next = applyHeightPatch(active.field, patch)
225
+ // The stroke's *snapshot* stays the pointer-down field (that is what makes
226
+ // it saturating); `active.field` is only the running result the mesh shows.
227
+ active.field = next
228
+ useLiveTerrain.getState().advance(active.siteId, next, patch)
229
+ }
230
+
231
+ const handlePointerMove = (event: PointerEvent) => {
232
+ // Only the owning pointer paints. A second finger resting on the glass
233
+ // still emits `pointermove`, and letting those through would drag the
234
+ // stroke between two contact points on every frame.
235
+ const action = strokePointerAction({
236
+ event: 'move',
237
+ pointerId: event.pointerId,
238
+ ownerPointerId: ownerPointerId(),
239
+ })
240
+ if (action !== 'apply') return
241
+ applyDab(event)
242
+ }
243
+
244
+ const handlePointerUp = (event: PointerEvent) => {
245
+ const active = strokeRef.current
246
+ // Lifting a non-owning finger must not commit the owner's stroke, which is
247
+ // still in progress under the finger that is still down.
248
+ if (
249
+ !active ||
250
+ strokePointerAction({
251
+ event: 'up',
252
+ pointerId: event.pointerId,
253
+ ownerPointerId: ownerPointerId(),
254
+ }) !== 'commit'
255
+ ) {
256
+ return
257
+ }
258
+ strokeRef.current = null
259
+ if (canvas.hasPointerCapture(event.pointerId)) canvas.releasePointerCapture(event.pointerId)
260
+ sfxEmitter.emit('sfx:terrain-sculpt-stop')
261
+
262
+ // Commit before ending the live stroke: `terrainFieldOf` prefers the live
263
+ // field, so ending first would flash one frame of the pre-stroke ground
264
+ // between the two writes.
265
+ commitStroke(active.siteId as SiteNode['id'], active.field)
266
+ useLiveTerrain.getState().end(active.siteId)
267
+ }
268
+
269
+ /**
270
+ * Drop the in-flight stroke, leaving the scene untouched — nothing was
271
+ * committed, so ending the live stroke restores the pre-stroke ground exactly.
272
+ *
273
+ * Shared by Escape/⌘Z and by a second pointer arriving. Releasing the capture
274
+ * matters as much as clearing the ref: the canvas keeps routing that pointer's
275
+ * events here until it is released, and the owning `pointerup` now early-
276
+ * returns on a null ref, so without this the capture outlived the gesture.
277
+ */
278
+ const abandonStroke = () => {
279
+ const active = strokeRef.current
280
+ if (!active) return false
281
+ strokeRef.current = null
282
+ if (canvas.hasPointerCapture(active.pointerId)) {
283
+ canvas.releasePointerCapture(active.pointerId)
284
+ }
285
+ sfxEmitter.emit('sfx:terrain-sculpt-stop')
286
+ useLiveTerrain.getState().end(active.siteId)
287
+ return true
288
+ }
289
+
290
+ /**
291
+ * On the `tool:cancel` bus rather than a raw `keydown`, and that detail is
292
+ * the fix rather than a style choice: the global Escape handler also listens
293
+ * on `window`, is registered first (it mounts at the app root, this tool
294
+ * mounts later), and its fall-through is `setMode('select')`. A window
295
+ * listener here would therefore run *after* the mode had already been
296
+ * exited. `tool:cancel` is emitted synchronously *before* that check, so
297
+ * `markToolCancelConsumed` still lands in time.
298
+ *
299
+ * The result is the two meanings of Escape stay separate: mid-stroke it
300
+ * abandons the dab and keeps the brush armed; with no stroke live it falls
301
+ * through and leaves terrain mode. Same contract every drafting tool uses
302
+ * (`slab/tool.tsx`). It also covers ⌘Z mid-stroke, which routes through the
303
+ * same emit — undoing a stroke in flight cancels it rather than jumping
304
+ * history to a baseline the stroke was never part of.
305
+ */
306
+ const onCancel = () => {
307
+ // Only claim the key when there was something to abandon, or Escape with no
308
+ // stroke live would be swallowed and never exit the mode.
309
+ if (abandonStroke()) markToolCancelConsumed()
310
+ }
311
+
312
+ // `pointercancel` abandons rather than commits, unlike `pointerup`: the
313
+ // browser fires it when it takes the gesture away (an OS edge swipe, palm
314
+ // rejection, the pointer being captured elsewhere), which means the user never
315
+ // finished the stroke. Committing there would persist a half-drag nobody
316
+ // released — and it was wired to `handlePointerUp` before, so it did.
317
+ const handlePointerCancel = (event: PointerEvent) => {
318
+ const action = strokePointerAction({
319
+ event: 'cancel',
320
+ pointerId: event.pointerId,
321
+ ownerPointerId: ownerPointerId(),
322
+ })
323
+ if (action === 'abandon') abandonStroke()
324
+ }
325
+
326
+ canvas.addEventListener('pointerdown', handlePointerDown)
327
+ canvas.addEventListener('pointermove', handlePointerMove)
328
+ canvas.addEventListener('pointerup', handlePointerUp)
329
+ canvas.addEventListener('pointercancel', handlePointerCancel)
330
+ emitter.on('tool:cancel', onCancel)
331
+
332
+ return () => {
333
+ canvas.removeEventListener('pointerdown', handlePointerDown)
334
+ canvas.removeEventListener('pointermove', handlePointerMove)
335
+ canvas.removeEventListener('pointerup', handlePointerUp)
336
+ canvas.removeEventListener('pointercancel', handlePointerCancel)
337
+ emitter.off('tool:cancel', onCancel)
338
+ // Unmounting mid-stroke (mode switch, hot reload) must not leave the live
339
+ // stroke armed — every reader prefers it over the persisted terrain, so a
340
+ // leaked stroke is a scene that shows ground the scene graph does not have.
341
+ abandonStroke()
342
+ sfxEmitter.emit('sfx:terrain-sculpt-stop')
343
+ }
344
+ }, [camera, gl])
345
+
346
+ if (!site) return null
347
+
348
+ return (
349
+ <>
350
+ <TerrainSculptGrid focusRef={brushFocusRef} site={site} />
351
+ <TerrainBrushCursor focusRef={brushFocusRef} site={site} />
352
+ </>
353
+ )
354
+ }
355
+
356
+ export default TerrainSculptTool
@@ -0,0 +1,58 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { createStairCommitGate, swallowFollowUpBrowserClick } from './stair-click-guard'
3
+
4
+ describe('createStairCommitGate', () => {
5
+ test('allows commits until the session exits', () => {
6
+ const gate = createStairCommitGate()
7
+ expect(gate.shouldCommit()).toBe(true)
8
+ // Repeat continuation: consecutive commits stay allowed.
9
+ expect(gate.shouldCommit()).toBe(true)
10
+ })
11
+
12
+ test('refuses every trigger after a single-continuation exit', () => {
13
+ const gate = createStairCommitGate()
14
+ expect(gate.shouldCommit()).toBe(true)
15
+ gate.markExited()
16
+ // The native follow-up click / stray node click of the same gesture.
17
+ expect(gate.shouldCommit()).toBe(false)
18
+ expect(gate.shouldCommit()).toBe(false)
19
+ })
20
+ })
21
+
22
+ describe('swallowFollowUpBrowserClick', () => {
23
+ test('stops exactly one follow-up click', () => {
24
+ const target = new EventTarget()
25
+ swallowFollowUpBrowserClick(target)
26
+
27
+ let firstStopped = 0
28
+ const first = new Event('click', { cancelable: true })
29
+ Object.defineProperty(first, 'stopPropagation', { value: () => firstStopped++ })
30
+ target.dispatchEvent(first)
31
+ expect(firstStopped).toBe(1)
32
+ expect(first.defaultPrevented).toBe(true)
33
+
34
+ // `once: true` — the next click of the next gesture passes through.
35
+ let secondStopped = 0
36
+ const second = new Event('click', { cancelable: true })
37
+ Object.defineProperty(second, 'stopPropagation', { value: () => secondStopped++ })
38
+ target.dispatchEvent(second)
39
+ expect(secondStopped).toBe(0)
40
+ expect(second.defaultPrevented).toBe(false)
41
+ })
42
+
43
+ test('disarms after the timeout when no click follows', async () => {
44
+ const target = new EventTarget()
45
+ swallowFollowUpBrowserClick(target, 5)
46
+ await new Promise((resolve) => setTimeout(resolve, 15))
47
+
48
+ let stopped = 0
49
+ const late = new Event('click', { cancelable: true })
50
+ Object.defineProperty(late, 'stopPropagation', { value: () => stopped++ })
51
+ target.dispatchEvent(late)
52
+ expect(stopped).toBe(0)
53
+ })
54
+
55
+ test('no-ops without a window-like target', () => {
56
+ expect(() => swallowFollowUpBrowserClick(undefined)).not.toThrow()
57
+ })
58
+ })
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Guards for the stair tool's commit triggers.
3
+ *
4
+ * One physical validation click can reach the stair tool's commit handler
5
+ * twice: node-surface clicks (`slab:click`, `wall:click`, …) are synthesized
6
+ * on *pointerup* by the viewer (`use-node-events`), while `grid:click` rides
7
+ * the browser's native *click* event from a canvas-level DOM listener
8
+ * (`use-grid-events`) that deliberately ignores R3F stopPropagation — and
9
+ * after a single-continuation commit the tool's emitter subscriptions survive
10
+ * until React unmounts it, which lands only after that native click. Without
11
+ * these guards a validation click over any node surface (a deck or floor
12
+ * slab, a wall, another stair) created TWO stairs from one click.
13
+ *
14
+ * Same hazard and same countermeasures as
15
+ * `packages/nodes/src/shared/floor-placement.ts` (`stopPlacementCommitPropagation`)
16
+ * and the `committed` flag in `move-registry-node-tool.tsx` — reimplemented
17
+ * here because `@pascal-app/editor` cannot depend on `@pascal-app/nodes`.
18
+ */
19
+
20
+ export type StairCommitGate = {
21
+ /** True while the armed session may still commit. */
22
+ shouldCommit: () => boolean
23
+ /**
24
+ * Mark the session exited (single continuation): every further click
25
+ * trigger reaching the still-subscribed handler — the native follow-up
26
+ * click, a stray second node click — is refused.
27
+ */
28
+ markExited: () => void
29
+ }
30
+
31
+ export function createStairCommitGate(): StairCommitGate {
32
+ let exited = false
33
+ return {
34
+ shouldCommit: () => !exited,
35
+ markExited: () => {
36
+ exited = true
37
+ },
38
+ }
39
+ }
40
+
41
+ type ClickSwallowTarget = {
42
+ addEventListener: (
43
+ type: string,
44
+ listener: (event: Event) => void,
45
+ options?: AddEventListenerOptions,
46
+ ) => void
47
+ removeEventListener: (
48
+ type: string,
49
+ listener: (event: Event) => void,
50
+ options?: EventListenerOptions,
51
+ ) => void
52
+ }
53
+
54
+ /**
55
+ * Eat the one native browser `click` that follows a pointerup-synthesized
56
+ * node click, before it reaches the canvas `grid:click` listener (capture
57
+ * phase on window runs first). Needed in repeat continuation too, where the
58
+ * tool stays armed and the gate above must keep allowing one commit per
59
+ * gesture. Self-disarms after the click or `timeoutMs`, whichever first.
60
+ */
61
+ export function swallowFollowUpBrowserClick(
62
+ target: ClickSwallowTarget | undefined = typeof window === 'undefined' ? undefined : window,
63
+ timeoutMs = 300,
64
+ ): void {
65
+ if (!target) return
66
+ const swallow = (event: Event) => {
67
+ event.stopPropagation()
68
+ event.preventDefault()
69
+ }
70
+ target.addEventListener('click', swallow, { capture: true, once: true })
71
+ setTimeout(() => target.removeEventListener('click', swallow, { capture: true }), timeoutMs)
72
+ }