fix(placement): wall-item facing via useFacingPose + right-click no longer cancels camera orbit

BUG 1 — wall-attached furniture facing. The coordinator drew its own inline
facing triangle and published the wall grid normal from the cursor ghost's own
yaw, which is the symmetric-wireframe yaw — π off the item's true facing for a
wall (and a different frame for a roof-segment face). The triangle pointed into
the wall and the grid normal was sign-flipped while the box still looked right.

Fold the coordinator into the unified `useFacingPose` overlay (drop the inline
triangle, geometry/material/constants). The per-frame surface publisher now
derives the true outward facing from the draft mesh's world orientation (its
local +Z faces out of the host surface) for wall/roof-wall and feeds that single
yaw to BOTH the grid normal and the facing triangle. Floor/ceiling/item-surface/
shelf paths are unchanged behaviourally — same cursor yaw and Y, just routed
through the overlay.

Right-click cancel vs camera orbit. The right button also orbits the camera, so
the old unconditional `contextmenu → onCancel` cancelled placement on every
right-drag. Gate the cancel on pointerup: only when the right press moved ≤4px
within ≤200ms (a quick stationary click); a longer/further press is an orbit and
is left alone. contextmenu now only suppresses the OS menu.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-27 16:59:06 -04:00
co-authored by Claude Opus 4.8
parent 8cc6c41b51
commit bbe5b9a8eb
@@ -27,10 +27,7 @@ import { useFrame, useThree } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { import {
Box3, Box3,
BufferGeometry,
DoubleSide,
Euler, Euler,
Float32BufferAttribute,
type Group, type Group,
type LineSegments, type LineSegments,
Matrix4, Matrix4,
@@ -52,6 +49,7 @@ import { formatLinearMeasurement } from '../../../lib/measurements'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import { resolveAlignmentForActiveBuilding } from '../../../lib/world-grid-snap' import { resolveAlignmentForActiveBuilding } from '../../../lib/world-grid-snap'
import useEditor, { isMagneticSnapActive } from '../../../store/use-editor' import useEditor, { isMagneticSnapActive } from '../../../store/use-editor'
import useFacingPose from '../../../store/use-facing-pose'
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview' import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
import { import {
createLineGeometry, createLineGeometry,
@@ -85,6 +83,13 @@ const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
* floor-plan overlay and the 3D registry move tool. */ * floor-plan overlay and the 3D registry move tool. */
const ALIGNMENT_THRESHOLD_M = 0.08 const ALIGNMENT_THRESHOLD_M = 0.08
/** Right-click cancels an active placement — but the right button also orbits
* the camera (CameraControls ROTATE). Only a quick, near-stationary right
* press/release counts as a cancel; anything that moves past the pixel
* threshold or is held longer is treated as a camera orbit and left alone. */
const RIGHT_CLICK_CANCEL_MAX_MOVE_PX = 4
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
@@ -192,45 +197,6 @@ const dist = distance(uv(), center)
const radialOpacity = smoothstep(0, 0.7, dist).mul(0.6) const radialOpacity = smoothstep(0, 0.7, dist).mul(0.6)
basePlaneMaterial.opacityNode = radialOpacity basePlaneMaterial.opacityNode = radialOpacity
// Facing indicator: a small flat triangle on the floor in front of the ghost,
// pointing along the item's forward (-Z) direction. The cursor group already
// applies the item rotation, so the triangle stays in the group's local frame.
// Pushed clear of the measurement label pill (which sits ~0.24 off the same
// edge) and sized up so it stays legible at shallow camera angles.
const FACING_INDICATOR_WIDTH = 0.4
const FACING_INDICATOR_LENGTH = 0.46
const FACING_INDICATOR_GAP = 0.45
const facingIndicatorGeometry = (() => {
const geometry = new BufferGeometry()
// Tip at local +Z (the item's forward face); base across the X axis.
geometry.setAttribute(
'position',
new Float32BufferAttribute(
[
0,
0,
FACING_INDICATOR_LENGTH,
FACING_INDICATOR_WIDTH / 2,
0,
0,
-FACING_INDICATOR_WIDTH / 2,
0,
0,
],
3,
),
)
return geometry
})()
const facingIndicatorMaterial = new MeshBasicNodeMaterial({
color: 0x22_c5_5e, // green-500 (forward)
depthTest: false,
depthWrite: false,
// The flat triangle is viewed from above; DoubleSide makes it visible
// regardless of vertex winding (and from a camera orbited below the floor).
side: DoubleSide,
})
export interface PlacementCoordinatorConfig { export interface PlacementCoordinatorConfig {
asset: AssetInput | null asset: AssetInput | null
draftNode: DraftNodeHandle draftNode: DraftNodeHandle
@@ -271,6 +237,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
) )
const altFreeRef = useRef(false) const altFreeRef = useRef(false)
const previewBoundsSignatureRef = useRef<string | null>(null) const previewBoundsSignatureRef = useRef<string | null>(null)
// Footprint shape (depth along local +Z and [x, z] centre) of the current
// preview box, mirrored from the rendered dimension bounds so the per-frame
// surface publisher can position the forward-facing triangle without reading
// React state. Updated in the render body below.
const facingShapeRef = useRef<{ depth: number; center: [number, number] }>({
depth: 0,
center: [0, 0],
})
// Goes true the first time a 3D pointer event drives this coordinator. // Goes true the first time a 3D pointer event drives this coordinator.
// The per-frame mesh-position lerp below is only useful for that path; // The per-frame mesh-position lerp below is only useful for that path;
// when the move is being driven externally (2D `FloorplanRegistryMoveOverlay` // when the move is being driven externally (2D `FloorplanRegistryMoveOverlay`
@@ -2017,13 +1991,35 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
} }
emitter.on('tool:cancel', onCancel) emitter.on('tool:cancel', onCancel)
// ---- Right-click cancel ---- // ---- Right-click cancel (quick click only, never a right-drag orbit) ----
const onContextMenu = (event: MouseEvent) => { // The right button is also the camera-orbit control, so a contextmenu/up
if (configRef.current.onCancel) { // alone can't tell "cancel placement" from "move the camera". Record the
event.preventDefault() // right-button-down point + time and only cancel on release when the
configRef.current.onCancel() // pointer barely moved within a short window — a longer / further press is
// an orbit and must leave the placement untouched.
let rightDown: { x: number; y: number; t: number } | null = null
const onRightPointerDown = (event: PointerEvent) => {
if (event.button !== 2) return
rightDown = { x: event.clientX, y: event.clientY, t: performance.now() }
}
const onRightPointerUp = (event: PointerEvent) => {
if (event.button !== 2) return
const down = rightDown
rightDown = null
if (!down || !configRef.current.onCancel) return
const movedSq = (event.clientX - down.x) ** 2 + (event.clientY - down.y) ** 2
const elapsed = performance.now() - down.t
if (movedSq <= RIGHT_CLICK_CANCEL_MAX_MOVE_PX ** 2 && elapsed <= RIGHT_CLICK_CANCEL_MAX_MS) {
onCancel()
} }
} }
// Suppress the OS context menu while placing; the cancel itself is decided
// on pointerup above.
const onContextMenu = (event: MouseEvent) => {
if (configRef.current.onCancel) event.preventDefault()
}
window.addEventListener('pointerdown', onRightPointerDown, true)
window.addEventListener('pointerup', onRightPointerUp, true)
window.addEventListener('contextmenu', onContextMenu) window.addEventListener('contextmenu', onContextMenu)
// ---- Bounding box geometry ---- // ---- Bounding box geometry ----
@@ -2150,6 +2146,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
emitter.off('tool:cancel', onCancel) emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp) window.removeEventListener('keyup', onKeyUp)
window.removeEventListener('pointerdown', onRightPointerDown, true)
window.removeEventListener('pointerup', onRightPointerUp, true)
window.removeEventListener('contextmenu', onContextMenu) window.removeEventListener('contextmenu', onContextMenu)
} }
}, [ }, [
@@ -2231,28 +2229,70 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// Publish the ghost's surface (contact point + normal) so the grid's snap // Publish the ghost's surface (contact point + normal) so the grid's snap
// patch sits at the item's resolved height (e.g. a shelf top) and orients to // patch sits at the item's resolved height (e.g. a shelf top) and orients to
// the surface (vertical in a wall plane). Only this coordinator publishes — a // the surface (vertical in a wall plane), AND publish the forward-facing
// triangle pose to the single editor-side overlay (`<FacingPoseIndicator>`)
// instead of drawing an inline triangle. Only this coordinator publishes — a
// moving existing node has no draft here, so the grid reads that case straight // moving existing node has no draft here, so the grid reads that case straight
// off the node's mesh. Cleared when idle. // off the node's mesh. Cleared when idle.
const surfaceNormalRef = useRef(new Vector3(0, 1, 0)) const surfaceNormalRef = useRef(new Vector3(0, 1, 0))
const facingForwardRef = useRef(new Vector3(0, 0, 1))
const facingQuatRef = useRef(new Quaternion())
useFrame(() => { useFrame(() => {
const ghost = cursorGroupRef.current const ghost = cursorGroupRef.current
if (asset && ghost) { if (!(asset && ghost)) {
const surf = placementState.current.surface
const n = surfaceNormalRef.current
if (surf === 'wall' || surf === 'roof-wall') {
// Wall-attached: the item's forward (+Z) faces out of the wall, so the
// item's outward face direction IS the wall normal.
n.set(0, 0, 1).applyQuaternion(ghost.quaternion)
} else {
n.set(0, 1, 0)
}
publishPlacementSurface(ghost.position, n)
} else {
clearPlacementSurface() clearPlacementSurface()
useFacingPose.getState().clear()
return
}
const surf = placementState.current.surface
const n = surfaceNormalRef.current
const shape = facingShapeRef.current
// Triangle yaw / Y default to the floor case: the cursor group's own yaw is
// the item's forward on the floor, and the triangle rides at the ghost's Y.
let facingYaw = ghost.rotation.y
let facingY = ghost.position.y
if (surf === 'wall' || surf === 'roof-wall') {
// Wall/roof-segment faces: the cursor group's yaw is the symmetric
// wireframe yaw (π off the real facing for a wall, and a different frame
// for a roof face), so derive the item's TRUE outward facing from the
// draft mesh's world orientation — its local +Z faces out of the host
// surface. This keeps BOTH the grid normal and the triangle correct for
// wall and roof-segment hosts alike, rather than the old quaternion read
// that pointed the wrong way.
const mesh = draftNode.current ? sceneRegistry.nodes.get(draftNode.current.id) : null
if (mesh) {
mesh.getWorldQuaternion(facingQuatRef.current)
const fwd = facingForwardRef.current.set(0, 0, 1).applyQuaternion(facingQuatRef.current)
fwd.y = 0
if (fwd.lengthSq() > 1e-6) facingYaw = Math.atan2(fwd.x, fwd.z)
}
// The forward triangle is a floor aid; drop it to the building-local floor
// under the wall (the ghost Y is up on the wall).
facingY = 0
n.set(Math.sin(facingYaw), 0, Math.cos(facingYaw))
} else {
n.set(0, 1, 0)
}
publishPlacementSurface(ghost.position, n)
if (shape.depth > 0) {
useFacingPose.getState().set({
position: [ghost.position.x, facingY, ghost.position.z],
rotationY: facingYaw,
depth: shape.depth,
center: shape.center,
})
} else {
useFacingPose.getState().clear()
} }
}) })
useEffect(() => () => clearPlacementSurface(), []) useEffect(
() => () => {
clearPlacementSurface()
useFacingPose.getState().clear()
},
[],
)
useFrame(() => { useFrame(() => {
if (!asset) { if (!asset) {
@@ -2340,6 +2380,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const initialDepthGuideGeometry = useMemo(() => createLineGeometry(), []) const initialDepthGuideGeometry = useMemo(() => createLineGeometry(), [])
const initialHeightGuideGeometry = useMemo(() => createLineGeometry(), []) const initialHeightGuideGeometry = useMemo(() => createLineGeometry(), [])
const currentDimensionBounds = dimensionBounds ?? initialDimensionBounds const currentDimensionBounds = dimensionBounds ?? initialDimensionBounds
// Feed the footprint shape to the per-frame surface publisher, which orients
// and positions the forward-facing triangle via `useFacingPose`.
facingShapeRef.current = {
depth: currentDimensionBounds.dimensions[2],
center: [currentDimensionBounds.center[0], currentDimensionBounds.center[2]],
}
const widthLabel = formatLinearMeasurement(currentDimensionBounds.dimensions[0], unit) const widthLabel = formatLinearMeasurement(currentDimensionBounds.dimensions[0], unit)
const depthLabel = formatLinearMeasurement(currentDimensionBounds.dimensions[2], unit) const depthLabel = formatLinearMeasurement(currentDimensionBounds.dimensions[2], unit)
const heightLabel = formatLinearMeasurement(currentDimensionBounds.dimensions[1], unit) const heightLabel = formatLinearMeasurement(currentDimensionBounds.dimensions[1], unit)
@@ -2358,14 +2404,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
currentDimensionBounds.dimensions[1] / 2, currentDimensionBounds.dimensions[1] / 2,
currentDimensionBounds.center[2] - currentDimensionBounds.dimensions[2] / 2, currentDimensionBounds.center[2] - currentDimensionBounds.dimensions[2] / 2,
] ]
const facingIndicatorPosition: [number, number, number] = [
currentDimensionBounds.center[0],
0.02,
currentDimensionBounds.center[2] +
currentDimensionBounds.dimensions[2] / 2 +
FACING_INDICATOR_GAP,
]
const measurementContent = ( const measurementContent = (
<> <>
<lineSegments <lineSegments
@@ -2466,13 +2504,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
ref={basePlaneRef} ref={basePlaneRef}
renderOrder={999} renderOrder={999}
/> />
<mesh
geometry={facingIndicatorGeometry}
layers={EDITOR_LAYER}
material={facingIndicatorMaterial}
position={facingIndicatorPosition}
renderOrder={1000}
/>
</group> </group>
) )
} }