ai-chatbot/packages/editor/src/components/editor/use-floorplan-background-placement.ts
Wassim SAMAD f773e6b8c5 feat(editor): placement & interaction overhaul — FSM spine, bug tracks, perf
Implements plans/editor-placement-interaction-overhaul.md: an authoritative
interaction-scope state machine plus the catalogued placement/interaction
fixes, and split-view floor-plan performance.

- Interaction-scope spine (lib/interaction/* + store/use-interaction-scope),
  driven from central useEditor setters; overlay scoping (zone labels,
  context badges, floating action menu) reads resolveOverlayPolicy.
- Bug tracks A/B/D/E/F/G/H: handle/cutout raycast, footprint validity,
  auto-slab loop, ceiling hosting, B-key tool desync, 2D drop offset,
  per-frame jank.
- Snapping modes (grid/lines/angles/off) + contextual HUD chips; modifier
  model (Shift=cycle, Alt=free place, Ctrl=grid step).
- Item move now tracks the cursor 1:1 (was a laggy per-frame lerp); handle
  rig hides during a whole-node move; rotate gizmo advertises Shift=free
  rotation in the HUD and hides the move cross while rotating.
- Floor-plan perf: pause live reactivity while in 3D-only view; per-node
  geometry cache so only changed nodes rebuild on a drag; hoist wall miters
  to a once-per-pass ctx.levelData (O(N^2) -> O(N) on wall/opening drags).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:04:58 -04:00

438 lines
16 KiB
TypeScript

'use client'
import { emitter, type FenceNode, isCurvedWall, type WallNode } from '@pascal-app/core'
import { type MouseEvent as ReactMouseEvent, useCallback } from 'react'
import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap'
import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan'
import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
import useAlignmentGuides from '../../store/use-alignment-guides'
import { isAngleSnapActive, isMagneticSnapActive } from '../../store/use-editor'
import usePlacementPreview from '../../store/use-placement-preview'
import useSegmentDraftChain from '../../store/use-segment-draft-chain'
import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
import { getSegmentGridStep, type WallPlanPoint } from '../tools/wall/wall-drafting'
type UseFloorplanBackgroundPlacementArgs = {
activePolygonDraftPoints: WallPlanPoint[]
ceilingDraftPoints: WallPlanPoint[]
clearFencePlacementDraft: () => void
clearRoofPlacementDraft: () => void
clearWallPlacementDraft: () => void
emitFloorplanGridEvent: (
type: 'click' | 'double-click' | 'move',
planPoint: WallPlanPoint,
event: ReactMouseEvent<SVGSVGElement>,
) => void
fenceDraftStart: WallPlanPoint | null
fences: FenceNode[]
findClosestWallPoint: (
point: WallPlanPoint,
walls: WallNode[],
options?: { canUseWall?: (wall: WallNode) => boolean },
) => {
normal: [number, number, number]
point: WallPlanPoint
t: number
wall: WallNode
} | null
floorplanOpeningLocalY: number
getSnappedFloorplanPoint: (point: WallPlanPoint) => WallPlanPoint
handleCeilingItemPlacementClick: (
planPoint: WallPlanPoint,
nativeEvent: ReactMouseEvent<SVGSVGElement>,
) => boolean
handleCeilingPlacementPoint: (point: WallPlanPoint) => void
handleSlabPlacementPoint: (point: WallPlanPoint) => void
handleWallPlacementPoint: (point: WallPlanPoint, options?: { singleWall?: boolean }) => void
handleZonePlacementPoint: (point: WallPlanPoint) => void
isCeilingBuildActive: boolean
isCeilingItemPlacementActive: boolean
isFenceBuildActive: boolean
isFloorplanGridInteractionActive: boolean
isOpeningPlacementActive: boolean
isPolygonBuildActive: boolean
isRoofBuildActive: boolean
isSlabBuildActive: boolean
isWallBuildActive: boolean
isZoneBuildActive: boolean
levelId: string | null
roofDraftStart: WallPlanPoint | null
setCursorPoint: React.Dispatch<React.SetStateAction<WallPlanPoint | null>>
setFenceDraftEnd: React.Dispatch<React.SetStateAction<WallPlanPoint | null>>
setFenceDraftStart: React.Dispatch<React.SetStateAction<WallPlanPoint | null>>
setRoofDraftEnd: React.Dispatch<React.SetStateAction<WallPlanPoint | null>>
setRoofDraftStart: React.Dispatch<React.SetStateAction<WallPlanPoint | null>>
shiftPressed: boolean
snapWallDraftPoint: (args: {
point: WallPlanPoint
walls: WallNode[]
start?: WallPlanPoint
angleSnap?: boolean
bypassSnap?: boolean
step?: number
gridSnap?: (point: WallPlanPoint) => WallPlanPoint
}) => WallPlanPoint
snapPolygonDraftPoint: (args: {
point: WallPlanPoint
start?: WallPlanPoint
angleSnap: boolean
bypassSnap?: boolean
}) => WallPlanPoint
toPoint2D: (point: WallPlanPoint) => { x: number; y: number }
walls: WallNode[]
/**
* Snap a building-local plan point to the world XZ grid at `step`.
* Injected so the hook doesn't have to know the building's rotation
* or position — used by wall / fence branches that snap at variable
* step.
*/
worldGridSnap: (point: WallPlanPoint, step: number) => WallPlanPoint
}
export function useFloorplanBackgroundPlacement({
activePolygonDraftPoints,
ceilingDraftPoints,
clearFencePlacementDraft,
clearRoofPlacementDraft,
clearWallPlacementDraft,
emitFloorplanGridEvent,
fenceDraftStart,
fences,
findClosestWallPoint,
floorplanOpeningLocalY,
getSnappedFloorplanPoint,
handleCeilingItemPlacementClick,
handleCeilingPlacementPoint,
handleSlabPlacementPoint,
handleWallPlacementPoint,
handleZonePlacementPoint,
isCeilingBuildActive,
isCeilingItemPlacementActive,
isFenceBuildActive,
isFloorplanGridInteractionActive,
isOpeningPlacementActive,
isPolygonBuildActive,
isRoofBuildActive,
isSlabBuildActive,
isWallBuildActive,
isZoneBuildActive,
levelId,
roofDraftStart,
setCursorPoint,
setFenceDraftEnd,
setFenceDraftStart,
setRoofDraftEnd,
setRoofDraftStart,
shiftPressed,
snapWallDraftPoint,
snapPolygonDraftPoint,
toPoint2D,
walls,
worldGridSnap,
}: UseFloorplanBackgroundPlacementArgs) {
const handleBackgroundPlacementClick = useCallback(
(
planPoint: WallPlanPoint,
event: ReactMouseEvent<SVGSVGElement>,
draftStart: WallPlanPoint | null,
) => {
if (isOpeningPlacementActive) {
const closest = findClosestWallPoint(planPoint, walls, {
canUseWall: (wall) => !isCurvedWall(wall),
})
if (closest) {
const dx = closest.wall.end[0] - closest.wall.start[0]
const dz = closest.wall.end[1] - closest.wall.start[1]
const length = Math.sqrt(dx * dx + dz * dz)
const distance = closest.t * length
emitter.emit('wall:click', {
node: closest.wall,
point: { x: closest.point[0], y: 0, z: closest.point[1] },
localPosition: [distance, floorplanOpeningLocalY, 0],
normal: closest.normal,
stopPropagation: () => {},
} as any)
}
// Drop the off-wall ghost on commit so it doesn't linger at the
// just-placed spot before the next pointer move re-evaluates.
usePlacementPreview.getState().clear()
return true
}
if (isCeilingBuildActive) {
const bypassSnap = shiftPressed || event.shiftKey
// Align the committed vertex the same way the move-preview did, so
// the placed point matches what the user saw. Wall magnetic snap may
// still win; generic alignment is skipped when angle snap owns the
// vertex (matches the move branch).
const angleSnap = ceilingDraftPoints.length > 0 && !bypassSnap
const fallbackPoint = snapPolygonDraftPoint({
point: planPoint,
start: ceilingDraftPoints[ceilingDraftPoints.length - 1],
angleSnap,
bypassSnap,
})
const snappedPoint = resolveCeilingPlanPointSnap({
rawPoint: planPoint,
fallbackPoint,
levelId,
altKey: event.altKey,
shiftKey: bypassSnap,
align: !angleSnap,
}).point
emitFloorplanGridEvent('click', snappedPoint, event)
handleCeilingPlacementPoint(snappedPoint)
return true
}
if (isRoofBuildActive) {
const bypassSnap = shiftPressed || event.shiftKey
const snappedPoint = alignFloorplanDraftPoint(
bypassSnap ? planPoint : getSnappedFloorplanPoint(planPoint),
{ bypass: event.altKey || bypassSnap },
)
emitFloorplanGridEvent('click', snappedPoint, event)
setCursorPoint(snappedPoint)
if (roofDraftStart) {
clearRoofPlacementDraft()
} else {
setRoofDraftStart(snappedPoint)
setRoofDraftEnd(snappedPoint)
}
return true
}
if (isFenceBuildActive) {
const bypassSnap = shiftPressed || event.shiftKey
// Fence draft: grid snap (+ existing-wall/fence endpoint snap), then
// Figma alignment — endpoint snap wins (same precedence as move).
// While a draft is open the segment locks to 15° rays from its
// start unless Shift is held; Shift bypasses grid, magnetic,
// angle, and alignment snap. `gridSnap` keeps the regular snap
// on the world XZ grid even when the building is rotated.
const fenceStep = getSegmentGridStep()
const fenceAngleSnap = fenceDraftStart !== null && !bypassSnap && isAngleSnapActive()
const fenceSnapped = snapFenceDraftPoint({
point: planPoint,
walls,
fences,
start: fenceDraftStart ?? undefined,
angleSnap: fenceAngleSnap,
bypassSnap,
magnetic: !bypassSnap && isMagneticSnapActive(),
gridSnap: (p) => worldGridSnap(p, fenceStep),
})
const fenceGridBase = bypassSnap ? planPoint : worldGridSnap(planPoint, fenceStep)
const fenceLocked =
!bypassSnap &&
(fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1])
const snappedPoint =
fenceLocked || fenceAngleSnap
? fenceSnapped
: alignFloorplanDraftPoint(fenceSnapped, {
bypass: event.altKey || bypassSnap || !isMagneticSnapActive(),
})
emitFloorplanGridEvent('click', snappedPoint, event)
setCursorPoint(snappedPoint)
// Double-click finishes the chain. The emit above already made the
// 3D fence tool stopDrafting (its detail >= 2 guard), so close the
// 2D draft too — leaving it open desyncs the two views.
if (fenceDraftStart && event.detail >= 2) {
clearFencePlacementDraft()
return true
}
if (!fenceDraftStart) {
setFenceDraftStart(snappedPoint)
setFenceDraftEnd(snappedPoint)
} else if (
getPlanPointDistance(toPoint2D(fenceDraftStart), toPoint2D(snappedPoint)) >= 0.01
) {
// The 3D fence tool owns creation and keeps chaining from the
// committed fence's resolved end — chain the 2D draft from the
// same published point so both views draft the next segment
// from the same start.
const nextStart = useSegmentDraftChain.getState().fence ?? snappedPoint
setFenceDraftStart(nextStart)
setFenceDraftEnd(nextStart)
} else {
setFenceDraftEnd(snappedPoint)
}
return true
}
// Slab / zone polygon build — local draft state + grid emit.
// Must run BEFORE the `isFloorplanGridInteractionActive` catch-all
// (since slab is registry-driven, the catch-all would otherwise
// swallow the click and skip local draft state updates — leaving
// the 2D draft polygon invisible while the 3D tool builds fine).
if (isPolygonBuildActive) {
const bypassSnap = shiftPressed || event.shiftKey
const angleSnap = activePolygonDraftPoints.length > 0 && !bypassSnap
const fallbackPoint = snapPolygonDraftPoint({
point: planPoint,
start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1],
angleSnap,
bypassSnap,
})
let snappedPoint = fallbackPoint
if (isSlabBuildActive) {
snappedPoint = resolveSlabPlanPointSnap({
rawPoint: planPoint,
fallbackPoint,
levelId,
altKey: event.altKey,
shiftKey: bypassSnap,
align: !angleSnap,
}).point
} else if (!angleSnap) {
snappedPoint = alignFloorplanDraftPoint(fallbackPoint, {
bypass: event.altKey || bypassSnap,
})
}
// Emit the grid event so the registry-driven slab tool also
// sees the click (parity with ceiling / fence / roof branches
// above). Zone has no registry tool — emit-or-not is irrelevant.
if (!isZoneBuildActive) {
emitFloorplanGridEvent('click', snappedPoint, event)
}
if (isZoneBuildActive) {
handleZonePlacementPoint(snappedPoint)
} else {
handleSlabPlacementPoint(snappedPoint)
}
return true
}
// Wall placement — local draft state + grid emit. Same reasoning
// as slab above: wall is registry-driven, so without this branch
// the catch-all would swallow the click and the local draftStart
// / draftEnd state in the floor plan would never update, leaving
// the dashed-line draft preview invisible.
if (isWallBuildActive) {
const bypassSnap = shiftPressed || event.shiftKey
// Wall draft: grid snap (+ existing-wall endpoint/join snap), then
// Figma alignment — endpoint/join snap wins (same precedence as the
// move-preview branch), so committing onto a corner still works.
// While a draft is open the segment locks to 15° rays from its
// start unless Shift is held; Shift bypasses grid, magnetic,
// angle, and alignment snap. `gridSnap` keeps the regular snap
// on the world XZ grid even when the building is rotated.
const wallStep = getSegmentGridStep()
const wallAngleSnap = draftStart !== null && !bypassSnap && isAngleSnapActive()
const wallSnapped = snapWallDraftPoint({
point: planPoint,
walls,
start: draftStart ?? undefined,
angleSnap: wallAngleSnap,
bypassSnap,
gridSnap: (p) => worldGridSnap(p, wallStep),
})
const wallGridBase = bypassSnap ? planPoint : worldGridSnap(planPoint, wallStep)
const wallLocked =
!bypassSnap && (wallSnapped[0] !== wallGridBase[0] || wallSnapped[1] !== wallGridBase[1])
let snappedPoint = wallSnapped
if (wallLocked) {
useAlignmentGuides.getState().clear()
} else {
snappedPoint = alignFloorplanDraftPoint(wallSnapped, {
applySnap: !wallAngleSnap,
// Figma alignment pulls the endpoint onto existing wall corners /
// edges, so it is a line snap — suppress it whenever magnetic snap
// is off (`'off'` / `'angles'`), matching the wall-geometry snap.
bypass: event.altKey || bypassSnap || !isMagneticSnapActive(),
})
}
emitFloorplanGridEvent('click', snappedPoint, event)
// Double-click finishes the chain. The emit above already made the
// 3D wall tool stopDrafting (its detail >= 2 guard), so close the
// 2D draft too — otherwise it stays open against a closed 3D tool
// and the next previewed segment is silently never created.
if (draftStart && event.detail >= 2) {
clearWallPlacementDraft()
setCursorPoint(snappedPoint)
return true
}
handleWallPlacementPoint(snappedPoint, { singleWall: event.altKey })
return true
}
// Ceiling-attached item placement (lights, fans). Routes the click
// through `ceiling:click` instead of `grid:click` so the placement
// strategy parents the new item to the ceiling at the correct
// height — mirrors the pointer-move handler in `floorplan-panel`.
if (isCeilingItemPlacementActive) {
handleCeilingItemPlacementClick(planPoint, event)
return true
}
// Generic catch-all — registry-driven tool whose kind has no
// local floor-plan draft handler (column / spawn / shelf / etc.).
// The tool's `grid:click` subscriber owns the placement.
if (isFloorplanGridInteractionActive) {
const snappedPoint = event.shiftKey ? planPoint : getSnappedFloorplanPoint(planPoint)
emitFloorplanGridEvent('click', snappedPoint, event)
setCursorPoint(snappedPoint)
return true
}
return false
},
[
activePolygonDraftPoints,
ceilingDraftPoints,
clearFencePlacementDraft,
clearRoofPlacementDraft,
clearWallPlacementDraft,
emitFloorplanGridEvent,
fenceDraftStart,
fences,
findClosestWallPoint,
floorplanOpeningLocalY,
getSnappedFloorplanPoint,
handleCeilingItemPlacementClick,
handleCeilingPlacementPoint,
handleSlabPlacementPoint,
handleZonePlacementPoint,
isCeilingBuildActive,
isCeilingItemPlacementActive,
isFenceBuildActive,
isFloorplanGridInteractionActive,
isOpeningPlacementActive,
isPolygonBuildActive,
isRoofBuildActive,
isSlabBuildActive,
isWallBuildActive,
isZoneBuildActive,
levelId,
roofDraftStart,
setCursorPoint,
setFenceDraftEnd,
setFenceDraftStart,
setRoofDraftEnd,
setRoofDraftStart,
shiftPressed,
snapWallDraftPoint,
snapPolygonDraftPoint,
toPoint2D,
walls,
handleWallPlacementPoint,
worldGridSnap,
],
)
return {
handleBackgroundPlacementClick,
}
}