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/Pulumi.*.yaml.backup
infra/desi-explorer-infra infra/desi-explorer-infra
api/target/ 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). - 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. - 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. - 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 ## 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. - 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 API := api
INFRA := infra 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 \ .PHONY: help setup run run-web build build-debug build-web test clean fmt \
api-wait renovate-validate renovate-validate
help: ## List available targets help: ## List available targets
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ @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) ----------------------------------------------------- ## ---- Renderer (Odin) -----------------------------------------------------
run: ## Run the native app (gui/); waits for the API to be healthy first run: ## Run the native app (gui/)
@API_ENV_FILE="$(API_ENV_FILE)" API_DESI_DATA="$(API_DESI_DATA)" API_BIND_ADDR="$(API_BIND_ADDR)" $(MAKE) -C $(API) run & api_pid=$$!; \ @$(MAKE) -C $(API) run & api_pid=$$!; \
trap 'kill $$api_pid 2>/dev/null' INT TERM EXIT; \ trap 'kill $$api_pid 2>/dev/null' INT TERM EXIT; \
$(MAKE) api-wait || { kill $$api_pid 2>/dev/null; exit 1; }; \ $(MAKE) -C $(GUI) run; \
GUI_ENV_FILE="$(GUI_ENV_FILE)" $(MAKE) -C $(GUI) run; \
kill $$api_pid 2>/dev/null kill $$api_pid 2>/dev/null
build: ## Release build (gui/ + api/) build: ## Release build (gui/ + api/)
@@ -65,28 +48,12 @@ build-debug: ## Debug build (gui/ + api/)
build-web: ## WebAssembly build -> build/web (gui/, needs emscripten) build-web: ## WebAssembly build -> build/web (gui/, needs emscripten)
@$(MAKE) -C $(GUI) build-web @$(MAKE) -C $(GUI) build-web
run-web: ## Start WASM build + API server for web dev (waits for API health) run-web: ## Start WASM build + API server for web dev
@API_ENV_FILE="$(API_ENV_FILE)" API_DESI_DATA="$(API_DESI_DATA)" API_BIND_ADDR="$(API_BIND_ADDR)" $(MAKE) -C $(API) run & api_pid=$$!; \ @$(MAKE) -C $(API) run & api_pid=$$!; \
trap 'kill $$api_pid 2>/dev/null' INT TERM EXIT; \ trap 'kill $$api_pid 2>/dev/null' INT TERM EXIT; \
$(MAKE) api-wait || { kill $$api_pid 2>/dev/null; exit 1; }; \ $(MAKE) -C $(GUI) build-web; \
GUI_ENV_FILE="$(GUI_ENV_FILE)" $(MAKE) -C $(GUI) build-web; \
kill $$api_pid 2>/dev/null 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 ---------------------------------------------------------- ## ---- Aggregates ----------------------------------------------------------
test: ## Test all projects (odin + cargo + go) 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: Early scaffolding. The application currently:
- Opens a resizable 3D raylib window with an orbital camera (zoom + rotate + pan). - 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). - 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. - 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 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 Project-specific targets live in their own `Makefile` and are reached with
`make -C <dir> <target>`: `make -C <dir> <target>`:
+2 -10
View File
@@ -99,7 +99,6 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
"dotenvy",
"serde", "serde",
"serde_json", "serde_json",
"tokio", "tokio",
@@ -109,12 +108,6 @@ dependencies = [
"tracing-subscriber", "tracing-subscriber",
] ]
[[package]]
name = "dotenvy"
version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]] [[package]]
name = "errno" name = "errno"
version = "0.3.14" version = "0.3.14"
@@ -565,15 +558,14 @@ dependencies = [
[[package]] [[package]]
name = "tower-http" name = "tower-http"
version = "0.7.1" version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"bytes", "bytes",
"http", "http",
"http-body", "http-body",
"percent-encoding",
"pin-project-lite", "pin-project-lite",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
+4 -5
View File
@@ -5,18 +5,17 @@ edition = "2021"
description = "Backend API for DESI Explorer — serves DESI survey catalog data" description = "Backend API for DESI Explorer — serves DESI survey catalog data"
[dependencies] [dependencies]
anyhow = "1"
axum = "0.8" axum = "0.8"
dotenvy = "0.15"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] } 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 = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tower-http = { version = "0.6", features = ["cors", "trace"] }
anyhow = "1"
[dev-dependencies] [dev-dependencies]
tower = { version = "0.5", features = ["util"] } tower = { version = "0.5", features = ["util"] }
serde_json = "1"
[profile.release] [profile.release]
lto = true lto = true
-14
View File
@@ -4,20 +4,6 @@
# `api-*` convenience targets). # `api-*` convenience targets).
CARGO ?= cargo 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 .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 struct Config {
pub bind_addr: String, 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 { impl Config {
@@ -12,14 +7,6 @@ impl Config {
let bind_addr = let bind_addr =
std::env::var("API_BIND_ADDR").unwrap_or_else(|_| "0.0.0.0:8080".to_string()); 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(Self { bind_addr })
.ok()
.filter(|s| !s.is_empty())
.map(PathBuf::from);
Ok(Self {
bind_addr,
desi_data,
})
} }
} }
-1
View File
@@ -1,4 +1,3 @@
pub mod config; pub mod config;
pub mod models; pub mod models;
pub mod routes; pub mod routes;
pub mod store;
+2 -40
View File
@@ -1,7 +1,4 @@
use std::path::Path; use desi_explorer_api::{config, routes};
use std::sync::Arc;
use desi_explorer_api::{config, routes, store};
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
@@ -14,22 +11,8 @@ async fn main() -> anyhow::Result<()> {
) )
.init(); .init();
load_env_file()?;
let config = config::Config::from_env()?; let config = config::Config::from_env()?;
let app = routes::app();
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 listener = tokio::net::TcpListener::bind(&config.bind_addr).await?; let listener = tokio::net::TcpListener::bind(&config.bind_addr).await?;
tracing::info!("DESI Explorer API listening on {}", config.bind_addr); tracing::info!("DESI Explorer API listening on {}", config.bind_addr);
@@ -41,27 +24,6 @@ async fn main() -> anyhow::Result<()> {
Ok(()) 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() { async fn shutdown_signal() {
let _ = tokio::signal::ctrl_c().await; let _ = tokio::signal::ctrl_c().await;
tracing::info!("shutting down"); 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. /// Catalog metadata for a DESI data release/survey.
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct Catalog { pub struct Catalog {
pub name: String, pub name: String,
pub release: String, pub release: String,
pub description: String, pub description: &'static str,
pub object_count: Option<u64>, pub object_count: Option<u64>,
} }
/// A single catalog object (galaxy / quasar / star) with its survey /// A single catalog object (galaxy / quasar / star) with its survey
/// coordinates. `ra` and `dec` are in degrees; `redshift` is dimensionless. /// coordinates. `ra` and `dec` are in degrees; `redshift` is dimensionless.
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Serialize)]
pub struct CatalogObject { pub struct CatalogObject {
pub id: String, pub id: String,
pub catalog: String, pub catalog: String,
+30 -24
View File
@@ -1,16 +1,34 @@
use std::sync::Arc;
use axum::{ use axum::{
extract::{Query, State}, extract::Query,
http::StatusCode,
response::{IntoResponse, Response},
Json, Json,
}; };
use serde::Deserialize; use serde::Deserialize;
use std::sync::LazyLock;
use crate::models::{Catalog, CatalogObject}; use crate::models::{Catalog, CatalogObject};
use crate::store::CatalogStore;
pub async fn list_catalogs(State(state): State<Arc<CatalogStore>>) -> Json<Vec<Catalog>> { /// Placeholder catalogs until real DESI EDR/DR1 ingestion lands.
Json(state.catalogs.clone()) 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)] #[derive(Debug, Deserialize)]
@@ -20,29 +38,17 @@ pub struct ObjectQuery {
limit: Option<usize>, limit: Option<usize>,
} }
pub async fn list_objects( /// Placeholder object query. Real implementation will page through the
State(state): State<Arc<CatalogStore>>, /// centralized DESI catalog store rather than return an empty result set.
Query(query): Query<ObjectQuery>, pub async fn list_objects(Query(query): Query<ObjectQuery>) -> Response {
) -> Json<Vec<CatalogObject>> {
let limit = query.limit.unwrap_or(100).min(10_000); let limit = query.limit.unwrap_or(100).min(10_000);
tracing::debug!( tracing::debug!(
%limit, %limit,
catalog = query.catalog.as_deref().unwrap_or("all"), catalog = query.catalog.as_deref().unwrap_or("all"),
objects = state.objects.len(), "querying catalog objects (placeholder)"
"querying catalog objects"
); );
let objects: Vec<CatalogObject> = match &query.catalog { let objects: Vec<CatalogObject> = Vec::new();
Some(catalog) => state (StatusCode::OK, Json(objects)).into_response()
.objects
.iter()
.filter(|o| &o.catalog == catalog)
.take(limit)
.cloned()
.collect(),
None => state.objects.iter().take(limit).cloned().collect(),
};
Json(objects)
} }
+2 -12
View File
@@ -1,23 +1,13 @@
pub mod catalogs; pub mod catalogs;
pub mod health; pub mod health;
use std::sync::Arc;
use axum::{routing::get, Router}; use axum::{routing::get, Router};
use crate::store::CatalogStore; /// Builds the application router. Kept separate from `main` so tests can
/// construct it without binding a socket.
/// Builds the application router with a static placeholder store. Kept
/// separate from `main` so tests can construct it without binding a socket.
pub fn app() -> Router { 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() Router::new()
.route("/health", get(health::health)) .route("/health", get(health::health))
.route("/api/v1/catalogs", get(catalogs::list_catalogs)) .route("/api/v1/catalogs", get(catalogs::list_catalogs))
.route("/api/v1/objects", get(catalogs::list_objects)) .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)`. # output dirs live at the repo root and are referenced through `$(ROOT)`.
ODIN ?= odin ODIN ?= odin
GDB ?= gdb
ROOT := .. ROOT := ..
BIN := $(ROOT)/bin BIN := $(ROOT)/bin
BINARY := $(BIN)/desi_explorer BINARY := $(BIN)/desi_explorer
ODIN_FLAGS := -collection:lib=lib/local ODIN_FLAGS := -collection:lib=lib/local
WASM_DEFINE := RAYLIB_WASM_LIB=env.o WASM_DEFINE := RAYLIB_WASM_LIB=env.o
# Normalize GUI_ENV_FILE (given relative to the repo root) to an absolute path .PHONY: help setup add-dep run build build-debug build-web test clean fmt
# 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
help: ## List available targets help: ## List available targets
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
@@ -56,22 +41,12 @@ build-debug: ## Debug build -> bin/desi_explorer
@mkdir -p lib/local $(BIN) @mkdir -p lib/local $(BIN)
$(ODIN) build src $(ODIN_FLAGS) -o:none -debug -out:$(BINARY) $(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) build-web: ## WebAssembly build -> build/web (needs emscripten)
@scripts/build_web.sh @scripts/build_web.sh
test: ## Run Odin unit tests test: ## Run Odin unit tests
@mkdir -p lib/local @mkdir -p lib/local
@if ls test/*.odin >/dev/null 2>&1; then \ $(ODIN) test src $(ODIN_FLAGS)
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
clean: ## Remove build artifacts clean: ## Remove build artifacts
rm -rf $(BIN) build 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 package main
import "core:encoding/json"
import "core:fmt"
import "core:log"
import "core:strings"
import http "lib:http/src"
Catalog :: struct { Catalog :: struct {
name: string, name: string,
release: string, release: string,
@@ -27,195 +21,21 @@ APIError :: struct {
message: string, message: string,
} }
endpoint :: proc(base_url, path: string) -> string { get_catalogs :: proc(url: string) -> ([dynamic]Catalog, ^APIError) {
return fmt.tprintf("%s/%s", strings.trim_right(base_url, "/"), strings.trim_left(path, "/")) return nil, nil
} }
api_error :: proc(code: int, message: string) -> ^APIError { get_catalog :: proc(url: string, name: string) -> (^Catalog, ^APIError) {
err := new(APIError) return nil, nil
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_objects :: proc( get_catalog_objects :: proc(
base_url: string, url: string,
catalog_name: string, catalog_name: string,
region: string = "",
) -> ( ) -> (
[dynamic]CatalogObject, [dynamic]CatalogObject,
^APIError, ^APIError,
) { ) {
sb := strings.builder_make() return nil, nil
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
} }
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)
}
+14 -113
View File
@@ -1,18 +1,14 @@
package main package main
import "base:runtime"
import "core:fmt"
import "core:math" import "core:math"
import "core:math/rand" import "core:math/rand"
import "core:os"
import "core:strings"
import rl "vendor:raylib" import rl "vendor:raylib"
WIDTH :: 1280 WIDTH :: 1280
HEIGHT :: 720 HEIGHT :: 720
// Number of procedurally generated points standing in for the DESI catalog. // 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 POINT_COUNT :: 4_000
WORLD_RADIUS :: f32(500.0) WORLD_RADIUS :: f32(500.0)
@@ -21,42 +17,13 @@ Galaxy :: struct {
color: rl.Color, 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 camera: rl.Camera3D
app: App
universe: [dynamic]Galaxy universe: [dynamic]Galaxy
// Deterministic generator so the placeholder sky is stable between runs. // Deterministic generator so the placeholder sky is stable between runs.
rng: rand.Default_Random_State rng: rand.Default_Random_State
main :: proc() { 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) rng = rand.create(0xDE51_0000)
context.random_generator = rand.default_random_generator(&rng) context.random_generator = rand.default_random_generator(&rng)
@@ -66,50 +33,21 @@ main :: proc() {
rl.SetTargetFPS(60) rl.SetTargetFPS(60)
camera = make_camera() camera = make_camera()
app = make_app(c) make_universe(&universe, POINT_COUNT)
defer destroy_app(&app) defer delete(universe)
for !rl.WindowShouldClose() { for !rl.WindowShouldClose() {
switch app.screen { update()
case .Start: draw()
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 { make_camera :: proc() -> rl.Camera3D {
return { return {
position = {0, 220, 220}, position = {0, 220, 220},
target = {0, 0, 0}, target = {0, 0, 0},
up = {0, 1, 0}, up = {0, 1, 0},
fovy = 60, fovy = 60,
projection = .PERSPECTIVE, projection = .PERSPECTIVE,
} }
} }
@@ -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. // Uniformly distributed random point inside the scene's bounding sphere.
random_sphere_point :: proc(radius: f32) -> rl.Vector3 { random_sphere_point :: proc(radius: f32) -> rl.Vector3 {
for { for {
@@ -170,11 +85,6 @@ color_for_position :: proc(p: rl.Vector3) -> rl.Color {
update :: proc() { update :: proc() {
// Orbital camera: drag to rotate, scroll to zoom, right-drag / shift to pan. // Orbital camera: drag to rotate, scroll to zoom, right-drag / shift to pan.
rl.UpdateCamera(&camera, .ORBITAL) rl.UpdateCamera(&camera, .ORBITAL)
// Return to the start screen to switch catalog / region without restarting.
if rl.IsKeyPressed(.TAB) {
app.screen = .Start
}
} }
draw :: proc() { draw :: proc() {
@@ -193,20 +103,11 @@ draw :: proc() {
} }
rl.DrawFPS(10, 10) rl.DrawFPS(10, 10)
ui_draw_text("DESI Explorer - drag to rotate, scroll to zoom", 10, 34, 18, rl.RAYWHITE) rl.DrawText(
"DESI Explorer — drag to rotate, scroll to zoom",
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",
10, 10,
i32(rl.GetScreenHeight()) - 28, 34,
16, 18,
{120, 120, 140, 255}, 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 ## Data flow gap
```mermaid ```
flowchart LR [DESI catalog store] --(future)--> [Rust/axum API] --(nothing today)--> [Odin + raylib GUI]
A["DESI catalog store"] ^ ^
B["Rust / axum API<br/><i>serde JSON models</i>"] | serde JSON models | hand-mirrored structs
C["Odin + raylib GUI<br/><i>hand-mirrored structs<br/>stubs, never used</i>"] | | (stubs, never used)
A -. "future ingestion" .-> B
B --x|"nothing today"| C
``` ```
There is **no live data flow**. The API currently returns JSON placeholders; the 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 ## High-level target architecture
```mermaid ```
flowchart TB catalog.fbs (single source of truth, checked into repo)
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"] flatc --rust flatcc --c (or hand-rolled Odin reader)
GUI["gui/ · Odin + raylib"] | |
S --> R --> API api/ (Rust) gui/ (Odin + raylib)
S --> C --> GUI | ^
API <-->|"HTTP / WebSocket<br/>framed FlatBuffer binary stream"| GUI | HTTP / WebSocket (framed FlatBuffer binary stream)
+------------------+
``` ```
- One schema file. Two generators. Byte-for-byte identical wire format. - 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) ## 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 root = follow(bytes) // jump to root table via uoffset
flowchart TD vtable = root - root.vtable_off // locate vtable for this table
R["root = follow(bytes)<br/>jump to root table via uoffset"] field_ra = vtable.slot_ra != 0 // present?
V["vtable = root root.vtable_off<br/>locate vtable for this table"] if present: ra = read_f64(bytes, root + slot_ra)
Q{"field slot present?"}
R --> V --> Q
Q -- "no" --> DEF["use schema default"]
Q -- "yes" --> RD["ra = read_f64(bytes, root + slot_ra)"]
``` ```
There is **no parsing loop**. Each accessor is a few offset dereferences and a There is **no parsing loop**. Each accessor is a few offset dereferences and a
@@ -196,18 +196,6 @@ Notes:
## Building buffers efficiently (Rust specifics) ## 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 - `FlatBufferBuilder::with_capacity(n)` pre-allocates; `reset()` reuses the
buffer across messages. In a loop streaming batches, create one builder, reuse buffer across messages. In a loop streaming batches, create one builder, reuse
it — avoid repeated reallocation. 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 schema plus a small `libflatccrt.a` runtime. Works via the C ABI, so Odin's
`foreign import` can consume it. `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 | | Path | Effort | Zero-copy on reads | Notes |
|---|---|---|---| |---|---|---|---|
| A: FFI to FlatCC (C runtime) | Medium | ✅ | Bind generated C headers to Odin `foreign` | | 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 The rendering win only materializes if data stays zero-copy **into the frame
loop**: loop**:
```mermaid 1. Fetch frame bytes → owned `[dynamic]u8` (or a slice pinned for the lifetime
flowchart LR of the frame).
A["fetch frame bytes → [dynamic]u8<br/>or a slice pinned for the frame"] 2. `verify` the buffer once.
B["verify the buffer once"] 3. Get `ra_slice := ObjectBatch.ra(&buf)``[]f64` view.
C["ObjectBatch.ra(&buf) → []f64 view"] 4. Per object in `update()`/`draw()`: read `ra[i]`, `dec[i]`, `z[i]` straight
D["per object in update()/draw()<br/>ra[i] · dec[i] · z[i] → rl.Vector3DrawPoint3D"] from that slice; build `rl.Vector3`; `DrawPoint3D`.
A --> B --> C --> D
```
No per-object allocation. The current `Galaxy { position, color }` dynamic array 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 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) ### 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 ```rust
builder.finish_size_prefixed(root, Some("DESI")); 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: Reader side:
@@ -56,12 +53,8 @@ message kinds).
### Option 2: Custom length-prefix framing (like `flatstream`) ### Option 2: Custom length-prefix framing (like `flatstream`)
```mermaid ```
flowchart LR [ u32 LE: message_len ] [ optional checksum (e.g. u32 crc/xxhash) ] [ flatbuffer payload ]
A["u32 LE<br/>message_len"]
B["optional checksum<br/>(u32 crc / xxhash)"]
C["FlatBuffer payload"]
A --> B --> C
``` ```
- `flatstream-rs` (see `03-rust-integration.md`) is a reference implementation - `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 ## 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 1. Start with **HTTP GET → one FlatBuffer body per batch** to validate the Rust
builder + Odin reader (no protocol work at all). builder + Odin reader (no protocol work at all).
2. Then add **WebSocket** with one `Binary` message per batch (no custom framing) 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 ## 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 - **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 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 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) ### 4. Cross-language conformance suite (THE key integration test)
This is the test that actually catches incompatibility. Pipeline: This is the test that actually catches incompatibility. Design:
```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:
1. **Static fixtures, committed to the repo** (`testdata/*.bin`): 1. **Static fixtures, committed to the repo** (`testdata/*.bin`):
- Built once by `flatc --binary <schema>.fbs <data>.json` (deterministic, - 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) ## 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`, 1. **Prototype schema first.** Write `catalog.fbs` covering `Catalog`, `CatalogObject`,
and a `ServerMessage` union (handshake / catalog list / chunk of objects / end). and a `ServerMessage` union (handshake / catalog list / chunk of objects / end).
2. **Generate Rust code** via `flatc --rust` in an `api/build.rs` (see 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