diff --git a/apps/web/src/features/scenario-creator/components/Area3DView.tsx b/apps/web/src/features/scenario-creator/components/Area3DView.tsx new file mode 100644 index 0000000..b0c3f84 --- /dev/null +++ b/apps/web/src/features/scenario-creator/components/Area3DView.tsx @@ -0,0 +1,119 @@ +import { Suspense, useMemo, useRef, useEffect } from 'react' +import { Canvas, useThree } from '@react-three/fiber' +import { useGLTF, Clone } from '@react-three/drei' +import * as THREE from 'three' +import { useScenarioStore } from '../store' +import { useSimulationStore } from '../../../stores/simulationStore' +import { useVesselPanelStore } from '../../../stores/vesselPanelStore' +import { AREA_ROT_X, simToScene, headingToSceneYaw } from '../../../lib/geo3d' +import { tokens } from '../../../theme/tokens' + +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 } + +/** 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. */ +function AreaScene() { + const group = useRef(null) + const parts = AREA_PARTS.map((u) => useGLTF(u)) + + useEffect(() => { + const g = group.current + if (!g) return + // Recentre on the horizontal bounding-box centre (keep vertical as-is). + const box = new THREE.Box3().setFromObject(g) + const c = box.getCenter(new THREE.Vector3()) + g.position.x -= c.x + g.position.z -= c.z + }, []) + + return ( + + {parts.map((p, i) => ( + + ))} + + ) +} + +function VesselModel({ x, y, headingDeg }: { x: number; y: number; headingDeg: number }) { + const { scene } = useGLTF(VESSEL_URL) + const [px, py, pz] = simToScene(x, y) + return ( + + + + ) +} + +/** Camera anchored to the selected vessel's onboard Front camera; falls back to a + * bay overview when nothing is selected. */ +function CameraRig({ target }: { target: { x: number; y: number; headingDeg: number } | null }) { + const { camera } = useThree() + useEffect(() => { + if (!target) { + camera.position.set(0, 1200, 1800) + camera.lookAt(0, 0, 0) + return + } + const yaw = 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) + 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) + camera.lookAt(bx + off.x + ahead.x, by + off.y + ahead.y, bz + off.z + ahead.z) + }, [camera, target]) + return null +} + +export function Area3DView() { + const { vessels } = useScenarioStore() + const { status, state } = useSimulationStore() + const { selectedVesselId } = useVesselPanelStore() + const running = status === 'running' || status === 'paused' + + // Resolve each vessel's current pose (live state while running, draft otherwise). + const posed = useMemo(() => vessels.map((v) => { + const live = running ? state?.vessels.find((s) => s.id === v.instanceId) : null + return live + ? { instanceId: v.instanceId, x: live.x, y: live.y, headingDeg: (live.psi * 180) / Math.PI } + : { instanceId: v.instanceId, x: v.x, y: v.y, headingDeg: v.headingDeg } + }), [vessels, running, state]) + + const target = posed.find((p) => p.instanceId === selectedVesselId) ?? posed[0] ?? null + + return ( + + + + + + + {posed.map((p) => ( + + ))} + + {/* Water plane at sea level. */} + + + + + + + ) +} + +AREA_PARTS.forEach((u) => useGLTF.preload(u)) +useGLTF.preload(VESSEL_URL) diff --git a/apps/web/src/features/scenario-creator/components/ViewToggle.test.tsx b/apps/web/src/features/scenario-creator/components/ViewToggle.test.tsx new file mode 100644 index 0000000..113ca16 --- /dev/null +++ b/apps/web/src/features/scenario-creator/components/ViewToggle.test.tsx @@ -0,0 +1,22 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { describe, it, expect, beforeEach } from 'vitest' +import { ViewToggle } from './ViewToggle' +import { useViewStore } from '../../../stores/viewStore' + +describe('ViewToggle', () => { + beforeEach(() => useViewStore.setState({ mode: 'map' })) + + it('defaults to Mapa selected', () => { + render() + expect(screen.getByRole('button', { name: 'Mapa' })).toHaveAttribute('aria-pressed', 'true') + expect(screen.getByRole('button', { name: '3D' })).toHaveAttribute('aria-pressed', 'false') + }) + + it('switches to 3D and back', () => { + render() + fireEvent.click(screen.getByRole('button', { name: '3D' })) + expect(useViewStore.getState().mode).toBe('3d') + fireEvent.click(screen.getByRole('button', { name: 'Mapa' })) + expect(useViewStore.getState().mode).toBe('map') + }) +}) diff --git a/apps/web/src/features/scenario-creator/components/ViewToggle.tsx b/apps/web/src/features/scenario-creator/components/ViewToggle.tsx new file mode 100644 index 0000000..92b748a --- /dev/null +++ b/apps/web/src/features/scenario-creator/components/ViewToggle.tsx @@ -0,0 +1,44 @@ +import type { CSSProperties } from 'react' +import { useViewStore, type ViewMode } from '../../../stores/viewStore' +import { tokens } from '../../../theme/tokens' + +/** Segmented control (top-left of the workspace) to switch the background between + * the 2D map and the 3D scene. Panels are unaffected. */ +export function ViewToggle() { + const { mode, setMode } = useViewStore() + + const seg = (m: ViewMode, label: string): CSSProperties => ({ + padding: `${tokens.space.xs}px ${tokens.space.md}px`, + border: 'none', + background: mode === m ? tokens.color.accent : 'transparent', + color: mode === m ? tokens.color.textOnDark : tokens.color.textSecondary, + fontFamily: tokens.font.sans, + fontSize: tokens.fontSize.label, + fontWeight: tokens.fontWeight.medium, + cursor: 'pointer', + borderRadius: tokens.radius.pill, + }) + + return ( +
+ + +
+ ) +} diff --git a/apps/web/src/features/shell/AppShell.test.tsx b/apps/web/src/features/shell/AppShell.test.tsx index d08ad11..2bba566 100644 --- a/apps/web/src/features/shell/AppShell.test.tsx +++ b/apps/web/src/features/shell/AppShell.test.tsx @@ -5,10 +5,13 @@ import { AppShell } from './AppShell' import { useScenarioStore } from '../scenario-creator/store' import { useSimulationStore } from '../../stores/simulationStore' -// Leaflet map can't render in jsdom — stub the map surface (covered by e2e). +// Leaflet map + Three canvas can't render in jsdom — stub both backgrounds. vi.mock('../scenario-creator/components/AreaMapView', () => ({ AreaMapView: () =>
, })) +vi.mock('../scenario-creator/components/Area3DView', () => ({ + Area3DView: () =>
, +})) vi.stubGlobal('Worker', class { onmessage: ((e: MessageEvent) => void) | null = null diff --git a/apps/web/src/features/shell/AppShell.tsx b/apps/web/src/features/shell/AppShell.tsx index a23e050..2acb318 100644 --- a/apps/web/src/features/shell/AppShell.tsx +++ b/apps/web/src/features/shell/AppShell.tsx @@ -8,12 +8,15 @@ import { MapActions } from '../../ui/MapActions' import { AlertDialog } from '../../ui/Modal' 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 { VesselPanel } from '../scenario-creator/components/VesselPanel' import { VesselPicker } from '../scenario-creator/components/VesselPicker' import { ScenarioInfoPanel } from '../scenario-info/ScenarioInfoPanel' import { useScenarioStore } from '../scenario-creator/store' import { useSimulationStore } from '../../stores/simulationStore' import { useMapStore } from '../../stores/mapStore' +import { useViewStore } from '../../stores/viewStore' import { tokens } from '../../theme/tokens' const API_BASE = import.meta.env.VITE_API_URL ?? 'http://localhost:3000' @@ -27,6 +30,7 @@ export function AppShell() { const { name, vessels, setName, toCreateScenarioDTO } = useScenarioStore() const { status, state, scenarioVessels, play, pause, loadScenario, applyEnvironment } = useSimulationStore() const { zoomIn, zoomOut } = useMapStore() + const { mode } = useViewStore() const [confirmNoOwnship, setConfirmNoOwnship] = useState(false) const [infoOpen, setInfoOpen] = useState(false) const [pickerOpen, setPickerOpen] = useState(false) @@ -127,10 +131,11 @@ export function AppShell() {
- + {mode === 'map' ? : } + setInfoOpen(false)} /> - + {mode === 'map' && } { + it('maps sim origin to the scene origin', () => { + expect(simToScene(0, 0)).toEqual([0, 0, 0]) + }) + + it('maps East to +X and North to -Z', () => { + expect(simToScene(100, 0)).toEqual([100, 0, 0]) // East + expect(simToScene(0, 100)).toEqual([0, 0, -100]) // North → -Z + }) + + it('keeps an optional height on the Y axis', () => { + expect(simToScene(10, 20, 5)).toEqual([10, 5, -20]) + }) + + it('passes heading through as the scene yaw', () => { + expect(headingToSceneYaw(Math.PI / 2)).toBeCloseTo(Math.PI / 2) + }) + + it('rotates the Z-up area meshes a quarter-turn onto Y-up', () => { + expect(AREA_ROT_X).toBeCloseTo(-Math.PI / 2) + }) +}) diff --git a/apps/web/src/lib/geo3d.ts b/apps/web/src/lib/geo3d.ts new file mode 100644 index 0000000..798f760 --- /dev/null +++ b/apps/web/src/lib/geo3d.ts @@ -0,0 +1,28 @@ +/** + * Maps the simulation's local frame (metres, x=East, y=North, ψ=heading as a math + * angle: 0=East, CCW positive) into the Three.js scene (Y-up, right-handed). + * + * The Baía de Guanabara meshes are exported Z-up (the thin vertical axis is Z), + * so the area group is rotated -90° about X to bring Z→Y. After that rotation the + * horizontal plane is X (East) / Z, with North pointing along -Z. Hence: + * sceneX = x(East) sceneZ = -y(North) sceneY = up + * + * Sign/offset constants are isolated here so the scene can be calibrated against a + * known landmark (e.g. the Rio-Niterói bridge) without touching component code. + */ +export const AREA_ROT_X = -Math.PI / 2 // rotate area meshes from Z-up to Y-up + +/** Convert sim ground coordinates (metres) to a Three.js position on the water plane. */ +export function simToScene(x: number, y: number, height = 0): [number, number, number] { + return [x, height, 0 - y] // `0 - y` avoids a negative-zero on the North axis +} + +/** + * Convert a sim heading ψ (radians, 0=East, CCW) to a Three.js Y-axis rotation. + * In the scene, +X is East and -Z is North; a yaw of 0 should point the model East. + * Three's Y rotation is CCW looking down the +Y axis, which corresponds to the + * East→North sense after the Z flip, so the heading maps directly. + */ +export function headingToSceneYaw(psiRad: number): number { + return psiRad +} diff --git a/apps/web/src/stores/viewStore.ts b/apps/web/src/stores/viewStore.ts new file mode 100644 index 0000000..551bdc9 --- /dev/null +++ b/apps/web/src/stores/viewStore.ts @@ -0,0 +1,17 @@ +import { create } from 'zustand' + +export type ViewMode = 'map' | '3d' + +interface ViewStore { + mode: ViewMode + setMode: (m: ViewMode) => void + toggle: () => void +} + +/** Which background the scenario workspace shows — 2D map or the 3D scene. + * Only the background swaps; all panels are shared. */ +export const useViewStore = create((set, get) => ({ + mode: 'map', + setMode: (mode) => set({ mode }), + toggle: () => set({ mode: get().mode === 'map' ? '3d' : 'map' }), +})) diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index 7b526e3..b77a923 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -21,9 +21,10 @@ export default defineConfig({ 'src/App.tsx', 'src/**/*.d.ts', 'src/**/index.ts', - // Leaflet components — require canvas/browser, tested via E2E + // Leaflet + Three.js scene components — require canvas/WebGL, tested via E2E 'src/features/scenario-creator/components/AreaMapView.tsx', 'src/features/scenario-creator/components/VesselMarker.tsx', + 'src/features/scenario-creator/components/Area3DView.tsx', // Pure layout composition — no logic to unit test 'src/features/scenario-creator/components/Sidebar.tsx', // Web Workers — not runnable in jsdom