Merge pull request #450 from pascalorg/feat/placement-interaction-polish

fix(editor): wall-item placement & floorplan polish
This commit is contained in:
Wassim SAMAD
2026-06-29 10:30:45 -04:00
committed by GitHub
6 changed files with 116 additions and 49 deletions
@@ -125,6 +125,10 @@ export function FloorplanRegistryMoveOverlay() {
// to be consumed. That legacy flow is gone in the registry layer;
// all entries use the action menu now.
let hasMovedSinceStart = false
// Last resolved position of the moved node — drives the move "tick" SFX.
// Parity with the 3D move, which emits on any change of the resolved
// position (every snapping mode, not only grid).
let lastSnapKey: string | null = null
// Live cursor location — updated on EVERY pointermove (even over the 3D
// canvas) so R-key ownership can follow the pointer's CURRENT pane rather
// than the sticky `hasMovedSinceStart`. Without this, once the user touched
@@ -155,6 +159,19 @@ export function FloorplanRegistryMoveOverlay() {
metaKey: event.metaKey,
},
})
// Move "tick" — same feedback the 3D move gives, which fires whenever the
// resolved position changes (any snapping mode, not just grid), so it
// ticks as the item lands on each new snapped/free position.
const movedId = session.affectedIds[0]
const moved = movedId ? useScene.getState().nodes[movedId] : undefined
const pos = (moved as { position?: [number, number, number] } | undefined)?.position
if (pos) {
const key = `${pos[0]},${pos[2]}`
if (key !== lastSnapKey) {
lastSnapKey = key
sfxEmitter.emit('sfx:grid-snap')
}
}
}
const commitFinalStateOrRevert = () => {
@@ -307,6 +324,8 @@ export function FloorplanRegistryMoveOverlay() {
// `hasMovedSinceStart`-only gate made the overlay claim R forever after
// the first 2D move, killing the 3D flip.)
if (event.key === 'r' || event.key === 'R') {
// Yield Cmd/Ctrl+R to the browser reload instead of flipping the side.
if (event.metaKey || event.ctrlKey) return
if (!(session.flipSide && hasMovedSinceStart && pointerOverFloorplan)) return
if (event.repeat) return
const t = event.target as HTMLElement | null
@@ -94,8 +94,9 @@ const RIGHT_CLICK_CANCEL_MAX_MS = 200
* Expand `bounds` outward so each axis is rounded up to the active grid step.
* The wireframe stays centered on the original bounds centre on each axis we
* expand, so an off-centre mesh bbox stays off-centre. Wall-side items keep
* `max.z = 0` (flush with the wall plane); the bottom (`min.y`) is preserved
* so the box still sits on the floor / attachment plane.
* `min.z = 0` (the mounted face flush with the wall plane) and extend into the
* room along +Z — matching the body and the 2D footprint; the bottom (`min.y`)
* is preserved so the box still sits on the floor / attachment plane.
*
* Floor / ceiling / item-surface: X and Z expand; Y stays exact.
* Wall / wall-side: X and Y expand; Z stays exact.
@@ -121,9 +122,9 @@ function expandBoundsToGrid(
let maxZ: number
let newCz: number
if (attachTo === 'wall-side') {
maxZ = 0
minZ = -expandedD
newCz = -expandedD / 2
minZ = 0
maxZ = expandedD
newCz = expandedD / 2
} else {
minZ = cz - expandedD / 2
maxZ = cz + expandedD / 2
@@ -145,10 +146,10 @@ function getFallbackPreviewBounds(
): PreviewBounds {
const dims = item ? getScaledDimensions(item) : (asset?.dimensions ?? DEFAULT_DIMENSIONS)
return {
min: [-dims[0] / 2, 0, attachTo === 'wall-side' ? -dims[2] : -dims[2] / 2],
max: [dims[0] / 2, dims[1], attachTo === 'wall-side' ? 0 : dims[2] / 2],
min: [-dims[0] / 2, 0, attachTo === 'wall-side' ? 0 : -dims[2] / 2],
max: [dims[0] / 2, dims[1], attachTo === 'wall-side' ? dims[2] : dims[2] / 2],
dimensions: dims,
center: [0, dims[1] / 2, attachTo === 'wall-side' ? -dims[2] / 2 : 0],
center: [0, dims[1] / 2, attachTo === 'wall-side' ? dims[2] / 2 : 0],
}
}
@@ -690,8 +691,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const localPos = worldToBuildingLocal(worldPos.x, worldPos.y, worldPos.z)
if (cursorGroupRef.current) {
cursorGroupRef.current.position.copy(localPos)
if (draftNode.current.asset.attachTo) {
// Wall/ceiling items: extract world Y rotation (handles wall-parented items correctly)
if (
draftNode.current.asset.attachTo ||
placementState.current?.surface === 'item-surface'
) {
// Wall/ceiling items AND items hosted on another item: the mesh is parented
// to a rotated host, so the box's building-local yaw must come from the mesh's
// world rotation, not the node's host-local `rotation[1]` (which would leave the
// box rotated by the host's yaw relative to the item).
const q = new Quaternion()
mesh.getWorldQuaternion(q)
cursorGroupRef.current.rotation.y = new Euler().setFromQuaternion(q, 'YXZ').y
@@ -1094,10 +1101,24 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
}
}
// Publish live transform for 2D floorplan
// Publish live transform for the 2D floorplan. The floorplan resolves a
// wall item's footprint (and its wall-side depth offset) from this
// rotation as a PLAN-space yaw. `cursorRotationY` is the 3D world cursor
// yaw, which is π off from the plan rotation on a wall face — feeding it
// raw flips the footprint to the far side of the wall during placement.
// Publish the plan rotation (wall angle + the item's wall-local yaw) so
// the preview matches what the committed node resolves to.
let liveRotation = result.cursorRotationY
const liveWallId = placementState.current.wallId
const liveWall = liveWallId ? useScene.getState().nodes[liveWallId as AnyNodeId] : undefined
if (liveWall?.type === 'wall') {
const w = liveWall as WallNode
const wallPlanRotation = -Math.atan2(w.end[1] - w.start[1], w.end[0] - w.start[0])
liveRotation = wallPlanRotation + (draft.rotation[1] ?? 0)
}
useLiveTransforms.getState().set(draft.id, {
position: result.cursorPosition,
rotation: result.cursorRotationY,
rotation: liveRotation,
})
}
}
@@ -1941,8 +1962,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
worldSnapped.y,
worldSnapped.z,
)
const surfaceQuat = new Quaternion()
surfaceMesh.getWorldQuaternion(surfaceQuat)
const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y
if (cursorGroupRef.current) {
cursorGroupRef.current.position.set(localSnapped.x, localSnapped.y, localSnapped.z)
// The box lives in building-local space while the mesh is parented to the host
// item, so add the host's world yaw: the box must track the item's true
// orientation, not its host-local `rotation[1]`.
cursorGroupRef.current.rotation.y = newRotationY + surfaceWorldY
}
if (mesh) mesh.position.set(x, y, z)
}
@@ -2352,7 +2380,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
? getScaledDimensions(initialDraft)
: (config.asset?.dimensions ?? DEFAULT_DIMENSIONS)
const dims = getGridAlignedDimensions(rawDims, initialAttachTo, gridSnapStep)
const wallSideZOffset = initialAttachTo === 'wall-side' ? -dims[2] / 2 : 0
const wallSideZOffset = initialAttachTo === 'wall-side' ? dims[2] / 2 : 0
const initialDimensionBounds = expandBoundsToGrid(
getFallbackPreviewBounds(initialDraft, config.asset, initialAttachTo),
initialAttachTo,
+9 -1
View File
@@ -254,7 +254,15 @@ export const useKeyboard = ({
}
}
}
} else if ((e.key === 'r' || e.key === 'R') && !isVersionPreviewMode && !isPlacingOpening()) {
} else if (
(e.key === 'r' || e.key === 'R') &&
!e.metaKey &&
!e.ctrlKey &&
!isVersionPreviewMode &&
!isPlacingOpening()
) {
// `!metaKey && !ctrlKey` lets Cmd/Ctrl+R reach the browser reload instead
// of rotating/flipping the selected node.
// Rotate selected node clockwise if it supports rotation (items, roofs, etc.)
// Doors use R to flip side (front ↔ back, rotation += π); their
// open/close toggle lives on E. Windows still use R to toggle
+5 -2
View File
@@ -82,7 +82,7 @@ export function getItemFloorplanTransform(
)
const wallLocalZ =
item.asset.attachTo === 'wall-side'
? ((parentNode.thickness ?? 0.1) / 2) * (item.side === 'back' ? -1 : 1)
? ((parentNode.thickness ?? 0.1) / 2) * (item.side === 'front' ? 1 : -1)
: item.position[2]
const [offsetX, offsetY] = rotatePlanVector(item.position[0], wallLocalZ, wallRotation)
@@ -164,7 +164,10 @@ type Point = {
function getItemDimensionPolygon(item: ItemNode, transform: FloorplanNodeTransform): Point[] {
const [width, , depth] = getScaledDimensions(item)
const centerLocalZ = item.asset.attachTo === 'wall-side' ? -depth / 2 : 0
// Wall-side items extend depth-ward away from the wall (into the room); push
// the footprint centre a half-depth out along local +Z. A negative offset
// would lay the box across the wall onto the far side (mirrored from 3D).
const centerLocalZ = item.asset.attachTo === 'wall-side' ? depth / 2 : 0
const [offsetX, offsetY] = rotatePlanVector(0, centerLocalZ, transform.rotation)
return getRotatedRectanglePolygon(
+35 -29
View File
@@ -13,7 +13,13 @@ import {
roofFacePointToSegment,
useScene,
} from '@pascal-app/core'
import { applyFloorplanAlignment, useEditor, type WallPlanPoint } from '@pascal-app/editor'
import {
applyFloorplanAlignment,
isGridSnapActive,
isMagneticSnapActive,
useEditor,
type WallPlanPoint,
} from '@pascal-app/editor'
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
import { findClosestWallInPlan, snapLocalXToNeighbors } from '../shared/wall-attach-target'
@@ -70,7 +76,7 @@ function resolveItemPlanTransform(
)
const wallLocalZ =
item.asset.attachTo === 'wall-side'
? ((parent.thickness ?? 0.1) / 2) * (item.side === 'back' ? -1 : 1)
? ((parent.thickness ?? 0.1) / 2) * (item.side === 'front' ? 1 : -1)
: item.position[2]
const [offsetX, offsetZ] = rotateVec(item.position[0], wallLocalZ, wallRotation)
result = {
@@ -143,12 +149,11 @@ function createPlanarMovePointResolver(originalPlanPoint: [number, number], node
metadata: node.metadata,
})
return (planPoint: readonly [number, number], shiftKey: boolean): WallPlanPoint => {
const snap = (value: number) => {
if (shiftKey) return value
const step = useEditor.getState().gridSnapStep
return Math.round(value / step) * step
}
return (planPoint: readonly [number, number]): WallPlanPoint => {
// Grid snap is mode-driven (matching 3D): quantize only when grid mode is
// active; in lines/off mode the cursor passes through unsnapped.
const step = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0
const snap = (value: number) => (step <= 0 ? value : Math.round(value / step) * step)
return resolveCursor(planPoint, { snap }) as WallPlanPoint
}
}
@@ -201,7 +206,7 @@ function buildWallItemSession(
return {
affectedIds: [node.id as AnyNodeId],
apply({ planPoint, modifiers }) {
apply({ planPoint }) {
const nodes = useScene.getState().nodes
const resolvedPlanPoint = resolveCursor(planPoint)
const hit = findClosestWallInPlan(resolvedPlanPoint, nodes, startLevelId)
@@ -210,21 +215,21 @@ function buildWallItemSession(
const [width] = getScaledDimensions(node)
// Figma-style along-wall alignment (edge-to-edge with other openings /
// wall items / wall ends), winning over the 0.5m grid snap; falls back
// to grid when nothing aligns. Alt bypasses alignment; Shift bypasses all snap.
const neighborX =
modifiers.altKey || modifiers.shiftKey
? null
: snapLocalXToNeighbors({
wall: hit.wall,
localX: hit.localX,
width,
selfId: node.id as AnyNodeId,
nodes,
})
const step = useEditor.getState().gridSnapStep
// wall items / wall ends), winning over the grid snap; falls back to grid
// when nothing aligns. Both are mode-driven (matching 3D): alignment only in
// lines/magnetic mode, grid quantization only in grid mode.
const neighborX = isMagneticSnapActive()
? snapLocalXToNeighbors({
wall: hit.wall,
localX: hit.localX,
width,
selfId: node.id as AnyNodeId,
nodes,
})
: null
const step = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0
const snappedLocalX =
neighborX ?? (modifiers.shiftKey ? hit.localX : Math.round(hit.localX / step) * step)
neighborX ?? (step <= 0 ? hit.localX : Math.round(hit.localX / step) * step)
const halfW = width / 2
const clampedX = Math.max(halfW, Math.min(hit.wallLength - halfW, snappedLocalX))
@@ -275,9 +280,10 @@ function buildFloorItemSession(
const candidates = collectAlignmentAnchors(nodes, node.id)
return {
affectedIds: [node.id as AnyNodeId],
apply({ planPoint, modifiers }) {
const gridSnapped = resolvePlanPoint(planPoint, modifiers.shiftKey)
// Figma-style alignment layered on the grid snap (Alt bypasses).
apply({ planPoint }) {
const gridSnapped = resolvePlanPoint(planPoint)
// Figma-style alignment layered on the grid snap, mode-driven (matching 3D):
// guides only resolve/snap when magnetic (lines) mode is active.
const { point: snapped } = applyFloorplanAlignment(
gridSnapped,
movingFootprintAnchors(
@@ -287,7 +293,7 @@ function buildFloorItemSession(
rotationY,
),
candidates,
{ bypass: modifiers.altKey || modifiers.shiftKey },
{ bypass: !isMagneticSnapActive() },
)
const sourceY = node.position[1]
@@ -332,9 +338,9 @@ function buildSurfaceItemSession(
)
return {
affectedIds: [node.id as AnyNodeId],
apply({ planPoint, modifiers }) {
apply({ planPoint }) {
const nodes = useScene.getState().nodes
const snapped = resolvePlanPoint(planPoint, modifiers.shiftKey)
const snapped = resolvePlanPoint(planPoint)
const surface = findContainingSurface(snapped, nodes, startLevelId, targetKind)
+7 -4
View File
@@ -67,7 +67,7 @@ function resolveItemTransform(
const wallRotation = -Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0])
const wallLocalZ =
item.asset.attachTo === 'wall-side'
? ((wall.thickness ?? 0.1) / 2) * (item.side === 'back' ? -1 : 1)
? ((wall.thickness ?? 0.1) / 2) * (item.side === 'front' ? 1 : -1)
: item.position[2]
const [offsetX, offsetY] = rotateVec(item.position[0], wallLocalZ, wallRotation)
result = {
@@ -160,9 +160,12 @@ export function buildItemFloorplan(node: ItemNode, ctx: GeometryContext): Floorp
const [width, , depth] = getScaledDimensions(node)
if (width <= 0 || depth <= 0) return null
// Wall-side items are anchored at the front face — center their footprint
// half-a-depth back toward the wall surface.
const centerLocalZ = node.asset.attachTo === 'wall-side' ? -depth / 2 : 0
// Wall-side items are anchored at the mounted wall face; their body extends
// depth-ward AWAY from the wall (into the room), so push the footprint centre
// a half-depth out along the item's local +Z. After the front/back π flip in
// `transform.rotation`, +depth/2 always points off the wall for either side;
// a negative offset would lay the footprint across the wall onto the far side.
const centerLocalZ = node.asset.attachTo === 'wall-side' ? depth / 2 : 0
const [centerOffsetX, centerOffsetY] = rotateVec(0, centerLocalZ, transform.rotation)
const cx = transform.x + centerOffsetX
const cy = transform.y + centerOffsetY