fix(editor): door/window move — fix 2D+3D FPS collapse + finish modifier migration

The 3D MoveDoor/MoveWindow tools wrote useScene every frame during a move
(freeFollowAt + applyPreview alternating): the wall:move (R3F) / grid:move
(DOM) de-dup compared event.timeStamp across two event systems with different
clocks, so it never matched and the floor free-follow ran during on-wall
slides too, ping-ponging the host and churning the nodes ref → framerate
collapse in both 2D and 3D. Replace it with a single-clock wall-ownership
window (performance.now, ~4 frames): the floor follow stands down while a
wall/roof hit is fresh. On-wall slides now write no scene per frame (mesh +
useLiveTransforms only). Lower the live wall-cutout throttle 120→60ms now that
the per-frame churn is gone.

Also completes the door/window modifier-model migration (#10): Shift=cycle /
Alt=force-place, fully mode-driven snap, snapProfile:'item'; exclude
ground-line candidates from along-wall opening alignment; emit the move SFX
once per snapped step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-25 10:21:37 -04:00
co-authored by Claude Opus 4.8
parent 8a57105eec
commit 76096ffe72
13 changed files with 486 additions and 439 deletions
@@ -141,6 +141,7 @@ type NodeDeps = {
highlighted: boolean
hovered: boolean
moving: boolean
liveOverride: LiveNodeOverrides | undefined
palette: FloorplanPalette | undefined
siblingEpoch: number
committedNodes: Record<string, AnyNode> | null
@@ -218,7 +219,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const ambientLevelId = useMemo<AnyNodeId | null>(() => {
if (selectedLevelId || !ambientBuildingSourceId) return null
const building = nodes[ambientBuildingSourceId]
if (!building || building.type !== 'building') return null
if (building?.type !== 'building') return null
let zero: AnyNodeId | null = null
let lowestId: AnyNodeId | null = null
let lowestIdx = Number.POSITIVE_INFINITY
@@ -626,6 +627,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const hovered = hoveredId === id
const moving = movingNode?.id === id
const live = liveTransforms.get(id)
const liveOverride = liveOverrides.get(id)
const dependsOnSiblingInputs = !!(
def.floorplanDependsOnSiblings || def.floorplanSiblingOverrides
)
@@ -636,6 +638,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
highlighted,
hovered,
moving,
liveOverride,
palette: renderCtx?.palette,
siblingEpoch: dependsOnSiblingInputs ? (nodeSiblingEpochs.get(id) ?? 0) : 0,
// Sibling-dependent kinds (wall miters, opening cuts) read other nodes'
@@ -719,7 +722,10 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
? def.floorplanSiblingOverrides({ nodeId: id, nodes, liveOverrides })
: nodes
const sourceNode = contextNodes !== nodes ? (contextNodes[id] ?? node) : node
const effectiveNode = applyLiveTransform(sourceNode)
const overrideNode = liveOverride
? ({ ...sourceNode, ...liveOverride } as AnyNode)
: sourceNode
const effectiveNode = applyLiveTransform(overrideNode)
const viewState = {
selected,
highlighted,
@@ -763,7 +769,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const visit = (id: AnyNodeId) => {
const node = nodes[id]
if (!node) return
if ((node as { visible?: boolean }).visible === false) return
if (!isFloorplanNodeVisible(node, liveOverrides.get(id))) return
buildEntry(id, node)
const childIds = (node as unknown as { children?: AnyNodeId[] }).children
if (Array.isArray(childIds)) {
@@ -790,7 +796,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const buildingScopedKindSet = new Set(buildingScopedKinds)
for (const [id, node] of Object.entries(nodes)) {
if (!node || !buildingScopedKindSet.has(node.type)) continue
if ((node as { visible?: boolean }).visible === false) continue
if (!isFloorplanNodeVisible(node, liveOverrides.get(id as AnyNodeId))) continue
const parentId = (node as { parentId?: AnyNodeId | null }).parentId
if (parentId !== activeBuildingId) continue
const cid = id as AnyNodeId
@@ -2093,6 +2099,12 @@ function applyPositionLiveTransform(
} as AnyNode
}
function isFloorplanNodeVisible(node: AnyNode, liveOverride?: LiveNodeOverrides): boolean {
const overrideVisible = liveOverride?.visible
if (typeof overrideVisible === 'boolean') return overrideVisible
return (node as { visible?: boolean }).visible !== false
}
function buildContext(
node: AnyNode,
nodes: Record<string, AnyNode>,
@@ -2303,6 +2315,8 @@ function computeAffectedSiblingIds(
} else if (node.type === 'door' || node.type === 'window') {
const hostId = (node as { parentId?: string }).parentId
if (hostId) affected.add(hostId as AnyNodeId)
const liveHostId = (liveOverrides.get(id) as { parentId?: string } | undefined)?.parentId
if (liveHostId) affected.add(liveHostId as AnyNodeId)
} else if (node.type === 'gutter') {
const roofId = (node as { parentId?: string }).parentId
if (roofId) {
@@ -2326,6 +2340,7 @@ function nodeDepsEqual(a: NodeDeps, b: NodeDeps): boolean {
'highlighted',
'hovered',
'moving',
'liveOverride',
'palette',
'siblingEpoch',
'committedNodes',
+7 -20
View File
@@ -43,26 +43,13 @@ export const useKeyboard = ({
return ed.mode === 'build' && (ed.tool === 'door' || ed.tool === 'window')
}
// Shift cycles the snapping mode while a snapping-mode-governed draft is
// armed: wall / fence build, item placement (build + item tool), and any
// active node move (`movingNode` — covers item 3D moves plus the generic
// registry move for shelf / spawn / column / stair). For items, free place
// moved to Alt, so Shift is free to cycle here too. Elsewhere Shift keeps
// its existing meaning — multi-select in plain select mode (no movingNode),
// free-place bypass during opening / zone placement — so this predicate
// must NOT fire for those. Door / window moves still use Shift for free
// place (out of this overhaul's scope), so they're excluded.
// Shift cycles the snapping mode (and clean-tap Ctrl the grid step) whenever
// there's an active snapping context — i.e. exactly when the HUD shows a
// snapping chip. That single source covers wall/fence/item drafting, every
// node move (including wall-hosted items), and endpoint/polygon reshaping,
// so the keys never silently stop working. Door / window keep Shift = free
// place until the modifier model unifies them.
const isSnappingCycleContext = () => {
const moving = getMovingNode()
if (moving?.type === 'door' || moving?.type === 'window') return false
return getActiveSnapContext() != null
}
// Shift cycles the snapping mode (and a clean-tap Ctrl the grid step)
// whenever there's an active snapping context — i.e. exactly when the HUD
// shows a snapping chip. That single source covers wall/fence/item drafting,
// every node move (including wall-hosted items + door/window openings, which
// now declare `snapProfile`), and endpoint/polygon reshaping, so the keys
// never silently stop working. (Force-place lives on Alt for all of them.)
const isSnappingCycleContext = () => getActiveSnapContext() != null
// A "clean tap" of Ctrl/Meta (pressed and released with NO other key in
// between) cycles the grid step — same context as the Shift snapping-mode