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 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(
|
||||||
|
|||||||
+6
-1
@@ -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',
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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'
|
} 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'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user