fix(viewer): UI flicker on camera move from interactive overlays + dirty-mark leaks (#401)

* chore: sync bun.lock with 0.9.1 workspace versions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(viewer): stop interactive overlays from starving frames and flickering the UI

Every interactive item mounted a drei <Html occlude> overlay
unconditionally — invisible (opacity 0) when no zone was selected, but
still alive. With `occlude` as a bare boolean, drei raycasts the entire
scene per overlay on every camera-move frame, and rewrites each
element's z-index while toggling display when the occlusion flips. On
scenes with hundreds of interactive items (recessed lights, ceiling
fans) this starved the frame budget and made the whole DOM UI blink
during camera moves while the WebGPU canvas stayed healthy.

Overlays now mount only while a zone is selected and the item sits
inside its polygon, fade in/out over 300ms (the child components stay
rendered so the exit transition can play before the <Html> unmounts),
and drop `occlude` entirely. eps=-1 works around a drei mount bug: its
mount path writes the element transform without the distanceFactor
scale, and with a static camera the eps guard never re-applies it, so
freshly mounted overlays stayed mis-scaled until the camera moved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(viewer): break down dirty nodes by kind in the perf overlay

DIRTY now reads e.g. "29 (12 wall, 9 ceiling, 8 item)" — sorted by
count, only non-zero kinds, with a "missing" bucket for dirty ids whose
node no longer exists. Makes dirty-mark leaks attributable at a glance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf(core): skip dirty marks for kinds with no dirty consumer

dirtyNodes is consumed by GeometrySystem (def.geometry),
FloorElevationSystem (capabilities.floorPlaced), and the legacy
per-kind viewer systems. Site, building, level, zone, and guide match
none of those, so their marks were never cleared: they accumulated for
the whole session (every child create/delete dirties its parent),
permanently defeated every consumer's empty-set early exit each frame,
and polluted the perf overlay's DIRTY readout.

NodeDefinition gains an explicit dirtyTracking?: boolean opt-out
(default tracked — no derivable predicate exists since wall's dirty
consumption lives in the viewer while zone/guide/level declare
def.system for unrelated per-frame work). markDirty consults the
registry; the five structural kinds opt out.

Also fixes a second leak: deleteNodesAction never removed deleted ids
from the dirty set, and every consumer skips missing nodes without
clearing them, so marks on deleted nodes lived forever.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style: replace concise-arrow forEach with for...of in deleteNodesAction

biome's useIterableCallbackReturn rejects forEach callbacks that
implicitly return a value (Set.add / clearDirty).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-12 10:05:06 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent 5411f5abc8
commit cf24b62c44
13 changed files with 205 additions and 38 deletions
+12
View File
@@ -658,6 +658,18 @@ export type NodeDefinition<S extends ZodObject<any>> = {
relations?: Relations
parametrics?: ParametricDescriptor<z.infer<S>>
/**
* Whether scene mutations add this kind to `dirtyNodes` (the per-frame
* rebuild queue). Default true. Set `false` for structural/organizational
* kinds (site, building, level, zone, guide) that no dirty consumer ever
* rebuilds — no `def.geometry`, no legacy viewer system, no
* `capabilities.floorPlaced`. Their marks are never cleared, so they
* accumulate for the whole session, defeat every consumer's empty-set
* early exit each frame, and pollute the perf overlay's DIRTY readout.
* If a kind later gains a dirty consumer, delete the flag.
*/
dirtyTracking?: boolean
/**
* Renderer for this kind. Optional under the three-checkbox composition
* model (see `wiki/architecture/node-definitions.md`): when omitted, the
@@ -985,6 +985,7 @@ export const deleteNodesAction = (
if (get().readOnly) return
const parentsToMarkDirty = new Set<AnyNodeId>()
const nodesToMarkDirty = new Set<AnyNodeId>()
const deletedIds = new Set<AnyNodeId>()
const mergePlans = buildWallMergePlans(get().nodes, ids)
set((state) => {
@@ -1007,6 +1008,7 @@ export const deleteNodesAction = (
for (const plan of mergePlans) {
allIds.add(plan.secondaryWallId)
}
for (const id of allIds) deletedIds.add(id)
for (const plan of mergePlans) {
const primaryWall = nextNodes[plan.primaryWallId]
@@ -1068,6 +1070,11 @@ export const deleteNodesAction = (
return { nodes: nextNodes, rootNodeIds: nextRootIds, collections: nextCollections }
})
// Deleted ids must leave the dirty set: every consumer skips missing
// nodes without clearing them, so a mark on a deleted node would sit in
// the set (and defeat the consumers' empty-set early exit) forever.
for (const id of deletedIds) get().clearDirty(id)
// Mark affected nodes dirty: parents of deleted nodes and their remaining children
// (e.g. deleting a slab affects sibling walls via level elevation changes)
parentsToMarkDirty.forEach((parentId) => {
@@ -0,0 +1,77 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import { nodeRegistry } from '../registry/registry'
import type { AnyNodeDefinition } from '../registry/types'
import type { AnyNode, AnyNodeId } from '../schema/types'
import useScene from './use-scene'
const untrackedDef = {
kind: 'test-untracked',
schemaVersion: 1,
schema: {} as never,
category: 'furnishing',
defaults: () => ({}),
capabilities: {},
dirtyTracking: false,
} as unknown as AnyNodeDefinition
const trackedDef = {
...untrackedDef,
kind: 'test-tracked',
dirtyTracking: undefined,
} as unknown as AnyNodeDefinition
const UNTRACKED = 'item_untracked' as AnyNodeId
const TRACKED = 'item_tracked' as AnyNodeId
const UNREGISTERED = 'item_unregistered' as AnyNodeId
const makeNode = (id: AnyNodeId, type: string): AnyNode =>
({
object: 'node',
id,
type,
parentId: null,
visible: true,
metadata: {},
children: [],
}) as unknown as AnyNode
describe('dirty tracking', () => {
beforeEach(() => {
if (!nodeRegistry.has(untrackedDef.kind)) nodeRegistry._register(untrackedDef)
if (!nodeRegistry.has(trackedDef.kind)) nodeRegistry._register(trackedDef)
useScene.setState({
nodes: {
[UNTRACKED]: makeNode(UNTRACKED, 'test-untracked'),
[TRACKED]: makeNode(TRACKED, 'test-tracked'),
[UNREGISTERED]: makeNode(UNREGISTERED, 'unregistered-kind'),
},
rootNodeIds: [UNTRACKED, TRACKED, UNREGISTERED],
dirtyNodes: new Set(),
collections: {},
} as never)
useScene.temporal.getState().clear()
})
// Membership asserts (not set size/equality): the scene store is a module
// singleton, and subscribers leaked by other test files can add their own
// dirty marks when `setState` fires.
test('markDirty skips kinds whose definition opts out', () => {
useScene.getState().markDirty(UNTRACKED)
expect(useScene.getState().dirtyNodes.has(UNTRACKED)).toBe(false)
})
test('markDirty tracks kinds without the opt-out, registered or not', () => {
useScene.getState().markDirty(TRACKED)
useScene.getState().markDirty(UNREGISTERED)
expect(useScene.getState().dirtyNodes.has(TRACKED)).toBe(true)
expect(useScene.getState().dirtyNodes.has(UNREGISTERED)).toBe(true)
})
test('deleteNodes removes deleted ids from the dirty set', () => {
useScene.getState().markDirty(TRACKED)
expect(useScene.getState().dirtyNodes.has(TRACKED)).toBe(true)
useScene.getState().deleteNodes([TRACKED])
expect(useScene.getState().nodes[TRACKED]).toBeUndefined()
expect(useScene.getState().dirtyNodes.has(TRACKED)).toBe(false)
})
})
+3
View File
@@ -3,6 +3,7 @@
import type { TemporalState } from 'zundo'
import { temporal } from 'zundo'
import { create, type StoreApi, type UseBoundStore } from 'zustand'
import { nodeRegistry } from '../registry/registry'
import { BuildingNode } from '../schema'
import type { Collection, CollectionId } from '../schema/collections'
import { generateCollectionId } from '../schema/collections'
@@ -853,6 +854,8 @@ const useScene: UseSceneStore = create<SceneState>()(
},
markDirty: (id) => {
const node = get().nodes[id]
if (node && nodeRegistry.get(node.type)?.dirtyTracking === false) return
get().dirtyNodes.add(id)
},