diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx index 74f599a0..c3f4b459 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx @@ -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 diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index 353e58a4..1737edbe 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -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, diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index f9f46b8e..47aee91a 100644 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -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 diff --git a/packages/editor/src/lib/floorplan/items.ts b/packages/editor/src/lib/floorplan/items.ts index bb978ff7..0cc72520 100644 --- a/packages/editor/src/lib/floorplan/items.ts +++ b/packages/editor/src/lib/floorplan/items.ts @@ -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( diff --git a/packages/nodes/src/item/floorplan-move.ts b/packages/nodes/src/item/floorplan-move.ts index 49f14faf..637c0b8a 100644 --- a/packages/nodes/src/item/floorplan-move.ts +++ b/packages/nodes/src/item/floorplan-move.ts @@ -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) diff --git a/packages/nodes/src/item/floorplan.ts b/packages/nodes/src/item/floorplan.ts index 535ace14..365d896f 100644 --- a/packages/nodes/src/item/floorplan.ts +++ b/packages/nodes/src/item/floorplan.ts @@ -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