fix(editor): wall-item placement & floorplan polish
Polish pass on the placement/interaction overhaul. Five fixes: - Item-on-item rotation box: the cursor box held the host-local yaw instead of world yaw, so it diverged from the item by the host's rotation when stacked on another item (fine on the floor). The box now derives world yaw from the mesh/host quaternion in both the R/T handler and the move-start sync. - 2D floorplan move now respects the snapping mode (parity with 3D): grid quantization only in grid mode, alignment guides only in lines/magnetic mode; Shift/Alt no longer hard-bypass. Item move also plays the move "tick" SFX on any resolved-position change, like the 3D move. - Wall-side item footprint side: the 2D footprint depth offset extended toward the wall (centerLocalZ -depth/2) instead of into the room, mirroring the item across the wall; flipped to +depth/2. Aligned the undefined-side anchor to the 3D convention (front +1 / else -1). - 3D placement preview side: the wall-side preview bounds + base plane used a -Z (into-wall) convention; flipped to +Z (into-room) to match the body and the fixed 2D footprint. The 2D live preview during a 3D wall placement now publishes the plan rotation (wall angle + item yaw) instead of the world cursor yaw, which was pi off on a wall face and flipped the footprint to the far side. - Cmd/Ctrl+R no longer rotates/flips the selected node (it reaches the browser reload); guard added to the global selected-node handler and the 2D move overlay. Verified: core/editor/nodes tsc, biome, editor 162/0 + nodes 292/0 tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
9084396a82
commit
ba464ef47e
@@ -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