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;
|
// 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