From 812b7306e8bc4bd6d8ac2efd59f5a9d74d0197e3 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 8 Jun 2026 15:04:28 -0400 Subject: [PATCH] Fix editor interaction, loading, and IFC cleanup (#385) * fix: update site labels after camera swaps * fix: gate scene display until viewer is ready * fix: render scene loader on first paint * fix: keep loader during pending scene graph * feat: add wasd camera panning * feat: add x delete mode shortcut * feat: add floorplan north compass * fix: move floorplan compass to lower corner * fix: progressively rebuild heavy scene geometry * fix: simplify noisy ifc wall output --- .../editor/custom-camera-controls.tsx | 142 +++++ .../src/components/editor/floorplan-panel.tsx | 71 +++ .../editor/src/components/editor/index.tsx | 35 +- .../components/editor/site-edge-labels.tsx | 32 +- .../ui/action-menu/control-modes.tsx | 2 +- .../editor/src/components/ui/scene-loader.tsx | 4 +- .../keyboard-shortcuts-dialog.tsx | 7 +- packages/editor/src/hooks/use-keyboard.ts | 2 +- packages/ifc-converter/src/cleanup.ts | 517 ++++++++++++++++++ packages/ifc-converter/src/index.ts | 23 + packages/ifc-converter/tests/cleanup.test.ts | 123 +++++ .../viewer/src/components/viewer/index.tsx | 117 +++- .../viewer/src/systems/door/door-system.tsx | 39 +- .../viewer/src/systems/wall/wall-system.tsx | 78 ++- .../src/systems/window/window-system.tsx | 41 +- 15 files changed, 1197 insertions(+), 36 deletions(-) create mode 100644 packages/ifc-converter/src/cleanup.ts create mode 100644 packages/ifc-converter/tests/cleanup.test.ts diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index 59da1f4a..f8508dcc 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -32,11 +32,20 @@ const tempSize = new Vector3() const tempTarget = new Vector3() const syncTarget = new Vector3() const syncSpherical = new Spherical() +const keyboardPanPosition = new Vector3() +const keyboardPanTarget = new Vector3() +const keyboardPanScreenRight = new Vector3() +const keyboardPanScreenUp = new Vector3() +const keyboardPanDelta = new Vector3() +const keyboardPanSpherical = new Spherical() const DEFAULT_MAX_POLAR_ANGLE = Math.PI / 2 - 0.1 const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05 const NAVIGATION_SYNC_POSITION_EPSILON = 0.001 const NAVIGATION_SYNC_AZIMUTH_EPSILON = 0.0005 const NAVIGATION_SYNC_VIEW_WIDTH_EPSILON = 0.001 +const KEYBOARD_PAN_VIEW_WIDTH_PER_SECOND = 0.65 +const KEYBOARD_PAN_MIN_SPEED = 2 +const KEYBOARD_PAN_MAX_SPEED = 55 type CameraMode = ReturnType['cameraMode'] type CameraPoseSnapshot = { mode: CameraMode @@ -90,6 +99,41 @@ function isEditableKeyboardTarget(target: EventTarget | null) { ) } +type KeyboardPanState = { + forward: boolean + backward: boolean + left: boolean + right: boolean +} + +function setKeyboardPanKey(state: KeyboardPanState, code: string, pressed: boolean): boolean { + if (code === 'KeyW') { + const changed = state.forward !== pressed + state.forward = pressed + return changed + } + if (code === 'KeyS') { + const changed = state.backward !== pressed + state.backward = pressed + return changed + } + if (code === 'KeyA') { + const changed = state.left !== pressed + state.left = pressed + return changed + } + if (code === 'KeyD') { + const changed = state.right !== pressed + state.right = pressed + return changed + } + return false +} + +function isKeyboardPanKey(code: string): boolean { + return code === 'KeyW' || code === 'KeyA' || code === 'KeyS' || code === 'KeyD' +} + type CameraViewportSize = { width: number height: number @@ -244,6 +288,12 @@ function useFirstPersonCameraPoseRestore( export const CustomCameraControls = () => { const controls = useRef(null) + const keyboardPanKeys = useRef({ + forward: false, + backward: false, + left: false, + right: false, + }) const isPreviewMode = useEditor((s) => s.isPreviewMode) const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode) const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera) @@ -417,6 +467,67 @@ export const CustomCameraControls = () => { } }, [currentLevelId, isFirstPersonMode, isFloorplanOpen, publishCurrentNavigationPose]) + useFrame((_, delta) => { + if (isFirstPersonMode || !controls.current) return + + const panKeys = keyboardPanKeys.current + const horizontal = (panKeys.right ? 1 : 0) - (panKeys.left ? 1 : 0) + const vertical = (panKeys.forward ? 1 : 0) - (panKeys.backward ? 1 : 0) + if (horizontal === 0 && vertical === 0) return + + const control = controls.current + control.getPosition(keyboardPanPosition) + control.getTarget(keyboardPanTarget) + + camera.updateMatrixWorld() + keyboardPanScreenRight.setFromMatrixColumn(camera.matrixWorld, 0) + keyboardPanScreenRight.y = 0 + keyboardPanScreenUp.setFromMatrixColumn(camera.matrixWorld, 1) + keyboardPanScreenUp.y = 0 + + if (keyboardPanScreenRight.lengthSq() < 1e-6) { + keyboardPanScreenRight.set(1, 0, 0) + } else { + keyboardPanScreenRight.normalize() + } + + if (keyboardPanScreenUp.lengthSq() < 1e-6) { + keyboardPanScreenUp.copy(keyboardPanTarget).sub(keyboardPanPosition) + keyboardPanScreenUp.y = 0 + if (keyboardPanScreenUp.lengthSq() < 1e-6) { + keyboardPanScreenUp.set(0, 0, -1) + } else { + keyboardPanScreenUp.normalize() + } + } else { + keyboardPanScreenUp.normalize() + } + + control.getSpherical(keyboardPanSpherical, false) + const viewWidth = getCameraViewWidth(camera, keyboardPanSpherical.radius, viewportSize) + const speed = Math.min( + Math.max(viewWidth * KEYBOARD_PAN_VIEW_WIDTH_PER_SECOND, KEYBOARD_PAN_MIN_SPEED), + KEYBOARD_PAN_MAX_SPEED, + ) + const step = (speed * Math.min(delta, 0.05)) / Math.hypot(horizontal, vertical) + + keyboardPanDelta + .set(0, 0, 0) + .addScaledVector(keyboardPanScreenRight, horizontal * step) + .addScaledVector(keyboardPanScreenUp, vertical * step) + + pendingFloorplanNavigationPose.current = null + control.setLookAt( + keyboardPanPosition.x + keyboardPanDelta.x, + keyboardPanPosition.y, + keyboardPanPosition.z + keyboardPanDelta.z, + keyboardPanTarget.x + keyboardPanDelta.x, + keyboardPanTarget.y, + keyboardPanTarget.z + keyboardPanDelta.z, + false, + ) + }) + // Configure mouse buttons based on control mode and camera mode const mouseButtons = useMemo(() => { // Use ZOOM for orthographic camera, DOLLY for perspective camera @@ -496,6 +607,13 @@ export const CustomCameraControls = () => { let panPointerId: number | null = null let panPointerButton: number | null = null + const clearKeyboardPanKeys = () => { + keyboardPanKeys.current.forward = false + keyboardPanKeys.current.backward = false + keyboardPanKeys.current.left = false + keyboardPanKeys.current.right = false + } + const setNavigationCursor = (cursor: 'grab' | 'grabbing') => { document.body.style.cursor = cursor gl.domElement.style.cursor = cursor @@ -557,6 +675,19 @@ export const CustomCameraControls = () => { } const onKeyDown = (event: KeyboardEvent) => { + if (isKeyboardPanKey(event.code)) { + if ( + !(event.metaKey || event.ctrlKey || event.altKey) && + !isEditableKeyboardTarget(event.target) + ) { + setKeyboardPanKey(keyboardPanKeys.current, event.code, true) + pendingFloorplanNavigationPose.current = null + event.preventDefault() + event.stopPropagation() + } + return + } + if (event.code === 'Space') { if (isEditableKeyboardTarget(event.target)) return event.preventDefault() @@ -579,6 +710,15 @@ export const CustomCameraControls = () => { } const onKeyUp = (event: KeyboardEvent) => { + if (isKeyboardPanKey(event.code)) { + const changed = setKeyboardPanKey(keyboardPanKeys.current, event.code, false) + if (changed) { + event.preventDefault() + event.stopPropagation() + } + return + } + if (event.code === 'Space') { keyState.space = false if (panPointerButton === 0) { @@ -628,6 +768,7 @@ export const CustomCameraControls = () => { const onBlur = () => { keyState.space = false + clearKeyboardPanKeys() panPointerId = null panPointerButton = null clearNavigationCursor() @@ -651,6 +792,7 @@ export const CustomCameraControls = () => { window.removeEventListener('pointercancel', onPointerUp, true) window.removeEventListener('blur', onBlur) gl.domElement.removeEventListener('wheel', onWheel) + clearKeyboardPanKeys() clearNavigationCursor() } }, [cameraMode, gl, isPreviewMode, isFirstPersonMode]) diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 37110a5d..04e0761c 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -141,6 +141,7 @@ import { } from '../tools/wall/wall-drafting' import { PALETTE_COLORS } from '../ui/primitives/color-dot' +import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip' import { resolveFloorplanBackgroundSelection } from './floorplan-background-selection' import { useFloorplanBackgroundPlacement } from './use-floorplan-background-placement' import { useFloorplanHitTesting } from './use-floorplan-hit-testing' @@ -427,6 +428,47 @@ type GuideHandleHintAnchor = { directionY: number } +function FloorplanCompassButton({ + northRotationDeg, + onAlignNorth, +}: { + northRotationDeg: number + onAlignNorth: () => void +}) { + return ( + + + + + Align view to north + + ) +} + type GuideInteractionState = { pointerId: number guideId: GuideNode['id'] @@ -6616,6 +6658,28 @@ export function FloorplanPanel() { [buildingPosition, buildingRotationY, floorplanGridWorldY], ) + const alignFloorplanViewToNorth = useCallback(() => { + const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current + if (!currentViewport) { + return + } + + const currentUserRotationDeg = latestFloorplanUserRotationDegRef.current + const currentSceneRotationDeg = + FLOORPLAN_VIEW_ROTATION_DEG + currentUserRotationDeg - buildingRotationDeg + const localCenter = rotateSvgPoint( + { + x: currentViewport.centerX, + y: currentViewport.centerY, + }, + -currentSceneRotationDeg, + ) + const nextUserRotationDeg = nearestEquivalentDegrees(0, currentUserRotationDeg) + + smoothFloorplanNavigationView(localCenter, nextUserRotationDeg, currentViewport.width) + publishFloorplanNavigationPose(localCenter, nextUserRotationDeg, currentViewport.width) + }, [buildingRotationDeg, publishFloorplanNavigationPose, smoothFloorplanNavigationView]) + const clearGuideInteraction = useCallback(() => { guideInteractionRef.current = null guideTransformDraftRef.current = null @@ -9744,6 +9808,13 @@ export function FloorplanPanel() { only action menu the floor plan mounts. */} + {(levelNode?.type === 'level' || hasAmbientBuildingLevel) && ( + + )} + {referenceScaleDraft && (
{referenceScaleDraft.start diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index c8e684de..444dae9a 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -816,6 +816,8 @@ const ViewerCanvas = memo(function ViewerCanvas({ isStudioMode, hasLoadedInitialScene, showLoader, + sceneReadyKey, + onSceneReadyChange, onThumbnailCapture, }: { isVersionPreviewMode: boolean @@ -824,6 +826,8 @@ const ViewerCanvas = memo(function ViewerCanvas({ isStudioMode: boolean hasLoadedInitialScene: boolean showLoader: boolean + sceneReadyKey: number + onSceneReadyChange: (ready: boolean) => void onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void }) { const viewMode = useEditor((s) => s.viewMode) @@ -927,12 +931,14 @@ const ViewerCanvas = memo(function ViewerCanvas({
- {!(isLoading || isVersionPreviewMode) && } + {!(showLoader || isVersionPreviewMode) && } ) }) @@ -984,6 +990,8 @@ export default function Editor({ const [isSceneLoading, setIsSceneLoading] = useState(false) const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false) + const [sceneReadyKey, setSceneReadyKey] = useState(0) + const [isViewerSceneReady, setIsViewerSceneReady] = useState(false) const isPreviewMode = useEditor((s) => s.isPreviewMode) const isCaptureMode = useEditor((s) => s.isCaptureMode) @@ -1010,15 +1018,24 @@ export default function Editor({ async function load() { isLoadingSceneRef.current = true setHasLoadedInitialScene(false) + setIsViewerSceneReady(false) setIsSceneLoading(true) + useScene.getState().unloadScene() + useViewer.getState().resetSelection() try { const sceneGraph = onLoad ? await onLoad() : loadSceneFromLocalStorage() if (!cancelled) { applySceneGraphToEditor(sceneGraph) + setIsViewerSceneReady(false) + setSceneReadyKey((key) => key + 1) } } catch { - if (!cancelled) applySceneGraphToEditor(null) + if (!cancelled) { + applySceneGraphToEditor(null) + setIsViewerSceneReady(false) + setSceneReadyKey((key) => key + 1) + } } finally { if (!cancelled) { setIsSceneLoading(false) @@ -1062,7 +1079,11 @@ export default function Editor({ } }, []) - const showLoader = isLoading || isSceneLoading + const handleSceneReadyChange = useCallback((ready: boolean) => { + setIsViewerSceneReady(ready) + }, []) + + const showLoader = isLoading || isSceneLoading || !hasLoadedInitialScene || !isViewerSceneReady const firstPersonPreviousLevelRef = useRef(useViewer.getState().selection.levelId) const wasFirstPersonModeRef = useRef(isFirstPersonMode) @@ -1125,7 +1146,9 @@ export default function Editor({ isLoading={isLoading} isStudioMode={isStudioMode} isVersionPreviewMode={isVersionPreviewMode} + onSceneReadyChange={handleSceneReadyChange} onThumbnailCapture={onThumbnailCapture} + sceneReadyKey={sceneReadyKey} showLoader={showLoader} /> ) @@ -1162,7 +1185,7 @@ export default function Editor({ <> {showLoader && (
- +
)} @@ -1227,7 +1250,7 @@ export default function Editor({
{showLoader && (
- +
)} diff --git a/packages/editor/src/components/editor/site-edge-labels.tsx b/packages/editor/src/components/editor/site-edge-labels.tsx index 5dd613b3..c3fc7c6c 100644 --- a/packages/editor/src/components/editor/site-edge-labels.tsx +++ b/packages/editor/src/components/editor/site-edge-labels.tsx @@ -4,9 +4,25 @@ import type { SiteNode } from '@pascal-app/core' import { sceneRegistry, useScene } from '@pascal-app/core' import { getSceneTheme, useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' -import { createPortal, useFrame } from '@react-three/fiber' -import { useMemo, useRef, useState } from 'react' -import type { Object3D } from 'three' +import { createPortal, useFrame, useThree } from '@react-three/fiber' +import { useCallback, useMemo, useRef, useState } from 'react' +import { type Camera, type Object3D, Vector3 } from 'three' + +type ViewportSize = { + width: number + height: number +} + +const htmlPosition = new Vector3() + +function calculateHtmlPosition(el: Object3D, camera: Camera, size: ViewportSize) { + htmlPosition.setFromMatrixPosition(el.matrixWorld) + htmlPosition.project(camera) + + const widthHalf = size.width / 2 + const heightHalf = size.height / 2 + return [htmlPosition.x * widthHalf + widthHalf, -htmlPosition.y * heightHalf + heightHalf] +} function formatMeasurement(value: number, unit: 'metric' | 'imperial') { if (unit === 'imperial') { @@ -30,7 +46,14 @@ export function SiteEdgeLabels() { return node?.type === 'site' ? (node as SiteNode) : null }) const unit = useViewer((state) => state.unit) + const cameraMode = useViewer((state) => state.cameraMode) const isNight = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark') + const camera = useThree((state) => state.camera) + // Drei Html can hold the previous default camera across a camera-object swap. + const calculateLabelPosition = useCallback( + (el: Object3D, _camera: Camera, size: ViewportSize) => calculateHtmlPosition(el, camera, size), + [camera], + ) const siteNodeId = siteNode?.id @@ -72,7 +95,8 @@ export function SiteEdgeLabels() { {edges.map((edge, i) => ( (null) + const [loaderClass, setLoaderClass] = useState(LOADERS[0]!) useEffect(() => { // Pick a random loader on mount setLoaderClass(LOADERS[Math.floor(Math.random() * LOADERS.length)] ?? LOADERS[0]!) }, []) - if (!loaderClass) return null - return (
+type OpeningNode = DoorNode | WindowNode + +export type IfcConversionSimplificationOptions = { + enabled?: boolean + maxWallJoinGap?: number +} + +export type IfcConversionSimplificationStats = { + input: { + walls: number + doors: number + windows: number + } + output: { + walls: number + doors: number + windows: number + } + removedTinyWalls: number + mergedWallGroups: number + removedMergedWalls: number + removedDuplicateOpenings: number +} + +type WallSegment = { + id: string + wall: WallNode + parentId: string | null + axisX: number + axisY: number + normalX: number + normalY: number + offset: number + t0: number + t1: number + length: number + height: number + thickness: number + angleBucket: number +} + +const MIN_WALL_LENGTH = 0.08 +const WALL_ANGLE_BUCKET_RAD = Math.PI / 180 +const DEFAULT_MAX_WALL_JOIN_GAP = 1.25 +const WALL_HEIGHT_TOLERANCE = 0.35 +const OPENING_DUPLICATE_TOLERANCE = 0.05 + +function countNodes(nodes: SceneNodes, type: AnyNode['type']) { + return Object.values(nodes).filter((node) => node.type === type).length +} + +function getInitialStats(nodes: SceneNodes): IfcConversionSimplificationStats { + return { + input: { + walls: countNodes(nodes, 'wall'), + doors: countNodes(nodes, 'door'), + windows: countNodes(nodes, 'window'), + }, + output: { + walls: 0, + doors: 0, + windows: 0, + }, + removedTinyWalls: 0, + mergedWallGroups: 0, + removedMergedWalls: 0, + removedDuplicateOpenings: 0, + } +} + +function finishStats(nodes: SceneNodes, stats: IfcConversionSimplificationStats) { + stats.output = { + walls: countNodes(nodes, 'wall'), + doors: countNodes(nodes, 'door'), + windows: countNodes(nodes, 'window'), + } +} + +function isOpeningNode(node: AnyNode | undefined): node is OpeningNode { + return node?.type === 'door' || node?.type === 'window' +} + +function getOpeningWallId(opening: OpeningNode, nodes: SceneNodes) { + if (opening.wallId && nodes[opening.wallId]?.type === 'wall') return opening.wallId + if (opening.parentId && nodes[opening.parentId]?.type === 'wall') return opening.parentId + return null +} + +function uniqueExistingChildren(children: string[] | undefined, nodes: SceneNodes) { + const next: string[] = [] + const seen = new Set() + for (const childId of children ?? []) { + if (!(childId in nodes) || seen.has(childId)) continue + seen.add(childId) + next.push(childId) + } + return next +} + +function normalizeChildren(nodes: SceneNodes) { + for (const node of Object.values(nodes)) { + const withChildren = node as { children?: string[] } + if (Array.isArray(withChildren.children)) { + withChildren.children = uniqueExistingChildren(withChildren.children, nodes) + } + } +} + +function syncWallOpeningChildren(nodes: SceneNodes) { + for (const node of Object.values(nodes)) { + if (node.type !== 'wall') continue + node.children = uniqueExistingChildren(node.children, nodes) as WallNode['children'] + } + + for (const node of Object.values(nodes)) { + if (!isOpeningNode(node)) continue + const wallId = getOpeningWallId(node, nodes) + const wall = wallId ? nodes[wallId] : undefined + if (wall?.type !== 'wall') continue + if (!wall.children.includes(node.id)) { + wall.children.push(node.id) + } + } +} + +function removeNodeFromParents(nodes: SceneNodes, nodeId: string) { + for (const node of Object.values(nodes)) { + const withChildren = node as { children?: string[] } + if (!Array.isArray(withChildren.children)) continue + withChildren.children = withChildren.children.filter((childId) => childId !== nodeId) + } +} + +function wallLength(wall: WallNode) { + return Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) +} + +function pruneTinyWalls(nodes: SceneNodes, stats: IfcConversionSimplificationStats) { + for (const node of Object.values(nodes)) { + if (node.type !== 'wall') continue + if (wallLength(node) >= MIN_WALL_LENGTH) continue + if (node.children.length > 0) continue + delete nodes[node.id] + removeNodeFromParents(nodes, node.id) + stats.removedTinyWalls += 1 + } +} + +function toWallSegment(wall: WallNode): WallSegment | null { + if (wall.curveOffset !== undefined && Math.abs(wall.curveOffset) > 1e-6) return null + + const dx = wall.end[0] - wall.start[0] + const dy = wall.end[1] - wall.start[1] + const length = Math.hypot(dx, dy) + if (length < MIN_WALL_LENGTH) return null + + let axisX = dx / length + let axisY = dy / length + + if (axisX < -1e-6 || (Math.abs(axisX) <= 1e-6 && axisY < 0)) { + axisX = -axisX + axisY = -axisY + } + + const normalX = -axisY + const normalY = axisX + const startT = wall.start[0] * axisX + wall.start[1] * axisY + const endT = wall.end[0] * axisX + wall.end[1] * axisY + const startOffset = wall.start[0] * normalX + wall.start[1] * normalY + const endOffset = wall.end[0] * normalX + wall.end[1] * normalY + const angle = Math.atan2(axisY, axisX) + + return { + id: wall.id, + wall, + parentId: wall.parentId ?? null, + axisX, + axisY, + normalX, + normalY, + offset: (startOffset + endOffset) / 2, + t0: Math.min(startT, endT), + t1: Math.max(startT, endT), + length, + height: wall.height ?? DEFAULT_WALL_HEIGHT, + thickness: wall.thickness ?? DEFAULT_WALL_THICKNESS, + angleBucket: Math.round(angle / WALL_ANGLE_BUCKET_RAD), + } +} + +function wallLineTolerance(a: WallSegment, b: WallSegment) { + return Math.max(0.06, Math.min(0.14, Math.max(a.thickness, b.thickness) * 0.5)) +} + +function wallHeightCompatible(a: WallSegment, b: WallSegment) { + return Math.abs(a.height - b.height) <= WALL_HEIGHT_TOLERANCE +} + +function wallIntervalsCompatible(a: WallSegment, b: WallSegment, maxJoinGap: number) { + const gap = Math.max(a.t0, b.t0) - Math.min(a.t1, b.t1) + if (gap <= maxJoinGap) return true + + const overlap = Math.min(a.t1, b.t1) - Math.max(a.t0, b.t0) + if (overlap <= 0) return false + return overlap / Math.min(a.length, b.length) >= 0.5 +} + +function wallsCanMerge(a: WallSegment, b: WallSegment, maxJoinGap: number) { + if (a.parentId !== b.parentId) return false + if (Math.abs(a.angleBucket - b.angleBucket) > 1) return false + if (Math.abs(a.offset - b.offset) > wallLineTolerance(a, b)) return false + if (!wallHeightCompatible(a, b)) return false + return wallIntervalsCompatible(a, b, maxJoinGap) +} + +function find(parent: number[], index: number): number { + let current = index + while (parent[current] !== current) { + parent[current] = parent[parent[current]] + current = parent[current] + } + return current +} + +function union(parent: number[], a: number, b: number) { + const rootA = find(parent, a) + const rootB = find(parent, b) + if (rootA !== rootB) parent[rootB] = rootA +} + +function openingWorldPosition(opening: OpeningNode, wall: WallNode): [number, number] { + const length = wallLength(wall) + if (length < 1e-6) return [wall.start[0], wall.start[1]] + const axisX = (wall.end[0] - wall.start[0]) / length + const axisY = (wall.end[1] - wall.start[1]) / length + const normalX = -axisY + const normalY = axisX + const [localX, , localZ] = opening.position + return [ + wall.start[0] + axisX * localX + normalX * localZ, + wall.start[1] + axisY * localX + normalY * localZ, + ] +} + +function clampOpeningAlongWall(opening: OpeningNode, along: number, wallLengthValue: number) { + const width = opening.width ?? (opening.type === 'door' ? 0.9 : 1.0) + const half = width / 2 + const lo = Math.min(half, wallLengthValue / 2) + const hi = Math.max(wallLengthValue - half, wallLengthValue / 2) + return Math.max(lo, Math.min(hi, along)) +} + +function rehostOpeningToWall(opening: OpeningNode, oldWall: WallNode, newWall: WallNode) { + const [worldX, worldY] = openingWorldPosition(opening, oldWall) + const newLength = wallLength(newWall) + if (newLength < 1e-6) return + + const axisX = (newWall.end[0] - newWall.start[0]) / newLength + const axisY = (newWall.end[1] - newWall.start[1]) / newLength + const normalX = -axisY + const normalY = axisX + const relX = worldX - newWall.start[0] + const relY = worldY - newWall.start[1] + const along = clampOpeningAlongWall(opening, relX * axisX + relY * axisY, newLength) + const across = relX * normalX + relY * normalY + opening.parentId = newWall.id + opening.wallId = newWall.id + opening.position = [along, opening.position[1], across] +} + +function pointOnLine(segment: WallSegment, t: number, offset: number): [number, number] { + return [ + segment.axisX * t + segment.normalX * offset, + segment.axisY * t + segment.normalY * offset, + ] +} + +function chooseKeptSegment(cluster: WallSegment[]) { + return cluster.reduce((best, current) => { + if (current.wall.children.length !== best.wall.children.length) { + return current.wall.children.length > best.wall.children.length ? current : best + } + return current.length > best.length ? current : best + }, cluster[0]) +} + +function collectOpeningIdsForWalls(nodes: SceneNodes, wallIds: Set) { + const childIds = new Set() + const childOriginWall = new Map() + + for (const wallId of wallIds) { + const wall = nodes[wallId] + if (wall?.type !== 'wall') continue + for (const childId of wall.children) { + childIds.add(childId) + childOriginWall.set(childId, wallId) + } + } + + for (const node of Object.values(nodes)) { + if (!isOpeningNode(node)) continue + const wallId = getOpeningWallId(node, nodes) + if (!wallId || !wallIds.has(wallId)) continue + childIds.add(node.id) + childOriginWall.set(node.id, wallId) + } + + return { childIds, childOriginWall } +} + +function mergeWallCluster( + nodes: SceneNodes, + cluster: WallSegment[], + stats: IfcConversionSimplificationStats, +) { + const kept = chooseKeptSegment(cluster) + const keptWall = kept.wall + const wallIds = new Set(cluster.map((segment) => segment.id)) + const originalWalls = new Map( + cluster.map((segment) => [ + segment.id, + { + ...segment.wall, + start: [...segment.wall.start], + end: [...segment.wall.end], + children: [...segment.wall.children], + } as WallNode, + ]), + ) + const { childIds, childOriginWall } = collectOpeningIdsForWalls(nodes, wallIds) + const t0 = Math.min(...cluster.map((segment) => segment.t0)) + const t1 = Math.max(...cluster.map((segment) => segment.t1)) + const crossMin = Math.min(...cluster.map((segment) => segment.offset - segment.thickness / 2)) + const crossMax = Math.max(...cluster.map((segment) => segment.offset + segment.thickness / 2)) + const offset = (crossMin + crossMax) / 2 + const thickness = Math.max(...cluster.map((segment) => segment.thickness), crossMax - crossMin) + const height = Math.max(...cluster.map((segment) => segment.height)) + const mergedExpressIds = cluster + .map((segment) => (segment.wall.metadata as { expressID?: unknown } | undefined)?.expressID) + .filter((expressID): expressID is number => typeof expressID === 'number') + + keptWall.start = pointOnLine(kept, t0, offset) + keptWall.end = pointOnLine(kept, t1, offset) + keptWall.thickness = thickness + keptWall.height = height + keptWall.metadata = { + ...((keptWall.metadata as Record | undefined) ?? {}), + ifcSimplification: { + mergedWallCount: cluster.length, + mergedExpressIDs: mergedExpressIds, + }, + } + + const nextChildren = new Set() + + for (const childId of childIds) { + const child = nodes[childId] + if (!child) continue + if (isOpeningNode(child)) { + const originWallId = childOriginWall.get(childId) + const originWall = originWallId ? originalWalls.get(originWallId) : undefined + if (originWall) { + rehostOpeningToWall(child, originWall, keptWall) + } else { + child.parentId = keptWall.id + child.wallId = keptWall.id + } + } else { + child.parentId = keptWall.id + } + nextChildren.add(childId) + } + + keptWall.children = Array.from(nextChildren) as WallNode['children'] + + for (const segment of cluster) { + if (segment.id === keptWall.id) continue + delete nodes[segment.id] + removeNodeFromParents(nodes, segment.id) + } + + const parent = keptWall.parentId ? nodes[keptWall.parentId] : undefined + const parentWithChildren = parent as { children?: string[] } | undefined + if (parentWithChildren?.children && !parentWithChildren.children.includes(keptWall.id)) { + parentWithChildren.children.push(keptWall.id) + } + + stats.mergedWallGroups += 1 + stats.removedMergedWalls += cluster.length - 1 +} + +function mergeWallFragments( + nodes: SceneNodes, + stats: IfcConversionSimplificationStats, + options: Required, +) { + const segments = Object.values(nodes) + .filter((node): node is WallNode => node.type === 'wall') + .map(toWallSegment) + .filter((segment): segment is WallSegment => segment !== null) + + const groups = new Map() + for (const segment of segments) { + const key = `${segment.parentId ?? 'root'}:${segment.angleBucket}` + const group = groups.get(key) + if (group) group.push(segment) + else groups.set(key, [segment]) + } + + for (const group of groups.values()) { + if (group.length < 2) continue + const parent = group.map((_, index) => index) + + for (let i = 0; i < group.length; i++) { + for (let j = i + 1; j < group.length; j++) { + if (wallsCanMerge(group[i], group[j], options.maxWallJoinGap)) { + union(parent, i, j) + } + } + } + + const clusters = new Map() + for (let i = 0; i < group.length; i++) { + const root = find(parent, i) + const cluster = clusters.get(root) + if (cluster) cluster.push(group[i]) + else clusters.set(root, [group[i]]) + } + + for (const cluster of clusters.values()) { + if (cluster.length < 2) continue + mergeWallCluster(nodes, cluster, stats) + } + } +} + +function signatureNumber(value: number | undefined, tolerance: number) { + return Math.round((value ?? 0) / tolerance) +} + +function openingSignature(opening: OpeningNode) { + const [x, y, z] = opening.position + const family = + opening.type === 'door' + ? `${opening.openingKind}:${opening.openingShape}:${opening.doorType}` + : `${opening.openingKind}:${opening.openingShape}:${opening.windowType}` + return [ + opening.type, + family, + signatureNumber(x, OPENING_DUPLICATE_TOLERANCE), + signatureNumber(y, OPENING_DUPLICATE_TOLERANCE), + signatureNumber(z, OPENING_DUPLICATE_TOLERANCE), + signatureNumber(opening.width, OPENING_DUPLICATE_TOLERANCE), + signatureNumber(opening.height, OPENING_DUPLICATE_TOLERANCE), + ].join(':') +} + +function dedupeOpenings(nodes: SceneNodes, stats: IfcConversionSimplificationStats) { + const byWall = new Map() + for (const node of Object.values(nodes)) { + if (!isOpeningNode(node)) continue + const wallId = getOpeningWallId(node, nodes) + if (!wallId) continue + const openings = byWall.get(wallId) + if (openings) openings.push(node) + else byWall.set(wallId, [node]) + } + + for (const openings of byWall.values()) { + const seen = new Set() + for (const opening of openings) { + const signature = openingSignature(opening) + if (!seen.has(signature)) { + seen.add(signature) + continue + } + delete nodes[opening.id] + removeNodeFromParents(nodes, opening.id) + stats.removedDuplicateOpenings += 1 + } + } +} + +export function simplifyConvertedSceneGraph( + nodes: SceneNodes, + options: IfcConversionSimplificationOptions = {}, +): IfcConversionSimplificationStats { + const stats = getInitialStats(nodes) + + if (options.enabled === false) { + finishStats(nodes, stats) + return stats + } + + const resolvedOptions: Required = { + enabled: true, + maxWallJoinGap: options.maxWallJoinGap ?? DEFAULT_MAX_WALL_JOIN_GAP, + } + + pruneTinyWalls(nodes, stats) + mergeWallFragments(nodes, stats, resolvedOptions) + syncWallOpeningChildren(nodes) + dedupeOpenings(nodes, stats) + normalizeChildren(nodes) + finishStats(nodes, stats) + return stats +} diff --git a/packages/ifc-converter/src/index.ts b/packages/ifc-converter/src/index.ts index c8e09c5c..75d29fbc 100644 --- a/packages/ifc-converter/src/index.ts +++ b/packages/ifc-converter/src/index.ts @@ -16,6 +16,12 @@ import { } from '@pascal-app/core' import { customAlphabet } from 'nanoid' import * as WebIFC from 'web-ifc' +import { type IfcConversionSimplificationOptions, simplifyConvertedSceneGraph } from './cleanup' + +export type { + IfcConversionSimplificationOptions, + IfcConversionSimplificationStats, +} from './cleanup' export type PascalNode = AnyNode @@ -636,6 +642,7 @@ export interface ConversionOptions { swapYZ?: boolean extrusionDepthIsHeight?: boolean swapProfileDimensions?: boolean + simplify?: boolean | IfcConversionSimplificationOptions label?: string } @@ -664,6 +671,12 @@ export async function convertIfcToPascal( extrusionDepthIsHeight: options?.extrusionDepthIsHeight ?? true, swapProfileDimensions: options?.swapProfileDimensions ?? false, } + const simplificationOptions = + options?.simplify === false + ? { enabled: false } + : typeof options?.simplify === 'object' + ? options.simplify + : undefined const progress = (msg: string, pct: number) => { console.log(`[IFC→Pascal] ${msg} (${pct}%)`) @@ -2049,6 +2062,16 @@ export async function convertIfcToPascal( /* no type rels */ } + progress('Simplifying converted scene...', 94) + const simplificationStats = simplifyConvertedSceneGraph(nodes, simplificationOptions) + if ( + simplificationStats.removedTinyWalls > 0 || + simplificationStats.removedMergedWalls > 0 || + simplificationStats.removedDuplicateOpenings > 0 + ) { + console.log('[IFC→Pascal] Simplification:', simplificationStats) + } + ifcApi.CloseModel(modelID) progress('Building scene graph...', 95) diff --git a/packages/ifc-converter/tests/cleanup.test.ts b/packages/ifc-converter/tests/cleanup.test.ts new file mode 100644 index 00000000..e0544269 --- /dev/null +++ b/packages/ifc-converter/tests/cleanup.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'bun:test' +import type { AnyNode } from '@pascal-app/core' +import { simplifyConvertedSceneGraph } from '../src/cleanup' + +function level(id = 'level_1', children: string[] = []): AnyNode { + return { + object: 'node', + id, + type: 'level', + name: 'Level', + parentId: null, + visible: true, + level: 0, + children, + } as AnyNode +} + +function wall( + id: string, + start: [number, number], + end: [number, number], + children: string[] = [], +): AnyNode { + return { + object: 'node', + id, + type: 'wall', + name: id, + parentId: 'level_1', + visible: true, + start, + end, + thickness: 0.2, + height: 3, + children, + frontSide: 'unknown', + backSide: 'unknown', + } as AnyNode +} + +function door(id: string, parentId: string, position: [number, number, number]): AnyNode { + return { + object: 'node', + id, + type: 'door', + name: id, + parentId, + wallId: parentId, + visible: true, + width: 0.9, + height: 2.1, + position, + } as AnyNode +} + +function windowNode(id: string, parentId: string, position: [number, number, number]): AnyNode { + return { + object: 'node', + id, + type: 'window', + name: id, + parentId, + wallId: parentId, + visible: true, + width: 1, + height: 1.2, + position, + } as AnyNode +} + +describe('simplifyConvertedSceneGraph', () => { + it('merges collinear wall fragments across door-sized gaps', () => { + const nodes: Record = { + level_1: level('level_1', ['wall_a', 'wall_b']), + wall_a: wall('wall_a', [0, 0], [2, 0]), + wall_b: wall('wall_b', [2.9, 0], [5, 0]), + } + + const stats = simplifyConvertedSceneGraph(nodes) + + expect(stats.removedMergedWalls).toBe(1) + expect(Object.values(nodes).filter((node) => node.type === 'wall')).toHaveLength(1) + const keptWall = Object.values(nodes).find((node) => node.type === 'wall') + expect(keptWall).toMatchObject({ start: [0, 0], end: [5, 0] }) + expect((nodes.level_1 as { children: string[] }).children).toEqual([keptWall?.id]) + }) + + it('reprojects openings from removed walls onto the merged wall', () => { + const nodes: Record = { + level_1: level('level_1', ['wall_a', 'wall_b']), + wall_a: wall('wall_a', [0, 0], [2, 0]), + wall_b: wall('wall_b', [2, 0], [4, 0], ['window_1']), + window_1: windowNode('window_1', 'wall_b', [1, 1.4, 0]), + } + + simplifyConvertedSceneGraph(nodes) + + const keptWall = Object.values(nodes).find((node) => node.type === 'wall') + expect(Object.values(nodes).filter((node) => node.type === 'wall')).toHaveLength(1) + expect(nodes.window_1).toMatchObject({ + parentId: keptWall?.id, + wallId: keptWall?.id, + position: [3, 1.4, 0], + }) + expect((keptWall as { children: string[] }).children).toEqual(['window_1']) + }) + + it('removes duplicate openings hosted on the same wall', () => { + const nodes: Record = { + level_1: level('level_1', ['wall_1']), + wall_1: wall('wall_1', [0, 0], [4, 0], ['door_1', 'door_2']), + door_1: door('door_1', 'wall_1', [1.5, 1.05, 0]), + door_2: door('door_2', 'wall_1', [1.51, 1.05, 0]), + } + + const stats = simplifyConvertedSceneGraph(nodes) + + expect(stats.removedDuplicateOpenings).toBe(1) + expect(nodes.door_1).toBeDefined() + expect(nodes.door_2).toBeUndefined() + expect((nodes.wall_1 as { children: string[] }).children).toEqual(['door_1']) + }) +}) diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 6409a141..6f07db6d 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -1,6 +1,12 @@ 'use client' -import { type AnyNodeId, StairOpeningSystem } from '@pascal-app/core' +import { + type AnyNodeId, + nodeRegistry, + StairOpeningSystem, + sceneRegistry, + useScene, +} from '@pascal-app/core' import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@react-three/fiber' import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react' import * as THREE from 'three/webgpu' @@ -44,6 +50,19 @@ extend(THREE as any) // concurrent configure() calls await the same init instead of creating two // renderers in parallel and only caching the second. const WEBGPU_RENDERER_CACHE = new WeakMap>() +const SCENE_READY_SETTLED_FRAMES = 2 +const SCENE_READY_MAX_WAIT_FRAMES = 180 +const DIRTY_BUILD_KINDS = new Set([ + 'ceiling', + 'door', + 'item', + 'roof', + 'roof-segment', + 'stair', + 'stair-segment', + 'wall', + 'window', +]) const warnedEmptyDraw = process.env.NODE_ENV === 'production' ? null : new WeakSet() @@ -176,6 +195,73 @@ function ToneMappingExposure() { return null } +function hasPendingSceneBuildWork() { + const { dirtyNodes, nodes } = useScene.getState() + + for (const id of dirtyNodes) { + const node = nodes[id] + if (!node) continue + const def = nodeRegistry.get(node.type) + if (def?.geometry || def?.capabilities?.floorPlaced || DIRTY_BUILD_KINDS.has(node.type)) { + return true + } + } + + return false +} + +function hasCommittedSceneRoot() { + const { nodes, rootNodeIds } = useScene.getState() + if (rootNodeIds.length === 0) return Object.keys(nodes).length === 0 + return rootNodeIds.some((id) => sceneRegistry.nodes.has(id)) +} + +function SceneReadyTracker({ + onSceneReadyChange, + sceneReadyKey, +}: { + onSceneReadyChange?: (ready: boolean) => void + sceneReadyKey?: string | number | null +}) { + const readyRef = useRef(false) + const settledFramesRef = useRef(0) + const waitedFramesRef = useRef(0) + const onSceneReadyChangeRef = useRef(onSceneReadyChange) + + useEffect(() => { + onSceneReadyChangeRef.current = onSceneReadyChange + }, [onSceneReadyChange]) + + useEffect(() => { + void sceneReadyKey + readyRef.current = false + settledFramesRef.current = 0 + waitedFramesRef.current = 0 + onSceneReadyChangeRef.current?.(false) + }, [sceneReadyKey]) + + useFrame(() => { + if (!(onSceneReadyChangeRef.current && !readyRef.current)) return + + waitedFramesRef.current += 1 + if ( + waitedFramesRef.current < SCENE_READY_MAX_WAIT_FRAMES && + (!hasCommittedSceneRoot() || hasPendingSceneBuildWork()) + ) { + settledFramesRef.current = 0 + return + } + + settledFramesRef.current += 1 + if (settledFramesRef.current < SCENE_READY_SETTLED_FRAMES) return + + readyRef.current = true + onSceneReadyChangeRef.current(true) + }, 10) + + return null +} + interface ViewerProps { children?: React.ReactNode hoverStyles?: HoverStyles @@ -197,6 +283,14 @@ interface ViewerProps { * for a future focus-mode UX. */ isolate?: AnyNodeId[] | null + /** + * Host-controlled key for scene readiness. Change it whenever a new scene + * graph is being loaded; the viewer will report not-ready until the graph is + * mounted, build systems have had a frame to settle, and one rendered frame + * has presented the new content. + */ + sceneReadyKey?: string | number | null + onSceneReadyChange?: (ready: boolean) => void } /** Imperative handle exposed via `ref` on ``. */ @@ -220,6 +314,8 @@ const Viewer = forwardRef(function Viewer( renderContext = 'editor', defaultRender, isolate, + sceneReadyKey, + onSceneReadyChange, }, ref, ) { @@ -246,13 +342,17 @@ const Viewer = forwardRef(function Viewer( }, [isolate]) const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark') + const defaultShading = defaultRender?.shading + const defaultTextures = defaultRender?.textures + const defaultColorPreset = defaultRender?.colorPreset + const hasDefaultRender = defaultRender != null useEffect(() => { const ctx = renderContext useViewer.getState().setRenderContext(ctx) const { shading, shadingByContext, setShading } = useViewer.getState() - setShading(shadingByContext[ctx] ?? defaultRender?.shading ?? shading) + setShading(shadingByContext[ctx] ?? defaultShading ?? shading) - if (!defaultRender || typeof window === 'undefined') return + if (!hasDefaultRender || typeof window === 'undefined') return let persistedState: Record = {} const rawPreferences = window.localStorage.getItem('viewer-preferences') @@ -270,13 +370,13 @@ const Viewer = forwardRef(function Viewer( } catch {} } - if (defaultRender.textures !== undefined && !('textures' in persistedState)) { - useViewer.getState().setTextures(defaultRender.textures) + if (defaultTextures !== undefined && !('textures' in persistedState)) { + useViewer.getState().setTextures(defaultTextures) } - if (defaultRender.colorPreset && !('colorPreset' in persistedState)) { - useViewer.getState().setColorPreset(defaultRender.colorPreset) + if (defaultColorPreset && !('colorPreset' in persistedState)) { + useViewer.getState().setColorPreset(defaultColorPreset) } - }, []) + }, [defaultColorPreset, defaultShading, defaultTextures, hasDefaultRender, renderContext]) // Coarse-pointer devices (phones/tablets) get a tighter DPR ceiling to keep // fragment-shader cost down — saves another ~30% over 1.5x on high-DPI mobile. @@ -329,6 +429,7 @@ const Viewer = forwardRef(function Viewer( + {/* { const shading = useViewer((state) => state.shading) const textures = useViewer((state) => state.textures) const colorPreset = useViewer((state) => state.colorPreset) + const materialRevisionRef = useRef(null) // Subscribe so an override-only update (no scene write) still re-runs // the component, letting the gate below pick up the latest dirtyNodes // set from the same render pass that received the override-publishing @@ -59,13 +63,17 @@ export const DoorSystem = () => { glassMaterial = textures ? defaultGlassMaterial : joineryMaterial useEffect(() => { + const materialRevision = `${shading}:${textures ? 'textures' : 'solid'}:${colorPreset}` + if (materialRevisionRef.current === materialRevision) return + materialRevisionRef.current = materialRevision + const nodes = useScene.getState().nodes for (const node of Object.values(nodes)) { if (node?.type === 'door') { useScene.getState().dirtyNodes.add(node.id as AnyNodeId) } } - }, [shading, textures, colorPreset]) + }) useFrame(() => { if (dirtyNodes.size === 0) return @@ -75,13 +83,35 @@ export const DoorSystem = () => { glassMaterial = textures ? defaultGlassMaterial : frameJoineryMaterial const nodes = useScene.getState().nodes + const dirtyDoorIds: AnyNodeId[] = [] dirtyNodes.forEach((id) => { const node = nodes[id] if (!node || node.type !== 'door') return + dirtyDoorIds.push(id as AnyNodeId) + }) + const useProgressiveDoorRebuilds = dirtyDoorIds.length > DOOR_PROGRESSIVE_DIRTY_THRESHOLD + const frameStartedAt = performance.now() + let rebuiltDoorsThisFrame = 0 + + for (const id of dirtyDoorIds) { + if (useProgressiveDoorRebuilds) { + if (rebuiltDoorsThisFrame >= MAX_DOOR_REBUILDS_PER_FRAME) { + break + } + if ( + rebuiltDoorsThisFrame > 0 && + performance.now() - frameStartedAt >= DOOR_PROGRESSIVE_TIME_BUDGET_MS + ) { + break + } + } + + const node = nodes[id] + if (!node || node.type !== 'door') continue const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh - if (!mesh) return // Keep dirty until mesh mounts + if (!mesh) continue // Keep dirty until mesh mounts // Merge any live override (width / height / position) so the mesh // rebuild reflects the in-flight drag without zustand churn. When @@ -89,6 +119,7 @@ export const DoorSystem = () => { const effectiveNode = getEffectiveNode(node as DoorNode) updateDoorMesh(effectiveNode, mesh) clearDirty(id as AnyNodeId) + rebuiltDoorsThisFrame += 1 // Rebuild the parent wall so its cutout reflects the updated door geometry // Avoid triggering expensive wall CSG rebuilds while the door is being interactively moved/duplicated. @@ -97,7 +128,7 @@ export const DoorSystem = () => { if (!isTransient && effectiveNode.parentId) { useScene.getState().dirtyNodes.add(effectiveNode.parentId as AnyNodeId) } - }) + } }, 3) return null diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index 912f885c..10d58413 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -329,9 +329,20 @@ let useFrameNb = 0 // within ~80ms. Standard CAD-app behavior. Speeds up t-junction drags ~3×, // 4-corner-room drags ~4×. const DRAG_FLUSH_MS = 80 +const MAX_WALL_REBUILDS_PER_FRAME = 8 +const WALL_PROGRESSIVE_DIRTY_THRESHOLD = MAX_WALL_REBUILDS_PER_FRAME +const WALL_PROGRESSIVE_TIME_BUDGET_MS = 8 let lastWallDirtyAtMs = 0 const pendingAdjacentByLevel = new Map>() +function getPendingAdjacentCount() { + let count = 0 + for (const ids of pendingAdjacentByLevel.values()) { + count += ids.size + } + return count +} + export const WallSystem = () => { const dirtyNodes = useScene((state) => state.dirtyNodes) const clearDirty = useScene((state) => state.clearDirty) @@ -353,6 +364,7 @@ export const WallSystem = () => { // Collect dirty walls and their levels const dirtyWallsByLevel = new Map>() + let dirtyWallCount = 0 useFrameNb += 1 if (hasDirty) { @@ -367,6 +379,7 @@ export const WallSystem = () => { dirtyWallsByLevel.set(levelId, new Set()) } dirtyWallsByLevel.get(levelId)?.add(id) + dirtyWallCount += 1 }) } @@ -375,25 +388,53 @@ export const WallSystem = () => { lastWallDirtyAtMs = now } + const useProgressiveWallRebuilds = dirtyWallCount > WALL_PROGRESSIVE_DIRTY_THRESHOLD + let rebuiltWallsThisFrame = 0 + const rebuildFrameStartedAt = now + // Process each level that has dirty walls for (const [levelId, dirtyWallIds] of dirtyWallsByLevel) { + if (useProgressiveWallRebuilds && rebuiltWallsThisFrame >= MAX_WALL_REBUILDS_PER_FRAME) { + break + } + const levelWalls = getLevelWalls(levelId) const miterData = calculateLevelMiters(levelWalls) + const rebuiltWallIds = new Set() // Update dirty walls — always, no throttling. The dragged wall must - // follow the cursor with full fidelity (cutouts and all). + // follow the cursor with full fidelity (cutouts and all). Large imports + // enter the progressive path so initial load can't lock the tab. for (const wallId of dirtyWallIds) { + if (useProgressiveWallRebuilds) { + if (rebuiltWallsThisFrame >= MAX_WALL_REBUILDS_PER_FRAME) { + break + } + if ( + rebuiltWallsThisFrame > 0 && + performance.now() - rebuildFrameStartedAt >= WALL_PROGRESSIVE_TIME_BUDGET_MS + ) { + break + } + } + const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh if (mesh) { updateWallGeometry(wallId, miterData) clearDirty(wallId as AnyNodeId) + rebuiltWallIds.add(wallId) + rebuiltWallsThisFrame += 1 } // If mesh not found, keep it dirty for next frame } + if (rebuiltWallIds.size === 0) { + continue + } + // 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, rebuiltWallIds) let pending = pendingAdjacentByLevel.get(levelId) if (!pending) { pending = new Set() @@ -411,16 +452,45 @@ export const WallSystem = () => { // their correct miter joins. const quiet = !hasDirtyWalls && now - lastWallDirtyAtMs >= DRAG_FLUSH_MS if (quiet && pendingAdjacentByLevel.size > 0) { + const pendingCount = getPendingAdjacentCount() + const useProgressiveAdjacentRebuilds = pendingCount > WALL_PROGRESSIVE_DIRTY_THRESHOLD + let rebuiltAdjacentThisFrame = 0 + const adjacentFrameStartedAt = performance.now() + for (const [levelId, pendingIds] of pendingAdjacentByLevel) { if (pendingIds.size === 0) continue const levelWalls = getLevelWalls(levelId) const miterData = calculateLevelMiters(levelWalls) - for (const wallId of pendingIds) { + for (const wallId of Array.from(pendingIds)) { + if (useProgressiveAdjacentRebuilds) { + if (rebuiltAdjacentThisFrame >= MAX_WALL_REBUILDS_PER_FRAME) { + break + } + if ( + rebuiltAdjacentThisFrame > 0 && + performance.now() - adjacentFrameStartedAt >= WALL_PROGRESSIVE_TIME_BUDGET_MS + ) { + break + } + } + const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh if (mesh) updateWallGeometry(wallId, miterData) + pendingIds.delete(wallId) + rebuiltAdjacentThisFrame += 1 + } + + if (pendingIds.size === 0) { + pendingAdjacentByLevel.delete(levelId) + } + + if ( + useProgressiveAdjacentRebuilds && + rebuiltAdjacentThisFrame >= MAX_WALL_REBUILDS_PER_FRAME + ) { + break } } - pendingAdjacentByLevel.clear() } }, 4) diff --git a/packages/viewer/src/systems/window/window-system.tsx b/packages/viewer/src/systems/window/window-system.tsx index e86413fe..6f953877 100644 --- a/packages/viewer/src/systems/window/window-system.tsx +++ b/packages/viewer/src/systems/window/window-system.tsx @@ -8,7 +8,7 @@ import { type WindowNode, } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' -import { useEffect } from 'react' +import { useEffect, useRef } from 'react' import * as THREE from 'three' import { createSurfaceRoleMaterial, @@ -32,12 +32,17 @@ export const LOUVERED_WINDOW_SLATS_NAME = 'louvered-window-slats' export const AWNING_WINDOW_SASH_NAME = 'awning-window-sash' export const HOPPER_WINDOW_SASH_NAME = 'hopper-window-sash' +const MAX_WINDOW_REBUILDS_PER_FRAME = 16 +const WINDOW_PROGRESSIVE_DIRTY_THRESHOLD = MAX_WINDOW_REBUILDS_PER_FRAME +const WINDOW_PROGRESSIVE_TIME_BUDGET_MS = 8 + export const WindowSystem = () => { const dirtyNodes = useScene((state) => state.dirtyNodes) const clearDirty = useScene((state) => state.clearDirty) const shading = useViewer((state) => state.shading) const textures = useViewer((state) => state.textures) const colorPreset = useViewer((state) => state.colorPreset) + const materialRevisionRef = useRef(null) // Subscribe so override-only updates re-run this component. Mirrors // WallSystem + DoorSystem. useLiveNodeOverrides((s) => s.overrides) @@ -50,13 +55,17 @@ export const WindowSystem = () => { : createSurfaceRoleMaterial('glazing', colorPreset) useEffect(() => { + const materialRevision = `${shading}:${textures ? 'textures' : 'solid'}:${colorPreset}` + if (materialRevisionRef.current === materialRevision) return + materialRevisionRef.current = materialRevision + const nodes = useScene.getState().nodes for (const node of Object.values(nodes)) { if (node?.type === 'window') { useScene.getState().dirtyNodes.add(node.id as AnyNodeId) } } - }, [shading, textures, colorPreset]) + }) useFrame(() => { if (dirtyNodes.size === 0) return @@ -68,19 +77,43 @@ export const WindowSystem = () => { : createSurfaceRoleMaterial('glazing', colorPreset) const nodes = useScene.getState().nodes + const dirtyWindowIds: AnyNodeId[] = [] dirtyNodes.forEach((id) => { const node = nodes[id] if (!node || node.type !== 'window') return + dirtyWindowIds.push(id as AnyNodeId) + }) + + const useProgressiveWindowRebuilds = dirtyWindowIds.length > WINDOW_PROGRESSIVE_DIRTY_THRESHOLD + const frameStartedAt = performance.now() + let rebuiltWindowsThisFrame = 0 + + for (const id of dirtyWindowIds) { + if (useProgressiveWindowRebuilds) { + if (rebuiltWindowsThisFrame >= MAX_WINDOW_REBUILDS_PER_FRAME) { + break + } + if ( + rebuiltWindowsThisFrame > 0 && + performance.now() - frameStartedAt >= WINDOW_PROGRESSIVE_TIME_BUDGET_MS + ) { + break + } + } + + const node = nodes[id] + if (!node || node.type !== 'window') continue const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh - if (!mesh) return // Keep dirty until mesh mounts + if (!mesh) continue // Keep dirty until mesh mounts // Merge any live override (width / height / position) so the mesh // rebuild reflects the in-flight drag without zustand churn. const effectiveNode = getEffectiveNode(node as WindowNode) updateWindowMesh(effectiveNode, mesh) clearDirty(id as AnyNodeId) + rebuiltWindowsThisFrame += 1 // Rebuild the parent wall so its cutout reflects the updated window geometry // Avoid triggering expensive wall CSG rebuilds while the window is being interactively moved/duplicated. @@ -89,7 +122,7 @@ export const WindowSystem = () => { if (!isTransient && effectiveNode.parentId) { useScene.getState().dirtyNodes.add(effectiveNode.parentId as AnyNodeId) } - }) + } }, 3) return null