#[cfg(feature = "std")]
use std::path::Path;
use super::error::{TargetLoadError, TargetValidationError};
use super::fiducials::OriginFiducials;
use super::lattice::{HexGeometry, LatticeGeometry};
use super::layout::TargetLayout;
use super::ring::{CodedRingSpec, MarkerCoding, RingGeometry};
pub(crate) const TARGET_SCHEMA_V5: &str = "ringgrid.target.v5";
pub(crate) const TARGET_SCHEMA_V4: &str = "ringgrid.target.v4";
const EXPECTED_SCHEMAS: &str = "'ringgrid.target.v5' or 'ringgrid.target.v4'";
#[derive(serde::Deserialize)]
struct SchemaProbe {
schema: String,
}
#[derive(serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct TargetSpecV5 {
schema: String,
name: String,
lattice: LatticeGeometry,
marker: RingGeometry,
coding: MarkerCoding,
#[serde(default, skip_serializing_if = "Option::is_none")]
fiducials: Option<OriginFiducials>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct BoardSpecV4 {
pub(crate) schema: String,
pub(crate) name: String,
pub(crate) pitch_mm: f32,
pub(crate) rows: usize,
pub(crate) long_row_cols: usize,
pub(crate) marker_outer_radius_mm: f32,
pub(crate) marker_inner_radius_mm: f32,
pub(crate) marker_ring_width_mm: f32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) id_assignment: Option<Vec<usize>>,
}
impl BoardSpecV4 {
pub(crate) fn into_layout(self) -> Result<TargetLayout, TargetValidationError> {
if self.schema != TARGET_SCHEMA_V4 {
return Err(TargetValidationError::UnsupportedSchema {
found: self.schema,
expected: EXPECTED_SCHEMAS,
});
}
TargetLayout::new(
self.name,
LatticeGeometry::Hex(HexGeometry {
rows: self.rows,
long_row_cols: self.long_row_cols,
pitch_mm: self.pitch_mm,
}),
RingGeometry {
outer_radius_mm: self.marker_outer_radius_mm,
inner_radius_mm: self.marker_inner_radius_mm,
},
MarkerCoding::Coded16(CodedRingSpec {
ring_width_mm: self.marker_ring_width_mm,
id_assignment: self.id_assignment,
}),
None,
)
}
}
impl TargetSpecV5 {
fn into_layout(self) -> Result<TargetLayout, TargetValidationError> {
if self.schema != TARGET_SCHEMA_V5 {
return Err(TargetValidationError::UnsupportedSchema {
found: self.schema,
expected: EXPECTED_SCHEMAS,
});
}
TargetLayout::new(
self.name,
self.lattice,
self.marker,
self.coding,
self.fiducials,
)
}
}
impl TargetLayout {
pub fn from_json_str(data: &str) -> Result<Self, TargetLoadError> {
let probe: SchemaProbe = serde_json::from_str(data)?;
match probe.schema.as_str() {
TARGET_SCHEMA_V5 => {
let spec: TargetSpecV5 = serde_json::from_str(data)?;
spec.into_layout().map_err(Into::into)
}
TARGET_SCHEMA_V4 => {
let spec: BoardSpecV4 = serde_json::from_str(data)?;
spec.into_layout().map_err(Into::into)
}
other => Err(TargetValidationError::UnsupportedSchema {
found: other.to_string(),
expected: EXPECTED_SCHEMAS,
}
.into()),
}
}
#[cfg(feature = "std")]
pub fn from_json_file(path: &Path) -> Result<Self, TargetLoadError> {
let data = std::fs::read_to_string(path)?;
Self::from_json_str(&data)
}
pub fn to_json_string(&self) -> String {
serde_json::to_string_pretty(&self.to_spec())
.expect("target layout JSON serialization must succeed")
}
#[cfg(feature = "std")]
pub fn write_json_file(&self, path: &Path) -> Result<(), std::io::Error> {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, format!("{}\n", self.to_json_string()))
}
fn to_spec(&self) -> TargetSpecV5 {
let coding = match self.coding() {
MarkerCoding::Coded16(spec) => {
let is_sequential = spec
.id_assignment
.as_ref()
.is_none_or(|ids| ids.iter().enumerate().all(|(i, &id)| id == i));
MarkerCoding::Coded16(CodedRingSpec {
ring_width_mm: spec.ring_width_mm,
id_assignment: if is_sequential {
None
} else {
spec.id_assignment.clone()
},
})
}
MarkerCoding::Plain => MarkerCoding::Plain,
};
TargetSpecV5 {
schema: TARGET_SCHEMA_V5.to_string(),
name: self.name().to_string(),
lattice: *self.lattice(),
marker: self.ring(),
coding,
fiducials: self.fiducials().cloned(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn v5_round_trip_preserves_all_aspects() {
let target = TargetLayout::isra_rect_24x24();
let json = target.to_json_string();
let reloaded = TargetLayout::from_json_str(&json).expect("round-trip");
assert_eq!(reloaded.name(), target.name());
assert_eq!(reloaded.lattice(), target.lattice());
assert_eq!(reloaded.ring(), target.ring());
assert_eq!(reloaded.coding(), target.coding());
assert_eq!(reloaded.fiducials(), target.fiducials());
assert_eq!(reloaded.cells(), target.cells());
}
#[test]
fn v5_json_shape_is_compositional() {
let json = TargetLayout::isra_rect_24x24().to_json_string();
let val: serde_json::Value = serde_json::from_str(&json).expect("valid json");
assert_eq!(val["schema"], "ringgrid.target.v5");
assert_eq!(val["lattice"]["kind"], "rect");
assert_eq!(val["lattice"]["rows"], 24);
assert_eq!(val["coding"]["kind"], "plain");
assert_eq!(val["marker"]["outer_radius_mm"], 5.6);
assert_eq!(
val["fiducials"]["dots_mm"].as_array().map(|a| a.len()),
Some(3)
);
let hex_json = TargetLayout::default_hex().to_json_string();
let hex: serde_json::Value = serde_json::from_str(&hex_json).expect("valid json");
assert_eq!(hex["lattice"]["kind"], "hex");
assert_eq!(hex["coding"]["kind"], "coded16");
let ring_width = hex["coding"]["ring_width_mm"].as_f64().expect("number");
assert!((ring_width - 1.152).abs() < 1e-6);
assert!(hex.get("fiducials").is_none());
}
#[test]
fn v4_auto_migrates_to_hex_coded() {
let raw = r#"{
"schema":"ringgrid.target.v4",
"name":"legacy",
"pitch_mm":8.0,
"rows":3,
"long_row_cols":4,
"marker_outer_radius_mm":4.8,
"marker_inner_radius_mm":3.2,
"marker_ring_width_mm":1.152
}"#;
let target = TargetLayout::from_json_str(raw).expect("v4 accepted");
assert_eq!(target.name(), "legacy");
assert!(target.is_coded());
assert!(matches!(target.lattice(), LatticeGeometry::Hex(_)));
assert_eq!(target.fiducials(), None);
let json = target.to_json_string();
assert!(json.contains("ringgrid.target.v5"));
}
#[test]
fn rejects_unknown_schema() {
let raw = r#"{"schema":"ringgrid.target.v2","name":"x"}"#;
let err = TargetLayout::from_json_str(raw).expect_err("unsupported");
assert!(matches!(
err,
TargetLoadError::Validation(TargetValidationError::UnsupportedSchema { .. })
));
}
#[test]
fn rejects_unknown_top_level_fields() {
let mut val: serde_json::Value =
serde_json::from_str(&TargetLayout::default_hex().to_json_string()).expect("json");
val["markers"] = serde_json::json!([]);
let raw = serde_json::to_string(&val).expect("json");
assert!(TargetLayout::from_json_str(&raw).is_err());
}
#[cfg(feature = "std")]
#[test]
fn checked_in_v4_fixtures_load() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let plain = TargetLayout::from_json_file(&root.join("tools/board/board_spec.json"))
.expect("v4 board_spec fixture loads");
assert_eq!(plain.n_cells(), 203);
let optimized =
TargetLayout::from_json_file(&root.join("tools/board/board_spec_optimized.json"))
.expect("v4 optimized fixture loads");
assert_eq!(optimized.n_cells(), 203);
let reloaded = TargetLayout::from_json_str(&optimized.to_json_string()).expect("v5");
let ids: Vec<_> = optimized.cells().iter().map(|c| c.id).collect();
let reloaded_ids: Vec<_> = reloaded.cells().iter().map(|c| c.id).collect();
assert_eq!(ids, reloaded_ids);
}
}