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
This commit is contained in:
Wassim SAMAD
2026-06-08 15:04:28 -04:00
committed by GitHub
parent ce6f999310
commit 812b7306e8
15 changed files with 1197 additions and 36 deletions
@@ -32,11 +32,20 @@ const tempSize = new Vector3()
const tempTarget = new Vector3() const tempTarget = new Vector3()
const syncTarget = new Vector3() const syncTarget = new Vector3()
const syncSpherical = new Spherical() 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 DEFAULT_MAX_POLAR_ANGLE = Math.PI / 2 - 0.1
const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05 const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05
const NAVIGATION_SYNC_POSITION_EPSILON = 0.001 const NAVIGATION_SYNC_POSITION_EPSILON = 0.001
const NAVIGATION_SYNC_AZIMUTH_EPSILON = 0.0005 const NAVIGATION_SYNC_AZIMUTH_EPSILON = 0.0005
const NAVIGATION_SYNC_VIEW_WIDTH_EPSILON = 0.001 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<typeof useViewer.getState>['cameraMode'] type CameraMode = ReturnType<typeof useViewer.getState>['cameraMode']
type CameraPoseSnapshot = { type CameraPoseSnapshot = {
mode: CameraMode 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 = { type CameraViewportSize = {
width: number width: number
height: number height: number
@@ -244,6 +288,12 @@ function useFirstPersonCameraPoseRestore(
export const CustomCameraControls = () => { export const CustomCameraControls = () => {
const controls = useRef<CameraControlsImpl | null>(null) const controls = useRef<CameraControlsImpl | null>(null)
const keyboardPanKeys = useRef<KeyboardPanState>({
forward: false,
backward: false,
left: false,
right: false,
})
const isPreviewMode = useEditor((s) => s.isPreviewMode) const isPreviewMode = useEditor((s) => s.isPreviewMode)
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode) const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera) const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera)
@@ -417,6 +467,67 @@ export const CustomCameraControls = () => {
} }
}, [currentLevelId, isFirstPersonMode, isFloorplanOpen, publishCurrentNavigationPose]) }, [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 // Configure mouse buttons based on control mode and camera mode
const mouseButtons = useMemo(() => { const mouseButtons = useMemo(() => {
// Use ZOOM for orthographic camera, DOLLY for perspective camera // Use ZOOM for orthographic camera, DOLLY for perspective camera
@@ -496,6 +607,13 @@ export const CustomCameraControls = () => {
let panPointerId: number | null = null let panPointerId: number | null = null
let panPointerButton: 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') => { const setNavigationCursor = (cursor: 'grab' | 'grabbing') => {
document.body.style.cursor = cursor document.body.style.cursor = cursor
gl.domElement.style.cursor = cursor gl.domElement.style.cursor = cursor
@@ -557,6 +675,19 @@ export const CustomCameraControls = () => {
} }
const onKeyDown = (event: KeyboardEvent) => { 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 (event.code === 'Space') {
if (isEditableKeyboardTarget(event.target)) return if (isEditableKeyboardTarget(event.target)) return
event.preventDefault() event.preventDefault()
@@ -579,6 +710,15 @@ export const CustomCameraControls = () => {
} }
const onKeyUp = (event: KeyboardEvent) => { 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') { if (event.code === 'Space') {
keyState.space = false keyState.space = false
if (panPointerButton === 0) { if (panPointerButton === 0) {
@@ -628,6 +768,7 @@ export const CustomCameraControls = () => {
const onBlur = () => { const onBlur = () => {
keyState.space = false keyState.space = false
clearKeyboardPanKeys()
panPointerId = null panPointerId = null
panPointerButton = null panPointerButton = null
clearNavigationCursor() clearNavigationCursor()
@@ -651,6 +792,7 @@ export const CustomCameraControls = () => {
window.removeEventListener('pointercancel', onPointerUp, true) window.removeEventListener('pointercancel', onPointerUp, true)
window.removeEventListener('blur', onBlur) window.removeEventListener('blur', onBlur)
gl.domElement.removeEventListener('wheel', onWheel) gl.domElement.removeEventListener('wheel', onWheel)
clearKeyboardPanKeys()
clearNavigationCursor() clearNavigationCursor()
} }
}, [cameraMode, gl, isPreviewMode, isFirstPersonMode]) }, [cameraMode, gl, isPreviewMode, isFirstPersonMode])
@@ -141,6 +141,7 @@ import {
} from '../tools/wall/wall-drafting' } from '../tools/wall/wall-drafting'
import { PALETTE_COLORS } from '../ui/primitives/color-dot' import { PALETTE_COLORS } from '../ui/primitives/color-dot'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip'
import { resolveFloorplanBackgroundSelection } from './floorplan-background-selection' import { resolveFloorplanBackgroundSelection } from './floorplan-background-selection'
import { useFloorplanBackgroundPlacement } from './use-floorplan-background-placement' import { useFloorplanBackgroundPlacement } from './use-floorplan-background-placement'
import { useFloorplanHitTesting } from './use-floorplan-hit-testing' import { useFloorplanHitTesting } from './use-floorplan-hit-testing'
@@ -427,6 +428,47 @@ type GuideHandleHintAnchor = {
directionY: number directionY: number
} }
function FloorplanCompassButton({
northRotationDeg,
onAlignNorth,
}: {
northRotationDeg: number
onAlignNorth: () => void
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label="Align view to north"
className="group absolute bottom-3 left-3 z-30 flex h-8 w-8 items-center justify-center rounded-full border border-black/10 bg-white/85 shadow-sm backdrop-blur-md transition hover:bg-white hover:shadow-md dark:border-white/10 dark:bg-neutral-900/85 dark:hover:bg-neutral-900"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
onAlignNorth()
}}
onPointerDown={(event) => {
event.stopPropagation()
}}
type="button"
>
<span className="relative flex h-6 w-6 items-center justify-center rounded-full bg-[#b8b8b8] shadow-inner dark:bg-neutral-700">
<svg
aria-hidden="true"
className="h-6 w-6 transition-transform duration-150 ease-out"
style={{ transform: `rotate(${northRotationDeg}deg)` }}
viewBox="0 0 48 48"
>
<path d="M24 4.5 31.5 25 24 21.5 16.5 25Z" fill="#f15b5b" />
<path d="M24 43.5 16.5 23 24 26.5 31.5 23Z" fill="#ffffff" />
</svg>
</span>
</button>
</TooltipTrigger>
<TooltipContent side="right">Align view to north</TooltipContent>
</Tooltip>
)
}
type GuideInteractionState = { type GuideInteractionState = {
pointerId: number pointerId: number
guideId: GuideNode['id'] guideId: GuideNode['id']
@@ -6616,6 +6658,28 @@ export function FloorplanPanel() {
[buildingPosition, buildingRotationY, floorplanGridWorldY], [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(() => { const clearGuideInteraction = useCallback(() => {
guideInteractionRef.current = null guideInteractionRef.current = null
guideTransformDraftRef.current = null guideTransformDraftRef.current = null
@@ -9744,6 +9808,13 @@ export function FloorplanPanel() {
only action menu the floor plan mounts. */} only action menu the floor plan mounts. */}
<FloorplanRegistryActionMenu /> <FloorplanRegistryActionMenu />
{(levelNode?.type === 'level' || hasAmbientBuildingLevel) && (
<FloorplanCompassButton
northRotationDeg={-floorplanUserRotationDeg}
onAlignNorth={alignFloorplanViewToNorth}
/>
)}
{referenceScaleDraft && ( {referenceScaleDraft && (
<div className="pointer-events-none absolute top-3 left-1/2 z-30 -translate-x-1/2 rounded-md border bg-background/95 px-3 py-2 text-center text-sm shadow-sm"> <div className="pointer-events-none absolute top-3 left-1/2 z-30 -translate-x-1/2 rounded-md border bg-background/95 px-3 py-2 text-center text-sm shadow-sm">
{referenceScaleDraft.start {referenceScaleDraft.start
@@ -816,6 +816,8 @@ const ViewerCanvas = memo(function ViewerCanvas({
isStudioMode, isStudioMode,
hasLoadedInitialScene, hasLoadedInitialScene,
showLoader, showLoader,
sceneReadyKey,
onSceneReadyChange,
onThumbnailCapture, onThumbnailCapture,
}: { }: {
isVersionPreviewMode: boolean isVersionPreviewMode: boolean
@@ -824,6 +826,8 @@ const ViewerCanvas = memo(function ViewerCanvas({
isStudioMode: boolean isStudioMode: boolean
hasLoadedInitialScene: boolean hasLoadedInitialScene: boolean
showLoader: boolean showLoader: boolean
sceneReadyKey: number
onSceneReadyChange: (ready: boolean) => void
onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
}) { }) {
const viewMode = useEditor((s) => s.viewMode) const viewMode = useEditor((s) => s.viewMode)
@@ -927,12 +931,14 @@ const ViewerCanvas = memo(function ViewerCanvas({
<Viewer <Viewer
defaultRender={EDITOR_DEFAULT_RENDER} defaultRender={EDITOR_DEFAULT_RENDER}
hoverStyles={EDITOR_HOVER_STYLES} hoverStyles={EDITOR_HOVER_STYLES}
onSceneReadyChange={onSceneReadyChange}
renderContext="editor" renderContext="editor"
sceneReadyKey={sceneReadyKey}
selectionManager={isFirstPersonMode ? 'default' : 'custom'} selectionManager={isFirstPersonMode ? 'default' : 'custom'}
> >
<ViewerSceneContent <ViewerSceneContent
isFirstPersonMode={isFirstPersonMode} isFirstPersonMode={isFirstPersonMode}
isLoading={isLoading} isLoading={showLoader}
isStudioMode={isStudioMode} isStudioMode={isStudioMode}
isVersionPreviewMode={isVersionPreviewMode} isVersionPreviewMode={isVersionPreviewMode}
onThumbnailCapture={onThumbnailCapture} onThumbnailCapture={onThumbnailCapture}
@@ -940,7 +946,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
</Viewer> </Viewer>
</div> </div>
</div> </div>
{!(isLoading || isVersionPreviewMode) && <ZoneLabelEditorSystem />} {!(showLoader || isVersionPreviewMode) && <ZoneLabelEditorSystem />}
</ErrorBoundary> </ErrorBoundary>
) )
}) })
@@ -984,6 +990,8 @@ export default function Editor({
const [isSceneLoading, setIsSceneLoading] = useState(false) const [isSceneLoading, setIsSceneLoading] = useState(false)
const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false) const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false)
const [sceneReadyKey, setSceneReadyKey] = useState(0)
const [isViewerSceneReady, setIsViewerSceneReady] = useState(false)
const isPreviewMode = useEditor((s) => s.isPreviewMode) const isPreviewMode = useEditor((s) => s.isPreviewMode)
const isCaptureMode = useEditor((s) => s.isCaptureMode) const isCaptureMode = useEditor((s) => s.isCaptureMode)
@@ -1010,15 +1018,24 @@ export default function Editor({
async function load() { async function load() {
isLoadingSceneRef.current = true isLoadingSceneRef.current = true
setHasLoadedInitialScene(false) setHasLoadedInitialScene(false)
setIsViewerSceneReady(false)
setIsSceneLoading(true) setIsSceneLoading(true)
useScene.getState().unloadScene()
useViewer.getState().resetSelection()
try { try {
const sceneGraph = onLoad ? await onLoad() : loadSceneFromLocalStorage() const sceneGraph = onLoad ? await onLoad() : loadSceneFromLocalStorage()
if (!cancelled) { if (!cancelled) {
applySceneGraphToEditor(sceneGraph) applySceneGraphToEditor(sceneGraph)
setIsViewerSceneReady(false)
setSceneReadyKey((key) => key + 1)
} }
} catch { } catch {
if (!cancelled) applySceneGraphToEditor(null) if (!cancelled) {
applySceneGraphToEditor(null)
setIsViewerSceneReady(false)
setSceneReadyKey((key) => key + 1)
}
} finally { } finally {
if (!cancelled) { if (!cancelled) {
setIsSceneLoading(false) 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 firstPersonPreviousLevelRef = useRef(useViewer.getState().selection.levelId)
const wasFirstPersonModeRef = useRef(isFirstPersonMode) const wasFirstPersonModeRef = useRef(isFirstPersonMode)
@@ -1125,7 +1146,9 @@ export default function Editor({
isLoading={isLoading} isLoading={isLoading}
isStudioMode={isStudioMode} isStudioMode={isStudioMode}
isVersionPreviewMode={isVersionPreviewMode} isVersionPreviewMode={isVersionPreviewMode}
onSceneReadyChange={handleSceneReadyChange}
onThumbnailCapture={onThumbnailCapture} onThumbnailCapture={onThumbnailCapture}
sceneReadyKey={sceneReadyKey}
showLoader={showLoader} showLoader={showLoader}
/> />
) )
@@ -1162,7 +1185,7 @@ export default function Editor({
<> <>
{showLoader && ( {showLoader && (
<div className="fixed inset-0 z-60"> <div className="fixed inset-0 z-60">
<SceneLoader /> <SceneLoader className="bg-background" />
</div> </div>
)} )}
@@ -1227,7 +1250,7 @@ export default function Editor({
<div className="dark flex h-full w-full gap-3 bg-neutral-100 p-3 text-foreground"> <div className="dark flex h-full w-full gap-3 bg-neutral-100 p-3 text-foreground">
{showLoader && ( {showLoader && (
<div className="fixed inset-0 z-60"> <div className="fixed inset-0 z-60">
<SceneLoader /> <SceneLoader className="bg-background" />
</div> </div>
)} )}
@@ -4,9 +4,25 @@ import type { SiteNode } from '@pascal-app/core'
import { sceneRegistry, useScene } from '@pascal-app/core' import { sceneRegistry, useScene } from '@pascal-app/core'
import { getSceneTheme, useViewer } from '@pascal-app/viewer' import { getSceneTheme, useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei' import { Html } from '@react-three/drei'
import { createPortal, useFrame } from '@react-three/fiber' import { createPortal, useFrame, useThree } from '@react-three/fiber'
import { useMemo, useRef, useState } from 'react' import { useCallback, useMemo, useRef, useState } from 'react'
import type { Object3D } from 'three' 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') { function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
if (unit === 'imperial') { if (unit === 'imperial') {
@@ -30,7 +46,14 @@ export function SiteEdgeLabels() {
return node?.type === 'site' ? (node as SiteNode) : null return node?.type === 'site' ? (node as SiteNode) : null
}) })
const unit = useViewer((state) => state.unit) const unit = useViewer((state) => state.unit)
const cameraMode = useViewer((state) => state.cameraMode)
const isNight = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark') 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 const siteNodeId = siteNode?.id
@@ -72,7 +95,8 @@ export function SiteEdgeLabels() {
{edges.map((edge, i) => ( {edges.map((edge, i) => (
<Html <Html
center center
key={`edge-${i}`} calculatePosition={calculateLabelPosition}
key={`${cameraMode}-${camera.uuid}-edge-${i}`}
occlude occlude
position={[edge.midX, 0.5, edge.midZ]} position={[edge.midX, 0.5, edge.midZ]}
style={{ pointerEvents: 'none', userSelect: 'none' }} style={{ pointerEvents: 'none', userSelect: 'none' }}
@@ -51,7 +51,7 @@ const controls: ControlConfig[] = [
id: 'delete', id: 'delete',
icon: Trash2, icon: Trash2,
label: 'Delete', label: 'Delete',
shortcut: 'D', shortcut: 'X',
color: 'hover:bg-red-500/20 hover:text-red-400', color: 'hover:bg-red-500/20 hover:text-red-400',
activeColor: 'bg-red-500/20 text-red-400', activeColor: 'bg-red-500/20 text-red-400',
}, },
@@ -17,15 +17,13 @@ interface SceneLoaderProps {
} }
export function SceneLoader({ className, fullScreen = false }: SceneLoaderProps) { export function SceneLoader({ className, fullScreen = false }: SceneLoaderProps) {
const [loaderClass, setLoaderClass] = useState<string | null>(null) const [loaderClass, setLoaderClass] = useState(LOADERS[0]!)
useEffect(() => { useEffect(() => {
// Pick a random loader on mount // Pick a random loader on mount
setLoaderClass(LOADERS[Math.floor(Math.random() * LOADERS.length)] ?? LOADERS[0]!) setLoaderClass(LOADERS[Math.floor(Math.random() * LOADERS.length)] ?? LOADERS[0]!)
}, []) }, [])
if (!loaderClass) return null
return ( return (
<div <div
className={cn( className={cn(
@@ -37,7 +37,6 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
{ keys: ['1'], action: 'Switch to Site phase' }, { keys: ['1'], action: 'Switch to Site phase' },
{ keys: ['2'], action: 'Switch to Structure phase' }, { keys: ['2'], action: 'Switch to Structure phase' },
{ keys: ['3'], action: 'Switch to Furnish phase' }, { keys: ['3'], action: 'Switch to Furnish phase' },
{ keys: ['S'], action: 'Switch to Structure layer' },
{ keys: ['F'], action: 'Switch to Furnish layer' }, { keys: ['F'], action: 'Switch to Furnish layer' },
{ keys: ['Z'], action: 'Switch to Zones layer' }, { keys: ['Z'], action: 'Switch to Zones layer' },
{ {
@@ -56,6 +55,7 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
shortcuts: [ shortcuts: [
{ keys: ['V'], action: 'Switch to Select mode' }, { keys: ['V'], action: 'Switch to Select mode' },
{ keys: ['B'], action: 'Switch to Build mode' }, { keys: ['B'], action: 'Switch to Build mode' },
{ keys: ['X'], action: 'Switch to Delete mode' },
{ {
keys: ['Esc'], keys: ['Esc'],
action: 'Cancel the active tool and return to Select mode', action: 'Cancel the active tool and return to Select mode',
@@ -100,6 +100,11 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
{ {
title: 'Camera', title: 'Camera',
shortcuts: [ shortcuts: [
{
keys: ['W', 'A', 'S', 'D'],
action: 'Pan camera',
note: 'Moves in screen space, similar to dragging the camera view.',
},
{ {
keys: ['Middle click'], keys: ['Middle click'],
action: 'Pan camera', action: 'Pan camera',
+1 -1
View File
@@ -99,7 +99,7 @@ export const useKeyboard = ({
useEditor.getState().setPhase('structure') useEditor.getState().setPhase('structure')
useEditor.getState().setStructureLayer('elements') useEditor.getState().setStructureLayer('elements')
useEditor.getState().setMode('build') useEditor.getState().setMode('build')
} else if (e.key === 'd' && !e.metaKey && !e.ctrlKey) { } else if (e.key === 'x' && !e.metaKey && !e.ctrlKey) {
if (isVersionPreviewMode) return if (isVersionPreviewMode) return
e.preventDefault() e.preventDefault()
useEditor.getState().setMode('delete') useEditor.getState().setMode('delete')
+517
View File
@@ -0,0 +1,517 @@
import {
type AnyNode,
DEFAULT_WALL_HEIGHT,
DEFAULT_WALL_THICKNESS,
type DoorNode,
type WallNode,
type WindowNode,
} from '@pascal-app/core'
type SceneNodes = Record<string, AnyNode>
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<string>()
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<string>) {
const childIds = new Set<string>()
const childOriginWall = new Map<string, string>()
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<string, unknown> | undefined) ?? {}),
ifcSimplification: {
mergedWallCount: cluster.length,
mergedExpressIDs: mergedExpressIds,
},
}
const nextChildren = new Set<string>()
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<IfcConversionSimplificationOptions>,
) {
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<string, WallSegment[]>()
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<number, WallSegment[]>()
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<string, OpeningNode[]>()
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<string>()
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<IfcConversionSimplificationOptions> = {
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
}
+23
View File
@@ -16,6 +16,12 @@ import {
} from '@pascal-app/core' } from '@pascal-app/core'
import { customAlphabet } from 'nanoid' import { customAlphabet } from 'nanoid'
import * as WebIFC from 'web-ifc' import * as WebIFC from 'web-ifc'
import { type IfcConversionSimplificationOptions, simplifyConvertedSceneGraph } from './cleanup'
export type {
IfcConversionSimplificationOptions,
IfcConversionSimplificationStats,
} from './cleanup'
export type PascalNode = AnyNode export type PascalNode = AnyNode
@@ -636,6 +642,7 @@ export interface ConversionOptions {
swapYZ?: boolean swapYZ?: boolean
extrusionDepthIsHeight?: boolean extrusionDepthIsHeight?: boolean
swapProfileDimensions?: boolean swapProfileDimensions?: boolean
simplify?: boolean | IfcConversionSimplificationOptions
label?: string label?: string
} }
@@ -664,6 +671,12 @@ export async function convertIfcToPascal(
extrusionDepthIsHeight: options?.extrusionDepthIsHeight ?? true, extrusionDepthIsHeight: options?.extrusionDepthIsHeight ?? true,
swapProfileDimensions: options?.swapProfileDimensions ?? false, swapProfileDimensions: options?.swapProfileDimensions ?? false,
} }
const simplificationOptions =
options?.simplify === false
? { enabled: false }
: typeof options?.simplify === 'object'
? options.simplify
: undefined
const progress = (msg: string, pct: number) => { const progress = (msg: string, pct: number) => {
console.log(`[IFC→Pascal] ${msg} (${pct}%)`) console.log(`[IFC→Pascal] ${msg} (${pct}%)`)
@@ -2049,6 +2062,16 @@ export async function convertIfcToPascal(
/* no type rels */ /* 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) ifcApi.CloseModel(modelID)
progress('Building scene graph...', 95) progress('Building scene graph...', 95)
@@ -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<string, AnyNode> = {
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<string, AnyNode> = {
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<string, AnyNode> = {
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'])
})
})
+109 -8
View File
@@ -1,6 +1,12 @@
'use client' '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 { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@react-three/fiber'
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react' import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
import * as THREE from 'three/webgpu' 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 // concurrent configure() calls await the same init instead of creating two
// renderers in parallel and only caching the second. // renderers in parallel and only caching the second.
const WEBGPU_RENDERER_CACHE = new WeakMap<HTMLCanvasElement, Promise<THREE.WebGPURenderer>>() const WEBGPU_RENDERER_CACHE = new WeakMap<HTMLCanvasElement, Promise<THREE.WebGPURenderer>>()
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<object>() const warnedEmptyDraw = process.env.NODE_ENV === 'production' ? null : new WeakSet<object>()
@@ -176,6 +195,73 @@ function ToneMappingExposure() {
return null 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 { interface ViewerProps {
children?: React.ReactNode children?: React.ReactNode
hoverStyles?: HoverStyles hoverStyles?: HoverStyles
@@ -197,6 +283,14 @@ interface ViewerProps {
* for a future focus-mode UX. * for a future focus-mode UX.
*/ */
isolate?: AnyNodeId[] | null 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 `<Viewer>`. */ /** Imperative handle exposed via `ref` on `<Viewer>`. */
@@ -220,6 +314,8 @@ const Viewer = forwardRef<ViewerHandle, ViewerProps>(function Viewer(
renderContext = 'editor', renderContext = 'editor',
defaultRender, defaultRender,
isolate, isolate,
sceneReadyKey,
onSceneReadyChange,
}, },
ref, ref,
) { ) {
@@ -246,13 +342,17 @@ const Viewer = forwardRef<ViewerHandle, ViewerProps>(function Viewer(
}, [isolate]) }, [isolate])
const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark') 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(() => { useEffect(() => {
const ctx = renderContext const ctx = renderContext
useViewer.getState().setRenderContext(ctx) useViewer.getState().setRenderContext(ctx)
const { shading, shadingByContext, setShading } = useViewer.getState() 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<string, unknown> = {} let persistedState: Record<string, unknown> = {}
const rawPreferences = window.localStorage.getItem('viewer-preferences') const rawPreferences = window.localStorage.getItem('viewer-preferences')
@@ -270,13 +370,13 @@ const Viewer = forwardRef<ViewerHandle, ViewerProps>(function Viewer(
} catch {} } catch {}
} }
if (defaultRender.textures !== undefined && !('textures' in persistedState)) { if (defaultTextures !== undefined && !('textures' in persistedState)) {
useViewer.getState().setTextures(defaultRender.textures) useViewer.getState().setTextures(defaultTextures)
} }
if (defaultRender.colorPreset && !('colorPreset' in persistedState)) { if (defaultColorPreset && !('colorPreset' in persistedState)) {
useViewer.getState().setColorPreset(defaultRender.colorPreset) useViewer.getState().setColorPreset(defaultColorPreset)
} }
}, []) }, [defaultColorPreset, defaultShading, defaultTextures, hasDefaultRender, renderContext])
// Coarse-pointer devices (phones/tablets) get a tighter DPR ceiling to keep // 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. // fragment-shader cost down — saves another ~30% over 1.5x on high-DPI mobile.
@@ -329,6 +429,7 @@ const Viewer = forwardRef<ViewerHandle, ViewerProps>(function Viewer(
<ViewerCamera /> <ViewerCamera />
<GPUDeviceWatcher /> <GPUDeviceWatcher />
<ToneMappingExposure /> <ToneMappingExposure />
<SceneReadyTracker onSceneReadyChange={onSceneReadyChange} sceneReadyKey={sceneReadyKey} />
<ErrorBoundary fallback={null} scope="viewer-scene"> <ErrorBoundary fallback={null} scope="viewer-scene">
{/* <directionalLight position={[10, 10, 5]} intensity={0.5} castShadow {/* <directionalLight position={[10, 10, 5]} intensity={0.5} castShadow
@@ -11,7 +11,7 @@ import {
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
import { useEffect } from 'react' import { useEffect, useRef } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { import {
createSurfaceRoleMaterial, createSurfaceRoleMaterial,
@@ -28,6 +28,9 @@ let revealMaterial: THREE.Material = defaultRevealMaterial
let glassMaterial: THREE.Material = defaultGlassMaterial let glassMaterial: THREE.Material = defaultGlassMaterial
const DOOR_RENDER_DEFAULTS = DoorNodeSchema.parse({ id: 'door_render_default' }) const DOOR_RENDER_DEFAULTS = DoorNodeSchema.parse({ id: 'door_render_default' })
const MAX_DOOR_REBUILDS_PER_FRAME = 16
const DOOR_PROGRESSIVE_DIRTY_THRESHOLD = MAX_DOOR_REBUILDS_PER_FRAME
const DOOR_PROGRESSIVE_TIME_BUDGET_MS = 8
// Legacy/unparsed door nodes can miss schema-defaulted fields (segments, // Legacy/unparsed door nodes can miss schema-defaulted fields (segments,
// columnRatios, dividerThickness, …) and crash the geometry build. Re-apply the // columnRatios, dividerThickness, …) and crash the geometry build. Re-apply the
@@ -47,6 +50,7 @@ export const DoorSystem = () => {
const shading = useViewer((state) => state.shading) const shading = useViewer((state) => state.shading)
const textures = useViewer((state) => state.textures) const textures = useViewer((state) => state.textures)
const colorPreset = useViewer((state) => state.colorPreset) const colorPreset = useViewer((state) => state.colorPreset)
const materialRevisionRef = useRef<string | null>(null)
// Subscribe so an override-only update (no scene write) still re-runs // Subscribe so an override-only update (no scene write) still re-runs
// the component, letting the gate below pick up the latest dirtyNodes // the component, letting the gate below pick up the latest dirtyNodes
// set from the same render pass that received the override-publishing // set from the same render pass that received the override-publishing
@@ -59,13 +63,17 @@ export const DoorSystem = () => {
glassMaterial = textures ? defaultGlassMaterial : joineryMaterial glassMaterial = textures ? defaultGlassMaterial : joineryMaterial
useEffect(() => { useEffect(() => {
const materialRevision = `${shading}:${textures ? 'textures' : 'solid'}:${colorPreset}`
if (materialRevisionRef.current === materialRevision) return
materialRevisionRef.current = materialRevision
const nodes = useScene.getState().nodes const nodes = useScene.getState().nodes
for (const node of Object.values(nodes)) { for (const node of Object.values(nodes)) {
if (node?.type === 'door') { if (node?.type === 'door') {
useScene.getState().dirtyNodes.add(node.id as AnyNodeId) useScene.getState().dirtyNodes.add(node.id as AnyNodeId)
} }
} }
}, [shading, textures, colorPreset]) })
useFrame(() => { useFrame(() => {
if (dirtyNodes.size === 0) return if (dirtyNodes.size === 0) return
@@ -75,13 +83,35 @@ export const DoorSystem = () => {
glassMaterial = textures ? defaultGlassMaterial : frameJoineryMaterial glassMaterial = textures ? defaultGlassMaterial : frameJoineryMaterial
const nodes = useScene.getState().nodes const nodes = useScene.getState().nodes
const dirtyDoorIds: AnyNodeId[] = []
dirtyNodes.forEach((id) => { dirtyNodes.forEach((id) => {
const node = nodes[id] const node = nodes[id]
if (!node || node.type !== 'door') return 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 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 // Merge any live override (width / height / position) so the mesh
// rebuild reflects the in-flight drag without zustand churn. When // rebuild reflects the in-flight drag without zustand churn. When
@@ -89,6 +119,7 @@ export const DoorSystem = () => {
const effectiveNode = getEffectiveNode(node as DoorNode) const effectiveNode = getEffectiveNode(node as DoorNode)
updateDoorMesh(effectiveNode, mesh) updateDoorMesh(effectiveNode, mesh)
clearDirty(id as AnyNodeId) clearDirty(id as AnyNodeId)
rebuiltDoorsThisFrame += 1
// Rebuild the parent wall so its cutout reflects the updated door geometry // 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. // 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) { if (!isTransient && effectiveNode.parentId) {
useScene.getState().dirtyNodes.add(effectiveNode.parentId as AnyNodeId) useScene.getState().dirtyNodes.add(effectiveNode.parentId as AnyNodeId)
} }
}) }
}, 3) }, 3)
return null return null
@@ -329,9 +329,20 @@ let useFrameNb = 0
// within ~80ms. Standard CAD-app behavior. Speeds up t-junction drags ~3×, // within ~80ms. Standard CAD-app behavior. Speeds up t-junction drags ~3×,
// 4-corner-room drags ~4×. // 4-corner-room drags ~4×.
const DRAG_FLUSH_MS = 80 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 let lastWallDirtyAtMs = 0
const pendingAdjacentByLevel = new Map<string, Set<string>>() const pendingAdjacentByLevel = new Map<string, Set<string>>()
function getPendingAdjacentCount() {
let count = 0
for (const ids of pendingAdjacentByLevel.values()) {
count += ids.size
}
return count
}
export const WallSystem = () => { export const WallSystem = () => {
const dirtyNodes = useScene((state) => state.dirtyNodes) const dirtyNodes = useScene((state) => state.dirtyNodes)
const clearDirty = useScene((state) => state.clearDirty) const clearDirty = useScene((state) => state.clearDirty)
@@ -353,6 +364,7 @@ export const WallSystem = () => {
// Collect dirty walls and their levels // Collect dirty walls and their levels
const dirtyWallsByLevel = new Map<string, Set<string>>() const dirtyWallsByLevel = new Map<string, Set<string>>()
let dirtyWallCount = 0
useFrameNb += 1 useFrameNb += 1
if (hasDirty) { if (hasDirty) {
@@ -367,6 +379,7 @@ export const WallSystem = () => {
dirtyWallsByLevel.set(levelId, new Set()) dirtyWallsByLevel.set(levelId, new Set())
} }
dirtyWallsByLevel.get(levelId)?.add(id) dirtyWallsByLevel.get(levelId)?.add(id)
dirtyWallCount += 1
}) })
} }
@@ -375,25 +388,53 @@ export const WallSystem = () => {
lastWallDirtyAtMs = now lastWallDirtyAtMs = now
} }
const useProgressiveWallRebuilds = dirtyWallCount > WALL_PROGRESSIVE_DIRTY_THRESHOLD
let rebuiltWallsThisFrame = 0
const rebuildFrameStartedAt = now
// Process each level that has dirty walls // Process each level that has dirty walls
for (const [levelId, dirtyWallIds] of dirtyWallsByLevel) { for (const [levelId, dirtyWallIds] of dirtyWallsByLevel) {
if (useProgressiveWallRebuilds && rebuiltWallsThisFrame >= MAX_WALL_REBUILDS_PER_FRAME) {
break
}
const levelWalls = getLevelWalls(levelId) const levelWalls = getLevelWalls(levelId)
const miterData = calculateLevelMiters(levelWalls) const miterData = calculateLevelMiters(levelWalls)
const rebuiltWallIds = new Set<string>()
// Update dirty walls — always, no throttling. The dragged wall must // 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) { 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 const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh
if (mesh) { if (mesh) {
updateWallGeometry(wallId, miterData) updateWallGeometry(wallId, miterData)
clearDirty(wallId as AnyNodeId) clearDirty(wallId as AnyNodeId)
rebuiltWallIds.add(wallId)
rebuiltWallsThisFrame += 1
} }
// If mesh not found, keep it dirty for next frame // If mesh not found, keep it dirty for next frame
} }
if (rebuiltWallIds.size === 0) {
continue
}
// Adjacent walls sharing junctions — *defer* during active drag // Adjacent walls sharing junctions — *defer* during active drag
// (dirty arrived this frame), flush on the trailing edge. // (dirty arrived this frame), flush on the trailing edge.
const adjacentWallIds = getAdjacentWallIds(levelWalls, dirtyWallIds) const adjacentWallIds = getAdjacentWallIds(levelWalls, rebuiltWallIds)
let pending = pendingAdjacentByLevel.get(levelId) let pending = pendingAdjacentByLevel.get(levelId)
if (!pending) { if (!pending) {
pending = new Set() pending = new Set()
@@ -411,16 +452,45 @@ export const WallSystem = () => {
// their correct miter joins. // their correct miter joins.
const quiet = !hasDirtyWalls && now - lastWallDirtyAtMs >= DRAG_FLUSH_MS const quiet = !hasDirtyWalls && now - lastWallDirtyAtMs >= DRAG_FLUSH_MS
if (quiet && pendingAdjacentByLevel.size > 0) { 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) { for (const [levelId, pendingIds] of pendingAdjacentByLevel) {
if (pendingIds.size === 0) continue if (pendingIds.size === 0) continue
const levelWalls = getLevelWalls(levelId) const levelWalls = getLevelWalls(levelId)
const miterData = calculateLevelMiters(levelWalls) 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 const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh
if (mesh) updateWallGeometry(wallId, miterData) 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) }, 4)
@@ -8,7 +8,7 @@ import {
type WindowNode, type WindowNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
import { useEffect } from 'react' import { useEffect, useRef } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { import {
createSurfaceRoleMaterial, 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 AWNING_WINDOW_SASH_NAME = 'awning-window-sash'
export const HOPPER_WINDOW_SASH_NAME = 'hopper-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 = () => { export const WindowSystem = () => {
const dirtyNodes = useScene((state) => state.dirtyNodes) const dirtyNodes = useScene((state) => state.dirtyNodes)
const clearDirty = useScene((state) => state.clearDirty) const clearDirty = useScene((state) => state.clearDirty)
const shading = useViewer((state) => state.shading) const shading = useViewer((state) => state.shading)
const textures = useViewer((state) => state.textures) const textures = useViewer((state) => state.textures)
const colorPreset = useViewer((state) => state.colorPreset) const colorPreset = useViewer((state) => state.colorPreset)
const materialRevisionRef = useRef<string | null>(null)
// Subscribe so override-only updates re-run this component. Mirrors // Subscribe so override-only updates re-run this component. Mirrors
// WallSystem + DoorSystem. // WallSystem + DoorSystem.
useLiveNodeOverrides((s) => s.overrides) useLiveNodeOverrides((s) => s.overrides)
@@ -50,13 +55,17 @@ export const WindowSystem = () => {
: createSurfaceRoleMaterial('glazing', colorPreset) : createSurfaceRoleMaterial('glazing', colorPreset)
useEffect(() => { useEffect(() => {
const materialRevision = `${shading}:${textures ? 'textures' : 'solid'}:${colorPreset}`
if (materialRevisionRef.current === materialRevision) return
materialRevisionRef.current = materialRevision
const nodes = useScene.getState().nodes const nodes = useScene.getState().nodes
for (const node of Object.values(nodes)) { for (const node of Object.values(nodes)) {
if (node?.type === 'window') { if (node?.type === 'window') {
useScene.getState().dirtyNodes.add(node.id as AnyNodeId) useScene.getState().dirtyNodes.add(node.id as AnyNodeId)
} }
} }
}, [shading, textures, colorPreset]) })
useFrame(() => { useFrame(() => {
if (dirtyNodes.size === 0) return if (dirtyNodes.size === 0) return
@@ -68,19 +77,43 @@ export const WindowSystem = () => {
: createSurfaceRoleMaterial('glazing', colorPreset) : createSurfaceRoleMaterial('glazing', colorPreset)
const nodes = useScene.getState().nodes const nodes = useScene.getState().nodes
const dirtyWindowIds: AnyNodeId[] = []
dirtyNodes.forEach((id) => { dirtyNodes.forEach((id) => {
const node = nodes[id] const node = nodes[id]
if (!node || node.type !== 'window') return 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 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 // Merge any live override (width / height / position) so the mesh
// rebuild reflects the in-flight drag without zustand churn. // rebuild reflects the in-flight drag without zustand churn.
const effectiveNode = getEffectiveNode(node as WindowNode) const effectiveNode = getEffectiveNode(node as WindowNode)
updateWindowMesh(effectiveNode, mesh) updateWindowMesh(effectiveNode, mesh)
clearDirty(id as AnyNodeId) clearDirty(id as AnyNodeId)
rebuiltWindowsThisFrame += 1
// Rebuild the parent wall so its cutout reflects the updated window geometry // 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. // 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) { if (!isTransient && effectiveNode.parentId) {
useScene.getState().dirtyNodes.add(effectiveNode.parentId as AnyNodeId) useScene.getState().dirtyNodes.add(effectiveNode.parentId as AnyNodeId)
} }
}) }
}, 3) }, 3)
return null return null