diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx
index ac86095a..e562d403 100644
--- a/packages/editor/src/components/editor/floorplan-panel.tsx
+++ b/packages/editor/src/components/editor/floorplan-panel.tsx
@@ -10797,7 +10797,16 @@ export function FloorplanPanel({
Switch to a building level to view and edit the floorplan.
- ) : (
+ ) : isFloorplanOpen ? (
+ // The panel stays mounted in 3D mode (display:none) to keep the
+ // portalled compass + viewport state warm, but the heavy 2D scene
+ // (registry layer → one InteractiveGeometry per node, geometry
+ // renderer, handle layers) must NOT render/reconcile while hidden —
+ // otherwise every scene/selection change in pure 3D re-rendered the
+ // whole floorplan tree (profiler: 150–200ms on a wall-endpoint drag).
+ // `isFloorplanOpen` is `viewMode !== '3d'`, so this still renders fully
+ // in both 2D and split. Viewport state lives on the still-mounted
+ // panel, so pan/zoom is preserved across the toggle.
)}
- )}
+ ) : null}
)
diff --git a/packages/nodes/src/wall/move-endpoint-tool.tsx b/packages/nodes/src/wall/move-endpoint-tool.tsx
index 01ee2a04..cf6aa0e5 100644
--- a/packages/nodes/src/wall/move-endpoint-tool.tsx
+++ b/packages/nodes/src/wall/move-endpoint-tool.tsx
@@ -11,6 +11,7 @@ import {
pauseSceneHistory,
resolveAlignment,
resumeSceneHistory,
+ useLiveNodeOverrides,
useScene,
type WallNode,
} from '@pascal-app/core'
@@ -230,20 +231,44 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
pauseSceneHistory(useScene)
let wasCommitted = false
+ // Wall ids carrying a live position override during the drag. Mirrors the
+ // 3D/2D wall MOVE tools: preview via `useLiveNodeOverrides` (the wall
+ // system, wall panel, and 2D floor plan all merge it) instead of writing
+ // the scene store every tick. A per-tick `updateNodes` hands a fresh `nodes`
+ // reference to every `useScene(s => s.nodes)` subscriber (sidebar panels,
+ // contextual HUD, tooltips, floor plan) and rebuilds them all each frame.
+ // The store is written ONCE, atomically, on commit.
+ const touchedWallIds = new Set()
+
const applyNodePreview = (
updates: Array<{ id: WallNode['id']; start: WallPlanPoint; end: WallPlanPoint }>,
) => {
- useScene.getState().updateNodes(
- updates.map((entry) => ({
- id: entry.id as AnyNodeId,
- data: { start: entry.start, end: entry.end },
- })),
+ const overrides = useLiveNodeOverrides.getState()
+ const sceneState = useScene.getState()
+ overrides.setMany(
+ updates.map(
+ (entry) =>
+ [entry.id, { start: entry.start, end: entry.end }] as [string, Record],
+ ),
)
for (const entry of updates) {
- useScene.getState().markDirty(entry.id as AnyNodeId)
+ touchedWallIds.add(entry.id as AnyNodeId)
+ sceneState.markDirty(entry.id as AnyNodeId)
}
}
+ // Drop every live override (mesh + miters revert to the scene store, which
+ // was never mutated during the drag) and re-dirty so geometry rebuilds.
+ const clearPreviewOverrides = () => {
+ const overrides = useLiveNodeOverrides.getState()
+ const sceneState = useScene.getState()
+ for (const id of touchedWallIds) {
+ overrides.clear(id)
+ sceneState.markDirty(id)
+ }
+ touchedWallIds.clear()
+ }
+
const applyPreview = (movingPoint: WallPlanPoint, detachLinkedWalls = false) => {
const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint
const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint
@@ -277,15 +302,23 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
}
const restoreOriginal = (clearAngleLabel = true) => {
- applyNodePreview([
- { id: nodeId, start: originalStart, end: originalEnd },
- ...linkedOriginalsRef.current,
- ])
+ clearPreviewOverrides()
if (clearAngleLabel) {
setAngleLabel(null)
}
}
+ // Eat the click the browser fires right after the commit pointerup so it
+ // doesn't fall through to the wall body and arm the wall move tool.
+ const swallowNextClick = () => {
+ const swallow = (e: Event) => {
+ e.stopPropagation()
+ e.preventDefault()
+ }
+ window.addEventListener('click', swallow, { capture: true, once: true })
+ setTimeout(() => window.removeEventListener('click', swallow, { capture: true }), 300)
+ }
+
const onGridMove = (event: GridEvent) => {
const planPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
// Endpoint move honours the active snapping mode (the HUD chip): grid →
@@ -351,6 +384,12 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
const onPointerUp = () => {
useAlignmentGuides.getState().clear()
useWallSnapIndicator.getState().clear()
+ // The handle sits on the wall body, so the browser fires a click on the
+ // wall after this release. Swallow it on EVERY endpoint-tool release (a
+ // no-drag tap dismisses, a drag commits) — otherwise that click falls
+ // through to the selection manager and arms the wall MOVE tool, a mode the
+ // user never asked for.
+ swallowNextClick()
// Press-release without drag: dismiss the tool without committing.
if (!hasDraggedRef.current) {
useViewer.getState().setSelection({ selectedIds: [nodeId] })
@@ -367,26 +406,33 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
if (hasChanged && isSegmentLongEnough(preview.start, preview.end)) {
wasCommitted = true
- // Restore original baseline while paused so the next resume+update
- // registers as a single tracked change (undo reverts to original).
- applyNodePreview([
- { id: nodeId, start: originalStart, end: originalEnd },
- ...linkedOriginalsRef.current,
- ])
+ const linkedUpdates = altPressedRef.current
+ ? []
+ : getLinkedWallUpdates(
+ linkedOriginalsRef.current,
+ originalStart,
+ originalEnd,
+ preview.start,
+ preview.end,
+ )
+ // Drop the live overrides; the store write below is the source of truth.
+ // The store sat at the pre-drag (original) values the whole drag — only
+ // overrides moved — so one resume+write records original→final as a
+ // single tracked change (one Ctrl-Z reverts to original).
+ clearPreviewOverrides()
resumeSceneHistory(useScene)
- applyNodePreview([
- { id: nodeId, start: preview.start, end: preview.end },
- ...(altPressedRef.current
- ? []
- : getLinkedWallUpdates(
- linkedOriginalsRef.current,
- originalStart,
- originalEnd,
- preview.start,
- preview.end,
- )),
+ useScene.getState().updateNodes([
+ { id: nodeId as AnyNodeId, data: { start: preview.start, end: preview.end } },
+ ...linkedUpdates.map((u) => ({
+ id: u.id as AnyNodeId,
+ data: { start: u.start, end: u.end },
+ })),
])
+ useScene.getState().markDirty(nodeId as AnyNodeId)
+ for (const u of linkedUpdates) {
+ useScene.getState().markDirty(u.id as AnyNodeId)
+ }
pauseSceneHistory(useScene)
triggerSFX('sfx:item-place')
}