From 51ace681a799083b57023eb9e4d2cd9e917a58bc Mon Sep 17 00:00:00 2001 From: Connor Johnstone Date: Wed, 14 Oct 2026 18:01:49 -0400 Subject: [PATCH] CLI, along with several examples, plus model downloading --- .gitignore | 1 + Cargo.lock | 179 +++++++++++++ Cargo.toml | 5 + crates/resolved-cli/Cargo.toml | 10 + crates/resolved-cli/src/main.rs | 225 ++++++++++++++++- crates/resolved-cli/src/scene_file.rs | 348 ++++++++++++++++++++++++++ crates/resolved-cli/tests/cli.rs | 130 ++++++++++ crates/resolved/src/ephemeris.rs | 17 +- crates/resolved/src/model.rs | 86 ++++++- docs/optics.md | 37 ++- examples/models/boxwing.gltf | 317 +++++++++++++++++++++++ examples/scenes/ace.toml | 20 ++ examples/scenes/boxwing-epoch.toml | 18 ++ examples/scenes/boxwing.toml | 29 +++ examples/scenes/icesat2.toml | 19 ++ examples/scenes/landsat8.toml | 19 ++ examples/scenes/ostm.toml | 19 ++ examples/scenes/ssl1300.toml | 19 ++ readme.md | 36 ++- scripts/fetch-models.sh | 66 +++++ scripts/make-example-model.py | 67 +++++ scripts/make-example-scenes.py | 155 ++++++++++++ 22 files changed, 1804 insertions(+), 18 deletions(-) create mode 100644 crates/resolved-cli/src/scene_file.rs create mode 100644 crates/resolved-cli/tests/cli.rs create mode 100644 examples/models/boxwing.gltf create mode 100644 examples/scenes/ace.toml create mode 100644 examples/scenes/boxwing-epoch.toml create mode 100644 examples/scenes/boxwing.toml create mode 100644 examples/scenes/icesat2.toml create mode 100644 examples/scenes/landsat8.toml create mode 100644 examples/scenes/ostm.toml create mode 100644 examples/scenes/ssl1300.toml create mode 100755 scripts/fetch-models.sh create mode 100755 scripts/make-example-model.py create mode 100755 scripts/make-example-scenes.py diff --git a/.gitignore b/.gitignore index ea8c4bf..dd4ff23 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +/examples/models/nasa/ diff --git a/Cargo.lock b/Cargo.lock index 399bff2..4458790 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -46,6 +46,56 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + [[package]] name = "approx" version = "0.5.1" @@ -188,6 +238,46 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + [[package]] name = "codespan-reporting" version = "0.13.1" @@ -199,6 +289,12 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "const_format" version = "0.2.36" @@ -541,6 +637,12 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a257582fdcde896fd96463bf2d40eefea0580021c0712a0e2b028b60b47a837a" +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" @@ -991,6 +1093,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "ordered-float" version = "5.4.0" @@ -1219,7 +1327,12 @@ dependencies = [ name = "resolved-cli" version = "0.1.0" dependencies = [ + "clap", + "hifitime", + "image", "resolved", + "serde", + "toml", ] [[package]] @@ -1298,6 +1411,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "simba" version = "0.10.2" @@ -1385,6 +1507,12 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "567641995c51a3b8befddb13e1826187bcf7eb7ca8d13746bfd1cc5a22e89fa8" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "syn" version = "2.0.119" @@ -1478,6 +1606,45 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "toml" +version = "1.1.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "typenum" version = "1.20.1" @@ -1508,6 +1675,12 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "version_check" version = "0.9.5" @@ -1913,6 +2086,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + [[package]] name = "xml-rs" version = "0.8.29" diff --git a/Cargo.toml b/Cargo.toml index d8040f2..9de802e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,3 +14,8 @@ repository = "https://git.rcjohnstone.com/connor/resolved" [workspace.dependencies] resolved = { path = "crates/resolved" } + +# Dependencies optimized in debug builds: wgpu's validation layers and PNG coding are +# otherwise slow enough to dominate the test suite. Our own crates stay unoptimized. +[profile.dev.package."*"] +opt-level = 2 diff --git a/crates/resolved-cli/Cargo.toml b/crates/resolved-cli/Cargo.toml index de3a46a..fe30369 100644 --- a/crates/resolved-cli/Cargo.toml +++ b/crates/resolved-cli/Cargo.toml @@ -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 } diff --git a/crates/resolved-cli/src/main.rs b/crates/resolved-cli/src/main.rs index 69ed1ec..f7d3525 100644 --- a/crates/resolved-cli/src/main.rs +++ b/crates/resolved-cli/src/main.rs @@ -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, + /// An 8-bit sRGB PNG for viewing: bias subtracted, saturation as white. + #[arg(long)] + preview: Option, + /// 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, + /// 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, + }, + /// 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, + 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 { + 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 = 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 = 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())) } diff --git a/crates/resolved-cli/src/scene_file.rs b/crates/resolved-cli/src/scene_file.rs new file mode 100644 index 0000000..a9c6679 --- /dev/null +++ b/crates/resolved-cli/src/scene_file.rs @@ -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 = std::result::Result; + +/// 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, + /// 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, +} + +/// 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, + pub height: Option, + pub focal_length: Option, + pub pixel_pitch: Option, + pub principal_point: Option<[f64; 2]>, + pub f_number: Option, + pub exposure: Option, + pub transmission: Option, + pub gain: Option, + pub bias: Option, + pub full_well: Option, + pub adc_bits: Option, +} + +/// 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, + pub moon_albedo: Option, +} + +/// 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 { + 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| { + 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 { + 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 { + 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, kernels: Option<&Path>) -> Result { + 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, kernels: Option<&Path>) -> Result { + 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) -> Result { + 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 { + 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 { + 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> { + 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::(&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::(&both) + .unwrap() + .observer() + .is_err()); + // Not a unit quaternion. + assert!(quaternion([1.0, 1.0, 0.0, 0.0]).is_err()); + } +} diff --git a/crates/resolved-cli/tests/cli.rs b/crates/resolved-cli/tests/cli.rs new file mode 100644 index 0000000..b28aec4 --- /dev/null +++ b/crates/resolved-cli/tests/cli.rs @@ -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, Vec> { + 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()); +} diff --git a/crates/resolved/src/ephemeris.rs b/crates/resolved/src/ephemeris.rs index 0b3d489..f394b64 100644 --- a/crates/resolved/src/ephemeris.rs +++ b/crates/resolved/src/ephemeris.rs @@ -38,6 +38,13 @@ impl Ephemeris { Self { almanac } } + /// Loads astrokit's kernels from `directory`. + pub fn load(directory: &std::path::Path) -> Result { + let almanac = ::astrokit::kernels::load(directory) + .map_err(|e| Error::Ephemeris(format!("{}: {e}", directory.display())))?; + Ok(Self::new(Arc::new(almanac))) + } + /// Loads astrokit's kernels from `ASTROKIT_KERNELS`, or from `data/kernels` under /// the working directory. astrokit's `scripts/fetch-kernels.sh` downloads them. pub fn load_default() -> Result { @@ -120,13 +127,9 @@ mod tests { let dir = ::astrokit::kernels::directory().unwrap_or_else(|| { Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../astrokit/data/kernels") }); - let almanac = ::astrokit::kernels::load(&dir).unwrap_or_else(|e| { - panic!( - "astrokit kernels not found in {}: {e}. Run astrokit's scripts/fetch-kernels.sh or set ASTROKIT_KERNELS.", - dir.display() - ) - }); - Ephemeris::new(Arc::new(almanac)) + Ephemeris::load(&dir).unwrap_or_else(|e| { + panic!("{e}. Run astrokit's scripts/fetch-kernels.sh or set ASTROKIT_KERNELS.") + }) } fn utc(y: i32, mo: u8, d: u8, h: u8, mi: u8, s: u8) -> Epoch { diff --git a/crates/resolved/src/model.rs b/crates/resolved/src/model.rs index 21373be..e4e106b 100644 --- a/crates/resolved/src/model.rs +++ b/crates/resolved/src/model.rs @@ -114,8 +114,9 @@ impl Mesh { Self::from_stl(&mut file, name, options) } Some("gltf" | "glb") => { - let (document, buffers, _) = - gltf::import(path).map_err(|e| model_error(path, e))?; + let bytes = std::fs::read(path).map_err(|e| model_error(path, e))?; + let (document, buffers) = + read_gltf(&bytes, path.parent()).map_err(|e| model_error(path, e))?; Self::from_gltf(&document, &buffers, options) } _ => Err(Error::Model(format!( @@ -151,6 +152,14 @@ impl Mesh { mesh.validated() } + /// Reads a glTF (JSON with embedded data) or GLB file from memory. Textures are + /// ignored, as in [`Mesh::load`]. + pub fn from_gltf_slice(bytes: &[u8], options: &LoadOptions) -> Result { + let (document, buffers) = + read_gltf(bytes, None).map_err(|e| Error::Model(e.to_string()))?; + Self::from_gltf(&document, &buffers, options) + } + /// Builds a mesh from an imported glTF document: every triangle primitive in the /// default scene (or the first), with node transforms applied. pub fn from_gltf( @@ -192,9 +201,6 @@ impl Mesh { let name = m .name() .map_or_else(|| format!("material {i}"), str::to_owned); - if m.pbr_metallic_roughness().base_color_texture().is_some() { - log::warn!("{name}: base color texture ignored; using its factor"); - } mesh.materials .push(Material::from_rgb(&name, [r, g, b].map(f64::from))); mesh.materials.len() - 1 @@ -309,6 +315,53 @@ impl Mesh { } } +/// Extensions that only describe textures, dropped along with them. +const TEXTURE_EXTENSIONS: &[&str] = &[ + "EXT_texture_webp", + "KHR_texture_basisu", + "KHR_texture_transform", + "MSFT_texture_dds", +]; + +/// Parses glTF or GLB bytes and loads their geometry buffers, with every texture, +/// image and sampler removed first. +/// +/// Textures are not used yet (materials take their base color factor), and removing +/// them lets a model load whose textures are in formats the `gltf` crate rejects +/// (WebP, Basis) or in image files that were not shipped with it. Everything else is +/// validated as usual. `base` is where external buffer files are relative to. +fn read_gltf( + bytes: &[u8], + base: Option<&Path>, +) -> Result<(gltf::Document, Vec), gltf::Error> { + let (json, blob) = if bytes.starts_with(b"glTF") { + let glb = gltf::Glb::from_slice(bytes)?; + (glb.json.into_owned(), glb.bin.map(|b| b.into_owned())) + } else { + (bytes.to_vec(), None) + }; + let mut root = gltf::json::Root::from_slice(&json)?; + root.textures.clear(); + root.images.clear(); + root.samplers.clear(); + for m in &mut root.materials { + let pbr = &mut m.pbr_metallic_roughness; + pbr.base_color_texture = None; + pbr.metallic_roughness_texture = None; + pbr.extensions = None; + m.normal_texture = None; + m.occlusion_texture = None; + m.emissive_texture = None; + m.extensions = None; + } + for list in [&mut root.extensions_required, &mut root.extensions_used] { + list.retain(|e| !TEXTURE_EXTENSIONS.contains(&e.as_str())); + } + let document = gltf::Document::from_json(root)?; + let buffers = gltf::import_buffers(&document, base, blob)?; + Ok((document, buffers)) +} + fn model_error(path: &Path, e: impl std::fmt::Display) -> Error { Error::Model(format!("{}: {e}", path.display())) } @@ -531,6 +584,29 @@ mod tests { std::fs::remove_dir_all(&dir).unwrap(); } + #[test] + fn gltf_textures_are_ignored_even_when_unreadable() { + // The material's texture is WebP-only (EXT_texture_webp, required, no plain + // source) and its image file does not exist. Neither may stop the geometry + // from loading, and the material keeps its base color factor. + let textured = GLTF + .replace( + r#""pbrMetallicRoughness": {"baseColorFactor": [0.8, 0.1, 0.05, 1]}"#, + r#""pbrMetallicRoughness": {"baseColorFactor": [0.8, 0.1, 0.05, 1], "baseColorTexture": {"index": 0}}"#, + ) + .replace( + r#""asset": {"version": "2.0"},"#, + r#""asset": {"version": "2.0"}, + "extensionsUsed": ["EXT_texture_webp"], + "extensionsRequired": ["EXT_texture_webp"], + "textures": [{"extensions": {"EXT_texture_webp": {"source": 0}}}], + "images": [{"uri": "missing.webp"}],"#, + ); + assert_ne!(textured, GLTF); + let mesh = Mesh::from_gltf_slice(textured.as_bytes(), &Default::default()).unwrap(); + assert_eq!(mesh, gltf_mesh(&Default::default())); + } + #[test] fn load_rejects_unknown_extension() { assert!(Mesh::load(Path::new("model.obj"), &Default::default()).is_err()); diff --git a/docs/optics.md b/docs/optics.md index 89a5c6f..691fb0e 100644 --- a/docs/optics.md +++ b/docs/optics.md @@ -371,6 +371,28 @@ its triangles. That is what M5's ray tracer does, because following rays gives exact shadows and light bouncing between surfaces. Rasterization can only approximate those, for example with the shadow maps planned for M2. +### One sample per pixel + +Both techniques decide each pixel's value from one point, the pixel's +center. A real pixel collects light over its whole area, about 5.5 um +square on the detector. Where a pixel straddles an edge, say half on a +sunlit panel and half on dark space, the real pixel reads about half the +panel's value. The renderer reads either the full value or none, depending +on which side the center falls. + +This shows in two ways. Edges are stair-stepped instead of graded. And +anything thinner than a pixel, like the booms of the example satellite seen +from 40 m, breaks into dashes, because only some pixel centers land on it. +For an algorithm that finds edges or measures brightness at sub-pixel +precision, the graded edge is the signal, so this matters for limb +navigation and pose estimation. + +The usual fix is supersampling: shading several points inside each pixel +and averaging. The GPU's multisampling hardware does this cheaply for +rasterized edges. Sampling the bodies' limbs needs it in the ray casting +shader. The lens blur that comes with the point spread function (M3) also +spreads every edge over a few pixels. + ## Earth, Moon and Sun Code: `bodies.rs`, `gpu/bodies.rs`, `shaders/bodies.wgsl`. @@ -453,8 +475,18 @@ or for anything in the near infrared, where the red value simply continues. For absolute radiometry of a real spacecraft, give the important materials measured reflectance spectra (M4 adds those, and BRDFs). +Texture images are not used yet; each material renders with its base color +value alone. The loader removes textures before reading a glTF file, so a +model still loads when its textures are in a format or file the loader cannot +read. A model that relies on textures for its colors (one material with a +painted image) renders in that material's flat base color, often gray. + STL has no units. CAD tools usually export millimeters, so loading a CAD -model usually needs `scale: 0.001`. glTF is always meters. +model usually needs `scale: 0.001`. glTF is meters by its specification, but +models in the wild do not always follow it: of the NASA example models, +Landsat 8 and ICESat-2 are in meters, while the SSL-1300 needs a scale of 0.1 +and ACE about 0.005. Checking a model's size against the real spacecraft's is +worth the minute it takes. ## Drawing the target @@ -535,6 +567,7 @@ velocity as an input, which the scene does not carry yet. | Earthshine on the Moon's night side | M4 | | Shadows the target casts on itself | M2 | | The Sun as a disk that is darker toward its edge (limb darkening) | M2 | +| Pixel-area sampling (anti-aliasing): graded edges, thin parts | M2 | | Lens distortion | M3 | | Blur from diffraction and lens imperfections (point spread function) | M3 | | Noise: photon shot noise, read noise, dark current, pixel-to-pixel variation | M3 | @@ -543,4 +576,4 @@ velocity as an input, which the scene does not carry yet. | Earth and Moon albedo maps | M2 | | Earth's atmosphere: scattering, the bright limb | M4 | | Motion blur, rolling shutter, stray light | M6 | -| Apparent positions: light time and aberration | not yet planned | +| Apparent positions: light time and aberration | M3 | diff --git a/examples/models/boxwing.gltf b/examples/models/boxwing.gltf new file mode 100644 index 0000000..b83f988 --- /dev/null +++ b/examples/models/boxwing.gltf @@ -0,0 +1,317 @@ +{ + "asset": { + "version": "2.0", + "generator": "resolved scripts/make-example-model.py" + }, + "scene": 0, + "scenes": [ + { + "nodes": [ + 0, + 1, + 2, + 3, + 4, + 5 + ] + } + ], + "nodes": [ + { + "name": "bus", + "mesh": 0 + }, + { + "name": "radiator", + "mesh": 1 + }, + { + "name": "boom +y", + "mesh": 2 + }, + { + "name": "boom -y", + "mesh": 3 + }, + { + "name": "array +y", + "mesh": 4 + }, + { + "name": "array -y", + "mesh": 5 + } + ], + "meshes": [ + { + "name": "bus", + "primitives": [ + { + "attributes": { + "POSITION": 0 + }, + "material": 0 + } + ] + }, + { + "name": "radiator", + "primitives": [ + { + "attributes": { + "POSITION": 1 + }, + "material": 1 + } + ] + }, + { + "name": "boom +y", + "primitives": [ + { + "attributes": { + "POSITION": 2 + }, + "material": 2 + } + ] + }, + { + "name": "boom -y", + "primitives": [ + { + "attributes": { + "POSITION": 3 + }, + "material": 3 + } + ] + }, + { + "name": "array +y", + "primitives": [ + { + "attributes": { + "POSITION": 4 + }, + "material": 4 + } + ] + }, + { + "name": "array -y", + "primitives": [ + { + "attributes": { + "POSITION": 5 + }, + "material": 5 + } + ] + } + ], + "materials": [ + { + "name": "bus", + "pbrMetallicRoughness": { + "baseColorFactor": [ + 0.8, + 0.6, + 0.2, + 1.0 + ] + } + }, + { + "name": "radiator", + "pbrMetallicRoughness": { + "baseColorFactor": [ + 0.85, + 0.85, + 0.85, + 1.0 + ] + } + }, + { + "name": "boom +y", + "pbrMetallicRoughness": { + "baseColorFactor": [ + 0.3, + 0.3, + 0.3, + 1.0 + ] + } + }, + { + "name": "boom -y", + "pbrMetallicRoughness": { + "baseColorFactor": [ + 0.3, + 0.3, + 0.3, + 1.0 + ] + } + }, + { + "name": "array +y", + "pbrMetallicRoughness": { + "baseColorFactor": [ + 0.05, + 0.08, + 0.2, + 1.0 + ] + } + }, + { + "name": "array -y", + "pbrMetallicRoughness": { + "baseColorFactor": [ + 0.05, + 0.08, + 0.2, + 1.0 + ] + } + } + ], + "accessors": [ + { + "bufferView": 0, + "componentType": 5126, + "count": 36, + "type": "VEC3", + "min": [ + -0.5, + -0.5, + -0.75 + ], + "max": [ + 0.5, + 0.5, + 0.75 + ] + }, + { + "bufferView": 1, + "componentType": 5126, + "count": 6, + "type": "VEC3", + "min": [ + -0.45, + -0.45, + -0.76 + ], + "max": [ + 0.45, + 0.45, + -0.76 + ] + }, + { + "bufferView": 2, + "componentType": 5126, + "count": 36, + "type": "VEC3", + "min": [ + -0.025, + 0.5, + -0.025 + ], + "max": [ + 0.025, + 1.0, + 0.025 + ] + }, + { + "bufferView": 3, + "componentType": 5126, + "count": 36, + "type": "VEC3", + "min": [ + -0.025, + -1.0, + -0.025 + ], + "max": [ + 0.025, + -0.5, + 0.025 + ] + }, + { + "bufferView": 4, + "componentType": 5126, + "count": 6, + "type": "VEC3", + "min": [ + 0, + 1.0, + -0.5 + ], + "max": [ + 0, + 4.0, + 0.5 + ] + }, + { + "bufferView": 5, + "componentType": 5126, + "count": 6, + "type": "VEC3", + "min": [ + 0, + -4.0, + -0.5 + ], + "max": [ + 0, + -1.0, + 0.5 + ] + } + ], + "bufferViews": [ + { + "buffer": 0, + "byteOffset": 0, + "byteLength": 432 + }, + { + "buffer": 0, + "byteOffset": 432, + "byteLength": 72 + }, + { + "buffer": 0, + "byteOffset": 504, + "byteLength": 432 + }, + { + "buffer": 0, + "byteOffset": 936, + "byteLength": 432 + }, + { + "buffer": 0, + "byteOffset": 1368, + "byteLength": 72 + }, + { + "buffer": 0, + "byteOffset": 1440, + "byteLength": 72 + } + ], + "buffers": [ + { + "byteLength": 1512, + "uri": "data:application/octet-stream;base64,AAAAvwAAAL8AAEC/AAAAvwAAAD8AAEC/AAAAPwAAAD8AAEC/AAAAvwAAAL8AAEC/AAAAPwAAAD8AAEC/AAAAPwAAAL8AAEC/AAAAvwAAAL8AAEA/AAAAPwAAAL8AAEA/AAAAPwAAAD8AAEA/AAAAvwAAAL8AAEA/AAAAPwAAAD8AAEA/AAAAvwAAAD8AAEA/AAAAvwAAAL8AAEC/AAAAPwAAAL8AAEC/AAAAPwAAAL8AAEA/AAAAvwAAAL8AAEC/AAAAPwAAAL8AAEA/AAAAvwAAAL8AAEA/AAAAvwAAAD8AAEC/AAAAvwAAAD8AAEA/AAAAPwAAAD8AAEA/AAAAvwAAAD8AAEC/AAAAPwAAAD8AAEA/AAAAPwAAAD8AAEC/AAAAvwAAAL8AAEC/AAAAvwAAAL8AAEA/AAAAvwAAAD8AAEA/AAAAvwAAAL8AAEC/AAAAvwAAAD8AAEA/AAAAvwAAAD8AAEC/AAAAPwAAAL8AAEC/AAAAPwAAAD8AAEC/AAAAPwAAAD8AAEA/AAAAPwAAAL8AAEC/AAAAPwAAAD8AAEA/AAAAPwAAAL8AAEA/ZmbmvmZm5r5cj0K/ZmbmvmZm5j5cj0K/ZmbmPmZm5j5cj0K/ZmbmvmZm5r5cj0K/ZmbmPmZm5j5cj0K/ZmbmPmZm5r5cj0K/zczMvAAAAD/NzMy8zczMvAAAgD/NzMy8zczMPAAAgD/NzMy8zczMvAAAAD/NzMy8zczMPAAAgD/NzMy8zczMPAAAAD/NzMy8zczMvAAAAD/NzMw8zczMPAAAAD/NzMw8zczMPAAAgD/NzMw8zczMvAAAAD/NzMw8zczMPAAAgD/NzMw8zczMvAAAgD/NzMw8zczMvAAAAD/NzMy8zczMPAAAAD/NzMy8zczMPAAAAD/NzMw8zczMvAAAAD/NzMy8zczMPAAAAD/NzMw8zczMvAAAAD/NzMw8zczMvAAAgD/NzMy8zczMvAAAgD/NzMw8zczMPAAAgD/NzMw8zczMvAAAgD/NzMy8zczMPAAAgD/NzMw8zczMPAAAgD/NzMy8zczMvAAAAD/NzMy8zczMvAAAAD/NzMw8zczMvAAAgD/NzMw8zczMvAAAAD/NzMy8zczMvAAAgD/NzMw8zczMvAAAgD/NzMy8zczMPAAAAD/NzMy8zczMPAAAgD/NzMy8zczMPAAAgD/NzMw8zczMPAAAAD/NzMy8zczMPAAAgD/NzMw8zczMPAAAAD/NzMw8zczMvAAAgL/NzMy8zczMvAAAAL/NzMy8zczMPAAAAL/NzMy8zczMvAAAgL/NzMy8zczMPAAAAL/NzMy8zczMPAAAgL/NzMy8zczMvAAAgL/NzMw8zczMPAAAgL/NzMw8zczMPAAAAL/NzMw8zczMvAAAgL/NzMw8zczMPAAAAL/NzMw8zczMvAAAAL/NzMw8zczMvAAAgL/NzMy8zczMPAAAgL/NzMy8zczMPAAAgL/NzMw8zczMvAAAgL/NzMy8zczMPAAAgL/NzMw8zczMvAAAgL/NzMw8zczMvAAAAL/NzMy8zczMvAAAAL/NzMw8zczMPAAAAL/NzMw8zczMvAAAAL/NzMy8zczMPAAAAL/NzMw8zczMPAAAAL/NzMy8zczMvAAAgL/NzMy8zczMvAAAgL/NzMw8zczMvAAAAL/NzMw8zczMvAAAgL/NzMy8zczMvAAAAL/NzMw8zczMvAAAAL/NzMy8zczMPAAAgL/NzMy8zczMPAAAAL/NzMy8zczMPAAAAL/NzMw8zczMPAAAgL/NzMy8zczMPAAAAL/NzMw8zczMPAAAgL/NzMw8AAAAAAAAgD8AAAC/AAAAAAAAgEAAAAC/AAAAAAAAgEAAAAA/AAAAAAAAgD8AAAC/AAAAAAAAgEAAAAA/AAAAAAAAgD8AAAA/AAAAAAAAgMAAAAC/AAAAAAAAgL8AAAC/AAAAAAAAgL8AAAA/AAAAAAAAgMAAAAC/AAAAAAAAgL8AAAA/AAAAAAAAgMAAAAA/" + } + ] +} diff --git a/examples/scenes/ace.toml b/examples/scenes/ace.toml new file mode 100644 index 0000000..f455d66 --- /dev/null +++ b/examples/scenes/ace.toml @@ -0,0 +1,20 @@ +# The Advanced Composition Explorer (26k triangles). The real ACE sits at the +# Sun-Earth L1 point; here it is put in low orbit, in an isometric view with +# Earth's limb behind it. Generated by scripts/make-example-scenes.py; the model +# comes from scripts/fetch-models.sh. +# +# resolved render --scene examples/scenes/ace.toml --preview ace.png + +sun = [111172883513, -100100513931, 0] +moon = [0, 0, -3.844e8] + +[observer] +position = [7000036.38, -73.51, 0.00] +look_at = [7000000.00, 0.00, 0.00] +up = [1, 0, 0] + +[[target]] +model = "../models/nasa/ace.glb" +scale = 0.00485 +position = [7000000.0000, 0.0000, 0.0000] +attitude = [0.218913627, -0.630230282, 0.320639661, -0.672366584] diff --git a/examples/scenes/boxwing-epoch.toml b/examples/scenes/boxwing-epoch.toml new file mode 100644 index 0000000..3db8968 --- /dev/null +++ b/examples/scenes/boxwing-epoch.toml @@ -0,0 +1,18 @@ +# The same view at a real epoch, with the Sun, Moon and Earth's orientation from +# astrokit's ephemerides (needs the astrokit feature and its kernels). The geometry of +# boxwing.toml is rotated as a whole so its Sun direction matches the real Sun's at +# this epoch: same limb, same isometric view, same lighting. +# +# resolved render --scene examples/scenes/boxwing-epoch.toml --preview epoch.png + +epoch = "2026-10-14T12:00:00 UTC" + +[observer] +position = [-3309620.6, -6112901.3, -824149.4] +look_at = [-3309561.3, -6112904.1, -824140.5] +up = [-0.472794, -0.873272, -0.117734] + +[[target]] +model = "../models/boxwing.gltf" +position = [-3309561.3, -6112904.1, -824140.5] +attitude = [0.411662751, 0.017111321, -0.752389012, 0.51395696] diff --git a/examples/scenes/boxwing.toml b/examples/scenes/boxwing.toml new file mode 100644 index 0000000..4faa290 --- /dev/null +++ b/examples/scenes/boxwing.toml @@ -0,0 +1,29 @@ +# A box-wing satellite 7000 km from Earth's center (about 620 km up), seen from 60 m +# with Earth's limb behind it. +# +# The camera looks 63.7 degrees from straight down. At this altitude the limb is +# 65.7 degrees from straight down, so it crosses the image about 2 degrees (320 px) +# above the center, with Earth below and space above. Image up is the local vertical. +# +# The satellite is turned so the camera sees it along its (+1, +1, +1) body diagonal, +# an isometric view of the +X (array side), +Y and +Z faces, with +Z toward image up. +# The Sun is placed to light those three faces and the Earth near the limb. +# +# resolved render --scene examples/scenes/boxwing.toml --preview boxwing.png + +sun = [111155951345, -100085268138, 2610842842] +moon = [0, 0, -3.844e8] + +# Anything left out keeps the example camera's value. +[camera] +exposure = 1e-4 + +[observer] +position = [7000026.62, -53.77, 0.0] +look_at = [7000000.0, 0.0, 0.0] +up = [1, 0, 0] + +[[target]] +model = "../models/boxwing.gltf" +position = [7000000.0, 0.0, 0.0] +attitude = [0.218913627, -0.630230282, 0.320639661, -0.672366584] diff --git a/examples/scenes/icesat2.toml b/examples/scenes/icesat2.toml new file mode 100644 index 0000000..a087b60 --- /dev/null +++ b/examples/scenes/icesat2.toml @@ -0,0 +1,19 @@ +# ICESat-2 (752k triangles) in its 496 km orbit; the heavy model for benchmarks, +# in an isometric view with Earth's limb behind it. Generated by +# scripts/make-example-scenes.py; the model comes from scripts/fetch-models.sh. +# +# resolved render --scene examples/scenes/icesat2.toml --preview icesat2.png + +sun = [105781668823, -105781668823, 0] +moon = [0, 0, -3.844e8] + +[observer] +position = [6874182.69, -103.11, 0.00] +look_at = [6874137.00, 0.00, 0.00] +up = [1, 0, 0] + +[[target]] +model = "../models/nasa/icesat2.glb" +scale = 1.0 +position = [6874133.2074, 2.1303, -1.3429] +attitude = [0.204578316, -0.623275321, 0.333957893, -0.676866097] diff --git a/examples/scenes/landsat8.toml b/examples/scenes/landsat8.toml new file mode 100644 index 0000000..22fcbd8 --- /dev/null +++ b/examples/scenes/landsat8.toml @@ -0,0 +1,19 @@ +# Landsat 8 (85k triangles) in its 705 km orbit, in an isometric view with +# Earth's limb behind it. Generated by scripts/make-example-scenes.py; the model +# comes from scripts/fetch-models.sh. +# +# resolved render --scene examples/scenes/landsat8.toml --preview landsat8.png + +sun = [111172883513, -100100513931, 0] +moon = [0, 0, -3.844e8] + +[observer] +position = [7083216.12, -150.19, 0.00] +look_at = [7083137.00, 0.00, 0.00] +up = [1, 0, 0] + +[[target]] +model = "../models/nasa/landsat8.glb" +scale = 1.0 +position = [7083133.0811, -2.1094, -2.6020] +attitude = [0.227385402, -0.634228406, 0.312656887, -0.669549012] diff --git a/examples/scenes/ostm.toml b/examples/scenes/ostm.toml new file mode 100644 index 0000000..d4434b6 --- /dev/null +++ b/examples/scenes/ostm.toml @@ -0,0 +1,19 @@ +# OSTM / Jason-2 (65k triangles) in its 1336 km orbit, in an isometric view with +# Earth's limb behind it. Generated by scripts/make-example-scenes.py; the model +# comes from scripts/fetch-models.sh. +# +# resolved render --scene examples/scenes/ostm.toml --preview ostm.png + +sun = [125463331183, -81476839938, 0] +moon = [0, 0, -3.844e8] + +[observer] +position = [7714190.15, -72.55, 0.00] +look_at = [7714137.00, 0.00, 0.00] +up = [1, 0, 0] + +[[target]] +model = "../models/nasa/ostm.glb" +scale = 3.5 +position = [7714137.0021, -0.1237, -0.1237] +attitude = [0.276078628, -0.655532500, 0.265098361, -0.650984325] diff --git a/examples/scenes/ssl1300.toml b/examples/scenes/ssl1300.toml new file mode 100644 index 0000000..f7c22ce --- /dev/null +++ b/examples/scenes/ssl1300.toml @@ -0,0 +1,19 @@ +# An SSL-1300 communications satellite (182k triangles) in geostationary orbit, +# in an isometric view with Earth's limb behind it. Generated by +# scripts/make-example-scenes.py; the model comes from scripts/fetch-models.sh. +# +# resolved render --scene examples/scenes/ssl1300.toml --preview ssl1300.png + +sun = [144500446867, 38718778044, 0] +moon = [0, 0, -3.844e8] + +[observer] +position = [42164291.13, -34.20, 0.00] +look_at = [42164000.00, 0.00, 0.00] +up = [1, 0, 0] + +[[target]] +model = "../models/nasa/ssl1300.glb" +scale = 0.1 +position = [42163999.7300, 0.2428, 0.3631] +attitude = [0.513066177, -0.706858772, -0.018726374, -0.486583084] diff --git a/readme.md b/readme.md index b850a97..f1315e1 100644 --- a/readme.md +++ b/readme.md @@ -61,6 +61,35 @@ assert!(frame.dn_at(960, 540, 1) > 1000); # } ``` +## Command line + +`resolved render` renders a scene described in TOML: camera overrides, the +observer, Sun and Moon positions (or an epoch, with the `astrokit` feature) and +targets with their models. `examples/scenes/` has commented examples: +`boxwing.toml` and `boxwing-epoch.toml` use a small generated model that is +in the repository, and the rest use NASA spacecraft models (Landsat 8, +ICESat-2, OSTM/Jason-2, an SSL-1300 and ACE) that `scripts/fetch-models.sh` +downloads into `examples/models/nasa/`. That script needs Node (for `npx`), +because most NASA models are Draco-compressed and are decoded on the way in. + +```sh +cargo run --release -p resolved-cli -- render \ + --scene examples/scenes/boxwing.toml \ + --out frame.png --preview preview.png --timings 50 +``` + +- `--out` writes raw DN as a 16-bit PNG, shifted up to fill 16 bits as + cameras usually store them: a 12-bit DN is the stored value divided by 16. + It is linear, so it looks dark next to the preview. +- `--preview` writes an 8-bit sRGB image for looking at, with the bias as + black and saturation as white. +- `--timings N` renders N frames and reports setup and per-frame times. +- `--adapter` takes `auto`, `gpu`, `cpu` or part of an adapter's name; + `resolved adapters` lists them. +- `--kernels DIR` says where astrokit's kernels are, for scenes given by + epoch. Without it the CLI tries `ASTROKIT_KERNELS`, then `data/kernels`, + then the sibling `../astrokit` checkout it was built against. + ## Features - `astrokit`: build a `Scene` from an epoch with @@ -73,7 +102,8 @@ assert!(frame.dn_at(960, 540, 1) > 1000); ## Status M1 in progress: the library renders Earth, Moon, Sun and targets to DN -through `Renderer`; the CLI and benchmarks are still to come. The work is +through `Renderer`, and the CLI renders scene files; benchmarks are still to +come. The work is tracked as Gitea milestones, each split into one issue per change: | Milestone | Scope | @@ -96,6 +126,10 @@ tracked as Gitea milestones, each split into one issue per change: - `crates/resolved`: the library. - `crates/resolved-cli`: the `resolved` command, for rendering without writing a host program. +- `examples/`: scene files and the box-wing model + (`scripts/make-example-model.py` regenerates it). The NASA scenes are + generated by `scripts/make-example-scenes.py`. +- `scripts/`: generators for compiled-in data and examples. - `docs/optics.md`: the optics and radiometry the renderer models, explained for readers without an optics background. diff --git a/scripts/fetch-models.sh b/scripts/fetch-models.sh new file mode 100755 index 0000000..ade881e --- /dev/null +++ b/scripts/fetch-models.sh @@ -0,0 +1,66 @@ +#!/bin/sh +# Downloads the NASA spacecraft models the example scenes use into +# examples/models/nasa/ (gitignored). +# +# Source: NASA 3D Resources (https://github.com/nasa/NASA-3D-Resources), pinned +# to one commit and checked by sha256. NASA describes the collection as free and +# without copyright; NASA's logos and insignia have their own restrictions, see +# https://www.nasa.gov/nasa-brand-center/images-and-media/. +# +# Most of the collection is stored with Draco mesh compression, which this +# crate's loader does not read yet, so those models are decoded with +# gltf-transform (Node, via npx). Node is needed only here, not to build or run +# anything. +# +# landsat8 Landsat 8, 85k triangles, meters. +# icesat2 ICESat-2, 752k triangles, meters; the heavy model for benchmarks. +# ssl1300 A Space Systems Loral 1300 communications satellite, 182k +# triangles; its units are 10 cm, so scenes scale it by 0.1. +# ostm OSTM / Jason-2, 65k triangles; its units are about 29 cm, so +# scenes scale it by 3.5 (3.5 x 1.9 m, solar panels 1.4 x 0.9 m). +# ace Advanced Composition Explorer, 26k triangles; its units are about +# 5 mm, so scenes scale it by 0.00485 (a 1.6 m body). + +set -eu + +commit=11ebb4ee043715aefbba6aeec8a61746fad67fa7 +base="https://raw.githubusercontent.com/nasa/NASA-3D-Resources/$commit/3D%20Models" +gltf_transform='@gltf-transform/cli@4.5.1' + +out="$(cd "$(dirname "$0")/.." && pwd)/examples/models/nasa" +mkdir -p "$out" +work=$(mktemp -d) +# npx runs from its own empty directory; downloads are only ever passed as paths. +tools=$(mktemp -d) +trap 'command rm -rf "$work" "$tools"' EXIT + +# name, path under 3D Models/, sha256 of the download, whether it is Draco-compressed +fetch() { + name=$1 path=$2 sum=$3 draco=$4 + if [ -f "$out/$name.glb" ]; then + echo "have $name" + return + fi + echo "fetching $name" + curl -fsSL "$base/$path" -o "$work/$name.glb" + echo "$sum $work/$name.glb" | sha256sum -c --quiet - + if [ "$draco" = yes ]; then + (cd "$tools" && npx --yes "$gltf_transform" copy "$work/$name.glb" "$work/$name.decoded.glb" >/dev/null) + mv "$work/$name.decoded.glb" "$out/$name.glb" + else + mv "$work/$name.glb" "$out/$name.glb" + fi +} + +fetch landsat8 "Landsat%208/Landsat%208.glb" \ + 8b599e867be20589cd35e55ed1d46da104299b430b1827b548a25d3fad0a314d yes +fetch icesat2 "Ice,%20Clouds,%20and%20Land%20Elevation%20Satellite-2%20(ICESat-2)%20(C)/Ice,%20Clouds,%20and%20Land%20Elevation%20Satellite-2%20(ICESat-2)%20(C).glb" \ + a842b4d58c32bb612b258489cf9680d439fdb73186ad793deb8fcdd6aa93a8a9 yes +fetch ssl1300 "Space%20Systems%20Loral%20(SSL-1300)/Space%20Systems%20Loral%20(SSL-1300).glb" \ + 63cd644d67ee49ab79e313bb6cb0495e9f6072d421a84e116c2c3f9c40bf9c0c yes +fetch ostm "Ocean%20Surface%20Topography%20Mission%20(OSTM%20Jason-2)/Ocean%20Surface%20Topography%20Mission%20(OSTM%20Jason-2).glb" \ + 61c58c2b1c9afce77186a781f699aad5b026573b2cb52e4476d7afd57eae2d04 yes +fetch ace "Advanced%20Composition%20Explorer/Advanced%20Composition%20Explorer.glb" \ + b03a394e57b6af3a1f3700f450d94a67dd3eaec8a00e125d2d005bd1359b6e9c no + +echo "models in $out" diff --git a/scripts/make-example-model.py b/scripts/make-example-model.py new file mode 100755 index 0000000..7100cd8 --- /dev/null +++ b/scripts/make-example-model.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Writes examples/models/boxwing.gltf: a simple box-wing satellite. + +A 1 x 1 x 1.5 m bus wrapped in gold-colored insulation, a white radiator panel on +its -Z face, and two 3 x 1 m dark blue solar arrays on short booms along +Y and -Y. +The arrays and radiator are zero-thickness quads, which the renderer shades from +whichever side faces the camera. Body frame: +X forward, +Z up through the bus. + +No normals are stored, so the renderer gives every triangle a flat normal from its +winding; faces are wound counterclockwise seen from outside. +""" + +import base64 +import json +import pathlib +import struct + + +def box(cx, cy, cz, sx, sy, sz): + """Triangles of an axis-aligned box centered at (cx, cy, cz) with sides s.""" + hx, hy, hz = sx / 2, sy / 2, sz / 2 + corner = lambda i: (cx + (hx if i & 1 else -hx), cy + (hy if i & 2 else -hy), cz + (hz if i & 4 else -hz)) + faces = [[0, 2, 3, 1], [4, 5, 7, 6], [0, 1, 5, 4], [2, 6, 7, 3], [0, 4, 6, 2], [1, 3, 7, 5]] + tris = [] + for f in faces: + tris += [(corner(f[0]), corner(f[1]), corner(f[2])), (corner(f[0]), corner(f[2]), corner(f[3]))] + return tris + + +def quad(a, b, c, d): + return [(a, b, c), (a, c, d)] + + +parts = { + "bus": ((0.80, 0.60, 0.20), box(0, 0, 0, 1.0, 1.0, 1.5)), + "radiator": ((0.85, 0.85, 0.85), quad((-0.45, -0.45, -0.76), (-0.45, 0.45, -0.76), (0.45, 0.45, -0.76), (0.45, -0.45, -0.76))), + "boom +y": ((0.30, 0.30, 0.30), box(0, 0.75, 0, 0.05, 0.5, 0.05)), + "boom -y": ((0.30, 0.30, 0.30), box(0, -0.75, 0, 0.05, 0.5, 0.05)), + # Arrays face +X. + "array +y": ((0.05, 0.08, 0.20), quad((0, 1.0, -0.5), (0, 4.0, -0.5), (0, 4.0, 0.5), (0, 1.0, 0.5))), + "array -y": ((0.05, 0.08, 0.20), quad((0, -4.0, -0.5), (0, -1.0, -0.5), (0, -1.0, 0.5), (0, -4.0, 0.5))), +} + +buffer = bytearray() +gltf = {"asset": {"version": "2.0", "generator": "resolved scripts/make-example-model.py"}, + "scene": 0, "scenes": [{"nodes": []}], "nodes": [], "meshes": [], "materials": [], + "accessors": [], "bufferViews": [], "buffers": []} +for i, (name, (color, tris)) in enumerate(parts.items()): + points = [p for t in tris for p in t] + offset = len(buffer) + for p in points: + buffer += struct.pack("<3f", *p) + gltf["bufferViews"].append({"buffer": 0, "byteOffset": offset, "byteLength": 12 * len(points)}) + gltf["accessors"].append({"bufferView": i, "componentType": 5126, "count": len(points), "type": "VEC3", + "min": [min(p[k] for p in points) for k in range(3)], + "max": [max(p[k] for p in points) for k in range(3)]}) + gltf["materials"].append({"name": name, "pbrMetallicRoughness": {"baseColorFactor": [*color, 1.0]}}) + gltf["meshes"].append({"name": name, "primitives": [{"attributes": {"POSITION": i}, "material": i}]}) + gltf["nodes"].append({"name": name, "mesh": i}) + gltf["scenes"][0]["nodes"].append(i) +gltf["buffers"].append({"byteLength": len(buffer), + "uri": "data:application/octet-stream;base64," + base64.b64encode(buffer).decode()}) + +out = pathlib.Path(__file__).resolve().parent.parent / "examples" / "models" / "boxwing.gltf" +out.parent.mkdir(parents=True, exist_ok=True) +out.write_text(json.dumps(gltf, indent=1) + "\n") +print(f"wrote {out}") diff --git a/scripts/make-example-scenes.py b/scripts/make-example-scenes.py new file mode 100755 index 0000000..c14a69f --- /dev/null +++ b/scripts/make-example-scenes.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""Writes examples/scenes/.toml for the NASA models fetch-models.sh downloads. + +Every scene has the same composition: the spacecraft in an isometric view (the +camera looks along its body (+1, +1, +1) diagonal, body +Z toward image up) with +Earth's limb crossing the image 2 degrees above center, Earth below and space +above. The camera distance makes the model's bounding sphere span about 5.6 +degrees, half the example camera's width. The Sun direction is the one, on a +3-degree grid, that best lights the three visible body faces and Earth near the +limb, without putting the spacecraft in Earth's shadow. + +Model centers and radii were measured from the decoded files at the listed +scales (the bounding box of every vertex); remeasure if a model changes. +""" + +import math +import pathlib +import textwrap + +RE = 6378137.0 +AU = 1.495978707e11 +LEO = 7.0e6 +GEO = 42_164_000.0 + +# name, file, scale, bounding-box center (model frame, scaled, m), radius (m), +# orbit radius (m), description +MODELS = [ + ("landsat8", "landsat8.glb", 1.0, (0.0001, -3.6797, 3.6108), 8.3027, RE + 705e3, + "Landsat 8 (85k triangles) in its 705 km orbit"), + ("icesat2", "icesat2.glb", 1.0, (1.8979, -0.0013, 4.1380), 5.5159, RE + 496e3, + "ICESat-2 (752k triangles) in its 496 km orbit; the heavy model for benchmarks"), + ("ostm", "ostm.glb", 3.5, (0.0, -0.1750, 0.0), 4.3988, RE + 1336e3, + "OSTM / Jason-2 (65k triangles) in its 1336 km orbit"), + ("ssl1300", "ssl1300.glb", 0.1, (0.0, 0.5135, 0.0), 14.3366, GEO, + "an SSL-1300 communications satellite (182k triangles) in geostationary orbit"), + ("ace", "ace.glb", 0.00485, (0.0, 0.0, 0.0), 4.0114, LEO, + "the Advanced Composition Explorer (26k triangles). The real ACE sits at the " + "Sun-Earth L1 point; here it is put in low orbit"), +] + + +def norm(v): + n = math.sqrt(sum(x * x for x in v)) + return [x / n for x in v] + + +def cross(a, b): + return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] + + +def dot(a, b): + return sum(x * y for x, y in zip(a, b)) + + +def mul(m, v): + return [dot(row, v) for row in m] + + +def transpose(m): + return [[m[j][i] for j in range(3)] for i in range(3)] + + +def matmul(a, b): + return [[sum(a[i][k] * b[k][j] for k in range(3)) for j in range(3)] for i in range(3)] + + +def triad(b1, b2, i1, i2): + """The rotation taking body b1 onto inertial i1, with b2 as close to i2 as it can.""" + def frame(v1, v2): + t1 = norm(v1) + t2 = norm(cross(v1, v2)) + t3 = cross(t1, t2) + return [[t1[i], t2[i], t3[i]] for i in range(3)] + return matmul(frame(i1, i2), transpose(frame(b1, b2))) + + +def quaternion(r): + """[w, x, y, z] of rotation matrix r, with w >= 0.""" + tr = r[0][0] + r[1][1] + r[2][2] + if tr > 0: + s = math.sqrt(tr + 1) * 2 + q = [0.25 * s, (r[2][1] - r[1][2]) / s, (r[0][2] - r[2][0]) / s, (r[1][0] - r[0][1]) / s] + else: + i = max(range(3), key=lambda k: r[k][k]) + j, k = (i + 1) % 3, (i + 2) % 3 + s = math.sqrt(1 + r[i][i] - r[j][j] - r[k][k]) * 2 + v = [0.0, 0.0, 0.0] + v[i] = 0.25 * s + v[j] = (r[j][i] + r[i][j]) / s + v[k] = (r[k][i] + r[i][k]) / s + q = [(r[k][j] - r[j][k]) / s] + v + return [-x for x in q] if q[0] < 0 else q + + +def scene(name, file, scale, center, radius, orbit, description): + limb = math.asin(RE / orbit) + theta = limb - math.radians(2.0) + zenith = [1.0, 0.0, 0.0] + look = [orbit, 0.0, 0.0] + boresight = [-math.cos(theta), math.sin(theta), 0.0] + attitude = triad([1, 1, 1], [0, 0, 1], [-x for x in boresight], zenith) + distance = radius / math.tan(math.radians(2.8)) + camera = [look[i] - distance * boresight[i] for i in range(3)] + # Place the model so its bounding-box center is at the look point. + offset = mul(attitude, center) + position = [look[i] - offset[i] for i in range(3)] + + to_limb = [-math.cos(limb), math.sin(limb), 0.0] + reach = math.sqrt(orbit * orbit - RE * RE) + limb_normal = norm([look[i] + reach * to_limb[i] for i in range(3)]) + best = None + for az in range(0, 360, 3): + for el in range(-87, 90, 3): + a, e = math.radians(az), math.radians(el) + sun = [math.cos(e) * math.cos(a), math.cos(e) * math.sin(a), math.sin(e)] + if dot(sun, [-1.0, 0.0, 0.0]) > math.cos(limb): + continue # behind Earth: the spacecraft would be in shadow + faces = [dot(mul(attitude, n), sun) for n in ([1, 0, 0], [0, 1, 0], [0, 0, 1])] + score = min(min(faces), dot(limb_normal, sun)) + if best is None or score > best[0]: + best = (score, sun) + sun = best[1] + + fmt = lambda v, d=2: "[" + ", ".join(f"{x:.{d}f}" for x in v) + "]" + q = quaternion(attitude) + header = textwrap.fill( + f"{description[0].upper() + description[1:]}, in an isometric view with Earth's limb " + "behind it. Generated by scripts/make-example-scenes.py; the model comes from " + "scripts/fetch-models.sh.", + width=80, initial_indent="# ", subsequent_indent="# ", break_on_hyphens=False) + return f"""{header} +# +# resolved render --scene examples/scenes/{name}.toml --preview {name}.png + +sun = {fmt([AU * x for x in sun], 0)} +moon = [0, 0, -3.844e8] + +[observer] +position = {fmt(camera)} +look_at = {fmt(look)} +up = [1, 0, 0] + +[[target]] +model = "../models/nasa/{file}" +scale = {scale} +position = {fmt(position, 4)} +attitude = {fmt(q, 9)} +""" + + +out = pathlib.Path(__file__).resolve().parent.parent / "examples" / "scenes" +for model in MODELS: + path = out / f"{model[0]}.toml" + path.write_text(scene(*model)) + print(f"wrote {path}")