Polish editor interaction behavior
This commit is contained in:
@@ -40,6 +40,7 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { type ReactNode, useCallback } from 'react'
|
import { type ReactNode, useCallback } from 'react'
|
||||||
|
import { flushSync } from 'react-dom'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from './toolbar-tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from './toolbar-tooltip'
|
||||||
|
|
||||||
@@ -49,6 +50,24 @@ const TOOLBAR_CONTAINER =
|
|||||||
const TOOLBAR_BTN =
|
const TOOLBAR_BTN =
|
||||||
'flex w-8 items-center justify-center text-muted-foreground/80 transition-colors hover:bg-white/8 hover:text-foreground/90'
|
'flex w-8 items-center justify-center text-muted-foreground/80 transition-colors hover:bg-white/8 hover:text-foreground/90'
|
||||||
|
|
||||||
|
function requestWalkthroughPointerLock() {
|
||||||
|
const canvas = document.querySelector<HTMLCanvasElement>('[data-pascal-viewer-3d] canvas')
|
||||||
|
if (!canvas) return
|
||||||
|
|
||||||
|
if (!canvas.hasAttribute('tabindex')) {
|
||||||
|
canvas.tabIndex = -1
|
||||||
|
}
|
||||||
|
canvas.focus({ preventScroll: true })
|
||||||
|
|
||||||
|
if (document.pointerLockElement === canvas) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
canvas.requestPointerLock?.()
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function ToolbarTooltip({ children, label }: { children: ReactNode; label: string }) {
|
function ToolbarTooltip({ children, label }: { children: ReactNode; label: string }) {
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -441,6 +460,15 @@ function DisplayMenu() {
|
|||||||
function WalkthroughButton() {
|
function WalkthroughButton() {
|
||||||
const isFirstPersonMode = useEditor((state) => state.isFirstPersonMode)
|
const isFirstPersonMode = useEditor((state) => state.isFirstPersonMode)
|
||||||
const setFirstPersonMode = useEditor((state) => state.setFirstPersonMode)
|
const setFirstPersonMode = useEditor((state) => state.setFirstPersonMode)
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
if (isFirstPersonMode) {
|
||||||
|
setFirstPersonMode(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
flushSync(() => setFirstPersonMode(true))
|
||||||
|
requestWalkthroughPointerLock()
|
||||||
|
}, [isFirstPersonMode, setFirstPersonMode])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToolbarTooltip label="Walkthrough">
|
<ToolbarTooltip label="Walkthrough">
|
||||||
@@ -449,7 +477,7 @@ function WalkthroughButton() {
|
|||||||
TOOLBAR_BTN,
|
TOOLBAR_BTN,
|
||||||
isFirstPersonMode && 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/20',
|
isFirstPersonMode && 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/20',
|
||||||
)}
|
)}
|
||||||
onClick={() => setFirstPersonMode(!isFirstPersonMode)}
|
onClick={handleClick}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Footprints className="h-4 w-4" />
|
<Footprints className="h-4 w-4" />
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap'
|
import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
|
import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state'
|
||||||
import { useFloorplanRender } from '../floorplan-render-context'
|
import { useFloorplanRender } from '../floorplan-render-context'
|
||||||
import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
|
import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
|
||||||
|
|
||||||
@@ -493,6 +494,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|||||||
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
suppressBoxSelectForPointer(event)
|
||||||
|
|
||||||
const session = handler.start({
|
const session = handler.start({
|
||||||
node,
|
node,
|
||||||
@@ -769,6 +771,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|||||||
if (!node) return
|
if (!node) return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
suppressBoxSelectForPointer(event)
|
||||||
sfxEmitter.emit('sfx:item-pick')
|
sfxEmitter.emit('sfx:item-pick')
|
||||||
setMovingNode(node as never)
|
setMovingNode(node as never)
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ import {
|
|||||||
type FirstPersonColliderWorld,
|
type FirstPersonColliderWorld,
|
||||||
type FirstPersonSpawn,
|
type FirstPersonSpawn,
|
||||||
} from './first-person/build-collider-world'
|
} from './first-person/build-collider-world'
|
||||||
import type { BVHEcctrlApi } from './first-person/bvh-ecctrl'
|
import type { BVHEcctrlApi, MovementInput } from './first-person/bvh-ecctrl'
|
||||||
import BVHEcctrl from './first-person/bvh-ecctrl'
|
import BVHEcctrl from './first-person/bvh-ecctrl'
|
||||||
|
|
||||||
const CAMERA_EYE_OFFSET = 0.45
|
const CAMERA_EYE_OFFSET = 0.45
|
||||||
@@ -79,7 +79,10 @@ const ELEVATOR_COLLIDER_DOOR_DEPTH = 0.12
|
|||||||
const ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD = 0.72
|
const ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD = 0.72
|
||||||
const DEFAULT_ELEVATOR_LEVEL_HEIGHT = 2.5
|
const DEFAULT_ELEVATOR_LEVEL_HEIGHT = 2.5
|
||||||
const VOID_FALL_RESPAWN_DEPTH = 12
|
const VOID_FALL_RESPAWN_DEPTH = 12
|
||||||
const keyboardMap = [
|
|
||||||
|
type MovementKeyName = Exclude<keyof MovementInput, 'joystick'>
|
||||||
|
|
||||||
|
const movementKeyboardBindings: Array<{ name: MovementKeyName; keys: string[] }> = [
|
||||||
{ name: 'forward', keys: ['ArrowUp', 'KeyW'] },
|
{ name: 'forward', keys: ['ArrowUp', 'KeyW'] },
|
||||||
{ name: 'backward', keys: ['ArrowDown', 'KeyS'] },
|
{ name: 'backward', keys: ['ArrowDown', 'KeyS'] },
|
||||||
{ name: 'leftward', keys: ['ArrowLeft', 'KeyA'] },
|
{ name: 'leftward', keys: ['ArrowLeft', 'KeyA'] },
|
||||||
@@ -87,6 +90,36 @@ const keyboardMap = [
|
|||||||
{ name: 'jump', keys: ['Space'] },
|
{ name: 'jump', keys: ['Space'] },
|
||||||
{ name: 'run', keys: ['ShiftLeft', 'ShiftRight'] },
|
{ name: 'run', keys: ['ShiftLeft', 'ShiftRight'] },
|
||||||
]
|
]
|
||||||
|
const keyboardMap = movementKeyboardBindings
|
||||||
|
const movementKeyToName = new Map<string, MovementKeyName>(
|
||||||
|
movementKeyboardBindings.flatMap(({ name, keys }) => keys.map((key) => [key, name] as const)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const inactiveMovementInput: MovementInput = {
|
||||||
|
backward: false,
|
||||||
|
forward: false,
|
||||||
|
jump: false,
|
||||||
|
leftward: false,
|
||||||
|
rightward: false,
|
||||||
|
run: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMovementInputForKey(code: string, active: boolean): MovementInput | null {
|
||||||
|
const name = movementKeyToName.get(code)
|
||||||
|
return name ? ({ [name]: active } as MovementInput) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusFirstPersonCanvas(canvas: HTMLCanvasElement) {
|
||||||
|
const activeElement = document.activeElement
|
||||||
|
if (activeElement instanceof HTMLElement && !canvas.contains(activeElement)) {
|
||||||
|
activeElement.blur()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canvas.hasAttribute('tabindex')) {
|
||||||
|
canvas.tabIndex = -1
|
||||||
|
}
|
||||||
|
canvas.focus({ preventScroll: true })
|
||||||
|
}
|
||||||
|
|
||||||
const cameraOffset = new Vector3(0, CAMERA_EYE_OFFSET, 0)
|
const cameraOffset = new Vector3(0, CAMERA_EYE_OFFSET, 0)
|
||||||
const cameraEuler = new Euler(0, 0, 0, 'YXZ')
|
const cameraEuler = new Euler(0, 0, 0, 'YXZ')
|
||||||
@@ -536,6 +569,8 @@ export const FirstPersonControls = () => {
|
|||||||
const selectedLevelId = useViewer((state) => state.selection.levelId)
|
const selectedLevelId = useViewer((state) => state.selection.levelId)
|
||||||
const placedSpawnNode = useScene((state) => resolvePlacedSpawnNode(state.nodes, selectedLevelId))
|
const placedSpawnNode = useScene((state) => resolvePlacedSpawnNode(state.nodes, selectedLevelId))
|
||||||
const controllerRef = useRef<BVHEcctrlApi | null>(null)
|
const controllerRef = useRef<BVHEcctrlApi | null>(null)
|
||||||
|
const movementInputRef = useRef<MovementInput>({ ...inactiveMovementInput })
|
||||||
|
const hadPointerLockRef = useRef(false)
|
||||||
const yawRef = useRef(0)
|
const yawRef = useRef(0)
|
||||||
const pitchRef = useRef(0)
|
const pitchRef = useRef(0)
|
||||||
const interactableTargetRef = useRef<FirstPersonInteractableTarget | null>(null)
|
const interactableTargetRef = useRef<FirstPersonInteractableTarget | null>(null)
|
||||||
@@ -578,6 +613,13 @@ export const FirstPersonControls = () => {
|
|||||||
setIsElevatorRideLocked(locked)
|
setIsElevatorRideLocked(locked)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const setControllerApi = useCallback((api: BVHEcctrlApi | null) => {
|
||||||
|
controllerRef.current = api
|
||||||
|
if (api) {
|
||||||
|
api.setMovement(movementInputRef.current)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const resolveInteractableDoorId = useCallback((): AnyNodeId | null => {
|
const resolveInteractableDoorId = useCallback((): AnyNodeId | null => {
|
||||||
const nodes = useScene.getState().nodes
|
const nodes = useScene.getState().nodes
|
||||||
camera.updateMatrixWorld(true)
|
camera.updateMatrixWorld(true)
|
||||||
@@ -916,6 +958,14 @@ export const FirstPersonControls = () => {
|
|||||||
})
|
})
|
||||||
}, [camera, controllerStart, placedSpawn, world])
|
}, [camera, controllerStart, placedSpawn, world])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = gl.domElement
|
||||||
|
focusFirstPersonCanvas(canvas)
|
||||||
|
|
||||||
|
const frame = window.requestAnimationFrame(() => focusFirstPersonCanvas(canvas))
|
||||||
|
return () => window.cancelAnimationFrame(frame)
|
||||||
|
}, [gl])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = gl.domElement
|
const canvas = gl.domElement
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
@@ -946,14 +996,29 @@ export const FirstPersonControls = () => {
|
|||||||
toggleInteractableTarget()
|
toggleInteractableTarget()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handlePointerLockChange = () => {
|
||||||
|
const isLocked = document.pointerLockElement === canvas
|
||||||
|
if (isLocked) {
|
||||||
|
hadPointerLockRef.current = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hadPointerLockRef.current && useEditor.getState().isFirstPersonMode) {
|
||||||
|
useEditor.getState().setFirstPersonMode(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePointerLockChange()
|
||||||
document.addEventListener('mousemove', handleMouseMove)
|
document.addEventListener('mousemove', handleMouseMove)
|
||||||
document.addEventListener('click', handleClick)
|
document.addEventListener('click', handleClick)
|
||||||
document.addEventListener('mousedown', handleMouseDown, true)
|
document.addEventListener('mousedown', handleMouseDown, true)
|
||||||
|
document.addEventListener('pointerlockchange', handlePointerLockChange)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('mousemove', handleMouseMove)
|
document.removeEventListener('mousemove', handleMouseMove)
|
||||||
document.removeEventListener('click', handleClick)
|
document.removeEventListener('click', handleClick)
|
||||||
document.removeEventListener('mousedown', handleMouseDown, true)
|
document.removeEventListener('mousedown', handleMouseDown, true)
|
||||||
|
document.removeEventListener('pointerlockchange', handlePointerLockChange)
|
||||||
if (document.pointerLockElement === canvas) {
|
if (document.pointerLockElement === canvas) {
|
||||||
document.exitPointerLock()
|
document.exitPointerLock()
|
||||||
}
|
}
|
||||||
@@ -963,7 +1028,24 @@ export const FirstPersonControls = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = gl.domElement
|
const canvas = gl.domElement
|
||||||
|
|
||||||
|
const applyMovementKey = (event: KeyboardEvent, active: boolean) => {
|
||||||
|
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const movement = getMovementInputForKey(event.code, active)
|
||||||
|
if (!movement) return false
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
Object.assign(movementInputRef.current, movement)
|
||||||
|
controllerRef.current?.setMovement(movement)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
const handledMovement = applyMovementKey(event, true)
|
||||||
|
if (handledMovement) return
|
||||||
|
|
||||||
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
|
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -986,9 +1068,15 @@ export const FirstPersonControls = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleKeyUp = (event: KeyboardEvent) => {
|
||||||
|
applyMovementKey(event, false)
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('keydown', handleKeyDown, true)
|
document.addEventListener('keydown', handleKeyDown, true)
|
||||||
|
document.addEventListener('keyup', handleKeyUp, true)
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('keydown', handleKeyDown, true)
|
document.removeEventListener('keydown', handleKeyDown, true)
|
||||||
|
document.removeEventListener('keyup', handleKeyUp, true)
|
||||||
}
|
}
|
||||||
}, [closeInteractableTarget, gl, toggleInteractableTarget])
|
}, [closeInteractableTarget, gl, toggleInteractableTarget])
|
||||||
|
|
||||||
@@ -1308,7 +1396,7 @@ export const FirstPersonControls = () => {
|
|||||||
maxWalkSpeed={4}
|
maxWalkSpeed={4}
|
||||||
paused={isElevatorRideLocked}
|
paused={isElevatorRideLocked}
|
||||||
position={controllerStart.position}
|
position={controllerStart.position}
|
||||||
ref={controllerRef}
|
ref={setControllerApi}
|
||||||
/>
|
/>
|
||||||
</KeyboardControls>
|
</KeyboardControls>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -887,6 +887,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
|
|||||||
{/* 3D viewer — always mounted, hidden via CSS to avoid destroying the WebGL context */}
|
{/* 3D viewer — always mounted, hidden via CSS to avoid destroying the WebGL context */}
|
||||||
<div
|
<div
|
||||||
className="relative min-w-0 flex-1 overflow-hidden"
|
className="relative min-w-0 flex-1 overflow-hidden"
|
||||||
|
data-pascal-viewer-3d
|
||||||
ref={viewer3dRef}
|
ref={viewer3dRef}
|
||||||
style={{ display: show3d ? undefined : 'none' }}
|
style={{ display: show3d ? undefined : 'none' }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import { EDITOR_LAYER } from '../../lib/constants'
|
|||||||
import { createEditorApi } from '../../lib/editor-api'
|
import { createEditorApi } from '../../lib/editor-api'
|
||||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||||
import useEditor from '../../store/use-editor'
|
import useEditor from '../../store/use-editor'
|
||||||
|
import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
|
||||||
import { formatAngleRadians } from '../tools/shared/segment-angle'
|
import { formatAngleRadians } from '../tools/shared/segment-angle'
|
||||||
import {
|
import {
|
||||||
ARROW_COLOR,
|
ARROW_COLOR,
|
||||||
@@ -1169,6 +1170,7 @@ function TranslateArrow({
|
|||||||
// 3D translate gizmo and the floating Move button behave identically.
|
// 3D translate gizmo and the floating Move button behave identically.
|
||||||
const activate = (event: ThreeEvent<PointerEvent>) => {
|
const activate = (event: ThreeEvent<PointerEvent>) => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
suppressBoxSelectForPointer(event)
|
||||||
sfxEmitter.emit('sfx:item-pick')
|
sfxEmitter.emit('sfx:item-pick')
|
||||||
useEditor.getState().setMovingNode(node as never)
|
useEditor.getState().setMovingNode(node as never)
|
||||||
useViewer.getState().setSelection({ selectedIds: [] })
|
useViewer.getState().setSelection({ selectedIds: [] })
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Icon } from '@iconify/react'
|
import { Icon } from '@iconify/react'
|
||||||
import { type LevelNode, useScene } from '@pascal-app/core'
|
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
|
||||||
import { type LucideIcon, Trash2 } from 'lucide-react'
|
import { type LucideIcon, Trash2 } from 'lucide-react'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { cn } from './../../../lib/utils'
|
import { cn } from './../../../lib/utils'
|
||||||
import useEditor, { selectSiteFloorplanContext } from './../../../store/use-editor'
|
import useEditor from './../../../store/use-editor'
|
||||||
import { ActionButton } from './action-button'
|
import { ActionButton } from './action-button'
|
||||||
|
|
||||||
type ControlId = 'select' | 'box-select' | 'site-edit' | 'zone' | 'delete'
|
type ControlId = 'select' | 'box-select' | 'zone' | 'delete'
|
||||||
|
|
||||||
type ControlConfig = {
|
type ControlConfig = {
|
||||||
id: ControlId
|
id: ControlId
|
||||||
@@ -32,13 +30,6 @@ const controls: ControlConfig[] = [
|
|||||||
color: 'hover:bg-blue-500/20 hover:text-blue-400',
|
color: 'hover:bg-blue-500/20 hover:text-blue-400',
|
||||||
activeColor: 'bg-blue-500/20 text-blue-400',
|
activeColor: 'bg-blue-500/20 text-blue-400',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'site-edit',
|
|
||||||
imageSrc: '/icons/site-flag.png',
|
|
||||||
label: 'Edit site',
|
|
||||||
color: 'hover:bg-white/5',
|
|
||||||
activeColor: 'bg-white/10 hover:bg-white/10',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'zone',
|
id: 'zone',
|
||||||
imageSrc: '/icons/zone.png',
|
imageSrc: '/icons/zone.png',
|
||||||
@@ -65,47 +56,20 @@ export function ControlModes() {
|
|||||||
const setPhase = useEditor((state) => state.setPhase)
|
const setPhase = useEditor((state) => state.setPhase)
|
||||||
const setStructureLayer = useEditor((state) => state.setStructureLayer)
|
const setStructureLayer = useEditor((state) => state.setStructureLayer)
|
||||||
const setSelectionTool = useEditor((state) => state.setFloorplanSelectionTool)
|
const setSelectionTool = useEditor((state) => state.setFloorplanSelectionTool)
|
||||||
const levelId = useViewer((s) => s.selection.levelId)
|
|
||||||
|
|
||||||
// Only subscribe to the primitive `level` number — when walls are added to
|
|
||||||
// this level the object ref changes but this number doesn't, so Object.is
|
|
||||||
// dedupes and we avoid a re-render.
|
|
||||||
const levelIndex = useScene((state) => {
|
|
||||||
if (!levelId) return null
|
|
||||||
const node = state.nodes[levelId]
|
|
||||||
return node?.type === 'level' ? (node as LevelNode).level : null
|
|
||||||
})
|
|
||||||
|
|
||||||
const isSiteEditing = phase === 'site'
|
const isSiteEditing = phase === 'site'
|
||||||
const isGroundFloor = levelIndex === 0
|
|
||||||
const canEnterSiteEdit = isGroundFloor || isSiteEditing
|
|
||||||
|
|
||||||
const structureLayer = useEditor((state) => state.structureLayer)
|
const structureLayer = useEditor((state) => state.structureLayer)
|
||||||
|
|
||||||
const getIsActive = (id: ControlId): boolean => {
|
const getIsActive = (id: ControlId): boolean => {
|
||||||
if (isSiteEditing) return id === 'site-edit'
|
|
||||||
if (id === 'select') return mode === 'select' && selectionTool === 'click'
|
if (id === 'select') return mode === 'select' && selectionTool === 'click'
|
||||||
if (id === 'box-select') return mode === 'select' && selectionTool === 'marquee'
|
if (id === 'box-select') return mode === 'select' && selectionTool === 'marquee'
|
||||||
if (id === 'site-edit') return false
|
|
||||||
if (id === 'zone')
|
if (id === 'zone')
|
||||||
return mode === 'build' && phase === 'structure' && structureLayer === 'zones'
|
return mode === 'build' && phase === 'structure' && structureLayer === 'zones'
|
||||||
return mode === id
|
return mode === id
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleClick = (id: ControlId) => {
|
const handleClick = (id: ControlId) => {
|
||||||
if (id === 'site-edit') {
|
|
||||||
if (isSiteEditing) {
|
|
||||||
// Toggle off → back to structure/select
|
|
||||||
setPhase('structure')
|
|
||||||
setMode('select')
|
|
||||||
setStructureLayer('elements')
|
|
||||||
} else if (isGroundFloor) {
|
|
||||||
useEditor.setState({ phase: 'site', mode: 'select', tool: null, catalogCategory: null })
|
|
||||||
selectSiteFloorplanContext()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exit site editing first if needed
|
// Exit site editing first if needed
|
||||||
if (isSiteEditing) {
|
if (isSiteEditing) {
|
||||||
setPhase('structure')
|
setPhase('structure')
|
||||||
@@ -136,36 +100,19 @@ export function ControlModes() {
|
|||||||
{controls.map((c) => {
|
{controls.map((c) => {
|
||||||
const ModeIcon = c.icon
|
const ModeIcon = c.icon
|
||||||
const isImageMode = Boolean(c.imageSrc)
|
const isImageMode = Boolean(c.imageSrc)
|
||||||
const isSiteButton = c.id === 'site-edit'
|
|
||||||
const isActive = getIsActive(c.id)
|
const isActive = getIsActive(c.id)
|
||||||
const isDisabled = isSiteButton && !canEnterSiteEdit
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
className={cn(
|
className={cn(
|
||||||
'group text-muted-foreground',
|
'group text-muted-foreground',
|
||||||
isSiteButton
|
!(isImageMode || isActive) && c.color,
|
||||||
? isActive
|
!isImageMode && isActive && c.activeColor,
|
||||||
? c.activeColor
|
isImageMode && isActive && 'bg-white/10 hover:bg-white/10',
|
||||||
: canEnterSiteEdit
|
isImageMode && !isActive && 'hover:bg-white/5',
|
||||||
? 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0'
|
|
||||||
: 'cursor-not-allowed opacity-35 grayscale'
|
|
||||||
: !(isImageMode || isActive) && c.color,
|
|
||||||
!(isSiteButton || isImageMode) && isActive && c.activeColor,
|
|
||||||
!isSiteButton && isImageMode && isActive && 'bg-white/10 hover:bg-white/10',
|
|
||||||
!isSiteButton && isImageMode && !isActive && 'hover:bg-white/5',
|
|
||||||
)}
|
)}
|
||||||
disabled={isDisabled}
|
|
||||||
key={c.id}
|
key={c.id}
|
||||||
label={
|
label={c.label}
|
||||||
isSiteButton
|
|
||||||
? isActive
|
|
||||||
? 'Exit site editing'
|
|
||||||
: canEnterSiteEdit
|
|
||||||
? 'Edit site'
|
|
||||||
: 'Site editing (ground level only)'
|
|
||||||
: c.label
|
|
||||||
}
|
|
||||||
onClick={() => handleClick(c.id)}
|
onClick={() => handleClick(c.id)}
|
||||||
shortcut={c.shortcut}
|
shortcut={c.shortcut}
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -176,11 +123,7 @@ export function ControlModes() {
|
|||||||
alt={c.label}
|
alt={c.label}
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-[28px] w-[28px] object-contain transition-[opacity,filter] duration-200',
|
'h-[28px] w-[28px] object-contain transition-[opacity,filter] duration-200',
|
||||||
isSiteButton
|
isActive
|
||||||
? isActive
|
|
||||||
? 'opacity-100 grayscale-0'
|
|
||||||
: ''
|
|
||||||
: isActive
|
|
||||||
? 'opacity-100 grayscale-0'
|
? 'opacity-100 grayscale-0'
|
||||||
: 'opacity-60 grayscale group-hover:opacity-100 group-hover:grayscale-0',
|
: 'opacity-60 grayscale group-hover:opacity-100 group-hover:grayscale-0',
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import { ActionButton } from './action-button'
|
|||||||
const MAX_FILE_SIZE = 200 * 1024 * 1024 // 200MB
|
const MAX_FILE_SIZE = 200 * 1024 * 1024 // 200MB
|
||||||
const ACCEPTED_FILE_TYPES = '.glb,.gltf,image/jpeg,image/png,image/webp,image/gif'
|
const ACCEPTED_FILE_TYPES = '.glb,.gltf,image/jpeg,image/png,image/webp,image/gif'
|
||||||
const GRID_SNAP_STEPS: GridSnapStep[] = [0.5, 0.25, 0.1, 0.05]
|
const GRID_SNAP_STEPS: GridSnapStep[] = [0.5, 0.25, 0.1, 0.05]
|
||||||
|
const REFERENCES_EMPTY_TEXT =
|
||||||
|
'Upload GLB meshes as scan references or blueprint images as guide references.'
|
||||||
|
|
||||||
function formatGridSnapStep(step: GridSnapStep) {
|
function formatGridSnapStep(step: GridSnapStep) {
|
||||||
return step.toFixed(2)
|
return step.toFixed(2)
|
||||||
@@ -342,7 +344,7 @@ function GuidesControl() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-xl border border-border/45 border-dashed bg-background/60 px-3 py-4 text-muted-foreground text-sm">
|
<div className="rounded-xl border border-border/45 border-dashed bg-background/60 px-3 py-4 text-muted-foreground text-sm">
|
||||||
No guide images on this level yet.
|
{REFERENCES_EMPTY_TEXT}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -581,7 +583,7 @@ function ScansControl() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-xl border border-border/45 border-dashed bg-background/60 px-3 py-4 text-muted-foreground text-sm">
|
<div className="rounded-xl border border-border/45 border-dashed bg-background/60 px-3 py-4 text-muted-foreground text-sm">
|
||||||
No scans on this level yet.
|
{REFERENCES_EMPTY_TEXT}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -805,7 +807,7 @@ function ReferencesControl() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ReferenceListSection
|
<ReferenceListSection
|
||||||
emptyText="No scans on this level yet."
|
emptyText={REFERENCES_EMPTY_TEXT}
|
||||||
iconSrc="/icons/mesh.png"
|
iconSrc="/icons/mesh.png"
|
||||||
nodes={scans}
|
nodes={scans}
|
||||||
noun="scan"
|
noun="scan"
|
||||||
@@ -816,7 +818,7 @@ function ReferencesControl() {
|
|||||||
/>
|
/>
|
||||||
<div className="h-px bg-border/45" />
|
<div className="h-px bg-border/45" />
|
||||||
<ReferenceListSection
|
<ReferenceListSection
|
||||||
emptyText="No guide images on this level yet."
|
emptyText={REFERENCES_EMPTY_TEXT}
|
||||||
iconSrc="/icons/floorplan.png"
|
iconSrc="/icons/floorplan.png"
|
||||||
nodes={guides}
|
nodes={guides}
|
||||||
noun="guide image"
|
noun="guide image"
|
||||||
|
|||||||
Reference in New Issue
Block a user