File size: 3,465 Bytes
d9bb6b1
 
 
5a3b695
d9bb6b1
 
 
 
 
 
 
 
 
 
 
 
5a3b695
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d9bb6b1
5a3b695
 
 
 
 
 
 
 
 
d9bb6b1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
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<FlyInstanceData[] | null> {
  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<FlyInstanceData[]>(() =>
    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;
}