CLI, along with several examples, plus model downloading
This commit is contained in:
1 parent
4f9e73a418
commit
51ace681a7
22 files changed
+1804
-18
No files matched your search
@@ -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 }
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
Reference in new issue
Block a user