editor: unify walkthrough + viewer UI, crouch, screenshot pause (#529)
* feat(walkthrough): unify first-person and baked walkthrough UI into a shared HUD The builder first-person overlay (crosshair, Exit Street View button, hints card) and the baked-GLB walkthrough HUD were two divergent UIs. Extract the GLB-style HUD (reticle, floor/room labels, Esc pill, interact prompt) into a shared WalkthroughHud in packages/editor, feed it from FirstPersonControls via a small useFirstPersonHud store (interact target each frame, floor/zone labels sampled from the camera), and align FOV/projection handling with the baked controller. The now-unused WalkthroughControls glide controller is removed from packages/viewer (WALKTHROUGH_FOV moves to the GLB controller module). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(viewer-ui): shared ViewerControlsBar + ViewerSceneHeader for preview and embedders Preview mode's ViewerOverlay was an older copy of the community viewer UI (separate scan/guide/camera buttons, own render/theme/edges menus, 4-state wall mode). Extract the community design into shared prop-driven components: ViewerControlsBar (visibility, level/wall modes, display menu, walkthrough, orbit/top view) and ViewerSceneHeader (back, project info, optional stats slot, breadcrumb, levels card). ViewerOverlay is now a thin composition of them. The display menu gains the edges submenu everywhere; the vestigial translucent wall mode is dropped (a stale value renders as cutaway). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(walkthrough): hold-Ctrl crouch + P screenshot pause in both controllers Crouch swaps the capsule for a short one (shrinks around the centre, so a mid-jump crouch lowers the head and raises the feet — enough to thread window openings), lowers the eye with a short lerp, and slows movement; standing back up is gated on headroom via an upward raycast against the collider world. Tuning constants live in the GLB controller module and are shared with the editor first-person controller. P releases the pointer lock without leaving the walkthrough so the cursor is free for an OS screenshot (macOS region capture needs a movable pointer); clicking the canvas re-locks and resumes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(walkthrough): crouched profile fits ~1 m openings The float gap counts toward the effective obstacle height — the capsule rides floatHeight (0.5 m) above the ground, so the old crouch spanned 0.5–1.3 m and a 1.14 m opening still blocked it. Crouching now also lowers the float gap (0.25 m) and uses a shorter capsule (0.7 m), for an effective 0.25–0.95 m span; the stand-up headroom check grows to match. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(walkthrough): seamless screenshot pause — auto-release pointer lock on ⌘ Replace the P shortcut: macOS swallows the full ⇧⌘4 but the ⌘-down keystroke still reaches the page, so the moment ⌘ (or PrintScreen) goes down while locked the cursor is released without leaving the walkthrough — the native screenshot flow just works, no user education. The HUD pill flips to "Click to resume" (click-through, so the resuming click lands on the canvas) via a new walkthroughSuspended flag on the viewer store, reset on lock/exit/unmount. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(walkthrough): screenshot pause is P again, advertised in the HUD The ⌘ auto-release fired on every command combo and felt broken. Back to an explicit P toggle, now discoverable: the HUD bottom shows a "P free cursor" pill next to "Esc to exit", and while paused it flips to "Click or P to resume · Esc to exit" (click-through so the resuming click lands on the canvas). P re-locks as well as releasing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(walkthrough): freeze crouch during cursor pause; no editor hints in first person While the P pause is active, Ctrl no longer toggles crouch — ⌃⇧⌘4 (clipboard screenshot) was crouching the player mid-capture; the held state stays frozen until resume. HelperManager now renders nothing in first-person mode, so the Ctrl multi-select hint no longer pops over the walkthrough HUD (Ctrl is crouch there). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: biome formatting + pre-existing useOptionalChain fix in wall panel The wall-panel lint error predates this branch (#526); fixed here to unblock the quality gate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
de13119f86
commit
cb6fadbc28
@@ -15,9 +15,11 @@ import {
|
||||
getElevatorShaftDepth,
|
||||
getElevatorShaftWallThickness,
|
||||
getElevatorShaftWidth,
|
||||
getLevelDisplayName,
|
||||
getLevelElevations,
|
||||
getResolvedElevatorDoorStyle,
|
||||
openElevatorDoor,
|
||||
pointInPolygon2D,
|
||||
requestElevatorLevel,
|
||||
resolveElevatorBuildingLevels,
|
||||
resolveElevatorDispatchTarget,
|
||||
@@ -26,7 +28,22 @@ import {
|
||||
useInteractive,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import {
|
||||
BVHEcctrl,
|
||||
type BVHEcctrlApi,
|
||||
CROUCH_CAPSULE,
|
||||
CROUCH_EYE_OFFSET,
|
||||
CROUCH_FLOAT_HEIGHT,
|
||||
CROUCH_RUN_SPEED,
|
||||
CROUCH_WALK_SPEED,
|
||||
EYE_LERP_SPEED,
|
||||
type MovementInput,
|
||||
STAND_CAPSULE,
|
||||
STAND_CLEARANCE,
|
||||
STAND_FLOAT_HEIGHT,
|
||||
useViewer,
|
||||
WALKTHROUGH_FOV,
|
||||
} from '@pascal-app/viewer'
|
||||
import { KeyboardControls } from '@react-three/drei'
|
||||
import { useFrame, useThree } from '@react-three/fiber'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
@@ -39,6 +56,7 @@ import {
|
||||
Mesh,
|
||||
MeshBasicMaterial,
|
||||
type Object3D,
|
||||
type PerspectiveCamera,
|
||||
Ray,
|
||||
Raycaster,
|
||||
Vector2,
|
||||
@@ -46,19 +64,22 @@ import {
|
||||
} from 'three'
|
||||
import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh'
|
||||
import '../../three-types'
|
||||
import { BVHEcctrl, type BVHEcctrlApi, type MovementInput } from '@pascal-app/viewer'
|
||||
import {
|
||||
closeDoorOpenState,
|
||||
DOOR_SWING_OPEN_ANGLE,
|
||||
getDisplayedDoorValue,
|
||||
isOperationDoorType,
|
||||
toggleDoorOpenState,
|
||||
} from '../../lib/door-interaction'
|
||||
import {
|
||||
closeWindowOpenState,
|
||||
getDisplayedWindowValue,
|
||||
isOperableWindowType,
|
||||
toggleWindowOpenState,
|
||||
} from '../../lib/window-interaction'
|
||||
import useEditor from '../../store/use-editor'
|
||||
import { useFirstPersonHud, type WalkthroughInteract } from '../../store/use-first-person-hud'
|
||||
import { WalkthroughHud } from '../walkthrough-hud'
|
||||
import {
|
||||
buildFirstPersonColliderWorldFromRegistry,
|
||||
deriveFirstPersonSpawn,
|
||||
@@ -78,6 +99,7 @@ const ELEVATOR_COLLIDER_FLOOR_THICKNESS = 0.08
|
||||
const ELEVATOR_COLLIDER_DOOR_DEPTH = 0.12
|
||||
const ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD = 0.72
|
||||
const VOID_FALL_RESPAWN_DEPTH = 12
|
||||
const HUD_LABEL_SAMPLE_FRAMES = 10
|
||||
|
||||
type MovementKeyName = Exclude<keyof MovementInput, 'joystick'>
|
||||
|
||||
@@ -120,8 +142,10 @@ function focusFirstPersonCanvas(canvas: HTMLCanvasElement) {
|
||||
canvas.focus({ preventScroll: true })
|
||||
}
|
||||
|
||||
const cameraOffset = new Vector3(0, CAMERA_EYE_OFFSET, 0)
|
||||
const cameraOffset = new Vector3()
|
||||
const cameraEuler = new Euler(0, 0, 0, 'YXZ')
|
||||
const standClearanceRaycaster = new Raycaster()
|
||||
const standClearanceUp = new Vector3(0, 1, 0)
|
||||
const centerScreenPoint = new Vector2(0, 0)
|
||||
const doorInteractionRaycaster = new Raycaster()
|
||||
const doorLeafBox = new Box3()
|
||||
@@ -146,6 +170,9 @@ const elevatorColliderMaterial = new MeshBasicMaterial({ visible: false })
|
||||
const spawnWorldPosition = new Vector3()
|
||||
const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ')
|
||||
const windowInteractionRaycaster = new Raycaster()
|
||||
const hudBuildingLocalEyePosition = new Vector3()
|
||||
const hudWorldEyePosition = new Vector3()
|
||||
const hudLevelBounds = new Box3()
|
||||
|
||||
type ElevatorColliderKind =
|
||||
| 'cab-back'
|
||||
@@ -202,6 +229,128 @@ type ElevatorButtonTarget = {
|
||||
levelId?: AnyNodeId
|
||||
}
|
||||
|
||||
function getLevelChildren(
|
||||
level: Extract<AnyNode, { type: 'level' }>,
|
||||
nodes: Record<string, AnyNode>,
|
||||
) {
|
||||
const childIds = new Set<string>(level.children)
|
||||
return Object.values(nodes).filter((node) => node.parentId === level.id || childIds.has(node.id))
|
||||
}
|
||||
|
||||
function pointIsInLevelFootprint(
|
||||
point: [number, number],
|
||||
worldPoint: Vector3,
|
||||
level: Extract<AnyNode, { type: 'level' }>,
|
||||
nodes: Record<string, AnyNode>,
|
||||
) {
|
||||
const children = getLevelChildren(level, nodes)
|
||||
const slabs = children.filter(
|
||||
(node): node is Extract<AnyNode, { type: 'slab' }> =>
|
||||
node.type === 'slab' && node.polygon.length >= 3,
|
||||
)
|
||||
const zones = children.filter(
|
||||
(node): node is Extract<AnyNode, { type: 'zone' }> =>
|
||||
node.type === 'zone' && node.polygon.length >= 3,
|
||||
)
|
||||
|
||||
if (slabs.length > 0) {
|
||||
return slabs.some(
|
||||
(slab) =>
|
||||
pointInPolygon2D(point, slab.polygon) &&
|
||||
!slab.holes.some((hole) => pointInPolygon2D(point, hole)),
|
||||
)
|
||||
}
|
||||
|
||||
if (zones.length > 0) {
|
||||
if (zones.some((zone) => pointInPolygon2D(point, zone.polygon))) return true
|
||||
}
|
||||
|
||||
const levelObject = sceneRegistry.nodes.get(level.id)
|
||||
if (!levelObject) return false
|
||||
hudLevelBounds.setFromObject(levelObject)
|
||||
return (
|
||||
!hudLevelBounds.isEmpty() &&
|
||||
worldPoint.x >= hudLevelBounds.min.x &&
|
||||
worldPoint.x <= hudLevelBounds.max.x &&
|
||||
worldPoint.z >= hudLevelBounds.min.z &&
|
||||
worldPoint.z <= hudLevelBounds.max.z
|
||||
)
|
||||
}
|
||||
|
||||
function resolveFirstPersonHudLabels(worldPoint: Vector3) {
|
||||
const nodes = useScene.getState().nodes
|
||||
const levelElevations = getLevelElevations(nodes as Record<AnyNodeId, AnyNode>)
|
||||
|
||||
for (const building of Object.values(nodes)) {
|
||||
if (building.type !== 'building') continue
|
||||
const buildingObject = sceneRegistry.nodes.get(building.id)
|
||||
if (!buildingObject) continue
|
||||
|
||||
buildingObject.updateWorldMatrix(true, true)
|
||||
hudBuildingLocalEyePosition.copy(worldPoint)
|
||||
buildingObject.worldToLocal(hudBuildingLocalEyePosition)
|
||||
|
||||
const levels = Object.values(nodes)
|
||||
.filter((node) => node.type === 'level')
|
||||
.filter((level) => levelElevations.get(level.id)?.buildingId === building.id)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
(levelElevations.get(left.id)?.baseY ?? 0) - (levelElevations.get(right.id)?.baseY ?? 0),
|
||||
)
|
||||
|
||||
let activeLevel: (typeof levels)[number] | null = null
|
||||
for (const level of levels) {
|
||||
const elevation = levelElevations.get(level.id)
|
||||
if (!elevation) continue
|
||||
if (
|
||||
hudBuildingLocalEyePosition.y >= elevation.baseY - 0.5 &&
|
||||
hudBuildingLocalEyePosition.y < elevation.baseY + elevation.height + 0.5
|
||||
) {
|
||||
activeLevel = level
|
||||
}
|
||||
}
|
||||
if (!activeLevel) continue
|
||||
|
||||
const point: [number, number] = [hudBuildingLocalEyePosition.x, hudBuildingLocalEyePosition.z]
|
||||
if (!pointIsInLevelFootprint(point, worldPoint, activeLevel, nodes)) continue
|
||||
|
||||
const zone = getLevelChildren(activeLevel, nodes).find(
|
||||
(node) =>
|
||||
node.type === 'zone' && node.polygon.length >= 3 && pointInPolygon2D(point, node.polygon),
|
||||
)
|
||||
|
||||
return {
|
||||
floorLabel: getLevelDisplayName(activeLevel),
|
||||
zoneLabel: zone?.type === 'zone' ? zone.name : null,
|
||||
}
|
||||
}
|
||||
|
||||
return { floorLabel: null, zoneLabel: null }
|
||||
}
|
||||
|
||||
function resolveHudInteract(target: FirstPersonInteractableTarget | null): WalkthroughInteract {
|
||||
if (!target) return null
|
||||
if (target.type === 'elevator') {
|
||||
return {
|
||||
label: target.action === 'open-door' ? 'door button' : 'elevator button',
|
||||
verb: 'press',
|
||||
}
|
||||
}
|
||||
|
||||
const node = useScene.getState().nodes[target.id]
|
||||
if (target.type === 'window') {
|
||||
if (node?.type !== 'window') return null
|
||||
const isOpen = getDisplayedWindowValue(target.id, node.operationState) > 0
|
||||
return { label: node.name || 'window', verb: isOpen ? 'close' : 'open' }
|
||||
}
|
||||
|
||||
if (node?.type !== 'door') return null
|
||||
const isOpen = isOperationDoorType(node.doorType)
|
||||
? getDisplayedDoorValue(target.id, 'operationState', node.operationState) > 0
|
||||
: getDisplayedDoorValue(target.id, 'swingAngle', node.swingAngle) > 0
|
||||
return { label: node.name || 'door', verb: isOpen ? 'close' : 'open' }
|
||||
}
|
||||
|
||||
function resolveElevatorButtonTarget(object: Object3D): ElevatorButtonTarget | null {
|
||||
let current: Object3D | null = object
|
||||
|
||||
@@ -553,6 +702,11 @@ export const FirstPersonControls = () => {
|
||||
const yawRef = useRef(0)
|
||||
const pitchRef = useRef(0)
|
||||
const interactableTargetRef = useRef<FirstPersonInteractableTarget | null>(null)
|
||||
const hudLabelFrameRef = useRef(HUD_LABEL_SAMPLE_FRAMES - 1)
|
||||
const crouchKeyRef = useRef(false)
|
||||
const suspendRef = useRef(false)
|
||||
const eyeOffsetRef = useRef(CAMERA_EYE_OFFSET)
|
||||
const [crouched, setCrouched] = useState(false)
|
||||
const [isElevatorRideLocked, setIsElevatorRideLocked] = useState(false)
|
||||
const ridingElevatorRef = useRef<{
|
||||
elevatorId: AnyNodeId
|
||||
@@ -569,6 +723,35 @@ export const FirstPersonControls = () => {
|
||||
yaw: number
|
||||
} | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const previousCameraMode = useViewer.getState().cameraMode
|
||||
if (previousCameraMode === 'orthographic') {
|
||||
useViewer.getState().setCameraMode('perspective')
|
||||
}
|
||||
return () => {
|
||||
if (previousCameraMode === 'orthographic') {
|
||||
useViewer.getState().setCameraMode('orthographic')
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const perspectiveCamera = camera as PerspectiveCamera
|
||||
if (!perspectiveCamera.isPerspectiveCamera) return
|
||||
const previousFov = perspectiveCamera.fov
|
||||
perspectiveCamera.fov = WALKTHROUGH_FOV
|
||||
perspectiveCamera.updateProjectionMatrix()
|
||||
return () => {
|
||||
perspectiveCamera.fov = previousFov
|
||||
perspectiveCamera.updateProjectionMatrix()
|
||||
}
|
||||
}, [camera])
|
||||
|
||||
useEffect(() => {
|
||||
useFirstPersonHud.getState().reset()
|
||||
return () => useFirstPersonHud.getState().reset()
|
||||
}, [])
|
||||
|
||||
const replaceColliderWorld = useCallback((nextWorld: FirstPersonColliderWorld | null) => {
|
||||
worldRef.current?.dispose()
|
||||
worldRef.current = nextWorld
|
||||
@@ -979,9 +1162,15 @@ export const FirstPersonControls = () => {
|
||||
const isLocked = document.pointerLockElement === canvas
|
||||
if (isLocked) {
|
||||
hadPointerLockRef.current = true
|
||||
suspendRef.current = false
|
||||
useViewer.getState().setWalkthroughSuspended(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Deliberately released (screenshot pause) — stay in first person;
|
||||
// clicking the canvas re-locks.
|
||||
if (suspendRef.current) return
|
||||
|
||||
if (hadPointerLockRef.current && useEditor.getState().isFirstPersonMode) {
|
||||
useEditor.getState().setFirstPersonMode(false)
|
||||
}
|
||||
@@ -998,6 +1187,7 @@ export const FirstPersonControls = () => {
|
||||
document.removeEventListener('click', handleClick)
|
||||
document.removeEventListener('mousedown', handleMouseDown, true)
|
||||
document.removeEventListener('pointerlockchange', handlePointerLockChange)
|
||||
useViewer.getState().setWalkthroughSuspended(false)
|
||||
if (document.pointerLockElement === canvas) {
|
||||
document.exitPointerLock()
|
||||
}
|
||||
@@ -1029,7 +1219,11 @@ export const FirstPersonControls = () => {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.code === 'Escape') {
|
||||
if (event.code === 'ControlLeft' || event.code === 'ControlRight') {
|
||||
// While paused (P), crouch is frozen as-is — ⌃⇧⌘4 (clipboard
|
||||
// screenshot) must not toggle it under the user.
|
||||
if (!suspendRef.current) crouchKeyRef.current = true
|
||||
} else if (event.code === 'Escape') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (document.pointerLockElement === canvas) {
|
||||
@@ -1044,18 +1238,41 @@ export const FirstPersonControls = () => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
closeInteractableTarget()
|
||||
} else if (event.code === 'KeyP') {
|
||||
// P toggles a cursor pause (advertised in the HUD): frees the pointer
|
||||
// without leaving first person — e.g. for an OS screenshot, which
|
||||
// needs a movable cursor — and click or P resumes.
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (document.pointerLockElement === canvas) {
|
||||
suspendRef.current = true
|
||||
useViewer.getState().setWalkthroughSuspended(true)
|
||||
document.exitPointerLock()
|
||||
} else if (suspendRef.current) {
|
||||
const result = canvas.requestPointerLock?.() as Promise<void> | undefined
|
||||
if (result && typeof result.catch === 'function') result.catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
if ((event.code === 'ControlLeft' || event.code === 'ControlRight') && !suspendRef.current) {
|
||||
crouchKeyRef.current = false
|
||||
}
|
||||
applyMovementKey(event, false)
|
||||
}
|
||||
|
||||
const handleBlur = () => {
|
||||
if (!suspendRef.current) crouchKeyRef.current = false
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown, true)
|
||||
document.addEventListener('keyup', handleKeyUp, true)
|
||||
window.addEventListener('blur', handleBlur)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown, true)
|
||||
document.removeEventListener('keyup', handleKeyUp, true)
|
||||
window.removeEventListener('blur', handleBlur)
|
||||
}
|
||||
}, [closeInteractableTarget, gl, toggleInteractableTarget])
|
||||
|
||||
@@ -1281,11 +1498,33 @@ export const FirstPersonControls = () => {
|
||||
[camera, setElevatorRideLocked],
|
||||
)
|
||||
|
||||
useFrame(() => {
|
||||
const hasStandingClearance = useCallback((position: Vector3) => {
|
||||
standClearanceRaycaster.set(position, standClearanceUp)
|
||||
standClearanceRaycaster.far = STAND_CLEARANCE
|
||||
const meshes: Mesh[] = []
|
||||
if (worldRef.current) meshes.push(worldRef.current.mesh)
|
||||
for (const mesh of elevatorColliderMeshesRef.current) {
|
||||
if (mesh.visible) meshes.push(mesh)
|
||||
}
|
||||
return standClearanceRaycaster.intersectObjects(meshes, false).length === 0
|
||||
}, [])
|
||||
|
||||
useFrame((_, delta) => {
|
||||
if (!controllerRef.current?.group) return
|
||||
|
||||
const group = controllerRef.current.group
|
||||
|
||||
// Crouch follows the held key; standing back up waits for headroom.
|
||||
// Frozen while the cursor pause is active.
|
||||
if (!suspendRef.current && crouchKeyRef.current !== crouched) {
|
||||
if (crouchKeyRef.current) setCrouched(true)
|
||||
else if (hasStandingClearance(group.position)) setCrouched(false)
|
||||
}
|
||||
const targetEyeOffset = crouched ? CROUCH_EYE_OFFSET : CAMERA_EYE_OFFSET
|
||||
eyeOffsetRef.current +=
|
||||
(targetEyeOffset - eyeOffsetRef.current) * Math.min(1, delta * EYE_LERP_SPEED)
|
||||
cameraOffset.set(0, eyeOffsetRef.current, 0)
|
||||
|
||||
// The site ground collider is effectively unbounded, but scenes without a
|
||||
// site node only have finite fallback floors — if the controller still ends
|
||||
// up below every collider it can never land, so put it back at the spawn.
|
||||
@@ -1326,6 +1565,17 @@ export const FirstPersonControls = () => {
|
||||
interactableTargetRef.current = nextInteractableTarget
|
||||
useViewer.getState().setHoveredId(nextInteractableTarget?.id ?? null)
|
||||
}
|
||||
|
||||
useFirstPersonHud.getState().setHud({
|
||||
interact: resolveHudInteract(nextInteractableTarget),
|
||||
})
|
||||
|
||||
hudLabelFrameRef.current += 1
|
||||
if (hudLabelFrameRef.current >= HUD_LABEL_SAMPLE_FRAMES) {
|
||||
hudLabelFrameRef.current = 0
|
||||
camera.getWorldPosition(hudWorldEyePosition)
|
||||
useFirstPersonHud.getState().setHud(resolveFirstPersonHudLabels(hudWorldEyePosition))
|
||||
}
|
||||
}, 2.5)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1352,7 +1602,7 @@ export const FirstPersonControls = () => {
|
||||
<BVHEcctrl
|
||||
acceleration={26}
|
||||
airDragFactor={0.3}
|
||||
colliderCapsuleArgs={[0.25, 0.8, 4, 8]}
|
||||
colliderCapsuleArgs={crouched ? CROUCH_CAPSULE : STAND_CAPSULE}
|
||||
colliderMeshes={firstPersonColliderMeshes}
|
||||
collisionCheckIteration={3}
|
||||
collisionPushBackDamping={0.1}
|
||||
@@ -1363,16 +1613,16 @@ export const FirstPersonControls = () => {
|
||||
fallGravityFactor={4}
|
||||
floatCheckType="BOTH"
|
||||
floatDampingC={36}
|
||||
floatHeight={0.5}
|
||||
floatHeight={crouched ? CROUCH_FLOAT_HEIGHT : STAND_FLOAT_HEIGHT}
|
||||
floatPullBackHeight={0.35}
|
||||
floatSensorRadius={0.15}
|
||||
floatSpringK={1200}
|
||||
gravity={9.81}
|
||||
jumpVel={5}
|
||||
key="first-person-controller"
|
||||
maxRunSpeed={5}
|
||||
maxRunSpeed={crouched ? CROUCH_RUN_SPEED : 5}
|
||||
maxSlope={1.2}
|
||||
maxWalkSpeed={2}
|
||||
maxWalkSpeed={crouched ? CROUCH_WALK_SPEED : 2}
|
||||
paused={isElevatorRideLocked}
|
||||
position={controllerStart.position}
|
||||
ref={setControllerApi}
|
||||
@@ -1383,27 +1633,14 @@ export const FirstPersonControls = () => {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Overlay UI for first-person mode: crosshair, controls hint, exit button.
|
||||
* Rendered as a regular DOM overlay (not inside the Canvas).
|
||||
*/
|
||||
export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
|
||||
const [isLocked, setIsLocked] = useState(false)
|
||||
const hasPlacedSpawn = useScene((state) =>
|
||||
Object.values(state.nodes).some((node) => node.type === 'spawn'),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const handlePointerLockChange = () => {
|
||||
setIsLocked(document.pointerLockElement != null)
|
||||
}
|
||||
|
||||
handlePointerLockChange()
|
||||
document.addEventListener('pointerlockchange', handlePointerLockChange)
|
||||
return () => {
|
||||
document.removeEventListener('pointerlockchange', handlePointerLockChange)
|
||||
}
|
||||
}, [])
|
||||
const floorLabel = useFirstPersonHud((state) => state.floorLabel)
|
||||
const zoneLabel = useFirstPersonHud((state) => state.zoneLabel)
|
||||
const interact = useFirstPersonHud((state) => state.interact)
|
||||
const suspended = useViewer((state) => state.walkthroughSuspended)
|
||||
|
||||
const handleExit = useCallback(() => {
|
||||
if (document.pointerLockElement) {
|
||||
@@ -1413,86 +1650,18 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
|
||||
}, [onExit])
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLocked && (
|
||||
<div className="pointer-events-none absolute inset-0 z-40 flex items-center justify-center">
|
||||
<div className="relative h-7 w-7">
|
||||
<div className="absolute top-1/2 left-1/2 h-px w-7 -translate-x-1/2 -translate-y-1/2 bg-white/60" />
|
||||
<div className="absolute top-1/2 left-1/2 h-7 w-px -translate-x-1/2 -translate-y-1/2 bg-white/60" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute top-4 right-4 z-50">
|
||||
<button
|
||||
className="pointer-events-auto flex items-center gap-2 rounded-xl border border-border/40 bg-background/90 px-4 py-2 font-medium text-foreground text-sm shadow-lg backdrop-blur-xl transition-colors hover:bg-background"
|
||||
onClick={handleExit}
|
||||
type="button"
|
||||
<WalkthroughHud
|
||||
floorLabel={floorLabel}
|
||||
interact={interact}
|
||||
onExit={handleExit}
|
||||
suspended={suspended}
|
||||
zoneLabel={zoneLabel}
|
||||
>
|
||||
<kbd className="rounded border border-border/50 bg-accent/50 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
||||
ESC
|
||||
</kbd>
|
||||
Exit Street View
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!hasPlacedSpawn && (
|
||||
<div className="absolute top-4 left-1/2 z-50 -translate-x-1/2">
|
||||
<div className="rounded-2xl border border-sky-300/35 bg-slate-950/88 px-4 py-2 text-center text-slate-100 text-sm shadow-lg backdrop-blur-xl">
|
||||
Place a Spawn Point from the Build tab to control where walkthrough starts.
|
||||
</div>
|
||||
<div className="corner-smooth rounded-full border border-border/40 bg-background/80 px-3 py-1 text-center text-muted-foreground text-xs shadow-elevation-3 backdrop-blur-xl">
|
||||
Place a spawn point from the Build tab to control where walkthrough starts.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLocked && (
|
||||
<div className="pointer-events-none absolute top-1/2 right-6 z-40 -translate-y-1/2">
|
||||
<div className="flex min-w-[148px] flex-col gap-3 rounded-2xl border border-border/35 bg-background/80 px-4 py-4 shadow-lg backdrop-blur-xl">
|
||||
<ControlHint keys={['W', 'A', 'S', 'D']} label="Move" />
|
||||
<div className="h-px w-full bg-border/30" />
|
||||
<InlineControlHint keyLabel="Space" label="Jump" />
|
||||
<InlineControlHint keyLabel="Shift" label="Sprint" />
|
||||
<InlineControlHint keyLabel="E / R" label="Interact" />
|
||||
<InlineControlHint keyLabel="T" label="Close" />
|
||||
<div className="h-px w-full bg-border/30" />
|
||||
<span className="text-center text-muted-foreground/60 text-xs">
|
||||
Click to look around
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ControlHint({ label, keys }: { label: string; keys: string[] }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1.5 text-center">
|
||||
<span className="font-medium text-[10px] text-muted-foreground/60 tracking-[0.03em]">
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center justify-center gap-1">
|
||||
{keys.map((key) => (
|
||||
<kbd
|
||||
className="flex h-5 min-w-5 items-center justify-center rounded border border-border/50 bg-accent/40 px-1 font-mono text-[10px] text-foreground/80 leading-none"
|
||||
key={key}
|
||||
>
|
||||
{key}
|
||||
</kbd>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InlineControlHint({ label, keyLabel }: { label: string; keyLabel: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="font-medium text-[10px] text-muted-foreground/60 uppercase tracking-[0.03em]">
|
||||
{label}
|
||||
</span>
|
||||
<kbd className="flex h-5 min-w-5 items-center justify-center rounded border border-border/50 bg-accent/40 px-1.5 font-mono text-[10px] text-foreground/80 leading-none">
|
||||
{keyLabel}
|
||||
</kbd>
|
||||
</div>
|
||||
</WalkthroughHud>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ function useActiveModifierKeys(): ActiveModifierKeys {
|
||||
export function HelperManager() {
|
||||
const mode = useEditor((s) => s.mode)
|
||||
const tool = useEditor((s) => s.tool)
|
||||
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
|
||||
const measurementToolKind = useEditor((s) => s.toolDefaults.measurement?.kind)
|
||||
const workspaceMode = useEditor((s) => s.workspaceMode)
|
||||
const scope = useInteractionScope((s) => s.scope)
|
||||
@@ -148,6 +149,10 @@ export function HelperManager() {
|
||||
// Helpers are keyboard-driven hints (Esc, R, etc.) — irrelevant on touch.
|
||||
if (isMobile) return null
|
||||
|
||||
// First-person walkthrough has its own HUD; editor shortcut hints (e.g. the
|
||||
// Ctrl multi-select hint — Ctrl is crouch there) don't apply while walking.
|
||||
if (isFirstPersonMode) return null
|
||||
|
||||
// The studio workspace (compose panel / gallery) has no scene selection or
|
||||
// tools — editor shortcut hints would only mislead there.
|
||||
if (workspaceMode === 'studio') return null
|
||||
|
||||
@@ -1,51 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { Icon } from '@iconify/react'
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type BuildingNode,
|
||||
emitter,
|
||||
getLevelDisplayName,
|
||||
type LevelNode,
|
||||
useScene,
|
||||
type ZoneNode,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
CLAY_PALETTE,
|
||||
type EdgeMode,
|
||||
getSceneTheme,
|
||||
SCENE_THEMES,
|
||||
useViewer,
|
||||
} from '@pascal-app/viewer'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Box,
|
||||
Camera,
|
||||
Check,
|
||||
ChevronRight,
|
||||
Diamond,
|
||||
Footprints,
|
||||
Layers,
|
||||
Palette,
|
||||
PenLine,
|
||||
Sparkles,
|
||||
Square,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { flushSync } from 'react-dom'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { cn } from '../lib/utils'
|
||||
import useEditor from '../store/use-editor'
|
||||
import { ActionButton } from './ui/action-menu/action-button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from './ui/primitives/dropdown-menu'
|
||||
import { TooltipProvider } from './ui/primitives/tooltip'
|
||||
import { ViewerControlsBar } from './viewer/viewer-controls-bar'
|
||||
import { ViewerSceneHeader } from './viewer/viewer-scene-header'
|
||||
|
||||
type ProjectOwner = {
|
||||
id: string
|
||||
@@ -72,218 +30,6 @@ function requestWalkthroughPointerLock() {
|
||||
}
|
||||
}
|
||||
|
||||
const levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = {
|
||||
stacked: 'Stacked',
|
||||
exploded: 'Exploded',
|
||||
solo: 'Solo',
|
||||
}
|
||||
|
||||
const levelModeBadgeLabels: Record<'manual' | 'stacked' | 'exploded' | 'solo', string> = {
|
||||
manual: 'Stack',
|
||||
stacked: 'Stack',
|
||||
exploded: 'Exploded',
|
||||
solo: 'Solo',
|
||||
}
|
||||
|
||||
const wallModeConfig = {
|
||||
up: {
|
||||
icon: (props: any) => (
|
||||
<img alt="Full Height" height={28} src="/icons/room.webp" width={28} {...props} />
|
||||
),
|
||||
label: 'Full Height',
|
||||
},
|
||||
cutaway: {
|
||||
icon: (props: any) => (
|
||||
<img alt="Cutaway" height={28} src="/icons/wallcut.webp" width={28} {...props} />
|
||||
),
|
||||
label: 'Cutaway',
|
||||
},
|
||||
down: {
|
||||
icon: (props: any) => (
|
||||
<img alt="Low" height={28} src="/icons/walllow.webp" width={28} {...props} />
|
||||
),
|
||||
label: 'Low',
|
||||
},
|
||||
translucent: {
|
||||
icon: (props: any) => (
|
||||
<img alt="Translucent" height={28} src="/icons/wall.png" width={28} {...props} />
|
||||
),
|
||||
label: 'Translucent',
|
||||
},
|
||||
}
|
||||
|
||||
const SHADING_OPTIONS = [
|
||||
{ id: 'solid', name: 'Solid', detail: 'Flat and fast — no ambient occlusion', icon: Box },
|
||||
{ id: 'rendered', name: 'Rendered', detail: 'Full ambient occlusion', icon: Sparkles },
|
||||
] as const
|
||||
|
||||
const TEXTURE_OPTIONS = [
|
||||
{ id: true, name: 'Colored', detail: 'Show materials, textures & colors', icon: Palette },
|
||||
{ id: false, name: 'Monochrome', detail: 'Flat clay surfaces by role', icon: Square },
|
||||
] as const
|
||||
|
||||
function RenderModeMenu() {
|
||||
const shading = useViewer((s) => s.shading)
|
||||
const textures = useViewer((s) => s.textures)
|
||||
const active = SHADING_OPTIONS.find((o) => o.id === shading) ?? SHADING_OPTIONS[0]
|
||||
const ActiveIcon = active.icon
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<ActionButton
|
||||
className="text-muted-foreground/80 hover:bg-white/5 hover:text-foreground"
|
||||
label={`Render: ${active.name}`}
|
||||
size="icon"
|
||||
tooltipSide="top"
|
||||
variant="ghost"
|
||||
>
|
||||
<ActiveIcon className="h-6 w-6" />
|
||||
</ActionButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="min-w-56" side="top">
|
||||
{SHADING_OPTIONS.map((option) => {
|
||||
const OptionIcon = option.icon
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={option.id}
|
||||
onSelect={() => useViewer.getState().setShading(option.id)}
|
||||
>
|
||||
<OptionIcon />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-foreground">{option.name}</span>
|
||||
<span className="text-muted-foreground text-xs">{option.detail}</span>
|
||||
</div>
|
||||
{shading === option.id ? <Check className="ml-auto text-foreground" /> : null}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
<DropdownMenuSeparator />
|
||||
{TEXTURE_OPTIONS.map((option) => {
|
||||
const OptionIcon = option.icon
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={option.name}
|
||||
onSelect={() => useViewer.getState().setTextures(option.id)}
|
||||
>
|
||||
<OptionIcon />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-foreground">{option.name}</span>
|
||||
<span className="text-muted-foreground text-xs">{option.detail}</span>
|
||||
</div>
|
||||
{textures === option.id ? <Check className="ml-auto text-foreground" /> : null}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
function SceneThemeMenu() {
|
||||
const sceneTheme = useViewer((s) => s.sceneTheme)
|
||||
const active = getSceneTheme(sceneTheme)
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<ActionButton
|
||||
className="text-muted-foreground/80 hover:bg-white/5 hover:text-foreground"
|
||||
label={`Theme: ${active.name}`}
|
||||
size="icon"
|
||||
tooltipSide="top"
|
||||
variant="ghost"
|
||||
>
|
||||
<Icon color="currentColor" height={24} icon="lucide:swatch-book" width={24} />
|
||||
</ActionButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="min-w-48" side="top">
|
||||
{SCENE_THEMES.map((sceneThemeOption) => {
|
||||
const swatches = (['wall', 'roof', 'floor', 'glazing'] as const).map(
|
||||
(role) => sceneThemeOption.clayTints?.[role] ?? CLAY_PALETTE[role],
|
||||
)
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={sceneThemeOption.id}
|
||||
onSelect={() => useViewer.getState().setSceneTheme(sceneThemeOption.id)}
|
||||
>
|
||||
<span
|
||||
className="grid h-5 w-5 shrink-0 grid-cols-2 overflow-hidden rounded-sm border border-black/10"
|
||||
style={{ backgroundColor: sceneThemeOption.background }}
|
||||
>
|
||||
{swatches.map((color, index) => (
|
||||
<span
|
||||
key={`${sceneThemeOption.id}-${index}`}
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
<span className="text-foreground">{sceneThemeOption.name}</span>
|
||||
{sceneTheme === sceneThemeOption.id ? (
|
||||
<Check className="ml-auto text-foreground" />
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
const EDGE_OPTIONS = [
|
||||
{ id: 'off', name: 'Off', detail: 'No edge lines' },
|
||||
{ id: 'soft', name: 'Soft', detail: 'Faint outline of major creases' },
|
||||
{ id: 'strong', name: 'Strong', detail: 'Crisp, opaque edge lines' },
|
||||
] as const satisfies readonly { id: EdgeMode; name: string; detail: string }[]
|
||||
|
||||
function EdgesMenu() {
|
||||
const edges = useViewer((s) => s.edges)
|
||||
const active = EDGE_OPTIONS.find((o) => o.id === edges) ?? EDGE_OPTIONS[0]
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<ActionButton
|
||||
className={
|
||||
edges === 'off'
|
||||
? 'text-muted-foreground/80 hover:bg-white/5 hover:text-foreground'
|
||||
: 'bg-white/10 text-foreground'
|
||||
}
|
||||
label={`Edges: ${active.name}`}
|
||||
size="icon"
|
||||
tooltipSide="top"
|
||||
variant="ghost"
|
||||
>
|
||||
<PenLine className="h-6 w-6" />
|
||||
</ActionButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="min-w-56" side="top">
|
||||
{EDGE_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.id}
|
||||
onSelect={() => useViewer.getState().setEdges(option.id)}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-foreground">{option.name}</span>
|
||||
<span className="text-muted-foreground text-xs">{option.detail}</span>
|
||||
</div>
|
||||
{edges === option.id ? <Check className="ml-auto text-foreground" /> : null}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
const getNodeName = (node: AnyNode): string => {
|
||||
if ('name' in node && node.name) return node.name
|
||||
if (node.type === 'wall') return 'Wall'
|
||||
if (node.type === 'fence') return 'Fence'
|
||||
if (node.type === 'item') return (node as { asset: { name: string } }).asset?.name || 'Item'
|
||||
if (node.type === 'slab') return 'Slab'
|
||||
if (node.type === 'ceiling') return 'Ceiling'
|
||||
if (node.type === 'roof') return 'Roof'
|
||||
if (node.type === 'roof-segment') return 'Roof Segment'
|
||||
return node.type
|
||||
}
|
||||
|
||||
interface ViewerOverlayProps {
|
||||
projectName?: string | null
|
||||
owner?: ProjectOwner | null
|
||||
@@ -298,401 +44,16 @@ export const ViewerOverlay = ({
|
||||
canShowScans = true,
|
||||
canShowGuides = true,
|
||||
onBack,
|
||||
}: ViewerOverlayProps) => {
|
||||
const selection = useViewer((s) => s.selection)
|
||||
const showScans = useViewer((s) => s.showScans)
|
||||
const showGuides = useViewer((s) => s.showGuides)
|
||||
const cameraMode = useViewer((s) => s.cameraMode)
|
||||
const levelMode = useViewer((s) => s.levelMode)
|
||||
const wallMode = useViewer((s) => s.wallMode)
|
||||
|
||||
// Subscribe only to the specific nodes we read so that creating an unrelated
|
||||
// node elsewhere in the scene doesn't re-render this overlay.
|
||||
const firstSelectedId = selection.selectedIds[0] ?? null
|
||||
const building = useScene((s) =>
|
||||
selection.buildingId ? (s.nodes[selection.buildingId] as BuildingNode | undefined) : null,
|
||||
)
|
||||
const level = useScene((s) =>
|
||||
selection.levelId ? (s.nodes[selection.levelId] as LevelNode | undefined) : null,
|
||||
)
|
||||
const zone = useScene((s) =>
|
||||
selection.zoneId ? (s.nodes[selection.zoneId] as ZoneNode | undefined) : null,
|
||||
)
|
||||
const selectedNode = useScene((s) =>
|
||||
firstSelectedId ? (s.nodes[firstSelectedId as AnyNodeId] as AnyNode | undefined) : null,
|
||||
)
|
||||
const levels = useScene(
|
||||
useShallow((s) => {
|
||||
if (!building) return []
|
||||
return building.children
|
||||
.map((id) => s.nodes[id as AnyNodeId] as LevelNode | undefined)
|
||||
.filter((n): n is LevelNode => n?.type === 'level')
|
||||
.sort((a, b) => a.level - b.level)
|
||||
}),
|
||||
)
|
||||
|
||||
const handleLevelClick = (levelId: LevelNode['id']) => {
|
||||
// When switching levels, deselect zone and items
|
||||
useViewer.getState().setSelection({ levelId })
|
||||
}
|
||||
|
||||
const handleBreadcrumbClick = (depth: 'root' | 'building' | 'level' | 'zone') => {
|
||||
switch (depth) {
|
||||
case 'root':
|
||||
useViewer.getState().resetSelection()
|
||||
break
|
||||
case 'building':
|
||||
useViewer.getState().setSelection({ levelId: null })
|
||||
break
|
||||
case 'level':
|
||||
useViewer.getState().setSelection({ zoneId: null })
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
}: ViewerOverlayProps) => (
|
||||
<>
|
||||
{/* Unified top-left card */}
|
||||
<div className="dark absolute top-4 left-4 z-20 flex flex-col gap-3 text-foreground">
|
||||
<div className="pointer-events-auto flex min-w-[200px] flex-col overflow-hidden rounded-2xl border border-border/40 bg-background/95 shadow-lg backdrop-blur-xl transition-colors duration-200 ease-out">
|
||||
{/* Project info + back */}
|
||||
<div className="flex items-center gap-3 px-3 py-2.5">
|
||||
{onBack ? (
|
||||
<button
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md transition-colors hover:bg-white/10"
|
||||
onClick={onBack}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md transition-colors hover:bg-white/10"
|
||||
href="/"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 text-muted-foreground" />
|
||||
</Link>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-foreground text-sm">
|
||||
{projectName || 'Untitled'}
|
||||
</div>
|
||||
{owner?.username && (
|
||||
<Link
|
||||
className="text-muted-foreground text-xs transition-colors hover:text-foreground"
|
||||
href={`/u/${owner.username}`}
|
||||
>
|
||||
@{owner.username}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumb — only shown when navigated into a building */}
|
||||
{building && (
|
||||
<div className="border-border/40 border-t px-3 py-2">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<button
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={() => handleBreadcrumbClick('root')}
|
||||
>
|
||||
Site
|
||||
</button>
|
||||
|
||||
{building && (
|
||||
<>
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground/50" />
|
||||
<button
|
||||
className={`truncate transition-colors ${level ? 'text-muted-foreground hover:text-foreground' : 'font-medium text-foreground'}`}
|
||||
onClick={() => handleBreadcrumbClick('building')}
|
||||
>
|
||||
{building.name || 'Building'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{level && (
|
||||
<>
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground/50" />
|
||||
<button
|
||||
className={`truncate transition-colors ${zone ? 'text-muted-foreground hover:text-foreground' : 'font-medium text-foreground'}`}
|
||||
onClick={() => handleBreadcrumbClick('level')}
|
||||
>
|
||||
{getLevelDisplayName(level)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{zone && (
|
||||
<>
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground/50" />
|
||||
<span
|
||||
className={`truncate transition-colors ${selectedNode ? 'text-muted-foreground' : 'font-medium text-foreground'}`}
|
||||
>
|
||||
{zone.name}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedNode && zone && (
|
||||
<>
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground/50" />
|
||||
<span className="truncate font-medium text-foreground">
|
||||
{getNodeName(selectedNode)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Level List (only when building is selected) */}
|
||||
{building && levels.length > 0 && (
|
||||
<div className="pointer-events-auto flex w-48 flex-col overflow-hidden rounded-2xl border border-border/40 bg-background/95 py-1 shadow-lg backdrop-blur-xl transition-colors duration-200 ease-out">
|
||||
<span className="px-3 py-2 font-medium text-[10px] text-muted-foreground uppercase tracking-wider">
|
||||
Levels
|
||||
</span>
|
||||
<div className="flex flex-col">
|
||||
{levels.map((lvl) => {
|
||||
const isSelected = lvl.id === selection.levelId
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'group/row relative flex h-8 w-full cursor-pointer select-none items-center border-border/50 border-r border-r-transparent border-b px-3 text-sm transition-all duration-200',
|
||||
isSelected
|
||||
? 'border-r-3 border-r-white bg-accent/50 text-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent/30 hover:text-foreground',
|
||||
)}
|
||||
key={lvl.id}
|
||||
onClick={() => handleLevelClick(lvl.id)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-4 w-4 shrink-0 items-center justify-center transition-all duration-200',
|
||||
!isSelected && 'opacity-60 grayscale',
|
||||
)}
|
||||
>
|
||||
<Layers className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 truncate text-left">
|
||||
{lvl.name || `Level ${lvl.level}`}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Controls Panel - Bottom Center */}
|
||||
<div className="dark absolute bottom-6 left-1/2 z-20 -translate-x-1/2 text-foreground">
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div className="pointer-events-auto flex h-14 flex-row items-center justify-center gap-1.5 rounded-2xl border border-border/40 bg-background/95 p-1.5 shadow-lg backdrop-blur-xl transition-colors duration-200 ease-out">
|
||||
{/* Scans and Guides Visibility */}
|
||||
{canShowScans && (
|
||||
<ActionButton
|
||||
className={
|
||||
showScans
|
||||
? 'bg-white/10'
|
||||
: 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0'
|
||||
}
|
||||
label={`Scans: ${showScans ? 'Visible' : 'Hidden'}`}
|
||||
onClick={() => useViewer.getState().setShowScans(!showScans)}
|
||||
size="icon"
|
||||
tooltipSide="top"
|
||||
variant="ghost"
|
||||
>
|
||||
<img
|
||||
alt="Scans"
|
||||
className="h-[28px] w-[28px] object-contain"
|
||||
src="/icons/mesh.webp"
|
||||
/>
|
||||
</ActionButton>
|
||||
)}
|
||||
|
||||
{canShowGuides && (
|
||||
<ActionButton
|
||||
className={
|
||||
showGuides
|
||||
? 'bg-white/10'
|
||||
: 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0'
|
||||
}
|
||||
label={`Guides: ${showGuides ? 'Visible' : 'Hidden'}`}
|
||||
onClick={() => useViewer.getState().setShowGuides(!showGuides)}
|
||||
size="icon"
|
||||
tooltipSide="top"
|
||||
variant="ghost"
|
||||
>
|
||||
<img
|
||||
alt="Guides"
|
||||
className="h-[28px] w-[28px] object-contain"
|
||||
src="/icons/floorplan.webp"
|
||||
/>
|
||||
</ActionButton>
|
||||
)}
|
||||
|
||||
{(canShowScans || canShowGuides) && <div className="mx-1 h-5 w-px bg-border/40" />}
|
||||
|
||||
{/* Camera Mode */}
|
||||
<ActionButton
|
||||
className={
|
||||
cameraMode === 'orthographic'
|
||||
? 'bg-violet-500/20 text-violet-400'
|
||||
: 'hover:bg-white/5 hover:text-violet-400'
|
||||
}
|
||||
label={`Camera: ${cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'}`}
|
||||
onClick={() =>
|
||||
useViewer
|
||||
.getState()
|
||||
.setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective')
|
||||
}
|
||||
size="icon"
|
||||
tooltipSide="top"
|
||||
variant="ghost"
|
||||
>
|
||||
<Camera className="h-6 w-6" />
|
||||
</ActionButton>
|
||||
|
||||
<RenderModeMenu />
|
||||
|
||||
<SceneThemeMenu />
|
||||
|
||||
<EdgesMenu />
|
||||
|
||||
{/* Level Mode */}
|
||||
<ActionButton
|
||||
className={cn(
|
||||
'p-0',
|
||||
levelMode === 'stacked' || levelMode === 'manual'
|
||||
? 'text-muted-foreground/80 hover:bg-white/5 hover:text-foreground'
|
||||
: 'bg-white/10 text-foreground',
|
||||
)}
|
||||
label={`Levels: ${levelMode === 'manual' ? 'Manual' : levelModeLabels[levelMode as keyof typeof levelModeLabels]}`}
|
||||
onClick={() => {
|
||||
if (levelMode === 'manual') return useViewer.getState().setLevelMode('stacked')
|
||||
const modes: ('stacked' | 'exploded' | 'solo')[] = ['stacked', 'exploded', 'solo']
|
||||
const nextIndex = (modes.indexOf(levelMode as any) + 1) % modes.length
|
||||
useViewer.getState().setLevelMode(modes[nextIndex] ?? 'stacked')
|
||||
}}
|
||||
size="icon"
|
||||
tooltipSide="top"
|
||||
variant="ghost"
|
||||
>
|
||||
<span className="relative flex h-full w-full items-center justify-center pb-1">
|
||||
{levelMode === 'solo' && <Diamond className="h-6 w-6" />}
|
||||
{levelMode === 'exploded' && (
|
||||
<Icon color="currentColor" height={24} icon="charm:stack-pop" width={24} />
|
||||
)}
|
||||
{(levelMode === 'stacked' || levelMode === 'manual') && (
|
||||
<Icon color="currentColor" height={24} icon="charm:stack-push" width={24} />
|
||||
)}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute right-1 bottom-1 left-1 rounded border border-border/50 bg-background/70 px-0.5 py-[2px] text-center font-medium font-pixel text-[8px] text-foreground/85 leading-none tracking-[-0.02em] backdrop-blur-sm"
|
||||
>
|
||||
{levelModeBadgeLabels[levelMode]}
|
||||
</span>
|
||||
</span>
|
||||
</ActionButton>
|
||||
|
||||
{/* Wall Mode */}
|
||||
<ActionButton
|
||||
className={
|
||||
wallMode !== 'cutaway'
|
||||
? 'bg-white/10'
|
||||
: 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0'
|
||||
}
|
||||
label={`Walls: ${wallModeConfig[wallMode as keyof typeof wallModeConfig].label}`}
|
||||
onClick={() => {
|
||||
const modes: ('cutaway' | 'up' | 'down' | 'translucent')[] = [
|
||||
'cutaway',
|
||||
'up',
|
||||
'down',
|
||||
'translucent',
|
||||
]
|
||||
const nextIndex = (modes.indexOf(wallMode as any) + 1) % modes.length
|
||||
useViewer.getState().setWallMode(modes[nextIndex] ?? 'cutaway')
|
||||
}}
|
||||
size="icon"
|
||||
tooltipSide="top"
|
||||
variant="ghost"
|
||||
>
|
||||
{(() => {
|
||||
const Icon = wallModeConfig[wallMode as keyof typeof wallModeConfig].icon
|
||||
return <Icon className="h-[28px] w-[28px]" />
|
||||
})()}
|
||||
</ActionButton>
|
||||
|
||||
<div className="mx-1 h-5 w-px bg-border/40" />
|
||||
|
||||
{/* Camera Actions */}
|
||||
<ActionButton
|
||||
className="group hidden hover:bg-white/5 sm:inline-flex"
|
||||
label="Orbit Left"
|
||||
onClick={() => emitter.emit('camera-controls:orbit-ccw')}
|
||||
size="icon"
|
||||
tooltipSide="top"
|
||||
variant="ghost"
|
||||
>
|
||||
<img
|
||||
alt="Orbit Left"
|
||||
className="h-[28px] w-[28px] -scale-x-100 object-contain opacity-70 transition-opacity group-hover:opacity-100"
|
||||
src="/icons/rotate.webp"
|
||||
/>
|
||||
</ActionButton>
|
||||
|
||||
<ActionButton
|
||||
className="group hidden hover:bg-white/5 sm:inline-flex"
|
||||
label="Orbit Right"
|
||||
onClick={() => emitter.emit('camera-controls:orbit-cw')}
|
||||
size="icon"
|
||||
tooltipSide="top"
|
||||
variant="ghost"
|
||||
>
|
||||
<img
|
||||
alt="Orbit Right"
|
||||
className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
|
||||
src="/icons/rotate.webp"
|
||||
/>
|
||||
</ActionButton>
|
||||
|
||||
<ActionButton
|
||||
className="group hover:bg-white/5"
|
||||
label="Top View"
|
||||
onClick={() => emitter.emit('camera-controls:top-view')}
|
||||
size="icon"
|
||||
tooltipSide="top"
|
||||
variant="ghost"
|
||||
>
|
||||
<img
|
||||
alt="Top View"
|
||||
className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
|
||||
src="/icons/topview.webp"
|
||||
/>
|
||||
</ActionButton>
|
||||
|
||||
<div className="mx-1 h-5 w-px bg-border/40" />
|
||||
|
||||
{/* First-person walkthrough */}
|
||||
<ActionButton
|
||||
className="hover:bg-white/5 hover:text-emerald-400"
|
||||
label="Walkthrough"
|
||||
onClick={() => {
|
||||
<ViewerSceneHeader onBack={onBack} owner={owner} projectName={projectName} />
|
||||
<ViewerControlsBar
|
||||
canShowGuides={canShowGuides}
|
||||
canShowScans={canShowScans}
|
||||
onWalkthroughToggle={() => {
|
||||
flushSync(() => useEditor.getState().setFirstPersonMode(true))
|
||||
requestWalkthroughPointerLock()
|
||||
}}
|
||||
size="icon"
|
||||
tooltipSide="top"
|
||||
variant="ghost"
|
||||
>
|
||||
<Footprints className="h-6 w-6" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
'use client'
|
||||
|
||||
import { emitter } from '@pascal-app/core'
|
||||
import {
|
||||
CLAY_PALETTE,
|
||||
type EdgeMode,
|
||||
getSceneTheme,
|
||||
SCENE_THEMES,
|
||||
useViewer,
|
||||
} from '@pascal-app/viewer'
|
||||
import {
|
||||
Box,
|
||||
Camera,
|
||||
Check,
|
||||
Contrast,
|
||||
Diamond,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Footprints,
|
||||
Layers,
|
||||
Layers2,
|
||||
Palette,
|
||||
PenLine,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Square,
|
||||
SwatchBook,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { ActionButton } from '../ui/action-menu/action-button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from '../ui/primitives/dropdown-menu'
|
||||
import { TooltipProvider } from '../ui/primitives/tooltip'
|
||||
|
||||
const levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = {
|
||||
stacked: 'Stacked',
|
||||
exploded: 'Exploded',
|
||||
solo: 'Solo',
|
||||
}
|
||||
|
||||
const wallModeConfig = {
|
||||
up: {
|
||||
icon: (props: any) => (
|
||||
<img alt="Full height" height={28} src="/icons/room.webp" width={28} {...props} />
|
||||
),
|
||||
label: 'Full height',
|
||||
},
|
||||
cutaway: {
|
||||
icon: (props: any) => (
|
||||
<img alt="Cutaway" height={28} src="/icons/wallcut.webp" width={28} {...props} />
|
||||
),
|
||||
label: 'Cutaway',
|
||||
},
|
||||
down: {
|
||||
icon: (props: any) => (
|
||||
<img alt="Low" height={28} src="/icons/walllow.webp" width={28} {...props} />
|
||||
),
|
||||
label: 'Low',
|
||||
},
|
||||
}
|
||||
|
||||
const SHADING_OPTIONS = [
|
||||
{ id: 'solid', name: 'Solid', detail: 'Flat and fast — no ambient occlusion', icon: Box },
|
||||
{ id: 'rendered', name: 'Rendered', detail: 'Full ambient occlusion', icon: Sparkles },
|
||||
] as const
|
||||
|
||||
const EDGE_OPTIONS = [
|
||||
{ id: 'off', name: 'Off', detail: 'No edge lines' },
|
||||
{ id: 'soft', name: 'Soft', detail: 'Faint outline of major creases' },
|
||||
{ id: 'strong', name: 'Strong', detail: 'Crisp, opaque edge lines' },
|
||||
] as const satisfies readonly { id: EdgeMode; name: string; detail: string }[]
|
||||
|
||||
// Keep the dropdown open when flipping an in-place toggle row.
|
||||
const keepOpen = (event: Event, fn: () => void) => {
|
||||
event.preventDefault()
|
||||
fn()
|
||||
}
|
||||
|
||||
// Scans + guides folded into one control. A baked GLB carries none of its own,
|
||||
// but the GLB viewer re-adds them from scene data when the privacy flags allow,
|
||||
// so the toggle shows for whichever exist.
|
||||
function VisibilityMenu({
|
||||
canShowScans,
|
||||
canShowGuides,
|
||||
}: {
|
||||
canShowScans: boolean
|
||||
canShowGuides: boolean
|
||||
}) {
|
||||
const showScans = useViewer((s) => s.showScans)
|
||||
const showGuides = useViewer((s) => s.showGuides)
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<ActionButton
|
||||
className="hover:bg-white/5 hover:text-foreground"
|
||||
label="Visibility"
|
||||
size="icon"
|
||||
tooltipSide="top"
|
||||
variant="ghost"
|
||||
>
|
||||
<Eye className="h-5 w-5" />
|
||||
</ActionButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="min-w-44" side="top">
|
||||
{canShowScans && (
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => keepOpen(e, () => useViewer.getState().setShowScans(!showScans))}
|
||||
>
|
||||
<img alt="" className="h-4 w-4 object-contain" src="/icons/mesh.webp" />
|
||||
<span>Scans</span>
|
||||
{showScans ? (
|
||||
<Eye className="ml-auto h-4 w-4 text-foreground" />
|
||||
) : (
|
||||
<EyeOff className="ml-auto h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canShowGuides && (
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => keepOpen(e, () => useViewer.getState().setShowGuides(!showGuides))}
|
||||
>
|
||||
<img alt="" className="h-4 w-4 object-contain" src="/icons/floorplan.webp" />
|
||||
<span>Guides</span>
|
||||
{showGuides ? (
|
||||
<Eye className="ml-auto h-4 w-4 text-foreground" />
|
||||
) : (
|
||||
<EyeOff className="ml-auto h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
// One "Display" button gathering shadows, camera projection, colors, render
|
||||
// mode, scene theme and edges.
|
||||
function DisplayMenu() {
|
||||
const cameraMode = useViewer((s) => s.cameraMode)
|
||||
const shading = useViewer((s) => s.shading)
|
||||
const textures = useViewer((s) => s.textures)
|
||||
const shadows = useViewer((s) => s.shadows)
|
||||
const sceneTheme = useViewer((s) => s.sceneTheme)
|
||||
const edges = useViewer((s) => s.edges)
|
||||
const activeShading = SHADING_OPTIONS.find((o) => o.id === shading) ?? SHADING_OPTIONS[0]
|
||||
const activeTheme = getSceneTheme(sceneTheme)
|
||||
const activeEdges = EDGE_OPTIONS.find((o) => o.id === edges) ?? EDGE_OPTIONS[0]
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<ActionButton
|
||||
className="hover:bg-white/5 hover:text-foreground"
|
||||
label="Display settings"
|
||||
size="icon"
|
||||
tooltipSide="top"
|
||||
variant="ghost"
|
||||
>
|
||||
<SlidersHorizontal className="h-5 w-5" />
|
||||
</ActionButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="min-w-56" side="top">
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => keepOpen(e, () => useViewer.getState().setShadows(!shadows))}
|
||||
>
|
||||
<Contrast className="h-4 w-4" />
|
||||
<span>Shadows</span>
|
||||
<span className="ml-auto text-muted-foreground text-xs">{shadows ? 'On' : 'Off'}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) =>
|
||||
keepOpen(e, () =>
|
||||
useViewer
|
||||
.getState()
|
||||
.setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective'),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Camera className="h-4 w-4" />
|
||||
<span>Camera</span>
|
||||
<span className="ml-auto text-muted-foreground text-xs">
|
||||
{cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => keepOpen(e, () => useViewer.getState().setTextures(!textures))}
|
||||
>
|
||||
{textures ? <Palette className="h-4 w-4" /> : <Square className="h-4 w-4" />}
|
||||
<span>Colors</span>
|
||||
<span className="ml-auto text-muted-foreground text-xs">
|
||||
{textures ? 'Colored' : 'Monochrome'}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<activeShading.icon className="h-4 w-4" />
|
||||
<span>Render</span>
|
||||
<span className="ml-auto text-muted-foreground text-xs">{activeShading.name}</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="min-w-56">
|
||||
{SHADING_OPTIONS.map((option) => {
|
||||
const OptionIcon = option.icon
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={option.id}
|
||||
onSelect={() => useViewer.getState().setShading(option.id)}
|
||||
>
|
||||
<OptionIcon className="h-4 w-4" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-foreground">{option.name}</span>
|
||||
<span className="text-muted-foreground text-xs">{option.detail}</span>
|
||||
</div>
|
||||
{shading === option.id ? (
|
||||
<Check className="ml-auto h-4 w-4 text-foreground" />
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<SwatchBook className="h-4 w-4" />
|
||||
<span>Theme</span>
|
||||
<span className="ml-auto truncate text-muted-foreground text-xs">
|
||||
{activeTheme.name}
|
||||
</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="min-w-48">
|
||||
{SCENE_THEMES.map((t) => {
|
||||
const swatches = (['wall', 'roof', 'floor', 'glazing'] as const).map(
|
||||
(role) => t.clayTints?.[role] ?? CLAY_PALETTE[role],
|
||||
)
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
className="gap-2"
|
||||
key={t.id}
|
||||
onSelect={() => useViewer.getState().setSceneTheme(t.id)}
|
||||
>
|
||||
<span
|
||||
className="grid h-5 w-5 shrink-0 grid-cols-2 overflow-hidden rounded-sm border border-black/10"
|
||||
style={{ backgroundColor: t.background }}
|
||||
>
|
||||
{swatches.map((color, index) => (
|
||||
<span key={`${t.id}-${index}`} style={{ backgroundColor: color }} />
|
||||
))}
|
||||
</span>
|
||||
<span>{t.name}</span>
|
||||
{sceneTheme === t.id ? <Check className="ml-auto h-4 w-4" /> : null}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<PenLine className="h-4 w-4" />
|
||||
<span>Edges</span>
|
||||
<span className="ml-auto text-muted-foreground text-xs">{activeEdges.name}</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="min-w-56">
|
||||
{EDGE_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.id}
|
||||
onSelect={() => useViewer.getState().setEdges(option.id)}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-foreground">{option.name}</span>
|
||||
<span className="text-muted-foreground text-xs">{option.detail}</span>
|
||||
</div>
|
||||
{edges === option.id ? <Check className="ml-auto h-4 w-4 text-foreground" /> : null}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export type ViewerControlsBarProps = {
|
||||
canShowScans?: boolean
|
||||
canShowGuides?: boolean
|
||||
/** A baked GLB is the active artifact: hide controls it can't honor (wall
|
||||
* modes aren't baked into the GLB). */
|
||||
glbActive?: boolean
|
||||
/** In GLB mode, whether scans/guides were re-added from scene data — so the
|
||||
* visibility control surfaces the matching toggle even though the artifact
|
||||
* itself carries none. */
|
||||
glbHasScans?: boolean
|
||||
glbHasGuides?: boolean
|
||||
walkthroughActive?: boolean
|
||||
onWalkthroughToggle: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const ViewerControlsBar = ({
|
||||
canShowScans = true,
|
||||
canShowGuides = true,
|
||||
glbActive = false,
|
||||
glbHasScans = false,
|
||||
glbHasGuides = false,
|
||||
walkthroughActive = false,
|
||||
onWalkthroughToggle,
|
||||
className,
|
||||
}: ViewerControlsBarProps) => {
|
||||
const levelMode = useViewer((s) => s.levelMode)
|
||||
const wallMode = useViewer((s) => s.wallMode)
|
||||
// Sessions may carry a stale mode outside the cycle (e.g. the retired
|
||||
// 'translucent'); render and cycle it as cutaway instead of crashing.
|
||||
const safeWallMode = (
|
||||
wallMode in wallModeConfig ? wallMode : 'cutaway'
|
||||
) as keyof typeof wallModeConfig
|
||||
const WallModeIcon = wallModeConfig[safeWallMode].icon
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'dark absolute bottom-4 left-1/2 z-20 -translate-x-1/2 text-foreground sm:bottom-6',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div className="corner-smooth pointer-events-auto flex h-12 max-w-[calc(100vw-1rem)] flex-row items-center justify-center gap-0.5 overflow-hidden rounded-2xl border border-border/40 bg-background/95 p-1 shadow-elevation-4 backdrop-blur-xl transition-colors duration-200 ease-out sm:h-14 sm:gap-1.5 sm:p-1.5">
|
||||
{((canShowScans && (!glbActive || glbHasScans)) ||
|
||||
(canShowGuides && (!glbActive || glbHasGuides))) && (
|
||||
<>
|
||||
<VisibilityMenu
|
||||
canShowGuides={canShowGuides && (!glbActive || glbHasGuides)}
|
||||
canShowScans={canShowScans && (!glbActive || glbHasScans)}
|
||||
/>
|
||||
<div className="mx-1 h-5 w-px bg-border/40" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Level mode */}
|
||||
<ActionButton
|
||||
className={
|
||||
levelMode === 'stacked'
|
||||
? 'hover:bg-white/5 hover:text-amber-400'
|
||||
: 'bg-amber-500/20 text-amber-400'
|
||||
}
|
||||
label={`Levels: ${levelMode === 'manual' ? 'Manual' : levelModeLabels[levelMode as keyof typeof levelModeLabels]}`}
|
||||
onClick={() => {
|
||||
if (levelMode === 'manual') return useViewer.getState().setLevelMode('stacked')
|
||||
const modes: ('stacked' | 'exploded' | 'solo')[] = ['stacked', 'exploded', 'solo']
|
||||
const nextIndex = (modes.indexOf(levelMode as any) + 1) % modes.length
|
||||
useViewer.getState().setLevelMode(modes[nextIndex] ?? 'stacked')
|
||||
}}
|
||||
size="icon"
|
||||
tooltipSide="top"
|
||||
variant="ghost"
|
||||
>
|
||||
{levelMode === 'solo' && <Diamond className="h-6 w-6" />}
|
||||
{levelMode === 'exploded' && <Layers2 className="h-6 w-6" />}
|
||||
{(levelMode === 'stacked' || levelMode === 'manual') && <Layers className="h-6 w-6" />}
|
||||
</ActionButton>
|
||||
|
||||
{/* Wall mode — parametric only; baked GLB walls are fixed-height. */}
|
||||
{!glbActive && (
|
||||
<ActionButton
|
||||
className={
|
||||
safeWallMode === 'cutaway'
|
||||
? 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0'
|
||||
: 'bg-white/10'
|
||||
}
|
||||
label={`Walls: ${wallModeConfig[safeWallMode].label}`}
|
||||
onClick={() => {
|
||||
const modes: ('cutaway' | 'up' | 'down')[] = ['cutaway', 'up', 'down']
|
||||
const nextIndex = (modes.indexOf(safeWallMode) + 1) % modes.length
|
||||
useViewer.getState().setWallMode(modes[nextIndex] ?? 'cutaway')
|
||||
}}
|
||||
size="icon"
|
||||
tooltipSide="top"
|
||||
variant="ghost"
|
||||
>
|
||||
<WallModeIcon className="h-[28px] w-[28px]" />
|
||||
</ActionButton>
|
||||
)}
|
||||
|
||||
<div className="mx-1 h-5 w-px bg-border/40" />
|
||||
|
||||
<DisplayMenu />
|
||||
|
||||
<div className="mx-1 h-5 w-px bg-border/40" />
|
||||
|
||||
{/* Walkthrough */}
|
||||
<ActionButton
|
||||
className={
|
||||
walkthroughActive
|
||||
? 'bg-emerald-500/20 text-emerald-400'
|
||||
: 'hover:bg-white/5 hover:text-emerald-400'
|
||||
}
|
||||
label={`Walkthrough: ${walkthroughActive ? 'On' : 'Off'}`}
|
||||
onClick={onWalkthroughToggle}
|
||||
size="icon"
|
||||
tooltipSide="top"
|
||||
variant="ghost"
|
||||
>
|
||||
<Footprints className="h-6 w-6" />
|
||||
</ActionButton>
|
||||
|
||||
<div className="mx-1 h-5 w-px bg-border/40" />
|
||||
|
||||
{/* Camera actions */}
|
||||
<ActionButton
|
||||
className="group hidden hover:bg-white/5 sm:inline-flex"
|
||||
label="Orbit left"
|
||||
onClick={() => emitter.emit('camera-controls:orbit-ccw')}
|
||||
size="icon"
|
||||
tooltipSide="top"
|
||||
variant="ghost"
|
||||
>
|
||||
<img
|
||||
alt="Orbit left"
|
||||
className="h-[28px] w-[28px] -scale-x-100 object-contain opacity-70 transition-opacity group-hover:opacity-100"
|
||||
src="/icons/rotate.webp"
|
||||
/>
|
||||
</ActionButton>
|
||||
|
||||
<ActionButton
|
||||
className="group hidden hover:bg-white/5 sm:inline-flex"
|
||||
label="Orbit right"
|
||||
onClick={() => emitter.emit('camera-controls:orbit-cw')}
|
||||
size="icon"
|
||||
tooltipSide="top"
|
||||
variant="ghost"
|
||||
>
|
||||
<img
|
||||
alt="Orbit right"
|
||||
className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
|
||||
src="/icons/rotate.webp"
|
||||
/>
|
||||
</ActionButton>
|
||||
|
||||
<ActionButton
|
||||
className="group hover:bg-white/5"
|
||||
label="Top view"
|
||||
onClick={() => emitter.emit('camera-controls:top-view')}
|
||||
size="icon"
|
||||
tooltipSide="top"
|
||||
variant="ghost"
|
||||
>
|
||||
<img
|
||||
alt="Top view"
|
||||
className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
|
||||
src="/icons/topview.webp"
|
||||
/>
|
||||
</ActionButton>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type BuildingNode,
|
||||
getLevelDisplayName,
|
||||
type LevelNode,
|
||||
useScene,
|
||||
type ZoneNode,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { ArrowLeft, ChevronRight, Layers } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { cn } from '../../lib/utils'
|
||||
|
||||
const getNodeName = (node: AnyNode): string => {
|
||||
if ('name' in node && node.name) return node.name
|
||||
if (node.type === 'wall') return 'Wall'
|
||||
if (node.type === 'fence') return 'Fence'
|
||||
if (node.type === 'item') return (node as { asset: { name: string } }).asset?.name || 'Item'
|
||||
if (node.type === 'slab') return 'Slab'
|
||||
if (node.type === 'ceiling') return 'Ceiling'
|
||||
if (node.type === 'roof') return 'Roof'
|
||||
if (node.type === 'roof-segment') return 'Roof Segment'
|
||||
return node.type
|
||||
}
|
||||
|
||||
export type ViewerSceneHeaderProps = {
|
||||
projectName?: string | null
|
||||
owner?: { username?: string | null } | null
|
||||
onBack?: () => void
|
||||
/** Fallback destination when no `onBack` handler is supplied. Must already be
|
||||
* sanitized by the caller. */
|
||||
backHref?: string
|
||||
/** Extra row under the project info (e.g. likes/fork actions). */
|
||||
stats?: ReactNode
|
||||
}
|
||||
|
||||
export const ViewerSceneHeader = ({
|
||||
projectName,
|
||||
owner,
|
||||
onBack,
|
||||
backHref = '/',
|
||||
stats,
|
||||
}: ViewerSceneHeaderProps) => {
|
||||
const selection = useViewer((s) => s.selection)
|
||||
|
||||
// Subscribe only to the specific nodes we read so that creating an unrelated
|
||||
// node elsewhere in the scene doesn't re-render this overlay.
|
||||
const firstSelectedId = selection.selectedIds[0] ?? null
|
||||
const building = useScene((s) =>
|
||||
selection.buildingId ? (s.nodes[selection.buildingId] as BuildingNode | undefined) : null,
|
||||
)
|
||||
const level = useScene((s) =>
|
||||
selection.levelId ? (s.nodes[selection.levelId] as LevelNode | undefined) : null,
|
||||
)
|
||||
const zone = useScene((s) =>
|
||||
selection.zoneId ? (s.nodes[selection.zoneId] as ZoneNode | undefined) : null,
|
||||
)
|
||||
const selectedNode = useScene((s) =>
|
||||
firstSelectedId ? (s.nodes[firstSelectedId as AnyNodeId] as AnyNode | undefined) : null,
|
||||
)
|
||||
// Highest first so the list reads top-down like a building section.
|
||||
const levels = useScene(
|
||||
useShallow((s) => {
|
||||
if (!building) return []
|
||||
return building.children
|
||||
.map((id) => s.nodes[id as AnyNodeId] as LevelNode | undefined)
|
||||
.filter((n): n is LevelNode => n?.type === 'level')
|
||||
.sort((a, b) => b.level - a.level)
|
||||
}),
|
||||
)
|
||||
|
||||
const handleLevelClick = (levelId: LevelNode['id']) => {
|
||||
// When switching levels, deselect zone and items
|
||||
useViewer.getState().setSelection({ levelId })
|
||||
}
|
||||
|
||||
const handleBreadcrumbClick = (depth: 'root' | 'building' | 'level') => {
|
||||
switch (depth) {
|
||||
case 'root':
|
||||
useViewer.getState().resetSelection()
|
||||
break
|
||||
case 'building':
|
||||
useViewer.getState().setSelection({ levelId: null })
|
||||
break
|
||||
case 'level':
|
||||
useViewer.getState().setSelection({ zoneId: null })
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dark absolute top-4 left-4 z-20 flex flex-col gap-3 text-foreground">
|
||||
<div className="corner-smooth pointer-events-auto flex min-w-[200px] flex-col overflow-hidden rounded-2xl border border-border/40 bg-background/95 shadow-elevation-4 backdrop-blur-xl transition-colors duration-200 ease-out">
|
||||
{/* Project info + back */}
|
||||
<div className="flex items-center gap-3 px-3 py-2.5">
|
||||
{onBack ? (
|
||||
<button
|
||||
aria-label="Back"
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md transition-colors hover:bg-white/10"
|
||||
onClick={onBack}
|
||||
type="button"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
aria-label="Back"
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md transition-colors hover:bg-white/10"
|
||||
href={backHref}
|
||||
prefetch={false}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 text-muted-foreground" />
|
||||
</Link>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium text-foreground text-sm">
|
||||
{projectName || 'Untitled'}
|
||||
</div>
|
||||
{owner?.username && (
|
||||
<Link
|
||||
className="text-muted-foreground text-xs transition-colors hover:text-foreground"
|
||||
href={`/u/${owner.username}`}
|
||||
>
|
||||
@{owner.username}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stats && (
|
||||
<div className="flex items-center gap-1 border-border/40 border-t px-3 py-2">{stats}</div>
|
||||
)}
|
||||
|
||||
{/* Breadcrumb — only shown when navigated into a building */}
|
||||
{building && (
|
||||
<div className="border-border/40 border-t px-3 py-2">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<button
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={() => handleBreadcrumbClick('root')}
|
||||
>
|
||||
Site
|
||||
</button>
|
||||
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground/50" />
|
||||
<button
|
||||
className={`truncate transition-colors ${level ? 'text-muted-foreground hover:text-foreground' : 'font-medium text-foreground'}`}
|
||||
onClick={() => handleBreadcrumbClick('building')}
|
||||
>
|
||||
{building.name || 'Building'}
|
||||
</button>
|
||||
|
||||
{level && (
|
||||
<>
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground/50" />
|
||||
<button
|
||||
className={`truncate transition-colors ${zone ? 'text-muted-foreground hover:text-foreground' : 'font-medium text-foreground'}`}
|
||||
onClick={() => handleBreadcrumbClick('level')}
|
||||
>
|
||||
{getLevelDisplayName(level)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{zone && (
|
||||
<>
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground/50" />
|
||||
<span
|
||||
className={`truncate transition-colors ${selectedNode ? 'text-muted-foreground' : 'font-medium text-foreground'}`}
|
||||
>
|
||||
{zone.name}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedNode && zone && (
|
||||
<>
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground/50" />
|
||||
<span className="truncate font-medium text-foreground">
|
||||
{getNodeName(selectedNode)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Level list (only when a building is selected) */}
|
||||
{building && levels.length > 0 && (
|
||||
<div className="corner-smooth pointer-events-auto flex w-48 flex-col overflow-hidden rounded-2xl border border-border/40 bg-background/95 py-1 shadow-elevation-4 backdrop-blur-xl transition-colors duration-200 ease-out">
|
||||
<span className="px-3 py-2 font-medium text-[10px] text-muted-foreground uppercase tracking-wider">
|
||||
Levels
|
||||
</span>
|
||||
<div className="flex flex-col">
|
||||
{levels.map((lvl) => {
|
||||
const isSelected = lvl.id === selection.levelId
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'group/row relative flex h-8 w-full cursor-pointer select-none items-center border-border/50 border-r border-r-transparent border-b px-3 text-sm transition-all duration-200',
|
||||
isSelected
|
||||
? 'border-r-3 border-r-white bg-accent/50 text-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent/30 hover:text-foreground',
|
||||
)}
|
||||
key={lvl.id}
|
||||
onClick={() => handleLevelClick(lvl.id)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-4 w-4 shrink-0 items-center justify-center transition-all duration-200',
|
||||
!isSelected && 'opacity-60 grayscale',
|
||||
)}
|
||||
>
|
||||
<Layers className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 truncate text-left">
|
||||
{getLevelDisplayName(lvl)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '../lib/utils'
|
||||
import type { WalkthroughInteract } from '../store/use-first-person-hud'
|
||||
|
||||
export type { WalkthroughInteract } from '../store/use-first-person-hud'
|
||||
|
||||
export type WalkthroughHudProps = {
|
||||
floorLabel?: string | null
|
||||
zoneLabel?: string | null
|
||||
interact?: WalkthroughInteract
|
||||
/** Pointer lock temporarily released (OS screenshot) — the pill flips to
|
||||
* "Click to resume" and lets clicks fall through to the canvas. */
|
||||
suspended?: boolean
|
||||
onExit?: () => void
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export function WalkthroughHud({
|
||||
floorLabel,
|
||||
zoneLabel,
|
||||
interact = null,
|
||||
suspended = false,
|
||||
onExit,
|
||||
children,
|
||||
}: WalkthroughHudProps) {
|
||||
const kbdClass = 'rounded border border-border/60 bg-white/10 px-1.5 py-0.5 font-mono text-[10px]'
|
||||
const pillClass =
|
||||
'flex items-center gap-1.5 rounded-full border border-border/40 bg-background/70 px-3 py-1 text-muted-foreground text-xs backdrop-blur-xl'
|
||||
const exitContent = (
|
||||
<>
|
||||
<kbd className={kbdClass}>Esc</kbd>
|
||||
to exit
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="dark pointer-events-none absolute inset-0 z-30 text-foreground">
|
||||
<div className="absolute top-6 left-1/2 flex -translate-x-1/2 flex-col items-center gap-1.5">
|
||||
{floorLabel && (
|
||||
<div className="font-medium text-[10px] text-muted-foreground uppercase tracking-wider">
|
||||
{floorLabel}
|
||||
</div>
|
||||
)}
|
||||
{zoneLabel && (
|
||||
<div className="corner-smooth rounded-full border border-border/40 bg-background/80 px-3 py-1 font-medium text-sm shadow-elevation-3 backdrop-blur-xl">
|
||||
{zoneLabel}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-full transition-all duration-150',
|
||||
interact
|
||||
? 'h-4 w-4 border-2 border-emerald-400 bg-emerald-400/10'
|
||||
: 'h-1.5 w-1.5 bg-white/80 shadow-[0_0_2px_rgba(0,0,0,0.6)]',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-6 left-1/2 flex -translate-x-1/2 items-center gap-2">
|
||||
{suspended ? (
|
||||
<div className={pillClass}>
|
||||
<span className="font-medium text-foreground">Click</span>
|
||||
<span>or</span>
|
||||
<kbd className={kbdClass}>P</kbd>
|
||||
<span>to resume</span>
|
||||
<span className="text-muted-foreground/60">·</span>
|
||||
{exitContent}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={pillClass}>
|
||||
<kbd className={kbdClass}>P</kbd>
|
||||
free cursor
|
||||
</div>
|
||||
{onExit ? (
|
||||
<button
|
||||
className={cn(pillClass, 'pointer-events-auto')}
|
||||
onClick={onExit}
|
||||
type="button"
|
||||
>
|
||||
{exitContent}
|
||||
</button>
|
||||
) : (
|
||||
<div className={pillClass}>{exitContent}</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{interact && (
|
||||
<div className="absolute top-1/2 left-1/2 mt-7 -translate-x-1/2 whitespace-nowrap">
|
||||
<div className="corner-smooth flex items-center gap-1.5 rounded-full border border-border/40 bg-background/80 px-3 py-1 text-xs shadow-elevation-3 backdrop-blur-xl">
|
||||
<kbd className="rounded border border-border/60 bg-white/10 px-1.5 py-0.5 font-mono text-[10px]">
|
||||
E
|
||||
</kbd>
|
||||
<span className="text-muted-foreground">or click to</span>
|
||||
<span className="font-medium">
|
||||
{interact.verb} {interact.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -26,6 +26,7 @@ export { default as Editor } from './components/editor'
|
||||
// surface uses the shorter, shell-friendly names from the unified
|
||||
// preset-system spec.
|
||||
export { BakeExporter } from './components/editor/bake-exporter'
|
||||
export { FirstPersonControls } from './components/editor/first-person-controls'
|
||||
export { FloatingActionMenu as FloatingMenu } from './components/editor/floating-action-menu'
|
||||
// Embed surface — the editor's real in-canvas affordances, so a host can mount
|
||||
// authentic selection handles, interactive build tools, and the mover on top
|
||||
@@ -261,6 +262,19 @@ export {
|
||||
SnapTargetBadge,
|
||||
SnapTargetIcon,
|
||||
} from './components/ui/snap-target-badge'
|
||||
export {
|
||||
ViewerControlsBar,
|
||||
type ViewerControlsBarProps,
|
||||
} from './components/viewer/viewer-controls-bar'
|
||||
export {
|
||||
ViewerSceneHeader,
|
||||
type ViewerSceneHeaderProps,
|
||||
} from './components/viewer/viewer-scene-header'
|
||||
export {
|
||||
WalkthroughHud,
|
||||
type WalkthroughHudProps,
|
||||
type WalkthroughInteract,
|
||||
} from './components/walkthrough-hud'
|
||||
export type { SaveStatus } from './hooks/use-auto-save'
|
||||
// useDragAction is the React-side glue for the registry's DragAction
|
||||
// primitive. Public so registry-driven kinds (Phase 5+ Stage D ports)
|
||||
@@ -470,6 +484,7 @@ export {
|
||||
} from './store/use-editor'
|
||||
export { default as useFacingPose, type FacingPose } from './store/use-facing-pose'
|
||||
export { default as useFenceCurveDraft } from './store/use-fence-curve-draft'
|
||||
export { type FirstPersonHudState, useFirstPersonHud } from './store/use-first-person-hud'
|
||||
export { useFloorplanDraftPreview } from './store/use-floorplan-draft-preview'
|
||||
export {
|
||||
default as useInteractionScope,
|
||||
|
||||
@@ -15,7 +15,7 @@ type DoorOpenAnimationOptions = {
|
||||
persist?: boolean
|
||||
}
|
||||
|
||||
function getDisplayedDoorValue(
|
||||
export function getDisplayedDoorValue(
|
||||
doorId: AnyNodeId,
|
||||
field: keyof DoorInteractiveState,
|
||||
nodeValue: number | undefined,
|
||||
|
||||
@@ -23,7 +23,7 @@ export function isOperableWindowType(windowType: string | undefined) {
|
||||
)
|
||||
}
|
||||
|
||||
function getDisplayedWindowValue(windowId: AnyNodeId, nodeValue: number | undefined) {
|
||||
export function getDisplayedWindowValue(windowId: AnyNodeId, nodeValue: number | undefined) {
|
||||
const interactive = useInteractive.getState()
|
||||
const runtimeValue = interactive.windows[windowId]?.operationState
|
||||
if (runtimeValue !== undefined) return runtimeValue
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
export type WalkthroughInteract = { label: string; verb: string } | null
|
||||
|
||||
export type FirstPersonHudState = {
|
||||
floorLabel: string | null
|
||||
zoneLabel: string | null
|
||||
interact: WalkthroughInteract
|
||||
setHud: (hud: Partial<Pick<FirstPersonHudState, 'floorLabel' | 'zoneLabel' | 'interact'>>) => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export const useFirstPersonHud = create<FirstPersonHudState>((set) => ({
|
||||
floorLabel: null,
|
||||
zoneLabel: null,
|
||||
interact: null,
|
||||
setHud: (hud) =>
|
||||
set((state) => {
|
||||
const floorLabel = hud.floorLabel === undefined ? state.floorLabel : hud.floorLabel
|
||||
const zoneLabel = hud.zoneLabel === undefined ? state.zoneLabel : hud.zoneLabel
|
||||
const interact = hud.interact === undefined ? state.interact : hud.interact
|
||||
|
||||
if (
|
||||
floorLabel === state.floorLabel &&
|
||||
zoneLabel === state.zoneLabel &&
|
||||
interact?.label === state.interact?.label &&
|
||||
interact?.verb === state.interact?.verb
|
||||
) {
|
||||
return state
|
||||
}
|
||||
|
||||
return { floorLabel, zoneLabel, interact }
|
||||
}),
|
||||
reset: () =>
|
||||
set((state) =>
|
||||
state.floorLabel === null && state.zoneLabel === null && state.interact === null
|
||||
? state
|
||||
: { floorLabel: null, zoneLabel: null, interact: null },
|
||||
),
|
||||
}))
|
||||
@@ -111,7 +111,7 @@ export default function WallPanel() {
|
||||
// renders at. `undefined` for walls with an explicit custom height.
|
||||
const planeBoundHeightMeters = useScene((s) => {
|
||||
const wall = selectedId ? (s.nodes[selectedId as AnyNodeId] as WallNode | undefined) : undefined
|
||||
if (!wall || wall.type !== 'wall' || wall.height != null) return undefined
|
||||
if (wall?.type !== 'wall' || wall.height != null) return undefined
|
||||
return resolveWallOpeningCeiling(wall, s.nodes)
|
||||
})
|
||||
|
||||
|
||||
@@ -923,7 +923,7 @@ export function GlbScene({
|
||||
})
|
||||
|
||||
// E or click activates the openable in view. The click also re-locks the
|
||||
// pointer via WalkthroughControls — harmless overlap; no selection happens.
|
||||
// pointer through the walkthrough controller; no selection happens.
|
||||
const activateWalkDoor = useCallback(() => {
|
||||
if (walkDoorRef.current) toggleOpenable(walkDoorRef.current)
|
||||
}, [toggleOpenable])
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type Object3D,
|
||||
type PerspectiveCamera,
|
||||
Quaternion,
|
||||
Raycaster,
|
||||
Vector3,
|
||||
} from 'three'
|
||||
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
||||
@@ -25,7 +26,12 @@ import { useGLTFKTX2 } from '../../hooks/use-gltf-ktx2'
|
||||
import { SCENE_LAYER } from '../../lib/layers'
|
||||
import useViewer from '../../store/use-viewer'
|
||||
import BVHEcctrl, { type BVHEcctrlApi, type MovementInput } from './bvh-ecctrl'
|
||||
import { WALKTHROUGH_FOV } from './walkthrough-controls'
|
||||
|
||||
// First-person FOV. The orbit camera is 50° (set on the Canvas), which feels
|
||||
// cramped on foot; ~60° vertical (~90° horizontal at 16:9) restores peripheral
|
||||
// awareness without wide-angle distortion. Applied only while walking — both
|
||||
// walkthrough controllers read this and restore the orbit FOV on exit.
|
||||
export const WALKTHROUGH_FOV = 60
|
||||
|
||||
// Eye/capsule geometry mirrors the editor's first-person controller so the
|
||||
// baked walkthrough feels identical. The capsule centre sits below the eye; the
|
||||
@@ -37,6 +43,28 @@ const SPAWN_EYE_HEIGHT = 1.65
|
||||
const LOOK_SENSITIVITY = 0.002
|
||||
const VOID_FALL_RESPAWN_DEPTH = 12
|
||||
|
||||
// Crouch (hold Ctrl): swap to a short capsule — it shrinks around the centre,
|
||||
// so a crouch mid-jump also lowers the head AND raises the feet, letting the
|
||||
// player thread window openings. Standing back up is gated on headroom.
|
||||
// The float gap counts toward the effective obstacle height (the capsule rides
|
||||
// floatHeight above the ground), so crouching also lowers it: crouched span is
|
||||
// CROUCH_FLOAT_HEIGHT + capsule = 0.25 + 0.7 = 0.95 m — fits a 1 m opening.
|
||||
export const STAND_CAPSULE: [number, number, number, number] = [0.25, 0.8, 4, 8]
|
||||
export const CROUCH_CAPSULE: [number, number, number, number] = [0.25, 0.2, 4, 8]
|
||||
export const STAND_FLOAT_HEIGHT = 0.5
|
||||
export const CROUCH_FLOAT_HEIGHT = 0.25
|
||||
export const CROUCH_EYE_OFFSET = 0.1
|
||||
export const CROUCH_WALK_SPEED = 1
|
||||
export const CROUCH_RUN_SPEED = 1.4
|
||||
// Headroom (from the capsule centre, upward) required before uncrouching:
|
||||
// standing raises the centre by half the length delta plus the float delta,
|
||||
// and the standing capsule top sits standLength/2 + radius above the centre.
|
||||
export const STAND_CLEARANCE = 1.25
|
||||
export const EYE_LERP_SPEED = 12
|
||||
|
||||
const standClearanceRaycaster = new Raycaster()
|
||||
const UP = new Vector3(0, 1, 0)
|
||||
|
||||
// Kinds that must not block the player: room helpers, the spawn marker, the
|
||||
// ceiling/roof shell (you walk under them), and door/window leaves — excluding
|
||||
// the latter lets you pass any doorway whether the leaf is open or shut (the
|
||||
@@ -54,7 +82,7 @@ const keyboardMap: Array<{ name: Exclude<keyof MovementInput, 'joystick'>; keys:
|
||||
{ name: 'run', keys: ['ShiftLeft', 'ShiftRight'] },
|
||||
]
|
||||
|
||||
const cameraOffset = new Vector3(0, CAMERA_EYE_OFFSET, 0)
|
||||
const cameraOffset = new Vector3()
|
||||
const cameraEuler = new Euler(0, 0, 0, 'YXZ')
|
||||
const spawnQuat = new Quaternion()
|
||||
const spawnEuler = new Euler(0, 0, 0, 'YXZ')
|
||||
@@ -238,6 +266,10 @@ export function GlbWalkthroughController({ url }: { url: string }) {
|
||||
const controllerRef = useRef<BVHEcctrlApi | null>(null)
|
||||
const yawRef = useRef(0)
|
||||
const pitchRef = useRef(0)
|
||||
const crouchKeyRef = useRef(false)
|
||||
const suspendRef = useRef(false)
|
||||
const eyeOffsetRef = useRef(CAMERA_EYE_OFFSET)
|
||||
const [crouched, setCrouched] = useState(false)
|
||||
const [start, setStart] = useState<{ position: [number, number, number] } | null>(null)
|
||||
const [world, setWorld] = useState<GlbColliderWorld | null>(null)
|
||||
|
||||
@@ -333,20 +365,58 @@ export function GlbWalkthroughController({ url }: { url: string }) {
|
||||
if (event.code === 'Escape' && document.pointerLockElement !== canvas) {
|
||||
useViewer.getState().setWalkthroughMode(false)
|
||||
}
|
||||
// P toggles a cursor pause (advertised in the HUD): frees the pointer
|
||||
// without leaving the walkthrough — e.g. for an OS screenshot, which
|
||||
// needs a movable cursor — and click or P resumes.
|
||||
if (event.code === 'KeyP') {
|
||||
if (document.pointerLockElement === canvas) {
|
||||
suspendRef.current = true
|
||||
useViewer.getState().setWalkthroughSuspended(true)
|
||||
document.exitPointerLock()
|
||||
} else if (suspendRef.current) {
|
||||
const result = canvas.requestPointerLock?.() as Promise<void> | undefined
|
||||
if (result && typeof result.catch === 'function') result.catch(() => {})
|
||||
}
|
||||
}
|
||||
// While paused (P), crouch is frozen as-is — ⌃⇧⌘4 (clipboard screenshot)
|
||||
// must not toggle it under the user.
|
||||
if ((event.code === 'ControlLeft' || event.code === 'ControlRight') && !suspendRef.current) {
|
||||
crouchKeyRef.current = true
|
||||
}
|
||||
}
|
||||
const onKeyUp = (event: KeyboardEvent) => {
|
||||
if ((event.code === 'ControlLeft' || event.code === 'ControlRight') && !suspendRef.current) {
|
||||
crouchKeyRef.current = false
|
||||
}
|
||||
}
|
||||
const onBlur = () => {
|
||||
if (!suspendRef.current) crouchKeyRef.current = false
|
||||
}
|
||||
const onPointerLockChange = () => {
|
||||
if (document.pointerLockElement === canvas) wasLocked = true
|
||||
else if (wasLocked) useViewer.getState().setWalkthroughMode(false)
|
||||
if (document.pointerLockElement === canvas) {
|
||||
wasLocked = true
|
||||
suspendRef.current = false
|
||||
useViewer.getState().setWalkthroughSuspended(false)
|
||||
} else if (suspendRef.current) {
|
||||
// Deliberately released (screenshot pause) — stay in walkthrough.
|
||||
} else if (wasLocked) {
|
||||
useViewer.getState().setWalkthroughMode(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
canvas.addEventListener('click', onClick)
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
document.addEventListener('keyup', onKeyUp)
|
||||
window.addEventListener('blur', onBlur)
|
||||
document.addEventListener('pointerlockchange', onPointerLockChange)
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
canvas.removeEventListener('click', onClick)
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
document.removeEventListener('keyup', onKeyUp)
|
||||
window.removeEventListener('blur', onBlur)
|
||||
document.removeEventListener('pointerlockchange', onPointerLockChange)
|
||||
useViewer.getState().setWalkthroughSuspended(false)
|
||||
if (document.pointerLockElement === canvas) document.exitPointerLock()
|
||||
}
|
||||
}, [gl])
|
||||
@@ -367,8 +437,16 @@ export function GlbWalkthroughController({ url }: { url: string }) {
|
||||
controllerRef.current = api
|
||||
}, [])
|
||||
|
||||
const hasStandingClearance = useCallback((position: Vector3) => {
|
||||
const mesh = worldRef.current?.mesh
|
||||
if (!mesh) return true
|
||||
standClearanceRaycaster.set(position, UP)
|
||||
standClearanceRaycaster.far = STAND_CLEARANCE
|
||||
return standClearanceRaycaster.intersectObject(mesh, false).length === 0
|
||||
}, [])
|
||||
|
||||
// Drive the camera from the capsule each frame + respawn if it falls into void.
|
||||
useFrame(() => {
|
||||
useFrame((_, delta) => {
|
||||
const group = controllerRef.current?.group
|
||||
if (!group) return
|
||||
|
||||
@@ -377,8 +455,18 @@ export function GlbWalkthroughController({ url }: { url: string }) {
|
||||
controllerRef.current?.resetLinVel()
|
||||
}
|
||||
|
||||
// Crouch follows the held key; standing back up waits for headroom.
|
||||
// Frozen while the cursor pause is active.
|
||||
if (!suspendRef.current && crouchKeyRef.current !== crouched) {
|
||||
if (crouchKeyRef.current) setCrouched(true)
|
||||
else if (hasStandingClearance(group.position)) setCrouched(false)
|
||||
}
|
||||
const targetEyeOffset = crouched ? CROUCH_EYE_OFFSET : CAMERA_EYE_OFFSET
|
||||
eyeOffsetRef.current +=
|
||||
(targetEyeOffset - eyeOffsetRef.current) * Math.min(1, delta * EYE_LERP_SPEED)
|
||||
|
||||
group.rotation.y = 0
|
||||
camera.position.copy(group.position).add(cameraOffset)
|
||||
camera.position.copy(group.position).add(cameraOffset.set(0, eyeOffsetRef.current, 0))
|
||||
cameraEuler.set(pitchRef.current, yawRef.current, 0, 'YXZ')
|
||||
camera.quaternion.setFromEuler(cameraEuler)
|
||||
camera.updateMatrixWorld(true)
|
||||
@@ -391,7 +479,7 @@ export function GlbWalkthroughController({ url }: { url: string }) {
|
||||
<BVHEcctrl
|
||||
acceleration={26}
|
||||
airDragFactor={0.3}
|
||||
colliderCapsuleArgs={[0.25, 0.8, 4, 8]}
|
||||
colliderCapsuleArgs={crouched ? CROUCH_CAPSULE : STAND_CAPSULE}
|
||||
colliderMeshes={[world.mesh]}
|
||||
collisionCheckIteration={3}
|
||||
collisionPushBackDamping={0.1}
|
||||
@@ -401,15 +489,15 @@ export function GlbWalkthroughController({ url }: { url: string }) {
|
||||
fallGravityFactor={4}
|
||||
floatCheckType="BOTH"
|
||||
floatDampingC={36}
|
||||
floatHeight={0.5}
|
||||
floatHeight={crouched ? CROUCH_FLOAT_HEIGHT : STAND_FLOAT_HEIGHT}
|
||||
floatPullBackHeight={0.35}
|
||||
floatSensorRadius={0.15}
|
||||
floatSpringK={1200}
|
||||
gravity={9.81}
|
||||
jumpVel={5}
|
||||
maxRunSpeed={5}
|
||||
maxRunSpeed={crouched ? CROUCH_RUN_SPEED : 5}
|
||||
maxSlope={1.2}
|
||||
maxWalkSpeed={2}
|
||||
maxWalkSpeed={crouched ? CROUCH_WALK_SPEED : 2}
|
||||
position={start.position}
|
||||
ref={setControllerApi}
|
||||
/>
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { PointerLockControls } from '@react-three/drei'
|
||||
import { useFrame, useThree } from '@react-three/fiber'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { type PerspectiveCamera, Vector3 } from 'three'
|
||||
import useViewer from '../../store/use-viewer'
|
||||
|
||||
const MOVE_SPEED = 5
|
||||
const EYE_HEIGHT = 1.6
|
||||
|
||||
// First-person FOV. The orbit camera is 50° (set on the Canvas), which feels
|
||||
// cramped on foot; ~60° vertical (~90° horizontal at 16:9) restores peripheral
|
||||
// awareness without wide-angle distortion. Applied only while walking — both
|
||||
// walkthrough controllers read this and restore the orbit FOV on exit.
|
||||
export const WALKTHROUGH_FOV = 60
|
||||
|
||||
const _direction = new Vector3()
|
||||
const _forward = new Vector3()
|
||||
const _right = new Vector3()
|
||||
|
||||
export const WalkthroughControls = () => {
|
||||
const controlsRef = useRef<any>(null!)
|
||||
const walkthroughMode = useViewer((s: any) => s.walkthroughMode)
|
||||
const keys = useRef({ w: false, a: false, s: false, d: false })
|
||||
const camera = useThree((s) => s.camera)
|
||||
|
||||
// Set initial eye height
|
||||
useEffect(() => {
|
||||
if (walkthroughMode) {
|
||||
camera.position.y = EYE_HEIGHT
|
||||
}
|
||||
}, [walkthroughMode, camera])
|
||||
|
||||
// Widen FOV while walking; restore the orbit FOV on exit.
|
||||
useEffect(() => {
|
||||
if (!walkthroughMode) return
|
||||
const cam = camera as PerspectiveCamera
|
||||
if (!cam.isPerspectiveCamera) return
|
||||
const prevFov = cam.fov
|
||||
cam.fov = WALKTHROUGH_FOV
|
||||
cam.updateProjectionMatrix()
|
||||
return () => {
|
||||
cam.fov = prevFov
|
||||
cam.updateProjectionMatrix()
|
||||
}
|
||||
}, [walkthroughMode, camera])
|
||||
|
||||
// Keyboard handlers
|
||||
useEffect(() => {
|
||||
if (!walkthroughMode) return
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
|
||||
const key = e.key.toLowerCase()
|
||||
|
||||
// ESC exits walkthrough mode completely
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
useViewer.getState().setWalkthroughMode(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (key === 'w' || key === 'arrowup') keys.current.w = true
|
||||
if (key === 'a' || key === 'arrowleft') keys.current.a = true
|
||||
if (key === 's' || key === 'arrowdown') keys.current.s = true
|
||||
if (key === 'd' || key === 'arrowright') keys.current.d = true
|
||||
}
|
||||
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
const key = e.key.toLowerCase()
|
||||
if (key === 'w' || key === 'arrowup') keys.current.w = false
|
||||
if (key === 'a' || key === 'arrowleft') keys.current.a = false
|
||||
if (key === 's' || key === 'arrowdown') keys.current.s = false
|
||||
if (key === 'd' || key === 'arrowright') keys.current.d = false
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
window.addEventListener('keyup', onKeyUp)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('keyup', onKeyUp)
|
||||
// Reset keys on cleanup
|
||||
keys.current = { w: false, a: false, s: false, d: false }
|
||||
}
|
||||
}, [walkthroughMode])
|
||||
|
||||
// Release pointer lock when walkthrough mode is turned off
|
||||
useEffect(() => {
|
||||
if (!walkthroughMode && document.pointerLockElement) {
|
||||
document.exitPointerLock()
|
||||
}
|
||||
}, [walkthroughMode])
|
||||
|
||||
// Movement loop
|
||||
useFrame((_, delta) => {
|
||||
if (!(walkthroughMode && controlsRef.current)) return
|
||||
|
||||
_direction.set(0, 0, 0)
|
||||
|
||||
// Get camera forward and right vectors (XZ plane only)
|
||||
camera.getWorldDirection(_forward)
|
||||
_forward.y = 0
|
||||
_forward.normalize()
|
||||
|
||||
_right.crossVectors(_forward, camera.up).normalize()
|
||||
|
||||
if (keys.current.w) _direction.add(_forward)
|
||||
if (keys.current.s) _direction.sub(_forward)
|
||||
if (keys.current.d) _direction.add(_right)
|
||||
if (keys.current.a) _direction.sub(_right)
|
||||
|
||||
if (_direction.lengthSq() > 0) {
|
||||
_direction.normalize().multiplyScalar(MOVE_SPEED * delta)
|
||||
camera.position.add(_direction)
|
||||
// Keep eye height constant
|
||||
camera.position.y = EYE_HEIGHT
|
||||
}
|
||||
})
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (walkthroughMode && controlsRef.current) {
|
||||
// Feature detection: some browsers (Facebook/Instagram in-app, older Safari)
|
||||
// don't support pointer lock on the canvas element
|
||||
if (typeof controlsRef.current.lock === 'function') {
|
||||
try {
|
||||
controlsRef.current.lock()
|
||||
} catch {
|
||||
// Silently ignore — pointer lock unavailable in this browser context
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [walkthroughMode])
|
||||
|
||||
// Click to lock
|
||||
useEffect(() => {
|
||||
if (!walkthroughMode) return
|
||||
const canvas = document.querySelector('canvas')
|
||||
if (!canvas) return
|
||||
|
||||
canvas.addEventListener('click', handleClick)
|
||||
return () => canvas.removeEventListener('click', handleClick)
|
||||
}, [walkthroughMode, handleClick])
|
||||
|
||||
if (!walkthroughMode) return null
|
||||
|
||||
// Skip PointerLockControls on browsers that don't support pointer lock
|
||||
// (Facebook/Instagram in-app browsers, some iOS WebViews)
|
||||
if (typeof document !== 'undefined' && !('requestPointerLock' in HTMLElement.prototype)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <PointerLockControls ref={controlsRef} />
|
||||
}
|
||||
@@ -34,14 +34,25 @@ export {
|
||||
GlbScene,
|
||||
type GlbWalkthrough,
|
||||
} from './components/viewer/glb-scene'
|
||||
export { GlbWalkthroughController } from './components/viewer/glb-walkthrough-controller'
|
||||
export {
|
||||
CROUCH_CAPSULE,
|
||||
CROUCH_EYE_OFFSET,
|
||||
CROUCH_FLOAT_HEIGHT,
|
||||
CROUCH_RUN_SPEED,
|
||||
CROUCH_WALK_SPEED,
|
||||
EYE_LERP_SPEED,
|
||||
GlbWalkthroughController,
|
||||
STAND_CAPSULE,
|
||||
STAND_CLEARANCE,
|
||||
STAND_FLOAT_HEIGHT,
|
||||
WALKTHROUGH_FOV,
|
||||
} from './components/viewer/glb-walkthrough-controller'
|
||||
export type { HoverStyle, HoverStyles } from './components/viewer/post-processing'
|
||||
export {
|
||||
DEFAULT_HOVER_STYLES,
|
||||
SSGI_PARAMS,
|
||||
} from './components/viewer/post-processing'
|
||||
export { SceneEnvironment } from './components/viewer/scene-environment'
|
||||
export { WalkthroughControls } from './components/viewer/walkthrough-controls'
|
||||
export { useAssetUrl } from './hooks/use-asset-url'
|
||||
export { useGLTFKTX2 } from './hooks/use-gltf-ktx2'
|
||||
export { useNodeEvents } from './hooks/use-node-events'
|
||||
|
||||
@@ -153,6 +153,11 @@ type ViewerState = {
|
||||
walkthroughMode: boolean
|
||||
setWalkthroughMode: (mode: boolean) => void
|
||||
|
||||
/** Pointer lock temporarily released mid-walkthrough (⌘/PrintScreen — OS
|
||||
* screenshot needs a movable cursor); clicking the canvas re-locks. */
|
||||
walkthroughSuspended: boolean
|
||||
setWalkthroughSuspended: (suspended: boolean) => void
|
||||
|
||||
cameraDragging: boolean
|
||||
setCameraDragging: (dragging: boolean) => void
|
||||
|
||||
@@ -515,7 +520,10 @@ const useViewer = create<ViewerState>()(
|
||||
setDebugColors: (enabled) => set({ debugColors: enabled }),
|
||||
|
||||
walkthroughMode: false,
|
||||
setWalkthroughMode: (mode) => set({ walkthroughMode: mode }),
|
||||
setWalkthroughMode: (mode) => set({ walkthroughMode: mode, walkthroughSuspended: false }),
|
||||
|
||||
walkthroughSuspended: false,
|
||||
setWalkthroughSuspended: (suspended) => set({ walkthroughSuspended: suspended }),
|
||||
|
||||
cameraDragging: false,
|
||||
setCameraDragging: (dragging) => set({ cameraDragging: dragging }),
|
||||
|
||||
Reference in New Issue
Block a user