1mod 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#[non_exhaustive]
55#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
56#[serde(tag = "kind", rename_all = "snake_case")]
57pub enum TargetSpec {
58 Chessboard(ChessboardTargetSpec),
60 Charuco(CharucoTargetSpec),
62 MarkerBoard(MarkerBoardTargetSpec),
64 PuzzleBoard(PuzzleBoardTargetSpec),
66}
67
68impl TargetSpec {
69 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 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 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 for j in 0..inner_rows {
182 for i in 0..inner_cols {
183 let master_i = spec.origin_col + i + 1; 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#[non_exhaustive]
204#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
205pub struct PrintableTargetDocument {
206 #[serde(default = "default_schema_version")]
208 pub schema_version: u32,
209 pub target: TargetSpec,
211 #[serde(default = "default_page_spec")]
213 pub page: PageSpec,
214 #[serde(default = "default_render_options")]
216 pub render: RenderOptions,
217}
218
219impl PrintableTargetDocument {
220 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 #[must_use]
232 pub fn with_page(mut self, page: PageSpec) -> Self {
233 self.page = page;
234 self
235 }
236
237 #[must_use]
239 pub fn with_render(mut self, render: RenderOptions) -> Self {
240 self.render = render;
241 self
242 }
243
244 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 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 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 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 pub fn to_json_pretty(&self) -> Result<String, PrintableTargetError> {
279 self.validate()?;
280 Ok(serde_json::to_string_pretty(self)?)
281 }
282
283 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 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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
332pub struct ResolvedTargetPoint {
333 pub position_mm: [f64; 2],
335 #[serde(default)]
337 pub grid: Option<Coord>,
338 #[serde(default)]
340 pub id: Option<u32>,
341}
342
343#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
345pub struct ResolvedTargetLayout {
346 pub page_width_mm: f64,
348 pub page_height_mm: f64,
350 pub board_origin_mm: [f64; 2],
352 pub board_width_mm: f64,
354 pub board_height_mm: f64,
356 pub points: Vec<ResolvedTargetPoint>,
358}
359
360#[non_exhaustive]
368#[derive(Clone, Debug, PartialEq, Eq)]
369pub struct StemPaths {
370 pub json: PathBuf,
372 pub svg: PathBuf,
374 pub png: PathBuf,
376 pub dxf: PathBuf,
378}
379
380impl StemPaths {
381 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
394pub 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}