Fix editor interaction, loading, and IFC cleanup (#385)
* fix: update site labels after camera swaps * fix: gate scene display until viewer is ready * fix: render scene loader on first paint * fix: keep loader during pending scene graph * feat: add wasd camera panning * feat: add x delete mode shortcut * feat: add floorplan north compass * fix: move floorplan compass to lower corner * fix: progressively rebuild heavy scene geometry * fix: simplify noisy ifc wall output
This commit is contained in:
@@ -32,11 +32,20 @@ const tempSize = new Vector3()
|
||||
const tempTarget = new Vector3()
|
||||
const syncTarget = new Vector3()
|
||||
const syncSpherical = new Spherical()
|
||||
const keyboardPanPosition = new Vector3()
|
||||
const keyboardPanTarget = new Vector3()
|
||||
const keyboardPanScreenRight = new Vector3()
|
||||
const keyboardPanScreenUp = new Vector3()
|
||||
const keyboardPanDelta = new Vector3()
|
||||
const keyboardPanSpherical = new Spherical()
|
||||
const DEFAULT_MAX_POLAR_ANGLE = Math.PI / 2 - 0.1
|
||||
const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05
|
||||
const NAVIGATION_SYNC_POSITION_EPSILON = 0.001
|
||||
const NAVIGATION_SYNC_AZIMUTH_EPSILON = 0.0005
|
||||
const NAVIGATION_SYNC_VIEW_WIDTH_EPSILON = 0.001
|
||||
const KEYBOARD_PAN_VIEW_WIDTH_PER_SECOND = 0.65
|
||||
const KEYBOARD_PAN_MIN_SPEED = 2
|
||||
const KEYBOARD_PAN_MAX_SPEED = 55
|
||||
type CameraMode = ReturnType<typeof useViewer.getState>['cameraMode']
|
||||
type CameraPoseSnapshot = {
|
||||
mode: CameraMode
|
||||
@@ -90,6 +99,41 @@ function isEditableKeyboardTarget(target: EventTarget | null) {
|
||||
)
|
||||
}
|
||||
|
||||
type KeyboardPanState = {
|
||||
forward: boolean
|
||||
backward: boolean
|
||||
left: boolean
|
||||
right: boolean
|
||||
}
|
||||
|
||||
function setKeyboardPanKey(state: KeyboardPanState, code: string, pressed: boolean): boolean {
|
||||
if (code === 'KeyW') {
|
||||
const changed = state.forward !== pressed
|
||||
state.forward = pressed
|
||||
return changed
|
||||
}
|
||||
if (code === 'KeyS') {
|
||||
const changed = state.backward !== pressed
|
||||
state.backward = pressed
|
||||
return changed
|
||||
}
|
||||
if (code === 'KeyA') {
|
||||
const changed = state.left !== pressed
|
||||
state.left = pressed
|
||||
return changed
|
||||
}
|
||||
if (code === 'KeyD') {
|
||||
const changed = state.right !== pressed
|
||||
state.right = pressed
|
||||
return changed
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function isKeyboardPanKey(code: string): boolean {
|
||||
return code === 'KeyW' || code === 'KeyA' || code === 'KeyS' || code === 'KeyD'
|
||||
}
|
||||
|
||||
type CameraViewportSize = {
|
||||
width: number
|
||||
height: number
|
||||
@@ -244,6 +288,12 @@ function useFirstPersonCameraPoseRestore(
|
||||
|
||||
export const CustomCameraControls = () => {
|
||||
const controls = useRef<CameraControlsImpl | null>(null)
|
||||
const keyboardPanKeys = useRef<KeyboardPanState>({
|
||||
forward: false,
|
||||
backward: false,
|
||||
left: false,
|
||||
right: false,
|
||||
})
|
||||
const isPreviewMode = useEditor((s) => s.isPreviewMode)
|
||||
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
|
||||
const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera)
|
||||
@@ -417,6 +467,67 @@ export const CustomCameraControls = () => {
|
||||
}
|
||||
}, [currentLevelId, isFirstPersonMode, isFloorplanOpen, publishCurrentNavigationPose])
|
||||
|
||||
useFrame((_, delta) => {
|
||||
if (isFirstPersonMode || !controls.current) return
|
||||
|
||||
const panKeys = keyboardPanKeys.current
|
||||
const horizontal = (panKeys.right ? 1 : 0) - (panKeys.left ? 1 : 0)
|
||||
const vertical = (panKeys.forward ? 1 : 0) - (panKeys.backward ? 1 : 0)
|
||||
if (horizontal === 0 && vertical === 0) return
|
||||
|
||||
const control = controls.current
|
||||
control.getPosition(keyboardPanPosition)
|
||||
control.getTarget(keyboardPanTarget)
|
||||
|
||||
camera.updateMatrixWorld()
|
||||
keyboardPanScreenRight.setFromMatrixColumn(camera.matrixWorld, 0)
|
||||
keyboardPanScreenRight.y = 0
|
||||
keyboardPanScreenUp.setFromMatrixColumn(camera.matrixWorld, 1)
|
||||
keyboardPanScreenUp.y = 0
|
||||
|
||||
if (keyboardPanScreenRight.lengthSq() < 1e-6) {
|
||||
keyboardPanScreenRight.set(1, 0, 0)
|
||||
} else {
|
||||
keyboardPanScreenRight.normalize()
|
||||
}
|
||||
|
||||
if (keyboardPanScreenUp.lengthSq() < 1e-6) {
|
||||
keyboardPanScreenUp.copy(keyboardPanTarget).sub(keyboardPanPosition)
|
||||
keyboardPanScreenUp.y = 0
|
||||
if (keyboardPanScreenUp.lengthSq() < 1e-6) {
|
||||
keyboardPanScreenUp.set(0, 0, -1)
|
||||
} else {
|
||||
keyboardPanScreenUp.normalize()
|
||||
}
|
||||
} else {
|
||||
keyboardPanScreenUp.normalize()
|
||||
}
|
||||
|
||||
control.getSpherical(keyboardPanSpherical, false)
|
||||
const viewWidth = getCameraViewWidth(camera, keyboardPanSpherical.radius, viewportSize)
|
||||
const speed = Math.min(
|
||||
Math.max(viewWidth * KEYBOARD_PAN_VIEW_WIDTH_PER_SECOND, KEYBOARD_PAN_MIN_SPEED),
|
||||
KEYBOARD_PAN_MAX_SPEED,
|
||||
)
|
||||
const step = (speed * Math.min(delta, 0.05)) / Math.hypot(horizontal, vertical)
|
||||
|
||||
keyboardPanDelta
|
||||
.set(0, 0, 0)
|
||||
.addScaledVector(keyboardPanScreenRight, horizontal * step)
|
||||
.addScaledVector(keyboardPanScreenUp, vertical * step)
|
||||
|
||||
pendingFloorplanNavigationPose.current = null
|
||||
control.setLookAt(
|
||||
keyboardPanPosition.x + keyboardPanDelta.x,
|
||||
keyboardPanPosition.y,
|
||||
keyboardPanPosition.z + keyboardPanDelta.z,
|
||||
keyboardPanTarget.x + keyboardPanDelta.x,
|
||||
keyboardPanTarget.y,
|
||||
keyboardPanTarget.z + keyboardPanDelta.z,
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
// Configure mouse buttons based on control mode and camera mode
|
||||
const mouseButtons = useMemo(() => {
|
||||
// Use ZOOM for orthographic camera, DOLLY for perspective camera
|
||||
@@ -496,6 +607,13 @@ export const CustomCameraControls = () => {
|
||||
let panPointerId: number | null = null
|
||||
let panPointerButton: number | null = null
|
||||
|
||||
const clearKeyboardPanKeys = () => {
|
||||
keyboardPanKeys.current.forward = false
|
||||
keyboardPanKeys.current.backward = false
|
||||
keyboardPanKeys.current.left = false
|
||||
keyboardPanKeys.current.right = false
|
||||
}
|
||||
|
||||
const setNavigationCursor = (cursor: 'grab' | 'grabbing') => {
|
||||
document.body.style.cursor = cursor
|
||||
gl.domElement.style.cursor = cursor
|
||||
@@ -557,6 +675,19 @@ export const CustomCameraControls = () => {
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (isKeyboardPanKey(event.code)) {
|
||||
if (
|
||||
!(event.metaKey || event.ctrlKey || event.altKey) &&
|
||||
!isEditableKeyboardTarget(event.target)
|
||||
) {
|
||||
setKeyboardPanKey(keyboardPanKeys.current, event.code, true)
|
||||
pendingFloorplanNavigationPose.current = null
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.code === 'Space') {
|
||||
if (isEditableKeyboardTarget(event.target)) return
|
||||
event.preventDefault()
|
||||
@@ -579,6 +710,15 @@ export const CustomCameraControls = () => {
|
||||
}
|
||||
|
||||
const onKeyUp = (event: KeyboardEvent) => {
|
||||
if (isKeyboardPanKey(event.code)) {
|
||||
const changed = setKeyboardPanKey(keyboardPanKeys.current, event.code, false)
|
||||
if (changed) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.code === 'Space') {
|
||||
keyState.space = false
|
||||
if (panPointerButton === 0) {
|
||||
@@ -628,6 +768,7 @@ export const CustomCameraControls = () => {
|
||||
|
||||
const onBlur = () => {
|
||||
keyState.space = false
|
||||
clearKeyboardPanKeys()
|
||||
panPointerId = null
|
||||
panPointerButton = null
|
||||
clearNavigationCursor()
|
||||
@@ -651,6 +792,7 @@ export const CustomCameraControls = () => {
|
||||
window.removeEventListener('pointercancel', onPointerUp, true)
|
||||
window.removeEventListener('blur', onBlur)
|
||||
gl.domElement.removeEventListener('wheel', onWheel)
|
||||
clearKeyboardPanKeys()
|
||||
clearNavigationCursor()
|
||||
}
|
||||
}, [cameraMode, gl, isPreviewMode, isFirstPersonMode])
|
||||
|
||||
@@ -141,6 +141,7 @@ import {
|
||||
} from '../tools/wall/wall-drafting'
|
||||
|
||||
import { PALETTE_COLORS } from '../ui/primitives/color-dot'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip'
|
||||
import { resolveFloorplanBackgroundSelection } from './floorplan-background-selection'
|
||||
import { useFloorplanBackgroundPlacement } from './use-floorplan-background-placement'
|
||||
import { useFloorplanHitTesting } from './use-floorplan-hit-testing'
|
||||
@@ -427,6 +428,47 @@ type GuideHandleHintAnchor = {
|
||||
directionY: number
|
||||
}
|
||||
|
||||
function FloorplanCompassButton({
|
||||
northRotationDeg,
|
||||
onAlignNorth,
|
||||
}: {
|
||||
northRotationDeg: number
|
||||
onAlignNorth: () => void
|
||||
}) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
aria-label="Align view to north"
|
||||
className="group absolute bottom-3 left-3 z-30 flex h-8 w-8 items-center justify-center rounded-full border border-black/10 bg-white/85 shadow-sm backdrop-blur-md transition hover:bg-white hover:shadow-md dark:border-white/10 dark:bg-neutral-900/85 dark:hover:bg-neutral-900"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onAlignNorth()
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
event.stopPropagation()
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span className="relative flex h-6 w-6 items-center justify-center rounded-full bg-[#b8b8b8] shadow-inner dark:bg-neutral-700">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="h-6 w-6 transition-transform duration-150 ease-out"
|
||||
style={{ transform: `rotate(${northRotationDeg}deg)` }}
|
||||
viewBox="0 0 48 48"
|
||||
>
|
||||
<path d="M24 4.5 31.5 25 24 21.5 16.5 25Z" fill="#f15b5b" />
|
||||
<path d="M24 43.5 16.5 23 24 26.5 31.5 23Z" fill="#ffffff" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">Align view to north</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
type GuideInteractionState = {
|
||||
pointerId: number
|
||||
guideId: GuideNode['id']
|
||||
@@ -6616,6 +6658,28 @@ export function FloorplanPanel() {
|
||||
[buildingPosition, buildingRotationY, floorplanGridWorldY],
|
||||
)
|
||||
|
||||
const alignFloorplanViewToNorth = useCallback(() => {
|
||||
const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current
|
||||
if (!currentViewport) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentUserRotationDeg = latestFloorplanUserRotationDegRef.current
|
||||
const currentSceneRotationDeg =
|
||||
FLOORPLAN_VIEW_ROTATION_DEG + currentUserRotationDeg - buildingRotationDeg
|
||||
const localCenter = rotateSvgPoint(
|
||||
{
|
||||
x: currentViewport.centerX,
|
||||
y: currentViewport.centerY,
|
||||
},
|
||||
-currentSceneRotationDeg,
|
||||
)
|
||||
const nextUserRotationDeg = nearestEquivalentDegrees(0, currentUserRotationDeg)
|
||||
|
||||
smoothFloorplanNavigationView(localCenter, nextUserRotationDeg, currentViewport.width)
|
||||
publishFloorplanNavigationPose(localCenter, nextUserRotationDeg, currentViewport.width)
|
||||
}, [buildingRotationDeg, publishFloorplanNavigationPose, smoothFloorplanNavigationView])
|
||||
|
||||
const clearGuideInteraction = useCallback(() => {
|
||||
guideInteractionRef.current = null
|
||||
guideTransformDraftRef.current = null
|
||||
@@ -9744,6 +9808,13 @@ export function FloorplanPanel() {
|
||||
only action menu the floor plan mounts. */}
|
||||
<FloorplanRegistryActionMenu />
|
||||
|
||||
{(levelNode?.type === 'level' || hasAmbientBuildingLevel) && (
|
||||
<FloorplanCompassButton
|
||||
northRotationDeg={-floorplanUserRotationDeg}
|
||||
onAlignNorth={alignFloorplanViewToNorth}
|
||||
/>
|
||||
)}
|
||||
|
||||
{referenceScaleDraft && (
|
||||
<div className="pointer-events-none absolute top-3 left-1/2 z-30 -translate-x-1/2 rounded-md border bg-background/95 px-3 py-2 text-center text-sm shadow-sm">
|
||||
{referenceScaleDraft.start
|
||||
|
||||
@@ -816,6 +816,8 @@ const ViewerCanvas = memo(function ViewerCanvas({
|
||||
isStudioMode,
|
||||
hasLoadedInitialScene,
|
||||
showLoader,
|
||||
sceneReadyKey,
|
||||
onSceneReadyChange,
|
||||
onThumbnailCapture,
|
||||
}: {
|
||||
isVersionPreviewMode: boolean
|
||||
@@ -824,6 +826,8 @@ const ViewerCanvas = memo(function ViewerCanvas({
|
||||
isStudioMode: boolean
|
||||
hasLoadedInitialScene: boolean
|
||||
showLoader: boolean
|
||||
sceneReadyKey: number
|
||||
onSceneReadyChange: (ready: boolean) => void
|
||||
onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
|
||||
}) {
|
||||
const viewMode = useEditor((s) => s.viewMode)
|
||||
@@ -927,12 +931,14 @@ const ViewerCanvas = memo(function ViewerCanvas({
|
||||
<Viewer
|
||||
defaultRender={EDITOR_DEFAULT_RENDER}
|
||||
hoverStyles={EDITOR_HOVER_STYLES}
|
||||
onSceneReadyChange={onSceneReadyChange}
|
||||
renderContext="editor"
|
||||
sceneReadyKey={sceneReadyKey}
|
||||
selectionManager={isFirstPersonMode ? 'default' : 'custom'}
|
||||
>
|
||||
<ViewerSceneContent
|
||||
isFirstPersonMode={isFirstPersonMode}
|
||||
isLoading={isLoading}
|
||||
isLoading={showLoader}
|
||||
isStudioMode={isStudioMode}
|
||||
isVersionPreviewMode={isVersionPreviewMode}
|
||||
onThumbnailCapture={onThumbnailCapture}
|
||||
@@ -940,7 +946,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
|
||||
</Viewer>
|
||||
</div>
|
||||
</div>
|
||||
{!(isLoading || isVersionPreviewMode) && <ZoneLabelEditorSystem />}
|
||||
{!(showLoader || isVersionPreviewMode) && <ZoneLabelEditorSystem />}
|
||||
</ErrorBoundary>
|
||||
)
|
||||
})
|
||||
@@ -984,6 +990,8 @@ export default function Editor({
|
||||
|
||||
const [isSceneLoading, setIsSceneLoading] = useState(false)
|
||||
const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false)
|
||||
const [sceneReadyKey, setSceneReadyKey] = useState(0)
|
||||
const [isViewerSceneReady, setIsViewerSceneReady] = useState(false)
|
||||
const isPreviewMode = useEditor((s) => s.isPreviewMode)
|
||||
const isCaptureMode = useEditor((s) => s.isCaptureMode)
|
||||
|
||||
@@ -1010,15 +1018,24 @@ export default function Editor({
|
||||
async function load() {
|
||||
isLoadingSceneRef.current = true
|
||||
setHasLoadedInitialScene(false)
|
||||
setIsViewerSceneReady(false)
|
||||
setIsSceneLoading(true)
|
||||
useScene.getState().unloadScene()
|
||||
useViewer.getState().resetSelection()
|
||||
|
||||
try {
|
||||
const sceneGraph = onLoad ? await onLoad() : loadSceneFromLocalStorage()
|
||||
if (!cancelled) {
|
||||
applySceneGraphToEditor(sceneGraph)
|
||||
setIsViewerSceneReady(false)
|
||||
setSceneReadyKey((key) => key + 1)
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) applySceneGraphToEditor(null)
|
||||
if (!cancelled) {
|
||||
applySceneGraphToEditor(null)
|
||||
setIsViewerSceneReady(false)
|
||||
setSceneReadyKey((key) => key + 1)
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsSceneLoading(false)
|
||||
@@ -1062,7 +1079,11 @@ export default function Editor({
|
||||
}
|
||||
}, [])
|
||||
|
||||
const showLoader = isLoading || isSceneLoading
|
||||
const handleSceneReadyChange = useCallback((ready: boolean) => {
|
||||
setIsViewerSceneReady(ready)
|
||||
}, [])
|
||||
|
||||
const showLoader = isLoading || isSceneLoading || !hasLoadedInitialScene || !isViewerSceneReady
|
||||
|
||||
const firstPersonPreviousLevelRef = useRef(useViewer.getState().selection.levelId)
|
||||
const wasFirstPersonModeRef = useRef(isFirstPersonMode)
|
||||
@@ -1125,7 +1146,9 @@ export default function Editor({
|
||||
isLoading={isLoading}
|
||||
isStudioMode={isStudioMode}
|
||||
isVersionPreviewMode={isVersionPreviewMode}
|
||||
onSceneReadyChange={handleSceneReadyChange}
|
||||
onThumbnailCapture={onThumbnailCapture}
|
||||
sceneReadyKey={sceneReadyKey}
|
||||
showLoader={showLoader}
|
||||
/>
|
||||
)
|
||||
@@ -1162,7 +1185,7 @@ export default function Editor({
|
||||
<>
|
||||
{showLoader && (
|
||||
<div className="fixed inset-0 z-60">
|
||||
<SceneLoader />
|
||||
<SceneLoader className="bg-background" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1227,7 +1250,7 @@ export default function Editor({
|
||||
<div className="dark flex h-full w-full gap-3 bg-neutral-100 p-3 text-foreground">
|
||||
{showLoader && (
|
||||
<div className="fixed inset-0 z-60">
|
||||
<SceneLoader />
|
||||
<SceneLoader className="bg-background" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -4,9 +4,25 @@ import type { SiteNode } from '@pascal-app/core'
|
||||
import { sceneRegistry, useScene } from '@pascal-app/core'
|
||||
import { getSceneTheme, useViewer } from '@pascal-app/viewer'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { createPortal, useFrame } from '@react-three/fiber'
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import type { Object3D } from 'three'
|
||||
import { createPortal, useFrame, useThree } from '@react-three/fiber'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { type Camera, type Object3D, Vector3 } from 'three'
|
||||
|
||||
type ViewportSize = {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
const htmlPosition = new Vector3()
|
||||
|
||||
function calculateHtmlPosition(el: Object3D, camera: Camera, size: ViewportSize) {
|
||||
htmlPosition.setFromMatrixPosition(el.matrixWorld)
|
||||
htmlPosition.project(camera)
|
||||
|
||||
const widthHalf = size.width / 2
|
||||
const heightHalf = size.height / 2
|
||||
return [htmlPosition.x * widthHalf + widthHalf, -htmlPosition.y * heightHalf + heightHalf]
|
||||
}
|
||||
|
||||
function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
|
||||
if (unit === 'imperial') {
|
||||
@@ -30,7 +46,14 @@ export function SiteEdgeLabels() {
|
||||
return node?.type === 'site' ? (node as SiteNode) : null
|
||||
})
|
||||
const unit = useViewer((state) => state.unit)
|
||||
const cameraMode = useViewer((state) => state.cameraMode)
|
||||
const isNight = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
|
||||
const camera = useThree((state) => state.camera)
|
||||
// Drei Html can hold the previous default camera across a camera-object swap.
|
||||
const calculateLabelPosition = useCallback(
|
||||
(el: Object3D, _camera: Camera, size: ViewportSize) => calculateHtmlPosition(el, camera, size),
|
||||
[camera],
|
||||
)
|
||||
|
||||
const siteNodeId = siteNode?.id
|
||||
|
||||
@@ -72,7 +95,8 @@ export function SiteEdgeLabels() {
|
||||
{edges.map((edge, i) => (
|
||||
<Html
|
||||
center
|
||||
key={`edge-${i}`}
|
||||
calculatePosition={calculateLabelPosition}
|
||||
key={`${cameraMode}-${camera.uuid}-edge-${i}`}
|
||||
occlude
|
||||
position={[edge.midX, 0.5, edge.midZ]}
|
||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||
|
||||
@@ -51,7 +51,7 @@ const controls: ControlConfig[] = [
|
||||
id: 'delete',
|
||||
icon: Trash2,
|
||||
label: 'Delete',
|
||||
shortcut: 'D',
|
||||
shortcut: 'X',
|
||||
color: 'hover:bg-red-500/20 hover:text-red-400',
|
||||
activeColor: 'bg-red-500/20 text-red-400',
|
||||
},
|
||||
|
||||
@@ -17,15 +17,13 @@ interface SceneLoaderProps {
|
||||
}
|
||||
|
||||
export function SceneLoader({ className, fullScreen = false }: SceneLoaderProps) {
|
||||
const [loaderClass, setLoaderClass] = useState<string | null>(null)
|
||||
const [loaderClass, setLoaderClass] = useState(LOADERS[0]!)
|
||||
|
||||
useEffect(() => {
|
||||
// Pick a random loader on mount
|
||||
setLoaderClass(LOADERS[Math.floor(Math.random() * LOADERS.length)] ?? LOADERS[0]!)
|
||||
}, [])
|
||||
|
||||
if (!loaderClass) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
+6
-1
@@ -37,7 +37,6 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
|
||||
{ keys: ['1'], action: 'Switch to Site phase' },
|
||||
{ keys: ['2'], action: 'Switch to Structure phase' },
|
||||
{ keys: ['3'], action: 'Switch to Furnish phase' },
|
||||
{ keys: ['S'], action: 'Switch to Structure layer' },
|
||||
{ keys: ['F'], action: 'Switch to Furnish layer' },
|
||||
{ keys: ['Z'], action: 'Switch to Zones layer' },
|
||||
{
|
||||
@@ -56,6 +55,7 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
|
||||
shortcuts: [
|
||||
{ keys: ['V'], action: 'Switch to Select mode' },
|
||||
{ keys: ['B'], action: 'Switch to Build mode' },
|
||||
{ keys: ['X'], action: 'Switch to Delete mode' },
|
||||
{
|
||||
keys: ['Esc'],
|
||||
action: 'Cancel the active tool and return to Select mode',
|
||||
@@ -100,6 +100,11 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
|
||||
{
|
||||
title: 'Camera',
|
||||
shortcuts: [
|
||||
{
|
||||
keys: ['W', 'A', 'S', 'D'],
|
||||
action: 'Pan camera',
|
||||
note: 'Moves in screen space, similar to dragging the camera view.',
|
||||
},
|
||||
{
|
||||
keys: ['Middle click'],
|
||||
action: 'Pan camera',
|
||||
|
||||
@@ -99,7 +99,7 @@ export const useKeyboard = ({
|
||||
useEditor.getState().setPhase('structure')
|
||||
useEditor.getState().setStructureLayer('elements')
|
||||
useEditor.getState().setMode('build')
|
||||
} else if (e.key === 'd' && !e.metaKey && !e.ctrlKey) {
|
||||
} else if (e.key === 'x' && !e.metaKey && !e.ctrlKey) {
|
||||
if (isVersionPreviewMode) return
|
||||
e.preventDefault()
|
||||
useEditor.getState().setMode('delete')
|
||||
|
||||
Reference in New Issue
Block a user