Merge pull request #450 from pascalorg/feat/placement-interaction-polish
fix(editor): wall-item placement & floorplan polish
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user