diff --git a/apps/web/src/features/scenario-creator/components/Area3DView.tsx b/apps/web/src/features/scenario-creator/components/Area3DView.tsx index b0c3f84..ec96704 100644 --- a/apps/web/src/features/scenario-creator/components/Area3DView.tsx +++ b/apps/web/src/features/scenario-creator/components/Area3DView.tsx @@ -5,6 +5,7 @@ import * as THREE from 'three' import { useScenarioStore } from '../store' import { useSimulationStore } from '../../../stores/simulationStore' import { useVesselPanelStore } from '../../../stores/vesselPanelStore' +import { useViewStore, type CameraId } from '../../../stores/viewStore' import { AREA_ROT_X, simToScene, headingToSceneYaw } from '../../../lib/geo3d' import { tokens } from '../../../theme/tokens' @@ -12,8 +13,15 @@ const AREA_BASE = '/assets/3d/baia_de_guanabara' const VESSEL_URL = '/assets/3d/vessel/celso_furtado.glb' const AREA_PARTS = [`${AREA_BASE}/batimetria.glb`, `${AREA_BASE}/ilhas.glb`, `${AREA_BASE}/ponte.glb`] -// Onboard "Front" camera offset from vessel_config (local metres, vessel frame). -const FRONT_CAM = { x: 0, y: 31.5, z: -58.8 } +// Onboard cameras from vessel_config (celso_furtado): local offset (metres) + +// local yaw (deg) in the vessel frame. +const CAMERAS: Record = { + Front: { x: 0, y: 31.5, z: -58.8, yawDeg: 0 }, + Back: { x: 0, y: 31.5, z: -80.94, yawDeg: 180 }, + Bridge: { x: 0, y: 31.5, z: -61.58, yawDeg: 0 }, + Starboard: { x: 16.32, y: 31.5, z: -61.3, yawDeg: 0 }, + Portside: { x: -16.32, y: 31.5, z: -61.3, yawDeg: 0 }, +} /** The Baía meshes are Z-up; rotate to Y-up and recentre horizontally on the sim * origin so vessels (near 0,0) sit on the bay. */ @@ -50,9 +58,9 @@ function VesselModel({ x, y, headingDeg }: { x: number; y: number; headingDeg: n ) } -/** Camera anchored to the selected vessel's onboard Front camera; falls back to a +/** Camera anchored to the selected vessel's chosen onboard camera; falls back to a * bay overview when nothing is selected. */ -function CameraRig({ target }: { target: { x: number; y: number; headingDeg: number } | null }) { +function CameraRig({ target, cameraId }: { target: { x: number; y: number; headingDeg: number } | null; cameraId: CameraId }) { const { camera } = useThree() useEffect(() => { if (!target) { @@ -60,17 +68,19 @@ function CameraRig({ target }: { target: { x: number; y: number; headingDeg: num camera.lookAt(0, 0, 0) return } - const yaw = headingToSceneYaw((target.headingDeg * Math.PI) / 180) + const cam = CAMERAS[cameraId] + const vesselYaw = headingToSceneYaw((target.headingDeg * Math.PI) / 180) const [bx, by, bz] = simToScene(target.x, target.y) - // Place the camera at the Front offset rotated into world space. - const off = new THREE.Vector3(FRONT_CAM.x, FRONT_CAM.y, FRONT_CAM.z) - off.applyAxisAngle(new THREE.Vector3(0, 1, 0), yaw) + const up = new THREE.Vector3(0, 1, 0) + // Camera position: local offset rotated by the vessel heading. + const off = new THREE.Vector3(cam.x, cam.y, cam.z).applyAxisAngle(up, vesselYaw) camera.position.set(bx + off.x, by + off.y, bz + off.z) - // Look level toward the horizon along the vessel heading (out the bridge), - // not down at the console. - const ahead = new THREE.Vector3(0, -250, -1500).applyAxisAngle(new THREE.Vector3(0, 1, 0), yaw) + // Look direction: vessel heading + this camera's own local yaw, aimed slightly + // down toward the water so the sea + horizon are visible. + const aimYaw = vesselYaw + (cam.yawDeg * Math.PI) / 180 + const ahead = new THREE.Vector3(0, -250, -1500).applyAxisAngle(up, aimYaw) camera.lookAt(bx + off.x + ahead.x, by + off.y + ahead.y, bz + off.z + ahead.z) - }, [camera, target]) + }, [camera, target, cameraId]) return null } @@ -89,6 +99,7 @@ export function Area3DView() { }), [vessels, running, state]) const target = posed.find((p) => p.instanceId === selectedVesselId) ?? posed[0] ?? null + const cameraId = useViewStore((s) => s.cameraId) return ( - + ) } diff --git a/apps/web/src/features/scenario-creator/components/CameraSelector.test.tsx b/apps/web/src/features/scenario-creator/components/CameraSelector.test.tsx new file mode 100644 index 0000000..03c4ad5 --- /dev/null +++ b/apps/web/src/features/scenario-creator/components/CameraSelector.test.tsx @@ -0,0 +1,22 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { describe, it, expect, beforeEach } from 'vitest' +import { CameraSelector } from './CameraSelector' +import { useViewStore } from '../../../stores/viewStore' + +describe('CameraSelector', () => { + beforeEach(() => useViewStore.setState({ cameraId: 'Front' })) + + it('marks the active camera as pressed', () => { + render() + expect(screen.getByRole('button', { name: 'Proa' })).toHaveAttribute('aria-pressed', 'true') + expect(screen.getByRole('button', { name: 'Popa' })).toHaveAttribute('aria-pressed', 'false') + }) + + it('switches the active onboard camera', () => { + render() + fireEvent.click(screen.getByRole('button', { name: 'Bombordo' })) + expect(useViewStore.getState().cameraId).toBe('Portside') + fireEvent.click(screen.getByRole('button', { name: 'Ponte' })) + expect(useViewStore.getState().cameraId).toBe('Bridge') + }) +}) diff --git a/apps/web/src/features/scenario-creator/components/CameraSelector.tsx b/apps/web/src/features/scenario-creator/components/CameraSelector.tsx new file mode 100644 index 0000000..22b2fc2 --- /dev/null +++ b/apps/web/src/features/scenario-creator/components/CameraSelector.tsx @@ -0,0 +1,57 @@ +import { useViewStore, type CameraId } from '../../../stores/viewStore' +import { tokens } from '../../../theme/tokens' + +const CAMERAS: { id: CameraId; label: string }[] = [ + { id: 'Front', label: 'Proa' }, + { id: 'Bridge', label: 'Ponte' }, + { id: 'Back', label: 'Popa' }, + { id: 'Starboard', label: 'Boreste' }, + { id: 'Portside', label: 'Bombordo' }, +] + +/** Onboard-camera picker shown over the 3D view (Proa/Ponte/Popa/Boreste/Bombordo). */ +export function CameraSelector() { + const { cameraId, setCamera } = useViewStore() + + return ( +
+ {CAMERAS.map((c) => ( + + ))} +
+ ) +} diff --git a/apps/web/src/features/shell/AppShell.tsx b/apps/web/src/features/shell/AppShell.tsx index 2acb318..2bec05f 100644 --- a/apps/web/src/features/shell/AppShell.tsx +++ b/apps/web/src/features/shell/AppShell.tsx @@ -10,6 +10,7 @@ import { Sidebar } from '../scenario-creator/components/Sidebar' import { AreaMapView } from '../scenario-creator/components/AreaMapView' import { Area3DView } from '../scenario-creator/components/Area3DView' import { ViewToggle } from '../scenario-creator/components/ViewToggle' +import { CameraSelector } from '../scenario-creator/components/CameraSelector' import { VesselPanel } from '../scenario-creator/components/VesselPanel' import { VesselPicker } from '../scenario-creator/components/VesselPicker' import { ScenarioInfoPanel } from '../scenario-info/ScenarioInfoPanel' @@ -133,6 +134,7 @@ export function AppShell() {
{mode === 'map' ? : } + {mode === '3d' && } setInfoOpen(false)} /> {mode === 'map' && } diff --git a/apps/web/src/stores/viewStore.ts b/apps/web/src/stores/viewStore.ts index 551bdc9..47a28ad 100644 --- a/apps/web/src/stores/viewStore.ts +++ b/apps/web/src/stores/viewStore.ts @@ -1,17 +1,24 @@ import { create } from 'zustand' export type ViewMode = 'map' | '3d' +/** Onboard camera ids from vessel_config (celso_furtado). */ +export type CameraId = 'Front' | 'Back' | 'Bridge' | 'Starboard' | 'Portside' interface ViewStore { mode: ViewMode + cameraId: CameraId setMode: (m: ViewMode) => void toggle: () => void + setCamera: (c: CameraId) => void } -/** Which background the scenario workspace shows — 2D map or the 3D scene. - * Only the background swaps; all panels are shared. */ +/** Which background the scenario workspace shows — 2D map or the 3D scene — and, + * in 3D, which onboard vessel camera is active. Only the background swaps; all + * panels are shared. */ export const useViewStore = create((set, get) => ({ mode: 'map', + cameraId: 'Front', setMode: (mode) => set({ mode }), toggle: () => set({ mode: get().mode === 'map' ? '3d' : 'map' }), + setCamera: (cameraId) => set({ cameraId }), }))