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
+1
View File
@@ -197,6 +197,7 @@ export const windowDefinition: NodeDefinition<typeof WindowNode> = {
toolHints: [
{ key: 'Left click', label: 'Place window on wall' },
{ key: 'Shift', label: 'Free place' },
{ key: 'Esc', label: 'Cancel' },
],
+44 -6
View File
@@ -15,6 +15,7 @@ import {
import {
calculateCursorRotation,
calculateItemRotation,
consumePlacementDragRelease,
EDITOR_LAYER,
getSideFromNormal,
isValidWallSideFace,
@@ -99,6 +100,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
}
let currentHostId: string | null = movingWindowNode.parentId
let committed = false
let dragAnchor: {
wallId: string
rawX: number
@@ -117,6 +119,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
valid: boolean
event: WallEvent
} | null = null
let lastRoofEvent: RoofEvent | null = null
const markHostDirty = (hostId: string | null) => {
if (hostId) useScene.getState().dirtyNodes.add(hostId as AnyNodeId)
@@ -285,29 +288,44 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
const onWallEnter = (event: WallEvent) => {
const target = resolveMoveTarget(event)
if (!target) return
if (!target) {
onWallLeave()
return
}
lastTarget = target
lastRoofEvent = null
applyPreview(target)
event.stopPropagation()
}
const onWallMove = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return
if (!isValidWallSideFace(event.normal)) {
onWallLeave()
return
}
if (isCurvedWall(event.node)) {
hideCursor()
onWallLeave()
return
}
// Only interact with walls on the current level
if (event.node.parentId !== getLevelId()) return
if (event.node.parentId !== getLevelId()) {
onWallLeave()
return
}
const target = resolveMoveTarget(event)
if (!target) return
if (!target) {
onWallLeave()
return
}
lastTarget = target
lastRoofEvent = null
applyPreview(target)
event.stopPropagation()
}
const onWallClick = (event: WallEvent) => {
if (committed) return
if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) return
// Only interact with walls on the current level
@@ -315,6 +333,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event)
if (!target?.valid) return
committed = true
let placedId: string
@@ -387,6 +406,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
useLiveTransforms.getState().clear(movingWindowNode.id)
dragAnchor = null
lastTarget = null
lastRoofEvent = null
if (isNew) return // No original to restore for duplicates
// Move mode: restore to original position while off-wall
if (currentHostId && currentHostId !== original.parentId) {
@@ -430,10 +450,14 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
const onRoofHover = (event: RoofEvent) => {
const target = resolveRoofMoveTarget(event)
if (!target) return
if (!target) {
onRoofLeave()
return
}
// Wall-frame drag anchor / live transform don't apply on a roof face.
dragAnchor = null
lastTarget = null
lastRoofEvent = event
useLiveTransforms.getState().clear(movingWindowNode.id)
if (currentHostId !== target.segment.id) {
useScene.getState().updateNode(movingWindowNode.id, {
@@ -459,8 +483,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
}
const onRoofClick = (event: RoofEvent) => {
if (committed) return
const target = resolveRoofMoveTarget(event)
if (!target?.valid) return
committed = true
const segmentId = target.segment.id
let placedId: string
@@ -531,6 +557,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
useLiveTransforms.getState().clear(movingWindowNode.id)
dragAnchor = null
lastTarget = null
lastRoofEvent = null
if (isNew) return
if (currentHostId && currentHostId !== original.parentId) {
markHostDirty(currentHostId)
@@ -571,6 +598,15 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
exitMoveMode()
}
const onPlacementDragPointerUp = (event: PointerEvent) => {
if (!consumePlacementDragRelease(event)) return
if (lastTarget) {
onWallClick(lastTarget.event)
return
}
if (lastRoofEvent) onRoofClick(lastRoofEvent)
}
emitter.on('wall:enter', onWallEnter)
emitter.on('wall:move', onWallMove)
emitter.on('wall:click', onWallClick)
@@ -580,6 +616,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
emitter.on('roof:click', onRoofClick)
emitter.on('roof:leave', onRoofLeave)
emitter.on('tool:cancel', onCancel)
window.addEventListener('pointerup', onPlacementDragPointerUp)
return () => {
// Safety cleanup: if still transient on unmount (e.g. phase switch mid-move)
@@ -617,6 +654,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
emitter.off('roof:click', onRoofClick)
emitter.off('roof:leave', onRoofLeave)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
}
}, [movingWindowNode, exitMoveMode])
+60 -14
View File
@@ -2,6 +2,7 @@ import {
type AnyNodeId,
collectAlignmentAnchors,
emitter,
type GridEvent,
isCurvedWall,
type RoofEvent,
type RoofNode,
@@ -23,12 +24,13 @@ import {
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react'
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three'
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu'
import {
getRoofWallOpeningCursorPose,
type RoofWallOpeningTarget,
resolveRoofWallOpeningTarget,
worldToSelectedBuildingLocal,
} from '../shared/roof-wall-opening-placement'
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
@@ -41,6 +43,11 @@ const edgeMaterial = new LineBasicNodeMaterial({
depthWrite: false,
})
const FALLBACK_WIDTH = 1.5
const FALLBACK_HEIGHT = 1.5
const FALLBACK_SILL_LIFT = 0.45
const roofFallbackPoint = new Vector3()
/**
* Window tool — places WindowNodes on walls and on roof-segment wall
* faces (the generated base walls under a roof, including coplanar gable
@@ -103,17 +110,48 @@ const WindowTool: React.FC = () => {
edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
}
const showFallbackCursor = (event: GridEvent) => {
if (draftRef.current) return
const [x, y, z] = event.localPosition
updateCursor([x, y + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], 0, false)
useAlignmentGuides.getState().clear()
}
const showRoofFallbackCursor = (event: RoofEvent) => {
const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position))
updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], 0, false)
useAlignmentGuides.getState().clear()
}
const showWallFallbackCursor = (event: WallEvent) => {
const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position))
updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], 0, false)
useAlignmentGuides.getState().clear()
}
const onWallEnter = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return
if (!isValidWallSideFace(event.normal)) {
destroyDraft()
showWallFallbackCursor(event)
return
}
if (isCurvedWall(event.node)) {
destroyDraft()
hideCursor()
showWallFallbackCursor(event)
return
}
const levelId = getLevelId()
if (!levelId) return
if (!levelId) {
destroyDraft()
showWallFallbackCursor(event)
return
}
// Only interact with walls on the current level
if (event.node.parentId !== levelId) return
if (event.node.parentId !== levelId) {
destroyDraft()
showWallFallbackCursor(event)
return
}
destroyDraft()
@@ -167,14 +205,22 @@ const WindowTool: React.FC = () => {
}
const onWallMove = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return
if (!isValidWallSideFace(event.normal)) {
destroyDraft()
showWallFallbackCursor(event)
return
}
if (isCurvedWall(event.node)) {
destroyDraft()
hideCursor()
showWallFallbackCursor(event)
return
}
// Only interact with walls on the current level
if (event.node.parentId !== getLevelId()) return
if (event.node.parentId !== getLevelId()) {
destroyDraft()
showWallFallbackCursor(event)
return
}
const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal)
@@ -395,10 +441,8 @@ const WindowTool: React.FC = () => {
if (!target) {
// On the roof but not over a placeable wall face (slope, soffit,
// or a face the window cannot fit on).
if (draftRef.current?.roofSegmentId) {
destroyDraft()
hideCursor()
}
destroyDraft()
showRoofFallbackCursor(event)
return
}
const { segment, face, position } = target
@@ -499,6 +543,7 @@ const WindowTool: React.FC = () => {
emitter.on('roof:move', onRoofHover)
emitter.on('roof:click', onRoofClick)
emitter.on('roof:leave', onRoofLeave)
emitter.on('grid:move', showFallbackCursor)
emitter.on('tool:cancel', onCancel)
return () => {
@@ -514,12 +559,13 @@ const WindowTool: React.FC = () => {
emitter.off('roof:move', onRoofHover)
emitter.off('roof:click', onRoofClick)
emitter.off('roof:leave', onRoofLeave)
emitter.off('grid:move', showFallbackCursor)
emitter.off('tool:cancel', onCancel)
}
}, [])
// Cursor geometry: window outline rectangle (width × height × frameDepth)
const boxGeo = new BoxGeometry(1.5, 1.5, 0.07)
// Cursor geometry: window outline rectangle.
const boxGeo = new BoxGeometry(FALLBACK_WIDTH, FALLBACK_HEIGHT, 0.07)
const edgesGeo = new EdgesGeometry(boxGeo)
boxGeo.dispose()