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:
co-authored by
Claude Opus 4.8
parent
b384bc8178
commit
eeadec11a7
@@ -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">
|
<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.
|
Switch to a building level to view and edit the floorplan.
|
||||||
</div>
|
</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: 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.
|
||||||
<svg
|
<svg
|
||||||
className="h-full w-full touch-none"
|
className="h-full w-full touch-none"
|
||||||
onClick={isMarqueeSelectionToolActive ? undefined : handleSvgClick}
|
onClick={isMarqueeSelectionToolActive ? undefined : handleSvgClick}
|
||||||
@@ -11137,7 +11146,7 @@ export function FloorplanPanel({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
pauseSceneHistory,
|
pauseSceneHistory,
|
||||||
resolveAlignment,
|
resolveAlignment,
|
||||||
resumeSceneHistory,
|
resumeSceneHistory,
|
||||||
|
useLiveNodeOverrides,
|
||||||
useScene,
|
useScene,
|
||||||
type WallNode,
|
type WallNode,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
@@ -230,20 +231,44 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
|||||||
pauseSceneHistory(useScene)
|
pauseSceneHistory(useScene)
|
||||||
let wasCommitted = false
|
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 = (
|
const applyNodePreview = (
|
||||||
updates: Array<{ id: WallNode['id']; start: WallPlanPoint; end: WallPlanPoint }>,
|
updates: Array<{ id: WallNode['id']; start: WallPlanPoint; end: WallPlanPoint }>,
|
||||||
) => {
|
) => {
|
||||||
useScene.getState().updateNodes(
|
const overrides = useLiveNodeOverrides.getState()
|
||||||
updates.map((entry) => ({
|
const sceneState = useScene.getState()
|
||||||
id: entry.id as AnyNodeId,
|
overrides.setMany(
|
||||||
data: { start: entry.start, end: entry.end },
|
updates.map(
|
||||||
})),
|
(entry) =>
|
||||||
|
[entry.id, { start: entry.start, end: entry.end }] as [string, Record<string, unknown>],
|
||||||
|
),
|
||||||
)
|
)
|
||||||
for (const entry of updates) {
|
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 applyPreview = (movingPoint: WallPlanPoint, detachLinkedWalls = false) => {
|
||||||
const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint
|
const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint
|
||||||
const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint
|
const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint
|
||||||
@@ -277,15 +302,23 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const restoreOriginal = (clearAngleLabel = true) => {
|
const restoreOriginal = (clearAngleLabel = true) => {
|
||||||
applyNodePreview([
|
clearPreviewOverrides()
|
||||||
{ id: nodeId, start: originalStart, end: originalEnd },
|
|
||||||
...linkedOriginalsRef.current,
|
|
||||||
])
|
|
||||||
if (clearAngleLabel) {
|
if (clearAngleLabel) {
|
||||||
setAngleLabel(null)
|
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 onGridMove = (event: GridEvent) => {
|
||||||
const planPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
|
const planPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
|
||||||
// Endpoint move honours the active snapping mode (the HUD chip): grid →
|
// Endpoint move honours the active snapping mode (the HUD chip): grid →
|
||||||
@@ -351,6 +384,12 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
|||||||
const onPointerUp = () => {
|
const onPointerUp = () => {
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
useWallSnapIndicator.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.
|
// Press-release without drag: dismiss the tool without committing.
|
||||||
if (!hasDraggedRef.current) {
|
if (!hasDraggedRef.current) {
|
||||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||||
@@ -367,26 +406,33 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
|||||||
if (hasChanged && isSegmentLongEnough(preview.start, preview.end)) {
|
if (hasChanged && isSegmentLongEnough(preview.start, preview.end)) {
|
||||||
wasCommitted = true
|
wasCommitted = true
|
||||||
|
|
||||||
// Restore original baseline while paused so the next resume+update
|
const linkedUpdates = altPressedRef.current
|
||||||
// registers as a single tracked change (undo reverts to original).
|
? []
|
||||||
applyNodePreview([
|
: getLinkedWallUpdates(
|
||||||
{ id: nodeId, start: originalStart, end: originalEnd },
|
linkedOriginalsRef.current,
|
||||||
...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)
|
resumeSceneHistory(useScene)
|
||||||
applyNodePreview([
|
useScene.getState().updateNodes([
|
||||||
{ id: nodeId, start: preview.start, end: preview.end },
|
{ id: nodeId as AnyNodeId, data: { start: preview.start, end: preview.end } },
|
||||||
...(altPressedRef.current
|
...linkedUpdates.map((u) => ({
|
||||||
? []
|
id: u.id as AnyNodeId,
|
||||||
: getLinkedWallUpdates(
|
data: { start: u.start, end: u.end },
|
||||||
linkedOriginalsRef.current,
|
})),
|
||||||
originalStart,
|
|
||||||
originalEnd,
|
|
||||||
preview.start,
|
|
||||||
preview.end,
|
|
||||||
)),
|
|
||||||
])
|
])
|
||||||
|
useScene.getState().markDirty(nodeId as AnyNodeId)
|
||||||
|
for (const u of linkedUpdates) {
|
||||||
|
useScene.getState().markDirty(u.id as AnyNodeId)
|
||||||
|
}
|
||||||
pauseSceneHistory(useScene)
|
pauseSceneHistory(useScene)
|
||||||
triggerSFX('sfx:item-place')
|
triggerSFX('sfx:item-place')
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user