Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/nodes/src/wall/actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -79,6 +79,7 @@ function MergeWallsAction() {
useScene.getState().applyNodeChanges(plan.changes),
)
useViewer.getState().setSelection({ selectedIds: [plan.wallId] })
triggerSFX('sfx:structure-build')
}}
>
<FoldHorizontal className="size-4" />
Expand Down
9 changes: 3 additions & 6 deletions packages/nodes/src/wall/drawing-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}, [])
}
12 changes: 12 additions & 0 deletions packages/nodes/src/wall/floorplan-tool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ function RectangleFloorplanTool({ activeLevelId }: FloorplanToolContext) {
)
draft().setWallRectangleDraftStart(point)
setError(null)
triggerSFX('sfx:structure-build-start')
return
}
try {
Expand All @@ -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)
Expand Down
10 changes: 9 additions & 1 deletion packages/nodes/src/wall/rectangle-tool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export default function RectangleWallTool() {
setDraft(null)
setMessage('')
let start: WallPlanPoint | null = null
let end: WallPlanPoint | null = null
let plane: ReturnType<typeof resolveEventConstructionPlane> | null = null
useInteractionScope.getState().begin({ kind: 'drafting', tool: 'wall' })
const pointFor = (e: GridEvent) => {
Expand Down Expand Up @@ -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)
Expand All @@ -96,6 +101,7 @@ export default function RectangleWallTool() {
const cancel = () => {
if (start) markToolCancelConsumed()
start = null
end = null
plane = null
setDraft(null)
setMessage('')
Expand All @@ -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 {
Expand Down
32 changes: 26 additions & 6 deletions packages/nodes/src/wall/split-pointer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down
24 changes: 19 additions & 5 deletions packages/nodes/src/wall/split-pointer.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
Expand Down
2 changes: 2 additions & 0 deletions packages/nodes/src/wall/split-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import {
isGridSnapActive,
isMagneticSnapActive,
triggerSFX,
useEditor,
useInteractionScope,
} from '@pascal-app/editor'
Expand Down Expand Up @@ -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')
}
Loading