diff --git a/.compozy/tasks/server-side-simulation/_tasks.md b/.compozy/tasks/server-side-simulation/_tasks.md index d904854..a68be59 100644 --- a/.compozy/tasks/server-side-simulation/_tasks.md +++ b/.compozy/tasks/server-side-simulation/_tasks.md @@ -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 | diff --git a/.compozy/tasks/server-side-simulation/task_08.md b/.compozy/tasks/server-side-simulation/task_08.md index cc71f53..500e9f3 100644 --- a/.compozy/tasks/server-side-simulation/task_08.md +++ b/.compozy/tasks/server-side-simulation/task_08.md @@ -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. ## 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). diff --git a/apps/web/public/wasm/.gitkeep b/apps/web/public/wasm/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/web/src/workers/mock-wasm.test.ts b/apps/web/src/workers/mock-wasm.test.ts deleted file mode 100644 index ea5edd0..0000000 --- a/apps/web/src/workers/mock-wasm.test.ts +++ /dev/null @@ -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() - }) -}) diff --git a/apps/web/src/workers/mock-wasm.ts b/apps/web/src/workers/mock-wasm.ts deleted file mode 100644 index bc88686..0000000 --- a/apps/web/src/workers/mock-wasm.ts +++ /dev/null @@ -1,123 +0,0 @@ -import type { SimulationStateDTO } from '@ymir/types' - -interface ActuatorStore { - rudderAngles: Record // rudderId → deg - thrusterPowers: Record // thrusterId → pct (-100..100) - thrusterAzimuths: Record // thrusterId → deg -} - -class MockYmirSimulation { - private t = 0 - private vessels: SimulationStateDTO['vessels'] = [] - // per-vessel actuator state: vesselId → ActuatorStore - private actuators: Record = {} - // 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> - } - 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 } -} diff --git a/apps/web/src/workers/simulation.worker.ts b/apps/web/src/workers/simulation.worker.ts deleted file mode 100644 index c740598..0000000 --- a/apps/web/src/workers/simulation.worker.ts +++ /dev/null @@ -1,154 +0,0 @@ -/// -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 | null = null -let pendingVessels: ScenarioDraftVessel[] = [] -let engine: 'wasm' | 'mock' = 'mock' - -function post(msg: WorkerMessageDTO) { - self.postMessage(msg) -} - -async function fetchVesselConfig(id: number): Promise { - try { - const res = await fetch(`${API_BASE}/vessels/${id}/config`) - if (!res.ok) return null - return res.json() as Promise - } 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() diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 8bedd1f..e5197df 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -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, diff --git a/packages/types/src/simulation.ts b/packages/types/src/simulation.ts index 7194918..3d7a342 100644 --- a/packages/types/src/simulation.ts +++ b/packages/types/src/simulation.ts @@ -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 -/** 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 - -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