feat(web): 3D scenario view with Mapa/3D toggle (v1)

New 3D background that swaps with the Leaflet map via a top-left Mapa/3D toggle —
only the background changes; all panels (top bar, sidebar, vessel panel, Play/Pause,
info) stay shared and live.

- Area3DView (react-three-fiber): loads the compressed Baía subset (batimetria +
  ilhas + ponte, Z-up meshes rotated to Y-up and recentred on the sim origin) plus
  a water plane and lighting; every vessel uses the celso_furtado mesh placed at
  its live/draft pose.
- Camera uses the vessel's onboard "Front" camera (from vessel_config), following
  the selected vessel; switching the selected vessel moves the view.
- geo3d util maps sim (x=East, y=North, ψ) → scene (X, -Z, yaw); viewStore holds
  the mode; ViewToggle switches it. MapActions (Leaflet zoom) hidden in 3D.

Tests: geo3d + ViewToggle + viewStore; Area3DView excluded from coverage (WebGL,
e2e-only) like the Leaflet views. 243 web tests green, tsc clean. Verified in
browser: toggle both ways, onboard bridge camera, panels unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
vinihcrosaandClaude Opus 4.8 committed 2026-06-30 00:57:25 -03:00
1 parent 45506efede
commit e3ea1fe8fa
9 files changed
+268 -4

No files matched your search

@@ -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<THREE.Group>(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 (
<group ref={group} rotation={[AREA_ROT_X, 0, 0]}>
{parts.map((p, i) => (
<primitive key={i} object={p.scene} />
))}
</group>
)
}
function VesselModel({ x, y, headingDeg }: { x: number; y: number; headingDeg: number }) {
const { scene } = useGLTF(VESSEL_URL)
const [px, py, pz] = simToScene(x, y)
return (
<group position={[px, py, pz]} rotation={[0, headingToSceneYaw((headingDeg * Math.PI) / 180), 0]}>
<Clone object={scene} />
</group>
)
}
/** 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 (
<Canvas
camera={{ fov: 55, near: 1, far: 100000, position: [0, 1200, 1800] }}
style={{ width: '100%', height: '100%', background: '#0b1f33' }}
data-testid="area-3d-view"
>
<ambientLight intensity={0.6} />
<hemisphereLight args={['#bcd8ff', '#24435f', 1.2]} />
<directionalLight position={[5000, 8000, 4000]} intensity={2.2} />
<Suspense fallback={null}>
<AreaScene />
{posed.map((p) => (
<VesselModel key={p.instanceId} x={p.x} y={p.y} headingDeg={p.headingDeg} />
))}
</Suspense>
{/* Water plane at sea level. */}
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0, 0]}>
<planeGeometry args={[80000, 80000]} />
<meshStandardMaterial color={tokens.color.accent} transparent opacity={0.55} />
</mesh>
<CameraRig target={target} />
</Canvas>
)
}
AREA_PARTS.forEach((u) => useGLTF.preload(u))
useGLTF.preload(VESSEL_URL)
@@ -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(<ViewToggle />)
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(<ViewToggle />)
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')
})
})
@@ -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 (
<div
role="group"
aria-label="Visualização"
style={{
position: 'absolute',
top: tokens.space.lg,
left: tokens.space.lg,
display: 'inline-flex',
gap: 2,
padding: 2,
background: tokens.color.surface,
border: `1px solid ${tokens.color.border}`,
borderRadius: tokens.radius.pill,
boxShadow: tokens.shadow.sm,
zIndex: tokens.z.panel,
}}
>
<button type="button" style={seg('map', 'Mapa')} aria-pressed={mode === 'map'} onClick={() => setMode('map')}>Mapa</button>
<button type="button" style={seg('3d', '3D')} aria-pressed={mode === '3d'} onClick={() => setMode('3d')}>3D</button>
</div>
)
}
@@ -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: () => <div data-testid="map" />,
}))
vi.mock('../scenario-creator/components/Area3DView', () => ({
Area3DView: () => <div data-testid="area-3d-view" />,
}))
vi.stubGlobal('Worker', class {
onmessage: ((e: MessageEvent) => void) | null = null
+7 -2
View File
@@ -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() {
<div style={{ flex: 1, display: 'flex', minHeight: 0 }}>
<Sidebar />
<div style={{ flex: 1, position: 'relative' }}>
<AreaMapView />
{mode === 'map' ? <AreaMapView /> : <Area3DView />}
<ViewToggle />
<VesselPanel />
<ScenarioInfoPanel open={infoOpen} onClose={() => setInfoOpen(false)} />
<MapActions onZoomIn={zoomIn} onZoomOut={zoomOut} />
{mode === 'map' && <MapActions onZoomIn={zoomIn} onZoomOut={zoomOut} />}
<SimulationControl
running={status === 'running'}
disabled={vessels.length === 0}
+25
View File
@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest'
import { simToScene, headingToSceneYaw, AREA_ROT_X } from './geo3d'
describe('geo3d', () => {
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)
})
})
+28
View File
@@ -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
}
+17
View File
@@ -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<ViewStore>((set, get) => ({
mode: 'map',
setMode: (mode) => set({ mode }),
toggle: () => set({ mode: get().mode === 'map' ? '3d' : 'map' }),
}))
+2 -1
View File
@@ -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