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.