Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 12 additions & 12 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions packages/core/src/registry/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/store/actions/node-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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]
Expand Down Expand Up @@ -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) => {
Expand Down
77 changes: 77 additions & 0 deletions packages/core/src/store/use-scene-dirty-tracking.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 3 additions & 0 deletions packages/core/src/store/use-scene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
},

Expand Down
2 changes: 2 additions & 0 deletions packages/nodes/src/building/definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ export const buildingDefinition: NodeDefinition<typeof BuildingNode> = {
},

parametrics: buildingParametrics,
// No dirty consumer rebuilds this kind — see NodeDefinition.dirtyTracking.
dirtyTracking: false,

renderer: {
kind: 'parametric',
Expand Down
2 changes: 2 additions & 0 deletions packages/nodes/src/guide/definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export const guideDefinition: NodeDefinition<typeof GuideNode> = {
},

parametrics: guideParametrics,
// No dirty consumer rebuilds this kind — see NodeDefinition.dirtyTracking.
dirtyTracking: false,

renderer: {
kind: 'parametric',
Expand Down
2 changes: 2 additions & 0 deletions packages/nodes/src/level/definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export const levelDefinition: NodeDefinition<typeof LevelNode> = {
},

parametrics: levelParametrics,
// No dirty consumer rebuilds this kind — see NodeDefinition.dirtyTracking.
dirtyTracking: false,

renderer: {
kind: 'parametric',
Expand Down
2 changes: 2 additions & 0 deletions packages/nodes/src/site/definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export const siteDefinition: NodeDefinition<typeof SiteNode> = {
},

parametrics: siteParametrics,
// No dirty consumer rebuilds this kind — see NodeDefinition.dirtyTracking.
dirtyTracking: false,

renderer: {
kind: 'parametric',
Expand Down
2 changes: 2 additions & 0 deletions packages/nodes/src/zone/definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ export const zoneDefinition: NodeDefinition<typeof ZoneNode> = {
},

parametrics: zoneParametrics,
// No dirty consumer rebuilds this kind — see NodeDefinition.dirtyTracking.
dirtyTracking: false,

renderer: {
kind: 'parametric',
Expand Down
19 changes: 17 additions & 2 deletions packages/viewer/src/components/viewer/perf-monitor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const PerfMonitor = () => {
drawCalls: 0,
triangles: 0,
dirty: 0,
dirtyDetail: '',
meshes: 0,
lines: 0,
sprites: 0,
Expand Down Expand Up @@ -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<string, number>()
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.
Expand Down Expand Up @@ -99,6 +113,7 @@ export const PerfMonitor = () => {
drawCalls,
triangles,
dirty,
dirtyDetail,
meshes,
lines,
sprites,
Expand Down Expand Up @@ -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}
Expand Down
Loading
Loading