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:
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user