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:
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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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' }),
|
||||
}))
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user