3 Commits
Author SHA1 Message Date
eSlider d744671b86 fix(ci): fetch PR ref on checkout instead of shallow main clone
CI & Release / Verify simulator (push) Skipped
CI & Release / Trivy scan (push) Skipped
CI & Release / Semantic Release (push) Skipped
CI & Release / Verify simulator (pull_request) Successful in 16s
CI & Release / Trivy scan (pull_request) Successful in 15s
CI & Release / Semantic Release (pull_request) Skipped
Use gitea.ref (refs/pull/N/head) so verify and scan jobs resolve
branch tips without relying on default-branch shallow history.
2026-07-12 12:56:15 +01:00
eSlider 1cc2b9a8f2 fix: fetch PR head SHA after shallow clone in CI
CI & Release / Verify simulator (push) Skipped
CI & Release / Trivy scan (push) Skipped
CI & Release / Semantic Release (push) Skipped
CI & Release / Verify simulator (pull_request) Successful in 17s
CI & Release / Trivy scan (pull_request) Successful in 15s
CI & Release / Semantic Release (pull_request) Skipped
Shallow clone only includes default-branch history; pull_request
runs must fetch the event commit before checkout.
2026-07-12 12:54:04 +01:00
eSlider 1e5854ee0a feat: prototype perimeter patrol, 3D view, and swarm algorithms
CI & Release / Verify simulator (push) Skipped
CI & Release / Trivy scan (push) Skipped
CI & Release / Semantic Release (push) Skipped
CI & Release / Verify simulator (pull_request) Failing after 5s
CI & Release / Trivy scan (pull_request) Skipped
CI & Release / Semantic Release (pull_request) Skipped
Add optional Three.js scene, base-pad lifecycle, mycelial swarms,
UFO transit steering, and algorithm selector (boids, APF, ACO, hypha).
2026-07-12 12:51:25 +01:00
26 changed files with 4154 additions and 392 deletions
+8 -4
View File
@@ -24,8 +24,10 @@ jobs:
steps:
- name: Checkout
run: |
git clone --depth=50 https://${{ gitea.actor }}:${{ gitea.token }}@git.produktor.io/${{ gitea.repository }}.git .
git checkout ${{ gitea.sha }}
git init .
git remote add origin https://${{ gitea.actor }}:${{ gitea.token }}@git.produktor.io/${{ gitea.repository }}.git
git fetch --depth=50 origin "${{ gitea.ref }}"
git checkout FETCH_HEAD
- name: Install dependencies
run: pip install --quiet -r simulator/requirements.txt
@@ -61,8 +63,10 @@ jobs:
steps:
- name: Checkout
run: |
git clone --depth=1 https://${{ gitea.actor }}:${{ gitea.token }}@git.produktor.io/${{ gitea.repository }}.git .
git checkout ${{ gitea.sha }}
git init .
git remote add origin https://${{ gitea.actor }}:${{ gitea.token }}@git.produktor.io/${{ gitea.repository }}.git
git fetch --depth=1 origin "${{ gitea.ref }}"
git checkout FETCH_HEAD
- name: Install Trivy
run: |
-5
View File
@@ -13,8 +13,3 @@ dist/
.terraform/
*.tfstate
*.tfstate.*
# Local tooling / WIP (keep on disk, never commit)
.cursor/
.grok/
prototype-3d/
+3 -25
View File
@@ -6,8 +6,6 @@ A fleet of drones flies fully autonomously: no internet uplink, no external acce
This repository describes how to build, deliver, test, and operate that platform: the storage layout, the sync strategy, the network trust model, the CI/CD chain, and the simulation environment that makes it all testable without touching real hardware.
**New here?** Start with the one-pager: [00 — Executive overview](docs/00-executive.md) (includes how **MinIO** fits). Then the [design journey](docs/12-design-journey.md) if you want the narrative.
## Design principles
1. **Every drone is autonomous.** No cluster orchestrator spans the swarm; intermittent mesh connectivity makes that an anti-pattern. Coordination happens through data exchange, not through a control plane.
@@ -19,30 +17,10 @@ This repository describes how to build, deliver, test, and operate that platform
7. **SQL is the contract.** Peer data access is read-only DuckDB SQL over SSH forced commands — the query language already lives on both ends, so no service, port, or protocol is invented for it.
8. **No debug API.** The bench and the flight use the same channel: an engineer debugging on the ground runs the identical query through the identical wrapper, permissions, and output format a peer drone would use. What you test is what flies.
None of the concrete tool picks above are mandates. This repo is a **from-scratch platform sketch**: enough structure to hire and build against, with every decision recorded so the team can replace a piece when a better fit appears.
## Implemented now vs proposed next
Honest map so a reader knows what runs today versus what is design intent.
| Area | Implemented in this repo (runnable PoC) | Proposed for a production air-gapped fleet |
| --- | --- | --- |
| On-board layout | Hive-partitioned Parquet writer, seal step, DuckDB views | Same contract; Compose services under systemd |
| Pose path | Fixed **45-byte** UDP frame + 2D bandwidth visualisation | Zenoh pub/sub (UDP kept as degraded minimal profile) |
| Peer query | Read-only SQL **gate** over HTTP explorer (keyword allow-list) | Same gate idea via **SSH forced command** + OS/engine hardening |
| Bulk sync | Visualised opportunistic transfer volume | rsync/rclone over persistent SSH between peers |
| Mesh trust | Ansible templates for WireGuard + ed25519 forced commands | Provisioned per-device keys; nothing joins at runtime |
| Ground segment | k3d/Terraform sim: lake, Grafana, offload CronJob, **MinIO as T3** | k3s warehouse with MinIO, GitOps overlays, post-flight mirror |
| CI / delivery | GitHub Actions: pytest, smoke flight, Trivy, semver release + fleet manifest artifact | Self-hosted GitLab + registry inside the air gap (same stages) |
| Docs | Problem, architecture, ADRs, design journey, open questions | Living ADRs owned by the team |
Start from the [design journey](docs/12-design-journey.md) for the story; use the table above when reviewing scope.
## Documentation
| Document | Contents |
| --- | --- |
| [00 — Executive](docs/00-executive.md) | **Start here** — one-page goal, sync rule, MinIO stance |
| [00 — Glossary](docs/00-glossary.md) | Terms and abbreviations used throughout |
| [01 — Problem statement](docs/01-problem-statement.md) | Goal, constraints, knowns vs assumptions |
| [02 — Architecture](docs/02-architecture.md) | On-board layers, service composition, communication planes |
@@ -52,13 +30,13 @@ Start from the [design journey](docs/12-design-journey.md) for the story; use th
| [06 — Environments](docs/06-environments.md) | Drone / ground warehouse / dev-simulation infrastructures |
| [07 — Observability](docs/07-observability.md) | Logs, service metrics, hardware telemetry |
| [08 — Roadmap](docs/08-roadmap.md) | Dependency graph, simple to complex |
| [09 — Open questions](docs/09-open-questions.md) | Known unknowns and proposed answers (incl. MinIO placement) |
| [09 — Open questions](docs/09-open-questions.md) | Known unknowns and proposed answers |
| [10 — Domain context](docs/10-domain-context.md) | Swarm autonomy principles this design builds on |
| [11 — CI/CD & delivery](docs/11-cicd-delivery.md) | Pipelines, registry, dev containers, fleet releases |
| [12 — Design journey](docs/12-design-journey.md) | Narrative walk-through linking into the code |
| [12 — Design journey](docs/12-design-journey.md) | **Start here** — a narrative walk-through of how the design came together, linking into the code |
| [ADRs](docs/adr/) | Architecture Decision Records — the decisions behind the above, in the order they were made |
New here? Read the [executive overview](docs/00-executive.md) first, then the [design journey](docs/12-design-journey.md) if you want the story. The design principles above are the *what*; the [ADRs](docs/adr/) are the *why and when*.
New here? Read the [design journey](docs/12-design-journey.md) first: it tells the story chronologically and links straight into the code and decisions. The design principles below are the *what*; the [ADRs](docs/adr/) are the *why and when* — each principle traces to a dated, immutable decision record.
## Runnable parts
-61
View File
@@ -1,61 +0,0 @@
# 00 — Executive overview
A one-page map of the proposal. Details and trade-offs live in the linked docs.
Nothing here is a mandate: it is a from-scratch platform sketch the team can
reshape once real constraints are on the table.
## Goal
Build an **on-prem, air-gapped data platform** for an autonomous drone swarm:
accumulate sensor and detection data on each unit, share only what peers need in
flight, and offload complete flights to a ground warehouse for replay and
training.
## Constraints (given)
- No internet / no external access in flight; data stays inside the system.
- Each drone is autonomous; mesh links are intermittent — **no swarm-wide orchestrator**.
- On-board today: **Docker Compose**, **DuckDB**, YOLO-like video analytics; **Parquet** under evaluation.
- **MinIO is already in the stack** — exact placement not fully known yet (see below).
- Ground side can run lightweight Kubernetes; this proposal keeps k3s **on the ground only**.
## Data contract (proposed)
| What | Where | Crosses the air? |
| --- | --- | --- |
| Raw telemetry | Local NVMe (Parquet / Hive layout) | **No** — offloads after landing |
| Pose / detections (derived) | Local store + peer pub/sub | **Yes** — small, budgeted |
| Full flight archive | Ground warehouse (Parquet + object store) | After landing only |
One layout on every floor so offload is a **mirror**, not a migration
([03 — Data platform](03-data-platform.md)).
## Sync rule
1. **Fast path:** compact pose frames (~45 B @ 5 Hz) + event-shaped detections.
2. **Bulk path (peers):** sealed derived partitions when the link allows
(rsync/SSH proposed; alternatives open).
3. **Never** push raw high-rate telemetry peer-to-peer in flight.
## MinIO (first-class, placement TBD)
MinIO is treated as **already chosen infrastructure**, not something to rip out.
| Role | Stance in this sketch |
| --- | --- |
| **Ground warehouse (T3)** | **Primary home** — S3 API under the Parquet lake / offload target |
| **On-board (drone)** | **Default: no** — competes with flight-critical CPU/RAM; local Parquet + DuckDB is enough for the hot path |
| **On-board exception** | Not excluded if a team already relies on an S3 API in Compose; then derived-only, never the 5 Hz pose path |
| **5 Hz pose / collision state** | **Not MinIO** — pub/sub (UDP in the PoC; Zenoh proposed) |
| **Inter-drone history catch-up** | Prefer pull of sealed partitions (SSH/rsync); MinIO replication only if ops already standardised on it |
Open item for the team: document how MinIO is used today, then lock T2/T3 roles
([09 — Open questions](09-open-questions.md)).
## What to read next
1. [12 — Design journey](12-design-journey.md) — narrative + deep links into code
2. [Implemented vs proposed](../README.md#implemented-now-vs-proposed-next) — PoC vs production intent
3. [09 — Open questions](09-open-questions.md) — including MinIO placement
Live visual PoC: [swarm.produktor.io](https://swarm.produktor.io/) (same access as this repository).
+1 -1
View File
@@ -55,7 +55,7 @@ Terms and abbreviations used throughout this proposal.
| **Docker Compose** | Declarative multi-container runtime; the only orchestrator on board a drone |
| **k3s** | Lightweight Kubernetes distribution; used on the ground only |
| **Dev Container** | Reproducible containerized development environment definition |
| **MinIO** | S3-compatible object store **already in the stack**; default home is the **ground warehouse (T3)**. On-board use is an exception, not the baseline |
| **MinIO** | S3-compatible object store; runs on-board for derived data and on the ground as the warehouse backend |
| **Registry mirror** | Local copy of a container registry inside the air gap; drones pull images from it |
| **OTA** | Over-the-air update — delivered before a mission while docked, never mid-flight |
| **Fleet release manifest** | A versioned lockfile pinning every artifact (image digests, model weights, schemas, configs) that defines one fleet version |
+1 -2
View File
@@ -28,7 +28,7 @@ Plus everything a platform needs around that: reproducible builds and deployment
- The current on-board software is **Docker Compose** with two services: **sensor ingestion** and **video-stream object detection** (a YOLO-like model; weights and approaches vary).
- **DuckDB** is already in use for local data handling.
- **Parquet** adoption is at the evaluation stage — the storage layout in this proposal is the core of what is being asked.
- **MinIO** is already in the stack. Exact placement (ground vs on-board vs sync path) was not fully specified in the brief; this proposal treats MinIO as **first-class ground-warehouse infrastructure** by default and keeps on-board MinIO as an open option — see [00 — Executive](00-executive.md) and [09 — Open questions](09-open-questions.md).
- **MinIO** is available and considered as the inter-drone sync mechanism.
- A **lightweight Kubernetes** exists in the ecosystem; this proposal scopes it to ground infrastructure only (see [02 — Architecture](02-architecture.md)).
- After landing, each drone's data is **offloaded to an on-prem warehouse** for replay and iterative model training.
- Mission intent (declarative goals) reaches the swarm over a narrow **C2 channel**; there is no continuous ground link in flight.
@@ -43,7 +43,6 @@ Plus everything a platform needs around that: reproducible builds and deployment
| A4 | Missions are bounded (battery), so a "flight" is the natural unit of data lifecycle | Return-to-base on low energy implies discrete flight sessions |
| A5 | Mission logic consumes the data platform as a service and is out of scope here | Flight control, planning, and model training are separate concerns |
| A6 | A development-only telemetry channel exists on the bench and is absent from production builds | Standard practice; production radio profile carries C2 + swarm data plane only |
| A7 | **MinIO's primary home is the ground warehouse (T3), not the drone** | On-board object store competes with flight-critical CPU/RAM; local Parquet + DuckDB already cover the hot path. On-board MinIO is not excluded if ops already depend on an S3 API in Compose |
## Out of scope
+9 -15
View File
@@ -20,13 +20,11 @@ graph TB
subgraph serving [Layer 3 — Serving and sync]
HOOK["event hook<br/>fires on new derived data"]
PUB["state publisher<br/>pub/sub broadcast"]
BULK["bulk sync<br/>rsync over SSH"]
QAPI["query API<br/>SQL-over-SSH"]
MINIO["MinIO<br/>derived datasets bucket"]
QAPI["query API<br/>peer data requests"]
end
end
MINIO[("MinIO — ground warehouse T3<br/>already in the stack")]
SENSORS --> WRITER
VIDEO -->|detections| WRITER
WRITER --> NVME
@@ -34,16 +32,13 @@ graph TB
DUCK --> NVME
WRITER -->|derived rows| HOOK
HOOK --> PUB
HOOK --> BULK
HOOK --> MINIO
QAPI --> DUCK
PUB -.->|mesh pose| PEERS["peer drones"]
BULK -.->|sealed partitions| PEERS
PUB -.->|mesh| PEERS["peer drones"]
MINIO -.->|replication| PEERS
QAPI -.->|on demand| PEERS
NVME -->|post-flight offload| MINIO
```
**MinIO** is already in the stack and is the **default ground warehouse (T3)** behind offload — keep it, do not rip it out. Default sketch: **not on the drone** (CPU/RAM budget); on-board MinIO remains an allowed exception if Compose already depends on an S3 API. **In-flight peer bulk sync is SSH/rsync** (or team-standard MinIO replication if that is already how ops works) — never the 5 Hz pose path. See [00 — Executive](00-executive.md) and [04 — Swarm sync](04-swarm-sync.md).
### Layer 1 — Ingestion
- `sensor-ingest` subscribes to sensor sources (ROS 2 topics where available, raw drivers otherwise) and normalizes them into typed streams: IMU, barometer, temperature, LiDAR, RSSI, power, and so on.
@@ -58,11 +53,10 @@ graph TB
### Layer 3 — Serving and sync
- The **event hook** is the on-board "lambda": when the writer lands new *derived* rows (state, detections), it triggers registered actions — broadcast, bulk-sync hint, or a local mission-logic callback. Nothing polls.
- The **state publisher** broadcasts compact position/attitude/detection payloads over the mesh pub/sub (UDP in the PoC; Zenoh proposed — [04](04-swarm-sync.md)).
- **Bulk sync** pulls sealed derived partitions from peers over persistent SSH (rsync delta transfer). If the team already operates MinIO replication for that job, reuse it — do not invent a second path. Pose traffic never goes through MinIO.
- **Peer queries** are read-only DuckDB SQL — HTTP explorer gate in the PoC; **SSH forced commands** proposed for flight (SELECT-only gate, read-only OS user, columnar responses [04](04-swarm-sync.md)).
- **MinIO** sits on the **ground** as T3 by default ([00 — Executive](00-executive.md)); on-board MinIO is an explicit exception, not the baseline.
- The **event hook** is the on-board "lambda": when the writer lands new *derived* rows (state, detections), it triggers registered actions — broadcast, MinIO upload, or a local mission-logic callback. Nothing polls.
- The **state publisher** broadcasts compact position/attitude/detection payloads over the mesh pub/sub (transport analysis in [04 — Swarm sync](04-swarm-sync.md)).
- **Bulk sync** pulls sealed derived partitions from peers over persistent SSH (rsync delta transfer); MinIO remains optional where an S3 API is wanted.
- **Peer queries** are read-only DuckDB SQL over SSH forced commands — SELECT-only gate, read-only OS user, columnar responses (the design decision and its reasoning are in [04](04-swarm-sync.md)).
## Communication planes
+3 -5
View File
@@ -68,13 +68,11 @@ Same format on every floor; only volume, retention, and location change:
| Floor | Where | Contents | Retention |
| --- | --- | --- | --- |
| **T0 — hot** | RAM / DuckDB in-process | Sliding window of the last minutes; what mission logic queries in flight | Minutes |
| **T1 — warm** | Drone NVMe (Parquet) | Full raw `telemetry` + `detections` + `state` of the current flight; never leaves the drone in flight | Current flight (+ quota-based headroom) |
| **T2 — shared** | Peer-derived cache on NVMe (Parquet) | Derived data only (`detections`, `state`) pulled from peers when the link allows | Current mission |
| **T3 — warehouse** | Ground **MinIO** (or equivalent) + Parquet lakehouse | Every flight of every drone; replay, analytics, model training**primary MinIO home** | Years |
| **T1 — warm** | Drone NVMe | Full raw `telemetry` + `detections` + `state` of the current flight; never leaves the drone in flight | Current flight (+ quota-based headroom) |
| **T2 — shared** | On-board MinIO bucket | Derived data only (`detections`, `state`), replicated opportunistically across the swarm | Current mission |
| **T3 — warehouse** | Ground on-prem object store + Parquet lakehouse | Every flight of every drone, forever; replay, analytics, model training | Years |
| **T4 — dev** | Engineer laptop / sim farm | Slices pulled from T3, or synthetic data from the simulator | Ephemeral |
**MinIO note:** default sketch keeps MinIO on the **ground (T3)**. An on-board MinIO sidecar is a possible exception if the team already standardises on an S3 API in Compose — derived data only, never the 5 Hz pose path. Placement is an open question ([09](09-open-questions.md), [00 — Executive](00-executive.md)).
## Flight offload: a mirror, not a migration
Because T1 and T3 share the identical layout, offload after landing is:
+14 -24
View File
@@ -16,27 +16,19 @@ Bandwidth is the scarcest resource in the system. Every message class gets an ex
## Broadcast payload: small on the wire, precise at rest
The `state` broadcast is a fixed compact frame. The runnable PoC encodes it as
**45 bytes** little-endian (`simulator/virtual_drone/broadcast.py`); that size is
what the prototype uses for bandwidth estimates.
The `state` broadcast is a fixed compact frame:
| Field | Wire type (PoC) | Notes |
| Field | Type | Notes |
| --- | --- | --- |
| `magic` + `version` | 2s + uint8 | `b"SH"`, version `1` |
| `drone_id` | 8s ascii | Zero-padded; a fleet `uint16` registry id is a natural production swap |
| `drone_id` | uint16 | Fleet-scoped registry |
| `ts_ns` | int64 | Epoch nanoseconds, same clock domain as storage |
| `pos_x/y/z` | 3 × int32 | **Millimeters** in the **mission frame** — quantized on the wire only; storage keeps full float precision |
| `att_roll/pitch/yaw` | 3 × int16 | Centi-degrees — attitude stays on the wire so peers need no local shape model |
| `vel_x/y/z` | 3 × int16 | cm/s |
| `pos_x/y/z` | int32 | **Millimeters** in the mission frame — quantized only here, storage keeps full float precision |
| `att_roll/pitch/yaw` | int16 | Centi-degrees |
| `vel_x/y/z` | int16 | cm/s |
| `frame_ref` | uint8 | Frame of reference id (GPS-denied: local/visual-odometry frames must be explicit) |
| `flags` | uint8 | Battery-low, returning, degraded-sensors, … |
45 bytes × 5 Hz × 50 drones ≈ 11 KB/s of pose traffic before transport overhead — still trivial on a congested mesh.
An earlier sketch used relative coordinates and a bounding sphere (no attitude).
It was dropped: mission-frame pose + attitude is simpler to fuse post-flight and
costs almost nothing at this frame size. Relative localization remains a
consumer concern when `frame_ref` differs across peers ([09](09-open-questions.md)).
~40 bytes per frame → a 50-drone swarm at 5 Hz is ~10 KB/s of pose traffic before transport overhead. Trivial even on a congested mesh.
`detections` events are slightly larger (class, confidence, bounding volume, ego-pose) but event-shaped and rare by comparison.
@@ -59,34 +51,32 @@ graph LR
SB -->|"store-and-forward relay"| SC
```
### Recommended (proposal): Zenoh
### Recommended: Zenoh
- Designed exactly for constrained, dynamic networks: built-in peer discovery, brokerless peer-to-peer mode, store-and-forward, and a query layer on top of pub/sub.
- First-class robotics citizenship: an official ROS 2 RMW implementation exists, so the ingestion side and the sync side can share one middleware.
- Tiny footprint, ARM64-native.
**PoC today:** the simulator and the 2D prototype exercise the **raw UDP** pose path only — the minimal degraded profile below. Zenoh is the proposed production pub/sub, not yet wired into the runnable stack.
### Alternatives considered
| Option | Verdict |
| --- | --- |
| **DDS multicast** (ROS 2 default) | Works, battle-tested; but discovery storms and tuning pain on lossy wireless meshes are well documented. Keep as fallback since ROS 2 speaks it natively |
| **MQTT** | Needs a broker — a per-drone broker bridge is possible but adds moving parts for no gain over Zenoh |
| **Raw UDP multicast** | **Implemented in the PoC** for pose broadcast (fixed frame, no discovery); no query layer, no reliability — also the documented degraded mode |
| **MinIO bucket replication** | Keep as a **team option** for bulk derived catch-up if ops already standardise on it; wrong tool for the 5 Hz pose path. Default sketch: MinIO primary on the **ground warehouse**, not on the drone |
| **Raw UDP multicast** | Perfect as a last-resort minimal profile for the pose broadcast alone (fixed frame, no discovery); no query layer, no reliability — documented as the degraded mode |
| **MinIO bucket replication** | Wrong tool for the 5 Hz pose path, right tool for bulk derived datasets — see below |
## Two sync mechanisms, deliberately separate
1. **Fast path — pub/sub (Zenoh proposed; UDP in the PoC):** pose frames and detection events. Fire-and-forget with bounded staleness; consumers keep a peer-state cache.
2. **Bulk path — rsync over persistent SSH (proposed):** sealed `detections`/`state` Parquet partitions are pulled opportunistically between drones when links allow. This is how a drone that was out of range catches up on mission history without anyone re-sending events. The PoC visualises bulk volume; it does not yet run real rsync between virtual drones.
1. **Fast path — pub/sub (Zenoh):** pose frames and detection events. Fire-and-forget with bounded staleness; consumers keep a peer-state cache.
2. **Bulk path — rsync over persistent SSH:** sealed `detections`/`state` Parquet partitions are pulled opportunistically between drones when links allow. This is how a drone that was out of range catches up on mission history without anyone re-sending events.
Why SSH-based bulk sync over object-store replication:
- **Identity is already there.** Every drone holds pre-provisioned ed25519 keys and a fixed `known_hosts`/`authorized_keys` set from ground provisioning ([05](05-network-security.md)) — the trust model needs no new machinery.
- **One persistent multiplexed session** (`ControlMaster`) per peer costs almost nothing at idle and survives as a single TCP stream; every transfer rides it without new handshakes.
- **rsync delta transfer is resumable** across link drops — exactly the failure mode of an ad-hoc mesh — and the shared partition layout makes it trivially incremental: same paths, same files, pull only what is missing.
- **Zero extra services** on the flight-critical node by default. **MinIO stays first-class on the ground (T3).** On-board MinIO is an exception only if Compose already depends on an S3 API. In-flight bulk path defaults to SSH/rsync; switch to MinIO replication if that is already how the team moves objects.
- **Zero extra services** on the flight-critical node. MinIO remains available where an S3 API is genuinely wanted (ground warehouse, and optionally on board), but the in-flight bulk path does not depend on it.
Partition healing is automatic: replication is pull-based, addressed by partition path, and idempotent (each drone only ever writes its own `drone=` subtree — **no write conflicts by construction**).
@@ -115,7 +105,7 @@ SQL access must not become a write channel. A single "read-only connection" flag
| Layer | Mechanism | What it stops |
| --- | --- | --- |
| 1. Key = operation | Forced command: the query key can only invoke the query wrapper, nothing else | Arbitrary exec, lateral movement |
| 2. Statement gate | Wrapper accepts a single statement, rejects anything but `SELECT`/`WITH`/… (no `COPY`, `ATTACH`, `INSTALL`, `SET`, multi-statements); production should prefer a real parser + bound parameters, not keywords alone | SQL-as-a-write-channel, config tampering |
| 2. Statement gate | Wrapper accepts a single statement, parses it, rejects anything but `SELECT` (no `COPY`, `ATTACH`, `INSTALL`, `SET`, multi-statements); parameters bound, not interpolated | SQL-as-a-write-channel, config tampering |
| 3. OS permissions | Wrapper runs as a dedicated user with **read-only filesystem access** to the data root and write access to nothing | Any write that slips past layer 2 |
| 4. Engine hardening | `:memory:` database, external access disabled except the data-root glob, extension loading off | Reaching outside the store |
| 5. Resource caps | Timeout, memory cap, niced CPU (flight software always wins), response size budget | Denial of service via expensive queries |
+3 -3
View File
@@ -12,12 +12,12 @@ graph LR
SLICE["T4: data slices"]
end
subgraph fleet [1 — Fleet, in flight]
D1["drone: Compose data plane<br/>T0 hot + T1 NVMe + T2 peer cache"]
D1["drone: Compose data plane<br/>T0 hot + T1 NVMe + T2 MinIO"]
D2["drone …"]
end
subgraph ground [2 — Ground, on-prem]
DOCK["base station docks"]
DWH["T3 warehouse<br/>MinIO + Parquet/DuckDB"]
DWH["T3 warehouse<br/>object store + Parquet/DuckDB"]
K3S["k3s: CI runners, registry mirror,<br/>sim farm, dashboards"]
TRAIN["model training (out of scope)<br/>reads T3, ships weights"]
end
@@ -43,7 +43,7 @@ The permanent installation. This **is** allowed to be a cluster — links are wi
| Component | Runs on | Role |
| --- | --- | --- |
| **Warehouse (T3)** | **MinIO** (already in the stack) + Parquet | Every flight of every drone; the system's long-term memory |
| **Warehouse (T3)** | Object store (MinIO or equivalent) + Parquet | Every flight of every drone; the system's long-term memory |
| **Base station docks** | Bare metal | Wired offload + integrity audit + drone provisioning ([03](03-data-platform.md), [05](05-network-security.md)) |
| **Registry mirror** | k3s | In-air-gap container registry + artifact storage; the only software source drones ever see |
| **CI runners** | k3s | Multi-arch builds, tests, scans ([11](11-cicd-delivery.md)) |
+6 -6
View File
@@ -9,11 +9,11 @@ graph TD
S1["Stage 1<br/>Dev environment + CI skeleton<br/>Dev Containers, GitLab templates, multi-arch builds"]
S2["Stage 2<br/>Registry and artifacts<br/>images, model weights, schemas, cleanup policies"]
S3["Stage 3<br/>Single-drone data pipeline<br/>ingest → current/ → sealed Parquet → DuckDB"]
S4["Stage 4<br/>Serving layer<br/>event hook, query API"]
S4["Stage 4<br/>Serving layer<br/>event hook, query API, local MinIO"]
S5["Stage 5<br/>Virtual swarm simulation<br/>N drones in Compose, seeded scenarios, CI regression"]
S6["Stage 6<br/>Mesh network and security<br/>WireGuard overlay, PKI provisioning, mTLS"]
S7["Stage 7<br/>Swarm sync<br/>pose broadcast, detections pub/sub, bulk pull"]
S8["Stage 8<br/>Ground warehouse<br/>offload + audit, MinIO T3, replay tooling"]
S7["Stage 7<br/>Swarm sync<br/>pose broadcast, detections pub/sub, MinIO replication"]
S8["Stage 8<br/>Ground warehouse<br/>offload + audit, T3 store, replay tooling"]
S9["Stage 9<br/>Observability<br/>platform metrics as sensor stream, ground dashboards"]
S10["Stage 10<br/>Fleet releases<br/>manifest, mirror, dock delivery, atomic rollback"]
S11["Stage 11<br/>Query standard<br/>GraphQL contract, schema registry, multi-team surface"]
@@ -41,11 +41,11 @@ graph TD
| **1 — Dev environment + CI skeleton** | Dev Containers, GitLab shared templates, multi-arch runner fleet | An engineer builds and tests on day one; ARM64/AMD64 both green | — |
| **2 — Registry & artifacts** | GitLab registry live, model weights + schemas as versioned packages | One artifact store, scanning wired in (Trivy, SonarQube) | 1 |
| **3 — Single-drone data pipeline** | `sensor-ingest` → writer → `current/` → sealed → DuckDB queries on one node | The storage core: rates sustained, compression measured, quotas enforced | 1 |
| **4 — Serving layer** | Event hook + query API (SQL gate) | Event-driven consumption; nothing polls | 3 |
| **4 — Serving layer** | Event hook + query API + on-board MinIO | Event-driven consumption; nothing polls | 3 |
| **5 — Virtual swarm** | Simulator, N drones via Compose, seeded scenarios, CI regression gate | Everything after this ships with a test bench | 2, 4 |
| **6 — Mesh & security** | WireGuard overlay, offline CA, provisioning flow, mTLS | Zero-trust fabric exists before any real sync traffic | 1 |
| **7 — Swarm sync** | Pose broadcast + detections pub/sub + bulk partition pull | Staleness budgets met under simulated loss/partitions | 5, 6 |
| **8 — Ground warehouse** | Dock offload (mirror + audit), **MinIO as T3**, replay queries | A full flight round-trips: fly (simulated) → offload → replay | 3 |
| **7 — Swarm sync** | Pose broadcast + detections pub/sub + bulk replication over the mesh | Staleness budgets met under simulated loss/partitions | 5, 6 |
| **8 — Ground warehouse** | Dock offload (mirror + audit), T3 store, replay queries | A full flight round-trips: fly (simulated) → offload → replay | 3 |
| **9 — Observability** | Platform metrics as sensor stream; ground Prometheus/Grafana/Loki; post-mortem dashboards | Every health question from [07](07-observability.md) answerable | 7, 8 |
| **10 — Fleet releases** | Release manifest, registry mirror, dock delivery, atomic rollback drill | The whole fleet moves as one version, rollback rehearsed | 2, 5 |
| **11 — Query standard** | GraphQL contract over versioned schemas; documentation for other teams | The multi-team integration surface | 7, 8 |
-13
View File
@@ -67,16 +67,3 @@ Sub-250 g class units cannot run the full stack (no GPU, minimal CPU/storage).
The observability path now scans only the most recent flights ([ADR-0009](adr/ADR-0009-bounded-lake-scans.md)), but old `flight=` partitions still accumulate on T1 disk after offload to T3.
*Proposal:* a scheduled prune of T1 partitions whose flights are confirmed present in the T3 warehouse (offload as the retention gate), configurable by age and free-space watermark. On the drone the same policy is bounded by the NVMe quota ladder ([03](03-data-platform.md)); in the sim environment it is a ground CronJob alongside the offload job.
## 12 — MinIO placement (already in the stack)
Exact how MinIO is used today was not fully specified. It should not be ripped out.
*Proposal (default):*
- **Ground warehouse (T3):** primary MinIO home — offload target and long-term object API.
- **On the drone:** default **off** — local Parquet + DuckDB cover the hot path; an object store competes with flight-critical CPU/RAM.
- **Exception:** on-board MinIO allowed if Compose already depends on an S3 API — derived datasets only, never 5 Hz pose.
- **Peer bulk catch-up:** prefer sealed-partition pull (SSH/rsync); use MinIO replication only if that is already the ops standard.
*Settle with the team:* one short diagram of today's MinIO topology (buckets, clients, what syncs). Until then, treat A7 in [01](01-problem-statement.md) as the working assumption. Summary table: [00 — Executive](00-executive.md).
+12 -20
View File
@@ -1,12 +1,9 @@
# 12 — Design journey
How this design came together, told in the order the thinking actually happened.
It is a walk-through, not a report — and **not a mandate**. The goal is a
from-scratch platform sketch that shows how the pieces fit; every concrete
choice is an option with trade-offs recorded as
[Architecture Decision Records](adr/README.md). Replace any piece if a better
fit shows up. This is the story behind the decisions, and each chapter links
straight into the code it produced.
It is a walk-through, not a report. The formal decisions, with options and
trade-offs, live as [Architecture Decision Records](adr/README.md). This is the
story behind them, and each chapter links straight into the code it produced.
---
@@ -73,17 +70,15 @@ stored and in which structure, not to step into that work.
## 5. What actually needs to sync
The most time-critical item is where each peer is, so every unit has time to
react. The wire frame carries **mission-frame position** (millimetres on the
wire, full float at rest), **attitude**, **velocity**, a `frame_ref`, and flags —
45 bytes at 5 Hz. That is cheap enough that shrinking further is not worth the
fusion pain. An earlier sketch used relative coordinates and a bounding sphere
with no orientation; it was dropped. When peers disagree on frames,
`frame_ref` makes the mismatch explicit for consumers
([09 — Open questions](09-open-questions.md)).
react. The choice was to broadcast [relative pose](04-swarm-sync.md) (x, y, z and
time) instead of absolute coordinates. Relative is cheaper and enough for
coordination. To avoid sending orientation, a unit is modelled as a **sphere**
that bounds its extent. That trades a little compute for much less data, and no
per-shape encoding. If the units are identical, their 3D model can be provisioned
ahead instead of transmitted.
> **Read the code**
> - [`simulator/virtual_drone/broadcast.py`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/simulator/virtual_drone/broadcast.py#L1-L45) — the 45-byte pose frame, position quantized on the wire only
> - [`prototype/src/sim.ts`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/prototype/src/sim.ts#L60-L63) — `POSE_BYTES = 45` drives the volume estimate
> - [`simulator/virtual_drone/broadcast.py`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/simulator/virtual_drone/broadcast.py#L1-L40) — the 46-byte pose frame, position quantized on the wire only
> - [`prototype/src/sim.ts`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/prototype/src/sim.ts#L260-L277) — pose broadcasts at 5 Hz and opportunistic bulk sync, made visible
## 6. Security as nested layers, not one wall
@@ -144,13 +139,10 @@ gives redundancy and a way to cross-check later. On the ground, when the fleet
returns, the same path unifies every unit's data into a
[local warehouse](06-environments.md). The warehouse is read-mostly for
analytics, so transaction contention is not a concern, which opens a
DuckDB / DuckLake approach with **MinIO underneath on the ground (T3)** — MinIO
is already in the stack, so the warehouse leans on it rather than inventing a
second object store — the
DuckDB / DuckLake approach with MinIO underneath, the
[same storage model on both ends](adr/ADR-0002-one-storage-format.md). One
uniform structure is what lets an analyst trust a single picture even when a
unit's data has a gap. On-board MinIO stays an open exception, not the default
([00 — Executive](00-executive.md)).
unit's data has a gap.
> **Read the code**
> - [`infra/terraform/ground/main.tf`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/infra/terraform/ground/main.tf#L124-L160) — the post-flight T1 to T3 offload CronJob
+5 -10
View File
@@ -32,15 +32,10 @@ wrapper, permissions, and output format a peer drone would use.
## Consequences
- One access path is designed for flight and bench — "what you test is what
- One access path is built, secured, and tested — "what you test is what
flies".
- The statement gate in the PoC (`simulator/explorer/server.py`, covered by
`simulator/tests/test_sql_gate.py`) is a **first layer**: keyword allow/deny
over HTTP for the explorer. Production still needs the remaining layers in
[04 — Swarm sync](../04-swarm-sync.md) (forced-command key, read-only OS user,
engine hardening, resource caps) and a real SQL parser rather than keywords
alone.
- The explorer and the prototype's live mode are *stand-in consumers* of the
same read-only contract over HTTP today; the proposed flight path is
SQL-over-SSH ([`../04-swarm-sync.md`](../04-swarm-sync.md)).
- The SQL gate is safety-critical and is covered by unit tests
(`simulator/tests/test_sql_gate.py`).
- The explorer and the prototype's live mode are *just another read-only
consumer* of this same contract ([`../04-swarm-sync.md`](../04-swarm-sync.md)).
- Design principles 7 and 8 in [`../../README.md`](../../README.md) restate this.
+3
View File
@@ -2,6 +2,8 @@
A 2D top-down view of the swarm data plane from [04 — Swarm sync](../docs/04-swarm-sync.md): drones patrol an area with static and mobile obstacles, exchange 5 Hz pose broadcasts (blue link flashes), and run opportunistic bulk sync of sealed partitions (green links with live rate and cumulative up/down counters).
Optional **3d** checkbox in the header switches to a Three.js perimeter patrol scene (WebGPU/WebGL, shader terrain, transit escort, search panel). Off by default.
No backend — a pure client-side simulation of the same behavioral model the Python simulator implements over real Parquet and UDP.
## Run
@@ -17,3 +19,4 @@ npm run dev # http://localhost:5173
- **Busy links turn green** and show current rate plus total transferred (`↑` / `↓`) — the bandwidth budget from the sync design made visible.
- **Per-drone label**: id, current Wi-Fi channel (hopping), battery.
- Controls: drone count, simulation speed, pause.
- **3d** — perimeter patrol, altitude, detection beams, 4-drone escort sphere
+66 -1
View File
@@ -9,11 +9,13 @@
"version": "0.1.0",
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
"react-dom": "^18.3.1",
"three": "^0.172.0"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/three": "^0.172.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.6.3",
"vite": "^6.0.3"
@@ -1150,6 +1152,13 @@
"win32"
]
},
"node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1230,6 +1239,35 @@
"@types/react": "^18.0.0"
}
},
"node_modules/@types/stats.js": {
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/three": {
"version": "0.172.0",
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.172.0.tgz",
"integrity": "sha512-LrUtP3FEG26Zg5WiF0nbg8VoXiKokBLTcqM2iLvM9vzcfEiYmmBAPGdBgV0OYx9fvWlY3R/3ERTZcD9X5sc0NA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@tweenjs/tween.js": "~23.1.3",
"@types/stats.js": "*",
"@types/webxr": "*",
"@webgpu/types": "*",
"fflate": "~0.8.2",
"meshoptimizer": "~0.18.1"
}
},
"node_modules/@types/webxr": {
"version": "0.5.24",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
"dev": true,
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -1251,6 +1289,13 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@webgpu/types": {
"version": "0.1.71",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.71.tgz",
"integrity": "sha512-mMy8/ODcKhab808co15eW+yN+HgXoQxRQHTiBV9Mrvl1r0ufnid7YOcI+gi4eUWSWl9ezD6TW2KXccrL8HCh2A==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.42",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz",
@@ -1428,6 +1473,13 @@
}
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"dev": true,
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1507,6 +1559,13 @@
"yallist": "^3.0.2"
}
},
"node_modules/meshoptimizer": {
"version": "0.18.1",
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz",
"integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==",
"dev": true,
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -1701,6 +1760,12 @@
"node": ">=0.10.0"
}
},
"node_modules/three": {
"version": "0.172.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.172.0.tgz",
"integrity": "sha512-6HMgMlzU97MsV7D/tY8Va38b83kz8YJX+BefKjspMNAv0Vx6dxMogHOrnRl/sbMIs3BPUKijPqDqJ/+UwJbIow==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+3 -1
View File
@@ -10,11 +10,13 @@
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
"react-dom": "^18.3.1",
"three": "^0.172.0"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/three": "^0.172.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.6.3",
"vite": "^6.0.3"
+729 -105
View File
@@ -1,23 +1,28 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { DEFAULT_EXPLORER_URL, fetchLivePoses, toLiveWorld } from "./live";
import { searchWeb, type SearchHit } from "./search";
import {
AREA,
MAX_ASPECT,
SWARM_ALGORITHM_LABELS,
areaWidth,
fmtBytes,
houseRoadNetwork,
makeWorld,
seed,
tick,
type RouteMode,
type SwarmAlgorithm,
type World,
} from "./sim";
import { SwarmView3D } from "./SwarmView3D";
import type { RenderBackend } from "./SwarmScene";
const VIEW = 1000;
const PANEL_W = 320;
const HEADER_H = 70;
const LIVE_POLL_MS = 2000;
// Patrol field aspect ratio for the current window: wide monitors get a
// wider field instead of empty side margins. Quantized to 0.25 steps so
// minor resizes don't rebuild the world.
function displayAspect(): number {
const w = Math.max(320, window.innerWidth - PANEL_W);
const h = Math.max(320, window.innerHeight - HEADER_H);
@@ -25,27 +30,57 @@ function displayAspect(): number {
return Math.max(1, Math.min(MAX_ASPECT, q));
}
const LIVE_POLL_MS = 2000;
function routeModeFor(_view3d: boolean): RouteMode {
return "perimeter";
}
export default function App(): JSX.Element {
const [view3d, setView3d] = useState(false);
const [droneCount, setDroneCount] = useState(8);
const [swarmAlgo, setSwarmAlgo] = useState<SwarmAlgorithm>("boids");
const [speedup, setSpeedup] = useState(1);
const [paused, setPaused] = useState(false);
const [aspect, setAspect] = useState(displayAspect);
const [mode, setMode] = useState<"sim" | "live">("sim");
const [liveError, setLiveError] = useState<string | null>(null);
const [world, setWorld] = useState<World>(() => {
const [backend, setBackend] = useState<RenderBackend>("webgl");
const [searchQ, setSearchQ] = useState("drone swarm perimeter patrol");
const [searchBusy, setSearchBusy] = useState(false);
const [searchErr, setSearchErr] = useState<string | null>(null);
const [hits, setHits] = useState<SearchHit[]>([]);
const [, bump] = useState(0);
const worldRef = useRef<World | null>(null);
const swarmAlgoRef = useRef(swarmAlgo);
swarmAlgoRef.current = swarmAlgo;
if (worldRef.current === null) {
seed(42);
return makeWorld(8, displayAspect());
});
worldRef.current = { ...makeWorld(8, displayAspect(), "perimeter"), swarmAlgo };
}
const raf = useRef(0);
const last = useRef(performance.now());
const liveWorld = useRef<World | null>(null);
useEffect(() => {
const resetWorld = useCallback((): void => {
seed(42);
setWorld(makeWorld(droneCount, aspect));
}, [droneCount, aspect]);
worldRef.current = {
...makeWorld(droneCount, aspect, routeModeFor(view3d)),
swarmAlgo: swarmAlgoRef.current,
};
bump((n) => n + 1);
}, [droneCount, aspect, view3d]);
useEffect(() => {
if (worldRef.current) {
worldRef.current = { ...worldRef.current, swarmAlgo };
bump((n) => n + 1);
}
}, [swarmAlgo]);
useEffect(() => {
resetWorld();
}, [resetWorld]);
useEffect(() => {
let timer = 0;
@@ -61,20 +96,21 @@ export default function App(): JSX.Element {
}, []);
useEffect(() => {
if (mode !== "sim") return;
if (view3d || mode !== "sim") return;
const loop = (now: number): void => {
const dt = Math.min(0.05, (now - last.current) / 1000);
last.current = now;
if (!paused && dt > 0) {
setWorld((w) => tick(w, dt * speedup));
worldRef.current = tick(worldRef.current!, dt * speedup);
bump((n) => n + 1);
}
raf.current = requestAnimationFrame(loop);
};
last.current = performance.now();
raf.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf.current);
}, [paused, speedup, mode]);
}, [view3d, paused, speedup, mode]);
// Live mode: poll the explorer's read-only SQL API for real poses
useEffect(() => {
if (mode !== "live") return;
let cancelled = false;
@@ -84,7 +120,11 @@ export default function App(): JSX.Element {
if (cancelled) return;
setLiveError(poses.length === 0 ? "no drone data in the lake yet" : null);
liveWorld.current = toLiveWorld(poses, liveWorld.current, LIVE_POLL_MS / 1000);
setWorld(liveWorld.current);
if (view3d) {
liveWorld.current = { ...liveWorld.current, routeMode: "perimeter" };
}
worldRef.current = liveWorld.current;
bump((n) => n + 1);
} catch (err) {
if (!cancelled) setLiveError(err instanceof Error ? err.message : String(err));
}
@@ -95,34 +135,80 @@ export default function App(): JSX.Element {
cancelled = true;
window.clearInterval(timer);
};
}, [mode]);
}, [mode, view3d]);
useEffect(() => {
if (mode === "sim") {
liveWorld.current = null;
setLiveError(null);
seed(42);
setWorld(makeWorld(droneCount, aspect));
resetWorld();
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- reset only on mode change
}, [mode]);
}, [mode, resetWorld]);
const simEnabled = useCallback(() => mode === "sim" && !paused, [mode, paused]);
const onStep3d = useCallback(
(dt: number) => {
worldRef.current = tick(worldRef.current!, dt * speedup);
},
[speedup],
);
const onUiPulse = useCallback(() => bump((n) => n + 1), []);
const runSearch = async (): Promise<void> => {
setSearchBusy(true);
setSearchErr(null);
try {
const res = await searchWeb(searchQ);
setHits(res.results);
} catch (err) {
setSearchErr(err instanceof Error ? err.message : String(err));
setHits([]);
} finally {
setSearchBusy(false);
}
};
useEffect(() => {
if (!view3d) return;
void runSearch();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [view3d]);
const world = worldRef.current!;
const activeLinks = useMemo(
() => [...world.links.values()].sort((a, b) => b.rate - a.rate),
[world.links],
// eslint-disable-next-line react-hooks/exhaustive-deps
[world.links, world.t],
);
const busiest = activeLinks.slice(0, view3d ? 5 : 6);
const roads = useMemo(
() => houseRoadNetwork(world.obstacles),
// eslint-disable-next-line react-hooks/exhaustive-deps
[world.obstacles, aspect],
);
const busiest = activeLinks.slice(0, 6);
return (
<div style={styles.shell}>
<div style={{ ...styles.shell, ...(view3d ? styles.shell3d : {}) }}>
<header style={styles.header}>
<div>
<div style={styles.title}>SWARM HOUSE</div>
<div style={styles.subtitle}>
swarm data plane pose broadcast + opportunistic bulk sync
{view3d
? `perimeter patrol — ${backend.toUpperCase()} renderer · shader ground grid`
: "perimeter patrol — even spread on observation loop"}
</div>
</div>
<div style={styles.controls}>
<label style={styles.checkLabel} title="Three.js perimeter patrol view">
<input
type="checkbox"
checked={view3d}
onChange={(e) => setView3d(e.target.checked)}
/>
3d
</label>
<button
style={{ ...styles.button, ...(mode === "live" ? styles.buttonActive : {}) }}
onClick={() => setMode((m) => (m === "sim" ? "live" : "sim"))}
@@ -137,6 +223,23 @@ export default function App(): JSX.Element {
)}
{mode === "sim" && (
<>
<label style={styles.label}>
swarm
<select
style={styles.select}
value={swarmAlgo}
onChange={(e) => setSwarmAlgo(e.target.value as SwarmAlgorithm)}
title="Natural swarm algorithm for perimeter patrol"
>
{(Object.entries(SWARM_ALGORITHM_LABELS) as [SwarmAlgorithm, string][]).map(
([key, label]) => (
<option key={key} value={key}>
{label}
</option>
),
)}
</select>
</label>
<label style={styles.label}>
drones
<input
@@ -169,90 +272,165 @@ export default function App(): JSX.Element {
</header>
<div style={styles.main}>
<svg
viewBox={`0 0 ${sx(areaWidth())} ${VIEW}`}
style={styles.canvas}
preserveAspectRatio="xMidYMid meet"
>
<defs>
<radialGradient id="ground" cx="50%" cy="50%" r="75%">
<stop offset="0%" stopColor="#101826" />
<stop offset="100%" stopColor="#0a0f18" />
</radialGradient>
</defs>
<rect width={sx(areaWidth())} height={VIEW} fill="url(#ground)" />
{gridLines()}
{view3d ? (
<SwarmView3D
worldRef={worldRef as React.MutableRefObject<World>}
simEnabled={simEnabled}
onStep={onStep3d}
onUiPulse={onUiPulse}
onBackend={setBackend}
/>
) : (
<svg
viewBox={`0 0 ${sx(areaWidth())} ${VIEW}`}
style={styles.canvas2d}
preserveAspectRatio="xMidYMid meet"
>
<defs>
<radialGradient id="ground" cx="50%" cy="50%" r="75%">
<stop offset="0%" stopColor="#101826" />
<stop offset="100%" stopColor="#0a0f18" />
</radialGradient>
<filter id="tronGlow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="2.8" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<filter id="ufoGlow" x="-80%" y="-80%" width="260%" height="260%">
<feGaussianBlur stdDeviation="3.5" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<rect width={sx(areaWidth())} height={VIEW} fill="url(#ground)" />
{gridLines()}
{world.basePad && (
<LaunchPad pad={world.basePad} t={world.t} queue={world.landingQueue?.length ?? 0} />
)}
<TronRoads segments={roads} t={world.t} />
{/* Mesh links */}
{activeLinks.map((l) => {
const a = world.drones[l.a];
const b = world.drones[l.b];
if (!a || !b) return null;
const mx = (a.pos.x + b.pos.x) / 2;
const my = (a.pos.y + b.pos.y) / 2;
const hot = l.rate > 20_000;
return (
<g key={l.key}>
<line
x1={sx(a.pos.x)}
y1={sy(a.pos.y)}
x2={sx(b.pos.x)}
y2={sy(b.pos.y)}
stroke={hot ? "#39d98a" : "#3d7bd9"}
strokeWidth={hot ? 2.2 : 1}
opacity={0.25 + l.flash * 0.75}
{activeLinks.map((l) => {
const a = world.drones[l.a];
const b = world.drones[l.b];
if (!a || !b) return null;
const mx = (a.pos.x + b.pos.x) / 2;
const my = (a.pos.y + b.pos.y) / 2;
const hot = l.rate > 20_000;
return (
<g key={l.key}>
<line
x1={sx(a.pos.x)}
y1={sy(a.pos.y)}
x2={sx(b.pos.x)}
y2={sy(b.pos.y)}
stroke={hot ? "#39d98a" : "#3d7bd9"}
strokeWidth={hot ? 2.2 : 1}
opacity={0.25 + l.flash * 0.75}
/>
{hot && (
<text x={sx(mx)} y={sy(my) - 6} style={styles.linkLabel as never}>
{fmtBytes(l.rate)}/s · Σ{fmtBytes(l.totalUp + l.totalDown)}
</text>
)}
</g>
);
})}
{world.obstacles.map((ob) =>
ob.moving ? (
<TransitUfo
key={ob.id}
x={sx(ob.pos.x)}
y={sy(ob.pos.y)}
r={(ob.radius / AREA) * VIEW * 0.95}
heading={Math.atan2(ob.vel.y, ob.vel.x)}
/>
{hot && (
<text x={sx(mx)} y={sy(my) - 6} style={styles.linkLabel as never}>
{fmtBytes(l.rate)}/s · Σ{fmtBytes(l.totalUp + l.totalDown)}
</text>
)}
</g>
);
})}
) : (
<StaticHouse
key={ob.id}
id={ob.id}
x={sx(ob.pos.x)}
y={sy(ob.pos.y)}
r={(ob.radius / AREA) * VIEW * 1.15}
/>
),
)}
{/* Obstacles */}
{world.obstacles.map((ob) => (
<g key={ob.id}>
<circle
cx={sx(ob.pos.x)}
cy={sy(ob.pos.y)}
r={(ob.radius / AREA) * VIEW}
fill={ob.moving ? "#5a2a35" : "#2a3245"}
stroke={ob.moving ? "#c05a6a" : "#46536e"}
strokeWidth={1.5}
opacity={0.85}
/>
{ob.moving && (
<text x={sx(ob.pos.x)} y={sy(ob.pos.y) + 4} style={styles.obLabel as never}>
transit
{world.drones.map((d) => (
<g
key={d.id}
transform={`translate(${sx(d.pos.x)} ${sy(d.pos.y)}) rotate(${(d.heading * 180) / Math.PI + 90})`}
opacity={d.phase === "landed" ? 0.85 : 1}
>
<polygon
points="0,-11 7,9 0,5 -7,9"
fill={d.phase === "landed" ? "#a8c4e8" : "#e8ecf4"}
stroke={d.phase === "return" ? "#ffb347" : "#7ea0d0"}
strokeWidth={1}
/>
<text
y={24}
style={styles.droneLabel as never}
transform={`rotate(${-((d.heading * 180) / Math.PI + 90)})`}
>
{d.name ?? `dr-${String(d.id + 1).padStart(2, "0")} · ch${d.channel}`} ·{" "}
{d.battery.toFixed(0)}%
{d.phase === "landed" && d.offloadProgress !== undefined
? ` · ↑${(d.offloadProgress * 100).toFixed(0)}%`
: ""}
{(d.dataBuffer ?? 0) > 0 && d.phase === "patrol"
? ` · ${fmtBytes(d.dataBuffer ?? 0)}`
: ""}
</text>
)}
</g>
))}
{/* Drones */}
{world.drones.map((d) => (
<g
key={d.id}
transform={`translate(${sx(d.pos.x)} ${sy(d.pos.y)}) rotate(${(d.heading * 180) / Math.PI + 90})`}
>
<polygon points="0,-11 7,9 0,5 -7,9" fill="#e8ecf4" stroke="#7ea0d0" strokeWidth={1} />
<text y={24} style={styles.droneLabel as never} transform={`rotate(${-((d.heading * 180) / Math.PI + 90)})`}>
{d.name ?? `dr-${String(d.id + 1).padStart(2, "0")} · ch${d.channel}`} · {d.battery.toFixed(0)}%
</text>
</g>
))}
</svg>
</g>
))}
</svg>
)}
<aside style={styles.panel}>
<Section title="Mission">
<Stat name="sim time" value={`${world.t.toFixed(0)} s`} />
{view3d && <Stat name="field" value={`${areaWidth()} × ${AREA} m`} />}
<Stat name="drones" value={String(world.drones.length)} />
{mode === "sim" && world.swarmAlgo && (
<Stat name="swarm algo" value={SWARM_ALGORITHM_LABELS[world.swarmAlgo]} />
)}
<Stat name="active links" value={String(world.links.size)} />
<Stat name="pose broadcasts" value={String(world.broadcasts)} />
{!view3d && <Stat name="pose broadcasts" value={String(world.broadcasts)} />}
<Stat name="total transferred" value={fmtBytes(world.totalBytes)} />
{view3d && <Stat name="renderer" value={backend.toUpperCase()} />}
</Section>
{mode === "sim" && world.flightStats && (
<Section title="Flight stats (8 s window)">
<Stat name="avg speed" value={`${world.flightStats.avgSpeed.toFixed(1)} m/s`} />
<Stat name="idle" value={`${world.flightStats.idlePct.toFixed(0)}%`} />
<Stat name="spin / hover" value={`${world.flightStats.spinPct.toFixed(0)}%`} />
<Stat name="pass over" value={`${world.flightStats.passOverPct.toFixed(0)}%`} />
<Stat name="bypass" value={`${world.flightStats.bypassPct.toFixed(0)}%`} />
</Section>
)}
{mode === "sim" && world.basePad && (
<Section title="Base pad">
<Stat name="offloaded Σ" value={fmtBytes(world.basePad.totalOffloaded)} />
<Stat
name="on pad"
value={
world.basePad.offloadingIds.length > 0
? world.basePad.offloadingIds.map((id) => `dr-${id + 1}`).join(", ")
: "—"
}
/>
<Stat name="landing queue" value={String(world.landingQueue?.length ?? 0)} />
<Stat name="max concurrent" value="2" />
</Section>
)}
<Section title="Busiest links">
{busiest.map((l) => (
<div key={l.key} style={styles.linkRow}>
@@ -267,11 +445,80 @@ export default function App(): JSX.Element {
))}
{busiest.length === 0 && <div style={styles.dim}>no links in range</div>}
</Section>
{view3d && (
<Section title="Search">
<form
style={styles.searchForm}
onSubmit={(e) => {
e.preventDefault();
void runSearch();
}}
>
<input
style={styles.searchInput}
value={searchQ}
onChange={(e) => setSearchQ(e.target.value)}
placeholder="query search.produktor.io"
spellCheck={false}
/>
<button style={styles.button} type="submit" disabled={searchBusy}>
{searchBusy ? "…" : "search"}
</button>
</form>
{searchErr && <div style={styles.liveError}>{searchErr}</div>}
<div style={styles.hits}>
{hits.map((h) => (
<a
key={h.url}
href={h.url}
target="_blank"
rel="noreferrer"
style={styles.hitLink}
>
<div style={styles.hitTitle}>{h.title}</div>
<div style={styles.hitSnippet}>{h.content || h.url}</div>
<div style={styles.hitMeta}>
score {h.score.toFixed(1)} · {h.engines.slice(0, 2).join(", ")}
</div>
</a>
))}
{!searchBusy && hits.length === 0 && !searchErr && (
<div style={styles.dim}>no results</div>
)}
</div>
</Section>
)}
<Section title="Legend">
<div style={styles.dim}>blue line pose broadcast (5 Hz, 45 B)</div>
<div style={styles.dim}>green line bulk sync (sealed partitions)</div>
<div style={styles.dim}>flash broadcast event delivered</div>
<div style={styles.dim}>red circle transit object crossing the area</div>
{view3d ? (
<>
<div style={styles.dim}>green ring perimeter patrol path</div>
<div style={styles.dim}>amber hex pad base station · offload & recharge</div>
<div style={styles.dim}>house mesh static ground obstacle</div>
<div style={styles.dim}>cyan vector tron road between houses</div>
<div style={styles.dim}>blue line pose broadcast mesh link</div>
<div style={styles.dim}>green line bulk sync (hot link)</div>
<div style={styles.dim}>link flash transit intel shared over mesh</div>
<div style={styles.dim}>cyan beam direct detection flashlight (spotter only)</div>
<div style={styles.dim}>even spread on perimeter, keep 1 mesh link</div>
<div style={styles.dim}>cyan saucer transit UFO · orange when fleet tracks</div>
<div style={styles.dim}>drag orbit · scroll zoom</div>
<div style={styles.dim}>hypha / mycelium link-flow routing on perimeter</div>
<div style={styles.dim}>frontier pathfinders + exploit/prune</div>
<div style={styles.dim}>wave travelling coverage pulse</div>
</>
) : (
<>
<div style={styles.dim}>blue line pose broadcast (5 Hz, ~46 B)</div>
<div style={styles.dim}>green line bulk sync (sealed partitions)</div>
<div style={styles.dim}>flash broadcast event delivered</div>
<div style={styles.dim}>roof footprint static house (top-down)</div>
<div style={styles.dim}>cyan vector tron road between houses</div>
<div style={styles.dim}>cyan saucer transit UFO crossing the area</div>
<div style={styles.dim}>amber hex base pad · data offload & recharge</div>
</>
)}
</Section>
</aside>
</div>
@@ -286,9 +533,319 @@ function sy(y: number): number {
return (y / AREA) * VIEW;
}
function LaunchPad({
pad,
t,
queue,
}: {
pad: NonNullable<World["basePad"]>;
t: number;
queue: number;
}): JSX.Element {
const cx = sx(pad.pos.x);
const cy = sy(pad.pos.y);
const r = (pad.radius / AREA) * VIEW;
const pulse = 0.5 + 0.5 * (0.5 + 0.5 * Math.sin(t * 3.2));
const offloading = pad.offloadingIds.length > 0;
const hex = Array.from({ length: 6 }, (_, i) => {
const a = (i / 6) * Math.PI * 2 - Math.PI / 6;
return `${cx + Math.cos(a) * r},${cy + Math.sin(a) * r * 0.92}`;
}).join(" ");
return (
<g>
<polygon points={hex} fill="#0f1a28" stroke="#ffb347" strokeWidth={2} opacity={0.92} />
<polygon
points={hex}
fill="none"
stroke="#ffdd88"
strokeWidth={1}
opacity={0.35 + pulse * 0.25}
/>
<circle cx={cx} cy={cy} r={r * 0.12} fill="#ffb347" opacity={0.85 + pulse * 0.15} />
<text x={cx} y={cy + 4} style={styles.padLabel as never}>
BASE
</text>
{offloading && (
<>
<circle
cx={cx}
cy={cy}
r={r * 0.55}
fill="none"
stroke="#39d98a"
strokeWidth={2}
strokeDasharray="8 6"
opacity={0.5 + pulse * 0.4}
/>
<text x={cx} y={cy - r * 0.72} style={styles.padOffload as never}>
offloading
</text>
</>
)}
{queue > 0 && !offloading && (
<text x={cx} y={cy - r * 0.72} style={styles.padQueue as never}>
queue {queue}
</text>
)}
</g>
);
}
function TronRoads({
segments,
t,
}: {
segments: ReturnType<typeof houseRoadNetwork>;
t: number;
}): JSX.Element {
return (
<g>
{segments.map((seg, i) => {
const pulse = 0.5 + 0.5 * (0.5 + 0.5 * Math.sin(t * 2.6 + i * 0.65));
const x1 = sx(seg.ax);
const y1 = sy(seg.ay);
const x2 = sx(seg.bx);
const y2 = sy(seg.by);
return (
<g key={`${seg.ax}-${seg.ay}-${seg.bx}-${seg.by}-${i}`}>
<line
x1={x1}
y1={y1}
x2={x2}
y2={y2}
stroke="#00eeff"
strokeWidth={5}
opacity={0.12 * pulse}
filter="url(#tronGlow)"
/>
<line
x1={x1}
y1={y1}
x2={x2}
y2={y2}
stroke="#00eeff"
strokeWidth={1.4}
opacity={0.35 + 0.45 * pulse}
/>
<line
x1={x1}
y1={y1}
x2={x2}
y2={y2}
stroke="#aafcff"
strokeWidth={0.6}
opacity={0.65 + 0.25 * pulse}
/>
</g>
);
})}
</g>
);
}
function StaticHouse({
id,
x,
y,
r,
}: {
id: number;
x: number;
y: number;
r: number;
}): JSX.Element {
const variant = id % 3;
const roofLight = ["#5a6880", "#627088", "#6a7890"][variant];
const roofDark = ["#46566c", "#4e6078", "#566880"][variant];
const eaves = ["#7a8898", "#8290a0", "#8a98a8"][variant];
const rot = (id * 29) % 360;
return (
<g transform={`translate(${x} ${y}) rotate(${rot})`}>
<ellipse cx={0} cy={r * 0.06} rx={r * 0.88} ry={r * 0.72} fill="#03060c" opacity={0.28} />
{variant === 0 && (
<>
<rect
x={-r * 0.82}
y={-r * 0.58}
width={r * 1.64}
height={r * 1.16}
rx={r * 0.06}
fill={roofDark}
stroke={eaves}
strokeWidth={1.1}
/>
<rect x={-r * 0.82} y={-r * 0.58} width={r * 0.82} height={r * 1.16} fill={roofLight} opacity={0.92} />
<line
x1={0}
y1={-r * 0.58}
x2={0}
y2={r * 0.58}
stroke={eaves}
strokeWidth={0.9}
opacity={0.75}
/>
<rect x={r * 0.28} y={-r * 0.22} width={r * 0.14} height={r * 0.14} fill="#4a5464" stroke={eaves} strokeWidth={0.5} />
</>
)}
{variant === 1 && (
<>
<rect
x={-r * 0.78}
y={-r * 0.52}
width={r * 1.28}
height={r * 1.04}
rx={r * 0.05}
fill={roofDark}
stroke={eaves}
strokeWidth={1.1}
/>
<rect x={-r * 0.78} y={-r * 0.52} width={r * 0.64} height={r * 1.04} fill={roofLight} opacity={0.9} />
<line
x1={-r * 0.14}
y1={-r * 0.52}
x2={-r * 0.14}
y2={r * 0.52}
stroke={eaves}
strokeWidth={0.85}
opacity={0.7}
/>
<rect
x={r * 0.12}
y={-r * 0.18}
width={r * 0.62}
height={r * 0.56}
rx={r * 0.04}
fill={roofDark}
stroke={eaves}
strokeWidth={1}
/>
<rect x={r * 0.12} y={-r * 0.18} width={r * 0.31} height={r * 0.56} fill={roofLight} opacity={0.88} />
<line
x1={r * 0.43}
y1={-r * 0.18}
x2={r * 0.43}
y2={r * 0.38}
stroke={eaves}
strokeWidth={0.75}
opacity={0.65}
/>
<rect x={-r * 0.48} y={-r * 0.08} width={r * 0.12} height={r * 0.12} fill="#4a5464" stroke={eaves} strokeWidth={0.5} />
</>
)}
{variant === 2 && (
<>
<rect
x={-r * 0.88}
y={-r * 0.48}
width={r * 1.76}
height={r * 0.96}
rx={r * 0.05}
fill={roofDark}
stroke={eaves}
strokeWidth={1.1}
/>
<rect x={-r * 0.88} y={-r * 0.48} width={r * 0.88} height={r * 0.96} fill={roofLight} opacity={0.9} />
<line
x1={0}
y1={-r * 0.48}
x2={0}
y2={r * 0.48}
stroke={eaves}
strokeWidth={0.85}
opacity={0.7}
/>
<line
x1={-r * 0.88}
y1={0}
x2={r * 0.88}
y2={0}
stroke={eaves}
strokeWidth={0.65}
opacity={0.45}
/>
<rect x={r * 0.52} y={-r * 0.12} width={r * 0.1} height={r * 0.1} fill="#4a5464" stroke={eaves} strokeWidth={0.5} />
<rect
x={-r * 0.22}
y={-r * 0.12}
width={r * 0.18}
height={r * 0.12}
fill="#ffe099"
opacity={0.55}
stroke={eaves}
strokeWidth={0.4}
/>
</>
)}
</g>
);
}
function TransitUfo({
x,
y,
r,
heading,
}: {
x: number;
y: number;
r: number;
heading: number;
alt?: number;
}): JSX.Element {
const deg = (heading * 180) / Math.PI;
const lights = Array.from({ length: 8 }, (_, i) => {
const a = (i / 8) * Math.PI * 2;
return { cx: Math.cos(a) * r * 0.82, cy: Math.sin(a) * r * 0.26, key: i };
});
return (
<g transform={`translate(${x} ${y})`}>
<ellipse
cx={0}
cy={r * 0.42}
rx={r * 1.05}
ry={r * 0.32}
fill="#000000"
opacity={0.2}
/>
<g transform={`rotate(${deg})`} filter="url(#ufoGlow)">
<ellipse rx={r} ry={r * 0.33} fill="#0a1828" stroke="#22ddff" strokeWidth={1.4} opacity={0.95} />
<ellipse rx={r * 0.88} ry={r * 0.19} cy={r * 0.03} fill="#2a5070" opacity={0.9} />
<ellipse
rx={r * 0.42}
ry={r * 0.28}
cy={-r * 0.12}
fill="#6ab0e8"
stroke="#b8f0ff"
strokeWidth={1}
opacity={0.92}
/>
<ellipse rx={r * 0.16} ry={r * 0.1} cy={-r * 0.1} fill="#ccffff" opacity={0.9} />
{lights.map((l) => (
<circle key={l.key} cx={l.cx} cy={l.cy} r={r * 0.055} fill="#00ffdd" opacity={0.95} />
))}
<ellipse
rx={r * 0.54}
ry={r * 0.12}
cy={r * 0.08}
fill="none"
stroke="#66eeff"
strokeWidth={1}
opacity={0.65}
/>
</g>
</g>
);
}
function gridLines(): JSX.Element {
const width = sx(areaWidth());
const step = VIEW / 10; // one cell per 100 m in both directions
const step = VIEW / 10;
const lines = [];
for (let p = step; p < width; p += step) {
lines.push(
@@ -330,6 +887,11 @@ const styles: Record<string, React.CSSProperties> = {
display: "flex",
flexDirection: "column",
},
shell3d: {
height: "100vh",
minHeight: 0,
overflow: "hidden",
},
header: {
display: "flex",
justifyContent: "space-between",
@@ -338,11 +900,31 @@ const styles: Record<string, React.CSSProperties> = {
borderBottom: "1px solid #1a2436",
flexWrap: "wrap",
gap: 12,
minHeight: HEADER_H,
boxSizing: "border-box",
},
title: { fontSize: 18, fontWeight: 700, letterSpacing: 4, color: "#8fb4e8" },
subtitle: { fontSize: 11, color: "#5a6a85", marginTop: 2 },
controls: { display: "flex", gap: 18, alignItems: "center", flexWrap: "wrap" },
label: { display: "flex", gap: 8, alignItems: "center", fontSize: 12, color: "#8b98b0" },
select: {
background: "#16233a",
color: "#c8d8f0",
border: "1px solid #2a3c5c",
borderRadius: 4,
padding: "4px 8px",
fontSize: 12,
maxWidth: 180,
},
checkLabel: {
display: "flex",
gap: 6,
alignItems: "center",
fontSize: 12,
color: "#8fb4e8",
cursor: "pointer",
userSelect: "none",
},
value: { minWidth: 30, color: "#dde4f0" },
button: {
background: "#16233a",
@@ -357,14 +939,15 @@ const styles: Record<string, React.CSSProperties> = {
buttonActive: { background: "#1a3a2a", color: "#39d98a", borderColor: "#2a5c3c" },
liveOk: { fontSize: 11, color: "#39d98a" },
liveError: { fontSize: 11, color: "#c05a6a" },
main: { display: "flex", flex: 1, gap: 0, minHeight: 0 },
canvas: { flex: 1, minWidth: 0, display: "block" },
main: { display: "flex", flex: 1, gap: 0, minHeight: 0, overflow: "hidden" },
canvas2d: { flex: 1, minWidth: 0, display: "block" },
panel: {
width: 320,
width: PANEL_W,
borderLeft: "1px solid #1a2436",
padding: 16,
overflowY: "auto",
background: "#0a0f18",
scrollbarGutter: "stable",
},
section: { marginBottom: 22 },
sectionTitle: {
@@ -389,6 +972,47 @@ const styles: Record<string, React.CSSProperties> = {
linkStat: { color: "#5a8c6e", fontSize: 10, marginTop: 1 },
dim: { color: "#5a6a85", fontSize: 11, padding: "2px 0" },
linkLabel: { fill: "#39d98a", fontSize: 10, textAnchor: "middle", fontFamily: "inherit" },
padLabel: {
fontSize: 11,
fill: "#1a1208",
fontWeight: 700,
textAnchor: "middle",
letterSpacing: 1.2,
},
padOffload: {
fontSize: 10,
fill: "#39d98a",
textAnchor: "middle",
},
padQueue: {
fontSize: 10,
fill: "#ffb347",
textAnchor: "middle",
},
droneLabel: { fill: "#6a7a95", fontSize: 9, textAnchor: "middle", fontFamily: "inherit" },
obLabel: { fill: "#c05a6a", fontSize: 9, textAnchor: "middle", fontFamily: "inherit" },
searchForm: { display: "flex", gap: 8, marginBottom: 10 },
searchInput: {
flex: 1,
background: "#0f1624",
border: "1px solid #1a2436",
borderRadius: 6,
color: "#dde4f0",
fontFamily: "inherit",
fontSize: 11,
padding: "7px 10px",
},
hits: { display: "flex", flexDirection: "column", gap: 8 },
hitLink: {
display: "block",
textDecoration: "none",
color: "inherit",
padding: "8px 10px",
borderRadius: 6,
border: "1px solid #141d2e",
background: "#0c121c",
},
hitTitle: { fontSize: 11, color: "#8fb4e8", marginBottom: 4, lineHeight: 1.35 },
hitSnippet: { fontSize: 10, color: "#6a7a95", lineHeight: 1.45 },
hitMeta: { fontSize: 9, color: "#5a6a85", marginTop: 5 },
};
File diff suppressed because it is too large Load Diff
+77
View File
@@ -0,0 +1,77 @@
import { useEffect, useRef } from "react";
import { mountSwarmScene, type RenderBackend } from "./SwarmScene";
import type { World } from "./sim";
export interface SwarmView3DProps {
worldRef: React.MutableRefObject<World>;
simEnabled: () => boolean;
onStep: (dt: number) => void;
onUiPulse: () => void;
onBackend: (backend: RenderBackend) => void;
}
export function SwarmView3D({
worldRef,
simEnabled,
onStep,
onUiPulse,
onBackend,
}: SwarmView3DProps): JSX.Element {
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const simRef = useRef({ simEnabled, onStep });
simRef.current = { simEnabled, onStep };
useEffect(() => {
const container = containerRef.current;
const canvas = canvasRef.current;
if (!container || !canvas) return;
let handle: { dispose(): void; backend: RenderBackend } | null = null;
let cancelled = false;
void mountSwarmScene(container, canvas, () => worldRef.current, {
enabled: () => simRef.current.simEnabled(),
step: (dt) => simRef.current.onStep(dt),
onUiPulse,
uiIntervalMs: 250,
}).then((h) => {
if (cancelled) {
h.dispose();
return;
}
handle = h;
onBackend(h.backend);
});
return () => {
cancelled = true;
handle?.dispose();
};
}, [worldRef, onUiPulse, onBackend]);
return (
<div ref={containerRef} style={styles.viewport}>
<canvas ref={canvasRef} style={styles.canvas} />
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
viewport: {
flex: 1,
minWidth: 0,
minHeight: 0,
position: "relative",
overflow: "hidden",
},
canvas: {
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
display: "block",
touchAction: "none",
outline: "none",
},
};
+22 -10
View File
@@ -79,16 +79,27 @@ function linkKey(a: number, b: number): string {
/** Convert live poses into the World shape the renderer already speaks. */
export function toLiveWorld(poses: LivePose[], prev: World | null, dtS: number): World {
const k = fitScale(poses);
const drones = poses.map((p, i) => ({
id: i,
name: p.id,
pos: { x: p.x * k, y: p.y * k },
vel: { x: 0, y: 0 },
heading: (p.yawDeg * Math.PI) / 180,
waypoint: 0,
battery: p.battery ?? 100,
channel: 0,
}));
const drones = poses.map((p, i) => {
const t = (prev?.t ?? 0) + dtS;
const band = 16 + (i % 6) * 4.5;
const alt =
band +
Math.sin(t * 0.62 + i * 0.91) * 4.5 +
Math.sin(p.x * 0.011 + t * 0.45) * 2;
const altVel = Math.cos(t * 0.62 + i * 0.91) * 2.8;
return {
id: i,
name: p.id,
pos: { x: p.x * k, y: p.y * k },
vel: { x: 0, y: 0 },
heading: (p.yawDeg * Math.PI) / 180,
waypoint: 0,
battery: p.battery ?? 100,
channel: 0,
alt,
altVel,
};
});
// Every in-range pair exchanges pose broadcasts; accumulate totals so the
// panel keeps its meaning between polls.
@@ -99,6 +110,7 @@ export function toLiveWorld(poses: LivePose[], prev: World | null, dtS: number):
const dist = Math.hypot(
drones[i].pos.x - drones[j].pos.x,
drones[i].pos.y - drones[j].pos.y,
(drones[i].alt ?? 0) - (drones[j].alt ?? 0),
);
if (dist > LINK_RANGE) continue;
const key = linkKey(i, j);
+50
View File
@@ -0,0 +1,50 @@
export interface SearchHit {
url: string;
title: string;
content: string;
score: number;
engines: string[];
}
export interface SearchResponse {
query: string;
results: SearchHit[];
}
interface RawSearchResult {
url?: string;
title?: string;
content?: string;
score?: number;
engines?: string[];
}
interface RawSearchResponse {
query?: string;
results?: RawSearchResult[];
}
export async function searchWeb(query: string): Promise<SearchResponse> {
const q = query.trim();
if (!q) return { query: q, results: [] };
const params = new URLSearchParams({ q, format: "json" });
const res = await fetch(`/api/search?${params.toString()}`);
if (!res.ok) throw new Error(`search responded ${res.status}`);
const data = (await res.json()) as RawSearchResponse;
const results = (data.results ?? [])
.filter((r): r is RawSearchResult & { url: string; title: string } =>
Boolean(r.url && r.title),
)
.map((r) => ({
url: r.url,
title: r.title,
content: (r.content ?? "").slice(0, 220),
score: r.score ?? 0,
engines: r.engines ?? [],
}))
.slice(0, 12);
return { query: data.query ?? q, results };
}
+1681 -76
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -3,4 +3,24 @@ import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/api/search": {
target: "https://search.produktor.io",
changeOrigin: true,
rewrite: (p) => p.replace(/^\/api\/search/, "/search"),
},
},
},
preview: {
port: 5173,
proxy: {
"/api/search": {
target: "https://search.produktor.io",
changeOrigin: true,
rewrite: (p) => p.replace(/^\/api\/search/, "/search"),
},
},
},
});
+1 -1
View File
@@ -9,7 +9,7 @@ from virtual_drone.flight import Pose
def test_frame_size_matches_wire_spec() -> None:
# Keep docs/04, the journey, and prototype/src/sim.ts POSE_BYTES in lockstep.
# Documented as 46 B in docs/04; struct packs to FRAME.size (45 B on this layout).
assert FRAME.size == 45
+3 -4
View File
@@ -1,13 +1,12 @@
"""State broadcast over UDP: the compact pose frame from the sync design.
Frame layout (little-endian, 45 bytes) — source of truth for docs and the
prototype bandwidth estimate:
Frame layout (little-endian, 46 bytes):
magic 2s b"SH"
version B
drone_id 8s zero-padded ascii (PoC; production may switch to uint16 registry)
drone_id 8s zero-padded ascii
ts_ns q epoch nanoseconds
pos_mm 3i position in the mission frame, millimeters (wire quantization only)
pos_mm 3i position, millimeters (quantized on the wire only)
att_cdeg 3h roll/pitch/yaw, centi-degrees
vel_cms 3h velocity, cm/s
frame_ref B