diff --git a/packages/nodes/src/wall/actions.tsx b/packages/nodes/src/wall/actions.tsx
index 07d6d546ce..feca6dcdc0 100644
--- a/packages/nodes/src/wall/actions.tsx
+++ b/packages/nodes/src/wall/actions.tsx
@@ -5,7 +5,7 @@ import {
runAsSingleSceneHistoryStep,
useScene,
} from '@pascal-app/core'
-import { Tooltip, TooltipContent, TooltipTrigger } from '@pascal-app/editor'
+import { Tooltip, TooltipContent, TooltipTrigger, triggerSFX } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { FoldHorizontal, Scissors } from 'lucide-react'
import { useMemo } from 'react'
@@ -79,6 +79,7 @@ function MergeWallsAction() {
useScene.getState().applyNodeChanges(plan.changes),
)
useViewer.getState().setSelection({ selectedIds: [plan.wallId] })
+ triggerSFX('sfx:structure-build')
}}
>
diff --git a/packages/nodes/src/wall/drawing-mode.ts b/packages/nodes/src/wall/drawing-mode.ts
index 19ee6508a3..2a32fb1fa1 100644
--- a/packages/nodes/src/wall/drawing-mode.ts
+++ b/packages/nodes/src/wall/drawing-mode.ts
@@ -26,17 +26,14 @@ let owners = 0
/**
* R toggles line / rectangle drawing (the HUD's Shape chip cycles the same
* store). Both wall tools mount this: the 3D tool only exists once the canvas
- * does, and split view mounts both, so one listener serves whichever is up and
- * the mode returns to line when the last one closes.
+ * does, and split view mounts both, so one listener serves whichever is up.
+ * The mode outlives the tool: re-arming walls resumes the last shape used.
*/
export function useWallDrawingModeKeys() {
useEffect(() => {
if (owners++ === 0) window.addEventListener('keydown', onKeyDown)
return () => {
- if (--owners === 0) {
- window.removeEventListener('keydown', onKeyDown)
- useWallDrawingMode.setState({ mode: 'line' })
- }
+ if (--owners === 0) window.removeEventListener('keydown', onKeyDown)
}
}, [])
}
diff --git a/packages/nodes/src/wall/floorplan-tool.tsx b/packages/nodes/src/wall/floorplan-tool.tsx
index 1e1f8b22e9..987171c57d 100644
--- a/packages/nodes/src/wall/floorplan-tool.tsx
+++ b/packages/nodes/src/wall/floorplan-tool.tsx
@@ -78,6 +78,7 @@ function RectangleFloorplanTool({ activeLevelId }: FloorplanToolContext) {
)
draft().setWallRectangleDraftStart(point)
setError(null)
+ triggerSFX('sfx:structure-build-start')
return
}
try {
@@ -97,11 +98,22 @@ function RectangleFloorplanTool({ activeLevelId }: FloorplanToolContext) {
const stopDouble = (e: MouseEvent) => {
if (e.button === 0) claim(e)
}
+ // The panel publishes the snapped cursor only when it moves to a new
+ // point, so each change while a corner is down is the line draft's tick.
+ const stopTick = useFloorplanDraftPreview.subscribe((state, previous) => {
+ if (
+ state.wallRectangleDraftStart &&
+ state.cursorPoint &&
+ state.cursorPoint !== previous.cursorPoint
+ )
+ triggerSFX('sfx:grid-snap')
+ })
svg.addEventListener('pointerdown', onDown, true)
svg.addEventListener('click', onClick, true)
svg.addEventListener('dblclick', stopDouble, true)
emitter.on('tool:cancel', cancel)
return () => {
+ stopTick()
svg.removeEventListener('pointerdown', onDown, true)
svg.removeEventListener('click', onClick, true)
svg.removeEventListener('dblclick', stopDouble, true)
diff --git a/packages/nodes/src/wall/rectangle-tool.tsx b/packages/nodes/src/wall/rectangle-tool.tsx
index 106fbb317c..c2751bebde 100644
--- a/packages/nodes/src/wall/rectangle-tool.tsx
+++ b/packages/nodes/src/wall/rectangle-tool.tsx
@@ -58,6 +58,7 @@ export default function RectangleWallTool() {
setDraft(null)
setMessage('')
let start: WallPlanPoint | null = null
+ let end: WallPlanPoint | null = null
let plane: ReturnType | null = null
useInteractionScope.getState().begin({ kind: 'drafting', tool: 'wall' })
const pointFor = (e: GridEvent) => {
@@ -87,7 +88,11 @@ export default function RectangleWallTool() {
if (plane) publishHorizontalConstructionPlane(e, plane)
setCursor([point[0], hoverPlane.localY, point[1]])
setMessage('')
- if (start) setDraft({ start, end: point, y: hoverPlane.localY })
+ if (!start) return
+ // The line draft's tick: once per snapped corner position.
+ if (end && (end[0] !== point[0] || end[1] !== point[1])) triggerSFX('sfx:grid-snap')
+ end = point
+ setDraft({ start, end: point, y: hoverPlane.localY })
}
const leave = () => {
setCursor(null)
@@ -96,6 +101,7 @@ export default function RectangleWallTool() {
const cancel = () => {
if (start) markToolCancelConsumed()
start = null
+ end = null
plane = null
setDraft(null)
setMessage('')
@@ -108,11 +114,13 @@ export default function RectangleWallTool() {
const point = pointFor(e)
if (!start) {
start = point
+ end = point
plane = resolveEventConstructionPlane(e, null)
publishHorizontalConstructionPlane(e, plane)
setCursor([point[0], plane.localY, point[1]])
setDraft({ start, end: point, y: plane.localY })
setMessage('')
+ triggerSFX('sfx:structure-build-start')
return
}
try {
diff --git a/packages/nodes/src/wall/split-pointer.test.ts b/packages/nodes/src/wall/split-pointer.test.ts
index f735ab501b..f48004eac5 100644
--- a/packages/nodes/src/wall/split-pointer.test.ts
+++ b/packages/nodes/src/wall/split-pointer.test.ts
@@ -22,10 +22,11 @@ const emit = (type: string, distance: number, button = 0, altKey = false) => {
events.dispatchEvent(event)
return event
}
-const scroll = (deltaY: number, target: unknown = surface) => {
+const scroll = (deltaY: number, at: number, target: unknown = surface, deltaMode = 0) => {
const event = new Event('wheel', { cancelable: true })
- Object.assign(event, { deltaY, deltaMode: 0, ctrlKey: false })
+ Object.assign(event, { deltaY, deltaMode, ctrlKey: false })
Object.defineProperty(event, 'target', { value: target })
+ Object.defineProperty(event, 'timeStamp', { value: at })
events.dispatchEvent(event)
return event
}
@@ -66,16 +67,35 @@ test('the single cut follows the pointer on the grid; Alt places it freely', ()
expect(useScene.temporal.getState().pastStates).toHaveLength(0)
})
test('scrolling over the viewport changes the cut count; elsewhere it scrolls', () => {
- expect(scroll(-120).defaultPrevented).toBe(true)
- expect(useWallSplit.getState().draft?.cuts).toBe(3)
- scroll(60)
+ expect(scroll(-120, 1000).defaultPrevented).toBe(true)
expect(cutAt()).toEqual([8 / 3, 16 / 3])
- expect(scroll(-120, {}).defaultPrevented).toBe(false)
+ expect(scroll(-120, 1600, {}).defaultPrevented).toBe(false)
expect(useWallSplit.getState().draft?.cuts).toBe(2)
// Evenly spaced cuts ignore the pointer.
emit('pointermove', 1)
expect(cutAt()).toEqual([8 / 3, 16 / 3])
})
+test('each notch of a notched wheel is one cut, however few pixels it reports', () => {
+ // Slow notches (macOS reports a handful of pixels each), then Firefox line mode.
+ for (const [at, cuts] of [
+ [1000, 2],
+ [1150, 3],
+ [1300, 4],
+ ] as const) {
+ scroll(-4, at)
+ expect(useWallSplit.getState().draft?.cuts).toBe(cuts)
+ }
+ scroll(3, 1310, surface, 1)
+ expect(useWallSplit.getState().draft?.cuts).toBe(3)
+})
+test('a continuous trackpad stream steps by travel after its first event', () => {
+ scroll(-10, 1000)
+ expect(useWallSplit.getState().draft?.cuts).toBe(2)
+ for (let at = 1016; at <= 1080; at += 16) scroll(-10, at)
+ expect(useWallSplit.getState().draft?.cuts).toBe(2)
+ scroll(-10, 1096)
+ expect(useWallSplit.getState().draft?.cuts).toBe(3)
+})
test('left click commits the release mark once; orbit and out-of-wall clicks do not cut', () => {
expect(emit('pointerdown', 3, 2).defaultPrevented).toBe(false)
emit('pointerup', 3, 2)
diff --git a/packages/nodes/src/wall/split-pointer.ts b/packages/nodes/src/wall/split-pointer.ts
index 3bf965d79c..45a57cd3c6 100644
--- a/packages/nodes/src/wall/split-pointer.ts
+++ b/packages/nodes/src/wall/split-pointer.ts
@@ -1,9 +1,12 @@
import { commitWallSplit, hoverWallSplit, setWallSplitCuts } from './split-session'
import { useWallSplit } from './split-store'
-// Pixels of wheel travel per cut: one mouse notch, or a short trackpad swipe.
+// Pixels of continuous wheel travel per cut: a short trackpad swipe.
const WHEEL_STEP_PX = 60
-const WHEEL_LINE_PX = 33
+// A wheel event after this pause is a new notch or gesture and steps at once,
+// so each notch of a notched wheel is one cut whatever pixels the OS reports
+// for it; a continuous stream (trackpad, fast spin) steps by travel.
+const WHEEL_GESTURE_GAP_MS = 80
/** Both viewports feed a wall distance; neither writes scene nodes while hovering. */
export function bindWallSplitPointer(
@@ -12,6 +15,7 @@ export function bindWallSplitPointer(
) {
let pressed: number | null = null
let wheelTravel = 0
+ let lastWheelAt = Number.NEGATIVE_INFINITY
const move = (event: PointerEvent) => {
if (event.buttons && pressed !== event.pointerId) return
const distance = distanceAt(event)
@@ -53,10 +57,20 @@ export function bindWallSplitPointer(
if (event.ctrlKey || !(event.target instanceof Node) || !surface.contains(event.target)) return
event.preventDefault()
event.stopImmediatePropagation()
- wheelTravel += event.deltaMode === 1 ? event.deltaY * WHEEL_LINE_PX : event.deltaY
- const steps = Math.trunc(wheelTravel / WHEEL_STEP_PX)
+ if (!event.deltaY) return
+ const fresh = event.timeStamp - lastWheelAt > WHEEL_GESTURE_GAP_MS
+ lastWheelAt = event.timeStamp
+ let steps: number
+ // Line-mode deltas only come from a notched wheel: one line event, one notch.
+ if (fresh || event.deltaMode !== 0) {
+ wheelTravel = 0
+ steps = Math.sign(event.deltaY)
+ } else {
+ wheelTravel += event.deltaY
+ steps = Math.trunc(wheelTravel / WHEEL_STEP_PX)
+ wheelTravel -= steps * WHEEL_STEP_PX
+ }
if (!steps) return
- wheelTravel -= steps * WHEEL_STEP_PX
const draft = useWallSplit.getState().draft
if (draft) setWallSplitCuts(draft.cuts - steps)
}
diff --git a/packages/nodes/src/wall/split-session.ts b/packages/nodes/src/wall/split-session.ts
index 1dcbd29f2a..3cb46b8939 100644
--- a/packages/nodes/src/wall/split-session.ts
+++ b/packages/nodes/src/wall/split-session.ts
@@ -8,6 +8,7 @@ import {
import {
isGridSnapActive,
isMagneticSnapActive,
+ triggerSFX,
useEditor,
useInteractionScope,
} from '@pascal-app/editor'
@@ -153,4 +154,5 @@ export function commitWallSplit() {
const plan = planWallDivisions(current.nodes, wall.id, checked.preview.distances)
runAsSingleSceneHistoryStep(useScene, () => current.applyNodeChanges(plan.changes))
closeWallSplit()
+ triggerSFX('sfx:structure-build')
}