use std::collections::HashMap;
#[cfg(feature = "std")]
use std::path::Path;
const TARGET_SCHEMA_V4: &str = "ringgrid.target.v4";
const DEFAULT_NAME: &str = "ringgrid_200mm_hex";
const DEFAULT_PITCH_MM: f32 = 8.0;
const DEFAULT_ROWS: usize = 15;
const DEFAULT_LONG_ROW_COLS: usize = 14;
const DEFAULT_OUTER_RADIUS_MM: f32 = 4.8;
const DEFAULT_INNER_RADIUS_MM: f32 = 3.2;
const DEFAULT_RING_WIDTH_MM: f32 = 1.152;
#[derive(Debug, Clone)]
pub enum BoardLayoutValidationError {
UnsupportedSchema {
found: String,
expected: &'static str,
},
EmptyName,
InvalidPitch {
pitch_mm: f32,
},
InvalidRows {
rows: usize,
},
InvalidLongRowCols {
long_row_cols: usize,
},
InvalidLongRowColsForRows {
rows: usize,
long_row_cols: usize,
},
InvalidOuterRadius {
marker_outer_radius_mm: f32,
},
InvalidInnerRadius {
marker_inner_radius_mm: f32,
},
InvalidRingWidth {
marker_ring_width_mm: f32,
},
InnerRadiusNotSmallerThanOuter {
marker_inner_radius_mm: f32,
marker_outer_radius_mm: f32,
},
NonPositiveCodeBandGap {
inner_ring_outer_edge_mm: f32,
outer_ring_inner_edge_mm: f32,
},
OuterDiameterExceedsMinCenterSpacing {
marker_outer_diameter_mm: f32,
min_center_spacing_mm: f32,
},
MarkerDrawDiameterExceedsMinCenterSpacing {
marker_draw_diameter_mm: f32,
min_center_spacing_mm: f32,
},
DerivedZeroColumns {
row_index: usize,
rows: usize,
long_row_cols: usize,
},
}
impl std::fmt::Display for BoardLayoutValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnsupportedSchema { found, expected } => write!(
f,
"unsupported target schema '{}' (expected '{}')",
found, expected
),
Self::EmptyName => f.write_str("target name must not be empty"),
Self::InvalidPitch { pitch_mm } => {
write!(f, "pitch_mm must be finite and > 0 (got {pitch_mm})")
}
Self::InvalidRows { rows } => write!(f, "rows must be >= 1 (got {rows})"),
Self::InvalidLongRowCols { long_row_cols } => {
write!(f, "long_row_cols must be >= 1 (got {long_row_cols})")
}
Self::InvalidLongRowColsForRows {
rows,
long_row_cols,
} => write!(
f,
"long_row_cols must be >= 2 when rows > 1 (got rows={}, long_row_cols={})",
rows, long_row_cols
),
Self::InvalidOuterRadius {
marker_outer_radius_mm,
} => write!(
f,
"marker_outer_radius_mm must be finite and > 0 (got {marker_outer_radius_mm})"
),
Self::InvalidInnerRadius {
marker_inner_radius_mm,
} => write!(
f,
"marker_inner_radius_mm must be finite and > 0 (got {marker_inner_radius_mm})"
),
Self::InvalidRingWidth {
marker_ring_width_mm,
} => write!(
f,
"marker_ring_width_mm must be finite and > 0 (got {marker_ring_width_mm})"
),
Self::InnerRadiusNotSmallerThanOuter {
marker_inner_radius_mm,
marker_outer_radius_mm,
} => write!(
f,
"marker_inner_radius_mm must be < marker_outer_radius_mm (inner={}, outer={})",
marker_inner_radius_mm, marker_outer_radius_mm
),
Self::NonPositiveCodeBandGap {
inner_ring_outer_edge_mm,
outer_ring_inner_edge_mm,
} => write!(
f,
"marker geometry leaves no code band between rings (inner ring outer edge={inner_ring_outer_edge_mm:.4}mm, outer ring inner edge={outer_ring_inner_edge_mm:.4}mm)"
),
Self::OuterDiameterExceedsMinCenterSpacing {
marker_outer_diameter_mm,
min_center_spacing_mm,
} => write!(
f,
"marker outer diameter ({marker_outer_diameter_mm:.4}mm) must be smaller than minimum center spacing ({min_center_spacing_mm:.4}mm)"
),
Self::MarkerDrawDiameterExceedsMinCenterSpacing {
marker_draw_diameter_mm,
min_center_spacing_mm,
} => write!(
f,
"printed marker diameter including ring stroke ({marker_draw_diameter_mm:.4}mm) must be smaller than minimum center spacing ({min_center_spacing_mm:.4}mm)"
),
Self::DerivedZeroColumns {
row_index,
rows,
long_row_cols,
} => write!(
f,
"derived row has zero columns at row {} (rows={}, long_row_cols={})",
row_index, rows, long_row_cols
),
}
}
}
impl std::error::Error for BoardLayoutValidationError {}
#[derive(Debug)]
pub enum BoardLayoutLoadError {
#[cfg(feature = "std")]
Io(std::io::Error),
JsonParse(serde_json::Error),
Validation(BoardLayoutValidationError),
}
impl std::fmt::Display for BoardLayoutLoadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
#[cfg(feature = "std")]
Self::Io(err) => write!(f, "failed to read target JSON: {err}"),
Self::JsonParse(err) => write!(f, "failed to parse target JSON: {err}"),
Self::Validation(err) => write!(f, "invalid target spec: {err}"),
}
}
}
impl std::error::Error for BoardLayoutLoadError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
#[cfg(feature = "std")]
Self::Io(err) => Some(err),
Self::JsonParse(err) => Some(err),
Self::Validation(err) => Some(err),
}
}
}
#[cfg(feature = "std")]
impl From<std::io::Error> for BoardLayoutLoadError {
fn from(value: std::io::Error) -> Self {
Self::Io(value)
}
}
impl From<serde_json::Error> for BoardLayoutLoadError {
fn from(value: serde_json::Error) -> Self {
Self::JsonParse(value)
}
}
impl From<BoardLayoutValidationError> for BoardLayoutLoadError {
fn from(value: BoardLayoutValidationError) -> Self {
Self::Validation(value)
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct BoardMarker {
pub id: usize,
pub xy_mm: [f32; 2],
#[serde(default, skip_serializing_if = "Option::is_none")]
pub q: Option<i16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub r: Option<i16>,
}
#[derive(Debug, Clone)]
pub struct BoardLayout {
pub name: String,
pub pitch_mm: f32,
pub rows: usize,
pub long_row_cols: usize,
pub marker_outer_radius_mm: f32,
pub marker_inner_radius_mm: f32,
pub marker_ring_width_mm: f32,
markers: Vec<BoardMarker>,
id_to_idx: HashMap<usize, usize>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct BoardLayoutSpecV4 {
schema: String,
name: String,
pitch_mm: f32,
rows: usize,
long_row_cols: usize,
marker_outer_radius_mm: f32,
marker_inner_radius_mm: f32,
marker_ring_width_mm: f32,
}
impl BoardLayout {
pub fn new(
pitch_mm: f32,
rows: usize,
long_row_cols: usize,
marker_outer_radius_mm: f32,
marker_inner_radius_mm: f32,
marker_ring_width_mm: f32,
) -> Result<Self, BoardLayoutValidationError> {
Self::with_name(
generated_name(
pitch_mm,
rows,
long_row_cols,
marker_outer_radius_mm,
marker_inner_radius_mm,
marker_ring_width_mm,
),
pitch_mm,
rows,
long_row_cols,
marker_outer_radius_mm,
marker_inner_radius_mm,
marker_ring_width_mm,
)
}
pub fn with_name<S: Into<String>>(
name: S,
pitch_mm: f32,
rows: usize,
long_row_cols: usize,
marker_outer_radius_mm: f32,
marker_inner_radius_mm: f32,
marker_ring_width_mm: f32,
) -> Result<Self, BoardLayoutValidationError> {
Self::from_layout_spec(BoardLayoutSpecV4 {
schema: TARGET_SCHEMA_V4.to_string(),
name: name.into(),
pitch_mm,
rows,
long_row_cols,
marker_outer_radius_mm,
marker_inner_radius_mm,
marker_ring_width_mm,
})
}
pub fn xy_mm(&self, id: usize) -> Option<[f32; 2]> {
self.id_to_idx.get(&id).map(|&idx| self.markers[idx].xy_mm)
}
pub fn marker(&self, id: usize) -> Option<&BoardMarker> {
self.id_to_idx.get(&id).map(|&idx| &self.markers[idx])
}
pub fn markers(&self) -> &[BoardMarker] {
&self.markers
}
pub fn marker_by_index(&self, index: usize) -> Option<&BoardMarker> {
self.markers.get(index)
}
pub fn n_markers(&self) -> usize {
self.markers.len()
}
pub fn marker_outer_radius_mm(&self) -> f32 {
self.marker_outer_radius_mm
}
pub(crate) fn min_center_spacing_mm(&self) -> f32 {
hex_row_spacing_mm(self.pitch_mm)
}
pub fn marker_inner_radius_mm(&self) -> f32 {
self.marker_inner_radius_mm
}
pub fn marker_ring_width_mm(&self) -> f32 {
self.marker_ring_width_mm
}
pub fn marker_ids(&self) -> impl Iterator<Item = usize> + '_ {
self.markers.iter().map(|m| m.id)
}
pub fn max_marker_id(&self) -> usize {
self.markers.iter().map(|m| m.id).max().unwrap_or(0)
}
pub fn marker_bounds_mm(&self) -> Option<([f32; 2], [f32; 2])> {
let first = self.markers.first()?;
let mut min_x = first.xy_mm[0];
let mut max_x = first.xy_mm[0];
let mut min_y = first.xy_mm[1];
let mut max_y = first.xy_mm[1];
for m in &self.markers[1..] {
min_x = min_x.min(m.xy_mm[0]);
max_x = max_x.max(m.xy_mm[0]);
min_y = min_y.min(m.xy_mm[1]);
max_y = max_y.max(m.xy_mm[1]);
}
Some(([min_x, min_y], [max_x, max_y]))
}
pub fn marker_span_mm(&self) -> Option<[f32; 2]> {
self.marker_bounds_mm()
.map(|(min_xy, max_xy)| [max_xy[0] - min_xy[0], max_xy[1] - min_xy[1]])
}
#[cfg(feature = "std")]
pub fn from_json_file(path: &Path) -> Result<Self, BoardLayoutLoadError> {
let data = std::fs::read_to_string(path)?;
Self::from_json_str(&data)
}
pub fn from_json_str(data: &str) -> Result<Self, BoardLayoutLoadError> {
let spec: BoardLayoutSpecV4 = serde_json::from_str(data)?;
Self::from_layout_spec(spec).map_err(Into::into)
}
pub fn to_json_string(&self) -> String {
serde_json::to_string_pretty(&self.to_layout_spec())
.expect("board 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 from_layout_spec(spec: BoardLayoutSpecV4) -> Result<Self, BoardLayoutValidationError> {
if spec.schema != TARGET_SCHEMA_V4 {
return Err(BoardLayoutValidationError::UnsupportedSchema {
found: spec.schema,
expected: TARGET_SCHEMA_V4,
});
}
validate_layout_spec(&spec)?;
let markers = generate_markers(spec.rows, spec.long_row_cols, spec.pitch_mm)?;
let id_to_idx = markers.iter().enumerate().map(|(i, m)| (m.id, i)).collect();
Ok(Self {
name: spec.name,
pitch_mm: spec.pitch_mm,
rows: spec.rows,
long_row_cols: spec.long_row_cols,
marker_outer_radius_mm: spec.marker_outer_radius_mm,
marker_inner_radius_mm: spec.marker_inner_radius_mm,
marker_ring_width_mm: spec.marker_ring_width_mm,
markers,
id_to_idx,
})
}
fn to_layout_spec(&self) -> BoardLayoutSpecV4 {
BoardLayoutSpecV4 {
schema: TARGET_SCHEMA_V4.to_string(),
name: self.name.clone(),
pitch_mm: self.pitch_mm,
rows: self.rows,
long_row_cols: self.long_row_cols,
marker_outer_radius_mm: self.marker_outer_radius_mm,
marker_inner_radius_mm: self.marker_inner_radius_mm,
marker_ring_width_mm: self.marker_ring_width_mm,
}
}
}
impl Default for BoardLayout {
fn default() -> Self {
let spec = BoardLayoutSpecV4 {
schema: TARGET_SCHEMA_V4.to_string(),
name: DEFAULT_NAME.to_string(),
pitch_mm: DEFAULT_PITCH_MM,
rows: DEFAULT_ROWS,
long_row_cols: DEFAULT_LONG_ROW_COLS,
marker_outer_radius_mm: DEFAULT_OUTER_RADIUS_MM,
marker_inner_radius_mm: DEFAULT_INNER_RADIUS_MM,
marker_ring_width_mm: DEFAULT_RING_WIDTH_MM,
};
Self::from_layout_spec(spec).expect("default board spec must be valid")
}
}
fn validate_layout_spec(spec: &BoardLayoutSpecV4) -> Result<(), BoardLayoutValidationError> {
if spec.name.trim().is_empty() {
return Err(BoardLayoutValidationError::EmptyName);
}
if !spec.pitch_mm.is_finite() || spec.pitch_mm <= 0.0 {
return Err(BoardLayoutValidationError::InvalidPitch {
pitch_mm: spec.pitch_mm,
});
}
if spec.rows == 0 {
return Err(BoardLayoutValidationError::InvalidRows { rows: spec.rows });
}
if spec.long_row_cols == 0 {
return Err(BoardLayoutValidationError::InvalidLongRowCols {
long_row_cols: spec.long_row_cols,
});
}
if spec.rows > 1 && spec.long_row_cols < 2 {
return Err(BoardLayoutValidationError::InvalidLongRowColsForRows {
rows: spec.rows,
long_row_cols: spec.long_row_cols,
});
}
if !spec.marker_outer_radius_mm.is_finite() || spec.marker_outer_radius_mm <= 0.0 {
return Err(BoardLayoutValidationError::InvalidOuterRadius {
marker_outer_radius_mm: spec.marker_outer_radius_mm,
});
}
if !spec.marker_inner_radius_mm.is_finite() || spec.marker_inner_radius_mm <= 0.0 {
return Err(BoardLayoutValidationError::InvalidInnerRadius {
marker_inner_radius_mm: spec.marker_inner_radius_mm,
});
}
if !spec.marker_ring_width_mm.is_finite() || spec.marker_ring_width_mm <= 0.0 {
return Err(BoardLayoutValidationError::InvalidRingWidth {
marker_ring_width_mm: spec.marker_ring_width_mm,
});
}
if spec.marker_inner_radius_mm >= spec.marker_outer_radius_mm {
return Err(BoardLayoutValidationError::InnerRadiusNotSmallerThanOuter {
marker_inner_radius_mm: spec.marker_inner_radius_mm,
marker_outer_radius_mm: spec.marker_outer_radius_mm,
});
}
let ring_half_thickness_mm = marker_ring_half_thickness_mm(spec.marker_ring_width_mm);
let inner_ring_outer_edge_mm = spec.marker_inner_radius_mm + ring_half_thickness_mm;
let outer_ring_inner_edge_mm = spec.marker_outer_radius_mm - ring_half_thickness_mm;
if inner_ring_outer_edge_mm >= outer_ring_inner_edge_mm {
return Err(BoardLayoutValidationError::NonPositiveCodeBandGap {
inner_ring_outer_edge_mm,
outer_ring_inner_edge_mm,
});
}
let min_center_spacing = hex_row_spacing_mm(spec.pitch_mm);
if spec.marker_outer_radius_mm * 2.0 >= min_center_spacing {
return Err(
BoardLayoutValidationError::OuterDiameterExceedsMinCenterSpacing {
marker_outer_diameter_mm: spec.marker_outer_radius_mm * 2.0,
min_center_spacing_mm: min_center_spacing,
},
);
}
let marker_draw_diameter_mm =
2.0 * marker_outer_draw_radius_mm(spec.marker_outer_radius_mm, spec.marker_ring_width_mm);
if marker_draw_diameter_mm >= min_center_spacing {
return Err(
BoardLayoutValidationError::MarkerDrawDiameterExceedsMinCenterSpacing {
marker_draw_diameter_mm,
min_center_spacing_mm: min_center_spacing,
},
);
}
Ok(())
}
fn generate_markers(
rows: usize,
long_row_cols: usize,
pitch_mm: f32,
) -> Result<Vec<BoardMarker>, BoardLayoutValidationError> {
let short_row_cols = long_row_cols.saturating_sub(1);
let mut markers = Vec::new();
let row_mid = (rows as i32) / 2;
for row_idx in 0..rows {
let r = row_idx as i32 - row_mid;
let n_cols = if rows == 1 || ((r + long_row_cols as i32 - 1) & 1) == 0 {
long_row_cols
} else {
short_row_cols
};
if n_cols == 0 {
return Err(BoardLayoutValidationError::DerivedZeroColumns {
row_index: row_idx,
rows,
long_row_cols,
});
}
let q_start = -((r + n_cols as i32 - 1) / 2);
for col_idx in 0..n_cols {
let q = q_start + col_idx as i32;
let xy = hex_axial_to_xy_mm(q, r, pitch_mm);
markers.push(BoardMarker {
id: markers.len(),
xy_mm: xy,
q: i16::try_from(q).ok(),
r: i16::try_from(r).ok(),
});
}
}
normalize_marker_origin(&mut markers);
Ok(markers)
}
fn hex_axial_to_xy_mm(q: i32, r: i32, pitch_mm: f32) -> [f32; 2] {
let qf = q as f64;
let rf = r as f64;
let pitch = pitch_mm as f64;
let x = pitch * (f64::sqrt(3.0) * qf + 0.5 * f64::sqrt(3.0) * rf);
let y = pitch * (1.5 * rf);
[x as f32, y as f32]
}
fn normalize_marker_origin(markers: &mut [BoardMarker]) {
let Some(anchor) = markers.first().map(|m| m.xy_mm) else {
return;
};
for marker in markers {
marker.xy_mm[0] -= anchor[0];
marker.xy_mm[1] -= anchor[1];
}
}
fn hex_row_spacing_mm(pitch_mm: f32) -> f32 {
pitch_mm * f32::sqrt(3.0)
}
pub(crate) fn marker_ring_half_thickness_mm(marker_ring_width_mm: f32) -> f32 {
0.5 * marker_ring_width_mm
}
pub(crate) fn marker_outer_draw_radius_mm(outer_radius_mm: f32, marker_ring_width_mm: f32) -> f32 {
outer_radius_mm + marker_ring_half_thickness_mm(marker_ring_width_mm)
}
pub(crate) fn marker_code_band_bounds_mm(
outer_radius_mm: f32,
inner_radius_mm: f32,
marker_ring_width_mm: f32,
) -> (f32, f32) {
let ring_half_thickness_mm = marker_ring_half_thickness_mm(marker_ring_width_mm);
(
inner_radius_mm + ring_half_thickness_mm,
outer_radius_mm - ring_half_thickness_mm,
)
}
fn generated_name(
pitch_mm: f32,
rows: usize,
long_row_cols: usize,
marker_outer_radius_mm: f32,
marker_inner_radius_mm: f32,
marker_ring_width_mm: f32,
) -> String {
format!(
"ringgrid_hex_r{rows}_c{long_row_cols}_p{pitch_mm:.3}_o{marker_outer_radius_mm:.3}_i{marker_inner_radius_mm:.3}_w{marker_ring_width_mm:.3}"
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
#[cfg(feature = "std")]
fn temp_json_path(prefix: &str) -> std::path::PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time")
.as_nanos();
std::env::temp_dir().join(format!(
"ringgrid_{prefix}_{}_{}.json",
std::process::id(),
nanos
))
}
#[test]
fn default_board_has_expected_shape() {
let board = BoardLayout::default();
assert_eq!(board.rows, 15);
assert_eq!(board.long_row_cols, 14);
assert_eq!(board.n_markers(), 203);
assert_eq!(board.xy_mm(0), Some([0.0, 0.0]));
assert_eq!(board.xy_mm(20), Some([90.06664, 12.0]));
}
#[test]
fn default_board_lookup_stays_consistent() {
let board = BoardLayout::default();
for id in 0..board.n_markers() {
let xy = board.xy_mm(id).expect("valid id");
let marker = board.marker_by_index(id).expect("marker index");
assert_eq!(xy, marker.xy_mm);
}
assert_eq!(board.xy_mm(999), None);
}
#[test]
fn default_board_min_center_spacing_matches_hex_pitch() {
let board = BoardLayout::default();
let expected = board.pitch_mm * f32::sqrt(3.0);
assert!((board.min_center_spacing_mm() - expected).abs() < 1.0e-6);
}
#[test]
fn default_board_anchor_is_top_left_marker() {
let board = BoardLayout::default();
assert_eq!(board.xy_mm(0), Some([0.0, 0.0]));
let anchor = board.marker_by_index(0).expect("marker 0").xy_mm;
let min_y = board
.markers()
.iter()
.map(|m| m.xy_mm[1])
.fold(f32::INFINITY, f32::min);
let min_x_at_min_y = board
.markers()
.iter()
.filter(|m| (m.xy_mm[1] - min_y).abs() < 1e-6)
.map(|m| m.xy_mm[0])
.fold(f32::INFINITY, f32::min);
assert!((anchor[1] - min_y).abs() < 1e-6);
assert!((anchor[0] - min_x_at_min_y).abs() < 1e-6);
}
#[test]
fn from_json_requires_v4_schema() {
let raw = r#"{
"schema":"ringgrid.target.v2",
"name":"x",
"pitch_mm":8.0,
"rows":1,
"long_row_cols":1,
"marker_outer_radius_mm":4.8,
"marker_inner_radius_mm":3.2,
"marker_ring_width_mm":1.152
}"#;
let spec: BoardLayoutSpecV4 = serde_json::from_str(raw).expect("valid json");
let err = BoardLayout::from_layout_spec(spec).expect_err("expected error");
assert!(matches!(
err,
BoardLayoutValidationError::UnsupportedSchema { .. }
));
}
#[test]
fn from_json_rejects_marker_list_field() {
let raw = r#"{
"schema":"ringgrid.target.v4",
"name":"x",
"pitch_mm":8.0,
"rows":1,
"long_row_cols":1,
"marker_outer_radius_mm":4.8,
"marker_inner_radius_mm":3.2,
"marker_ring_width_mm":1.152,
"markers":[{"id":0,"xy_mm":[0.0,0.0]}]
}"#;
let parsed: Result<BoardLayoutSpecV4, _> = serde_json::from_str(raw);
assert!(parsed.is_err());
}
#[test]
fn from_json_rejects_legacy_fields() {
let raw = r#"{
"schema":"ringgrid.target.v4",
"name":"x",
"pitch_mm":8.0,
"rows":3,
"long_row_cols":4,
"origin_mm":[0.0,0.0],
"board_size_mm":[200.0,200.0],
"marker_code_band_outer_radius_mm":4.64,
"marker_code_band_inner_radius_mm":3.36,
"marker_outer_radius_mm":4.8,
"marker_inner_radius_mm":3.2,
"marker_ring_width_mm":1.152
}"#;
let parsed: Result<BoardLayoutSpecV4, _> = serde_json::from_str(raw);
assert!(parsed.is_err());
}
#[test]
fn marker_span_is_positive() {
let board = BoardLayout::default();
let span = board.marker_span_mm().expect("span");
assert!(span[0] > 0.0);
assert!(span[1] > 0.0);
}
#[cfg(feature = "std")]
#[test]
fn from_json_file_maps_io_error_to_typed_variant() {
let missing = temp_json_path("missing_board");
let err = BoardLayout::from_json_file(&missing).expect_err("expected io error");
assert!(matches!(err, BoardLayoutLoadError::Io(_)));
}
#[cfg(feature = "std")]
#[test]
fn from_json_file_maps_parse_error_to_typed_variant() {
let path = temp_json_path("bad_json");
std::fs::write(&path, "{ this is not valid json").expect("write temp json");
let err = BoardLayout::from_json_file(&path).expect_err("expected parse error");
assert!(matches!(err, BoardLayoutLoadError::JsonParse(_)));
let _ = std::fs::remove_file(path);
}
#[cfg(feature = "std")]
#[test]
fn from_json_file_maps_validation_error_to_typed_variant() {
let path = temp_json_path("bad_schema");
let raw = r#"{
"schema":"ringgrid.target.v2",
"name":"x",
"pitch_mm":8.0,
"rows":1,
"long_row_cols":1,
"marker_outer_radius_mm":4.8,
"marker_inner_radius_mm":3.2,
"marker_ring_width_mm":1.152
}"#;
std::fs::write(&path, raw).expect("write temp json");
let err = BoardLayout::from_json_file(&path).expect_err("expected validation error");
assert!(matches!(
err,
BoardLayoutLoadError::Validation(BoardLayoutValidationError::UnsupportedSchema { .. })
));
let _ = std::fs::remove_file(path);
}
#[test]
fn from_json_str_loads_valid_spec() {
let raw = r#"{
"schema":"ringgrid.target.v4",
"name":"x",
"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 board = BoardLayout::from_json_str(raw).expect("valid board json");
assert_eq!(board.name, "x");
assert_eq!(board.rows, 3);
assert_eq!(board.long_row_cols, 4);
assert!(board.n_markers() > 0);
}
#[test]
fn direct_constructor_matches_round_trip_json() {
let board = BoardLayout::with_name("fixture_compact_hex", 8.0, 3, 4, 4.8, 3.2, 1.152)
.expect("valid direct geometry");
let json = board.to_json_string();
let reloaded = BoardLayout::from_json_str(&json).expect("round-trip json");
assert_eq!(reloaded.name, "fixture_compact_hex");
assert_eq!(reloaded.rows, 3);
assert_eq!(reloaded.long_row_cols, 4);
assert_eq!(reloaded.marker_outer_radius_mm, 4.8);
assert_eq!(reloaded.marker_inner_radius_mm, 3.2);
assert!((reloaded.marker_ring_width_mm - 1.152).abs() < 1e-6);
assert_eq!(reloaded.markers().len(), board.markers().len());
assert_eq!(reloaded.xy_mm(0), Some([0.0, 0.0]));
}
#[test]
fn direct_constructor_uses_deterministic_default_name() {
let board = BoardLayout::new(8.0, 3, 4, 4.8, 3.2, 1.152).expect("valid direct geometry");
assert_eq!(board.name, "ringgrid_hex_r3_c4_p8.000_o4.800_i3.200_w1.152");
}
#[cfg(feature = "std")]
#[test]
fn write_json_file_creates_parent_dirs_and_round_trips() {
let path = temp_json_path("round_trip");
let nested = path.with_file_name("nested").join("board.json");
let board = BoardLayout::with_name("fixture_compact_hex", 8.0, 3, 4, 4.8, 3.2, 1.152)
.expect("valid direct geometry");
board
.write_json_file(&nested)
.expect("write nested board json");
let loaded = BoardLayout::from_json_file(&nested).expect("load nested board json");
assert_eq!(loaded.name, board.name);
assert_eq!(loaded.markers().len(), board.markers().len());
let _ = std::fs::remove_file(&nested);
let _ = std::fs::remove_dir(nested.parent().expect("nested parent"));
}
#[test]
fn direct_constructor_reuses_layout_validation() {
assert!(matches!(
BoardLayout::new(8.0, 0, 4, 4.8, 3.2, 1.152),
Err(BoardLayoutValidationError::InvalidRows { rows: 0 })
));
assert!(matches!(
BoardLayout::new(8.0, 3, 1, 4.8, 3.2, 1.152),
Err(BoardLayoutValidationError::InvalidLongRowColsForRows {
rows: 3,
long_row_cols: 1,
})
));
assert!(matches!(
BoardLayout::new(8.0, 3, 4, 4.8, 4.8, 1.152),
Err(BoardLayoutValidationError::InnerRadiusNotSmallerThanOuter {
marker_inner_radius_mm: 4.8,
marker_outer_radius_mm: 4.8,
})
));
assert!(matches!(
BoardLayout::new(8.0, 3, 4, 4.8, 4.1, 1.152),
Err(BoardLayoutValidationError::NonPositiveCodeBandGap { .. })
));
assert!(matches!(
BoardLayout::new(f32::NAN, 3, 4, 4.8, 3.2, 1.152),
Err(BoardLayoutValidationError::InvalidPitch { .. })
));
assert!(matches!(
BoardLayout::new(5.0, 3, 4, 4.0, 2.0, 1.152),
Err(BoardLayoutValidationError::MarkerDrawDiameterExceedsMinCenterSpacing { .. })
));
assert!(matches!(
BoardLayout::new(8.0, 3, 4, 4.8, 3.2, 0.0),
Err(BoardLayoutValidationError::InvalidRingWidth { .. })
));
}
}