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:
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).
|
||||
|
||||
Whitespace-only changes.
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user