import { useEffect, useState } from "react"; import { apiPath, FLY_AREA_CONFIG } from "./env"; import { EXTENT_MM, GROUND_OFFSET_MM } from "./rigMetadata"; import { FREE_FLOOR_RECTS, type AabbMm } from "./roomLayout"; export interface FlyInstanceData { id: string; index: number; position: [number, number, number]; /** atan2(x, z) convention, matching the frontend's heading math. */ heading: number; } export const FLY_GROUND_OFFSET_MM = GROUND_OFFSET_MM; export const FLY_EXTENT_MM = EXTENT_MM; // Uniform over the union of FREE_FLOOR_RECTS (area-weighted so a big // rectangle isn't under-sampled relative to a small one), never inside a // furniture footprint -- rather than the old full-area uniform sample, // which could spawn a fly inside the couch. function pickFreeFloorPoint(): { x: number; z: number } { const areas = FREE_FLOOR_RECTS.map((r) => (r.maxX - r.minX) * (r.maxZ - r.minZ)); const total = areas.reduce((a, b) => a + b, 0); let pick = Math.random() * total; let rect: AabbMm = FREE_FLOOR_RECTS[0]; for (let i = 0; i < FREE_FLOOR_RECTS.length; i++) { pick -= areas[i]; if (pick <= 0) { rect = FREE_FLOOR_RECTS[i]; break; } } return { x: rect.minX + Math.random() * (rect.maxX - rect.minX), z: rect.minZ + Math.random() * (rect.maxZ - rect.minZ), }; } function generateLocalLayout(config: typeof FLY_AREA_CONFIG, groundOffsetMm: number): FlyInstanceData[] { return Array.from({ length: config.count }, (_, index) => { const { x, z } = pickFreeFloorPoint(); return { id: `fly-${index}`, index, position: [x, groundOffsetMm + Math.random() * config.heightMm, z], heading: Math.random() * Math.PI * 2, }; }); } interface RosterEntry { id: string; x: number; y: number; theta: number; } /** * flyyt-backend (if reachable) owns the fleet roster -- stable serialNumbers * and spawn positions, so VDA5050 identity means something across page * reloads (see useVda5050Nats.ts). No backend running is a completely * normal, fully-supported case ("local practice mode") -- this app has * always worked standalone and keeps working exactly the same way if the * fetch fails or times out. */ async function fetchRoster(groundOffsetMm: number): Promise { try { const res = await fetch(apiPath("roster"), { signal: AbortSignal.timeout(1500) }); if (!res.ok) return null; const roster: RosterEntry[] = await res.json(); if (roster.length === 0) return null; return roster.map((entry, index) => ({ id: entry.id, index, // Same (x,y)->(x,z) conversion as useVda5050Nats.ts's applyPosition. position: [entry.x, groundOffsetMm, -entry.y], heading: entry.theta + Math.PI / 2, })); } catch { return null; } } export function useFlyLayout(): FlyInstanceData[] { const [flies, setFlies] = useState(() => generateLocalLayout(FLY_AREA_CONFIG, FLY_GROUND_OFFSET_MM), ); useEffect(() => { let cancelled = false; fetchRoster(FLY_GROUND_OFFSET_MM).then((roster) => { // Swaps the locally-generated layout for the backend's once it // arrives, rather than blocking first paint on the fetch -- normally // near-instant (local dev) or a fast, harmless no-op (unreachable). if (!cancelled && roster) setFlies(roster); }); return () => { cancelled = true; }; }, []); return flies; }