perf+fix(editor): gate 2D scene in 3D mode; wall-endpoint move via live overrides

Two changes from the split-view / wall-endpoint perf + UX pass.

floorplan-panel: render the heavy 2D <svg> scene only when the panel is visible
(`isFloorplanOpen`, i.e. viewMode !== '3d'). The panel stays mounted in 3D
(display:none) to keep the portalled compass + viewport state warm, but the
registry layer / per-node InteractiveGeometry / handle layers no longer
reconcile on every scene change while invisible. Renders fully in 2D and split;
viewport pan/zoom is preserved across the toggle.

wall move-endpoint-tool: preview via `useLiveNodeOverrides` instead of writing
`useScene.updateNodes` every grid:move tick. The per-tick store write handed a
fresh `nodes` ref to every `useScene(s => s.nodes)` subscriber (WallPanel, the
contextual HUD, tooltips, floor plan), rebuilding them all each frame. Overrides
are merged by the wall system, wall panel, and 2D floor plan, so the preview
still tracks live with no store churn; the store is written once on commit, and
one Ctrl-Z reverts to the original endpoint. Also swallow the click that follows
every endpoint-tool release so it can't fall through to the wall body and arm
the wall move tool (no-drag tap or post-commit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-27 19:39:10 -04:00
co-authored by Claude Opus 4.8
parent b384bc8178
commit eeadec11a7
2 changed files with 84 additions and 29 deletions
@@ -10797,7 +10797,16 @@ export function FloorplanPanel({
<div className="flex h-full items-center justify-center px-6 text-center text-muted-foreground text-sm">
Switch to a building level to view and edit the floorplan.
</div>
) : (
) : 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: 150200ms 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.
<svg
className="h-full w-full touch-none"
onClick={isMarqueeSelectionToolActive ? undefined : handleSvgClick}
@@ -11137,7 +11146,7 @@ export function FloorplanPanel({
/>
)}
</svg>
)}
) : null}
</div>
</div>
)
+73 -27
View File
@@ -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<AnyNodeId>()
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<string, unknown>],
),
)
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')
}