@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.
- package/package.json +9 -7
- package/src/components/editor/bake-exporter.tsx +3 -1
- package/src/components/editor/bake-thumbnail.tsx +95 -0
- package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
- package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
- package/src/components/editor/custom-camera-controls.tsx +307 -212
- package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
- package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
- package/src/components/editor/first-person/build-collider-world.test.ts +66 -2
- package/src/components/editor/first-person/build-collider-world.ts +20 -0
- package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
- package/src/components/editor/first-person/terrain-collider.ts +212 -0
- package/src/components/editor/first-person-controls.tsx +295 -132
- package/src/components/editor/floating-action-menu.tsx +59 -65
- package/src/components/editor/floorplan-background-selection.test.ts +16 -2
- package/src/components/editor/floorplan-background-selection.ts +4 -18
- package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
- package/src/components/editor/floorplan-camera-sync.ts +213 -0
- package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
- package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
- package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
- package/src/components/editor/floorplan-panel.tsx +931 -861
- package/src/components/editor/group-actions.ts +155 -15
- package/src/components/editor/group-floating-action-menu.tsx +1 -1
- package/src/components/editor/group-rotate-handle.tsx +1 -1
- package/src/components/editor/group-selection-box-3d.tsx +1 -1
- package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
- package/src/components/editor/handles/handle-drag-history.ts +14 -0
- package/src/components/editor/handles/preview-overrides.test.ts +26 -0
- package/src/components/editor/handles/preview-overrides.ts +13 -0
- package/src/components/editor/handles/resize-snap.test.ts +52 -0
- package/src/components/editor/handles/resize-snap.ts +23 -0
- package/src/components/editor/handles/use-handle-drag.ts +17 -7
- package/src/components/editor/index.tsx +42 -9
- package/src/components/editor/measurement-pill.tsx +6 -1
- package/src/components/editor/node-arrow-handles.tsx +51 -15
- package/src/components/editor/quick-measurement-card.tsx +9 -3
- package/src/components/editor/quick-measurement-hud.tsx +7 -1
- package/src/components/editor/selection-manager.tsx +3 -1
- package/src/components/editor/site-edge-labels.tsx +24 -2
- package/src/components/editor/snapshot-capture-overlay.tsx +4 -1
- package/src/components/editor/thumbnail-generator.tsx +66 -294
- package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
- package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
- package/src/components/editor/use-mesh-settle-epoch.ts +1 -0
- package/src/components/editor/wall-measurement-label.tsx +8 -16
- package/src/components/editor/wall-move-side-handles.tsx +193 -18
- package/src/components/editor/wall-opening-highlights.tsx +63 -32
- package/src/components/editor/wall-snap-beacon-layer.tsx +6 -4
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +39 -8
- package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
- package/src/components/editor-2d/floorplan-group-move.tsx +47 -4
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +21 -5
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +93 -59
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +27 -0
- package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
- package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
- package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
- package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +75 -1
- package/src/components/editor-2d/renderers/floorplan-label-angle.ts +92 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +93 -28
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +279 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +621 -281
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +12 -7
- package/src/components/tools/fence/fence-drafting.ts +27 -0
- package/src/components/tools/item/use-draft-node.ts +131 -72
- package/src/components/tools/item/use-placement-coordinator.tsx +52 -4
- package/src/components/tools/registry/move-registry-node-tool.tsx +75 -4
- package/src/components/tools/roof/roof-tool.tsx +16 -0
- package/src/components/tools/shared/floor-stack-preview.ts +4 -0
- package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
- package/src/components/tools/shared/pointer-support-cap.ts +273 -0
- package/src/components/tools/site/site-boundary-editor.tsx +13 -6
- package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
- package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
- package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
- package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
- package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
- package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
- package/src/components/tools/stair/stair-click-guard.ts +72 -0
- package/src/components/tools/stair/stair-tool.tsx +98 -47
- package/src/components/tools/tool-manager.tsx +67 -9
- package/src/components/tools/wall/wall-drafting.test.ts +118 -1
- package/src/components/tools/wall/wall-drafting.ts +128 -9
- package/src/components/tools/wall/wall-snap-geometry.test.ts +8 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +70 -3
- package/src/components/tools/zone/zone-tool.tsx +16 -0
- package/src/components/ui/action-menu/control-modes.tsx +4 -1
- package/src/components/ui/action-menu/measurement-control.tsx +107 -28
- package/src/components/ui/command-palette/editor-commands.tsx +14 -1
- package/src/components/ui/controls/material-paint-panel.tsx +7 -1
- package/src/components/ui/controls/material-picker.tsx +86 -7
- package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
- package/src/components/ui/floating-level-selector.tsx +73 -19
- package/src/components/ui/helpers/helper-manager.tsx +46 -0
- package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
- package/src/components/ui/panels/panel-manager.tsx +14 -1
- package/src/components/ui/panels/parametric-inspector.tsx +4 -2
- package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
- package/src/components/ui/panels/selection-breakdown.ts +20 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +14 -4
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +16 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +2 -0
- package/src/components/viewer/viewer-controls-bar.tsx +466 -0
- package/src/components/viewer/viewer-scene-header.tsx +235 -0
- package/src/components/viewer-overlay.tsx +20 -655
- package/src/components/walkthrough-hud.tsx +111 -0
- package/src/hooks/use-grid-events.ts +24 -0
- package/src/hooks/use-keyboard.ts +53 -30
- package/src/index.tsx +137 -4
- package/src/lib/active-placement-surface.test.ts +24 -0
- package/src/lib/active-placement-surface.ts +8 -1
- package/src/lib/camera-pose.test.ts +237 -0
- package/src/lib/camera-pose.ts +172 -0
- package/src/lib/direct-manipulation.test.ts +83 -0
- package/src/lib/direct-manipulation.ts +9 -0
- package/src/lib/door-interaction.ts +1 -1
- package/src/lib/elevation-guides.test.ts +107 -0
- package/src/lib/elevation-guides.ts +236 -0
- package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
- package/src/lib/floorplan/annotation-visibility.ts +133 -0
- package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
- package/src/lib/floorplan/drawing-coordination.ts +13 -0
- package/src/lib/floorplan/floorplan-export.test.ts +319 -0
- package/src/lib/floorplan/floorplan-export.tsx +745 -131
- package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
- package/src/lib/floorplan/floorplan-extension.ts +181 -0
- package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
- package/src/lib/floorplan/floorplan-mode.ts +73 -0
- package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
- package/src/lib/floorplan/geometry.ts +2 -3
- package/src/lib/fresh-planar-placement.test.ts +44 -1
- package/src/lib/fresh-planar-placement.ts +21 -4
- package/src/lib/glb-export.test.ts +116 -1
- package/src/lib/glb-export.ts +204 -7
- package/src/lib/ground-surface.ts +95 -0
- package/src/lib/history.test.ts +96 -0
- package/src/lib/history.ts +22 -0
- package/src/lib/interaction/overlay-policy.test.ts +2 -1
- package/src/lib/interaction/scope.ts +40 -8
- package/src/lib/level-duplication.test.ts +2 -1
- package/src/lib/measurements.test.ts +5 -0
- package/src/lib/measurements.ts +12 -1
- package/src/lib/quick-action-feedback.ts +33 -0
- package/src/lib/quick-action-nodes.test.ts +94 -0
- package/src/lib/quick-action-nodes.ts +47 -0
- package/src/lib/scene-clipboard.test.ts +130 -1
- package/src/lib/scene-clipboard.ts +237 -13
- package/src/lib/selection-routing.test.ts +16 -1
- package/src/lib/selection-routing.ts +5 -0
- package/src/lib/sfx-bus.ts +33 -2
- package/src/lib/sfx-player.test.ts +148 -6
- package/src/lib/sfx-player.ts +156 -8
- package/src/lib/site-boundary.test.ts +53 -0
- package/src/lib/site-boundary.ts +27 -0
- package/src/lib/snapping-mode.test.ts +29 -2
- package/src/lib/snapping-mode.ts +15 -3
- package/src/lib/stair-duplication.test.ts +103 -0
- package/src/lib/stair-duplication.ts +57 -85
- package/src/lib/stair-levels.ts +2 -0
- package/src/lib/terrain-sculpt.test.ts +429 -0
- package/src/lib/terrain-sculpt.ts +301 -0
- package/src/lib/terrain-verb-color.test.ts +51 -0
- package/src/lib/terrain-verb-color.ts +58 -0
- package/src/lib/touch-gesture-priority.test.ts +45 -0
- package/src/lib/touch-gesture-priority.ts +30 -0
- package/src/lib/window-interaction.ts +1 -1
- package/src/store/camera-pose-store.test.ts +29 -0
- package/src/store/camera-pose-store.ts +27 -0
- package/src/store/live-draft-preview-stores.test.ts +136 -0
- package/src/store/navigation-sync-pose-store.test.ts +34 -0
- package/src/store/navigation-sync-pose-store.ts +27 -0
- package/src/store/terrain-sculpt-mode.test.ts +223 -0
- package/src/store/use-delete-confirmation.ts +27 -0
- package/src/store/use-drawing-view.test.ts +23 -0
- package/src/store/use-drawing-view.ts +84 -0
- package/src/store/use-editor.tsx +184 -15
- package/src/store/use-elevation-guides.ts +40 -0
- package/src/store/use-fence-curve-draft.ts +38 -8
- package/src/store/use-first-person-hud.ts +40 -0
- package/src/store/use-floorplan-annotation-visibility.ts +60 -0
- package/src/store/use-floorplan-draft-preview.ts +54 -4
- package/src/store/use-floorplan-mode.ts +110 -0
- package/src/store/use-interaction-scope.test.ts +23 -3
- package/src/store/use-interaction-scope.ts +8 -0
- package/src/store/use-measurement-draft.test.ts +29 -0
- package/src/store/use-measurement-draft.ts +33 -6
- package/src/store/use-path-draft-preview.ts +90 -0
- package/src/store/use-stair-build-preview.ts +12 -2
- 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
|
+
}
|