Skip to main content

calib_targets_print/model/
mod.rs

1//! Printable target document model.
2//!
3//! Sub-modules split by concern:
4//! - `error`: [`PrintableTargetError`]
5//! - `page`: [`PageSize`], [`PageOrientation`], [`PageSpec`], [`RenderOptions`]
6//! - `chessboard`: [`ChessboardTargetSpec`]
7//! - `charuco`: [`CharucoTargetSpec`]
8//! - `marker`: [`MarkerCircleSpec`], [`MarkerBoardTargetSpec`]
9//! - `puzzleboard`: [`PuzzleBoardTargetSpec`]
10
11mod charuco;
12mod chessboard;
13mod error;
14mod marker;
15mod page;
16mod puzzleboard;
17
18pub use charuco::CharucoTargetSpec;
19pub use chessboard::ChessboardTargetSpec;
20pub use error::PrintableTargetError;
21pub use marker::{MarkerBoardTargetSpec, MarkerCircleSpec};
22pub use page::{PageOrientation, PageSize, PageSpec, RenderOptions};
23pub use puzzleboard::PuzzleBoardTargetSpec;
24
25pub(crate) use charuco::validate_charuco_spec;
26pub(crate) use chessboard::validate_inner_corner_grid;
27pub(crate) use marker::validate_marker_board_spec;
28pub(crate) use puzzleboard::validate_puzzleboard_spec;
29
30use error::SCHEMA_VERSION_V1;
31
32use calib_targets_core::Coord;
33use calib_targets_marker::MarkerBoardSpec;
34use calib_targets_puzzleboard::MASTER_COLS;
35use serde::{Deserialize, Serialize};
36use std::{
37    fs,
38    path::{Path, PathBuf},
39};
40
41fn default_schema_version() -> u32 {
42    SCHEMA_VERSION_V1
43}
44
45fn default_page_spec() -> PageSpec {
46    PageSpec::default()
47}
48
49fn default_render_options() -> RenderOptions {
50    RenderOptions::default()
51}
52
53/// Union of all printable target geometries.
54#[non_exhaustive]
55#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
56#[serde(tag = "kind", rename_all = "snake_case")]
57pub enum TargetSpec {
58    /// A plain chessboard target.
59    Chessboard(ChessboardTargetSpec),
60    /// A ChArUco board target.
61    Charuco(CharucoTargetSpec),
62    /// A checkerboard marker board target.
63    MarkerBoard(MarkerBoardTargetSpec),
64    /// A PuzzleBoard self-identifying target.
65    PuzzleBoard(PuzzleBoardTargetSpec),
66}
67
68impl TargetSpec {
69    /// Short name for the target kind, suitable for filenames and JSON.
70    pub fn kind_name(&self) -> &'static str {
71        match self {
72            Self::Chessboard(_) => "chessboard",
73            Self::Charuco(_) => "charuco",
74            Self::MarkerBoard(_) => "marker_board",
75            Self::PuzzleBoard(_) => "puzzleboard",
76        }
77    }
78
79    /// Returns `(width_mm, height_mm)` of the rendered board area.
80    pub fn board_size_mm(&self) -> Result<(f64, f64), PrintableTargetError> {
81        match self {
82            Self::Chessboard(spec) => {
83                validate_inner_corner_grid(spec.inner_rows, spec.inner_cols, spec.square_size_mm)?;
84                Ok((
85                    (spec.inner_cols as f64 + 1.0) * spec.square_size_mm,
86                    (spec.inner_rows as f64 + 1.0) * spec.square_size_mm,
87                ))
88            }
89            Self::Charuco(spec) => {
90                validate_charuco_spec(spec)?;
91                Ok((
92                    spec.cols as f64 * spec.square_size_mm,
93                    spec.rows as f64 * spec.square_size_mm,
94                ))
95            }
96            Self::MarkerBoard(spec) => {
97                validate_marker_board_spec(spec)?;
98                Ok((
99                    (spec.inner_cols as f64 + 1.0) * spec.square_size_mm,
100                    (spec.inner_rows as f64 + 1.0) * spec.square_size_mm,
101                ))
102            }
103            Self::PuzzleBoard(spec) => {
104                validate_puzzleboard_spec(spec)?;
105                Ok((
106                    spec.cols as f64 * spec.square_size_mm,
107                    spec.rows as f64 * spec.square_size_mm,
108                ))
109            }
110        }
111    }
112
113    /// Compute board-space coordinates for every output point.
114    pub fn resolved_points(&self) -> Result<Vec<ResolvedTargetPoint>, PrintableTargetError> {
115        match self {
116            Self::Chessboard(spec) => {
117                validate_inner_corner_grid(spec.inner_rows, spec.inner_cols, spec.square_size_mm)?;
118                let mut points =
119                    Vec::with_capacity(spec.inner_rows as usize * spec.inner_cols as usize);
120                for j in 0..spec.inner_rows {
121                    for i in 0..spec.inner_cols {
122                        points.push(ResolvedTargetPoint {
123                            position_mm: [
124                                (i as f64 + 1.0) * spec.square_size_mm,
125                                (j as f64 + 1.0) * spec.square_size_mm,
126                            ],
127                            grid: Some(Coord::new(i as i32, j as i32)),
128                            id: None,
129                        });
130                    }
131                }
132                Ok(points)
133            }
134            Self::Charuco(spec) => {
135                validate_charuco_spec(spec)?;
136                let mut points = Vec::with_capacity(
137                    (spec.rows.saturating_sub(1) * spec.cols.saturating_sub(1)) as usize,
138                );
139                let inner_rows = spec.rows - 1;
140                let inner_cols = spec.cols - 1;
141                for j in 0..inner_rows {
142                    for i in 0..inner_cols {
143                        let id = j * inner_cols + i;
144                        points.push(ResolvedTargetPoint {
145                            position_mm: [
146                                (i as f64 + 1.0) * spec.square_size_mm,
147                                (j as f64 + 1.0) * spec.square_size_mm,
148                            ],
149                            grid: Some(Coord::new(i as i32, j as i32)),
150                            id: Some(id),
151                        });
152                    }
153                }
154                Ok(points)
155            }
156            Self::MarkerBoard(spec) => {
157                validate_marker_board_spec(spec)?;
158                let mut points =
159                    Vec::with_capacity(spec.inner_rows as usize * spec.inner_cols as usize);
160                for j in 0..spec.inner_rows {
161                    for i in 0..spec.inner_cols {
162                        points.push(ResolvedTargetPoint {
163                            position_mm: [
164                                (i as f64 + 1.0) * spec.square_size_mm,
165                                (j as f64 + 1.0) * spec.square_size_mm,
166                            ],
167                            grid: Some(Coord::new(i as i32, j as i32)),
168                            id: None,
169                        });
170                    }
171                }
172                Ok(points)
173            }
174            Self::PuzzleBoard(spec) => {
175                validate_puzzleboard_spec(spec)?;
176                let inner_rows = spec.rows.saturating_sub(1);
177                let inner_cols = spec.cols.saturating_sub(1);
178                let mut points = Vec::with_capacity((inner_rows * inner_cols) as usize);
179                // Each corner's id is `I * MASTER_COLS + J` where (I, J) are
180                // master-absolute coords. (I, J) = local (i, j) + origin.
181                for j in 0..inner_rows {
182                    for i in 0..inner_cols {
183                        let master_i = spec.origin_col + i + 1; // inner corner → (origin_col + 1 + i)
184                        let master_j = spec.origin_row + j + 1;
185                        let id = master_j * MASTER_COLS + master_i;
186                        points.push(ResolvedTargetPoint {
187                            position_mm: [
188                                (i as f64 + 1.0) * spec.square_size_mm,
189                                (j as f64 + 1.0) * spec.square_size_mm,
190                            ],
191                            grid: Some(Coord::new(master_i as i32, master_j as i32)),
192                            id: Some(id),
193                        });
194                    }
195                }
196                Ok(points)
197            }
198        }
199    }
200}
201
202/// Top-level printable target document (the JSON file on disk).
203#[non_exhaustive]
204#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
205pub struct PrintableTargetDocument {
206    /// JSON schema version of this document.
207    #[serde(default = "default_schema_version")]
208    pub schema_version: u32,
209    /// The target geometry to render.
210    pub target: TargetSpec,
211    /// Page size and orientation the target is placed on.
212    #[serde(default = "default_page_spec")]
213    pub page: PageSpec,
214    /// Rendering options (margins, labels, etc.).
215    #[serde(default = "default_render_options")]
216    pub render: RenderOptions,
217}
218
219impl PrintableTargetDocument {
220    /// Create a new document with default page and render settings.
221    pub fn new(target: TargetSpec) -> Self {
222        Self {
223            schema_version: default_schema_version(),
224            target,
225            page: PageSpec::default(),
226            render: RenderOptions::default(),
227        }
228    }
229
230    /// Override the page size / orientation / margin.
231    #[must_use]
232    pub fn with_page(mut self, page: PageSpec) -> Self {
233        self.page = page;
234        self
235    }
236
237    /// Override the rasterization / annotation options.
238    #[must_use]
239    pub fn with_render(mut self, render: RenderOptions) -> Self {
240        self.render = render;
241        self
242    }
243
244    /// Build a printable document from a ChArUco board spec whose `cell_size`
245    /// is already expressed in millimeters.
246    pub fn from_charuco_board_spec_mm(board: &calib_targets_charuco::CharucoBoardSpec) -> Self {
247        Self::new(TargetSpec::Charuco(CharucoTargetSpec::from_board_spec_mm(
248            board,
249        )))
250    }
251
252    /// Build a printable document from a marker-board layout whose `cell_size`
253    /// is already expressed in millimeters.
254    pub fn try_from_marker_board_layout_mm(
255        layout: &MarkerBoardSpec,
256    ) -> Result<Self, PrintableTargetError> {
257        Ok(Self::new(TargetSpec::MarkerBoard(
258            MarkerBoardTargetSpec::try_from_layout_mm(layout)?,
259        )))
260    }
261
262    /// Load and validate a document from a JSON file.
263    pub fn load_json(path: impl AsRef<Path>) -> Result<Self, PrintableTargetError> {
264        let raw = fs::read_to_string(path)?;
265        let doc: Self = serde_json::from_str(&raw)?;
266        doc.validate()?;
267        Ok(doc)
268    }
269
270    /// Validate and write to a JSON file.
271    pub fn write_json(&self, path: impl AsRef<Path>) -> Result<(), PrintableTargetError> {
272        self.validate()?;
273        fs::write(path, self.to_json_pretty()?)?;
274        Ok(())
275    }
276
277    /// Serialize to pretty-printed JSON.
278    pub fn to_json_pretty(&self) -> Result<String, PrintableTargetError> {
279        self.validate()?;
280        Ok(serde_json::to_string_pretty(self)?)
281    }
282
283    /// Validate schema version, page dimensions, and target geometry.
284    pub fn validate(&self) -> Result<(), PrintableTargetError> {
285        if self.schema_version != SCHEMA_VERSION_V1 {
286            return Err(PrintableTargetError::UnsupportedSchemaVersion(
287                self.schema_version,
288            ));
289        }
290        let _ = self.page.printable_dimensions_mm()?;
291        if self.render.png_dpi == 0 {
292            return Err(PrintableTargetError::InvalidPngDpi);
293        }
294        let (board_width_mm, board_height_mm) = self.target.board_size_mm()?;
295        let (printable_width_mm, printable_height_mm) = self.page.printable_dimensions_mm()?;
296        if board_width_mm > printable_width_mm || board_height_mm > printable_height_mm {
297            return Err(PrintableTargetError::BoardDoesNotFit {
298                board_width_mm,
299                board_height_mm,
300                printable_width_mm,
301                printable_height_mm,
302            });
303        }
304        let _ = self.target.resolved_points()?;
305        Ok(())
306    }
307
308    /// Compute the full page + board layout for rendering.
309    pub fn resolve_layout(&self) -> Result<ResolvedTargetLayout, PrintableTargetError> {
310        self.validate()?;
311        let (page_width_mm, page_height_mm) = self.page.dimensions_mm()?;
312        let (board_width_mm, board_height_mm) = self.target.board_size_mm()?;
313        let printable_width_mm = page_width_mm - 2.0 * self.page.margin_mm;
314        let printable_height_mm = page_height_mm - 2.0 * self.page.margin_mm;
315        let board_origin_mm = [
316            self.page.margin_mm + 0.5 * (printable_width_mm - board_width_mm),
317            self.page.margin_mm + 0.5 * (printable_height_mm - board_height_mm),
318        ];
319        Ok(ResolvedTargetLayout {
320            page_width_mm,
321            page_height_mm,
322            board_origin_mm,
323            board_width_mm,
324            board_height_mm,
325            points: self.target.resolved_points()?,
326        })
327    }
328}
329
330/// A single output point in board-space coordinates.
331#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
332pub struct ResolvedTargetPoint {
333    /// Point position `[x, y]` in board-space millimeters.
334    pub position_mm: [f64; 2],
335    /// Integer `(i, j)` grid label of the point, when it has one.
336    #[serde(default)]
337    pub grid: Option<Coord>,
338    /// Logical ID of the point (e.g. ChArUco corner ID), when it has one.
339    #[serde(default)]
340    pub id: Option<u32>,
341}
342
343/// Full resolved layout for a single printable target document.
344#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
345pub struct ResolvedTargetLayout {
346    /// Page width in millimeters.
347    pub page_width_mm: f64,
348    /// Page height in millimeters.
349    pub page_height_mm: f64,
350    /// Board top-left origin `[x, y]` on the page, in millimeters.
351    pub board_origin_mm: [f64; 2],
352    /// Board width in millimeters.
353    pub board_width_mm: f64,
354    /// Board height in millimeters.
355    pub board_height_mm: f64,
356    /// Every resolved board point in board-space coordinates.
357    pub points: Vec<ResolvedTargetPoint>,
358}
359
360/// Output file paths derived from a single stem.
361///
362/// Returned by [`StemPaths::from_stem`] (and the legacy [`stem_paths`]
363/// shim). `#[non_exhaustive]` so that adding new bundled formats — DXF
364/// today, potentially PDF or Gerber tomorrow — is not a breaking
365/// change for cross-crate consumers, who must construct instances
366/// through the named constructor.
367#[non_exhaustive]
368#[derive(Clone, Debug, PartialEq, Eq)]
369pub struct StemPaths {
370    /// `<stem>.json` — printable target description.
371    pub json: PathBuf,
372    /// `<stem>.svg` — vector rendering.
373    pub svg: PathBuf,
374    /// `<stem>.png` — raster rendering.
375    pub png: PathBuf,
376    /// `<stem>.dxf` — chrome-on-glass photolithography handoff (pattern only).
377    pub dxf: PathBuf,
378}
379
380impl StemPaths {
381    /// Compute the four output paths from a single filename stem by
382    /// appending the format-specific extensions.
383    pub fn from_stem(output_stem: impl AsRef<Path>) -> Self {
384        let stem = output_stem.as_ref();
385        Self {
386            json: stem.with_extension("json"),
387            svg: stem.with_extension("svg"),
388            png: stem.with_extension("png"),
389            dxf: stem.with_extension("dxf"),
390        }
391    }
392}
393
394/// Free-function alias for [`StemPaths::from_stem`].
395///
396/// Returns the four output paths derived from a single stem
397/// (`<stem>.json`, `.svg`, `.png`, `.dxf`) as a [`StemPaths`] struct.
398///
399/// **0.9.0 breaking change:** prior versions of this function
400/// returned a `(PathBuf, PathBuf, PathBuf)` tuple covering only the
401/// JSON / SVG / PNG outputs. With the addition of DXF as a fourth
402/// printable-target output, the return type is now the
403/// `#[non_exhaustive]` [`StemPaths`] struct so future formats can be
404/// added without further breaking changes. Callers that destructured
405/// the tuple should switch to named-field access on the returned
406/// struct (`let paths = stem_paths(...); paths.json` / `.svg` /
407/// `.png` / `.dxf`).
408pub fn stem_paths(output_stem: impl AsRef<Path>) -> StemPaths {
409    StemPaths::from_stem(output_stem)
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415    use calib_targets_aruco::builtins;
416    use calib_targets_charuco::MarkerLayout;
417    use calib_targets_marker::CirclePolarity;
418    use calib_targets_marker::{CellCoords, MarkerCircleSpec as DetectorMarkerCircleSpec};
419
420    fn sample_chessboard() -> PrintableTargetDocument {
421        PrintableTargetDocument::new(TargetSpec::Chessboard(ChessboardTargetSpec {
422            inner_rows: 6,
423            inner_cols: 8,
424            square_size_mm: 20.0,
425        }))
426    }
427
428    fn sample_charuco() -> PrintableTargetDocument {
429        PrintableTargetDocument::new(TargetSpec::Charuco(CharucoTargetSpec {
430            rows: 5,
431            cols: 7,
432            square_size_mm: 15.0,
433            marker_size_rel: 0.75,
434            dictionary: builtins::builtin_dictionary("DICT_4X4_50").expect("dict"),
435            marker_layout: MarkerLayout::OpenCvCharuco,
436            border_bits: 1,
437        }))
438    }
439
440    fn sample_marker_board() -> PrintableTargetDocument {
441        PrintableTargetDocument::new(TargetSpec::MarkerBoard(MarkerBoardTargetSpec {
442            inner_rows: 6,
443            inner_cols: 8,
444            square_size_mm: 20.0,
445            circles: MarkerBoardTargetSpec::default_circles(6, 8),
446            circle_diameter_rel: 0.5,
447        }))
448    }
449
450    #[test]
451    fn resolves_chessboard_points() {
452        let doc = sample_chessboard();
453        let layout = doc.resolve_layout().expect("layout");
454        assert_eq!(layout.points.len(), 48);
455        assert_eq!(layout.points[0].position_mm, [20.0, 20.0]);
456        assert_eq!(layout.board_width_mm, 180.0);
457        assert_eq!(layout.board_height_mm, 140.0);
458    }
459
460    #[test]
461    fn resolves_charuco_points() {
462        let doc = sample_charuco();
463        let layout = doc.resolve_layout().expect("layout");
464        assert_eq!(layout.points.len(), 24);
465        assert_eq!(layout.points[0].id, Some(0));
466        assert_eq!(layout.points[0].grid, Some(Coord::new(0, 0)));
467    }
468
469    #[test]
470    fn rejects_board_that_does_not_fit_page() {
471        let mut doc = sample_chessboard();
472        doc.page.size = PageSize::Custom {
473            width_mm: 50.0,
474            height_mm: 50.0,
475        };
476        let err = doc.validate().expect_err("fit check");
477        assert!(matches!(err, PrintableTargetError::BoardDoesNotFit { .. }));
478    }
479
480    #[test]
481    fn rejects_duplicate_marker_circles() {
482        let mut doc = sample_marker_board();
483        if let TargetSpec::MarkerBoard(spec) = &mut doc.target {
484            spec.circles = [
485                MarkerCircleSpec {
486                    i: 1,
487                    j: 1,
488                    polarity: CirclePolarity::White,
489                },
490                MarkerCircleSpec {
491                    i: 1,
492                    j: 1,
493                    polarity: CirclePolarity::Black,
494                },
495                MarkerCircleSpec {
496                    i: 2,
497                    j: 2,
498                    polarity: CirclePolarity::White,
499                },
500            ];
501        }
502        let err = doc.validate().expect_err("duplicate circles");
503        assert!(matches!(err, PrintableTargetError::DuplicateCircleCells));
504    }
505
506    #[test]
507    fn json_roundtrip_is_stable() {
508        let doc = sample_charuco();
509        let json = doc.to_json_pretty().expect("json");
510        let parsed: PrintableTargetDocument = serde_json::from_str(&json).expect("parse");
511        assert_eq!(parsed, doc);
512    }
513
514    #[test]
515    fn builds_charuco_spec_from_board_spec_mm() {
516        use calib_targets_charuco::CharucoBoardSpec;
517        let board = CharucoBoardSpec::new(
518            5,
519            7,
520            20.0,
521            0.75,
522            builtins::builtin_dictionary("DICT_4X4_50").expect("dict"),
523        )
524        .with_marker_layout(MarkerLayout::OpenCvCharuco);
525        let spec = CharucoTargetSpec::from_board_spec_mm(&board);
526        assert_eq!(spec.rows, board.rows);
527        assert_eq!(spec.cols, board.cols);
528        assert_eq!(spec.square_size_mm, 20.0);
529        assert_eq!(spec.marker_size_rel, 0.75);
530        assert_eq!(spec.dictionary.name(), board.dictionary.name());
531        assert_eq!(spec.marker_layout, board.marker_layout);
532        assert_eq!(spec.border_bits, 1);
533    }
534
535    #[test]
536    fn builds_charuco_document_from_board_spec_mm() {
537        use calib_targets_charuco::CharucoBoardSpec;
538        let board = CharucoBoardSpec::new(
539            5,
540            7,
541            20.0,
542            0.75,
543            builtins::builtin_dictionary("DICT_4X4_50").expect("dict"),
544        )
545        .with_marker_layout(MarkerLayout::OpenCvCharuco);
546        let doc = PrintableTargetDocument::from_charuco_board_spec_mm(&board);
547        assert!(matches!(
548            &doc.target,
549            TargetSpec::Charuco(spec)
550                if spec.rows == 5
551                    && spec.cols == 7
552                    && spec.square_size_mm == 20.0
553                    && spec.marker_size_rel == 0.75
554                    && spec.border_bits == 1
555        ));
556        doc.validate().expect("valid printable charuco");
557    }
558
559    #[test]
560    fn builds_marker_board_spec_from_layout_mm() {
561        let layout = MarkerBoardSpec::new(
562            6,
563            8,
564            [
565                DetectorMarkerCircleSpec::new(CellCoords { i: 3, j: 2 }, CirclePolarity::White),
566                DetectorMarkerCircleSpec::new(CellCoords { i: 4, j: 2 }, CirclePolarity::Black),
567                DetectorMarkerCircleSpec::new(CellCoords { i: 4, j: 3 }, CirclePolarity::White),
568            ],
569        )
570        .with_cell_size(20.0);
571        let spec = MarkerBoardTargetSpec::try_from_layout_mm(&layout).expect("marker board spec");
572        assert_eq!(spec.inner_rows, 6);
573        assert_eq!(spec.inner_cols, 8);
574        assert_eq!(spec.square_size_mm, 20.0);
575        assert_eq!(
576            spec.circles,
577            [
578                MarkerCircleSpec {
579                    i: 3,
580                    j: 2,
581                    polarity: CirclePolarity::White,
582                },
583                MarkerCircleSpec {
584                    i: 4,
585                    j: 2,
586                    polarity: CirclePolarity::Black,
587                },
588                MarkerCircleSpec {
589                    i: 4,
590                    j: 3,
591                    polarity: CirclePolarity::White,
592                },
593            ]
594        );
595        assert_eq!(spec.circle_diameter_rel, 0.5);
596    }
597
598    #[test]
599    fn builds_marker_board_document_from_layout_mm() {
600        let layout = MarkerBoardSpec::new(
601            6,
602            8,
603            [
604                DetectorMarkerCircleSpec::new(CellCoords { i: 3, j: 2 }, CirclePolarity::White),
605                DetectorMarkerCircleSpec::new(CellCoords { i: 4, j: 2 }, CirclePolarity::Black),
606                DetectorMarkerCircleSpec::new(CellCoords { i: 4, j: 3 }, CirclePolarity::White),
607            ],
608        )
609        .with_cell_size(20.0);
610        let doc = PrintableTargetDocument::try_from_marker_board_layout_mm(&layout)
611            .expect("marker board doc");
612        assert!(matches!(
613            &doc.target,
614            TargetSpec::MarkerBoard(spec)
615                if spec.inner_rows == 6
616                    && spec.inner_cols == 8
617                    && spec.square_size_mm == 20.0
618                    && spec.circle_diameter_rel == 0.5
619        ));
620        doc.validate().expect("valid printable marker board");
621    }
622
623    #[test]
624    fn rejects_marker_board_layout_without_cell_size() {
625        let layout = MarkerBoardSpec::new(6, 8, MarkerBoardSpec::default().circles);
626        let err =
627            MarkerBoardTargetSpec::try_from_layout_mm(&layout).expect_err("missing cell size");
628        assert!(matches!(
629            err,
630            PrintableTargetError::MissingMarkerBoardCellSize
631        ));
632    }
633
634    #[test]
635    fn rejects_negative_detector_circle_coords() {
636        let layout = MarkerBoardSpec::new(
637            6,
638            8,
639            [
640                DetectorMarkerCircleSpec::new(CellCoords { i: -1, j: 2 }, CirclePolarity::White),
641                DetectorMarkerCircleSpec::new(CellCoords { i: 4, j: 2 }, CirclePolarity::Black),
642                DetectorMarkerCircleSpec::new(CellCoords { i: 4, j: 3 }, CirclePolarity::White),
643            ],
644        )
645        .with_cell_size(20.0);
646        let err =
647            MarkerBoardTargetSpec::try_from_layout_mm(&layout).expect_err("negative detector cell");
648        assert!(matches!(err, PrintableTargetError::InvalidCircleCell));
649    }
650}