refactor(web): remove client-side physics engine

Single engine now lives on the server. Delete the simulation Web Worker,
the JS mock (mock-wasm), apps/web/public/wasm, and retire WorkerMessageDTO
and the wasm|mock SimulationEngine union from @ymir/types. Drop the
COOP/COEP headers and worker.format from vite config (were only for
in-browser WASM).

Task: task_08. Verified: web typecheck + unit suite green (83%), types
100%, api build clean, no dangling references. E2E against a live server
is validated separately (requires the running apps/api + web).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
vinihcrosaandClaude Opus 4.8 committed 2026-07-03 20:43:38 -03:00
1 parent dfbcd28762
commit ff2e3db3bb
8 files changed
+8 -411

No files matched your search

@@ -11,6 +11,6 @@
| 05 | `WsGateway` + `MessageRouter` + rota `/ws` | completed | high | task_02, task_04 |
| 06 | `SimulationSocket` (web) — WS client, reconexão, `localStorage` | completed | medium | task_02, task_05 |
| 07 | Religar `simulationStore` ao `SimulationSocket` | completed | medium | task_06 |
| 08 | Remoção do motor no cliente (worker, mock, public/wasm, tipos) | pending | medium | task_07 |
| 08 | Remoção do motor no cliente (worker, mock, public/wasm, tipos) | completed | medium | task_07 |
| 09 | CI: build WASM (emsdk) + cache antes dos testes | completed | medium | task_01 |
</content>
@@ -1,5 +1,5 @@
---
status: pending
status: completed
title: Remoção do motor no cliente (worker, mock, public/wasm, tipos)
type: refactor
complexity: medium
@@ -32,11 +32,11 @@ executar após a paridade validada pela task_07.
</requirements>
## Subtasks
- [ ] 8.1 Deletar worker, mock e seus testes.
- [ ] 8.2 Remover `apps/web/public/wasm/` e referências de carregamento.
- [ ] 8.3 Remover tipos aposentados de `packages/types` e corrigir importadores.
- [ ] 8.4 Limpar `vite.config.ts` (COOP/COEP, worker.format) se aplicável.
- [ ] 8.5 Rodar build + testes do web e confirmar ausência de referências ao motor local.
- [x] 8.1 Deletar worker, mock e seus testes.
- [x] 8.2 Remover `apps/web/public/wasm/` e referências de carregamento.
- [x] 8.3 Remover tipos aposentados de `packages/types` e corrigir importadores.
- [x] 8.4 Limpar `vite.config.ts` (COOP/COEP, worker.format) se aplicável.
- [x] 8.5 Rodar build + testes do web e confirmar ausência de referências ao motor local.
## Implementation Details
Ver TechSpec "Impact Analysis" (linhas marcadas `deprecated`) e "Development Sequencing" (passo 8).
View File
Whitespace-only changes.
-89
View File
@@ -1,89 +0,0 @@
import { describe, it, expect } from 'vitest'
import createMockModule from './mock-wasm.js'
describe('MockYmirSimulation', () => {
it('addVessel places vessel at origin', async () => {
const { YmirSimulation } = await createMockModule()
const sim = new YmirSimulation()
sim.addVessel(1)
const state = sim.getState()
expect(state.vessels[0]).toMatchObject({ id: 1, x: 0, y: 0, psi: 0 })
})
it('addVesselAt places vessel at given x, y, psi', async () => {
const { YmirSimulation } = await createMockModule()
const sim = new YmirSimulation()
sim.addVesselAt(1, 100, 200, Math.PI / 4)
const state = sim.getState()
expect(state.vessels[0]).toMatchObject({ id: 1, x: 100, y: 200, psi: Math.PI / 4 })
})
it('vessel stays at initial coords when no actuator commanded', async () => {
const { YmirSimulation } = await createMockModule()
const sim = new YmirSimulation()
sim.addVesselAt(1, 500, 300, 0)
sim.step(1.0)
const state = sim.getState()
// no thrust → u=0, no motion
expect(state.vessels[0].x).toBeCloseTo(500, 2)
expect(state.vessels[0].y).toBeCloseTo(300, 2)
})
it('thruster command drives vessel forward', async () => {
const { YmirSimulation } = await createMockModule()
const sim = new YmirSimulation()
sim.addVesselAt(1, 0, 0, 0)
sim.setThrusterCommand(1, 1, 100, 0) // full ahead, no azimuth
sim.step(1.0)
const state = sim.getState()
// should have moved forward (x > 0 with heading psi=0)
expect(state.vessels[0].x).toBeGreaterThan(0)
})
// loadEnvironment integration tests (task_04)
it('loadEnvironment is a function on the simulation instance', async () => {
const { YmirSimulation } = await createMockModule()
const sim = new YmirSimulation()
expect(typeof sim.loadEnvironment).toBe('function')
})
it('loadEnvironment with empty string does not throw', async () => {
const { YmirSimulation } = await createMockModule()
const sim = new YmirSimulation()
sim.addVessel(1)
expect(() => sim.loadEnvironment('')).not.toThrow()
})
it('loadEnvironment with "{}" does not throw', async () => {
const { YmirSimulation } = await createMockModule()
const sim = new YmirSimulation()
sim.addVessel(1)
expect(() => sim.loadEnvironment('{}')).not.toThrow()
})
it('loadEnvironment followed by step does not throw', async () => {
const { YmirSimulation } = await createMockModule()
const sim = new YmirSimulation()
sim.addVessel(1)
const json = JSON.stringify({
currentSeries: [[{ t: 0, speed: 1.0, dirNaut: 90 }]],
windSeries: [],
waveSeries: [],
})
sim.loadEnvironment(json)
expect(() => sim.step(0.1)).not.toThrow()
})
it('loadEnvironment called twice does not throw (idempotent re-load)', async () => {
const { YmirSimulation } = await createMockModule()
const sim = new YmirSimulation()
sim.addVessel(1)
const json1 = JSON.stringify({ currentSeries: [[{ t: 0, speed: 1.0, dirNaut: 90 }]], windSeries: [], waveSeries: [] })
const json2 = JSON.stringify({ currentSeries: [[{ t: 0, speed: 2.0, dirNaut: 270 }]], windSeries: [], waveSeries: [] })
expect(() => {
sim.loadEnvironment(json1)
sim.loadEnvironment(json2)
sim.step(0.1)
}).not.toThrow()
})
})
-123
View File
@@ -1,123 +0,0 @@
import type { SimulationStateDTO } from '@ymir/types'
interface ActuatorStore {
rudderAngles: Record<number, number> // rudderId → deg
thrusterPowers: Record<number, number> // thrusterId → pct (-100..100)
thrusterAzimuths: Record<number, number> // thrusterId → deg
}
class MockYmirSimulation {
private t = 0
private vessels: SimulationStateDTO['vessels'] = []
// per-vessel actuator state: vesselId → ActuatorStore
private actuators: Record<number, ActuatorStore> = {}
// resultant current velocity in world frame (East=x, North=y)
private currentVEast = 0
private currentVNorth = 0
addVessel(id: number) {
this.addVesselAt(id, 0, 0, 0)
}
addVesselAt(id: number, x: number, y: number, psi: number) {
this.vessels.push({ id, x, y, z: 0, phi: 0, theta: 0, psi, u: 0, v: 0, r: 0 })
this.actuators[id] = { rudderAngles: {}, thrusterPowers: {}, thrusterAzimuths: {} }
}
setRudderAngle(vesselId: number, rudderId: number, angleDeg: number) {
if (!this.actuators[vesselId]) return
this.actuators[vesselId].rudderAngles[rudderId] = angleDeg
}
setThrusterCommand(vesselId: number, thrusterId: number, powerPct: number, azimuthDeg: number) {
if (!this.actuators[vesselId]) return
this.actuators[vesselId].thrusterPowers[thrusterId] = powerPct
this.actuators[vesselId].thrusterAzimuths[thrusterId] = azimuthDeg
}
step(dt: number) {
this.t += dt
for (const v of this.vessels) {
const act = this.actuators[v.id] ?? { rudderAngles: {}, thrusterPowers: {}, thrusterAzimuths: {} }
// Sum thruster forces in body frame
let fx = 0 // surge force contribution
let fy = 0 // sway force contribution
for (const [, pct] of Object.entries(act.thrusterPowers)) {
const azRad = ((act.thrusterAzimuths[Number(Object.keys(act.thrusterPowers)[0])] ?? 0) * Math.PI) / 180
const f = (Number(pct) / 100) * 5.0 // simplified: 5 m/s² at 100%
fx += f * Math.cos(azRad)
fy += f * Math.sin(azRad)
}
// Rudder → yaw moment (simplified: proportional to average angle)
const rudderAngles = Object.values(act.rudderAngles)
const avgRudder = rudderAngles.length > 0
? rudderAngles.reduce((s, a) => s + a, 0) / rudderAngles.length
: 0
// yaw rate target proportional to rudder angle and surge speed
const rTarget = (avgRudder / 45) * 0.08 * Math.sign(v.u + 0.01)
// Current in body frame — same rotation as nautToBodyFrame in C++:
// vx = cos(psi)*vEast + sin(psi)*vNorth
const cosP = Math.cos(v.psi)
const sinP = Math.sin(v.psi)
const cu = cosP * this.currentVEast + sinP * this.currentVNorth
const cv = -sinP * this.currentVEast + cosP * this.currentVNorth
// Damping relative to current: vessel drifts toward current velocity when no thrust
v.u += (fx - 0.5 * (v.u - cu)) * dt
v.v += (fy - 0.5 * (v.v - cv)) * dt
v.r += (rTarget - v.r) * 2.0 * dt // fast yaw rate response
// Integrate position in inertial frame
v.x += (v.u * cosP - v.v * sinP) * dt
v.y += (v.u * sinP + v.v * cosP) * dt
v.psi += v.r * dt
}
}
getState(): SimulationStateDTO {
return { t: this.t, vessels: this.vessels.map(v => ({ ...v })) }
}
reset() {
this.t = 0
this.currentVEast = 0
this.currentVNorth = 0
for (const v of this.vessels) {
v.x = 0; v.y = 0; v.psi = 0; v.u = 0; v.v = 0; v.r = 0
}
}
getTime() { return this.t }
loadEnvironment(json: string) {
if (!json || json === '{}') return
try {
const payload = JSON.parse(json) as {
currentSeries?: Array<Array<{ t: number; speed: number; dirNaut: number }>>
}
let vEast = 0
let vNorth = 0
for (const series of payload.currentSeries ?? []) {
if (series.length === 0) continue
// Use t=0 keyframe (first after sort); mirrors EnvironmentTimeline resolution
const kf = [...series].sort((a, b) => a.t - b.t)[0]
// Nautical "from" → world velocity: towardNaut = dirNaut + 180, mathRad = 90 - towardNaut
const towardNaut = kf.dirNaut + 180
const mathRad = ((90 - towardNaut) * Math.PI) / 180
vEast += kf.speed * Math.cos(mathRad)
vNorth += kf.speed * Math.sin(mathRad)
}
this.currentVEast = vEast
this.currentVNorth = vNorth
} catch {
// ignore malformed JSON
}
}
}
export default async function createMockModule() {
return { YmirSimulation: MockYmirSimulation }
}
-154
View File
@@ -1,154 +0,0 @@
/// <reference lib="webworker" />
import type { SimulationStateDTO, WorkerMessageDTO } from '@ymir/types'
import type { VesselConfigDTO } from '@ymir/types'
import createMockModule from './mock-wasm.js'
const API_BASE = (self as unknown as { VITE_API_URL?: string }).VITE_API_URL
?? 'http://localhost:3000'
interface ScenarioDraftVessel { instanceId: number; vesselId: number; name: string; x: number; y: number; headingDeg: number }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let simulation: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let SimulationClass: (new () => any) | null = null
let loopHandle: ReturnType<typeof setInterval> | null = null
let pendingVessels: ScenarioDraftVessel[] = []
let engine: 'wasm' | 'mock' = 'mock'
function post(msg: WorkerMessageDTO) {
self.postMessage(msg)
}
async function fetchVesselConfig(id: number): Promise<VesselConfigDTO | null> {
try {
const res = await fetch(`${API_BASE}/vessels/${id}/config`)
if (!res.ok) return null
return res.json() as Promise<VesselConfigDTO>
} catch {
return null
}
}
function stopLoop() {
if (loopHandle !== null) {
clearInterval(loopHandle)
loopHandle = null
}
}
function applyScenarioVessels(vessels: ScenarioDraftVessel[]) {
stopLoop()
// addVessel after step() is undefined behaviour in the C++ engine — the CVODE
// integrator is already initialised for existing bodies. The only safe path is
// to reconstruct the YmirSimulation from scratch (per the C++ comment in
// YmirBindings.cpp). Emscripten Embind objects must be .delete()d to free heap.
if (simulation && typeof simulation.delete === 'function') simulation.delete()
if (!SimulationClass) return
simulation = new SimulationClass()
for (const v of vessels) {
const psi = v.headingDeg * (Math.PI / 180)
simulation.addVesselAt(v.instanceId, v.x, v.y, psi)
}
}
async function initWasm() {
// Try real WASM first; fall back to JS mock when ymir.js is not built yet.
try {
// eslint-disable-next-line @typescript-eslint/no-implied-eval, @typescript-eslint/no-explicit-any
const { default: createYmirModule } = await (new Function('u', 'return import(u)'))('/wasm/ymir.js') as any
const Module = await createYmirModule()
SimulationClass = Module.YmirSimulation
engine = 'wasm'
} catch {
const Module = await createMockModule()
SimulationClass = Module.YmirSimulation
engine = 'mock'
}
simulation = new SimulationClass!()
const config = await fetchVesselConfig(1)
if (config) {
self.postMessage({ type: 'vessel_config', payload: config })
}
if (pendingVessels.length > 0) {
applyScenarioVessels(pendingVessels)
} else {
// Fallback: start with a single default vessel (ID=1)
simulation.addVessel(1)
}
post({ type: 'ready', engine })
}
function startLoop(dt: number) {
if (loopHandle !== null) return
loopHandle = setInterval(() => {
try {
simulation.step(dt)
const state: SimulationStateDTO = simulation.getState()
post({ type: 'state', payload: state })
} catch (err) {
post({ type: 'error', message: String(err) })
stopLoop()
}
}, 50) // 20Hz
}
self.addEventListener('message', (e: MessageEvent) => {
const msg = e.data as { type: string; dt?: number }
switch (msg.type) {
case 'start':
if (simulation) startLoop(msg.dt ?? 0.05)
break
case 'stop':
stopLoop()
break
case 'loadScenario': {
const vessels = (e.data as { type: string; vessels: ScenarioDraftVessel[] }).vessels
pendingVessels = vessels
if (simulation) {
applyScenarioVessels(vessels)
}
break
}
case 'loadEnvironment': {
if (!simulation) break
const json = (e.data as { type: string; json: string }).json
if (typeof simulation.loadEnvironment === 'function') {
simulation.loadEnvironment(json)
}
// Apply the sea state to the wave force model (drives heave/roll/pitch).
if (typeof simulation.setWaveConditions === 'function') {
try {
const env = JSON.parse(json) as { waveSeries?: Array<{ Hs?: number; Tp?: number; dirNaut?: number; gamma?: number; spectrum?: string }> }
const kf = env.waveSeries?.[0]
if (kf) {
const spectrumId = kf.spectrum === 'REGULAR' ? 2 : kf.spectrum === 'PIERSON' ? 1 : 0
simulation.setWaveConditions(kf.Hs ?? 0, kf.Tp ?? 8, kf.dirNaut ?? 0, kf.gamma ?? 3.3, spectrumId)
}
} catch { /* malformed env — leave sea calm */ }
}
break
}
case 'setActuator': {
if (!simulation) break
const { vesselId, deviceType, deviceId, value, value2 } = e.data as {
type: string; vesselId: number; deviceType: 'rudder' | 'thruster'
deviceId: number; value: number; value2?: number
}
if (deviceType === 'rudder' && typeof simulation.setRudderAngle === 'function') {
simulation.setRudderAngle(vesselId, deviceId, value)
} else if (deviceType === 'thruster' && typeof simulation.setThrusterCommand === 'function') {
simulation.setThrusterCommand(vesselId, deviceId, value, value2 ?? 0)
}
break
}
}
})
// Initialize on worker load
initWasm()
+1 -23
View File
@@ -2,29 +2,7 @@ import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [
react(),
{
name: 'coop-coep-headers',
configureServer(server) {
server.middlewares.use((_req, res, next) => {
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin')
res.setHeader('Cross-Origin-Embedder-Policy', 'credentialless')
next()
})
},
configurePreviewServer(server) {
server.middlewares.use((_req, res, next) => {
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin')
res.setHeader('Cross-Origin-Embedder-Policy', 'credentialless')
next()
})
},
},
],
worker: {
format: 'es',
},
plugins: [react()],
server: {
host: '0.0.0.0',
port: 5173,
-15
View File
@@ -1,5 +1,4 @@
import { Type, Static } from '@sinclair/typebox'
import { VesselConfigDTO } from './vessel.js'
export const VesselStateDTO = Type.Object({
id: Type.Number(),
@@ -21,17 +20,3 @@ export const SimulationStateDTO = Type.Object({
})
export type SimulationStateDTO = Static<typeof SimulationStateDTO>
/** Which physics backend the worker actually loaded. */
export const SimulationEngine = Type.Union([
Type.Literal('wasm'), // real C++ dynamics compiled to WebAssembly
Type.Literal('mock'), // JS kinematic fallback used when ymir.wasm is not built
])
export type SimulationEngine = Static<typeof SimulationEngine>
export const WorkerMessageDTO = Type.Union([
Type.Object({ type: Type.Literal('ready'), engine: SimulationEngine }),
Type.Object({ type: Type.Literal('state'), payload: SimulationStateDTO }),
Type.Object({ type: Type.Literal('error'), message: Type.String() }),
Type.Object({ type: Type.Literal('vessel_config'), payload: VesselConfigDTO }),
])
export type WorkerMessageDTO = Static<typeof WorkerMessageDTO>