perf(floorplan): override-driven 3D wall move + granular sibling invalidation
Two fixes for the split-view FPS cliff when moving a wall or opening: - wall/move-tool: publish the live preview to useLiveNodeOverrides instead of writing useScene.updateNodes every frame. The store's `nodes` ref no longer churns per frame (which had re-rendered every useScene(s => s.nodes) subscriber app-wide). Matches the existing 2D wall drag + 3D wall-system override pattern; the final plan still commits atomically as one undoable change. - floorplan-registry-layer: replace the single global siblingEpoch with a per-node epoch bumped only for the nodes affected by the live drag (dragged wall -> walls at its old + new junctions + child openings; door/window -> host wall; gutter -> roof-peer gutters), unioned with the previous frame's live set so a cancelled drag reverts. Dragging one wall/opening now rebuilds a handful of geometries instead of all the level's walls + openings. 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
cfc1fb1672
commit
353b429a01
@@ -305,8 +305,15 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|||||||
const [activeDragId, setActiveDragId] = useState<string | null>(null)
|
const [activeDragId, setActiveDragId] = useState<string | null>(null)
|
||||||
const [rotationOverlay, setRotationOverlay] = useState<RotationOverlayState | null>(null)
|
const [rotationOverlay, setRotationOverlay] = useState<RotationOverlayState | null>(null)
|
||||||
const geometryCacheRef = useRef<Map<string, CacheEntry>>(new Map())
|
const geometryCacheRef = useRef<Map<string, CacheEntry>>(new Map())
|
||||||
const siblingEpochInputsRef = useRef<unknown[]>([])
|
// Per-node sibling epoch (replaces a single global epoch). Bumped only for the
|
||||||
const siblingEpochRef = useRef(0)
|
// nodes affected by this frame's live drags, so an unaffected wall/opening
|
||||||
|
// keeps its epoch and stays cached. `prevLiveFlaggedIdsRef` remembers which
|
||||||
|
// sibling-dependent nodes were live last frame, so a node that just STOPPED
|
||||||
|
// being dragged (override cleared, no commit) still gets one final rebuild to
|
||||||
|
// revert — its dependents (host wall, junction neighbours) don't carry its
|
||||||
|
// override in their own deps.
|
||||||
|
const nodeSiblingEpochRef = useRef<Map<AnyNodeId, number>>(new Map())
|
||||||
|
const prevLiveFlaggedIdsRef = useRef<AnyNodeId[]>([])
|
||||||
|
|
||||||
const applyEntrySelection = useCallback(
|
const applyEntrySelection = useCallback(
|
||||||
(id: AnyNodeId, shouldToggle: boolean) => {
|
(id: AnyNodeId, shouldToggle: boolean) => {
|
||||||
@@ -542,30 +549,38 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
// The sibling epoch bumps whenever a sibling-affecting node's LIVE state
|
// Granular sibling invalidation. A sibling-dependent node (wall miters,
|
||||||
// changes (a wall/door/window/gutter being dragged or live-edited). Only
|
// door/window cuts, gutter joins) must rebuild when a node it actually
|
||||||
// flagged kinds feed it, so dragging or rotating a plain item — which also
|
// DEPENDS ON has a live drag in flight — not when ANY flagged node anywhere
|
||||||
// publishes to liveTransforms / liveOverrides — leaves it stable and the
|
// does. The old single global epoch took the latter route: dragging one wall
|
||||||
// hundreds of wall/door geometries stay cached. Committed structural edits
|
// or opening rebuilt every wall + opening on the level (the floor-plan FPS
|
||||||
// are covered separately by keying flagged kinds on the `nodes` ref.
|
// cliff). Instead, collect the flagged nodes with a live transform/override,
|
||||||
const siblingEpochInputs: unknown[] = []
|
// expand to the set that depends on them (junction neighbours, host walls,
|
||||||
for (const [id, live] of liveTransforms) {
|
// gutter peers — see `computeAffectedSiblingIds`), and bump a PER-NODE epoch
|
||||||
|
// only for that set. `committedNodes` (kept in the deps) still catches
|
||||||
|
// committed structural edits, so this only narrows the LIVE-drag case.
|
||||||
|
const liveFlaggedIds: AnyNodeId[] = []
|
||||||
|
for (const [id] of liveTransforms) {
|
||||||
const node = nodes[id as AnyNodeId]
|
const node = nodes[id as AnyNodeId]
|
||||||
if (node && nodeRegistry.get(node.type)?.floorplanDependsOnSiblings) {
|
if (node && nodeRegistry.get(node.type)?.floorplanDependsOnSiblings) {
|
||||||
siblingEpochInputs.push(live)
|
liveFlaggedIds.push(id as AnyNodeId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const [id, override] of liveOverrides) {
|
for (const [id] of liveOverrides) {
|
||||||
const node = nodes[id as AnyNodeId]
|
const node = nodes[id as AnyNodeId]
|
||||||
if (node && nodeRegistry.get(node.type)?.floorplanDependsOnSiblings) {
|
if (node && nodeRegistry.get(node.type)?.floorplanDependsOnSiblings) {
|
||||||
siblingEpochInputs.push(override)
|
liveFlaggedIds.push(id as AnyNodeId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!depsValueEqual(siblingEpochInputsRef.current, siblingEpochInputs)) {
|
// Union with last frame's live set so a node that just stopped being dragged
|
||||||
siblingEpochRef.current += 1
|
// (and its dependents) rebuilds one final time to drop the now-cleared override.
|
||||||
siblingEpochInputsRef.current = siblingEpochInputs
|
const expandFrom = Array.from(new Set([...liveFlaggedIds, ...prevLiveFlaggedIdsRef.current]))
|
||||||
|
const affectedSiblingIds = computeAffectedSiblingIds(expandFrom, nodes, liveOverrides)
|
||||||
|
const nodeSiblingEpochs = nodeSiblingEpochRef.current
|
||||||
|
for (const id of affectedSiblingIds) {
|
||||||
|
nodeSiblingEpochs.set(id, (nodeSiblingEpochs.get(id) ?? 0) + 1)
|
||||||
}
|
}
|
||||||
const siblingEpoch = siblingEpochRef.current
|
prevLiveFlaggedIdsRef.current = liveFlaggedIds
|
||||||
const out: FloorplanEntry[] = []
|
const out: FloorplanEntry[] = []
|
||||||
const levelDataByType = new Map<string, unknown>()
|
const levelDataByType = new Map<string, unknown>()
|
||||||
const levelNodeIdsByType = new Map<string, AnyNodeId[]>()
|
const levelNodeIdsByType = new Map<string, AnyNodeId[]>()
|
||||||
@@ -624,7 +639,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|||||||
hovered,
|
hovered,
|
||||||
moving,
|
moving,
|
||||||
palette: renderCtx?.palette,
|
palette: renderCtx?.palette,
|
||||||
siblingEpoch: dependsOnSiblingInputs ? siblingEpoch : 0,
|
siblingEpoch: dependsOnSiblingInputs ? (nodeSiblingEpochs.get(id) ?? 0) : 0,
|
||||||
// Sibling-dependent kinds (wall miters, opening cuts) read other nodes'
|
// Sibling-dependent kinds (wall miters, opening cuts) read other nodes'
|
||||||
// COMMITTED state via `ctx`, so a committed edit to a sibling/child that
|
// COMMITTED state via `ctx`, so a committed edit to a sibling/child that
|
||||||
// doesn't change this node's own ref must still invalidate it. The
|
// doesn't change this node's own ref must still invalidate it. The
|
||||||
@@ -2216,6 +2231,95 @@ function splitFloorplanOverlay(g: FloorplanGeometry): {
|
|||||||
return { base: g, overlay: null }
|
return { base: g, overlay: null }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stable string key for a wall endpoint, rounded to 1 mm so floating-point
|
||||||
|
// drift collapses while distinct corners stay distinct.
|
||||||
|
function endpointKey(x: number, y: number): string {
|
||||||
|
return `${Math.round(x * 1000)},${Math.round(y * 1000)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Given the sibling-dependent nodes with a live drag in flight, the set of
|
||||||
|
// floor-plan geometries that must rebuild this frame. A node's geometry depends
|
||||||
|
// on more than its own data:
|
||||||
|
// - a wall's miters depend on the walls meeting at each of its endpoints, so a
|
||||||
|
// dragged wall invalidates the walls at its old AND new junctions, plus its
|
||||||
|
// own door/window children (their cuts are drawn into it);
|
||||||
|
// - a door/window cut is drawn into its host wall, so it invalidates that wall;
|
||||||
|
// - a gutter join depends on sibling gutters under the same roof.
|
||||||
|
// Everything else stays cached, so dragging one wall/opening rebuilds a handful
|
||||||
|
// of geometries rather than every wall + opening on the level.
|
||||||
|
function computeAffectedSiblingIds(
|
||||||
|
liveFlaggedIds: readonly AnyNodeId[],
|
||||||
|
nodes: Record<string, AnyNode>,
|
||||||
|
liveOverrides: Map<string, Record<string, unknown>>,
|
||||||
|
): Set<AnyNodeId> {
|
||||||
|
const affected = new Set<AnyNodeId>()
|
||||||
|
if (liveFlaggedIds.length === 0) return affected
|
||||||
|
|
||||||
|
// Junction map (committed wall endpoint → wall ids), built lazily on first use.
|
||||||
|
let junctions: Map<string, AnyNodeId[]> | null = null
|
||||||
|
const wallsAtPoint = (x: number, y: number): AnyNodeId[] => {
|
||||||
|
if (!junctions) {
|
||||||
|
junctions = new Map()
|
||||||
|
for (const id in nodes) {
|
||||||
|
const n = nodes[id]
|
||||||
|
if (n?.type !== 'wall') continue
|
||||||
|
const w = n as unknown as { start: [number, number]; end: [number, number] }
|
||||||
|
for (const [px, py] of [w.start, w.end]) {
|
||||||
|
const key = endpointKey(px, py)
|
||||||
|
const arr = junctions.get(key)
|
||||||
|
if (arr) arr.push(id as AnyNodeId)
|
||||||
|
else junctions.set(key, [id as AnyNodeId])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return junctions.get(endpointKey(x, y)) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of liveFlaggedIds) {
|
||||||
|
const node = nodes[id]
|
||||||
|
if (!node) continue
|
||||||
|
affected.add(id)
|
||||||
|
if (node.type === 'wall') {
|
||||||
|
const w = node as unknown as {
|
||||||
|
start: [number, number]
|
||||||
|
end: [number, number]
|
||||||
|
children?: AnyNodeId[]
|
||||||
|
}
|
||||||
|
// Use the live (override-merged) endpoints as well as the committed ones,
|
||||||
|
// so walls at both the wall's old and new junctions get fresh miters.
|
||||||
|
const ov = liveOverrides.get(id) as
|
||||||
|
| { start?: [number, number]; end?: [number, number] }
|
||||||
|
| undefined
|
||||||
|
const points: [number, number][] = [w.start, w.end]
|
||||||
|
if (ov?.start) points.push(ov.start)
|
||||||
|
if (ov?.end) points.push(ov.end)
|
||||||
|
for (const [px, py] of points) {
|
||||||
|
for (const wid of wallsAtPoint(px, py)) affected.add(wid)
|
||||||
|
}
|
||||||
|
if (Array.isArray(w.children)) {
|
||||||
|
for (const cid of w.children) {
|
||||||
|
const child = nodes[cid]
|
||||||
|
if (child?.type === 'door' || child?.type === 'window') affected.add(cid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (node.type === 'door' || node.type === 'window') {
|
||||||
|
const hostId = (node as { parentId?: string }).parentId
|
||||||
|
if (hostId) affected.add(hostId as AnyNodeId)
|
||||||
|
} else if (node.type === 'gutter') {
|
||||||
|
const roofId = (node as { parentId?: string }).parentId
|
||||||
|
if (roofId) {
|
||||||
|
for (const sid in nodes) {
|
||||||
|
const s = nodes[sid]
|
||||||
|
if (s?.type === 'gutter' && (s as { parentId?: string }).parentId === roofId) {
|
||||||
|
affected.add(sid as AnyNodeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return affected
|
||||||
|
}
|
||||||
|
|
||||||
function nodeDepsEqual(a: NodeDeps, b: NodeDeps): boolean {
|
function nodeDepsEqual(a: NodeDeps, b: NodeDeps): boolean {
|
||||||
const keys: Array<keyof NodeDeps> = [
|
const keys: Array<keyof NodeDeps> = [
|
||||||
'node',
|
'node',
|
||||||
|
|||||||
@@ -213,17 +213,32 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
|||||||
pauseSceneHistory(useScene)
|
pauseSceneHistory(useScene)
|
||||||
let shouldRestoreOnCleanup = true
|
let shouldRestoreOnCleanup = true
|
||||||
|
|
||||||
|
// Wall ids that currently carry a live position override. Cleared on commit
|
||||||
|
// (after the final store write lands) and on cancel / external unmount.
|
||||||
|
const touchedWallIds = new Set<AnyNodeId>()
|
||||||
|
|
||||||
const applyNodePreview = (
|
const applyNodePreview = (
|
||||||
updates: Array<{ id: WallNode['id']; start: [number, number]; end: [number, number] }>,
|
updates: Array<{ id: WallNode['id']; start: [number, number]; end: [number, number] }>,
|
||||||
) => {
|
) => {
|
||||||
useScene.getState().updateNodes(
|
// Publish the preview to `useLiveNodeOverrides` rather than writing the
|
||||||
updates.map((entry) => ({
|
// scene store. The 3D wall system (`getEffectiveWall`) and the 2D floor
|
||||||
id: entry.id as AnyNodeId,
|
// plan (`wallFloorplanSiblingOverrides`) both merge these overrides, so
|
||||||
data: { start: entry.start, end: entry.end },
|
// the mesh + miters track the cursor with NO `useScene` churn during the
|
||||||
})),
|
// drag. A store write would hand a fresh `nodes` reference to every
|
||||||
|
// `useScene(s => s.nodes)` subscriber each frame (catalog tiles, panels,
|
||||||
|
// selection) and rebuild them all. Mirrors the wall's own 2D drag pattern;
|
||||||
|
// the final plan is written once, atomically, on commit.
|
||||||
|
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) {
|
for (const entry of updates) {
|
||||||
useScene.getState().markDirty(entry.id as AnyNodeId)
|
touchedWallIds.add(entry.id as AnyNodeId)
|
||||||
|
sceneState.markDirty(entry.id as AnyNodeId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,6 +368,19 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
|||||||
latestSurfacePlans = null
|
latestSurfacePlans = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drop the wall position overrides and mark the walls dirty so they rebuild
|
||||||
|
// from the now-authoritative store value (after commit) or the unchanged
|
||||||
|
// pre-drag value (after cancel).
|
||||||
|
const clearWallOverrides = () => {
|
||||||
|
const overrides = useLiveNodeOverrides.getState()
|
||||||
|
const sceneState = useScene.getState()
|
||||||
|
for (const id of touchedWallIds) {
|
||||||
|
overrides.clear(id)
|
||||||
|
sceneState.markDirty(id)
|
||||||
|
}
|
||||||
|
touchedWallIds.clear()
|
||||||
|
}
|
||||||
|
|
||||||
const buildWallFromCenter = (center: [number, number]) => {
|
const buildWallFromCenter = (center: [number, number]) => {
|
||||||
const rotatedHalf = rotateVector(originalHalfVector, pendingRotationRef.current)
|
const rotatedHalf = rotateVector(originalHalfVector, pendingRotationRef.current)
|
||||||
const nextStart: [number, number] = [center[0] - rotatedHalf[0], center[1] - rotatedHalf[1]]
|
const nextStart: [number, number] = [center[0] - rotatedHalf[0], center[1] - rotatedHalf[1]]
|
||||||
@@ -426,13 +454,10 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
|||||||
|
|
||||||
const restoreOriginal = () => {
|
const restoreOriginal = () => {
|
||||||
setGhostWallPreviews([])
|
setGhostWallPreviews([])
|
||||||
applyNodePreview([
|
// Nothing was written to the scene store during the drag — the preview
|
||||||
{ id: nodeId, start: originalStart, end: originalEnd },
|
// was override-driven — so dropping the wall + surface overrides reveals
|
||||||
...linkedOriginalsRef.current,
|
// the unchanged pre-drag state.
|
||||||
])
|
clearWallOverrides()
|
||||||
// No scene rollback for surfaces — nothing was written. Just
|
|
||||||
// clear the live overrides so the renderer falls back to the
|
|
||||||
// (pre-drag, unchanged) store state.
|
|
||||||
clearSurfaceOverrides()
|
clearSurfaceOverrides()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -512,15 +537,11 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
|||||||
|
|
||||||
shouldRestoreOnCleanup = false
|
shouldRestoreOnCleanup = false
|
||||||
|
|
||||||
// Restore original baseline while paused so the next resume+update
|
// The store was never touched during the drag (the preview was
|
||||||
// registers as a single tracked change (undo reverts to original).
|
// override-driven for both walls and surfaces), so there is no baseline to
|
||||||
// Surfaces stayed in the store the whole drag (override-driven
|
// restore before the tracked commit — just resume history and write the
|
||||||
// mesh preview), so there's nothing to restore for them.
|
// final plan as one undoable change.
|
||||||
setGhostWallPreviews([])
|
setGhostWallPreviews([])
|
||||||
applyNodePreview([
|
|
||||||
{ id: nodeId, start: originalStart, end: originalEnd },
|
|
||||||
...linkedOriginalsRef.current,
|
|
||||||
])
|
|
||||||
|
|
||||||
resumeSceneHistory(useScene)
|
resumeSceneHistory(useScene)
|
||||||
const commitPlan = getMovePlan(preview.start, preview.end)
|
const commitPlan = getMovePlan(preview.start, preview.end)
|
||||||
@@ -577,9 +598,10 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
|||||||
// updates, deletes) into the store while history is still
|
// updates, deletes) into the store while history is still
|
||||||
// resumed, so the surface delta joins the wall change as one
|
// resumed, so the surface delta joins the wall change as one
|
||||||
// undoable step. Then drop the live overrides — the renderer
|
// undoable step. Then drop the live overrides — the renderer
|
||||||
// now reads the committed polygons directly.
|
// now reads the committed walls + polygons directly.
|
||||||
commitSurfacesToStore()
|
commitSurfacesToStore()
|
||||||
clearSurfaceOverrides()
|
clearSurfaceOverrides()
|
||||||
|
clearWallOverrides()
|
||||||
|
|
||||||
pauseSceneHistory(useScene)
|
pauseSceneHistory(useScene)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user