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:
co-authored by
Claude Opus 4.8
parent
b2f1a8432e
commit
f773e6b8c5
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user