3 Commits

Author SHA1 Message Date
sam_oneal 53cc4ee321 updated the runner to utilize caching
CI / Detect changed paths (pull_request) Successful in 11s
CI / Odin unit tests and build (pull_request) Successful in 1m18s
CI / API unit tests and lint (pull_request) Has been skipped
CI / Infra unit tests, vet, and preview (pull_request) Successful in 1m13s
2026-09-06 14:08:56 -06:00
sam_oneal d227296e77 updated procedures in data.odin to pass url; added research notes for data-streaming
CI / Detect changed paths (pull_request) Successful in 6s
CI / Odin unit tests and build (pull_request) Successful in 1m28s
CI / API unit tests and lint (pull_request) Has been skipped
CI / Infra unit tests, vet, and preview (pull_request) Successful in 1m36s
2026-09-06 19:57:31 +00:00
sam_oneal 060098c673 added data file to store API objects and reaching out to API 2026-09-06 19:57:31 +00:00
38 changed files with 116 additions and 2078 deletions
-3
View File
@@ -7,6 +7,3 @@ resources/ai/sessions
infra/Pulumi.*.yaml.backup
infra/desi-explorer-infra
api/target/
.env
.env.*
*.env
-1
View File
@@ -25,7 +25,6 @@ The root `Makefile` is a lean delegator: base commands (`run`, `build`, `test`,
- Odin code lives in `gui/src/`; external deps go in `gui/lib/` and are wired via `-collection:lib=lib/local` (or a git submodule imported by relative path).
- Everything is plain `make` — no Taskfile — so CI (Gitea Actions) can call `make` directly.
- Keep the renderer (gui/), API (api/), and infra (infra/) logically separated; each owns its own Makefile, and the root Makefile is the only place that ties them together.
- **Diagrams in this repo's documentation are Mermaid flowcharts.** Gitea renders ` ```mermaid ` fenced blocks natively. Prefer a Mermaid flowchart over ASCII art / box-drawing diagrams; if a diagram genuinely can't be expressed as a flowchart, fall back to a plain text-based markdown diagram (e.g. a code block or table) rather than hand-rawn ASCII boxes.
## Gotchas
- Odin version is pinned in `.gitea/workflows/*.yml` (`ODIN_VERSION`) and defaults in `scripts/install_odin.sh`; bump both together when tracking a new release.
+7 -40
View File
@@ -9,24 +9,8 @@ GUI := gui
API := api
INFRA := infra
# Local dev/test assets live under resources/dev. These are the defaults for
# the run/*-web targets; override any of them on the command line, e.g.
# make run GUI_ENV_FILE=/path/to/gui.env API_DESI_DATA=/path/to/data.json
RESOURCE_DIR := $(CURDIR)/resources/dev
GUI_ENV_FILE ?= $(RESOURCE_DIR)/gui.env.example
API_ENV_FILE ?= $(RESOURCE_DIR)/api.env.example
API_DESI_DATA ?= $(RESOURCE_DIR)/desi_subset.json
# The API binds here for local dev (see api/src/config.rs); `make run` passes
# it through and waits for $(API_HEALTH_URL) to respond before launching the
# GUI, so the renderer never races the API on startup. A bind host of
# 0.0.0.0 is probed via 127.0.0.1. Adjust API_WAIT_TIMEOUT (seconds) if the
# API takes longer than 60s to become healthy on a given machine.
API_BIND_ADDR ?= 127.0.0.1:8080
API_HEALTH_URL := http://$(subst 0.0.0.0,127.0.0.1,$(API_BIND_ADDR))/health
API_WAIT_TIMEOUT ?= 60
.PHONY: help setup run run-web build build-debug build-web test clean fmt \
api-wait renovate-validate
renovate-validate
help: ## List available targets
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
@@ -47,11 +31,10 @@ setup: ## Setup all sub-projects (submodules, gui deps, api deps, infra deps)
## ---- Renderer (Odin) -----------------------------------------------------
run: ## Run the native app (gui/); waits for the API to be healthy first
@API_ENV_FILE="$(API_ENV_FILE)" API_DESI_DATA="$(API_DESI_DATA)" API_BIND_ADDR="$(API_BIND_ADDR)" $(MAKE) -C $(API) run & api_pid=$$!; \
run: ## Run the native app (gui/)
@$(MAKE) -C $(API) run & api_pid=$$!; \
trap 'kill $$api_pid 2>/dev/null' INT TERM EXIT; \
$(MAKE) api-wait || { kill $$api_pid 2>/dev/null; exit 1; }; \
GUI_ENV_FILE="$(GUI_ENV_FILE)" $(MAKE) -C $(GUI) run; \
$(MAKE) -C $(GUI) run; \
kill $$api_pid 2>/dev/null
build: ## Release build (gui/ + api/)
@@ -65,28 +48,12 @@ build-debug: ## Debug build (gui/ + api/)
build-web: ## WebAssembly build -> build/web (gui/, needs emscripten)
@$(MAKE) -C $(GUI) build-web
run-web: ## Start WASM build + API server for web dev (waits for API health)
@API_ENV_FILE="$(API_ENV_FILE)" API_DESI_DATA="$(API_DESI_DATA)" API_BIND_ADDR="$(API_BIND_ADDR)" $(MAKE) -C $(API) run & api_pid=$$!; \
run-web: ## Start WASM build + API server for web dev
@$(MAKE) -C $(API) run & api_pid=$$!; \
trap 'kill $$api_pid 2>/dev/null' INT TERM EXIT; \
$(MAKE) api-wait || { kill $$api_pid 2>/dev/null; exit 1; }; \
GUI_ENV_FILE="$(GUI_ENV_FILE)" $(MAKE) -C $(GUI) build-web; \
$(MAKE) -C $(GUI) build-web; \
kill $$api_pid 2>/dev/null
## ---- Dev helpers ----------------------------------------------------------
api-wait: ## Poll the API health endpoint until it responds (or times out)
@echo "Waiting for API at $(API_HEALTH_URL) ..."; \
elapsed=0; \
while ! curl -sf "$(API_HEALTH_URL)" >/dev/null 2>&1; do \
elapsed=$$((elapsed + 1)); \
if [ "$${elapsed}" -ge "$(API_WAIT_TIMEOUT)" ]; then \
echo "Error: API at $(API_HEALTH_URL) not healthy after $(API_WAIT_TIMEOUT)s" >&2; \
exit 1; \
fi; \
sleep 1; \
done; \
echo "API is up."
## ---- Aggregates ----------------------------------------------------------
test: ## Test all projects (odin + cargo + go)
-25
View File
@@ -25,7 +25,6 @@ A fun personal project with three interlocking goals:
Early scaffolding. The application currently:
- Opens a resizable 3D raylib window with an orbital camera (zoom + rotate + pan).
- Starts on a launch screen where you configure the API URL, pick a region, hit **Refresh catalogs** to pull the catalog list, and press **Explore** to enter the 3D view (TAB returns to the launch screen).
- Renders a procedurally generated point cloud standing in for the galaxy catalog (real DESI data ingestion is the next milestone).
- Compiles natively, to WebAssembly, and is deployed to an on-prem Kubernetes cluster as a placeholder web service.
@@ -80,30 +79,6 @@ make clean # remove build artifacts from all projects
make fmt # format all projects
```
`make run` / `make run-web` configure the API and renderer from the example
assets under `resources/dev/` (see the dotenv lib in `gui/lib/local/dotenv`).
Override any of them on the command line:
```sh
make run \
GUI_ENV_FILE=/path/to/gui.env \
API_ENV_FILE=/path/to/api.env \
API_DESI_DATA=/path/to/desi_data.json
```
The defaults point at `resources/dev/gui.env.example` (renderer's `API_URL`),
`resources/dev/api.env.example` (API `API_BIND_ADDR`), and
`resources/dev/desi_subset.json` (a small JSON catalog subset served by the
API's `/api/v1/catalogs` and `/api/v1/objects` endpoints).
`make run` and `make run-web` start the API in the background and then wait for
its `/health` endpoint before launching the GUI (`make api-wait`), so the
renderer never races the API on startup. Override the bind address and worst
case wait with `API_BIND_ADDR` (default `127.0.0.1:8080`) and
`API_WAIT_TIMEOUT` (default 60s). The earlier you click **Refresh** on the GUI's
launch screen, the more likely you are to catch the API mid-boot; a slow API can
also be re-polled by pressing **Refresh** again.
Project-specific targets live in their own `Makefile` and are reached with
`make -C <dir> <target>`:
+2 -10
View File
@@ -99,7 +99,6 @@ version = "0.1.0"
dependencies = [
"anyhow",
"axum",
"dotenvy",
"serde",
"serde_json",
"tokio",
@@ -109,12 +108,6 @@ dependencies = [
"tracing-subscriber",
]
[[package]]
name = "dotenvy"
version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "errno"
version = "0.3.14"
@@ -565,15 +558,14 @@ dependencies = [
[[package]]
name = "tower-http"
version = "0.7.1"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"bitflags",
"bytes",
"http",
"http-body",
"percent-encoding",
"pin-project-lite",
"tower-layer",
"tower-service",
+4 -5
View File
@@ -5,18 +5,17 @@ edition = "2021"
description = "Backend API for DESI Explorer — serves DESI survey catalog data"
[dependencies]
anyhow = "1"
axum = "0.8"
dotenvy = "0.15"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] }
tower-http = { version = "0.7", features = ["cors", "trace"] }
serde = { version = "1", features = ["derive"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tower-http = { version = "0.6", features = ["cors", "trace"] }
anyhow = "1"
[dev-dependencies]
tower = { version = "0.5", features = ["util"] }
serde_json = "1"
[profile.release]
lto = true
-14
View File
@@ -4,20 +4,6 @@
# `api-*` convenience targets).
CARGO ?= cargo
ROOT := ..
# Normalize API_ENV_FILE / API_DESI_DATA (given relative to the repo root) to
# absolute paths so the API process can open them regardless of its working
# directory. "override" is required because they are usually passed as
# command-line/env vars, which would otherwise override any assignment here.
define normalize_path
ifdef $1
ifneq ($(abspath $($1)),$($1))
override $1 := $(abspath $(ROOT)/$($1))
endif
endif
endef
$(foreach v,API_ENV_FILE API_DESI_DATA,$(eval $(call normalize_path,$v)))
.PHONY: help setup run build test check fmt clean
+1 -14
View File
@@ -1,10 +1,5 @@
use std::path::PathBuf;
pub struct Config {
pub bind_addr: String,
/// Path to a DESI data file (JSON) to serve; `None` falls back to the
/// built-in placeholder catalogs.
pub desi_data: Option<PathBuf>,
}
impl Config {
@@ -12,14 +7,6 @@ impl Config {
let bind_addr =
std::env::var("API_BIND_ADDR").unwrap_or_else(|_| "0.0.0.0:8080".to_string());
let desi_data = std::env::var("API_DESI_DATA")
.ok()
.filter(|s| !s.is_empty())
.map(PathBuf::from);
Ok(Self {
bind_addr,
desi_data,
})
Ok(Self { bind_addr })
}
}
-1
View File
@@ -1,4 +1,3 @@
pub mod config;
pub mod models;
pub mod routes;
pub mod store;
+2 -40
View File
@@ -1,7 +1,4 @@
use std::path::Path;
use std::sync::Arc;
use desi_explorer_api::{config, routes, store};
use desi_explorer_api::{config, routes};
use tracing_subscriber::EnvFilter;
@@ -14,22 +11,8 @@ async fn main() -> anyhow::Result<()> {
)
.init();
load_env_file()?;
let config = config::Config::from_env()?;
let catalog_store = match &config.desi_data {
Some(path) => {
tracing::info!(path = %path.display(), "loading DESI data");
store::CatalogStore::load(Path::new(path))?
}
None => {
tracing::warn!("API_DESI_DATA not set, serving placeholder catalogs");
store::CatalogStore::placeholder()
}
};
let app = routes::app_with_state(Arc::new(catalog_store));
let app = routes::app();
let listener = tokio::net::TcpListener::bind(&config.bind_addr).await?;
tracing::info!("DESI Explorer API listening on {}", config.bind_addr);
@@ -41,27 +24,6 @@ async fn main() -> anyhow::Result<()> {
Ok(())
}
/// Loads the env file named by `API_ENV_FILE` (if set) into the process
/// environment. Existing env vars are not overridden, so values passed
/// directly on the command line or by the Makefile take precedence.
fn load_env_file() -> anyhow::Result<()> {
let path = std::env::var("API_ENV_FILE").unwrap_or_default();
if path.is_empty() {
return Ok(());
}
match dotenvy::from_path(&path) {
Ok(_) => tracing::info!(%path, "loaded env file"),
Err(err) => {
return Err(anyhow::anyhow!(
"failed to load API_ENV_FILE {path:?}: {err}"
))
}
}
Ok(())
}
async fn shutdown_signal() {
let _ = tokio::signal::ctrl_c().await;
tracing::info!("shutting down");
+4 -4
View File
@@ -1,17 +1,17 @@
use serde::{Deserialize, Serialize};
use serde::Serialize;
/// Catalog metadata for a DESI data release/survey.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Clone, Serialize)]
pub struct Catalog {
pub name: String,
pub release: String,
pub description: String,
pub description: &'static str,
pub object_count: Option<u64>,
}
/// A single catalog object (galaxy / quasar / star) with its survey
/// coordinates. `ra` and `dec` are in degrees; `redshift` is dimensionless.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Serialize)]
pub struct CatalogObject {
pub id: String,
pub catalog: String,
+30 -24
View File
@@ -1,16 +1,34 @@
use std::sync::Arc;
use axum::{
extract::{Query, State},
extract::Query,
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::Deserialize;
use std::sync::LazyLock;
use crate::models::{Catalog, CatalogObject};
use crate::store::CatalogStore;
pub async fn list_catalogs(State(state): State<Arc<CatalogStore>>) -> Json<Vec<Catalog>> {
Json(state.catalogs.clone())
/// Placeholder catalogs until real DESI EDR/DR1 ingestion lands.
static CATALOGS: LazyLock<Vec<Catalog>> = LazyLock::new(|| {
vec![
Catalog {
name: "edr".to_string(),
release: "EDR".to_string(),
description: "DESI Early Data Release",
object_count: None,
},
Catalog {
name: "dr1".to_string(),
release: "DR1".to_string(),
description: "DESI Data Release 1",
object_count: None,
},
]
});
pub async fn list_catalogs() -> Json<Vec<Catalog>> {
Json(CATALOGS.clone())
}
#[derive(Debug, Deserialize)]
@@ -20,29 +38,17 @@ pub struct ObjectQuery {
limit: Option<usize>,
}
pub async fn list_objects(
State(state): State<Arc<CatalogStore>>,
Query(query): Query<ObjectQuery>,
) -> Json<Vec<CatalogObject>> {
/// Placeholder object query. Real implementation will page through the
/// centralized DESI catalog store rather than return an empty result set.
pub async fn list_objects(Query(query): Query<ObjectQuery>) -> Response {
let limit = query.limit.unwrap_or(100).min(10_000);
tracing::debug!(
%limit,
catalog = query.catalog.as_deref().unwrap_or("all"),
objects = state.objects.len(),
"querying catalog objects"
"querying catalog objects (placeholder)"
);
let objects: Vec<CatalogObject> = match &query.catalog {
Some(catalog) => state
.objects
.iter()
.filter(|o| &o.catalog == catalog)
.take(limit)
.cloned()
.collect(),
None => state.objects.iter().take(limit).cloned().collect(),
};
Json(objects)
let objects: Vec<CatalogObject> = Vec::new();
(StatusCode::OK, Json(objects)).into_response()
}
+2 -12
View File
@@ -1,23 +1,13 @@
pub mod catalogs;
pub mod health;
use std::sync::Arc;
use axum::{routing::get, Router};
use crate::store::CatalogStore;
/// Builds the application router with a static placeholder store. Kept
/// separate from `main` so tests can construct it without binding a socket.
/// Builds the application router. Kept separate from `main` so tests can
/// construct it without binding a socket.
pub fn app() -> Router {
app_with_state(Arc::new(CatalogStore::placeholder()))
}
/// Builds the application router serving the given catalog store.
pub fn app_with_state(state: Arc<CatalogStore>) -> Router {
Router::new()
.route("/health", get(health::health))
.route("/api/v1/catalogs", get(catalogs::list_catalogs))
.route("/api/v1/objects", get(catalogs::list_objects))
.with_state(state)
}
-57
View File
@@ -1,57 +0,0 @@
use std::path::Path;
use serde::Deserialize;
use crate::models::{Catalog, CatalogObject};
/// In-memory catalog store, shared (via `Arc`) across routes. Populated either
/// from a DESI data file loaded at startup or from `placeholder`.
#[derive(Debug, Clone, Default)]
pub struct CatalogStore {
pub catalogs: Vec<Catalog>,
pub objects: Vec<CatalogObject>,
}
/// JSON layout of the DESI data file referenced by `API_DESI_DATA`.
#[derive(Debug, Deserialize)]
pub struct DataFile {
pub catalogs: Vec<Catalog>,
#[serde(default)]
pub objects: Vec<CatalogObject>,
}
impl CatalogStore {
/// Static fallback catalogs used when no `API_DESI_DATA` file is given
/// (and by the `routes::app()` test helper).
pub fn placeholder() -> Self {
Self {
catalogs: vec![
Catalog {
name: "edr".to_string(),
release: "EDR".to_string(),
description: "DESI Early Data Release".to_string(),
object_count: None,
},
Catalog {
name: "dr1".to_string(),
release: "DR1".to_string(),
description: "DESI Data Release 1".to_string(),
object_count: None,
},
],
objects: Vec::new(),
}
}
/// Loads catalogs and objects from a JSON data file. Errors on unreadable
/// files or malformed JSON so the caller can fail loudly instead of
/// silently serving empty data.
pub fn load(path: &Path) -> anyhow::Result<Self> {
let text = std::fs::read_to_string(path)?;
let file: DataFile = serde_json::from_str(&text)?;
Ok(Self {
catalogs: file.catalogs,
objects: file.objects,
})
}
}
-93
View File
@@ -1,93 +0,0 @@
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use std::sync::Arc;
use tower::ServiceExt;
use desi_explorer_api::models::CatalogObject;
use desi_explorer_api::routes;
use desi_explorer_api::store::CatalogStore;
fn sample_store() -> CatalogStore {
CatalogStore {
catalogs: Vec::new(),
objects: vec![
CatalogObject {
id: "o1".to_string(),
catalog: "edr".to_string(),
object_type: "GALAXY".to_string(),
ra: 1.5,
dec: 2.5,
redshift: 0.8,
},
CatalogObject {
id: "o2".to_string(),
catalog: "dr1".to_string(),
object_type: "STAR".to_string(),
ra: 3.5,
dec: 4.5,
redshift: 0.0,
},
],
}
}
#[tokio::test]
async fn objects_returns_all_when_no_filter() {
let app = routes::app_with_state(Arc::new(sample_store()));
let response = app
.oneshot(
Request::builder()
.uri("/api/v1/objects")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let objects: Vec<CatalogObject> = serde_json::from_slice(&body).unwrap();
assert_eq!(objects.len(), 2);
}
#[tokio::test]
async fn objects_filters_by_catalog() {
let app = routes::app_with_state(Arc::new(sample_store()));
let response = app
.oneshot(
Request::builder()
.uri("/api/v1/objects?catalog=edr")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let objects: Vec<CatalogObject> = serde_json::from_slice(&body).unwrap();
assert_eq!(objects.len(), 1);
assert_eq!(objects[0].id, "o1");
}
#[tokio::test]
async fn objects_respects_limit() {
let app = routes::app_with_state(Arc::new(sample_store()));
let response = app
.oneshot(
Request::builder()
.uri("/api/v1/objects?limit=1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let objects: Vec<CatalogObject> = serde_json::from_slice(&body).unwrap();
assert_eq!(objects.len(), 1);
}
-54
View File
@@ -1,54 +0,0 @@
use desi_explorer_api::store::CatalogStore;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn load_parses_catalogs_and_objects() {
let json = r#"{
"catalogs": [
{"name":"edr","release":"EDR","description":"test","object_count":2}
],
"objects": [
{"id":"o1","catalog":"edr","object_type":"GALAXY","ra":1.5,"dec":2.5,"redshift":0.8}
]
}"#;
let dir = std::env::temp_dir().join(format!(
"desi_explorer_store_{}_{}",
std::process::id(),
line!()
));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("data.json");
std::fs::write(&path, json).unwrap();
let store = CatalogStore::load(&path).unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(store.catalogs.len(), 1);
assert_eq!(store.catalogs[0].name, "edr");
assert_eq!(store.catalogs[0].object_count, Some(2));
assert_eq!(store.objects.len(), 1);
assert_eq!(store.objects[0].id, "o1");
assert_eq!(store.objects[0].ra, 1.5);
}
#[test]
fn load_rejects_malformed_json() {
let dir = std::env::temp_dir().join(format!(
"desi_explorer_store_{}_{}",
std::process::id(),
line!()
));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("data.json");
std::fs::write(&path, "not json").unwrap();
let result = CatalogStore::load(&path);
let _ = std::fs::remove_dir_all(&dir);
assert!(result.is_err());
}
}
+2 -27
View File
@@ -8,28 +8,13 @@
# output dirs live at the repo root and are referenced through `$(ROOT)`.
ODIN ?= odin
GDB ?= gdb
ROOT := ..
BIN := $(ROOT)/bin
BINARY := $(BIN)/desi_explorer
ODIN_FLAGS := -collection:lib=lib/local
WASM_DEFINE := RAYLIB_WASM_LIB=env.o
# Normalize GUI_ENV_FILE (given relative to the repo root) to an absolute path
# so the Odin process can open it regardless of its working directory.
# "override" is required because GUI_ENV_FILE is usually set on the command
# line (or passed as an env var to this sub-make), which would otherwise
# override any assignment made here.
ifdef GUI_ENV_FILE
ifneq ($(abspath $(GUI_ENV_FILE)),$(GUI_ENV_FILE))
override GUI_ENV_FILE := $(abspath $(ROOT)/$(GUI_ENV_FILE))
endif
endif
# "override" on a command-line variable silently drops it from the recipe
# environment; re-export it so the app can find its env file (run + gdb).
export GUI_ENV_FILE
.PHONY: help setup add-dep run build build-debug gdb build-web test clean fmt
.PHONY: help setup add-dep run build build-debug build-web test clean fmt
help: ## List available targets
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
@@ -56,22 +41,12 @@ build-debug: ## Debug build -> bin/desi_explorer
@mkdir -p lib/local $(BIN)
$(ODIN) build src $(ODIN_FLAGS) -o:none -debug -out:$(BINARY)
gdb: build-debug ## Run the native app under gdb (type 'run', then 'bt' on a crash)
$(GDB) -q --args $(BINARY) $(ARGS)
build-web: ## WebAssembly build -> build/web (needs emscripten)
@scripts/build_web.sh
test: ## Run Odin unit tests
@mkdir -p lib/local
@if ls test/*.odin >/dev/null 2>&1; then \
echo "== gui/test =="; \
$(ODIN) test test $(ODIN_FLAGS); \
fi
@for dir in $$(find lib/local -name '*_test.odin' -exec dirname {} \; | sort -u); do \
echo "== $$dir =="; \
$(ODIN) test "$$dir" $(ODIN_FLAGS); \
done
$(ODIN) test src $(ODIN_FLAGS)
clean: ## Remove build artifacts
rm -rf $(BIN) build
-167
View File
@@ -1,167 +0,0 @@
package dotenv
import "base:runtime"
import "core:os"
import "core:reflect"
import "core:strconv"
import "core:strings"
// parse parses dotenv-format source (KEY=VALUE lines) into a map allocated
// with allocator. Blank lines, lines starting with '#', and lines without a
// '=' are skipped. Keys and values are trimmed; values may be wrapped in
// double quotes. Real process environment variables take precedence over the
// file. The returned map owns its keys/values; release it with destroy.
@(private)
parse :: proc(src: string, allocator := context.allocator) -> map[string]string {
result := make(map[string]string, allocator)
it := src
for line in strings.split_lines_iterator(&it) {
tr := strings.trim_space(line)
if len(tr) == 0 || strings.has_prefix(tr, "#") {
continue
}
eq := strings.index_byte(tr, '=')
if eq < 0 {
continue
}
key := strings.trim_space(tr[:eq])
if key == "" {
continue
}
value := strings.trim_space(tr[eq + 1:])
if len(value) >= 2 && value[0] == '"' && value[len(value) - 1] == '"' {
value = value[1:len(value) - 1]
}
// real process env vars win over the file
if override, found := os.lookup_env(key, allocator); found {
result[strings.clone(key, allocator)] = override
continue
}
// clone so the map outlives the source buffer (e.g. a freed file read)
result[strings.clone(key, allocator)] = strings.clone(value, allocator)
}
return result
}
// parse_file reads a dotenv file from disk and parses it into a map. It
// returns (nil, false) when the file cannot be read (e.g. it does not exist).
parse_file :: proc(filename: string, allocator := context.allocator) -> (map[string]string, bool) {
data, err := os.read_entire_file(filename, allocator)
if err != nil {
return nil, false
}
defer delete(data, allocator)
return parse(string(data), allocator), true
}
// destroy frees the cloned keys/values and the map itself. Use it to release
// a map returned by parse/parse_file (plain delete does not free the strings).
// Any allocator passed to parse/parse_file must be passed here too.
destroy :: proc(env: map[string]string, allocator := context.allocator) {
for key, value in env {
delete(key, allocator)
delete(value, allocator)
}
delete(env)
}
// env_key returns the env key that should bind to a struct field. It prefers
// an explicit `env:"NAME"` tag; when the tag is absent or empty it falls
// back to the field's name. Matching against the parsed map is
// case-insensitive, so API_URL maps onto api_url (or an `env:"API_URL"` tag).
@(private)
env_key_for_field :: proc(field: reflect.Struct_Field) -> string {
if tag_key, ok := reflect.struct_tag_lookup(field.tag, "env"); ok && tag_key != "" {
return tag_key
}
return field.name
}
// decode populates dest's fields from env, matching each field by name
// (or by an `env:"NAME"` struct tag). Values are converted to the field's
// type: string is cloned as-is into allocator, integers are parsed with
// strconv.parse_int (decimal/hex/negative), booleans with strconv.parse_bool,
// and floats with strconv.parse_f64. Keys missing from env leave the field
// at its zero value. It returns false if a present value cannot be converted
// to the field's type.
decode :: proc(env: map[string]string, dest: ^$T, allocator := context.allocator) -> bool {
ti := reflect.type_info_base(type_info_of(T))
fields, ok := ti.variant.(runtime.Type_Info_Struct)
if !ok {
return false
}
value: string
field_ptr := rawptr(dest)
st: reflect.Struct_Field
for _, i in fields.names[:fields.field_count] {
st = reflect.struct_field_at(T, i)
name := env_key_for_field(st)
value = ""
found := false
for key, v in env {
if key == name || strings.equal_fold(key, name) {
value, found = v, true
break
}
}
if !found {
continue
}
field_ptr = rawptr(uintptr(dest) + fields.offsets[i])
field_ti := reflect.type_info_base(fields.types[i])
#partial switch variant in field_ti.variant {
case runtime.Type_Info_String:
(^string)(field_ptr)^ = strings.clone(value, allocator)
case runtime.Type_Info_Integer:
parsed, err := strconv.parse_int(value)
if !err {
return false
}
switch field_ti.size {
case 1:
(^i8)(field_ptr)^ = cast(i8)parsed
case 2:
(^i16)(field_ptr)^ = cast(i16)parsed
case 4:
(^i32)(field_ptr)^ = cast(i32)parsed
case 8:
(^i64)(field_ptr)^ = cast(i64)parsed
case:
return false
}
case runtime.Type_Info_Boolean:
parsed, err := strconv.parse_bool(value)
if !err {
return false
}
(^bool)(field_ptr)^ = parsed
case runtime.Type_Info_Float:
parsed, err := strconv.parse_f64(value)
if !err {
return false
}
switch field_ti.size {
case 4:
(^f32)(field_ptr)^ = cast(f32)parsed
case 8:
(^f64)(field_ptr)^ = parsed
case:
return false
}
case:
// unsupported field type (slices, pointers, ...) is left untouched
}
}
return true
}
-245
View File
@@ -1,245 +0,0 @@
package dotenv_tests
import "core:os"
import "core:strings"
import "core:testing"
import dotenv "lib:dotenv/src"
Test_Config :: struct {
api_url: string,
debug: bool,
port: int,
ratio: f64,
}
Tagged_Config :: struct {
api_url: string `env:"API_URL"`,
port: int `env:"PORT"`,
debug: bool `env:"DEBUG"`,
}
Tagged_Empty :: struct {
api_url: string `env:""`,
}
// load_env writes src to a unique temp file and parses it via parse_file.
// The returned map owns its strings; callers must destroy it.
load_env :: proc(t: ^testing.T, src: string) -> map[string]string {
dir, err := os.make_directory_temp("", "dotenv_test_*", context.allocator)
testing.expect(t, err == nil, "expected temp dir to be created")
defer os.remove_all(dir)
defer delete(dir)
path := strings.concatenate({dir, "/.env"})
defer delete(path)
testing.expect(
t,
os.write_entire_file(path, src) == nil,
"expected file write to succeed",
)
env, ok := dotenv.parse_file(path)
testing.expect(t, ok, "expected parse_file to succeed")
return env
}
@(test)
test_parse_basic :: proc(t: ^testing.T) {
env := load_env(t, "API_URL=http://127.0.0.1:8080\nDEBUG=true\nPORT=8080\n")
defer dotenv.destroy(env)
testing.expect(t, env["API_URL"] == "http://127.0.0.1:8080")
testing.expect(t, env["DEBUG"] == "true")
testing.expect(t, env["PORT"] == "8080")
}
@(test)
test_parse_ignores_comments_and_blank_lines :: proc(t: ^testing.T) {
env := load_env(t, "# leading comment\n\n \nFOO=bar \nBAZ = qux \n")
defer dotenv.destroy(env)
testing.expect(t, env["FOO"] == "bar", "value should be trimmed")
testing.expect(
t,
env["BAZ"] == "qux",
"key and value should be trimmed around '='",
)
testing.expect(
t,
"API_URL" not_in env,
"comment-only lines should not be parsed",
)
}
@(test)
test_parse_quoted_values :: proc(t: ^testing.T) {
env := load_env(t, "GREETING=\"hello world\"\nEMPTY=\"\"\n")
defer dotenv.destroy(env)
testing.expect(
t,
env["GREETING"] == "hello world",
"quoted value with inner space",
)
testing.expect(t, env["EMPTY"] == "", "double-quoted empty value")
}
@(test)
test_parse_skips_lines_without_equals :: proc(t: ^testing.T) {
env := load_env(t, "not-an-assignment\nOK=yep\n")
defer dotenv.destroy(env)
testing.expect(t, env["OK"] == "yep")
testing.expect(
t,
"not-an-assignment" not_in env,
"line without '=' should be skipped",
)
}
@(test)
test_parse_missing_file :: proc(t: ^testing.T) {
env, ok := dotenv.parse_file("/nonexistent/dotenv_test_does_not_exist.env")
testing.expect(t, !ok, "missing file should report failure")
testing.expect(t, env == nil, "missing file should return nil map")
}
@(test)
test_real_env_overrides_file :: proc(t: ^testing.T) {
testing.expect(t, os.set_env("DESI_EXPLORER_TEST_FOO", "from_env") == nil)
defer os.unset_env("DESI_EXPLORER_TEST_FOO")
env := load_env(t, "DESI_EXPLORER_TEST_FOO=from_file\n")
defer dotenv.destroy(env)
testing.expect(
t,
env["DESI_EXPLORER_TEST_FOO"] == "from_env",
"real env var should win over file",
)
}
@(test)
test_decode_maps_fields_case_insensitively :: proc(t: ^testing.T) {
env := load_env(
t,
"API_URL=http://127.0.0.1:8080\nDEBUG=true\nPORT=8080\nRATIO=0.5\n",
)
defer dotenv.destroy(env)
cfg := Test_Config{}
testing.expect(t, dotenv.decode(env, &cfg))
defer delete(cfg.api_url)
testing.expect(
t,
cfg.api_url == "http://127.0.0.1:8080",
"API_URL maps onto api_url",
)
testing.expect(t, cfg.debug == true, "DEBUG=true should decode to true")
testing.expect(t, cfg.port == 8080, "PORT=8080 should decode to int 8080")
testing.expect(t, cfg.ratio == 0.5, "RATIO=0.5 should decode to f64 0.5")
}
@(test)
test_decode_matches_exact_and_lowercase_keys :: proc(t: ^testing.T) {
env := load_env(t, "api_url=http://exact\nPort=9090\n")
defer dotenv.destroy(env)
cfg := Test_Config{}
testing.expect(t, dotenv.decode(env, &cfg))
defer delete(cfg.api_url)
testing.expect(
t,
cfg.api_url == "http://exact",
"exact-case key should match",
)
testing.expect(t, cfg.port == 9090, "mixed-case key should match field")
}
@(test)
test_decode_missing_keys_leave_zero_values :: proc(t: ^testing.T) {
env := load_env(t, "UNRELATED=value\n")
defer dotenv.destroy(env)
cfg := Test_Config{}
testing.expect(t, dotenv.decode(env, &cfg))
testing.expect(t, cfg.api_url == "")
testing.expect(t, !cfg.debug)
testing.expect(t, cfg.port == 0)
testing.expect(t, cfg.ratio == 0)
}
@(test)
test_decode_unparsable_int_fails :: proc(t: ^testing.T) {
env := load_env(t, "PORT=oops\n")
defer dotenv.destroy(env)
cfg := Test_Config{}
testing.expect(
t,
!dotenv.decode(env, &cfg),
"unparsable int should make decode fail",
)
}
@(test)
test_decode_unparsable_bool_fails :: proc(t: ^testing.T) {
env := load_env(t, "DEBUG=maybe\nAPI_URL=http://127.0.0.1:8080\n")
defer dotenv.destroy(env)
cfg := Test_Config{}
testing.expect(
t,
!dotenv.decode(env, &cfg),
"unparsable bool should make decode fail",
)
defer delete(cfg.api_url)
}
@(test)
test_decode_hex_and_negative_ints :: proc(t: ^testing.T) {
env := load_env(t, "PORT=0x1F\n")
defer dotenv.destroy(env)
cfg := Test_Config{}
testing.expect(t, dotenv.decode(env, &cfg))
testing.expect(t, cfg.port == 31, "hex int should decode")
}
@(test)
test_decode_honors_env_tags :: proc(t: ^testing.T) {
env := load_env(t, "API_URL=http://127.0.0.1:8080\nPORT=9090\nDEBUG=true\n")
defer dotenv.destroy(env)
cfg := Tagged_Config{}
testing.expect(t, dotenv.decode(env, &cfg))
defer delete(cfg.api_url)
testing.expect(
t,
cfg.api_url == "http://127.0.0.1:8080",
"env tag should bind API_URL",
)
testing.expect(t, cfg.port == 9090, "env tag should bind PORT")
testing.expect(t, cfg.debug == true, "env tag should bind DEBUG")
}
@(test)
test_decode_empty_env_tag_falls_back_to_field_name :: proc(t: ^testing.T) {
env := load_env(t, "api_url=http://fallback\n")
defer dotenv.destroy(env)
cfg := Tagged_Empty{}
testing.expect(t, dotenv.decode(env, &cfg))
defer delete(cfg.api_url)
testing.expect(
t,
cfg.api_url == "http://fallback",
"empty env tag should use field name",
)
}
-311
View File
@@ -1,311 +0,0 @@
package http
import "core:fmt"
import "core:net"
import "core:strconv"
import "core:strings"
import "core:time"
DEFAULT_HTTP_PORT :: 80
MAX_REDIRECTS :: 5
HTTP_TIMEOUT :: 5 * time.Second
Response :: struct {
status: int,
location: string,
body: string,
content_length: int,
chunked: bool,
}
Error :: struct {
code: int,
message: string,
}
Error_Code :: enum int {
Invalid_Url,
Dial_Failed,
Send_Failed,
Invalid_Response,
Invalid_Redirect,
Invalid_Chunked,
Too_Many_Redirects,
}
error_make :: proc(code: Error_Code) -> ^Error {
err := new(Error)
err.code = int(code)
switch code {
case .Invalid_Url:
err.message = "invalid url"
case .Dial_Failed:
err.message = "could not connect to host"
case .Send_Failed:
err.message = "could not send request"
case .Invalid_Response:
err.message = "could not parse response"
case .Invalid_Redirect:
err.message = "invalid redirect"
case .Invalid_Chunked:
err.message = "invalid chunked response"
case .Too_Many_Redirects:
err.message = "too many redirects"
}
return err
}
http_get :: proc(url: string) -> (resp: Response, err: ^Error) {
current := url
owns := false
for _ in 0 ..= MAX_REDIRECTS {
next_url, next_resp, request_err := perform_request(current)
if owns do delete(current)
if request_err != nil {
return {}, request_err
}
if next_url != "" {
current = next_url
owns = true
continue
}
return next_resp, nil
}
if owns do delete(current)
return {}, error_make(.Too_Many_Redirects)
}
perform_request :: proc(url: string) -> (next_url: string, resp: Response, err: ^Error) {
host, port, path, parse_ok := parse_http_url(url)
if !parse_ok {
return "", {}, error_make(.Invalid_Url)
}
conn, dial_err := net.dial_tcp_from_hostname_with_port_override(host, port)
if dial_err != nil {
return "", {}, error_make(.Dial_Failed)
}
defer net.close(conn)
net.set_option(conn, .Receive_Timeout, HTTP_TIMEOUT)
net.set_option(conn, .Send_Timeout, HTTP_TIMEOUT)
host_buf: [256]byte
host_head := host
if port != DEFAULT_HTTP_PORT {
host_head = fmt.bprintf(host_buf[:], "%s:%d", host, port)
}
request_buf: [1024]byte
request := fmt.bprintf(
request_buf[:],
"GET %s HTTP/1.1\r\nHost: %s\r\nConnection: close\r\nUser-Agent: desi-explorer-http\r\nAccept: */*\r\n\r\n",
path,
host_head,
)
if _, send_err := net.send_tcp(conn, transmute([]u8)request); send_err != nil {
return "", {}, error_make(.Send_Failed)
}
buf: [dynamic]u8
defer delete(buf)
scratch: [4096]byte
for {
n, recv_err := net.recv_tcp(conn, scratch[:])
if n > 0 do append(&buf, ..scratch[:n])
if recv_err != nil || n == 0 do break
}
parsed, response_ok := parse_response(buf[:])
if !response_ok {
return "", {}, error_make(.Invalid_Response)
}
resp = parsed
if resp.status >= 300 && resp.status < 400 && resp.location != "" {
resolved := resolve_redirect(url, resp.location)
if resolved == "" {
return "", {}, error_make(.Invalid_Redirect)
}
resp.body = ""
return resolved, resp, nil
}
if resp.chunked {
decoded, decode_ok := decode_chunked(resp.body)
if !decode_ok {
return "", {}, error_make(.Invalid_Chunked)
}
resp.body = decoded
return "", resp, nil
}
if resp.content_length >= 0 && resp.content_length < len(resp.body) {
resp.body = resp.body[:resp.content_length]
}
resp.body = strings.clone(resp.body)
return "", resp, nil
}
parse_response :: proc(raw: []byte) -> (resp: Response, ok: bool) {
resp.content_length = -1
raw_str := string(raw)
header_end := strings.index(raw_str, "\r\n\r\n")
if header_end < 0 {
return resp, false
}
lines := strings.split(raw_str[:header_end], "\r\n")
defer delete(lines)
if len(lines) == 0 {
return resp, false
}
resp.status = parse_status_code(lines[0])
if resp.status == 0 {
return resp, false
}
for _, i in lines {
if i == 0 do continue
colon := strings.index_byte(lines[i], ':')
if colon < 0 do continue
key := strings.trim_space(lines[i][:colon])
value := strings.trim_space(lines[i][colon + 1:])
switch {
case fold_eq(key, "content-length"):
if n, number_ok := strconv.parse_int(value); number_ok {
resp.content_length = n
}
case fold_eq(key, "transfer-encoding"):
if strings.contains(value, "chunked") {
resp.chunked = true
}
case fold_eq(key, "location"):
resp.location = value
}
}
body_start := header_end + 4
if body_start <= len(raw_str) {
resp.body = raw_str[body_start:]
}
return resp, true
}
parse_status_code :: proc(status_line: string) -> int {
parts := strings.fields(status_line)
defer delete(parts)
if len(parts) < 2 {
return 0
}
code, ok := strconv.parse_int(parts[1])
return code if ok else 0
}
parse_http_url :: proc(url: string) -> (host: string, port: int, path: string, ok: bool) {
if strings.has_prefix(url, "https://") || !strings.has_prefix(url, "http://") {
return "", 0, "", false
}
rest := url[len("http://"):]
host_and_port := rest
if slash := strings.index_byte(rest, '/'); slash >= 0 {
host_and_port = rest[:slash]
path = rest[slash:]
} else {
path = "/"
}
host = host_and_port
port = DEFAULT_HTTP_PORT
if colon := strings.last_index_byte(host_and_port, ':'); colon >= 0 {
parsed, p_ok := strconv.parse_int(host_and_port[colon + 1:])
if !p_ok {
return "", 0, "", false
}
host = host_and_port[:colon]
port = parsed
}
if host == "" {
return "", 0, "", false
}
return host, port, path, true
}
resolve_redirect :: proc(base_url, location: string) -> string {
if strings.has_prefix(location, "http://") || strings.has_prefix(location, "https://") {
return strings.clone(location)
}
if !strings.has_prefix(location, "/") {
return ""
}
rest := base_url[len("http://"):]
if slash := strings.index_byte(rest, '/'); slash >= 0 {
rest = rest[:slash]
}
return strings.concatenate([]string{"http://", rest, location})
}
decode_chunked :: proc(data: string) -> (body: string, ok: bool) {
decoded: [dynamic]u8
defer delete(decoded)
at := 0
for at < len(data) {
nl := strings.index_byte(data[at:], '\n')
if nl < 0 do return "", false
size_line := strings.trim_space(data[at:at + nl])
at += nl + 1
if semi := strings.index_byte(size_line, ';'); semi >= 0 {
size_line = size_line[:semi]
}
size, size_ok := strconv.parse_int(size_line, 16)
if !size_ok || size < 0 {
return "", false
}
if size == 0 {
at += 2
break
}
if at + size > len(data) {
return "", false
}
append(&decoded, ..transmute([]u8)data[at:at + size])
at += size + 2
}
if len(decoded) == 0 {
return "", true
}
return strings.clone(string(decoded[:])), true
}
fold_eq :: proc(a, b: string) -> bool {
if len(a) != len(b) {
return false
}
for i in 0 ..< len(a) {
ca := a[i]
cb := b[i]
if ca >= 'A' && ca <= 'Z' do ca += 'a' - 'A'
if cb >= 'A' && cb <= 'Z' do cb += 'a' - 'A'
if ca != cb do return false
}
return true
}
-76
View File
@@ -1,76 +0,0 @@
package http_test
import "core:fmt"
import "core:log"
import "core:net"
import "core:testing"
import "core:thread"
import http "../src"
SERVER_BODY :: "hello world"
SERVER_RESPONSE := "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n" +
"Content-Length: 11\r\n" +
"Connection: close\r\n\r\n" +
SERVER_BODY
Serve_Args :: struct {
listener: net.TCP_Socket,
}
serve_proc :: proc(data: rawptr) {
args := (^Serve_Args)(data)
conn, _, err := net.accept_tcp(args.listener)
if err != nil {
log.errorf("test server: accept failed: %v", err)
return
}
defer net.close(conn)
request: [4096]byte
if _, err := net.recv_tcp(conn, request[:]); err != nil {
log.errorf("test server: recv failed: %v", err)
return
}
if _, err := net.send_tcp(conn, transmute([]u8)SERVER_RESPONSE); err != nil {
log.errorf("test server: send failed: %v", err)
return
}
}
@(test)
test_http_get_request :: proc(t: ^testing.T) {
listener, err := net.listen_tcp({net.IP4_Loopback, 0})
if err != nil {
log.errorf("could not start test server: %v", err)
testing.fail(t)
return
}
defer net.close(listener)
bound, berr := net.bound_endpoint(listener)
if berr != nil {
log.errorf("could not read test server port: %v", berr)
testing.fail(t)
return
}
args: Serve_Args = {listener = listener}
server_thread := thread.create_and_start_with_data(&args, serve_proc)
defer thread.destroy(server_thread)
url_buf: [64]byte
url := fmt.bprintf(url_buf[:], "http://127.0.0.1:%d/", bound.port)
log.infof("requesting %s", url)
resp, req_err := http.http_get(url)
if !testing.expect(t, req_err == nil, "http_get returned an error") {
return
}
defer delete(resp.body)
testing.expectf(t, resp.status == 200, "expected status 200, got %d", resp.status)
testing.expectf(t, resp.body == SERVER_BODY, "expected %q, got %q", SERVER_BODY, resp.body)
}
-44
View File
@@ -1,44 +0,0 @@
package main
import "core:log"
import "core:os"
import dotenv "lib:dotenv/src"
Config :: struct {
api_url: string `env:"API_URL"`,
}
get_config :: proc(env_file: ^string = nil) -> (^Config, ^Error) {
path := ".env"
if env_file != nil && env_file^ != "" {
path = env_file^
} else if from_env, ok := os.lookup_env("GUI_ENV_FILE", context.temp_allocator); ok {
path = from_env
}
log.debugf("getting config from file: %s", path)
env, _ := dotenv.parse_file(path, context.temp_allocator)
defer dotenv.destroy(env, context.temp_allocator)
c := new(Config)
if env == nil {
c.api_url = os.get_env("API_URL", context.temp_allocator)
} else if !dotenv.decode(env, c) {
return nil, new_clone(Error{.Config, "failed to decode .env into Config"})
}
if err := validate_config(c); err != nil {
return nil, err
}
return c, nil
}
validate_config :: proc(c: ^Config) -> (err: ^Error) {
if c.api_url == "" {
err = new_clone(Error{.Config, "'API_URL' is required"})
}
return err
}
+6 -186
View File
@@ -1,11 +1,5 @@
package main
import "core:encoding/json"
import "core:fmt"
import "core:log"
import "core:strings"
import http "lib:http/src"
Catalog :: struct {
name: string,
release: string,
@@ -27,195 +21,21 @@ APIError :: struct {
message: string,
}
endpoint :: proc(base_url, path: string) -> string {
return fmt.tprintf("%s/%s", strings.trim_right(base_url, "/"), strings.trim_left(path, "/"))
get_catalogs :: proc(url: string) -> ([dynamic]Catalog, ^APIError) {
return nil, nil
}
api_error :: proc(code: int, message: string) -> ^APIError {
err := new(APIError)
err.code = code
err.message = message
return err
}
destroy_api_error :: proc(err: ^APIError) {
delete(err.message)
free(err)
}
destroy_catalogs :: proc(catalogs: [dynamic]Catalog) {
for &c in catalogs {
delete(c.name)
delete(c.release)
if c.description != nil {
delete(c.description^)
free(c.description)
}
if c.object_count != nil {
free(c.object_count)
}
}
delete(catalogs)
}
destroy_catalog_objects :: proc(objects: [dynamic]CatalogObject) {
for &o in objects {
delete(o.id)
delete(o.catalog)
delete(o.object_type)
}
delete(objects)
}
request_json_array :: proc(
url: string,
) -> (
arr: json.Array,
val: json.Value,
err: ^APIError,
ok: bool,
) {
resp, req_err := http.http_get(url)
if req_err != nil {
defer free(req_err)
return nil, json.Null(nil), api_error(req_err.code, strings.clone(req_err.message)), false
}
defer delete(resp.body)
if resp.status != 200 {
return nil, json.Null(nil), api_error(resp.status, strings.clone(strings.trim_space(resp.body))), false
}
log.debugf("got %s => %s", url, resp.body)
v, perr := json.parse_string(resp.body)
if perr != .None {
return nil, json.Null(nil), api_error(int(perr), "invalid JSON in API response"), false
}
a, is_arr := v.(json.Array)
if !is_arr {
json.destroy_value(v)
return nil, json.Null(nil), api_error(0, "API response was not a JSON array"), false
}
return a, v, nil, true
}
get_catalogs :: proc(base_url: string) -> ([dynamic]Catalog, ^APIError) {
url := endpoint(base_url, "/api/v1/catalogs")
defer delete(url)
arr, val, err, ok := request_json_array(url)
if !ok {
return nil, err
}
defer json.destroy_value(val)
catalogs := make([dynamic]Catalog, 0, len(arr))
for item in arr {
if c, cat_ok := catalog_from_json(item); cat_ok {
append(&catalogs, c)
}
}
return catalogs, nil
get_catalog :: proc(url: string, name: string) -> (^Catalog, ^APIError) {
return nil, nil
}
get_catalog_objects :: proc(
base_url: string,
url: string,
catalog_name: string,
region: string = "",
) -> (
[dynamic]CatalogObject,
^APIError,
) {
sb := strings.builder_make()
defer strings.builder_destroy(&sb)
fmt.sbprintf(&sb, "%s/api/v1/objects?catalog=%s", endpoint(base_url, ""), catalog_name)
if region != "" {
fmt.sbprintf(&sb, "&region=%s", region)
}
fmt.sbprintf(&sb, "&limit=%d", 10_000)
url := strings.to_string(sb)
arr, val, err, ok := request_json_array(url)
if !ok {
return nil, err
}
defer json.destroy_value(val)
objects := make([dynamic]CatalogObject, 0, len(arr))
for item in arr {
if o, obj_ok := catalog_object_from_json(item); obj_ok {
append(&objects, o)
}
}
return objects, nil
return nil, nil
}
catalog_from_json :: proc(v: json.Value) -> (c: Catalog, ok: bool) {
obj, is_obj := v.(json.Object)
if !is_obj {
return {}, false
}
c = Catalog{
name = json_string(obj, "name"),
release = json_string(obj, "release"),
}
if d, found := obj["description"]; found {
if s, s_ok := d.(json.String); s_ok {
c.description = new_clone(strings.clone(s))
}
}
if n, n_ok := json_u64(obj, "object_count"); n_ok {
c.object_count = new_clone(n)
}
return c, true
}
catalog_object_from_json :: proc(v: json.Value) -> (o: CatalogObject, ok: bool) {
obj, is_obj := v.(json.Object)
if !is_obj {
return {}, false
}
return CatalogObject{
id = json_string(obj, "id"),
catalog = json_string(obj, "catalog"),
object_type = json_string(obj, "object_type"),
ra = json_f64(obj, "ra"),
dec = json_f64(obj, "dec"),
redshift = json_f64(obj, "redshift"),
}, true
}
json_string :: proc(obj: json.Object, key: string) -> string {
if v, found := obj[key]; found {
if s, ok := v.(json.String); ok {
return strings.clone(s)
}
}
return ""
}
json_f64 :: proc(obj: json.Object, key: string) -> f64 {
if v, found := obj[key]; found {
#partial switch n in v {
case json.Integer:
return f64(n)
case json.Float:
return n
}
}
return 0
}
json_u64 :: proc(obj: json.Object, key: string) -> (u64, bool) {
if v, found := obj[key]; found {
#partial switch n in v {
case json.Integer:
return u64(n), true
case json.Float:
return u64(n), true
}
}
return 0, false
}
-20
View File
@@ -1,20 +0,0 @@
package main
import "core:fmt"
import "core:strings"
ErrorType :: enum {
Config,
API,
}
Error :: struct {
type: ErrorType,
message: string,
}
format_error :: proc(err: ^Error) -> string {
sb := strings.builder_make(context.temp_allocator)
return fmt.sbprintf(&sb, "[%s] => %s", err.type, err.message)
}
+8 -107
View File
@@ -1,18 +1,14 @@
package main
import "base:runtime"
import "core:fmt"
import "core:math"
import "core:math/rand"
import "core:os"
import "core:strings"
import rl "vendor:raylib"
WIDTH :: 1280
HEIGHT :: 720
// Number of procedurally generated points standing in for the DESI catalog.
// This is replaced by real survey data once a catalog is loaded.
// This is replaced by real survey data once ingestion lands.
POINT_COUNT :: 4_000
WORLD_RADIUS :: f32(500.0)
@@ -21,42 +17,13 @@ Galaxy :: struct {
color: rl.Color,
}
Screen :: enum {
Start,
Universe,
}
App :: struct {
screen: Screen,
api_url: strings.Builder,
api_url_focused: bool,
region: strings.Builder,
region_focused: bool,
catalogs: [dynamic]Catalog,
selected_catalog: int,
catalog_scroll: int,
status: strings.Builder,
loaded_catalog: string,
loaded_region: string,
}
camera: rl.Camera3D
app: App
universe: [dynamic]Galaxy
// Deterministic generator so the placeholder sky is stable between runs.
rng: rand.Default_Random_State
main :: proc() {
c: ^Config
err: ^Error
s := os.get_env("GUI_ENV_FILE", context.temp_allocator)
if c, err = get_config(&s); err != nil {
panic(format_error(err))
}
rng = rand.create(0xDE51_0000)
context.random_generator = rand.default_random_generator(&rng)
@@ -66,43 +33,14 @@ main :: proc() {
rl.SetTargetFPS(60)
camera = make_camera()
app = make_app(c)
defer destroy_app(&app)
make_universe(&universe, POINT_COUNT)
defer delete(universe)
for !rl.WindowShouldClose() {
switch app.screen {
case .Start:
start_screen_update()
start_screen_draw()
case .Universe:
update()
draw()
}
}
}
make_app :: proc(c: ^Config) -> App {
a := App{
screen = .Start,
selected_catalog = -1,
}
a.api_url = strings.builder_make()
a.region = strings.builder_make()
a.status = strings.builder_make()
strings.write_string(&a.api_url, c.api_url)
strings.write_string(&a.status, "Set the API URL, press Refresh, pick a catalog, then Explore.")
return a
}
destroy_app :: proc(a: ^App) {
strings.builder_destroy(&a.api_url)
strings.builder_destroy(&a.region)
strings.builder_destroy(&a.status)
destroy_catalogs(a.catalogs)
delete(a.loaded_catalog)
delete(a.loaded_region)
delete(universe)
}
make_camera :: proc() -> rl.Camera3D {
return {
@@ -123,29 +61,6 @@ make_universe :: proc(u: ^[dynamic]Galaxy, count: int) {
}
}
make_universe_from_objects :: proc(u: ^[dynamic]Galaxy, objects: []CatalogObject) {
clear(u)
reserve(u, len(objects))
for obj in objects {
pos := ra_dec_to_pos(f32(obj.ra), f32(obj.dec), f32(obj.redshift))
append(u, Galaxy{position = pos, color = color_for_position(pos)})
}
}
// Maps an equatorial position (ra/dec in degrees) plus redshift to a point in
// the scene: shells telescope outward with redshift.
ra_dec_to_pos :: proc(ra, dec, redshift: f32) -> rl.Vector3 {
theta := math.to_radians(ra)
phi := math.to_radians(dec)
t := math.clamp(redshift * 0.6, 0.05, 1.0)
r := WORLD_RADIUS * t
return {
r * math.cos(phi) * math.cos(theta),
r * math.sin(phi),
r * math.cos(phi) * math.sin(theta),
}
}
// Uniformly distributed random point inside the scene's bounding sphere.
random_sphere_point :: proc(radius: f32) -> rl.Vector3 {
for {
@@ -170,11 +85,6 @@ color_for_position :: proc(p: rl.Vector3) -> rl.Color {
update :: proc() {
// Orbital camera: drag to rotate, scroll to zoom, right-drag / shift to pan.
rl.UpdateCamera(&camera, .ORBITAL)
// Return to the start screen to switch catalog / region without restarting.
if rl.IsKeyPressed(.TAB) {
app.screen = .Start
}
}
draw :: proc() {
@@ -193,20 +103,11 @@ draw :: proc() {
}
rl.DrawFPS(10, 10)
ui_draw_text("DESI Explorer - drag to rotate, scroll to zoom", 10, 34, 18, rl.RAYWHITE)
buf: [128]u8
if app.loaded_catalog != "" {
ui_draw_text(fmt.bprintf(buf[:], "Catalog: %s", app.loaded_catalog), 10, 60, 18, rl.YELLOW)
}
if app.loaded_region != "" {
ui_draw_text(fmt.bprintf(buf[:], "Region: %s", app.loaded_region), 10, 82, 18, rl.YELLOW)
}
ui_draw_text(
"Press TAB to return to the start screen",
rl.DrawText(
"DESI Explorer — drag to rotate, scroll to zoom",
10,
i32(rl.GetScreenHeight()) - 28,
16,
{120, 120, 140, 255},
34,
18,
rl.RAYWHITE,
)
}
-196
View File
@@ -1,196 +0,0 @@
package main
import "core:fmt"
import "core:strings"
import rl "vendor:raylib"
CATALOG_ROW_H :: 30
start_screen_update :: proc() {
api_rect := rl.Rectangle{60, 112, 460, 34}
region_rect := rl.Rectangle{60, 186, 460, 34}
text_input_update(&app.api_url, &app.api_url_focused, api_rect)
text_input_update(&app.region, &app.region_focused, region_rect)
if ui_button_clicked(rl.Rectangle{60, 230, 170, 38}) {
refresh_catalogs()
}
list_rect := rl.Rectangle{60, 300, 520, 230}
if clicked := catalog_list_update(list_rect); clicked >= 0 {
app.selected_catalog = clicked
set_statusf("Selected catalog: %s", app.catalogs[clicked].name)
}
if ui_button_clicked(rl.Rectangle{60, 548, 150, 38}) {
if explore() {
app.screen = .Universe
}
}
}
start_screen_draw :: proc() {
rl.BeginDrawing()
defer rl.EndDrawing()
rl.ClearBackground({8, 10, 20, 255})
ui_draw_text("DESI Explorer", 60, 36, 40, rl.WHITE)
ui_draw_text("API URL", 60, 92, 16, {150, 160, 190, 255})
text_input_draw(&app.api_url, app.api_url_focused, rl.Rectangle{60, 112, 460, 34})
ui_draw_text("Region (optional)", 60, 166, 16, {150, 160, 190, 255})
text_input_draw(&app.region, app.region_focused, rl.Rectangle{60, 186, 460, 34})
ui_button(rl.Rectangle{60, 230, 170, 38}, "Refresh catalogs")
ui_draw_text("Catalogs", 60, 280, 16, {150, 160, 190, 255})
catalog_list_draw(rl.Rectangle{60, 300, 520, 230})
ui_button(rl.Rectangle{60, 548, 150, 38}, "Explore")
ui_draw_text(strings.to_string(app.status), 60, i32(rl.GetScreenHeight()) - 36, 16, {200, 200, 220, 255})
}
set_status :: proc(msg: string) {
strings.builder_reset(&app.status)
strings.write_string(&app.status, msg)
}
set_statusf :: proc(format: string, args: ..any) {
strings.builder_reset(&app.status)
fmt.sbprintf(&app.status, format, ..args)
}
refresh_catalogs :: proc() {
destroy_catalogs(app.catalogs)
app.catalogs = nil
app.selected_catalog = -1
base := strings.trim_space(strings.to_string(app.api_url))
if base == "" {
set_status("Enter an API URL first.")
return
}
catalogs, err := get_catalogs(base)
if err != nil {
set_statusf("Failed to load catalogs: %s", err.message)
destroy_api_error(err)
return
}
app.catalogs = catalogs
set_statusf("Loaded %d catalog(s). Select one and press Explore.", len(catalogs))
}
explore :: proc() -> bool {
if app.selected_catalog < 0 || app.selected_catalog >= len(app.catalogs) {
set_status("Select a catalog first.")
return false
}
base := strings.trim_space(strings.to_string(app.api_url))
region := strings.trim_space(strings.to_string(app.region))
if base == "" {
set_status("Enter an API URL first.")
return false
}
c := app.catalogs[app.selected_catalog]
objects, err := get_catalog_objects(base, c.name, region)
if err != nil {
set_statusf("Failed to load objects: %s", err.message)
destroy_api_error(err)
return false
}
delete(app.loaded_catalog)
delete(app.loaded_region)
app.loaded_catalog = strings.clone(c.name)
app.loaded_region = strings.clone(region)
if len(objects) == 0 {
make_universe(&universe, POINT_COUNT)
set_status("No objects returned - showing placeholder sky.")
} else {
make_universe_from_objects(&universe, objects[:])
set_statusf("Loaded %d object(s).", len(objects))
}
destroy_catalog_objects(objects)
return true
}
visible_row_count :: proc(rec: rl.Rectangle) -> int {
return max(1, int(rec.height) / CATALOG_ROW_H)
}
row_rect :: proc(rec: rl.Rectangle, index, scroll_offset: int) -> rl.Rectangle {
y := rec.y + f32((index - scroll_offset) * CATALOG_ROW_H)
return rl.Rectangle{rec.x, y, rec.width, CATALOG_ROW_H}
}
catalog_list_update :: proc(rec: rl.Rectangle) -> int {
mouse := rl.GetMousePosition()
if rl.CheckCollisionPointRec(mouse, rec) {
wheel := rl.GetMouseWheelMove()
max_scroll := max(0, len(app.catalogs) - visible_row_count(rec))
app.catalog_scroll = max(0, min(app.catalog_scroll - int(wheel), max_scroll))
}
clicked := -1
if len(app.catalogs) == 0 {
return clicked
}
visible := visible_row_count(rec)
for i in app.catalog_scroll ..< min(len(app.catalogs), app.catalog_scroll + visible) {
row := row_rect(rec, i, app.catalog_scroll)
if rl.CheckCollisionPointRec(mouse, row) && rl.IsMouseButtonPressed(.LEFT) {
clicked = i
}
}
return clicked
}
catalog_list_draw :: proc(rec: rl.Rectangle) {
rl.DrawRectangleRec(rec, {12, 14, 22, 255})
rl.DrawRectangleLinesEx(rec, 1, {56, 60, 82, 255})
if len(app.catalogs) == 0 {
ui_draw_text("No catalogs loaded - press Refresh", i32(rec.x) + 12, i32(rec.y) + 12, 16, {120, 120, 140, 255})
return
}
mouse := rl.GetMousePosition()
visible := visible_row_count(rec)
for i in app.catalog_scroll ..< min(len(app.catalogs), app.catalog_scroll + visible) {
row := row_rect(rec, i, app.catalog_scroll)
if i == app.selected_catalog {
rl.DrawRectangleRec(row, {44, 56, 92, 255})
rl.DrawRectangleLinesEx(row, 1, {120, 160, 235, 255})
} else if rl.CheckCollisionPointRec(mouse, row) {
rl.DrawRectangleRec(row, {26, 34, 56, 255})
}
buf: [256]u8
ui_draw_text(
catalog_display(buf[:], app.catalogs[i]),
i32(rec.x) + 12,
i32(row.y) + 6,
16,
rl.RAYWHITE,
)
}
}
catalog_display :: proc(buf: []u8, c: Catalog) -> string {
if c.description != nil {
return fmt.bprintf(buf, "%s (%s)", c.name, c.description^)
}
if c.release != "" {
return fmt.bprintf(buf, "%s (%s)", c.name, c.release)
}
return fmt.bprintf(buf, "%s", c.name)
}
-90
View File
@@ -1,90 +0,0 @@
package main
import c "core:c"
import "core:strings"
import "core:unicode/utf8"
import rl "vendor:raylib"
MAX_INPUT_LEN :: 256
to_cstring_buf :: proc(buf: []u8, s: string) -> cstring {
n := min(len(s), len(buf) - 1)
copy(buf[:n], s[:n])
buf[n] = 0
return cstring(&buf[0])
}
ui_draw_text :: proc(text: string, x, y, size: c.int, color: rl.Color) {
buf: [1024]u8
rl.DrawText(to_cstring_buf(buf[:], text), x, y, size, color)
}
text_input_update :: proc(b: ^strings.Builder, focused: ^bool, rec: rl.Rectangle) {
mouse := rl.GetMousePosition()
if rl.CheckCollisionPointRec(mouse, rec) && rl.IsMouseButtonPressed(.LEFT) {
focused^ = true
} else if rl.IsMouseButtonPressed(.LEFT) {
focused^ = false
}
if !focused^ {
return
}
for r := rl.GetCharPressed(); r != 0; {
if len(b.buf) < MAX_INPUT_LEN {
strings.write_rune(b, r)
}
r = rl.GetCharPressed()
}
if rl.IsKeyPressed(.BACKSPACE) {
s := strings.to_string(b^)
_, rune_len := utf8.decode_last_rune(s)
if rune_len > 0 && len(b.buf) >= rune_len {
resize(&b.buf, len(b.buf) - rune_len)
}
}
}
text_input_draw :: proc(b: ^strings.Builder, focused: bool, rec: rl.Rectangle) {
text := strings.to_string(b^)
if focused {
rl.DrawRectangleRec(rec, {28, 34, 56, 255})
rl.DrawRectangleLinesEx(rec, 2, {90, 160, 240, 255})
} else {
rl.DrawRectangleRec(rec, {16, 18, 30, 255})
rl.DrawRectangleLinesEx(rec, 1, {70, 76, 96, 255})
}
buf: [MAX_INPUT_LEN + 1]u8
cstr := to_cstring_buf(buf[:], text)
rl.DrawText(cstr, i32(rec.x) + 8, i32(rec.y) + 8, 18, rl.RAYWHITE)
if focused {
cw := rl.MeasureText(cstr, 18)
rl.DrawRectangle(i32(rec.x) + 8 + cw + 1, i32(rec.y) + 8, 2, 20, rl.SKYBLUE)
}
}
ui_button_clicked :: proc(rec: rl.Rectangle) -> bool {
mouse := rl.GetMousePosition()
return rl.CheckCollisionPointRec(mouse, rec) && rl.IsMouseButtonPressed(.LEFT)
}
ui_button :: proc(rec: rl.Rectangle, label: string) {
mouse := rl.GetMousePosition()
hovered := rl.CheckCollisionPointRec(mouse, rec)
if hovered {
rl.DrawRectangleRec(rec, {48, 66, 118, 255})
} else {
rl.DrawRectangleRec(rec, {30, 38, 66, 255})
}
rl.DrawRectangleLinesEx(rec, 1, {92, 104, 134, 255})
buf: [128]u8
cstr := to_cstring_buf(buf[:], label)
w := rl.MeasureText(cstr, 18)
rl.DrawText(cstr, i32(rec.x) + (i32(rec.width) - w) / 2, i32(rec.y) + 10, 18, rl.RAYWHITE)
}
@@ -59,13 +59,11 @@ get_catalog_objects :: proc(url: string, catalog_name: string) // nil
## Data flow gap
```mermaid
flowchart LR
A["DESI catalog store"]
B["Rust / axum API<br/><i>serde JSON models</i>"]
C["Odin + raylib GUI<br/><i>hand-mirrored structs<br/>stubs, never used</i>"]
A -. "future ingestion" .-> B
B --x|"nothing today"| C
```
[DESI catalog store] --(future)--> [Rust/axum API] --(nothing today)--> [Odin + raylib GUI]
^ ^
| serde JSON models | hand-mirrored structs
| | (stubs, never used)
```
There is **no live data flow**. The API currently returns JSON placeholders; the
@@ -118,16 +116,17 @@ response protocol with a cheap binary payload would fit this well.
## High-level target architecture
```mermaid
flowchart TB
S["catalog.fbs<br/><i>single source of truth<br/>checked into repo</i>"]
R["flatc --rust"]
C["flatcc --c<br/><i>or hand-rolled Odin reader</i>"]
API["api/ · Rust"]
GUI["gui/ · Odin + raylib"]
S --> R --> API
S --> C --> GUI
API <-->|"HTTP / WebSocket<br/>framed FlatBuffer binary stream"| GUI
```
catalog.fbs (single source of truth, checked into repo)
|
+--------+---------+
| |
flatc --rust flatcc --c (or hand-rolled Odin reader)
| |
api/ (Rust) gui/ (Odin + raylib)
| ^
| HTTP / WebSocket (framed FlatBuffer binary stream)
+------------------+
```
- One schema file. Two generators. Byte-for-byte identical wire format.
@@ -105,28 +105,19 @@ These are the "thou shalt" rules for keeping buffers compatible:
## Reading a buffer (conceptual)
Buffer layout:
```mermaid
flowchart LR
subgraph BUF["bytes: &[u8]"]
O["uoffset<br/>root table offset"]
FI["file_identifier<br/>(optional)"]
D["tables · vtables · data"]
end
O --> FI --> D
```
bytes: &[u8]
Access sequence — each field is a few offset dereferences and a read:
┌─────────────────────────────┐
│ uoffset (root table offset) │
│ file_identifier (optional) │
│ ... tables, vtables, data ...│
└─────────────────────────────┘
```mermaid
flowchart TD
R["root = follow(bytes)<br/>jump to root table via uoffset"]
V["vtable = root root.vtable_off<br/>locate vtable for this table"]
Q{"field slot present?"}
R --> V --> Q
Q -- "no" --> DEF["use schema default"]
Q -- "yes" --> RD["ra = read_f64(bytes, root + slot_ra)"]
root = follow(bytes) // jump to root table via uoffset
vtable = root - root.vtable_off // locate vtable for this table
field_ra = vtable.slot_ra != 0 // present?
if present: ra = read_f64(bytes, root + slot_ra)
```
There is **no parsing loop**. Each accessor is a few offset dereferences and a
@@ -196,18 +196,6 @@ Notes:
## Building buffers efficiently (Rust specifics)
Build order is **back-to-front** (children before parents):
```mermaid
flowchart TB
A["create_string / create_vector<br/>children first"]
B["create nested child tables"]
C["create parent table<br/>ObjectBatch::create(&args)"]
D["builder.finish(root, Some(&#34;DESI&#34;))"]
E["Bytes::copy_from_slice(fbb.finished_data())<br/>→ HTTP / WebSocket response"]
A --> B --> C --> D --> E
```
- `FlatBufferBuilder::with_capacity(n)` pre-allocates; `reset()` reuses the
buffer across messages. In a loop streaming batches, create one builder, reuse
it — avoid repeated reallocation.
@@ -17,21 +17,6 @@ has two C-related access points:
schema plus a small `libflatccrt.a` runtime. Works via the C ABI, so Odin's
`foreign import` can consume it.
Choosing a path:
```mermaid
flowchart TD
NAT{"primary target is native desktop?"}
NAT -- "yes" --> CGO{"want to avoid C in the build?"}
CGO -- "yes" --> PATHA["Path B · pure-Odin reader<br/>hand-rolled, no C dependency"]
CGO -- "no" --> PATHA
CGO -- "prefer proven lib / less maintenance" --> PATHC["Path A · FFI to FlatCC<br/>bind generated C headers"]
PATHC --> REUSE["Path C · OdinArrow reuse<br/>or borrow its decode patterns"]
NAT -- "no · browser/WASM" --> PATHD["Path D · TS/JS interop<br/>official JS lib → typed arrays into WASM"]
```
Index of paths:
| Path | Effort | Zero-copy on reads | Notes |
|---|---|---|---|
| A: FFI to FlatCC (C runtime) | Medium | ✅ | Bind generated C headers to Odin `foreign` |
@@ -190,14 +175,12 @@ typed Odin slices.
The rendering win only materializes if data stays zero-copy **into the frame
loop**:
```mermaid
flowchart LR
A["fetch frame bytes → [dynamic]u8<br/>or a slice pinned for the frame"]
B["verify the buffer once"]
C["ObjectBatch.ra(&buf) → []f64 view"]
D["per object in update()/draw()<br/>ra[i] · dec[i] · z[i] → rl.Vector3DrawPoint3D"]
A --> B --> C --> D
```
1. Fetch frame bytes → owned `[dynamic]u8` (or a slice pinned for the lifetime
of the frame).
2. `verify` the buffer once.
3. Get `ra_slice := ObjectBatch.ra(&buf)``[]f64` view.
4. Per object in `update()`/`draw()`: read `ra[i]`, `dec[i]`, `z[i]` straight
from that slice; build `rl.Vector3`; `DrawPoint3D`.
No per-object allocation. The current `Galaxy { position, color }` dynamic array
in `main.odin` is the data structure you'd replace with *slices into the
@@ -24,17 +24,14 @@ offers two built-in mechanisms plus the community pattern:
### Option 1: Size-prefixed FlatBuffers (built-in)
```mermaid
flowchart LR
A["u32 LE<br/>total buffer len<br/><i>size prefix</i>"]
B["u32 LE<br/>root table offset"]
C["file identifier<br/>(4 bytes)"]
D["tables · vtables · data"]
A --> B --> C --> D
```
```rust
builder.finish_size_prefixed(root, Some("DESI"));
// +---------------------------+
// | u32 LE: total buffer len | <-- size prefix
// | u32 LE: root table offset |
// | file identifier (4 bytes) |
// | ... data ... |
// +---------------------------+
```
Reader side:
@@ -56,12 +53,8 @@ message kinds).
### Option 2: Custom length-prefix framing (like `flatstream`)
```mermaid
flowchart LR
A["u32 LE<br/>message_len"]
B["optional checksum<br/>(u32 crc / xxhash)"]
C["FlatBuffer payload"]
A --> B --> C
```
[ u32 LE: message_len ] [ optional checksum (e.g. u32 crc/xxhash) ] [ flatbuffer payload ]
```
- `flatstream-rs` (see `03-rust-integration.md`) is a reference implementation
@@ -267,16 +260,6 @@ FlatBuffers long-term (mmap-friendly, page-in-what-you-touch).
## Decision summary for this repo
```mermaid
flowchart TD
A["HTTP GET → one FlatBuffer body per batch<br/>validate Rust builder + Odin reader"]
B["WebSocket → one Binary message per batch<br/>interactive path · no custom framing"]
C["Self-identifying messages<br/>file_identifier &#34;DESI&#34;"]
D["size-prefixed / flatstream-style framing<br/>or HTTP-range + mmap for static catalogs"]
A --> B --> C
C -. "later, if needed" .-> D
```
1. Start with **HTTP GET → one FlatBuffer body per batch** to validate the Rust
builder + Odin reader (no protocol work at all).
2. Then add **WebSocket** with one `Binary` message per batch (no custom framing)
@@ -108,15 +108,6 @@ message shape — see `10-performance-benchmarks.md`.)
## Bottom line
```mermaid
flowchart TD
Q1{"zero-copy reads<br/>in the per-frame render loop?"}
Q1 -- "no" --> PB["Protobuf / gRPC<br/>decode once into draw buffers"]
Q1 -- "yes" --> Q2{"truly columnar?<br/>millions of rows"}
Q2 -- "yes" --> ARR["Apache Arrow IPC<br/>via OdinArrow"]
Q2 -- "no · batched vectors" --> FB["FlatBuffers · this proposal"]
```
- **FlatBuffers is the best default** for this project: the zero-copy read model
matches the render loop, the wire format is compact for numeric vectors, schema
evolution fits DESI's release cadence, and the Rust + WASM/JS official story
@@ -139,23 +139,7 @@ side / flatc* — see the "cross-language fixture" section below.
### 4. Cross-language conformance suite (THE key integration test)
This is the test that actually catches incompatibility. Pipeline:
```mermaid
flowchart LR
S["schema/catalog.fbs"]
J["testdata/catalog_sample.json"]
S --> F["flatc --binary"]
J --> F
F --> BIN["committed .bin fixtures<br/>repo-checked-in"]
BIN --> OT["Odin tests<br/>assert identical values"]
BIN --> RT["Rust tests<br/>assert expected values"]
RT -. "deterministic builder" .-> PAR["byte-for-byte parity"]
OT -. "reads it" .-> PAR
PAR -. "catch drift" .-> F
```
Design:
This is the test that actually catches incompatibility. Design:
1. **Static fixtures, committed to the repo** (`testdata/*.bin`):
- Built once by `flatc --binary <schema>.fbs <data>.json` (deterministic,
@@ -70,16 +70,6 @@ directly from the buffer each frame without allocations.
## Immediate Next Steps (when you're ready to implement)
```mermaid
flowchart TD
A["Prototype schema<br/>catalog.fbs: Catalog · CatalogObject · ServerMessage union"]
B["Generate Rust code<br/>flatc --rust → api/build.rs · serve WS via axum"]
C["Prototype the Odin reader<br/>flatcc FFI · pure-Odin · OdinArrow"]
D["Static fixture files<br/>flatc --binary → committed .bin"]
E["Cross-language tests<br/>Rust + Odin read the same fixtures identically"]
A --> B --> C --> D --> E
```
1. **Prototype schema first.** Write `catalog.fbs` covering `Catalog`, `CatalogObject`,
and a `ServerMessage` union (handshake / catalog list / chunk of objects / end).
2. **Generate Rust code** via `flatc --rust` in an `api/build.rs` (see
-8
View File
@@ -1,8 +0,0 @@
# Example API environment — passed via API_ENV_FILE (see root Makefile).
#
# The API also reads API_DESI_DATA from the environment (set by the root
# Makefile to resources/dev/desi_subset.json by default), so it is not
# repeated here. Values here win unless the same key is already set in the
# real process environment.
API_BIND_ADDR=127.0.0.1:8080
-58
View File
@@ -1,58 +0,0 @@
{
"catalogs": [
{
"name": "edr",
"release": "EDR",
"description": "DESI Early Data Release (local dev subset)",
"object_count": 3
},
{
"name": "dr1",
"release": "DR1",
"description": "DESI Data Release 1 (local dev subset)",
"object_count": 2
}
],
"objects": [
{
"id": "DESI_EDR_000000001",
"catalog": "edr",
"object_type": "GALAXY",
"ra": 150.123456,
"dec": 2.345678,
"redshift": 0.5521
},
{
"id": "DESI_EDR_000000002",
"catalog": "edr",
"object_type": "GALAXY",
"ra": 254.987654,
"dec": -15.203041,
"redshift": 1.1045
},
{
"id": "DESI_EDR_000000003",
"catalog": "edr",
"object_type": "QSO",
"ra": 75.001234,
"dec": 38.765432,
"redshift": 2.8756
},
{
"id": "DESI_DR1_000000001",
"catalog": "dr1",
"object_type": "STAR",
"ra": 188.556677,
"dec": 47.112233,
"redshift": 0.0001
},
{
"id": "DESI_DR1_000000002",
"catalog": "dr1",
"object_type": "GALAXY",
"ra": 300.445566,
"dec": 12.778899,
"redshift": 0.7742
}
]
}
-7
View File
@@ -1,7 +0,0 @@
# Example GUI environment — passed via GUI_ENV_FILE (see root Makefile).
#
# Point the renderer at the local dev API: `make run` also starts the API
# server, so http://127.0.0.1:8080 is the default. Values here win unless
# the same key is already set in the real process environment.
API_URL=http://127.0.0.1:8080