Wassim SAMAD 68e5c6ca67 feat(editor): unify placement/move facing triangle into one editor-side renderer
Every placement and move path now publishes its ghost pose to a single
`useFacingPose` store, drawn by one editor-side `<FacingPoseIndicator>` overlay,
instead of each path drawing its own triangle (which left the nodes-package and
PlacementBox paths invisible):

- column/shelf presets + all moves (PlacementBox via move-registry, and
  DragBoundingBox) now publish the pose, so the triangle finally shows
- stair create + move use a declarative `facingIndicator: { reversed: true }`
  (new registry resolver) so the triangle sits before the entry pointing out —
  resolved in one place, so create and move match automatically
- stair placement defaults to single and respects the shared `point`
  continuation (C) toggle, like the other placement tools

Checkpoint on the placement-interaction epic: also carries the in-flight
continuation-profile extraction (lib/continuation), grid surface (item #8), and
HUD work. Door/window still render their own legacy inline triangle and are
migrated to the overlay next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:48:58 -04:00

516 lines
17 KiB
TypeScript

import {
type AnyNode,
collectAlignmentAnchors,
createSurfaceOpeningPreviewController,
type EventSuffix,
emitter,
type GridEvent,
type LevelNode,
movingAlignmentAnchors,
type NodeEvent,
resolveAlignment,
StairNode,
StairSegmentNode,
syncAutoStairOpenings,
useScene,
} from '@pascal-app/core'
import { useAlignmentGuides } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { sfxEmitter } from '../../../lib/sfx-bus'
import {
resolveStairDestinationLevel,
resolveStairPlacementLevelId,
} from '../../../lib/stair-levels'
import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../../store/use-editor'
import useFacingPose from '../../../store/use-facing-pose'
import { CursorSphere } from '../shared/cursor-sphere'
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
import {
DEFAULT_CURVED_STAIR_INNER_RADIUS,
DEFAULT_CURVED_STAIR_SWEEP_ANGLE,
DEFAULT_SPIRAL_SHOW_CENTER_COLUMN,
DEFAULT_SPIRAL_SHOW_STEP_SUPPORTS,
DEFAULT_SPIRAL_TOP_LANDING_DEPTH,
DEFAULT_SPIRAL_TOP_LANDING_MODE,
DEFAULT_STAIR_ATTACHMENT_SIDE,
DEFAULT_STAIR_FILL_TO_FLOOR,
DEFAULT_STAIR_HEIGHT,
DEFAULT_STAIR_LENGTH,
DEFAULT_STAIR_RAILING_HEIGHT,
DEFAULT_STAIR_RAILING_MODE,
DEFAULT_STAIR_STEP_COUNT,
DEFAULT_STAIR_THICKNESS,
DEFAULT_STAIR_TYPE,
DEFAULT_STAIR_WIDTH,
} from './stair-defaults'
const GRID_OFFSET = 0.02
/** Figma-style alignment-snap threshold (meters), matching the move tools. */
const ALIGNMENT_THRESHOLD_M = 0.08
type ClickTriggerEvent = GridEvent | NodeEvent<AnyNode>
const CLICK_TRIGGER_KINDS = [
'shelf',
'item',
'slab',
'ceiling',
'wall',
'fence',
'column',
'roof',
'roof-segment',
'stair',
'stair-segment',
] as const
/**
* Generates the step-profile geometry for the ghost preview.
* Same algorithm as StairSystem's generateStairSegmentGeometry.
*/
function createStairPreviewGeometry(): THREE.BufferGeometry {
const riserHeight = DEFAULT_STAIR_HEIGHT / DEFAULT_STAIR_STEP_COUNT
const treadDepth = DEFAULT_STAIR_LENGTH / DEFAULT_STAIR_STEP_COUNT
const shape = new THREE.Shape()
shape.moveTo(0, 0)
for (let i = 0; i < DEFAULT_STAIR_STEP_COUNT; i++) {
shape.lineTo(i * treadDepth, (i + 1) * riserHeight)
shape.lineTo((i + 1) * treadDepth, (i + 1) * riserHeight)
}
// Fill to floor (absoluteHeight = 0)
shape.lineTo(DEFAULT_STAIR_LENGTH, 0)
shape.lineTo(0, 0)
const geometry = new THREE.ExtrudeGeometry(shape, {
steps: 1,
depth: DEFAULT_STAIR_WIDTH,
bevelEnabled: false,
})
// Rotate so extrusion is along X (width), shape profile in XZ plane
const matrix = new THREE.Matrix4()
matrix.makeRotationY(-Math.PI / 2)
matrix.setPosition(DEFAULT_STAIR_WIDTH / 2, 0, 0)
geometry.applyMatrix4(matrix)
return geometry
}
/**
* Creates a default straight stair segment.
*/
function createDefaultStairSegment() {
return StairSegmentNode.parse({
segmentType: 'stair',
width: DEFAULT_STAIR_WIDTH,
length: DEFAULT_STAIR_LENGTH,
height: DEFAULT_STAIR_HEIGHT,
stepCount: DEFAULT_STAIR_STEP_COUNT,
attachmentSide: DEFAULT_STAIR_ATTACHMENT_SIDE,
fillToFloor: DEFAULT_STAIR_FILL_TO_FLOOR,
thickness: DEFAULT_STAIR_THICKNESS,
position: [0, 0, 0],
})
}
function createDefaultStairNode({
name,
levelId,
nextLevelId,
position,
rotation,
segmentId,
}: {
name: string
levelId: LevelNode['id']
nextLevelId: LevelNode['id']
position: [number, number, number]
rotation: number
segmentId: StairSegmentNode['id']
}) {
return StairNode.parse({
name,
position,
rotation,
stairType: DEFAULT_STAIR_TYPE,
fromLevelId: levelId,
toLevelId: nextLevelId,
slabOpeningMode: 'destination',
openingOffset: 0.08,
width: DEFAULT_STAIR_WIDTH,
totalRise: DEFAULT_STAIR_HEIGHT,
stepCount: DEFAULT_STAIR_STEP_COUNT,
thickness: DEFAULT_STAIR_THICKNESS,
fillToFloor: DEFAULT_STAIR_FILL_TO_FLOOR,
innerRadius: DEFAULT_CURVED_STAIR_INNER_RADIUS,
sweepAngle: DEFAULT_CURVED_STAIR_SWEEP_ANGLE,
topLandingMode: DEFAULT_SPIRAL_TOP_LANDING_MODE,
topLandingDepth: DEFAULT_SPIRAL_TOP_LANDING_DEPTH,
showCenterColumn: DEFAULT_SPIRAL_SHOW_CENTER_COLUMN,
showStepSupports: DEFAULT_SPIRAL_SHOW_STEP_SUPPORTS,
railingHeight: DEFAULT_STAIR_RAILING_HEIGHT,
railingMode: DEFAULT_STAIR_RAILING_MODE,
children: [segmentId],
})
}
/**
* Creates a stair group with one default stair segment at the given position/rotation.
*/
function commitStairPlacement(
levelId: LevelNode['id'],
position: [number, number, number],
rotation: number,
): void {
const { createNodes, nodes } = useScene.getState()
const placementLevelId = resolveStairPlacementLevelId(
nodes,
levelId,
useViewer.getState().selection.buildingId,
)
if (!placementLevelId) return
const stairCount = Object.values(nodes).filter((n) => n.type === 'stair').length
const name = `Staircase ${stairCount + 1}`
const segment = createDefaultStairSegment()
const destinationPlan = resolveStairDestinationLevel({
createMissing: true,
fromLevelId: placementLevelId,
nodes,
})
const nextLevelId = destinationPlan?.toLevel.id ?? placementLevelId
const stair = createDefaultStairNode({
name,
levelId: placementLevelId,
nextLevelId,
position,
rotation,
segmentId: segment.id,
})
const createdLevel = destinationPlan?.createdLevel
const levelCreateOps =
createdLevel && destinationPlan.buildingId
? [{ node: createdLevel, parentId: destinationPlan.buildingId }]
: []
createNodes([
...levelCreateOps,
{ node: stair, parentId: placementLevelId },
{ node: segment, parentId: stair.id },
])
sfxEmitter.emit('sfx:structure-build')
}
export const StairTool: React.FC = () => {
const cursorRef = useRef<THREE.Group>(null)
const previewRef = useRef<THREE.Group>(null)
const rotationRef = useRef(0)
const previousGridPosRef = useRef<[number, number] | null>(null)
const lastCanonicalPositionRef = useRef<[number, number, number] | null>(null)
const currentLevelId = useViewer((state) => state.selection.levelId)
const previewGeometry = useMemo(() => createStairPreviewGeometry(), [])
useEffect(() => {
if (!currentLevelId) return
const openingPreview = createSurfaceOpeningPreviewController()
// Reset rotation when tool activates
rotationRef.current = 0
if (previewRef.current) previewRef.current.rotation.y = 0
lastCanonicalPositionRef.current = null
const buildPreviewScene = (position: [number, number, number], rotation: number) => {
const nodes = useScene.getState().nodes
const placementLevelId = resolveStairPlacementLevelId(
nodes,
currentLevelId,
useViewer.getState().selection.buildingId,
)
if (!placementLevelId) return null
const destinationPlan = resolveStairDestinationLevel({
createMissing: true,
fromLevelId: placementLevelId,
nodes,
})
const nextLevelId = destinationPlan?.toLevel.id ?? placementLevelId
const segment = createDefaultStairSegment()
const stair = createDefaultStairNode({
name: 'Staircase Preview',
levelId: placementLevelId,
nextLevelId,
position,
rotation,
segmentId: segment.id,
})
const previewNodes = {
...nodes,
...(destinationPlan?.createdLevel
? { [destinationPlan.createdLevel.id]: destinationPlan.createdLevel }
: {}),
[stair.id]: { ...stair, parentId: placementLevelId },
[segment.id]: { ...segment, parentId: stair.id },
} as Record<string, AnyNode>
return { placementLevelId, previewNodes, stair }
}
// The preview rebuild (full-scene copy + destination-level resolution +
// auto-opening CSG) is expensive; `grid:move` fires it every pointer event
// but the placed position is grid-snapped, so within a cell every rebuild
// is identical. Dedupe on the snapped position + rotation so we rebuild
// only when the staircase would actually land somewhere new — this is the
// difference between a smooth and a stuttering stair tool (the elevator is
// cheap because it has no opening sync).
let lastPreviewKey: string | null = null
const applyDraftPreview = (position: [number, number, number], rotation: number) => {
const key = `${position[0].toFixed(3)},${position[2].toFixed(3)},${rotation.toFixed(4)}`
if (key === lastPreviewKey) return
lastPreviewKey = key
const preview = buildPreviewScene(position, rotation)
const visualPosition = preview
? getFloorStackPreviewPosition({
node: preview.stair,
position,
rotation,
levelId: preview.placementLevelId,
nodes: preview.previewNodes,
})
: position
if (cursorRef.current) {
cursorRef.current.position.set(
visualPosition[0],
visualPosition[1] + GRID_OFFSET,
visualPosition[2],
)
}
if (previewRef.current) {
previewRef.current.position.set(...visualPosition)
previewRef.current.rotation.y = rotation
}
// Forward-facing triangle (editor-side overlay). The run ascends along
// local +Z from the entry at z≈0; the stair's front is the -Z entry side,
// so `reversed` points the triangle out of the entry (where you approach
// from), sitting just before it — not inside the footprint or at the
// elevated far end. Centre is the footprint mid-run (origin is the entry).
useFacingPose.getState().set({
position: visualPosition,
rotationY: rotation,
depth: DEFAULT_STAIR_LENGTH,
center: [0, DEFAULT_STAIR_LENGTH / 2],
reversed: true,
})
if (!preview) {
openingPreview.clear()
return
}
openingPreview.apply(syncAutoStairOpenings(preview.previewNodes))
}
// Alignment candidates — anchors of every alignable object; refreshed
// after each placement. The moving stair aligns by its footprint edges so
// users can snap the run side against walls, slabs, elevators, or another
// stair instead of only lining up the invisible origin point.
let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', currentLevelId)
const resolveStairFootprintAlignment = (
x: number,
z: number,
rotation: number,
): ReturnType<typeof resolveAlignment> | null => {
const preview = buildPreviewScene([x, 0, z], rotation)
const moving = preview
? movingAlignmentAnchors(preview.stair, preview.previewNodes, x, z, rotation)
: []
if (moving.length === 0) return null
return resolveAlignment({
moving,
candidates: alignmentCandidates,
threshold: ALIGNMENT_THRESHOLD_M,
})
}
// The probe is the RAW cursor, not the grid-snapped point: resolving
// against the grid point would only catch anchors that happen to sit near
// a grid line. Matched axes use the raw probe + snap delta; unmatched axes
// keep the normal grid snap. Alignment runs only when the magnetic (lines)
// snapping mode is active.
const alignPoint = (
gridX: number,
gridZ: number,
rawX: number,
rawZ: number,
bypass: boolean,
): [number, number] => {
if (bypass || alignmentCandidates.length === 0) {
useAlignmentGuides.getState().clear()
return [gridX, gridZ]
}
const ar = resolveStairFootprintAlignment(rawX, rawZ, rotationRef.current)
if (!ar || ar.guides.length === 0) {
useAlignmentGuides.getState().clear()
return [gridX, gridZ]
}
let x = gridX
let z = gridZ
if (ar.snap) {
if (ar.guides.some((guide) => guide.axis === 'x')) x = rawX + ar.snap.dx
if (ar.guides.some((guide) => guide.axis === 'z')) z = rawZ + ar.snap.dz
}
const finalAlignment = resolveStairFootprintAlignment(x, z, rotationRef.current)
useAlignmentGuides.getState().set(finalAlignment?.guides ?? ar.guides)
return [x, z]
}
const onGridMove = (event: GridEvent) => {
// Grid snap follows the global mode (live step so the HUD chip is
// honest); Off keeps the raw cursor. Shift cycles the mode centrally.
const step = useEditor.getState().gridSnapStep
const [gridX, gridZ] = alignPoint(
isGridSnapActive()
? Math.round(event.localPosition[0] / step) * step
: event.localPosition[0],
isGridSnapActive()
? Math.round(event.localPosition[2] / step) * step
: event.localPosition[2],
event.localPosition[0],
event.localPosition[2],
!isMagneticSnapActive(),
)
const position: [number, number, number] = [gridX, 0, gridZ]
lastCanonicalPositionRef.current = position
applyDraftPreview(position, rotationRef.current)
if (
(isGridSnapActive() || isMagneticSnapActive()) &&
previousGridPosRef.current &&
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousGridPosRef.current = [gridX, gridZ]
}
const getAlignedGridPosition = (event: GridEvent): [number, number, number] => {
const step = useEditor.getState().gridSnapStep
const [gridX, gridZ] = alignPoint(
isGridSnapActive()
? Math.round(event.localPosition[0] / step) * step
: event.localPosition[0],
isGridSnapActive()
? Math.round(event.localPosition[2] / step) * step
: event.localPosition[2],
event.localPosition[0],
event.localPosition[2],
!isMagneticSnapActive(),
)
return [gridX, 0, gridZ]
}
const commitAtCursor = (event: ClickTriggerEvent) => {
if (!currentLevelId) return
const nodeEvent = 'node' in event ? (event as NodeEvent<AnyNode>) : null
if (nodeEvent) {
nodeEvent.stopPropagation()
nodeEvent.nativeEvent.stopPropagation()
}
const position = nodeEvent
? lastCanonicalPositionRef.current
: getAlignedGridPosition(event as GridEvent)
if (!position) return
commitStairPlacement(currentLevelId, position, rotationRef.current)
openingPreview.clear()
// Commit cleared the opening preview, so force the next hover (even on the
// same cell) to rebuild rather than dedupe against the just-placed key.
lastPreviewKey = null
useAlignmentGuides.getState().clear()
// Single by default; the C-toggle ('point' context, shared with every
// other placement tool) opts into placing more. On single, drop the tool
// and the facing triangle so we fall back to select after one stair.
if (useEditor.getState().getContinuation('point') === 'repeat') {
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', currentLevelId)
} else {
useFacingPose.getState().clear()
useEditor.getState().setTool(null)
}
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
return
}
const ROTATION_STEP = Math.PI / 4
let rotationDelta = 0
if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP
else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP
if (rotationDelta !== 0) {
event.preventDefault()
sfxEmitter.emit('sfx:item-rotate')
rotationRef.current += rotationDelta
if (lastCanonicalPositionRef.current) {
applyDraftPreview(lastCanonicalPositionRef.current, rotationRef.current)
} else if (previewRef.current) {
previewRef.current.rotation.y = rotationRef.current
}
}
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', commitAtCursor)
type SuffixedKey<K extends string> = `${K}:${EventSuffix}`
type ClickKey = SuffixedKey<(typeof CLICK_TRIGGER_KINDS)[number]>
for (const kind of CLICK_TRIGGER_KINDS) {
const key = `${kind}:click` as ClickKey
emitter.on(key, commitAtCursor as never)
}
window.addEventListener('keydown', onKeyDown)
return () => {
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', commitAtCursor)
for (const kind of CLICK_TRIGGER_KINDS) {
const key = `${kind}:click` as ClickKey
emitter.off(key, commitAtCursor as never)
}
window.removeEventListener('keydown', onKeyDown)
useAlignmentGuides.getState().clear()
openingPreview.clear()
useFacingPose.getState().clear()
}
}, [currentLevelId])
return (
<group>
<CursorSphere ref={cursorRef} />
{/* 3D ghost preview — position/rotation updated imperatively. The
forward-facing triangle is drawn by the editor-side overlay from the
pose published in `applyDraftPreview`. */}
<group ref={previewRef}>
<mesh castShadow geometry={previewGeometry}>
<meshStandardMaterial color="#818cf8" depthWrite={false} opacity={0.35} transparent />
</mesh>
</group>
</group>
)
}