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
@@ -125,6 +125,10 @@ export function FloorplanRegistryMoveOverlay() {
|
|||||||
// to be consumed. That legacy flow is gone in the registry layer;
|
// to be consumed. That legacy flow is gone in the registry layer;
|
||||||
// all entries use the action menu now.
|
// all entries use the action menu now.
|
||||||
let hasMovedSinceStart = false
|
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
|
// Live cursor location — updated on EVERY pointermove (even over the 3D
|
||||||
// canvas) so R-key ownership can follow the pointer's CURRENT pane rather
|
// canvas) so R-key ownership can follow the pointer's CURRENT pane rather
|
||||||
// than the sticky `hasMovedSinceStart`. Without this, once the user touched
|
// than the sticky `hasMovedSinceStart`. Without this, once the user touched
|
||||||
@@ -155,6 +159,19 @@ export function FloorplanRegistryMoveOverlay() {
|
|||||||
metaKey: event.metaKey,
|
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 = () => {
|
const commitFinalStateOrRevert = () => {
|
||||||
@@ -307,6 +324,8 @@ export function FloorplanRegistryMoveOverlay() {
|
|||||||
// `hasMovedSinceStart`-only gate made the overlay claim R forever after
|
// `hasMovedSinceStart`-only gate made the overlay claim R forever after
|
||||||
// the first 2D move, killing the 3D flip.)
|
// the first 2D move, killing the 3D flip.)
|
||||||
if (event.key === 'r' || event.key === 'R') {
|
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 (!(session.flipSide && hasMovedSinceStart && pointerOverFloorplan)) return
|
||||||
if (event.repeat) return
|
if (event.repeat) return
|
||||||
const t = event.target as HTMLElement | null
|
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.
|
* 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
|
* 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
|
* 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
|
* `min.z = 0` (the mounted face flush with the wall plane) and extend into the
|
||||||
* so the box still sits on the floor / attachment plane.
|
* 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.
|
* Floor / ceiling / item-surface: X and Z expand; Y stays exact.
|
||||||
* Wall / wall-side: X and Y expand; Z stays exact.
|
* Wall / wall-side: X and Y expand; Z stays exact.
|
||||||
@@ -121,9 +122,9 @@ function expandBoundsToGrid(
|
|||||||
let maxZ: number
|
let maxZ: number
|
||||||
let newCz: number
|
let newCz: number
|
||||||
if (attachTo === 'wall-side') {
|
if (attachTo === 'wall-side') {
|
||||||
maxZ = 0
|
minZ = 0
|
||||||
minZ = -expandedD
|
maxZ = expandedD
|
||||||
newCz = -expandedD / 2
|
newCz = expandedD / 2
|
||||||
} else {
|
} else {
|
||||||
minZ = cz - expandedD / 2
|
minZ = cz - expandedD / 2
|
||||||
maxZ = cz + expandedD / 2
|
maxZ = cz + expandedD / 2
|
||||||
@@ -145,10 +146,10 @@ function getFallbackPreviewBounds(
|
|||||||
): PreviewBounds {
|
): PreviewBounds {
|
||||||
const dims = item ? getScaledDimensions(item) : (asset?.dimensions ?? DEFAULT_DIMENSIONS)
|
const dims = item ? getScaledDimensions(item) : (asset?.dimensions ?? DEFAULT_DIMENSIONS)
|
||||||
return {
|
return {
|
||||||
min: [-dims[0] / 2, 0, attachTo === 'wall-side' ? -dims[2] : -dims[2] / 2],
|
min: [-dims[0] / 2, 0, attachTo === 'wall-side' ? 0 : -dims[2] / 2],
|
||||||
max: [dims[0] / 2, dims[1], attachTo === 'wall-side' ? 0 : dims[2] / 2],
|
max: [dims[0] / 2, dims[1], attachTo === 'wall-side' ? dims[2] : dims[2] / 2],
|
||||||
dimensions: dims,
|
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)
|
const localPos = worldToBuildingLocal(worldPos.x, worldPos.y, worldPos.z)
|
||||||
if (cursorGroupRef.current) {
|
if (cursorGroupRef.current) {
|
||||||
cursorGroupRef.current.position.copy(localPos)
|
cursorGroupRef.current.position.copy(localPos)
|
||||||
if (draftNode.current.asset.attachTo) {
|
if (
|
||||||
// Wall/ceiling items: extract world Y rotation (handles wall-parented items correctly)
|
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()
|
const q = new Quaternion()
|
||||||
mesh.getWorldQuaternion(q)
|
mesh.getWorldQuaternion(q)
|
||||||
cursorGroupRef.current.rotation.y = new Euler().setFromQuaternion(q, 'YXZ').y
|
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, {
|
useLiveTransforms.getState().set(draft.id, {
|
||||||
position: result.cursorPosition,
|
position: result.cursorPosition,
|
||||||
rotation: result.cursorRotationY,
|
rotation: liveRotation,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1941,8 +1962,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
worldSnapped.y,
|
worldSnapped.y,
|
||||||
worldSnapped.z,
|
worldSnapped.z,
|
||||||
)
|
)
|
||||||
|
const surfaceQuat = new Quaternion()
|
||||||
|
surfaceMesh.getWorldQuaternion(surfaceQuat)
|
||||||
|
const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y
|
||||||
if (cursorGroupRef.current) {
|
if (cursorGroupRef.current) {
|
||||||
cursorGroupRef.current.position.set(localSnapped.x, localSnapped.y, localSnapped.z)
|
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)
|
if (mesh) mesh.position.set(x, y, z)
|
||||||
}
|
}
|
||||||
@@ -2352,7 +2380,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
? getScaledDimensions(initialDraft)
|
? getScaledDimensions(initialDraft)
|
||||||
: (config.asset?.dimensions ?? DEFAULT_DIMENSIONS)
|
: (config.asset?.dimensions ?? DEFAULT_DIMENSIONS)
|
||||||
const dims = getGridAlignedDimensions(rawDims, initialAttachTo, gridSnapStep)
|
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(
|
const initialDimensionBounds = expandBoundsToGrid(
|
||||||
getFallbackPreviewBounds(initialDraft, config.asset, initialAttachTo),
|
getFallbackPreviewBounds(initialDraft, config.asset, initialAttachTo),
|
||||||
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.)
|
// Rotate selected node clockwise if it supports rotation (items, roofs, etc.)
|
||||||
// Doors use R to flip side (front ↔ back, rotation += π); their
|
// Doors use R to flip side (front ↔ back, rotation += π); their
|
||||||
// open/close toggle lives on E. Windows still use R to toggle
|
// open/close toggle lives on E. Windows still use R to toggle
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export function getItemFloorplanTransform(
|
|||||||
)
|
)
|
||||||
const wallLocalZ =
|
const wallLocalZ =
|
||||||
item.asset.attachTo === 'wall-side'
|
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]
|
: item.position[2]
|
||||||
const [offsetX, offsetY] = rotatePlanVector(item.position[0], wallLocalZ, wallRotation)
|
const [offsetX, offsetY] = rotatePlanVector(item.position[0], wallLocalZ, wallRotation)
|
||||||
|
|
||||||
@@ -164,7 +164,10 @@ type Point = {
|
|||||||
|
|
||||||
function getItemDimensionPolygon(item: ItemNode, transform: FloorplanNodeTransform): Point[] {
|
function getItemDimensionPolygon(item: ItemNode, transform: FloorplanNodeTransform): Point[] {
|
||||||
const [width, , depth] = getScaledDimensions(item)
|
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)
|
const [offsetX, offsetY] = rotatePlanVector(0, centerLocalZ, transform.rotation)
|
||||||
|
|
||||||
return getRotatedRectanglePolygon(
|
return getRotatedRectanglePolygon(
|
||||||
|
|||||||
@@ -13,7 +13,13 @@ import {
|
|||||||
roofFacePointToSegment,
|
roofFacePointToSegment,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} 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 { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
|
||||||
import { findClosestWallInPlan, snapLocalXToNeighbors } from '../shared/wall-attach-target'
|
import { findClosestWallInPlan, snapLocalXToNeighbors } from '../shared/wall-attach-target'
|
||||||
|
|
||||||
@@ -70,7 +76,7 @@ function resolveItemPlanTransform(
|
|||||||
)
|
)
|
||||||
const wallLocalZ =
|
const wallLocalZ =
|
||||||
item.asset.attachTo === 'wall-side'
|
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]
|
: item.position[2]
|
||||||
const [offsetX, offsetZ] = rotateVec(item.position[0], wallLocalZ, wallRotation)
|
const [offsetX, offsetZ] = rotateVec(item.position[0], wallLocalZ, wallRotation)
|
||||||
result = {
|
result = {
|
||||||
@@ -143,12 +149,11 @@ function createPlanarMovePointResolver(originalPlanPoint: [number, number], node
|
|||||||
metadata: node.metadata,
|
metadata: node.metadata,
|
||||||
})
|
})
|
||||||
|
|
||||||
return (planPoint: readonly [number, number], shiftKey: boolean): WallPlanPoint => {
|
return (planPoint: readonly [number, number]): WallPlanPoint => {
|
||||||
const snap = (value: number) => {
|
// Grid snap is mode-driven (matching 3D): quantize only when grid mode is
|
||||||
if (shiftKey) return value
|
// active; in lines/off mode the cursor passes through unsnapped.
|
||||||
const step = useEditor.getState().gridSnapStep
|
const step = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0
|
||||||
return Math.round(value / step) * step
|
const snap = (value: number) => (step <= 0 ? value : Math.round(value / step) * step)
|
||||||
}
|
|
||||||
return resolveCursor(planPoint, { snap }) as WallPlanPoint
|
return resolveCursor(planPoint, { snap }) as WallPlanPoint
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -201,7 +206,7 @@ function buildWallItemSession(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
affectedIds: [node.id as AnyNodeId],
|
affectedIds: [node.id as AnyNodeId],
|
||||||
apply({ planPoint, modifiers }) {
|
apply({ planPoint }) {
|
||||||
const nodes = useScene.getState().nodes
|
const nodes = useScene.getState().nodes
|
||||||
const resolvedPlanPoint = resolveCursor(planPoint)
|
const resolvedPlanPoint = resolveCursor(planPoint)
|
||||||
const hit = findClosestWallInPlan(resolvedPlanPoint, nodes, startLevelId)
|
const hit = findClosestWallInPlan(resolvedPlanPoint, nodes, startLevelId)
|
||||||
@@ -210,21 +215,21 @@ function buildWallItemSession(
|
|||||||
const [width] = getScaledDimensions(node)
|
const [width] = getScaledDimensions(node)
|
||||||
|
|
||||||
// Figma-style along-wall alignment (edge-to-edge with other openings /
|
// Figma-style along-wall alignment (edge-to-edge with other openings /
|
||||||
// wall items / wall ends), winning over the 0.5m grid snap; falls back
|
// wall items / wall ends), winning over the grid snap; falls back to grid
|
||||||
// to grid when nothing aligns. Alt bypasses alignment; Shift bypasses all snap.
|
// when nothing aligns. Both are mode-driven (matching 3D): alignment only in
|
||||||
const neighborX =
|
// lines/magnetic mode, grid quantization only in grid mode.
|
||||||
modifiers.altKey || modifiers.shiftKey
|
const neighborX = isMagneticSnapActive()
|
||||||
? null
|
? snapLocalXToNeighbors({
|
||||||
: snapLocalXToNeighbors({
|
wall: hit.wall,
|
||||||
wall: hit.wall,
|
localX: hit.localX,
|
||||||
localX: hit.localX,
|
width,
|
||||||
width,
|
selfId: node.id as AnyNodeId,
|
||||||
selfId: node.id as AnyNodeId,
|
nodes,
|
||||||
nodes,
|
})
|
||||||
})
|
: null
|
||||||
const step = useEditor.getState().gridSnapStep
|
const step = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0
|
||||||
const snappedLocalX =
|
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 halfW = width / 2
|
||||||
const clampedX = Math.max(halfW, Math.min(hit.wallLength - halfW, snappedLocalX))
|
const clampedX = Math.max(halfW, Math.min(hit.wallLength - halfW, snappedLocalX))
|
||||||
@@ -275,9 +280,10 @@ function buildFloorItemSession(
|
|||||||
const candidates = collectAlignmentAnchors(nodes, node.id)
|
const candidates = collectAlignmentAnchors(nodes, node.id)
|
||||||
return {
|
return {
|
||||||
affectedIds: [node.id as AnyNodeId],
|
affectedIds: [node.id as AnyNodeId],
|
||||||
apply({ planPoint, modifiers }) {
|
apply({ planPoint }) {
|
||||||
const gridSnapped = resolvePlanPoint(planPoint, modifiers.shiftKey)
|
const gridSnapped = resolvePlanPoint(planPoint)
|
||||||
// Figma-style alignment layered on the grid snap (Alt bypasses).
|
// 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(
|
const { point: snapped } = applyFloorplanAlignment(
|
||||||
gridSnapped,
|
gridSnapped,
|
||||||
movingFootprintAnchors(
|
movingFootprintAnchors(
|
||||||
@@ -287,7 +293,7 @@ function buildFloorItemSession(
|
|||||||
rotationY,
|
rotationY,
|
||||||
),
|
),
|
||||||
candidates,
|
candidates,
|
||||||
{ bypass: modifiers.altKey || modifiers.shiftKey },
|
{ bypass: !isMagneticSnapActive() },
|
||||||
)
|
)
|
||||||
|
|
||||||
const sourceY = node.position[1]
|
const sourceY = node.position[1]
|
||||||
@@ -332,9 +338,9 @@ function buildSurfaceItemSession(
|
|||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
affectedIds: [node.id as AnyNodeId],
|
affectedIds: [node.id as AnyNodeId],
|
||||||
apply({ planPoint, modifiers }) {
|
apply({ planPoint }) {
|
||||||
const nodes = useScene.getState().nodes
|
const nodes = useScene.getState().nodes
|
||||||
const snapped = resolvePlanPoint(planPoint, modifiers.shiftKey)
|
const snapped = resolvePlanPoint(planPoint)
|
||||||
|
|
||||||
const surface = findContainingSurface(snapped, nodes, startLevelId, targetKind)
|
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 wallRotation = -Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0])
|
||||||
const wallLocalZ =
|
const wallLocalZ =
|
||||||
item.asset.attachTo === 'wall-side'
|
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]
|
: item.position[2]
|
||||||
const [offsetX, offsetY] = rotateVec(item.position[0], wallLocalZ, wallRotation)
|
const [offsetX, offsetY] = rotateVec(item.position[0], wallLocalZ, wallRotation)
|
||||||
result = {
|
result = {
|
||||||
@@ -160,9 +160,12 @@ export function buildItemFloorplan(node: ItemNode, ctx: GeometryContext): Floorp
|
|||||||
const [width, , depth] = getScaledDimensions(node)
|
const [width, , depth] = getScaledDimensions(node)
|
||||||
if (width <= 0 || depth <= 0) return null
|
if (width <= 0 || depth <= 0) return null
|
||||||
|
|
||||||
// Wall-side items are anchored at the front face — center their footprint
|
// Wall-side items are anchored at the mounted wall face; their body extends
|
||||||
// half-a-depth back toward the wall surface.
|
// depth-ward AWAY from the wall (into the room), so push the footprint centre
|
||||||
const centerLocalZ = node.asset.attachTo === 'wall-side' ? -depth / 2 : 0
|
// 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 [centerOffsetX, centerOffsetY] = rotateVec(0, centerLocalZ, transform.rotation)
|
||||||
const cx = transform.x + centerOffsetX
|
const cx = transform.x + centerOffsetX
|
||||||
const cy = transform.y + centerOffsetY
|
const cy = transform.y + centerOffsetY
|
||||||
|
|||||||
Reference in New Issue
Block a user