summaryrefslogtreecommitdiff
path: root/crates/gipfel-core/src/profile.rs
diff options
context:
space:
mode:
authorJohannes Hofmann <Johannes.Hofmann@gmx.de>2026-10-26 20:05:20 +0100
committerJohannes Hofmann <Johannes.Hofmann@gmx.de>2026-10-26 20:05:20 +0100
commit375d87a9b0ec1cf2fffd705adb870c86144d2400 (patch)
tree1e8e05e7cd58686628d0f4622622456613a42e4d /crates/gipfel-core/src/profile.rs
parent45425dd047b791fe63f13a46a3251c529cfffc94 (diff)
Replace the C++ implementation with the Rust port
The Rust port (formerly gipfel-rs) is now the official successor to the FLTK/GSL C++ code base. This squashes the port into a single commit and removes the C++ sources (they remain in the git history below). Layout: crates/gipfel-core headless core: hills, panorama geometry, projections (maxima-generated derivatives), pure-Rust LM fit, gipfel: JPEG comment + EXIF I/O, profiles, stitching crates/gipfel-cli command-line tools (export, info, stitch, profiles) crates/gipfel-app Cosmic DE (libcosmic) GUI, binary name `gipfel` codegen/ maxima scripts + regen.sh for the projection code tests/ golden files + C++ reference harnesses (historical) The original README is kept for now; it will be updated to describe the Rust build. gipfel.dat, AUTHORS, ChangeLog and NEWS are unchanged.
Diffstat (limited to 'crates/gipfel-core/src/profile.rs')
-rw-r--r--crates/gipfel-core/src/profile.rs362
1 files changed, 362 insertions, 0 deletions
diff --git a/crates/gipfel-core/src/profile.rs b/crates/gipfel-core/src/profile.rs
new file mode 100644
index 0000000..e8bda68
--- /dev/null
+++ b/crates/gipfel-core/src/profile.rs
@@ -0,0 +1,362 @@
+//! Lens distortion profiles.
+//!
+//! Profiles live in the standard per-user data directory
+//! (`$XDG_DATA_HOME/gipfel/distortion-profiles.prefs`, i.e.
+//! `~/.local/share/gipfel/…`). The file format is the FLTK preferences
+//! format used by the C version (`[./name]` groups, `key:value` entries,
+//! `%g` values), so the legacy file
+//! `~/.fltk/Johannes.HofmannATgmx.de/gipfel/DistortionProfiles.prefs`
+//! imports automatically into the new location on first use.
+//!
+//! File layout (as written by FLTK):
+//! ```text
+//! ; FLTK preferences file format 1.0
+//! ; vendor: Johannes.HofmannATgmx.de
+//! ; application: gipfel/DistortionProfiles
+//!
+//! [.]
+//!
+//! [./Canon_EOS_5D_50.00_mm]
+//!
+//! k0:-0.00123
+//! k1:2.5e-05
+//! x0:0.5
+//! ```
+
+use std::path::{Path, PathBuf};
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct Profile {
+ pub k0: f64,
+ pub k1: f64,
+ pub x0: f64,
+}
+
+#[derive(Debug)]
+pub enum Error {
+ NotFound,
+ Exists,
+ BadName,
+ Io(std::io::Error),
+}
+
+impl std::fmt::Display for Error {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Error::NotFound => write!(f, "profile not found"),
+ Error::Exists => write!(f, "profile already exists"),
+ Error::BadName => write!(f, "invalid profile name"),
+ Error::Io(e) => write!(f, "{e}"),
+ }
+ }
+}
+
+/// The profile store in the standard data directory
+/// (`$XDG_DATA_HOME/gipfel/distortion-profiles.prefs`).
+pub fn prefs_path() -> PathBuf {
+ dirs::data_dir()
+ .unwrap_or_else(|| PathBuf::from(std::env::var("HOME").unwrap_or_default()))
+ .join("gipfel")
+ .join("distortion-profiles.prefs")
+}
+
+/// Legacy FLTK preferences file used by the C version.
+fn fltk_prefs_path() -> PathBuf {
+ let home = std::env::var("HOME").unwrap_or_default();
+ Path::new(&home)
+ .join(".fltk")
+ .join("Johannes.HofmannATgmx.de")
+ .join("gipfel")
+ .join("DistortionProfiles.prefs")
+}
+
+/// Path to the profile store; imports legacy FLTK profiles on first use.
+fn ensure_store() -> PathBuf {
+ let path = prefs_path();
+ if !path.exists() {
+ let legacy = fltk_prefs_path();
+ if legacy.exists() {
+ if let Some(dir) = path.parent() {
+ let _ = std::fs::create_dir_all(dir);
+ }
+ // same format, so a copy suffices
+ let _ = std::fs::copy(&legacy, &path);
+ }
+ }
+ path
+}
+
+/// C `get_distortion_profile_name`: `"%s_%s_%.2f_mm"`. `None` when the
+/// camera is unknown or the focal length is unusable (C would build a name
+/// containing "nan" that can never match a stored profile).
+pub fn profile_name(manufacturer: &str, model: &str, focal_mm: f64) -> Option<String> {
+ if manufacturer.is_empty() || model.is_empty() || !focal_mm.is_finite() || focal_mm <= 0.0 {
+ return None;
+ }
+ let name = format!("{manufacturer}_{model}_{focal_mm:.2}_mm");
+ sanitize_name(&name).ok().map(|_| name)
+}
+
+/// Reject names that would break the prefs file structure (FLTK group paths
+/// use '/' as separator; control chars/newlines corrupt the file).
+pub fn sanitize_name(name: &str) -> Result<(), Error> {
+ if name.trim().is_empty() || name.len() > 255 {
+ return Err(Error::BadName);
+ }
+ if name
+ .chars()
+ .any(|c| c == '/' || c == '\\' || c.is_control())
+ {
+ return Err(Error::BadName);
+ }
+ Ok(())
+}
+
+/// Parse a prefs file into ordered (name, profile) pairs. Tolerant:
+/// comments (`;`), the root group `[.]`, blank lines and unknown keys are
+/// ignored; values are parsed with Rust's `f64` (superset of C `%g`).
+fn parse(text: &str) -> Vec<(String, Profile)> {
+ let mut out: Vec<(String, Profile)> = Vec::new();
+ let mut cur: Option<usize> = None;
+ for line in text.lines() {
+ let line = line.trim_end();
+ if line.is_empty() || line.starts_with(';') {
+ continue;
+ }
+ if let Some(group) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
+ cur = match group.strip_prefix("./") {
+ Some(name) => {
+ out.push((
+ name.to_string(),
+ Profile {
+ k0: f64::NAN,
+ k1: f64::NAN,
+ x0: f64::NAN,
+ },
+ ));
+ Some(out.len() - 1)
+ }
+ None => None, // "[.]" root group
+ };
+ continue;
+ }
+ let Some(idx) = cur else { continue };
+ if let Some((key, val)) = line.split_once(':') {
+ let Ok(v) = val.trim().parse::<f64>() else {
+ continue;
+ };
+ let p = &mut out[idx].1;
+ match key.trim() {
+ "k0" => p.k0 = v,
+ "k1" => p.k1 = v,
+ "x0" => p.x0 = v,
+ _ => {}
+ }
+ }
+ }
+ out.retain(|(_, p)| p.k0.is_finite() && p.k1.is_finite() && p.x0.is_finite());
+ out
+}
+
+/// Serialize in FLTK's layout so the C version can read our file.
+fn serialize(profiles: &[(String, Profile)]) -> String {
+ let mut s = String::from(
+ "; FLTK preferences file format 1.0\n\
+ ; vendor: Johannes.HofmannATgmx.de\n\
+ ; application: gipfel/DistortionProfiles\n\
+ \n\
+ [.]\n\
+ \n",
+ );
+ for (name, p) in profiles {
+ s.push_str(&format!(
+ "\n[./{name}]\n\nk0:{}\nk1:{}\nx0:{}\n",
+ fmt_g(p.k0),
+ fmt_g(p.k1),
+ fmt_g(p.x0)
+ ));
+ }
+ s
+}
+
+/// Closest Rust equivalent of C `%g`: scientific notation for magnitudes
+/// outside [1e-4, 1e15), exponent zero-padded to two digits ("2.5e-05").
+/// Unlike C we keep the shortest round-trip mantissa (more precise; the
+/// FLTK reader uses strtod and reads either fine).
+fn fmt_g(v: f64) -> String {
+ if v != 0.0 && (v.abs() < 1e-4 || v.abs() >= 1e15) {
+ if let Some((mant, exp)) = format!("{v:e}").split_once('e') {
+ let neg = exp.starts_with('-');
+ let digits = exp.trim_start_matches(['-', '+']);
+ let digits = if digits.len() < 2 { format!("0{digits}") } else { digits.to_string() };
+ let sign = if neg { '-' } else { '+' };
+ return format!("{mant}e{sign}{digits}");
+ }
+ }
+ if v == v.trunc() && v.abs() < 1e16 {
+ format!("{}", v as i64)
+ } else {
+ format!("{v}")
+ }
+}
+
+fn read_all(path: &Path) -> Result<Vec<(String, Profile)>, Error> {
+ match std::fs::read_to_string(path) {
+ Ok(text) => Ok(parse(&text)),
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
+ Err(e) => Err(Error::Io(e)),
+ }
+}
+
+fn write_all(path: &Path, profiles: &[(String, Profile)]) -> Result<(), Error> {
+ if let Some(dir) = path.parent() {
+ std::fs::create_dir_all(dir).map_err(Error::Io)?;
+ }
+ // atomic: temp file + rename
+ let tmp = path.with_extension("prefs.tmp");
+ std::fs::write(&tmp, serialize(profiles)).map_err(Error::Io)?;
+ std::fs::rename(&tmp, path).map_err(Error::Io)?;
+ Ok(())
+}
+
+/// Load a profile by exact name from the default store.
+pub fn load(name: &str) -> Result<Profile, Error> {
+ load_from(&ensure_store(), name)
+}
+
+pub fn load_from(path: &Path, name: &str) -> Result<Profile, Error> {
+ read_all(path)?
+ .into_iter()
+ .find(|(n, _)| n == name)
+ .map(|(_, p)| p)
+ .ok_or(Error::NotFound)
+}
+
+/// Save a profile. With `force == false` an existing profile of that name is
+/// not overwritten (C `save_distortion_params(name, force)`).
+pub fn save(name: &str, profile: Profile, force: bool) -> Result<(), Error> {
+ save_to(&ensure_store(), name, profile, force)
+}
+
+pub fn save_to(path: &Path, name: &str, profile: Profile, force: bool) -> Result<(), Error> {
+ sanitize_name(name)?;
+ let mut all = read_all(path)?;
+ if let Some(pos) = all.iter().position(|(n, _)| n == name) {
+ if !force {
+ return Err(Error::Exists);
+ }
+ all[pos].1 = profile;
+ } else {
+ all.push((name.to_string(), profile));
+ }
+ write_all(path, &all)
+}
+
+/// All profiles in the default store, in file order.
+pub fn list() -> Vec<(String, Profile)> {
+ read_all(&ensure_store()).unwrap_or_default()
+}
+
+pub fn list_from(path: &Path) -> Vec<(String, Profile)> {
+ read_all(path).unwrap_or_default()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// Exact bytes as written by the C version (verified against FLTK 1.4).
+ const FLTK_FIXTURE: &str = "; FLTK preferences file format 1.0\n\
+ ; vendor: Johannes.HofmannATgmx.de\n\
+ ; application: gipfel/DistortionProfiles\n\
+ \n\
+ [.]\n\
+ \n\
+ \n\
+ [./Canon_EOS_5D_50.00_mm]\n\
+ \n\
+ k0:-0.00123\n\
+ k1:2.5e-05\n\
+ x0:0.5\n\
+ \n\
+ [./Nikon D850_35.00_mm]\n\
+ \n\
+ k0:0.01\n\
+ k1:0\n\
+ x0:0\n";
+
+ #[test]
+ fn parses_fltk_layout() {
+ let all = parse(FLTK_FIXTURE);
+ assert_eq!(all.len(), 2);
+ assert_eq!(all[0].0, "Canon_EOS_5D_50.00_mm");
+ assert_eq!(all[0].1.k0, -0.00123);
+ assert_eq!(all[0].1.k1, 2.5e-5);
+ assert_eq!(all[0].1.x0, 0.5);
+ assert_eq!(all[1].0, "Nikon D850_35.00_mm");
+ assert_eq!(all[1].1.k0, 0.01);
+ assert_eq!(all[1].1.k1, 0.0);
+ }
+
+ #[test]
+ fn serialize_replicates_fltk_layout() {
+ let all = parse(FLTK_FIXTURE);
+ assert_eq!(serialize(&all), FLTK_FIXTURE);
+ }
+
+ #[test]
+ fn fmt_g_matches_c_percent_g() {
+ assert_eq!(fmt_g(2.5e-5), "2.5e-05");
+ assert_eq!(fmt_g(-0.00123), "-0.00123");
+ assert_eq!(fmt_g(0.0001), "0.0001");
+ assert_eq!(fmt_g(1e15), "1e+15");
+ assert_eq!(fmt_g(0.0), "0");
+ assert_eq!(fmt_g(0.01), "0.01");
+ }
+
+ #[test]
+ fn profile_name_matches_c() {
+ assert_eq!(
+ profile_name("Canon", "EOS_5D", 50.0).as_deref(),
+ Some("Canon_EOS_5D_50.00_mm")
+ );
+ assert_eq!(
+ profile_name("Nikon", "D850", 34.666).as_deref(),
+ Some("Nikon_D850_34.67_mm")
+ );
+ assert!(profile_name("", "D850", 35.0).is_none());
+ assert!(profile_name("Canon", "D850", f64::NAN).is_none());
+ assert!(profile_name("Can/on", "D850", 35.0).is_none());
+ }
+
+ #[test]
+ fn roundtrip_and_overwrite() {
+ let dir = std::env::temp_dir().join(format!("gipfel-prof-{}", std::process::id()));
+ let path = dir.join("DistortionProfiles.prefs");
+ let _ = std::fs::remove_dir_all(&dir);
+
+ let p = Profile { k0: -0.001, k1: 2e-5, x0: 0.0 };
+ save_to(&path, "Cam_A_24.00_mm", p, false).unwrap();
+ // exists -> error without force
+ assert!(matches!(
+ save_to(&path, "Cam_A_24.00_mm", p, false),
+ Err(Error::Exists)
+ ));
+ save_to(&path, "Cam_B_50.00_mm", Profile { k0: 0.1, k1: 0.0, x0: 1.0 }, false).unwrap();
+ // overwrite first, keep second
+ let p2 = Profile { k0: -0.002, k1: 2e-5, x0: 0.0 };
+ save_to(&path, "Cam_A_24.00_mm", p2, true).unwrap();
+
+ let all = list_from(&path);
+ assert_eq!(all.len(), 2);
+ assert_eq!(all[0].1, p2);
+ assert_eq!(load_from(&path, "Cam_B_50.00_mm").unwrap().k0, 0.1);
+ assert!(matches!(load_from(&path, "nope"), Err(Error::NotFound)));
+
+ // bad names rejected
+ assert!(save_to(&path, "a/b", p, true).is_err());
+ assert!(save_to(&path, "with\nnewline", p, true).is_err());
+
+ std::fs::remove_dir_all(&dir).unwrap();
+ }
+}