fix(editor): improve guided manipulation and snap affordances

This commit is contained in:
Aymeric Rabot
2026-06-11 23:59:15 -04:00
committed by GitHub
parent aab48e053f
commit 5411f5abc8
89 changed files with 2830 additions and 518 deletions
@@ -20,6 +20,7 @@ import {
import {
CursorSphere,
commitFreshPlacementSubtree,
consumePlacementDragRelease,
DragBoundingBox,
getFloorStackPreviewPosition,
resolvePlanarCursorPosition,
@@ -359,6 +360,7 @@ export const MoveRoofTool: React.FC<{
}
const onGridClick = (event: GridEvent) => {
if (wasCommitted) return
if (!hasMoved) return
const [localX, , localZ] = lastLocalPosition
@@ -394,6 +396,11 @@ export const MoveRoofTool: React.FC<{
event.nativeEvent?.stopPropagation?.()
}
const onPlacementDragPointerUp = (event: PointerEvent) => {
if (!consumePlacementDragRelease(event)) return
onGridClick({ nativeEvent: event } as unknown as GridEvent)
}
const onCancel = () => {
wasCancelled = true
useLiveTransforms.getState().clear(movingNode.id)
@@ -454,6 +461,7 @@ export const MoveRoofTool: React.FC<{
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('pointerup', onPlacementDragPointerUp)
return () => {
// Restore segment wrapper visibility (React will re-sync on next render)
@@ -485,6 +493,7 @@ export const MoveRoofTool: React.FC<{
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
}
}, [movingNode, exitMoveMode, isFreshPlacement, revealFreshPlacement, useAbsoluteCursorPlacement])
@@ -0,0 +1,111 @@
'use client'
import {
type AnyNodeId,
emitter,
type GridEvent,
type GutterEvent,
type RoofEvent,
sceneRegistry,
} from '@pascal-app/core'
import { DragBoundingBox } from '@pascal-app/editor'
import { useEffect, useRef, useState } from 'react'
import { Vector3 } from 'three'
const INVALID_PREVIEW_COLOR = 0xef_44_44
type ValidTarget = 'roof' | 'gutter'
export function RoofAttachmentFallbackPreview({
activeBuildingId,
isValidRoofTarget,
lift = 0,
onInvalidTarget,
size,
validTarget = 'roof',
}: {
activeBuildingId: string | null | undefined
isValidRoofTarget?: (event: RoofEvent) => boolean
lift?: number
onInvalidTarget?: () => void
size: [number, number, number]
validTarget?: ValidTarget
}) {
const [position, setPosition] = useState<[number, number, number] | null>(null)
const lastValidTargetEventRef = useRef<unknown>(null)
const localPointRef = useRef(new Vector3())
const isValidRoofTargetRef = useRef(isValidRoofTarget)
const onInvalidTargetRef = useRef(onInvalidTarget)
isValidRoofTargetRef.current = isValidRoofTarget
onInvalidTargetRef.current = onInvalidTarget
useEffect(() => {
if (!activeBuildingId) {
setPosition(null)
lastValidTargetEventRef.current = null
return
}
const trackValidHit = (nativeEvent: unknown) => {
lastValidTargetEventRef.current = nativeEvent
setPosition(null)
}
const showInvalidAt = (x: number, y: number, z: number) => {
setPosition([x, y + lift, z])
onInvalidTargetRef.current?.()
}
const showInvalidAtWorld = (event: RoofEvent) => {
const point = localPointRef.current.set(...event.position)
const building = sceneRegistry.nodes.get(activeBuildingId as AnyNodeId)
if (building) {
building.updateWorldMatrix(true, false)
building.worldToLocal(point)
}
showInvalidAt(point.x, 0, point.z)
}
const onRoofHit = (event: RoofEvent) => {
if (isValidRoofTargetRef.current?.(event) === false) {
showInvalidAtWorld(event)
return
}
trackValidHit(event.nativeEvent)
}
const onGutterHit = (event: GutterEvent) => trackValidHit(event.nativeEvent)
const onGridMove = (event: GridEvent) => {
if (event.nativeEvent === lastValidTargetEventRef.current) return
const [x, y, z] = event.localPosition
showInvalidAt(x, y, z)
}
if (validTarget === 'roof') {
emitter.on('roof:enter', onRoofHit)
emitter.on('roof:move', onRoofHit)
} else {
emitter.on('gutter:enter', onGutterHit)
emitter.on('gutter:move', onGutterHit)
}
emitter.on('grid:move', onGridMove)
return () => {
if (validTarget === 'roof') {
emitter.off('roof:enter', onRoofHit)
emitter.off('roof:move', onRoofHit)
} else {
emitter.off('gutter:enter', onGutterHit)
emitter.off('gutter:move', onGutterHit)
}
emitter.off('grid:move', onGridMove)
}
}, [activeBuildingId, lift, validTarget])
if (!(activeBuildingId && position)) return null
return (
<DragBoundingBox
color={INVALID_PREVIEW_COLOR}
nodeId="roof-attachment-fallback"
position={position}
size={size}
/>
)
}
@@ -88,7 +88,10 @@ const cursorPoint = new Vector3()
export function worldToSelectedBuildingLocal(point: Vector3): [number, number, number] {
const buildingId = useViewer.getState().selection.buildingId
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : undefined
if (buildingObj) buildingObj.worldToLocal(point)
if (buildingObj) {
buildingObj.updateWorldMatrix(true, false)
buildingObj.worldToLocal(point)
}
return [point.x, point.y, point.z]
}