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:
@@ -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<typeof useViewer.getState>['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<CameraControlsImpl | null>(null)
|
||||
const keyboardPanKeys = useRef<KeyboardPanState>({
|
||||
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])
|
||||
|
||||
@@ -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 (
|
||||
<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 = {
|
||||
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. */}
|
||||
<FloorplanRegistryActionMenu />
|
||||
|
||||
{(levelNode?.type === 'level' || hasAmbientBuildingLevel) && (
|
||||
<FloorplanCompassButton
|
||||
northRotationDeg={-floorplanUserRotationDeg}
|
||||
onAlignNorth={alignFloorplanViewToNorth}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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">
|
||||
{referenceScaleDraft.start
|
||||
|
||||
@@ -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({
|
||||
<Viewer
|
||||
defaultRender={EDITOR_DEFAULT_RENDER}
|
||||
hoverStyles={EDITOR_HOVER_STYLES}
|
||||
onSceneReadyChange={onSceneReadyChange}
|
||||
renderContext="editor"
|
||||
sceneReadyKey={sceneReadyKey}
|
||||
selectionManager={isFirstPersonMode ? 'default' : 'custom'}
|
||||
>
|
||||
<ViewerSceneContent
|
||||
isFirstPersonMode={isFirstPersonMode}
|
||||
isLoading={isLoading}
|
||||
isLoading={showLoader}
|
||||
isStudioMode={isStudioMode}
|
||||
isVersionPreviewMode={isVersionPreviewMode}
|
||||
onThumbnailCapture={onThumbnailCapture}
|
||||
@@ -940,7 +946,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
|
||||
</Viewer>
|
||||
</div>
|
||||
</div>
|
||||
{!(isLoading || isVersionPreviewMode) && <ZoneLabelEditorSystem />}
|
||||
{!(showLoader || isVersionPreviewMode) && <ZoneLabelEditorSystem />}
|
||||
</ErrorBoundary>
|
||||
)
|
||||
})
|
||||
@@ -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 && (
|
||||
<div className="fixed inset-0 z-60">
|
||||
<SceneLoader />
|
||||
<SceneLoader className="bg-background" />
|
||||
</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">
|
||||
{showLoader && (
|
||||
<div className="fixed inset-0 z-60">
|
||||
<SceneLoader />
|
||||
<SceneLoader className="bg-background" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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) => (
|
||||
<Html
|
||||
center
|
||||
key={`edge-${i}`}
|
||||
calculatePosition={calculateLabelPosition}
|
||||
key={`${cameraMode}-${camera.uuid}-edge-${i}`}
|
||||
occlude
|
||||
position={[edge.midX, 0.5, edge.midZ]}
|
||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||
|
||||
@@ -51,7 +51,7 @@ const controls: ControlConfig[] = [
|
||||
id: 'delete',
|
||||
icon: Trash2,
|
||||
label: 'Delete',
|
||||
shortcut: 'D',
|
||||
shortcut: 'X',
|
||||
color: 'hover:bg-red-500/20 hover:text-red-400',
|
||||
activeColor: 'bg-red-500/20 text-red-400',
|
||||
},
|
||||
|
||||
@@ -17,15 +17,13 @@ interface SceneLoaderProps {
|
||||
}
|
||||
|
||||
export function SceneLoader({ className, fullScreen = false }: SceneLoaderProps) {
|
||||
const [loaderClass, setLoaderClass] = useState<string | null>(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 (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
+6
-1
@@ -37,7 +37,6 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
|
||||
{ keys: ['1'], action: 'Switch to Site phase' },
|
||||
{ keys: ['2'], action: 'Switch to Structure phase' },
|
||||
{ keys: ['3'], action: 'Switch to Furnish phase' },
|
||||
{ keys: ['S'], action: 'Switch to Structure layer' },
|
||||
{ keys: ['F'], action: 'Switch to Furnish layer' },
|
||||
{ keys: ['Z'], action: 'Switch to Zones layer' },
|
||||
{
|
||||
@@ -56,6 +55,7 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
|
||||
shortcuts: [
|
||||
{ keys: ['V'], action: 'Switch to Select mode' },
|
||||
{ keys: ['B'], action: 'Switch to Build mode' },
|
||||
{ keys: ['X'], action: 'Switch to Delete mode' },
|
||||
{
|
||||
keys: ['Esc'],
|
||||
action: 'Cancel the active tool and return to Select mode',
|
||||
@@ -100,6 +100,11 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
|
||||
{
|
||||
title: 'Camera',
|
||||
shortcuts: [
|
||||
{
|
||||
keys: ['W', 'A', 'S', 'D'],
|
||||
action: 'Pan camera',
|
||||
note: 'Moves in screen space, similar to dragging the camera view.',
|
||||
},
|
||||
{
|
||||
keys: ['Middle click'],
|
||||
action: 'Pan camera',
|
||||
|
||||
@@ -99,7 +99,7 @@ export const useKeyboard = ({
|
||||
useEditor.getState().setPhase('structure')
|
||||
useEditor.getState().setStructureLayer('elements')
|
||||
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
|
||||
e.preventDefault()
|
||||
useEditor.getState().setMode('delete')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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<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>()
|
||||
|
||||
@@ -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 `<Viewer>`. */
|
||||
@@ -220,6 +314,8 @@ const Viewer = forwardRef<ViewerHandle, ViewerProps>(function Viewer(
|
||||
renderContext = 'editor',
|
||||
defaultRender,
|
||||
isolate,
|
||||
sceneReadyKey,
|
||||
onSceneReadyChange,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
@@ -246,13 +342,17 @@ const Viewer = forwardRef<ViewerHandle, ViewerProps>(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<string, unknown> = {}
|
||||
const rawPreferences = window.localStorage.getItem('viewer-preferences')
|
||||
@@ -270,13 +370,13 @@ const Viewer = forwardRef<ViewerHandle, ViewerProps>(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<ViewerHandle, ViewerProps>(function Viewer(
|
||||
<ViewerCamera />
|
||||
<GPUDeviceWatcher />
|
||||
<ToneMappingExposure />
|
||||
<SceneReadyTracker onSceneReadyChange={onSceneReadyChange} sceneReadyKey={sceneReadyKey} />
|
||||
|
||||
<ErrorBoundary fallback={null} scope="viewer-scene">
|
||||
{/* <directionalLight position={[10, 10, 5]} intensity={0.5} castShadow
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
useScene,
|
||||
} 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,
|
||||
@@ -28,6 +28,9 @@ let revealMaterial: THREE.Material = defaultRevealMaterial
|
||||
let glassMaterial: THREE.Material = defaultGlassMaterial
|
||||
|
||||
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,
|
||||
// columnRatios, dividerThickness, …) and crash the geometry build. Re-apply the
|
||||
@@ -47,6 +50,7 @@ export const DoorSystem = () => {
|
||||
const shading = useViewer((state) => state.shading)
|
||||
const textures = useViewer((state) => state.textures)
|
||||
const colorPreset = useViewer((state) => state.colorPreset)
|
||||
const materialRevisionRef = useRef<string | null>(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
|
||||
|
||||
@@ -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<string, Set<string>>()
|
||||
|
||||
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<string, Set<string>>()
|
||||
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<string>()
|
||||
|
||||
// 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)
|
||||
|
||||
|
||||
@@ -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<string | null>(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
|
||||
|
||||
Reference in New Issue
Block a user