WallSystem: throttle adjacent-wall rebuild during drag

Endpoint drags fire markDirty(wallId) on every pointermove tick. The
old behavior rebuilt the dragged wall AND every wall sharing a junction
on every tick — in a 4-corner room with doors, that's 4× the CSG
+miter pass per tick. Visible as drag lag.

New behavior: the dragged wall rebuilds every tick (so the drag tracks
the cursor with full fidelity, cutouts and all). Adjacent walls are
queued in pendingAdjacentByLevel and rebuilt on the trailing edge —
80ms after the dirty stream stops. The corners snap into their correct
miter joins ~80ms after release, which is the standard CAD-app
"rubber-band the dragged element, fix neighbors on commit" pattern.

Module-level singleton state for the queue + timestamp — WallSystem is
mounted exactly once globally, so module state is the right scope.

Expected speedup:
- t-junction drag: ~3× (was 3 walls/tick, now 1)
- 4-corner room with door per wall: ~4×

The trailing flush condition (!hasDirtyWalls && now - lastWallDirtyAtMs
>= DRAG_FLUSH_MS) means single edits (non-drag) pay an 80ms latency
before neighbors miter correctly. Acceptable for now; the real fix is
the affordance/tool port (Milestone C) which will explicitly signal
"drag in progress" so we can drop the heuristic. Until then this is a
substantial drag-perf win for zero risk.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-15 09:42:04 -04:00
co-authored by Claude Opus 4.7
parent 375914a07c
commit 60117e848b
@@ -311,19 +311,41 @@ function assignWallMaterialGroups(
// ============================================================================ // ============================================================================
let useFrameNb = 0 let useFrameNb = 0
// ─── Drag-throttle state (singleton — one WallSystem mounted globally) ──
//
// Endpoint drags fire `markDirty(wallId)` on every pointermove tick. Without
// throttling, each tick rebuilds the dragged wall (~1 CSG + miter pass) AND
// every adjacent wall sharing a corner (34× in a t-junction or room).
// Visible as drag lag, especially on walls with door/window cutouts.
//
// Strategy: rebuild the dragged wall every tick (so the drag follows the
// cursor with full fidelity), but defer adjacent rebuilds to a trailing-
// edge flush DRAG_FLUSH_MS after the dirty stream stops. Visually, neighbor
// corners stay at their pre-drag miter until release, then snap into place
// within ~80ms. Standard CAD-app behavior. Speeds up t-junction drags ~3×,
// 4-corner-room drags ~4×.
const DRAG_FLUSH_MS = 80
let lastWallDirtyAtMs = 0
const pendingAdjacentByLevel = new Map<string, Set<string>>()
export const WallSystem = () => { export const WallSystem = () => {
const dirtyNodes = useScene((state) => state.dirtyNodes) const dirtyNodes = useScene((state) => state.dirtyNodes)
const clearDirty = useScene((state) => state.clearDirty) const clearDirty = useScene((state) => state.clearDirty)
useFrame(() => { useFrame(() => {
if (dirtyNodes.size === 0) return const hasDirty = dirtyNodes.size > 0
const hasPending = pendingAdjacentByLevel.size > 0
if (!hasDirty && !hasPending) return
const nodes = useScene.getState().nodes const nodes = useScene.getState().nodes
const now = performance.now()
// Collect dirty walls and their levels // Collect dirty walls and their levels
const dirtyWallsByLevel = new Map<string, Set<string>>() const dirtyWallsByLevel = new Map<string, Set<string>>()
useFrameNb += 1 useFrameNb += 1
if (hasDirty) {
dirtyNodes.forEach((id) => { dirtyNodes.forEach((id) => {
const node = nodes[id] const node = nodes[id]
if (!node || node.type !== 'wall') return if (!node || node.type !== 'wall') return
@@ -336,13 +358,20 @@ export const WallSystem = () => {
} }
dirtyWallsByLevel.get(levelId)?.add(id) dirtyWallsByLevel.get(levelId)?.add(id)
}) })
}
const hasDirtyWalls = dirtyWallsByLevel.size > 0
if (hasDirtyWalls) {
lastWallDirtyAtMs = now
}
// Process each level that has dirty walls // Process each level that has dirty walls
for (const [levelId, dirtyWallIds] of dirtyWallsByLevel) { for (const [levelId, dirtyWallIds] of dirtyWallsByLevel) {
const levelWalls = getLevelWalls(levelId) const levelWalls = getLevelWalls(levelId)
const miterData = calculateLevelMiters(levelWalls) const miterData = calculateLevelMiters(levelWalls)
// Update dirty walls // Update dirty walls — always, no throttling. The dragged wall must
// follow the cursor with full fidelity (cutouts and all).
for (const wallId of dirtyWallIds) { for (const wallId of dirtyWallIds) {
const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh
if (mesh) { if (mesh) {
@@ -352,16 +381,36 @@ export const WallSystem = () => {
// If mesh not found, keep it dirty for next frame // If mesh not found, keep it dirty for next frame
} }
// Update adjacent walls that share junctions // Adjacent walls sharing junctions — *defer* during active drag
// (dirty arrived this frame), flush on the trailing edge.
const adjacentWallIds = getAdjacentWallIds(levelWalls, dirtyWallIds) const adjacentWallIds = getAdjacentWallIds(levelWalls, dirtyWallIds)
let pending = pendingAdjacentByLevel.get(levelId)
if (!pending) {
pending = new Set()
pendingAdjacentByLevel.set(levelId, pending)
}
for (const wallId of adjacentWallIds) { for (const wallId of adjacentWallIds) {
if (!dirtyWallIds.has(wallId)) { if (!dirtyWallIds.has(wallId)) {
pending.add(wallId)
}
}
}
// Trailing-edge flush: if no new dirty marks for DRAG_FLUSH_MS, the
// drag has ended — rebuild the queued neighbors so corners snap into
// their correct miter joins.
const quiet = !hasDirtyWalls && now - lastWallDirtyAtMs >= DRAG_FLUSH_MS
if (quiet && pendingAdjacentByLevel.size > 0) {
for (const [levelId, pendingIds] of pendingAdjacentByLevel) {
if (pendingIds.size === 0) continue
const levelWalls = getLevelWalls(levelId)
const miterData = calculateLevelMiters(levelWalls)
for (const wallId of pendingIds) {
const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh
if (mesh) { if (mesh) updateWallGeometry(wallId, miterData)
updateWallGeometry(wallId, miterData)
}
} }
} }
pendingAdjacentByLevel.clear()
} }
}, 4) }, 4)