Fix editor interaction, loading, and IFC cleanup (#385)

* fix: update site labels after camera swaps

* fix: gate scene display until viewer is ready

* fix: render scene loader on first paint

* fix: keep loader during pending scene graph

* feat: add wasd camera panning

* feat: add x delete mode shortcut

* feat: add floorplan north compass

* fix: move floorplan compass to lower corner

* fix: progressively rebuild heavy scene geometry

* fix: simplify noisy ifc wall output
This commit is contained in:
Wassim SAMAD
2026-06-08 15:04:28 -04:00
committed by GitHub
parent ce6f999310
commit 812b7306e8
15 changed files with 1197 additions and 36 deletions
@@ -32,11 +32,20 @@ const tempSize = new Vector3()
const tempTarget = new Vector3()
const 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(
@@ -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',
+1 -1
View File
@@ -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')