use serde::{Deserialize, Serialize};
use crate::target::{
CodedRingSpec, HexGeometry, LatticeGeometry, MarkerCoding, OriginFiducials, RectGeometry,
RingGeometry, TargetLayout, TargetValidationError,
};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TargetRecipe {
pub name: String,
pub lattice: LatticeRecipe,
pub marker: MarkerRecipe,
#[serde(default)]
pub coding: CodingRecipe,
#[serde(default)]
pub fiducials: FiducialsRecipe,
#[serde(default)]
pub render: RenderRecipe,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum LatticeRecipe {
Hex {
rows: usize,
long_row_cols: usize,
pitch_mm: f32,
},
Rect {
rows: usize,
cols: usize,
pitch_mm: f32,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MarkerRecipe {
pub outer_radius_mm: f32,
pub inner_radius_mm: f32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ring_width_mm: Option<f32>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CodingRecipe {
Coded,
#[default]
Plain,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FiducialMode {
None,
Auto,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum FiducialsRecipe {
Mode(FiducialMode),
Explicit(OriginFiducials),
}
impl Default for FiducialsRecipe {
fn default() -> Self {
Self::Mode(FiducialMode::None)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RenderRecipe {
#[serde(default = "default_dpi")]
pub dpi: f32,
#[serde(default)]
pub margin_mm: f32,
#[serde(default = "default_true")]
pub scale_bar: bool,
#[serde(default = "default_formats")]
pub formats: Vec<Format>,
}
impl Default for RenderRecipe {
fn default() -> Self {
Self {
dpi: default_dpi(),
margin_mm: 0.0,
scale_bar: true,
formats: default_formats(),
}
}
}
fn default_dpi() -> f32 {
300.0
}
fn default_true() -> bool {
true
}
fn default_formats() -> Vec<Format> {
vec![Format::Json, Format::Svg, Format::Png, Format::Dxf]
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "lowercase")]
pub enum Format {
Json,
Svg,
Png,
Dxf,
}
#[derive(Debug)]
pub enum RecipeError {
MissingRingWidth,
CodedWithFiducials,
Validation(TargetValidationError),
}
impl std::fmt::Display for RecipeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingRingWidth => {
f.write_str("coded markers require marker.ring_width_mm in the recipe")
}
Self::CodedWithFiducials => f.write_str(
"coded markers cannot use origin dots — set coding = \"plain\" or fiducials = \"none\"",
),
Self::Validation(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for RecipeError {}
impl From<TargetValidationError> for RecipeError {
fn from(value: TargetValidationError) -> Self {
Self::Validation(value)
}
}
impl TargetRecipe {
pub fn parse_toml(text: &str) -> Result<Self, toml::de::Error> {
toml::from_str(text)
}
pub fn parse_json(text: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(text)
}
pub fn to_target(&self) -> Result<TargetLayout, RecipeError> {
let lattice = match self.lattice {
LatticeRecipe::Hex {
rows,
long_row_cols,
pitch_mm,
} => LatticeGeometry::Hex(HexGeometry {
rows,
long_row_cols,
pitch_mm,
}),
LatticeRecipe::Rect {
rows,
cols,
pitch_mm,
} => LatticeGeometry::Rect(RectGeometry {
rows,
cols,
pitch_mm,
}),
};
let marker = RingGeometry {
outer_radius_mm: self.marker.outer_radius_mm,
inner_radius_mm: self.marker.inner_radius_mm,
};
let coded = self.coding == CodingRecipe::Coded;
let coding = if coded {
let ring_width_mm = self
.marker
.ring_width_mm
.ok_or(RecipeError::MissingRingWidth)?;
MarkerCoding::Coded16(CodedRingSpec {
ring_width_mm,
id_assignment: None,
})
} else {
MarkerCoding::Plain
};
match &self.fiducials {
FiducialsRecipe::Mode(FiducialMode::None) => Ok(TargetLayout::new(
&self.name, lattice, marker, coding, None,
)?),
FiducialsRecipe::Mode(FiducialMode::Auto) => {
if coded {
return Err(RecipeError::CodedWithFiducials);
}
Ok(TargetLayout::with_auto_fiducials(
&self.name, lattice, marker, coding,
)?)
}
FiducialsRecipe::Explicit(dots) => {
if coded {
return Err(RecipeError::CodedWithFiducials);
}
Ok(TargetLayout::new(
&self.name,
lattice,
marker,
coding,
Some(dots.clone()),
)?)
}
}
}
pub fn set_pitch_mm(&mut self, pitch_mm: f32) {
match &mut self.lattice {
LatticeRecipe::Hex { pitch_mm: p, .. } | LatticeRecipe::Rect { pitch_mm: p, .. } => {
*p = pitch_mm;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_and_lowers_every_matrix_combo() {
for (name, text) in super::super::EXAMPLE_RECIPES {
let recipe =
TargetRecipe::parse_toml(text).unwrap_or_else(|e| panic!("{name} parse: {e}"));
recipe
.to_target()
.unwrap_or_else(|e| panic!("{name} lower: {e}"));
}
}
#[test]
fn rect_plain_dots_matches_preset_shape() {
let recipe = TargetRecipe::parse_toml(
super::super::example_recipe("rect_plain_dots").expect("example present"),
)
.expect("parse");
let target = recipe.to_target().expect("lower");
assert!(!target.is_coded());
assert!(target.fiducials().is_some());
assert_eq!(target.n_cells(), 24 * 24);
}
#[test]
fn coded_with_auto_dots_is_rejected() {
let text = r#"
name = "bad"
coding = "coded"
fiducials = "auto"
[lattice]
kind = "hex"
rows = 5
long_row_cols = 5
pitch_mm = 8.0
[marker]
outer_radius_mm = 4.8
inner_radius_mm = 3.2
ring_width_mm = 1.152
"#;
let recipe = TargetRecipe::parse_toml(text).expect("parse");
assert!(matches!(
recipe.to_target(),
Err(RecipeError::CodedWithFiducials)
));
}
#[test]
fn coded_without_ring_width_is_rejected() {
let text = r#"
name = "bad"
coding = "coded"
[lattice]
kind = "rect"
rows = 5
cols = 5
pitch_mm = 14.0
[marker]
outer_radius_mm = 4.8
inner_radius_mm = 3.2
"#;
let recipe = TargetRecipe::parse_toml(text).expect("parse");
assert!(matches!(
recipe.to_target(),
Err(RecipeError::MissingRingWidth)
));
}
}