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>
This commit is contained in:
Wassim SAMAD
2026-06-23 09:04:58 -04:00
co-authored by Claude Opus 4.8
parent b2f1a8432e
commit f773e6b8c5
71 changed files with 2362 additions and 598 deletions
@@ -1,16 +1,19 @@
import {
type AnyNodeId,
clampDoorOperationState,
DEFAULT_WALL_THICKNESS,
type DoorNode,
DoorNode as DoorNodeSchema,
getDoorRenderOpenAmount,
getEffectiveNode,
getWallThickness,
type SceneMaterial,
type SceneMaterialId,
sceneRegistry,
useInteractive,
useLiveNodeOverrides,
useScene,
type WallNode,
} from '@pascal-app/core'
import { useFrame } from '@react-three/fiber'
import { useEffect, useRef } from 'react'
@@ -26,6 +29,7 @@ import {
resolveMaterialRef,
} from '../../lib/materials'
import useViewer from '../../store/use-viewer'
import { getOpeningCutoutProxyDepth } from '../wall/opening-cutout-geometry'
// Invisible material for root mesh — used as selection hitbox only
const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false })
@@ -186,6 +190,19 @@ function tagDoorSlot(mesh: THREE.Mesh): THREE.Mesh {
return mesh
}
const NO_RAYCAST = () => {}
// An open door leaf swings perpendicular to the wall, so in a top-down view its
// flat panel blankets the room interior and wins the selection raycast over the
// slab/items beneath it. Drop the swung leaf out of the raycast so a click on
// the floor falls through to what's underneath; the door stays selectable via
// its proud invisible cutout proxy at the opening (see syncDoorCutout).
function disableSubtreeRaycast(object: THREE.Object3D) {
object.traverse((child) => {
;(child as unknown as { raycast: () => void }).raycast = NO_RAYCAST
})
}
function nodeReferencesSceneMaterial(node: { slots?: Record<string, string> }): boolean {
const slots = node.slots
if (!slots) return false
@@ -1323,6 +1340,14 @@ function addDoorLeaf(
)
addBox(mesh, hardwareMaterial, hingeW, hingeH, hingeD, hingeMarkerX, leafTop - 0.25, 0)
}
// When the leaf is swung open it projects into the room and would otherwise
// win a top-down selection click over the floor beneath it. Drop only the
// swung leaf out of the raycast; a closed leaf stays in the wall plane and
// keeps its hit-eligibility (so paint-by-slot still works on it).
if (Math.abs(swingRotation) > 1e-3) {
disableSubtreeRaycast(leafGroup)
}
}
function addFoldingDoor(
@@ -2556,18 +2581,21 @@ function hideEmptyGeometryMeshes(root: THREE.Object3D) {
}
function syncDoorCutout(node: DoorNode, mesh: THREE.Mesh) {
// ── Cutout (for wall CSG) — always full door dimensions, 1m deep ──
// ── Cutout: invisible raycast hit target for the whole opening ──
let cutout = mesh.getObjectByName('cutout') as THREE.Mesh | undefined
if (!cutout) {
cutout = new THREE.Mesh()
cutout.name = 'cutout'
// The cutout (a 1m-deep CSG helper, invisible) is proud of the wall, so it
// wins the scene raycast over the wall in front of the recessed door body —
// making it the selection AND paint hit target for the whole opening. The
// paint capability then re-raycasts the door's parts to find the slot.
// The cutout (invisible) is proud of the wall on both faces, so it wins the
// scene raycast over the wall in front of the recessed door body — making it
// the selection AND paint hit target for the whole opening. The paint
// capability then re-raycasts the door's parts to find the slot. Its depth
// is snug to the wall (not 1m) so it no longer blankets the room floor in a
// top-down view; the wall CSG ignores this depth (see getOpeningCutoutProxyDepth).
mesh.add(cutout)
}
cutout.geometry.dispose()
const depth = resolveOpeningCutoutProxyDepth(node)
const openingShape = getEffectiveOpeningShape(node)
if (openingShape === 'arch') {
cutout.geometry = new THREE.ExtrudeGeometry(
@@ -2579,12 +2607,12 @@ function syncDoorCutout(node: DoorNode, mesh: THREE.Mesh) {
getClampedArchHeight(node.width, node.height, node.archHeight),
),
{
depth: 1,
depth,
bevelEnabled: false,
curveSegments: 24,
},
)
cutout.geometry.translate(0, 0, -0.5)
cutout.geometry.translate(0, 0, -depth / 2)
} else if (openingShape === 'rounded') {
cutout.geometry = new THREE.ExtrudeGeometry(
createRoundedTopShape(
@@ -2595,18 +2623,30 @@ function syncDoorCutout(node: DoorNode, mesh: THREE.Mesh) {
getDoorTopRadii(node, node.width, node.height),
),
{
depth: 1,
depth,
bevelEnabled: false,
curveSegments: 24,
},
)
cutout.geometry.translate(0, 0, -0.5)
cutout.geometry.translate(0, 0, -depth / 2)
} else {
cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0)
cutout.geometry = new THREE.BoxGeometry(node.width, node.height, depth)
}
cutout.visible = false
}
// Resolve the cutout proxy depth from the opening's parent wall thickness so
// the proxy stays proud of both wall faces (front/back selection) without the
// old 1m depth that blanketed the floor. Falls back to the default thickness
// when the parent wall isn't a resolvable wall node.
function resolveOpeningCutoutProxyDepth(node: DoorNode): number {
const parentId = node.parentId
const parent = parentId ? useScene.getState().nodes[parentId as AnyNodeId] : undefined
const wallThickness =
parent?.type === 'wall' ? getWallThickness(parent as WallNode) : DEFAULT_WALL_THICKNESS
return getOpeningCutoutProxyDepth(wallThickness)
}
/**
* Build a fresh door mesh for preview/ghost rendering.
* Returns a mesh with an invisible hitbox root and visible children (frame, panels, hardware).
@@ -10,6 +10,20 @@ export type OpeningCutoutRect = {
top: number
}
// The cutout proxy doubles as the invisible raycast hit target for an opening:
// centered on the wall and extending past both faces so it wins the scene
// raycast over the recessed door/window body for front AND back selection +
// paint. It only needs to clear the wall thickness plus a small proud margin —
// the wall CSG brush ignores this proxy's depth entirely (it rebuilds its own
// full-thickness box from the proxy's X/Y bounds in `collectCutoutBrushes`), so
// a snug depth keeps the cut intact while no longer blanketing the room floor in
// a top-down view (the bug a 1m-deep proxy caused in narrow hallways).
const OPENING_CUTOUT_PROXY_PROUD_MARGIN = 0.08
export function getOpeningCutoutProxyDepth(wallThickness: number): number {
return Math.max(wallThickness, 0) + OPENING_CUTOUT_PROXY_PROUD_MARGIN
}
type CornerRadii = {
topLeft: number
topRight: number
@@ -1,12 +1,15 @@
import {
type AnyNodeId,
DEFAULT_WALL_THICKNESS,
getEffectiveNode,
getWallThickness,
type SceneMaterial,
type SceneMaterialId,
sceneRegistry,
useInteractive,
useLiveNodeOverrides,
useScene,
type WallNode,
type WindowNode,
} from '@pascal-app/core'
import { useFrame } from '@react-three/fiber'
@@ -22,6 +25,7 @@ import {
resolveMaterialRef,
} from '../../lib/materials'
import useViewer from '../../store/use-viewer'
import { getOpeningCutoutProxyDepth } from '../wall/opening-cutout-geometry'
// Invisible material for root mesh — used as selection hitbox only
const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false })
@@ -163,6 +167,21 @@ function tagWindowSlot(mesh: THREE.Mesh): THREE.Mesh {
return mesh
}
const NO_RAYCAST = () => {}
// An open casement sash swings perpendicular to the wall, so in a top-down view
// its flat panel blankets the room interior and wins the selection raycast over
// the slab/items beneath it. Drop the swung sash out of the raycast so a floor
// click falls through; the window stays selectable via its proud invisible
// cutout proxy at the opening (see syncWindowCutout). Skipped while closed so
// paint-by-slot still resolves on the sash.
function disableSubtreeRaycastIfSwung(object: THREE.Object3D, rotationY: number) {
if (Math.abs(rotationY) <= 1e-3) return
object.traverse((child) => {
;(child as unknown as { raycast: () => void }).raycast = NO_RAYCAST
})
}
function nodeReferencesSceneMaterial(node: { slots?: Record<string, string> }): boolean {
const slots = node.slots
if (!slots) return false
@@ -1020,6 +1039,8 @@ function addRectCasementSash(
)
currentWindowSlot = 'glass'
addBox(sash, glassMaterial, glassW, glassH, glassDepth, sashCenterX, 0, sashDepth * 0.08)
disableSubtreeRaycastIfSwung(sash, rotationY)
}
function addFrenchCasementHingeMarkers(
@@ -1244,6 +1265,7 @@ function addShapedFrenchCasementSash(
sashDepth * 0.08,
)
}
disableSubtreeRaycastIfSwung(sash, rotationY)
return
}
@@ -1271,6 +1293,7 @@ function addShapedFrenchCasementSash(
sashDepth * 0.08,
)
}
disableSubtreeRaycastIfSwung(sash, rotationY)
}
function addFrenchCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
@@ -1534,6 +1557,8 @@ function addShapedCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
}
}
disableSubtreeRaycastIfSwung(sash, sash.rotation.y)
currentWindowSlot = 'frame'
addBox(
mesh,
@@ -1696,6 +1721,8 @@ function addCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
currentWindowSlot = 'glass'
addBox(sash, glassMaterial, glassW, glassH, glassDepth, sashCenterX, 0, sashDepth * 0.08)
disableSubtreeRaycastIfSwung(sash, sash.rotation.y)
// Small hinge markers make the pivot side legible when the sash is closed.
currentWindowSlot = 'frame'
addBox(
@@ -3597,20 +3624,23 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
}
function syncWindowCutout(node: WindowNode, mesh: THREE.Mesh) {
// ── Cutout (for wall CSG) — always full window dimensions, 1m deep ──
// ── Cutout: invisible raycast hit target for the whole opening ──
let cutout = mesh.getObjectByName('cutout') as THREE.Mesh | undefined
if (!cutout) {
cutout = new THREE.Mesh()
cutout.name = 'cutout'
// The cutout (a 1m-deep CSG helper, invisible) is proud of the wall, so it
// wins the scene raycast over the wall in front of the recessed window —
// making it the selection AND paint hit target for the whole opening. The
// paint capability then re-raycasts the window's parts to find the slot.
// The cutout (invisible) is proud of the wall on both faces, so it wins the
// scene raycast over the wall in front of the recessed window — making it
// the selection AND paint hit target for the whole opening. The paint
// capability then re-raycasts the window's parts to find the slot. Its depth
// is snug to the wall (not 1m) so it no longer blankets the room floor in a
// top-down view; the wall CSG ignores this depth (see getOpeningCutoutProxyDepth).
mesh.add(cutout)
}
cutout.geometry.dispose()
const depth = resolveOpeningCutoutProxyDepth(node)
if (isRectangleOnlyWindowType(node)) {
cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0)
cutout.geometry = new THREE.BoxGeometry(node.width, node.height, depth)
} else if (node.openingShape === 'arch') {
cutout.geometry = new THREE.ExtrudeGeometry(
createArchShape(
@@ -3621,12 +3651,12 @@ function syncWindowCutout(node: WindowNode, mesh: THREE.Mesh) {
getClampedArchHeight(node.width, node.height, node.archHeight),
),
{
depth: 1,
depth,
bevelEnabled: false,
curveSegments: 24,
},
)
cutout.geometry.translate(0, 0, -0.5)
cutout.geometry.translate(0, 0, -depth / 2)
} else if (node.openingShape === 'rounded') {
cutout.geometry = new THREE.ExtrudeGeometry(
createRoundedShape(
@@ -3637,18 +3667,30 @@ function syncWindowCutout(node: WindowNode, mesh: THREE.Mesh) {
getWindowRoundedRadii(node, node.width, node.height),
),
{
depth: 1,
depth,
bevelEnabled: false,
curveSegments: 24,
},
)
cutout.geometry.translate(0, 0, -0.5)
cutout.geometry.translate(0, 0, -depth / 2)
} else {
cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0)
cutout.geometry = new THREE.BoxGeometry(node.width, node.height, depth)
}
cutout.visible = false
}
// Resolve the cutout proxy depth from the opening's parent wall thickness so
// the proxy stays proud of both wall faces (front/back selection) without the
// old 1m depth that blanketed the floor. Falls back to the default thickness
// when the parent wall isn't a resolvable wall node.
function resolveOpeningCutoutProxyDepth(node: WindowNode): number {
const parentId = node.parentId
const parent = parentId ? useScene.getState().nodes[parentId as AnyNodeId] : undefined
const wallThickness =
parent?.type === 'wall' ? getWallThickness(parent as WallNode) : DEFAULT_WALL_THICKNESS
return getOpeningCutoutProxyDepth(wallThickness)
}
/**
* Build a fresh window mesh for preview/ghost rendering.
* Returns a mesh with an invisible hitbox root and visible children (frame, glass, sash, hardware).