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') }