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:
co-authored by
Claude Opus 4.8
parent
8a57105eec
commit
76096ffe72
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user