From cf24b62c4411ac02c6d17943f5c6845fc373778c Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 12 Jun 2026 10:05:06 -0400 Subject: [PATCH] fix(viewer): UI flicker on camera move from interactive overlays + dirty-mark leaks (#401) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: sync bun.lock with 0.9.1 workspace versions Co-Authored-By: Claude Fable 5 * fix(viewer): stop interactive overlays from starving frames and flickering the UI Every interactive item mounted a drei 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 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 * 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 * 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 * 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 --------- Co-authored-by: Claude Fable 5 --- bun.lock | 24 ++--- packages/core/src/registry/types.ts | 12 +++ .../core/src/store/actions/node-actions.ts | 7 ++ .../store/use-scene-dirty-tracking.test.ts | 77 ++++++++++++++++ packages/core/src/store/use-scene.ts | 3 + packages/nodes/src/building/definition.ts | 2 + packages/nodes/src/guide/definition.ts | 2 + packages/nodes/src/level/definition.ts | 2 + packages/nodes/src/site/definition.ts | 2 + packages/nodes/src/zone/definition.ts | 2 + .../src/components/viewer/perf-monitor.tsx | 19 +++- .../interactive/interactive-system.tsx | 87 ++++++++++++++----- wiki/architecture/node-definitions.md | 4 + 13 files changed, 205 insertions(+), 38 deletions(-) create mode 100644 packages/core/src/store/use-scene-dirty-tracking.test.ts diff --git a/bun.lock b/bun.lock index d5fcd942..be8566b4 100644 --- a/bun.lock +++ b/bun.lock @@ -96,7 +96,7 @@ }, "packages/core": { "name": "@pascal-app/core", - "version": "0.9.0", + "version": "0.9.1", "dependencies": { "dedent": "^1.7.1", "idb-keyval": "^6.2.2", @@ -122,7 +122,7 @@ }, "packages/editor": { "name": "@pascal-app/editor", - "version": "0.9.0", + "version": "0.9.1", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", @@ -156,8 +156,8 @@ "zustand": "^5.0.11", }, "devDependencies": { - "@pascal-app/core": "^0.9.0", - "@pascal-app/viewer": "^0.9.0", + "@pascal-app/core": "^0.9.1", + "@pascal-app/viewer": "^0.9.1", "@pascal/typescript-config": "*", "@types/bun": "^1.3.0", "@types/howler": "^2.2.12", @@ -167,8 +167,8 @@ "typescript": "6.0.3", }, "peerDependencies": { - "@pascal-app/core": "^0.9.0", - "@pascal-app/viewer": "^0.9.0", + "@pascal-app/core": "^0.9.1", + "@pascal-app/viewer": "^0.9.1", "@react-three/drei": "^10", "@react-three/fiber": "^9", "next": ">=15", @@ -196,7 +196,7 @@ }, "packages/ifc-converter": { "name": "@pascal-app/ifc-converter", - "version": "0.1.0", + "version": "0.1.1", "dependencies": { "@pascal-app/core": "*", "nanoid": "^5.1.6", @@ -210,7 +210,7 @@ }, "packages/mcp": { "name": "@pascal-app/mcp", - "version": "0.3.0", + "version": "0.3.1", "bin": { "pascal-mcp": "./dist/bin/pascal-mcp.js", }, @@ -219,13 +219,13 @@ "zod": "^4.3.5", }, "devDependencies": { - "@pascal-app/core": "^0.9.0", + "@pascal-app/core": "^0.9.1", "@pascal/typescript-config": "*", "@types/node": "^22.19.20", "typescript": "6.0.3", }, "peerDependencies": { - "@pascal-app/core": "^0.9.0", + "@pascal-app/core": "^0.9.1", }, }, "packages/nodes": { @@ -277,7 +277,7 @@ }, "packages/viewer": { "name": "@pascal-app/viewer", - "version": "0.9.0", + "version": "0.9.1", "dependencies": { "three-bvh-csg": "^0.0.18", "three-mesh-bvh": "^0.9.8", @@ -291,7 +291,7 @@ "typescript": "6.0.3", }, "peerDependencies": { - "@pascal-app/core": "^0.9.0", + "@pascal-app/core": "^0.9.1", "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index c168871f..03cbd617 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -658,6 +658,18 @@ export type NodeDefinition> = { relations?: Relations parametrics?: ParametricDescriptor> + /** + * 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 diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts index d8a28400..5de80de9 100644 --- a/packages/core/src/store/actions/node-actions.ts +++ b/packages/core/src/store/actions/node-actions.ts @@ -985,6 +985,7 @@ export const deleteNodesAction = ( if (get().readOnly) return const parentsToMarkDirty = new Set() const nodesToMarkDirty = new Set() + const deletedIds = new Set() 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) => { diff --git a/packages/core/src/store/use-scene-dirty-tracking.test.ts b/packages/core/src/store/use-scene-dirty-tracking.test.ts new file mode 100644 index 00000000..5a8635c8 --- /dev/null +++ b/packages/core/src/store/use-scene-dirty-tracking.test.ts @@ -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) + }) +}) diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index c802c9ea..b3ac8c9b 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -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()( }, markDirty: (id) => { + const node = get().nodes[id] + if (node && nodeRegistry.get(node.type)?.dirtyTracking === false) return get().dirtyNodes.add(id) }, diff --git a/packages/nodes/src/building/definition.ts b/packages/nodes/src/building/definition.ts index ee63d60f..629684f0 100644 --- a/packages/nodes/src/building/definition.ts +++ b/packages/nodes/src/building/definition.ts @@ -39,6 +39,8 @@ export const buildingDefinition: NodeDefinition = { }, parametrics: buildingParametrics, + // No dirty consumer rebuilds this kind — see NodeDefinition.dirtyTracking. + dirtyTracking: false, renderer: { kind: 'parametric', diff --git a/packages/nodes/src/guide/definition.ts b/packages/nodes/src/guide/definition.ts index 49985f83..2f5e09f5 100644 --- a/packages/nodes/src/guide/definition.ts +++ b/packages/nodes/src/guide/definition.ts @@ -30,6 +30,8 @@ export const guideDefinition: NodeDefinition = { }, parametrics: guideParametrics, + // No dirty consumer rebuilds this kind — see NodeDefinition.dirtyTracking. + dirtyTracking: false, renderer: { kind: 'parametric', diff --git a/packages/nodes/src/level/definition.ts b/packages/nodes/src/level/definition.ts index e7623f68..a22db01a 100644 --- a/packages/nodes/src/level/definition.ts +++ b/packages/nodes/src/level/definition.ts @@ -36,6 +36,8 @@ export const levelDefinition: NodeDefinition = { }, parametrics: levelParametrics, + // No dirty consumer rebuilds this kind — see NodeDefinition.dirtyTracking. + dirtyTracking: false, renderer: { kind: 'parametric', diff --git a/packages/nodes/src/site/definition.ts b/packages/nodes/src/site/definition.ts index b8d8375e..512d9216 100644 --- a/packages/nodes/src/site/definition.ts +++ b/packages/nodes/src/site/definition.ts @@ -30,6 +30,8 @@ export const siteDefinition: NodeDefinition = { }, parametrics: siteParametrics, + // No dirty consumer rebuilds this kind — see NodeDefinition.dirtyTracking. + dirtyTracking: false, renderer: { kind: 'parametric', diff --git a/packages/nodes/src/zone/definition.ts b/packages/nodes/src/zone/definition.ts index 7dfdc08d..afbdeac7 100644 --- a/packages/nodes/src/zone/definition.ts +++ b/packages/nodes/src/zone/definition.ts @@ -37,6 +37,8 @@ export const zoneDefinition: NodeDefinition = { }, parametrics: zoneParametrics, + // No dirty consumer rebuilds this kind — see NodeDefinition.dirtyTracking. + dirtyTracking: false, renderer: { kind: 'parametric', diff --git a/packages/viewer/src/components/viewer/perf-monitor.tsx b/packages/viewer/src/components/viewer/perf-monitor.tsx index 689b0a66..9798c138 100644 --- a/packages/viewer/src/components/viewer/perf-monitor.tsx +++ b/packages/viewer/src/components/viewer/perf-monitor.tsx @@ -15,6 +15,7 @@ export const PerfMonitor = () => { drawCalls: 0, triangles: 0, dirty: 0, + dirtyDetail: '', meshes: 0, lines: 0, sprites: 0, @@ -60,7 +61,20 @@ export const PerfMonitor = () => { const drawCalls = Math.round(totalCalls / Math.max(1, frameCount.current)) const triangles = totalTriangles / Math.max(1, frameCount.current) info.reset() - const dirty = useScene.getState().dirtyNodes.size + const sceneState = useScene.getState() + const dirty = sceneState.dirtyNodes.size + let dirtyDetail = '' + if (dirty > 0) { + const counts = new Map() + for (const id of sceneState.dirtyNodes) { + const type = sceneState.nodes[id]?.type ?? 'missing' + counts.set(type, (counts.get(type) ?? 0) + 1) + } + dirtyDetail = [...counts.entries()] + .sort((a, b) => b[1] - a[1]) + .map(([type, count]) => `${count} ${type}`) + .join(', ') + } // Count visible drawables by type so we can match scene contents // against the renderer's draw count and find hidden contributors. @@ -99,6 +113,7 @@ export const PerfMonitor = () => { drawCalls, triangles, dirty, + dirtyDetail, meshes, lines, sprites, @@ -133,7 +148,7 @@ export const PerfMonitor = () => { GPU ${stats.gpuMs > 0 ? `${stats.gpuMs.toFixed(1)}ms (max ${stats.gpuMaxMs.toFixed(1)})` : '—'} DRAW ${stats.drawCalls} TRI ${(stats.triangles / 1000).toFixed(1)}k -DIRTY ${stats.dirty} +DIRTY ${stats.dirty}${stats.dirtyDetail ? ` (${stats.dirtyDetail})` : ''} MESH ${stats.meshes} LINE ${stats.lines} SPRITE ${stats.sprites} diff --git a/packages/viewer/src/systems/interactive/interactive-system.tsx b/packages/viewer/src/systems/interactive/interactive-system.tsx index f85726d5..3d6e684c 100644 --- a/packages/viewer/src/systems/interactive/interactive-system.tsx +++ b/packages/viewer/src/systems/interactive/interactive-system.tsx @@ -13,16 +13,32 @@ import { } from '@pascal-app/core' import { Html } from '@react-three/drei' import { createPortal, useFrame } from '@react-three/fiber' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { type Object3D, Vector3 } from 'three' import { useShallow } from 'zustand/react/shallow' import useViewer from '../../store/use-viewer' const _tempVec = new Vector3() -// ---- Parent: one overlay per interactive item ---- +// ---- Parent: one overlay per interactive item inside the selected zone ---- +// +// The overlays only exist while a zone is selected and the item sits +// inside it. Mounting them unconditionally is not an option: each drei +// repositions and re-sorts its DOM element on every camera-move frame, and +// with `occlude` it also raycasts the entire scene per overlay per frame. On +// large scenes (hundreds of interactive items) that starves the frame budget +// and the display/z-index churn makes the whole DOM UI flicker. +// +// The child components stay rendered (returning null) so an overlay can fade +// out before its unmounts. export const InteractiveSystem = () => { + const zoneId = useViewer((s) => s.selection.zoneId) + const zonePolygon = useScene((s) => { + if (!zoneId) return null + const z = s.nodes[zoneId] as ZoneNode | undefined + return z?.polygon ?? null + }) const interactiveNodeIds = useScene( useShallow((state) => Object.values(state.nodes) @@ -34,7 +50,7 @@ export const InteractiveSystem = () => { return ( <> {interactiveNodeIds.map((id) => ( - + ))} ) @@ -42,7 +58,15 @@ export const InteractiveSystem = () => { // ---- Child: polls sceneRegistry then portals controls into the item group ---- -const ItemControlsOverlay = ({ nodeId }: { nodeId: AnyNodeId }) => { +const FADE_MS = 300 + +const ItemControlsOverlay = ({ + nodeId, + zonePolygon, +}: { + nodeId: AnyNodeId + zonePolygon: ZoneNode['polygon'] | null +}) => { const node = useScene((state) => state.nodes[nodeId] as ItemNode) const [itemObj, setItemObj] = useState(null) @@ -55,29 +79,44 @@ const ItemControlsOverlay = ({ nodeId }: { nodeId: AnyNodeId }) => { const controlValues = useInteractive(useShallow((state) => state.items[nodeId]?.controlValues)) const setControlValue = useInteractive((state) => state.setControlValue) - const zoneId = useViewer((s) => s.selection.zoneId) - const zonePolygon = useScene((s) => { - if (!zoneId) return null - const z = s.nodes[zoneId] as ZoneNode | undefined - return z?.polygon ?? null - }) + let visible = false + if (itemObj && zonePolygon?.length) { + itemObj.getWorldPosition(_tempVec) + visible = pointInPolygon(_tempVec.x, _tempVec.z, zonePolygon) + } - if (!(itemObj && controlValues && node?.asset.interactive)) return null + // Fade in on mount and fade out before unmounting the . + const [mounted, setMounted] = useState(false) + const [shown, setShown] = useState(false) + useEffect(() => { + if (visible) { + setMounted(true) + // Double rAF: the overlay has to paint once at opacity 0 before the + // opacity-1 style lands, otherwise the fade-in transition is skipped. + let raf2 = 0 + const raf1 = requestAnimationFrame(() => { + raf2 = requestAnimationFrame(() => setShown(true)) + }) + return () => { + cancelAnimationFrame(raf1) + cancelAnimationFrame(raf2) + } + } + setShown(false) + const timeout = setTimeout(() => setMounted(false), FADE_MS) + return () => clearTimeout(timeout) + }, [visible]) + + if (!(mounted && itemObj && controlValues && node?.asset.interactive)) return null const { controls } = node.asset.interactive const [, height] = node.asset.dimensions - let opacity = 0 - let pointerEvents: 'auto' | 'none' = 'none' - if (zoneId && zonePolygon?.length) { - itemObj.getWorldPosition(_tempVec) - const inside = pointInPolygon(_tempVec.x, _tempVec.z, zonePolygon) - opacity = inside ? 1 : 0.1 - pointerEvents = inside ? 'auto' : 'none' - } - return createPortal( - + // eps=-1 forces drei to re-apply translate/scale every frame: its mount + // path writes a transform without the distanceFactor scale, and with a + // static camera the eps guard would skip the fix until the camera moves. +
{ borderRadius: 8, padding: '8px 12px', minWidth: 120, - pointerEvents, + pointerEvents: visible ? 'auto' : 'none', userSelect: 'none', - opacity, - transition: 'opacity 0.3s ease', + opacity: shown ? 1 : 0, + transition: `opacity ${FADE_MS}ms ease`, }} > {controls.map((control, i) => ( diff --git a/wiki/architecture/node-definitions.md b/wiki/architecture/node-definitions.md index 723f6e58..460659db 100644 --- a/wiki/architecture/node-definitions.md +++ b/wiki/architecture/node-definitions.md @@ -59,6 +59,10 @@ A kind may declare `surfaceRole?: SurfaceRole` on its definition. It is a colour Per-kind `def.system` components mount alongside via ``. They run their own `useFrame` and can mark nodes dirty, address meshes by `getObjectByName`, advance animation state, etc. They run **in addition** to `GeometrySystem`, not instead of it. +### `dirtyTracking` + +`dirtyNodes` is the per-frame rebuild queue consumed by `` (`def.geometry`), `` (`capabilities.floorPlaced`), and the legacy per-kind viewer systems. Kinds none of those consume — structural/organizational kinds like site, building, level, zone, guide — declare `dirtyTracking: false` so `markDirty` skips them. Without it their marks are never cleared: 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 such a kind later gains `def.geometry` (or any other dirty consumer), delete the flag. + ## `GeometryContext` The second arg to `geometry()` is scene read access for builders that reference other nodes by ID. Most kinds ignore it.