CLI, along with several examples, plus model downloading

This commit is contained in:
connor committed 2026-10-14 18:01:49 -04:00
1 parent 4f9e73a418
commit 51ace681a7
22 files changed
+1804 -18

No files matched your search

+10
View File
@@ -15,5 +15,15 @@ path = "src/main.rs"
# The library is also named `resolved`; documenting both collides in target/doc.
doc = false
[features]
default = ["astrokit"]
# Scenes given by `epoch` instead of explicit Sun and Moon positions.
astrokit = ["resolved/astrokit", "dep:hifitime"]
[dependencies]
resolved.workspace = true
clap = { version = "4.6", features = ["derive"] }
serde = { version = "1.0", features = ["derive"] }
toml = "1.1"
image = { version = "0.25", default-features = false, features = ["png"] }
hifitime = { version = "4.3", optional = true }
+222 -3
View File
@@ -1,5 +1,224 @@
//! The `resolved` command. Rendering arrives with the `render` subcommand.
//! The `resolved` command: render a scene file to PNG without writing a host program.
fn main() {
println!("resolved {}", env!("CARGO_PKG_VERSION"));
mod scene_file;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::time::Instant;
use clap::{Parser, Subcommand};
use resolved::gpu::AdapterChoice;
use resolved::{Frame, Renderer, RendererOptions};
use crate::scene_file::SceneFile;
#[derive(Parser)]
#[command(
version,
about = "Radiometric images of spacecraft, Earth, Moon and Sun"
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Render a scene file.
Render {
/// The scene, as TOML (see examples/scenes/).
#[arg(long)]
scene: PathBuf,
/// Raw DN as a 16-bit PNG (RGB for three channels, gray for one), shifted up to
/// fill the 16-bit range: DN = value >> (16 - ADC bits), so 12-bit DN are x 16.
#[arg(long)]
out: Option<PathBuf>,
/// An 8-bit sRGB PNG for viewing: bias subtracted, saturation as white.
#[arg(long)]
preview: Option<PathBuf>,
/// Device: auto, gpu, cpu, or part of an adapter's name.
#[arg(long, default_value = "auto")]
adapter: String,
/// Render this many frames and report setup and per-frame times.
#[arg(long, value_name = "FRAMES")]
timings: Option<usize>,
/// Directory of astrokit's kernels, for scenes given by epoch. Without it:
/// ASTROKIT_KERNELS, then ./data/kernels, then the sibling astrokit checkout.
#[arg(long, value_name = "DIR")]
kernels: Option<PathBuf>,
},
/// List the adapters this machine offers.
Adapters,
}
fn main() -> ExitCode {
let result = match Cli::parse().command {
Command::Adapters => {
for a in resolved::gpu::adapters() {
println!(
"{:<40} {:?}, {:?}, {}",
a.name, a.device_type, a.backend, a.driver
);
}
Ok(())
}
Command::Render {
scene,
out,
preview,
adapter,
timings,
kernels,
} => render(
&scene,
out.as_deref(),
preview.as_deref(),
&adapter,
timings,
kernels.as_deref(),
),
};
match result {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e}");
ExitCode::FAILURE
}
}
}
fn render(
path: &Path,
out: Option<&Path>,
preview: Option<&Path>,
adapter: &str,
timings: Option<usize>,
kernels: Option<&Path>,
) -> Result<(), String> {
if out.is_none() && preview.is_none() && timings.is_none() {
return Err("nothing to do: give --out, --preview or --timings".into());
}
let file = SceneFile::read(path)?;
let camera = file.camera();
let options = RendererOptions {
adapter: match adapter {
"auto" => AdapterChoice::Auto,
"gpu" => AdapterChoice::Gpu,
"cpu" => AdapterChoice::Cpu,
name => AdapterChoice::Named(name.to_owned()),
},
appearance: file.appearance(),
read_radiance: false,
};
let start = Instant::now();
let mut renderer = Renderer::new(camera, options).map_err(|e| e.to_string())?;
let setup = start.elapsed();
let start = Instant::now();
let dir = path.parent().unwrap_or(Path::new("."));
let scene = file.scene(&mut renderer, dir, kernels)?;
let observer = file.observer()?;
let loading = start.elapsed();
let mut frame_times = Vec::new();
let mut frame = None;
for _ in 0..timings.unwrap_or(1).max(1) {
let start = Instant::now();
let rendered = renderer
.submit(&scene, &observer)
.and_then(|p| p.wait())
.map_err(|e| e.to_string())?;
frame_times.push(start.elapsed().as_secs_f64() * 1e3);
frame = Some(rendered);
}
let frame = frame.expect("at least one frame");
if timings.is_some() {
println!("adapter {}", renderer.adapter_info().name);
println!("setup {:.1} ms", setup.as_secs_f64() * 1e3);
println!(
"scene {:.1} ms (models and ephemeris)",
loading.as_secs_f64() * 1e3
);
let first = frame_times[0];
let mut warm = frame_times[1..].to_vec();
warm.sort_by(f64::total_cmp);
println!("frame 1 {first:.2} ms");
if !warm.is_empty() {
println!(
"frames median {:.2} ms, min {:.2}, max {:.2} over {}",
warm[warm.len() / 2],
warm[0],
warm[warm.len() - 1],
warm.len()
);
}
}
let detector = &renderer.camera().detector;
if let Some(out) = out {
write_dn(&frame, out, detector.adc_bits)?;
}
if let Some(preview) = preview {
// Bias reads as black and the saturation level as white.
let black = detector.bias;
let white = detector.dn(detector.full_well) as f64;
write_preview(&frame, preview, black, white)?;
}
Ok(())
}
fn color_type(frame: &Frame, sixteen: bool) -> Result<image::ExtendedColorType, String> {
use image::ExtendedColorType as C;
match (frame.channels, sixteen) {
(1, true) => Ok(C::L16),
(3, true) => Ok(C::Rgb16),
(1, false) => Ok(C::L8),
(3, false) => Ok(C::Rgb8),
(n, _) => Err(format!("PNG output supports 1 or 3 channels, not {n}")),
}
}
/// Raw DN, 16 bits per channel, shifted so the ADC's range fills 16 bits. The shift
/// loses nothing and makes the file display as an image in ordinary viewers. The
/// encoder takes native-endian samples and writes PNG's big-endian itself.
fn write_dn(frame: &Frame, path: &Path, adc_bits: u32) -> Result<(), String> {
let shift = 16 - adc_bits;
let bytes: Vec<u8> = frame
.dn
.iter()
.flat_map(|d| (d << shift).to_ne_bytes())
.collect();
image::save_buffer(
path,
&bytes,
frame.width,
frame.height,
color_type(frame, true)?,
)
.map_err(|e| format!("{}: {e}", path.display()))
}
fn write_preview(frame: &Frame, path: &Path, black: f64, white: f64) -> Result<(), String> {
let srgb = |x: f64| {
let x = x.clamp(0.0, 1.0);
let v = if x <= 0.003_130_8 {
12.92 * x
} else {
1.055 * x.powf(1.0 / 2.4) - 0.055
};
(v * 255.0).round() as u8
};
let bytes: Vec<u8> = frame
.dn
.iter()
.map(|&d| srgb((d as f64 - black) / (white - black)))
.collect();
image::save_buffer(
path,
&bytes,
frame.width,
frame.height,
color_type(frame, false)?,
)
.map_err(|e| format!("{}: {e}", path.display()))
}
+348
View File
@@ -0,0 +1,348 @@
//! The scene file: a TOML description of a camera, an observer and what it sees.
//!
//! Positions are inertial (GCRF), Earth-centered, in meters. Quaternions are
//! `[w, x, y, z]` and rotate body-frame vectors into the inertial frame. Model paths
//! are relative to the scene file. See `examples/scenes/` for complete files.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use resolved::bodies::Appearance;
use resolved::nalgebra::{Quaternion, UnitQuaternion, Vector2, Vector3};
use resolved::{
CameraConfig, LoadOptions, Material, Mesh, ModelId, Pose, Renderer, Scene, Spectrum, Target,
};
use serde::Deserialize;
type Result<T> = std::result::Result<T, String>;
/// The whole file.
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SceneFile {
/// Overrides of the example camera's parameters.
#[serde(default)]
pub camera: CameraFile,
/// Where the observer is and where it looks.
pub observer: ObserverFile,
/// The epoch, for Sun, Moon and Earth orientation from astrokit's ephemerides.
pub epoch: Option<String>,
/// Sun position, if not given by `epoch`.
pub sun: Option<[f64; 3]>,
/// Moon position, if not given by `epoch`.
pub moon: Option<[f64; 3]>,
/// Earth-fixed to inertial rotation, if not given by `epoch`; identity by default.
pub earth_attitude: Option<[f64; 4]>,
/// Earth's and the Moon's albedo.
#[serde(default)]
pub appearance: AppearanceFile,
/// Spacecraft in the scene.
#[serde(default, rename = "target")]
pub targets: Vec<TargetFile>,
}
/// Camera parameters; anything left out keeps the example camera's value.
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CameraFile {
pub width: Option<u32>,
pub height: Option<u32>,
pub focal_length: Option<f64>,
pub pixel_pitch: Option<f64>,
pub principal_point: Option<[f64; 2]>,
pub f_number: Option<f64>,
pub exposure: Option<f64>,
pub transmission: Option<f64>,
pub gain: Option<f64>,
pub bias: Option<f64>,
pub full_well: Option<f64>,
pub adc_bits: Option<u32>,
}
/// The observer's pose: `position`, plus either `attitude` or `look_at` (and `up`).
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ObserverFile {
pub position: [f64; 3],
pub attitude: Option<[f64; 4]>,
pub look_at: Option<[f64; 3]>,
pub up: Option<[f64; 3]>,
}
/// Flat albedos for Earth and the Moon.
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AppearanceFile {
pub earth_albedo: Option<f64>,
pub moon_albedo: Option<f64>,
}
/// One spacecraft.
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TargetFile {
/// STL, glTF or GLB file, relative to the scene file.
pub model: PathBuf,
/// Multiplies the model's coordinates; STL from CAD usually needs 0.001.
#[serde(default = "one")]
pub scale: f64,
/// Albedo for an STL, or glTF parts without a material.
#[serde(default = "half")]
pub albedo: f64,
pub position: [f64; 3],
pub attitude: Option<[f64; 4]>,
}
fn one() -> f64 {
1.0
}
fn half() -> f64 {
0.5
}
impl SceneFile {
/// Reads and parses `path`.
pub fn read(path: &Path) -> Result<Self> {
let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
toml::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))
}
/// The example camera with this file's overrides.
pub fn camera(&self) -> CameraConfig {
let c = &self.camera;
let mut camera = CameraConfig::example();
let set = |field: &mut f64, value: Option<f64>| {
if let Some(v) = value {
*field = v;
}
};
if let Some(w) = c.width {
camera.width = w;
}
if let Some(h) = c.height {
camera.height = h;
}
set(&mut camera.focal_length, c.focal_length);
set(&mut camera.pixel_pitch, c.pixel_pitch);
set(&mut camera.f_number, c.f_number);
set(&mut camera.exposure, c.exposure);
set(&mut camera.detector.gain, c.gain);
set(&mut camera.detector.bias, c.bias);
set(&mut camera.detector.full_well, c.full_well);
if let Some(bits) = c.adc_bits {
camera.detector.adc_bits = bits;
}
if let Some([u, v]) = c.principal_point {
camera.principal_point = Some(Vector2::new(u, v));
}
if let Some(t) = c.transmission {
camera.transmission = Spectrum::constant(t);
}
camera
}
/// Earth's and the Moon's appearance.
pub fn appearance(&self) -> Appearance {
let mut a = Appearance::default();
if let Some(e) = self.appearance.earth_albedo {
a.earth_albedo = Spectrum::constant(e);
}
if let Some(m) = self.appearance.moon_albedo {
a.moon_albedo = Spectrum::constant(m);
}
a
}
/// The observer's pose.
pub fn observer(&self) -> Result<Pose> {
let o = &self.observer;
let position = Vector3::from(o.position);
match (o.attitude, o.look_at) {
(Some(q), None) => Ok(Pose::new(position, quaternion(q)?)),
(None, Some(target)) => {
let up = Vector3::from(o.up.unwrap_or([0.0, 0.0, 1.0]));
Pose::looking_at(position, Vector3::from(target), up).ok_or_else(|| {
"observer: look_at is at the position, or straight along up".into()
})
}
_ => Err("observer: give exactly one of attitude or look_at".into()),
}
}
/// Loads each distinct (model, scale, albedo) once into `renderer`, and returns the
/// scene. `dir` is where model paths are relative to; `kernels` is where to find
/// astrokit's kernels if the scene gives an epoch (see [`kernel_directories`]).
pub fn scene(
&self,
renderer: &mut Renderer,
dir: &Path,
kernels: Option<&Path>,
) -> Result<Scene> {
let mut loaded: HashMap<(PathBuf, u64, u64), ModelId> = HashMap::new();
let mut targets = Vec::new();
for t in &self.targets {
let path = dir.join(&t.model);
let key = (path.clone(), t.scale.to_bits(), t.albedo.to_bits());
let model = match loaded.get(&key) {
Some(id) => *id,
None => {
let options = LoadOptions {
scale: t.scale,
default_material: Material::gray("default", t.albedo),
};
let mesh = Mesh::load(&path, &options).map_err(|e| e.to_string())?;
let id = renderer.load_model(&mesh).map_err(|e| e.to_string())?;
loaded.insert(key, id);
id
}
};
let attitude = t
.attitude
.map(quaternion)
.transpose()?
.unwrap_or_else(UnitQuaternion::identity);
targets.push(Target {
model,
pose: Pose::new(Vector3::from(t.position), attitude),
});
}
self.bodies(targets, kernels)
}
#[cfg(feature = "astrokit")]
fn bodies(&self, targets: Vec<Target>, kernels: Option<&Path>) -> Result<Scene> {
if let Some(epoch) = &self.epoch {
if self.sun.is_some() || self.moon.is_some() || self.earth_attitude.is_some() {
return Err("give either epoch or sun, moon and earth_attitude, not both".into());
}
let epoch: hifitime::Epoch =
epoch.parse().map_err(|e| format!("epoch {epoch:?}: {e}"))?;
let ephemeris = load_ephemeris(kernels)?;
return ephemeris.scene(epoch, targets).map_err(|e| e.to_string());
}
self.explicit_bodies(targets)
}
#[cfg(not(feature = "astrokit"))]
fn bodies(&self, targets: Vec<Target>, kernels: Option<&Path>) -> Result<Scene> {
if self.epoch.is_some() {
let _ = kernels;
return Err("epoch needs the astrokit feature; give sun and moon instead".into());
}
self.explicit_bodies(targets)
}
fn explicit_bodies(&self, targets: Vec<Target>) -> Result<Scene> {
let (Some(sun), Some(moon)) = (self.sun, self.moon) else {
return Err("give sun and moon positions, or an epoch".into());
};
Ok(Scene {
earth_attitude: self
.earth_attitude
.map(quaternion)
.transpose()?
.unwrap_or_else(UnitQuaternion::identity),
sun: Vector3::from(sun),
moon: Vector3::from(moon),
targets,
})
}
}
/// Where to look for astrokit's kernels: `explicit` alone if given; otherwise
/// `ASTROKIT_KERNELS`, then `data/kernels` under the working directory, then the
/// sibling astrokit checkout this binary was built against.
#[cfg(feature = "astrokit")]
pub fn kernel_directories(explicit: Option<&Path>) -> Vec<PathBuf> {
if let Some(dir) = explicit {
return vec![dir.to_owned()];
}
let sibling = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../astrokit/data/kernels");
std::env::var_os("ASTROKIT_KERNELS")
.map(PathBuf::from)
.into_iter()
.chain([PathBuf::from("data/kernels"), sibling])
.collect()
}
#[cfg(feature = "astrokit")]
fn load_ephemeris(explicit: Option<&Path>) -> Result<resolved::ephemeris::Ephemeris> {
let mut failures = Vec::new();
for dir in kernel_directories(explicit) {
match resolved::ephemeris::Ephemeris::load(&dir) {
Ok(ephemeris) => return Ok(ephemeris),
Err(e) => failures.push(format!(" {e}")),
}
}
Err(format!(
"astrokit kernels not found. Pass --kernels DIR or set ASTROKIT_KERNELS; astrokit's \
scripts/fetch-kernels.sh downloads them. Tried:\n{}",
failures.join("\n")
))
}
/// A unit quaternion from `[w, x, y, z]`, which must be close to unit length.
fn quaternion([w, x, y, z]: [f64; 4]) -> Result<UnitQuaternion<f64>> {
let q = Quaternion::new(w, x, y, z);
if (q.norm() - 1.0).abs() > 1e-6 {
return Err(format!(
"quaternion {:?} is not unit length ({})",
[w, x, y, z],
q.norm()
));
}
Ok(UnitQuaternion::from_quaternion(q))
}
#[cfg(test)]
mod tests {
use super::*;
const MINIMAL: &str = r#"
sun = [1.496e11, 0, 0]
moon = [0, 3.844e8, 0]
[observer]
position = [7.00003e6, 0, 0]
look_at = [7e6, 0, 0]
"#;
#[test]
fn minimal_file_uses_the_example_camera() {
let file: SceneFile = toml::from_str(MINIMAL).unwrap();
assert_eq!(file.camera(), CameraConfig::example());
assert!(file.targets.is_empty());
let observer = file.observer().unwrap();
// Looking from +X toward the origin.
assert!((observer.attitude * Vector3::z() + Vector3::x()).norm() < 1e-12);
}
#[test]
fn camera_overrides_apply() {
let file: SceneFile = toml::from_str(&format!(
"{MINIMAL}\n[camera]\nwidth = 640\nexposure = 2e-3\nadc_bits = 10\n"
))
.unwrap();
let camera = file.camera();
assert_eq!((camera.width, camera.height), (640, 1080));
assert_eq!(camera.exposure, 2e-3);
assert_eq!(camera.detector.adc_bits, 10);
}
#[test]
fn mistakes_are_reported() {
// A misspelled field.
assert!(toml::from_str::<SceneFile>(&format!("{MINIMAL}\nsunn = [1, 0, 0]\n")).is_err());
// Both attitude and look_at.
let both = MINIMAL.replace(
"look_at = [7e6, 0, 0]",
"look_at = [7e6, 0, 0]\nattitude = [1, 0, 0, 0]",
);
assert!(toml::from_str::<SceneFile>(&both)
.unwrap()
.observer()
.is_err());
// Not a unit quaternion.
assert!(quaternion([1.0, 1.0, 0.0, 0.0]).is_err());
}
}
+130
View File
@@ -0,0 +1,130 @@
//! Runs the `resolved` binary on the example scenes.
use std::path::{Path, PathBuf};
use std::process::Command;
fn repo() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")
}
fn out_dir() -> PathBuf {
let dir = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("cli");
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// Runs the binary from the repository root with `ASTROKIT_KERNELS` cleared, so the
/// kernel search falls back the way it does for someone who has not set it.
fn resolved(args: &[&str]) -> std::process::Output {
Command::new(env!("CARGO_BIN_EXE_resolved"))
.args(args)
.current_dir(repo())
.env_remove("ASTROKIT_KERNELS")
.output()
.unwrap()
}
/// Renders `scene` on `adapter` to a 16-bit PNG and returns its pixels as DN (the
/// file holds 12-bit DN shifted up by 4).
fn render(scene: &str, adapter: &str) -> image::ImageBuffer<image::Rgb<u16>, Vec<u16>> {
let out = out_dir().join(format!(
"{}-{adapter}.png",
Path::new(scene).file_stem().unwrap().to_str().unwrap()
));
let preview = out.with_extension("preview.png");
let result = resolved(&[
"render",
"--scene",
scene,
"--adapter",
adapter,
"--out",
out.to_str().unwrap(),
"--preview",
preview.to_str().unwrap(),
]);
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
assert!(preview.is_file());
let mut pixels = image::open(&out).unwrap().into_rgb16();
for p in pixels.pixels_mut() {
assert!(p.0.iter().all(|v| v & 0xf == 0), "low 4 bits are padding");
p.0 = p.0.map(|v| v >> 4);
}
pixels
}
#[test]
fn example_scene_on_gpu_and_cpu() {
let gpu = render("examples/scenes/boxwing.toml", "gpu");
let cpu = render("examples/scenes/boxwing.toml", "cpu");
assert_eq!(gpu.dimensions(), (1920, 1080));
// The image center is on the sunlit gold bus: brighter in red than in blue.
let [r, _, b] = gpu.get_pixel(960, 540).0;
assert!(r > 500 && r > 2 * b, "{r} {b}");
// Above the limb is empty space, which reads the 64 DN bias; below it, Earth's
// dayside.
assert_eq!(gpu.get_pixel(0, 0).0, [64; 3]);
assert!(gpu.get_pixel(0, 1079).0.iter().all(|&d| d > 300));
let differing = gpu
.pixels()
.zip(cpu.pixels())
.filter(|(a, b)| a.0.iter().zip(b.0).any(|(x, y)| x.abs_diff(y) > 1))
.count();
// Only pixels on silhouette edges may land differently.
assert!(
differing < 500,
"{differing} pixels differ between GPU and CPU"
);
}
#[cfg(feature = "astrokit")]
#[test]
fn epoch_scene_renders() {
let frame = render("examples/scenes/boxwing-epoch.toml", "gpu");
// The same view as boxwing.toml, rotated to the real Sun: the sunlit bus at the
// center, space above the limb.
assert!(frame.get_pixel(960, 540).0[0] > 500);
assert_eq!(frame.get_pixel(0, 0).0, [64; 3]);
}
#[test]
fn errors_are_reported() {
let missing = resolved(&["render", "--scene", "no/such.toml", "--preview", "x.png"]);
assert!(!missing.status.success());
assert!(String::from_utf8_lossy(&missing.stderr).contains("no/such.toml"));
let idle = resolved(&["render", "--scene", "examples/scenes/boxwing.toml"]);
assert!(!idle.status.success());
assert!(String::from_utf8_lossy(&idle.stderr).contains("nothing to do"));
}
#[cfg(feature = "astrokit")]
#[test]
fn missing_kernels_say_where_they_looked() {
let result = resolved(&[
"render",
"--scene",
"examples/scenes/boxwing-epoch.toml",
"--kernels",
"no/such/dir",
"--preview",
"x.png",
]);
assert!(!result.status.success());
let stderr = String::from_utf8_lossy(&result.stderr);
assert!(
stderr.contains("--kernels") && stderr.contains("no/such/dir"),
"{stderr}"
);
}
#[test]
fn adapters_lists_something() {
let result = resolved(&["adapters"]);
assert!(result.status.success());
assert!(!result.stdout.is_empty());
}