1use crate::model::{
2 validate_charuco_spec, validate_inner_corner_grid, validate_marker_board_spec,
3 validate_puzzleboard_spec, CharucoTargetSpec, MarkerBoardTargetSpec, PrintableTargetDocument,
4 PrintableTargetError, PuzzleBoardTargetSpec, RenderOptions, ResolvedTargetLayout, TargetSpec,
5};
6use calib_targets_charuco::CharucoBoard;
7use calib_targets_marker::CirclePolarity;
8use calib_targets_puzzleboard::code_maps;
9use png::{BitDepth, ColorType, Encoder, PixelDimensions, Unit};
10
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12pub(crate) enum Fill {
13 White,
14 Black,
15 Accent,
16 Guide,
17}
18
19impl Fill {
20 fn gray(self) -> u8 {
21 match self {
22 Self::White => 255,
23 Self::Black => 0,
24 Self::Accent => 96,
25 Self::Guide => 180,
26 }
27 }
28
29 fn svg(self) -> &'static str {
30 match self {
31 Self::White => "#ffffff",
32 Self::Black => "#000000",
33 Self::Accent => "#d22f27",
34 Self::Guide => "#4a90e2",
35 }
36 }
37}
38
39#[derive(Clone, Debug)]
40pub(crate) enum Primitive {
41 Rect {
42 x_mm: f64,
43 y_mm: f64,
44 width_mm: f64,
45 height_mm: f64,
46 fill: Fill,
47 },
48 Circle {
49 cx_mm: f64,
50 cy_mm: f64,
51 radius_mm: f64,
52 fill: Fill,
53 },
54}
55
56#[derive(Clone, Debug)]
57pub(crate) struct Scene {
58 pub(crate) width_mm: f64,
59 pub(crate) height_mm: f64,
60 pub(crate) primitives: Vec<Primitive>,
61}
62
63impl Scene {
64 pub(crate) fn new(width_mm: f64, height_mm: f64) -> Self {
65 Self {
66 width_mm,
67 height_mm,
68 primitives: Vec::new(),
69 }
70 }
71}
72
73#[non_exhaustive]
81#[derive(Clone, Debug)]
82pub struct GeneratedTargetBundle {
83 pub json_text: String,
85 pub svg_text: String,
87 pub png_bytes: Vec<u8>,
89 pub dxf_text: String,
93}
94
95impl GeneratedTargetBundle {
96 pub fn new(json_text: String, svg_text: String, png_bytes: Vec<u8>, dxf_text: String) -> Self {
98 Self {
99 json_text,
100 svg_text,
101 png_bytes,
102 dxf_text,
103 }
104 }
105}
106
107pub fn render_target_bundle(
110 document: &PrintableTargetDocument,
111) -> Result<GeneratedTargetBundle, PrintableTargetError> {
112 let layout = document.resolve_layout()?;
113 let mut scene = Scene::new(layout.page_width_mm, layout.page_height_mm);
114 scene.primitives.push(Primitive::Rect {
115 x_mm: 0.0,
116 y_mm: 0.0,
117 width_mm: layout.page_width_mm,
118 height_mm: layout.page_height_mm,
119 fill: Fill::White,
120 });
121 build_board_scene(&mut scene, document, &layout)?;
122 let dxf_text = crate::render_dxf::render_dxf(&scene);
126 if document.render.debug_annotations {
127 add_debug_primitives(&mut scene, document, &layout);
128 }
129 Ok(GeneratedTargetBundle::new(
130 document.to_json_pretty()?,
131 render_svg(&scene),
132 render_png(&scene, &document.render)?,
133 dxf_text,
134 ))
135}
136
137fn build_board_scene(
138 scene: &mut Scene,
139 document: &PrintableTargetDocument,
140 layout: &ResolvedTargetLayout,
141) -> Result<(), PrintableTargetError> {
142 match &document.target {
143 TargetSpec::Chessboard(spec) => build_chessboard(scene, spec, layout),
144 TargetSpec::Charuco(spec) => build_charuco(scene, spec, layout),
145 TargetSpec::MarkerBoard(spec) => build_marker_board(scene, spec, layout),
146 TargetSpec::PuzzleBoard(spec) => build_puzzleboard(scene, spec, layout),
147 }
148}
149
150fn build_chessboard(
151 scene: &mut Scene,
152 spec: &crate::model::ChessboardTargetSpec,
153 layout: &ResolvedTargetLayout,
154) -> Result<(), PrintableTargetError> {
155 validate_inner_corner_grid(spec.inner_rows, spec.inner_cols, spec.square_size_mm)?;
156 let squares_x = spec.inner_cols + 1;
157 let squares_y = spec.inner_rows + 1;
158 for sy in 0..squares_y {
159 for sx in 0..squares_x {
160 let fill = if (sx + sy) % 2 == 0 {
161 Fill::Black
162 } else {
163 Fill::White
164 };
165 scene.primitives.push(Primitive::Rect {
166 x_mm: layout.board_origin_mm[0] + sx as f64 * spec.square_size_mm,
167 y_mm: layout.board_origin_mm[1] + sy as f64 * spec.square_size_mm,
168 width_mm: spec.square_size_mm,
169 height_mm: spec.square_size_mm,
170 fill,
171 });
172 }
173 }
174 Ok(())
175}
176
177fn build_charuco(
178 scene: &mut Scene,
179 spec: &CharucoTargetSpec,
180 layout: &ResolvedTargetLayout,
181) -> Result<(), PrintableTargetError> {
182 validate_charuco_spec(spec)?;
183 for sy in 0..spec.rows {
184 for sx in 0..spec.cols {
185 let fill = if (sx + sy) % 2 == 0 {
186 Fill::Black
187 } else {
188 Fill::White
189 };
190 scene.primitives.push(Primitive::Rect {
191 x_mm: layout.board_origin_mm[0] + sx as f64 * spec.square_size_mm,
192 y_mm: layout.board_origin_mm[1] + sy as f64 * spec.square_size_mm,
193 width_mm: spec.square_size_mm,
194 height_mm: spec.square_size_mm,
195 fill,
196 });
197 }
198 }
199
200 let board = CharucoBoard::new(spec.to_board_spec())?;
201 let marker_side_mm = spec.square_size_mm * spec.marker_size_rel;
202 let marker_offset_mm = 0.5 * (spec.square_size_mm - marker_side_mm);
203 let bits = spec.dictionary.marker_size();
204 let total_cells = bits + 2 * spec.border_bits;
205 let bit_cell_mm = marker_side_mm / total_cells as f64;
206
207 for marker_id in 0..board.marker_count() {
208 let cell = board
209 .marker_position(marker_id as u32)
210 .expect("validated marker position");
211 let origin_x =
212 layout.board_origin_mm[0] + cell.u as f64 * spec.square_size_mm + marker_offset_mm;
213 let origin_y =
214 layout.board_origin_mm[1] + cell.v as f64 * spec.square_size_mm + marker_offset_mm;
215 let code = spec.dictionary.codes()[marker_id];
216 for cy in 0..total_cells {
217 for cx in 0..total_cells {
218 let is_black = if cx < spec.border_bits
219 || cy < spec.border_bits
220 || cx >= spec.border_bits + bits
221 || cy >= spec.border_bits + bits
222 {
223 true
224 } else {
225 let bx = cx - spec.border_bits;
226 let by = cy - spec.border_bits;
227 let idx = by * bits + bx;
228 ((code >> idx) & 1) == 1
229 };
230 scene.primitives.push(Primitive::Rect {
231 x_mm: origin_x + cx as f64 * bit_cell_mm,
232 y_mm: origin_y + cy as f64 * bit_cell_mm,
233 width_mm: bit_cell_mm,
234 height_mm: bit_cell_mm,
235 fill: if is_black { Fill::Black } else { Fill::White },
236 });
237 }
238 }
239 }
240
241 Ok(())
242}
243
244fn build_puzzleboard(
245 scene: &mut Scene,
246 spec: &PuzzleBoardTargetSpec,
247 layout: &ResolvedTargetLayout,
248) -> Result<(), PrintableTargetError> {
249 validate_puzzleboard_spec(spec)?;
250 let origin_x = layout.board_origin_mm[0];
251 let origin_y = layout.board_origin_mm[1];
252
253 for sy in 0..spec.rows {
257 for sx in 0..spec.cols {
258 let master_r = spec.origin_row + sy;
259 let master_c = spec.origin_col + sx;
260 let fill = if (master_r + master_c).is_multiple_of(2) {
261 Fill::Black
262 } else {
263 Fill::White
264 };
265 scene.primitives.push(Primitive::Rect {
266 x_mm: origin_x + sx as f64 * spec.square_size_mm,
267 y_mm: origin_y + sy as f64 * spec.square_size_mm,
268 width_mm: spec.square_size_mm,
269 height_mm: spec.square_size_mm,
270 fill,
271 });
272 }
273 }
274
275 let dot_radius_mm = 0.5 * spec.dot_diameter_rel * spec.square_size_mm;
278
279 for r in 0..spec.rows.saturating_sub(1) {
282 for c in 0..spec.cols {
283 let master_r = (spec.origin_row + r) as i32;
284 let master_c = (spec.origin_col + c) as i32;
285 let bit = code_maps::horizontal_edge_bit(master_r, master_c);
286 let fill = if bit == 1 { Fill::White } else { Fill::Black };
287 let cx = origin_x + (c as f64 + 0.5) * spec.square_size_mm;
288 let cy = origin_y + (r as f64 + 1.0) * spec.square_size_mm;
289 scene.primitives.push(Primitive::Circle {
290 cx_mm: cx,
291 cy_mm: cy,
292 radius_mm: dot_radius_mm,
293 fill,
294 });
295 }
296 }
297
298 for r in 0..spec.rows {
301 for c in 0..spec.cols.saturating_sub(1) {
302 let master_r = (spec.origin_row + r) as i32;
303 let master_c = (spec.origin_col + c) as i32;
304 let bit = code_maps::vertical_edge_bit(master_r, master_c);
305 let fill = if bit == 1 { Fill::White } else { Fill::Black };
306 let cx = origin_x + (c as f64 + 1.0) * spec.square_size_mm;
307 let cy = origin_y + (r as f64 + 0.5) * spec.square_size_mm;
308 scene.primitives.push(Primitive::Circle {
309 cx_mm: cx,
310 cy_mm: cy,
311 radius_mm: dot_radius_mm,
312 fill,
313 });
314 }
315 }
316
317 Ok(())
318}
319
320fn build_marker_board(
321 scene: &mut Scene,
322 spec: &MarkerBoardTargetSpec,
323 layout: &ResolvedTargetLayout,
324) -> Result<(), PrintableTargetError> {
325 validate_marker_board_spec(spec)?;
326 build_chessboard(
327 scene,
328 &crate::model::ChessboardTargetSpec {
329 inner_rows: spec.inner_rows,
330 inner_cols: spec.inner_cols,
331 square_size_mm: spec.square_size_mm,
332 },
333 layout,
334 )?;
335 let radius_mm = 0.5 * spec.circle_diameter_rel * spec.square_size_mm;
336 for circle in spec.circles {
337 scene.primitives.push(Primitive::Circle {
338 cx_mm: layout.board_origin_mm[0] + (circle.i as f64 + 0.5) * spec.square_size_mm,
339 cy_mm: layout.board_origin_mm[1] + (circle.j as f64 + 0.5) * spec.square_size_mm,
340 radius_mm,
341 fill: match circle.polarity {
343 CirclePolarity::White => Fill::White,
344 CirclePolarity::Black => Fill::Black,
345 _ => unreachable!("unhandled CirclePolarity variant — update render_marker_board"),
346 },
347 });
348 }
349 Ok(())
350}
351
352fn add_debug_primitives(
353 scene: &mut Scene,
354 document: &PrintableTargetDocument,
355 layout: &ResolvedTargetLayout,
356) {
357 let margin = document.page.margin_mm;
358 let printable_width_mm = layout.page_width_mm - 2.0 * margin;
359 let printable_height_mm = layout.page_height_mm - 2.0 * margin;
360 add_outline_rect(
361 scene,
362 margin,
363 margin,
364 printable_width_mm,
365 printable_height_mm,
366 0.5,
367 Fill::Guide,
368 );
369 add_outline_rect(
370 scene,
371 layout.board_origin_mm[0],
372 layout.board_origin_mm[1],
373 layout.board_width_mm,
374 layout.board_height_mm,
375 0.7,
376 Fill::Accent,
377 );
378 for point in &layout.points {
379 scene.primitives.push(Primitive::Circle {
380 cx_mm: layout.board_origin_mm[0] + point.position_mm[0],
381 cy_mm: layout.board_origin_mm[1] + point.position_mm[1],
382 radius_mm: 0.8,
383 fill: Fill::Accent,
384 });
385 }
386}
387
388fn add_outline_rect(
389 scene: &mut Scene,
390 x_mm: f64,
391 y_mm: f64,
392 width_mm: f64,
393 height_mm: f64,
394 thickness_mm: f64,
395 fill: Fill,
396) {
397 scene.primitives.push(Primitive::Rect {
398 x_mm,
399 y_mm,
400 width_mm,
401 height_mm: thickness_mm,
402 fill,
403 });
404 scene.primitives.push(Primitive::Rect {
405 x_mm,
406 y_mm: y_mm + height_mm - thickness_mm,
407 width_mm,
408 height_mm: thickness_mm,
409 fill,
410 });
411 scene.primitives.push(Primitive::Rect {
412 x_mm,
413 y_mm,
414 width_mm: thickness_mm,
415 height_mm,
416 fill,
417 });
418 scene.primitives.push(Primitive::Rect {
419 x_mm: x_mm + width_mm - thickness_mm,
420 y_mm,
421 width_mm: thickness_mm,
422 height_mm,
423 fill,
424 });
425}
426
427fn render_svg(scene: &Scene) -> String {
428 let mut out = String::new();
429 out.push_str(r#"<?xml version="1.0" encoding="UTF-8"?>"#);
430 out.push('\n');
431 out.push_str(&format!(
432 r#"<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="{}mm" height="{}mm" viewBox="0 0 {} {}">"#,
433 fmt_mm(scene.width_mm),
434 fmt_mm(scene.height_mm),
435 fmt_mm(scene.width_mm),
436 fmt_mm(scene.height_mm),
437 ));
438 out.push('\n');
439 for primitive in &scene.primitives {
440 match primitive {
441 Primitive::Rect {
442 x_mm,
443 y_mm,
444 width_mm,
445 height_mm,
446 fill,
447 } => {
448 out.push_str(&format!(
449 r#"<rect x="{}" y="{}" width="{}" height="{}" fill="{}"/>"#,
450 fmt_mm(*x_mm),
451 fmt_mm(*y_mm),
452 fmt_mm(*width_mm),
453 fmt_mm(*height_mm),
454 fill.svg(),
455 ));
456 }
457 Primitive::Circle {
458 cx_mm,
459 cy_mm,
460 radius_mm,
461 fill,
462 } => {
463 out.push_str(&format!(
464 r#"<circle cx="{}" cy="{}" r="{}" fill="{}"/>"#,
465 fmt_mm(*cx_mm),
466 fmt_mm(*cy_mm),
467 fmt_mm(*radius_mm),
468 fill.svg(),
469 ));
470 }
471 }
472 out.push('\n');
473 }
474 out.push_str("</svg>\n");
475 out
476}
477
478fn render_png(scene: &Scene, options: &RenderOptions) -> Result<Vec<u8>, PrintableTargetError> {
479 let px_per_mm = options.png_dpi as f64 / 25.4;
480 let width_px = (scene.width_mm * px_per_mm).round().max(1.0) as usize;
481 let height_px = (scene.height_mm * px_per_mm).round().max(1.0) as usize;
482 let mut data = vec![255u8; width_px * height_px];
483 let mut canvas = RasterCanvas {
484 data: &mut data,
485 width_px,
486 height_px,
487 px_per_mm,
488 };
489
490 for primitive in &scene.primitives {
491 match primitive {
492 Primitive::Rect {
493 x_mm,
494 y_mm,
495 width_mm,
496 height_mm,
497 fill,
498 } => fill_rect(
499 &mut canvas,
500 *x_mm,
501 *y_mm,
502 [*width_mm, *height_mm],
503 fill.gray(),
504 ),
505 Primitive::Circle {
506 cx_mm,
507 cy_mm,
508 radius_mm,
509 fill,
510 } => fill_circle(&mut canvas, [*cx_mm, *cy_mm], *radius_mm, fill.gray()),
511 }
512 }
513
514 let mut bytes = Vec::new();
515 let mut encoder = Encoder::new(&mut bytes, width_px as u32, height_px as u32);
516 encoder.set_color(ColorType::Grayscale);
517 encoder.set_depth(BitDepth::Eight);
518 encoder.set_pixel_dims(Some(PixelDimensions {
519 xppu: (options.png_dpi as f64 / 25.4 * 1000.0).round() as u32,
520 yppu: (options.png_dpi as f64 / 25.4 * 1000.0).round() as u32,
521 unit: Unit::Meter,
522 }));
523 let mut writer = encoder.write_header()?;
524 writer.write_image_data(&data)?;
525 writer.finish()?;
526 Ok(bytes)
527}
528
529struct RasterCanvas<'a> {
530 data: &'a mut [u8],
531 width_px: usize,
532 height_px: usize,
533 px_per_mm: f64,
534}
535
536fn fill_rect(canvas: &mut RasterCanvas<'_>, x_mm: f64, y_mm: f64, size_mm: [f64; 2], gray: u8) {
537 let x0 = (x_mm * canvas.px_per_mm).round().max(0.0) as i32;
538 let y0 = (y_mm * canvas.px_per_mm).round().max(0.0) as i32;
539 let x1 = ((x_mm + size_mm[0]) * canvas.px_per_mm)
540 .round()
541 .min(canvas.width_px as f64) as i32;
542 let y1 = ((y_mm + size_mm[1]) * canvas.px_per_mm)
543 .round()
544 .min(canvas.height_px as f64) as i32;
545 for y in y0.max(0)..y1.max(0) {
546 let y = y as usize;
547 if y >= canvas.height_px {
548 continue;
549 }
550 let row = y * canvas.width_px;
551 for x in x0.max(0)..x1.max(0) {
552 let x = x as usize;
553 if x < canvas.width_px {
554 canvas.data[row + x] = gray;
555 }
556 }
557 }
558}
559
560fn fill_circle(canvas: &mut RasterCanvas<'_>, center_mm: [f64; 2], radius_mm: f64, gray: u8) {
561 let cx_px = center_mm[0] * canvas.px_per_mm;
562 let cy_px = center_mm[1] * canvas.px_per_mm;
563 let radius_px = radius_mm * canvas.px_per_mm;
564 let x0 = (cx_px - radius_px).floor().max(0.0) as i32;
565 let y0 = (cy_px - radius_px).floor().max(0.0) as i32;
566 let x1 = (cx_px + radius_px).ceil().min(canvas.width_px as f64) as i32;
567 let y1 = (cy_px + radius_px).ceil().min(canvas.height_px as f64) as i32;
568 let radius_sq = radius_px * radius_px;
569 for y in y0..y1 {
570 let y_usize = y as usize;
571 if y_usize >= canvas.height_px {
572 continue;
573 }
574 let py = y as f64 + 0.5;
575 let row = y_usize * canvas.width_px;
576 for x in x0..x1 {
577 let x_usize = x as usize;
578 if x_usize >= canvas.width_px {
579 continue;
580 }
581 let px = x as f64 + 0.5;
582 let dx = px - cx_px;
583 let dy = py - cy_px;
584 if dx * dx + dy * dy <= radius_sq {
585 canvas.data[row + x_usize] = gray;
586 }
587 }
588 }
589}
590
591fn fmt_mm(value: f64) -> String {
592 let mut text = format!("{value:.4}");
593 while text.contains('.') && text.ends_with('0') {
594 text.pop();
595 }
596 if text.ends_with('.') {
597 text.pop();
598 }
599 text
600}
601
602impl From<png::EncodingError> for PrintableTargetError {
603 fn from(value: png::EncodingError) -> Self {
604 PrintableTargetError::Io(std::io::Error::other(value.to_string()))
605 }
606}
607
608#[cfg(test)]
609mod tests {
610 use super::*;
611 use crate::model::{
612 CharucoTargetSpec, ChessboardTargetSpec, MarkerBoardTargetSpec, MarkerCircleSpec, PageSize,
613 PrintableTargetDocument, TargetSpec,
614 };
615 use calib_targets_aruco::builtins;
616 use calib_targets_charuco::MarkerLayout;
617
618 #[test]
619 fn svg_and_png_follow_page_dimensions() {
620 let mut doc = PrintableTargetDocument::new(TargetSpec::Chessboard(ChessboardTargetSpec {
621 inner_rows: 6,
622 inner_cols: 8,
623 square_size_mm: 20.0,
624 }));
625 doc.page.size = PageSize::Custom {
626 width_mm: 250.0,
627 height_mm: 180.0,
628 };
629 let bundle = render_target_bundle(&doc).expect("bundle");
630 assert!(bundle.svg_text.contains(r#"width="250mm""#));
631 assert!(bundle.svg_text.contains(r#"height="180mm""#));
632 assert!(!bundle.png_bytes.is_empty());
633 }
634
635 #[test]
636 fn debug_annotations_add_outline_primitives() {
637 let mut doc =
638 PrintableTargetDocument::new(TargetSpec::MarkerBoard(MarkerBoardTargetSpec {
639 inner_rows: 6,
640 inner_cols: 8,
641 square_size_mm: 20.0,
642 circles: [
643 MarkerCircleSpec {
644 i: 3,
645 j: 2,
646 polarity: CirclePolarity::White,
647 },
648 MarkerCircleSpec {
649 i: 4,
650 j: 2,
651 polarity: CirclePolarity::Black,
652 },
653 MarkerCircleSpec {
654 i: 4,
655 j: 3,
656 polarity: CirclePolarity::White,
657 },
658 ],
659 circle_diameter_rel: 0.5,
660 }));
661 doc.render.debug_annotations = true;
662 let bundle = render_target_bundle(&doc).expect("bundle");
663 assert!(bundle.svg_text.contains("#d22f27"));
664 assert!(bundle.svg_text.contains("#4a90e2"));
665 }
666
667 #[test]
668 fn charuco_svg_contains_marker_cells() {
669 let doc = PrintableTargetDocument::new(TargetSpec::Charuco(CharucoTargetSpec {
670 rows: 5,
671 cols: 7,
672 square_size_mm: 15.0,
673 marker_size_rel: 0.75,
674 dictionary: builtins::builtin_dictionary("DICT_4X4_50").expect("dict"),
675 marker_layout: MarkerLayout::OpenCvCharuco,
676 border_bits: 1,
677 }));
678 let bundle = render_target_bundle(&doc).expect("bundle");
679 let rect_count = bundle.svg_text.matches("<rect ").count();
680 assert!(rect_count > 35);
681 }
682}