Polish editor interaction behavior
This commit is contained in:
@@ -32,6 +32,7 @@ import {
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state'
|
||||
import { useFloorplanRender } from '../floorplan-render-context'
|
||||
import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
|
||||
|
||||
@@ -493,6 +494,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
suppressBoxSelectForPointer(event)
|
||||
|
||||
const session = handler.start({
|
||||
node,
|
||||
@@ -769,6 +771,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
if (!node) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
suppressBoxSelectForPointer(event)
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
setMovingNode(node as never)
|
||||
}}
|
||||
|
||||
@@ -64,7 +64,7 @@ import {
|
||||
type FirstPersonColliderWorld,
|
||||
type FirstPersonSpawn,
|
||||
} 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'
|
||||
|
||||
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 DEFAULT_ELEVATOR_LEVEL_HEIGHT = 2.5
|
||||
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: 'backward', keys: ['ArrowDown', 'KeyS'] },
|
||||
{ name: 'leftward', keys: ['ArrowLeft', 'KeyA'] },
|
||||
@@ -87,6 +90,36 @@ const keyboardMap = [
|
||||
{ name: 'jump', keys: ['Space'] },
|
||||
{ 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 cameraEuler = new Euler(0, 0, 0, 'YXZ')
|
||||
@@ -536,6 +569,8 @@ export const FirstPersonControls = () => {
|
||||
const selectedLevelId = useViewer((state) => state.selection.levelId)
|
||||
const placedSpawnNode = useScene((state) => resolvePlacedSpawnNode(state.nodes, selectedLevelId))
|
||||
const controllerRef = useRef<BVHEcctrlApi | null>(null)
|
||||
const movementInputRef = useRef<MovementInput>({ ...inactiveMovementInput })
|
||||
const hadPointerLockRef = useRef(false)
|
||||
const yawRef = useRef(0)
|
||||
const pitchRef = useRef(0)
|
||||
const interactableTargetRef = useRef<FirstPersonInteractableTarget | null>(null)
|
||||
@@ -578,6 +613,13 @@ export const FirstPersonControls = () => {
|
||||
setIsElevatorRideLocked(locked)
|
||||
}, [])
|
||||
|
||||
const setControllerApi = useCallback((api: BVHEcctrlApi | null) => {
|
||||
controllerRef.current = api
|
||||
if (api) {
|
||||
api.setMovement(movementInputRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const resolveInteractableDoorId = useCallback((): AnyNodeId | null => {
|
||||
const nodes = useScene.getState().nodes
|
||||
camera.updateMatrixWorld(true)
|
||||
@@ -916,6 +958,14 @@ export const FirstPersonControls = () => {
|
||||
})
|
||||
}, [camera, controllerStart, placedSpawn, world])
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = gl.domElement
|
||||
focusFirstPersonCanvas(canvas)
|
||||
|
||||
const frame = window.requestAnimationFrame(() => focusFirstPersonCanvas(canvas))
|
||||
return () => window.cancelAnimationFrame(frame)
|
||||
}, [gl])
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = gl.domElement
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
@@ -946,14 +996,29 @@ export const FirstPersonControls = () => {
|
||||
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('click', handleClick)
|
||||
document.addEventListener('mousedown', handleMouseDown, true)
|
||||
document.addEventListener('pointerlockchange', handlePointerLockChange)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('click', handleClick)
|
||||
document.removeEventListener('mousedown', handleMouseDown, true)
|
||||
document.removeEventListener('pointerlockchange', handlePointerLockChange)
|
||||
if (document.pointerLockElement === canvas) {
|
||||
document.exitPointerLock()
|
||||
}
|
||||
@@ -963,7 +1028,24 @@ export const FirstPersonControls = () => {
|
||||
useEffect(() => {
|
||||
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 handledMovement = applyMovementKey(event, true)
|
||||
if (handledMovement) return
|
||||
|
||||
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
|
||||
return
|
||||
}
|
||||
@@ -986,9 +1068,15 @@ export const FirstPersonControls = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
applyMovementKey(event, false)
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown, true)
|
||||
document.addEventListener('keyup', handleKeyUp, true)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown, true)
|
||||
document.removeEventListener('keyup', handleKeyUp, true)
|
||||
}
|
||||
}, [closeInteractableTarget, gl, toggleInteractableTarget])
|
||||
|
||||
@@ -1308,7 +1396,7 @@ export const FirstPersonControls = () => {
|
||||
maxWalkSpeed={4}
|
||||
paused={isElevatorRideLocked}
|
||||
position={controllerStart.position}
|
||||
ref={controllerRef}
|
||||
ref={setControllerApi}
|
||||
/>
|
||||
</KeyboardControls>
|
||||
)}
|
||||
|
||||
@@ -887,6 +887,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
|
||||
{/* 3D viewer — always mounted, hidden via CSS to avoid destroying the WebGL context */}
|
||||
<div
|
||||
className="relative min-w-0 flex-1 overflow-hidden"
|
||||
data-pascal-viewer-3d
|
||||
ref={viewer3dRef}
|
||||
style={{ display: show3d ? undefined : 'none' }}
|
||||
>
|
||||
|
||||
@@ -43,6 +43,7 @@ import { EDITOR_LAYER } from '../../lib/constants'
|
||||
import { createEditorApi } from '../../lib/editor-api'
|
||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||
import useEditor from '../../store/use-editor'
|
||||
import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
|
||||
import { formatAngleRadians } from '../tools/shared/segment-angle'
|
||||
import {
|
||||
ARROW_COLOR,
|
||||
@@ -1169,6 +1170,7 @@ function TranslateArrow({
|
||||
// 3D translate gizmo and the floating Move button behave identically.
|
||||
const activate = (event: ThreeEvent<PointerEvent>) => {
|
||||
event.stopPropagation()
|
||||
suppressBoxSelectForPointer(event)
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
useEditor.getState().setMovingNode(node as never)
|
||||
useViewer.getState().setSelection({ selectedIds: [] })
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
'use client'
|
||||
|
||||
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 Image from 'next/image'
|
||||
import { cn } from './../../../lib/utils'
|
||||
import useEditor, { selectSiteFloorplanContext } from './../../../store/use-editor'
|
||||
import useEditor from './../../../store/use-editor'
|
||||
import { ActionButton } from './action-button'
|
||||
|
||||
type ControlId = 'select' | 'box-select' | 'site-edit' | 'zone' | 'delete'
|
||||
type ControlId = 'select' | 'box-select' | 'zone' | 'delete'
|
||||
|
||||
type ControlConfig = {
|
||||
id: ControlId
|
||||
@@ -32,13 +30,6 @@ const controls: ControlConfig[] = [
|
||||
color: 'hover:bg-blue-500/20 hover: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',
|
||||
imageSrc: '/icons/zone.png',
|
||||
@@ -65,47 +56,20 @@ export function ControlModes() {
|
||||
const setPhase = useEditor((state) => state.setPhase)
|
||||
const setStructureLayer = useEditor((state) => state.setStructureLayer)
|
||||
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 isGroundFloor = levelIndex === 0
|
||||
const canEnterSiteEdit = isGroundFloor || isSiteEditing
|
||||
|
||||
const structureLayer = useEditor((state) => state.structureLayer)
|
||||
|
||||
const getIsActive = (id: ControlId): boolean => {
|
||||
if (isSiteEditing) return id === 'site-edit'
|
||||
if (id === 'select') return mode === 'select' && selectionTool === 'click'
|
||||
if (id === 'box-select') return mode === 'select' && selectionTool === 'marquee'
|
||||
if (id === 'site-edit') return false
|
||||
if (id === 'zone')
|
||||
return mode === 'build' && phase === 'structure' && structureLayer === 'zones'
|
||||
return mode === id
|
||||
}
|
||||
|
||||
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
|
||||
if (isSiteEditing) {
|
||||
setPhase('structure')
|
||||
@@ -136,36 +100,19 @@ export function ControlModes() {
|
||||
{controls.map((c) => {
|
||||
const ModeIcon = c.icon
|
||||
const isImageMode = Boolean(c.imageSrc)
|
||||
const isSiteButton = c.id === 'site-edit'
|
||||
const isActive = getIsActive(c.id)
|
||||
const isDisabled = isSiteButton && !canEnterSiteEdit
|
||||
|
||||
return (
|
||||
<ActionButton
|
||||
className={cn(
|
||||
'group text-muted-foreground',
|
||||
isSiteButton
|
||||
? isActive
|
||||
? c.activeColor
|
||||
: canEnterSiteEdit
|
||||
? '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',
|
||||
!(isImageMode || isActive) && c.color,
|
||||
!isImageMode && isActive && c.activeColor,
|
||||
isImageMode && isActive && 'bg-white/10 hover:bg-white/10',
|
||||
isImageMode && !isActive && 'hover:bg-white/5',
|
||||
)}
|
||||
disabled={isDisabled}
|
||||
key={c.id}
|
||||
label={
|
||||
isSiteButton
|
||||
? isActive
|
||||
? 'Exit site editing'
|
||||
: canEnterSiteEdit
|
||||
? 'Edit site'
|
||||
: 'Site editing (ground level only)'
|
||||
: c.label
|
||||
}
|
||||
label={c.label}
|
||||
onClick={() => handleClick(c.id)}
|
||||
shortcut={c.shortcut}
|
||||
size="icon"
|
||||
@@ -176,13 +123,9 @@ export function ControlModes() {
|
||||
alt={c.label}
|
||||
className={cn(
|
||||
'h-[28px] w-[28px] object-contain transition-[opacity,filter] duration-200',
|
||||
isSiteButton
|
||||
? isActive
|
||||
? 'opacity-100 grayscale-0'
|
||||
: ''
|
||||
: isActive
|
||||
? 'opacity-100 grayscale-0'
|
||||
: 'opacity-60 grayscale group-hover:opacity-100 group-hover:grayscale-0',
|
||||
isActive
|
||||
? 'opacity-100 grayscale-0'
|
||||
: 'opacity-60 grayscale group-hover:opacity-100 group-hover:grayscale-0',
|
||||
)}
|
||||
height={28}
|
||||
src={c.imageSrc}
|
||||
|
||||
@@ -26,6 +26,8 @@ import { ActionButton } from './action-button'
|
||||
const MAX_FILE_SIZE = 200 * 1024 * 1024 // 200MB
|
||||
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 REFERENCES_EMPTY_TEXT =
|
||||
'Upload GLB meshes as scan references or blueprint images as guide references.'
|
||||
|
||||
function formatGridSnapStep(step: GridSnapStep) {
|
||||
return step.toFixed(2)
|
||||
@@ -342,7 +344,7 @@ function GuidesControl() {
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
@@ -581,7 +583,7 @@ function ScansControl() {
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
@@ -805,7 +807,7 @@ function ReferencesControl() {
|
||||
</div>
|
||||
)}
|
||||
<ReferenceListSection
|
||||
emptyText="No scans on this level yet."
|
||||
emptyText={REFERENCES_EMPTY_TEXT}
|
||||
iconSrc="/icons/mesh.png"
|
||||
nodes={scans}
|
||||
noun="scan"
|
||||
@@ -816,7 +818,7 @@ function ReferencesControl() {
|
||||
/>
|
||||
<div className="h-px bg-border/45" />
|
||||
<ReferenceListSection
|
||||
emptyText="No guide images on this level yet."
|
||||
emptyText={REFERENCES_EMPTY_TEXT}
|
||||
iconSrc="/icons/floorplan.png"
|
||||
nodes={guides}
|
||||
noun="guide image"
|
||||
|
||||
Reference in New Issue
Block a user