1#![allow(clippy::collapsible_if)]
4
5pub mod writer;
6
7use std::collections::HashMap;
8use std::f64::consts::PI;
9use std::io::BufRead;
10
11use crate::cad::dxf::geometry::{BBox, circle_path, fmt_coord, parse_cad_float};
12use crate::convert::{ConvertOptions, PageConsumer};
13use crate::error::{Error, Result};
14use crate::ir::{IDENTITY, LineCap, LineJoin, Node, Page, Paint, SourceMeta, Stroke};
15
16const TARGET_PAGE_LONG_EDGE: f64 = 1200.0;
17const MIN_PAGE_DIMENSION: f64 = 400.0;
18const MAX_GERBER_COMMANDS: usize = 1_000_000;
19
20#[derive(Clone, Copy, Debug, PartialEq)]
21pub enum Polarity {
22 Dark,
23 Clear,
24}
25
26#[derive(Clone, Copy, Debug, PartialEq)]
27pub enum InterpolationMode {
28 Linear,
29 Clockwise,
30 CounterClockwise,
31}
32
33#[derive(Clone, Debug, PartialEq)]
34pub enum Aperture {
35 Circle {
36 diameter: f64,
37 hole_diameter: Option<f64>,
38 },
39 Rectangle {
40 width: f64,
41 height: f64,
42 hole_diameter: Option<f64>,
43 },
44 Obround {
45 width: f64,
46 height: f64,
47 hole_diameter: Option<f64>,
48 },
49 Polygon {
50 diameter: f64,
51 vertices: usize,
52 rotation: Option<f64>,
53 hole_diameter: Option<f64>,
54 },
55}
56
57#[derive(Clone, Debug)]
58pub struct CoordinateFormat {
59 pub x_int: usize,
60 pub x_dec: usize,
61 pub y_int: usize,
62 pub y_dec: usize,
63 pub suppress_leading_zeros: bool,
64}
65
66impl Default for CoordinateFormat {
67 fn default() -> Self {
68 Self {
69 x_int: 2,
70 x_dec: 4,
71 y_int: 2,
72 y_dec: 4,
73 suppress_leading_zeros: true,
74 }
75 }
76}
77
78impl CoordinateFormat {
79 pub fn parse_coord(&self, s: &str, is_y: bool) -> f64 {
80 if s.is_empty() {
81 return 0.0;
82 }
83 if s.contains('.') {
85 return parse_cad_float(s).unwrap_or(0.0);
86 }
87 let sign = if s.starts_with('-') { -1.0 } else { 1.0 };
88 let num_part = s.trim_start_matches(['+', '-']);
89 let dec_digits = if is_y { self.y_dec } else { self.x_dec };
90 let total_digits = if is_y {
91 self.y_int + self.y_dec
92 } else {
93 self.x_int + self.x_dec
94 };
95
96 let padded = if self.suppress_leading_zeros {
97 if num_part.len() < total_digits {
98 format!("{:0>width$}", num_part, width = total_digits)
99 } else {
100 num_part.to_string()
101 }
102 } else if num_part.len() < total_digits {
103 format!("{:0<width$}", num_part, width = total_digits)
104 } else {
105 num_part.to_string()
106 };
107
108 let raw_val: f64 = padded.parse().unwrap_or(0.0);
109 sign * (raw_val / 10f64.powi(dec_digits as i32))
110 }
111}
112
113pub(crate) fn convert<R: BufRead>(
114 mut reader: R,
115 _options: &ConvertOptions,
116 sink: &mut dyn PageConsumer,
117) -> Result<Vec<String>> {
118 let mut warnings = Vec::new();
119 let mut buffer = String::new();
120 reader.read_to_string(&mut buffer)?;
121
122 let mut apertures: HashMap<u32, Aperture> = HashMap::new();
123 let mut current_aperture: Option<u32> = None;
124 let mut is_metric = true; let mut coord_format = CoordinateFormat::default();
126 let mut current_x = 0.0;
127 let mut current_y = 0.0;
128 let mut current_polarity = Polarity::Dark;
129 let mut interpolation_mode = InterpolationMode::Linear;
130 let mut in_polygon_mode = false;
131 let mut polygon_points: Vec<(f64, f64)> = Vec::new();
132
133 let mut raw_paths: Vec<GerberElement> = Vec::new();
134 let mut bbox = BBox::new();
135
136 let mut commands_count = 0;
137 let mut stop_parsing = false;
138
139 for (seg_idx, segment) in buffer.split('%').enumerate() {
142 if stop_parsing {
143 break;
144 }
145 if seg_idx % 2 == 1 {
146 for param_cmd in segment.split('*') {
148 let param_body = param_cmd.trim();
149 if param_body.starts_with("MOMM") {
150 is_metric = true;
151 } else if param_body.starts_with("MOIN") {
152 is_metric = false;
153 } else if param_body.starts_with("FSLA") || param_body.starts_with("FS") {
154 parse_format_spec(param_body, &mut coord_format);
155 } else if param_body.starts_with("ADD") {
156 parse_aperture_definition(param_body, &mut apertures);
157 } else if param_body.starts_with("LPD") {
158 current_polarity = Polarity::Dark;
159 } else if param_body.starts_with("LPC") {
160 current_polarity = Polarity::Clear;
161 }
162 }
163 continue;
164 }
165
166 for token in segment.split('*') {
168 let trimmed = token.trim();
169 if trimmed.is_empty() {
170 continue;
171 }
172 commands_count += 1;
173 if commands_count > MAX_GERBER_COMMANDS {
174 return Err(Error::LimitExceeded(format!(
175 "Gerber commands exceed safety limit of {MAX_GERBER_COMMANDS}"
176 )));
177 }
178
179 if trimmed.starts_with("G04") || trimmed.starts_with("G4") {
181 continue;
182 }
183 if trimmed == "M02" || trimmed == "M00" || trimmed == "M2" || trimmed == "M0" {
184 stop_parsing = true;
185 break;
186 }
187
188 let mut chars = trimmed.chars().peekable();
190 let mut target_x = current_x;
191 let mut target_y = current_y;
192 let mut target_i: f64 = 0.0;
193 let mut target_j: f64 = 0.0;
194 let mut has_coord = false;
195
196 while let Some(c) = chars.next() {
197 match c {
198 'G' => {
199 let mut num_str = String::new();
200 while let Some(&nc) = chars.peek() {
201 if nc.is_ascii_digit() {
202 num_str.push(nc);
203 chars.next();
204 } else {
205 break;
206 }
207 }
208 match num_str.as_str() {
209 "4" | "04" => break, "1" | "01" => interpolation_mode = InterpolationMode::Linear,
211 "2" | "02" => interpolation_mode = InterpolationMode::Clockwise,
212 "3" | "03" => interpolation_mode = InterpolationMode::CounterClockwise,
213 "36" => {
214 in_polygon_mode = true;
215 polygon_points.clear();
216 polygon_points.push((current_x, current_y));
217 }
218 "37" => {
219 in_polygon_mode = false;
220 if polygon_points.len() >= 3 {
221 for pt in &polygon_points {
222 bbox.update(pt.0, pt.1);
223 }
224 raw_paths.push(GerberElement::Polygon {
225 points: polygon_points.clone(),
226 polarity: current_polarity,
227 });
228 }
229 polygon_points.clear();
230 }
231 _ => {}
232 }
233 }
234 'D' => {
235 let mut num_str = String::new();
236 while let Some(&nc) = chars.peek() {
237 if nc.is_ascii_digit() {
238 num_str.push(nc);
239 chars.next();
240 } else {
241 break;
242 }
243 }
244 if let Ok(d_code) = num_str.parse::<u32>() {
245 match d_code {
246 1 => {
247 if in_polygon_mode {
249 current_x = target_x;
250 current_y = target_y;
251 polygon_points.push((current_x, current_y));
252 } else {
253 let ap = current_aperture
254 .and_then(|id| apertures.get(&id))
255 .cloned();
256 let width = match &ap {
257 Some(Aperture::Circle { diameter, .. }) => *diameter,
258 Some(Aperture::Rectangle { width, height, .. }) => {
259 width.min(*height)
260 }
261 _ => 0.2, };
263 bbox.update(current_x - width, current_y - width);
264 bbox.update(current_x + width, current_y + width);
265 bbox.update(target_x - width, target_y - width);
266 bbox.update(target_x + width, target_y + width);
267
268 if interpolation_mode == InterpolationMode::Linear
269 || (target_i.abs() < 1e-6 && target_j.abs() < 1e-6)
270 {
271 raw_paths.push(GerberElement::Line {
272 start: (current_x, current_y),
273 end: (target_x, target_y),
274 width,
275 polarity: current_polarity,
276 });
277 } else {
278 let clockwise =
279 interpolation_mode == InterpolationMode::Clockwise;
280 raw_paths.push(GerberElement::Arc {
281 start: (current_x, current_y),
282 end: (target_x, target_y),
283 center_offset: (target_i, target_j),
284 clockwise,
285 width,
286 polarity: current_polarity,
287 });
288 }
289 current_x = target_x;
290 current_y = target_y;
291 }
292 }
293 2 => {
294 current_x = target_x;
296 current_y = target_y;
297 if in_polygon_mode {
298 polygon_points.push((current_x, current_y));
299 }
300 }
301 3 => {
302 current_x = target_x;
304 current_y = target_y;
305 if let Some(ap_id) = current_aperture {
306 if let Some(ap) = apertures.get(&ap_id) {
307 accumulate_aperture_bbox(
308 current_x, current_y, ap, &mut bbox,
309 );
310 raw_paths.push(GerberElement::Flash {
311 x: current_x,
312 y: current_y,
313 aperture: ap.clone(),
314 polarity: current_polarity,
315 });
316 }
317 }
318 }
319 id if id >= 10 => {
320 current_aperture = Some(id);
321 }
322 _ => {}
323 }
324 }
325 }
326 'X' => {
327 let mut num_str = String::new();
328 while let Some(&nc) = chars.peek() {
329 if nc.is_ascii_digit() || nc == '-' || nc == '+' || nc == '.' {
330 num_str.push(nc);
331 chars.next();
332 } else {
333 break;
334 }
335 }
336 let mut val = coord_format.parse_coord(&num_str, false);
337 if !is_metric {
338 val *= 25.4; }
340 target_x = val;
341 has_coord = true;
342 }
343 'Y' => {
344 let mut num_str = String::new();
345 while let Some(&nc) = chars.peek() {
346 if nc.is_ascii_digit() || nc == '-' || nc == '+' || nc == '.' {
347 num_str.push(nc);
348 chars.next();
349 } else {
350 break;
351 }
352 }
353 let mut val = coord_format.parse_coord(&num_str, true);
354 if !is_metric {
355 val *= 25.4; }
357 target_y = val;
358 has_coord = true;
359 }
360 'I' => {
361 let mut num_str = String::new();
362 while let Some(&nc) = chars.peek() {
363 if nc.is_ascii_digit() || nc == '-' || nc == '+' || nc == '.' {
364 num_str.push(nc);
365 chars.next();
366 } else {
367 break;
368 }
369 }
370 let mut val = coord_format.parse_coord(&num_str, false);
371 if !is_metric {
372 val *= 25.4;
373 }
374 target_i = val;
375 }
376 'J' => {
377 let mut num_str = String::new();
378 while let Some(&nc) = chars.peek() {
379 if nc.is_ascii_digit() || nc == '-' || nc == '+' || nc == '.' {
380 num_str.push(nc);
381 chars.next();
382 } else {
383 break;
384 }
385 }
386 let mut val = coord_format.parse_coord(&num_str, true);
387 if !is_metric {
388 val *= 25.4;
389 }
390 target_j = val;
391 }
392 _ => {}
393 }
394 }
395
396 if has_coord && in_polygon_mode {
397 current_x = target_x;
398 current_y = target_y;
399 polygon_points.push((current_x, current_y));
400 }
401 }
402 }
403
404 if !bbox.is_valid() {
405 bbox.min_x = 0.0;
406 bbox.min_y = 0.0;
407 bbox.max_x = 100.0;
408 bbox.max_y = 100.0;
409 warnings.push("Gerber contains no renderable elements; using default viewport".into());
410 }
411
412 let raw_w = bbox.width().max(1.0);
413 let raw_h = bbox.height().max(1.0);
414 let margin = (raw_w.max(raw_h) * 0.05).max(5.0);
415 let content_w = raw_w + 2.0 * margin;
416 let content_h = raw_h + 2.0 * margin;
417
418 let scale = (TARGET_PAGE_LONG_EDGE / content_w.max(content_h)).clamp(0.01, 100.0);
419 let page_w = (content_w * scale).max(MIN_PAGE_DIMENSION);
420 let page_h = (content_h * scale).max(MIN_PAGE_DIMENSION);
421
422 let mut page = Page::new(1, page_w, page_h, "gerber");
423 page.title = "PCB Layer".into();
424
425 let map_pt = |x: f64, y: f64| -> (f64, f64) {
426 let sx = (x - bbox.min_x + margin) * scale;
427 let sy = (bbox.max_y - y + margin) * scale; (sx, sy)
429 };
430
431 let bg_paint = Paint::solid("#143d22");
432 let bg_rect = Node::Path {
433 id: "pcb-substrate".into(),
434 d: format!(
435 "M 0 0 L {} 0 L {} {} L 0 {} Z",
436 fmt_coord(page_w),
437 fmt_coord(page_w),
438 fmt_coord(page_h),
439 fmt_coord(page_h)
440 ),
441 fill_rule: "nonzero".into(),
442 fill: bg_paint.clone(),
443 stroke: Stroke::default(),
444 transform: IDENTITY,
445 clip_id: None,
446 meta: SourceMeta {
447 semantic_role: "pcb:substrate".into(),
448 ..Default::default()
449 },
450 };
451 page.nodes.push(bg_rect);
452
453 let mut copper_nodes = Vec::new();
454 let copper_paint = Paint::solid("#e8be38");
455
456 let get_paint = |polarity: Polarity| -> Paint {
457 match polarity {
458 Polarity::Dark => copper_paint.clone(),
459 Polarity::Clear => bg_paint.clone(),
460 }
461 };
462
463 for elem in raw_paths {
464 match elem {
465 GerberElement::Line {
466 start,
467 end,
468 width,
469 polarity,
470 } => {
471 let p1 = map_pt(start.0, start.1);
472 let p2 = map_pt(end.0, end.1);
473 let stroke_w = (width * scale).clamp(0.75, 200.0);
474 let d = format!(
475 "M {} {} L {} {}",
476 fmt_coord(p1.0),
477 fmt_coord(p1.1),
478 fmt_coord(p2.0),
479 fmt_coord(p2.1)
480 );
481 copper_nodes.push(Node::Path {
482 id: String::new(),
483 d,
484 fill_rule: "nonzero".into(),
485 fill: Paint::None,
486 stroke: Stroke {
487 paint: get_paint(polarity),
488 width: stroke_w,
489 line_cap: LineCap::Round,
490 line_join: LineJoin::Round,
491 miter_limit: 4.0,
492 dash_array: Vec::new(),
493 dash_offset: 0.0,
494 },
495 transform: IDENTITY,
496 clip_id: None,
497 meta: SourceMeta::default(),
498 });
499 }
500 GerberElement::Arc {
501 start,
502 end,
503 center_offset,
504 clockwise,
505 width,
506 polarity,
507 } => {
508 let p1 = map_pt(start.0, start.1);
509 let p2 = map_pt(end.0, end.1);
510 let stroke_w = (width * scale).clamp(0.75, 200.0);
511 let r_unscaled = (center_offset.0.powi(2) + center_offset.1.powi(2)).sqrt();
512 let r = (r_unscaled * scale).max(0.1);
513 let sweep = if clockwise { 0 } else { 1 };
515 let d = format!(
516 "M {} {} A {} {} 0 0 {} {} {}",
517 fmt_coord(p1.0),
518 fmt_coord(p1.1),
519 fmt_coord(r),
520 fmt_coord(r),
521 sweep,
522 fmt_coord(p2.0),
523 fmt_coord(p2.1)
524 );
525 copper_nodes.push(Node::Path {
526 id: String::new(),
527 d,
528 fill_rule: "nonzero".into(),
529 fill: Paint::None,
530 stroke: Stroke {
531 paint: get_paint(polarity),
532 width: stroke_w,
533 line_cap: LineCap::Round,
534 line_join: LineJoin::Round,
535 miter_limit: 4.0,
536 dash_array: Vec::new(),
537 dash_offset: 0.0,
538 },
539 transform: IDENTITY,
540 clip_id: None,
541 meta: SourceMeta::default(),
542 });
543 }
544 GerberElement::Polygon { points, polarity } => {
545 if points.len() >= 3 {
546 let mut d = String::new();
547 for (i, pt) in points.iter().enumerate() {
548 let (x, y) = map_pt(pt.0, pt.1);
549 if i == 0 {
550 d.push_str(&format!("M {} {}", fmt_coord(x), fmt_coord(y)));
551 } else {
552 d.push_str(&format!(" L {} {}", fmt_coord(x), fmt_coord(y)));
553 }
554 }
555 d.push_str(" Z");
556 copper_nodes.push(Node::Path {
557 id: String::new(),
558 d,
559 fill_rule: "nonzero".into(),
560 fill: get_paint(polarity),
561 stroke: Stroke::default(),
562 transform: IDENTITY,
563 clip_id: None,
564 meta: SourceMeta::default(),
565 });
566 }
567 }
568 GerberElement::Flash {
569 x,
570 y,
571 aperture,
572 polarity,
573 } => {
574 let (cx, cy) = map_pt(x, y);
575 let fill_paint = get_paint(polarity);
576 match aperture {
577 Aperture::Circle {
578 diameter,
579 hole_diameter,
580 } => {
581 let r = (diameter / 2.0) * scale;
582 let d = circle_path(cx, cy, r);
583 copper_nodes.push(Node::Path {
584 id: String::new(),
585 d,
586 fill_rule: "nonzero".into(),
587 fill: fill_paint,
588 stroke: Stroke::default(),
589 transform: IDENTITY,
590 clip_id: None,
591 meta: SourceMeta::default(),
592 });
593 if let Some(hd) = hole_diameter {
594 let hr = (hd / 2.0) * scale;
595 let hole_d = circle_path(cx, cy, hr);
596 copper_nodes.push(Node::Path {
597 id: String::new(),
598 d: hole_d,
599 fill_rule: "nonzero".into(),
600 fill: bg_paint.clone(),
601 stroke: Stroke::default(),
602 transform: IDENTITY,
603 clip_id: None,
604 meta: SourceMeta::default(),
605 });
606 }
607 }
608 Aperture::Rectangle {
609 width,
610 height,
611 hole_diameter,
612 } => {
613 let w = width * scale;
614 let h = height * scale;
615 let x0 = cx - w / 2.0;
616 let y0 = cy - h / 2.0;
617 let d = format!(
618 "M {} {} L {} {} L {} {} L {} {} Z",
619 fmt_coord(x0),
620 fmt_coord(y0),
621 fmt_coord(x0 + w),
622 fmt_coord(y0),
623 fmt_coord(x0 + w),
624 fmt_coord(y0 + h),
625 fmt_coord(x0),
626 fmt_coord(y0 + h)
627 );
628 copper_nodes.push(Node::Path {
629 id: String::new(),
630 d,
631 fill_rule: "nonzero".into(),
632 fill: fill_paint,
633 stroke: Stroke::default(),
634 transform: IDENTITY,
635 clip_id: None,
636 meta: SourceMeta::default(),
637 });
638 if let Some(hd) = hole_diameter {
639 let hr = (hd / 2.0) * scale;
640 let hole_d = circle_path(cx, cy, hr);
641 copper_nodes.push(Node::Path {
642 id: String::new(),
643 d: hole_d,
644 fill_rule: "nonzero".into(),
645 fill: bg_paint.clone(),
646 stroke: Stroke::default(),
647 transform: IDENTITY,
648 clip_id: None,
649 meta: SourceMeta::default(),
650 });
651 }
652 }
653 Aperture::Obround {
654 width,
655 height,
656 hole_diameter,
657 } => {
658 let w = width * scale;
659 let h = height * scale;
660 let d = if (w - h).abs() < 1e-4 {
661 circle_path(cx, cy, w / 2.0)
662 } else if w > h {
663 let r = h / 2.0;
664 let dx = (w - h) / 2.0;
665 format!(
666 "M {} {} L {} {} A {} {} 0 0 1 {} {} L {} {} A {} {} 0 0 1 {} {} Z",
667 fmt_coord(cx - dx),
668 fmt_coord(cy - r),
669 fmt_coord(cx + dx),
670 fmt_coord(cy - r),
671 fmt_coord(r),
672 fmt_coord(r),
673 fmt_coord(cx + dx),
674 fmt_coord(cy + r),
675 fmt_coord(cx - dx),
676 fmt_coord(cy + r),
677 fmt_coord(r),
678 fmt_coord(r),
679 fmt_coord(cx - dx),
680 fmt_coord(cy - r),
681 )
682 } else {
683 let r = w / 2.0;
684 let dy = (h - w) / 2.0;
685 format!(
686 "M {} {} L {} {} A {} {} 0 0 1 {} {} L {} {} A {} {} 0 0 1 {} {} Z",
687 fmt_coord(cx + r),
688 fmt_coord(cy - dy),
689 fmt_coord(cx + r),
690 fmt_coord(cy + dy),
691 fmt_coord(r),
692 fmt_coord(r),
693 fmt_coord(cx - r),
694 fmt_coord(cy + dy),
695 fmt_coord(cx - r),
696 fmt_coord(cy - dy),
697 fmt_coord(r),
698 fmt_coord(r),
699 fmt_coord(cx + r),
700 fmt_coord(cy - dy),
701 )
702 };
703 copper_nodes.push(Node::Path {
704 id: String::new(),
705 d,
706 fill_rule: "nonzero".into(),
707 fill: fill_paint,
708 stroke: Stroke::default(),
709 transform: IDENTITY,
710 clip_id: None,
711 meta: SourceMeta::default(),
712 });
713 if let Some(hd) = hole_diameter {
714 let hr = (hd / 2.0) * scale;
715 let hole_d = circle_path(cx, cy, hr);
716 copper_nodes.push(Node::Path {
717 id: String::new(),
718 d: hole_d,
719 fill_rule: "nonzero".into(),
720 fill: bg_paint.clone(),
721 stroke: Stroke::default(),
722 transform: IDENTITY,
723 clip_id: None,
724 meta: SourceMeta::default(),
725 });
726 }
727 }
728 Aperture::Polygon {
729 diameter,
730 vertices,
731 rotation,
732 hole_diameter,
733 } => {
734 let r = (diameter / 2.0) * scale;
735 let mut d = String::new();
736 let count = vertices.max(3);
737 let rot_rad = rotation.unwrap_or(0.0).to_radians();
738 for i in 0..count {
739 let angle = rot_rad + (i as f64) * 2.0 * PI / (count as f64);
740 let px = cx + r * angle.cos();
741 let py = cy + r * angle.sin();
742 if i == 0 {
743 d.push_str(&format!("M {} {}", fmt_coord(px), fmt_coord(py)));
744 } else {
745 d.push_str(&format!(" L {} {}", fmt_coord(px), fmt_coord(py)));
746 }
747 }
748 d.push_str(" Z");
749 copper_nodes.push(Node::Path {
750 id: String::new(),
751 d,
752 fill_rule: "nonzero".into(),
753 fill: fill_paint,
754 stroke: Stroke::default(),
755 transform: IDENTITY,
756 clip_id: None,
757 meta: SourceMeta::default(),
758 });
759 if let Some(hd) = hole_diameter {
760 let hr = (hd / 2.0) * scale;
761 let hole_d = circle_path(cx, cy, hr);
762 copper_nodes.push(Node::Path {
763 id: String::new(),
764 d: hole_d,
765 fill_rule: "nonzero".into(),
766 fill: bg_paint.clone(),
767 stroke: Stroke::default(),
768 transform: IDENTITY,
769 clip_id: None,
770 meta: SourceMeta::default(),
771 });
772 }
773 }
774 }
775 }
776 }
777 }
778
779 let copper_group = Node::Group {
780 id: "pcb-copper-layer".into(),
781 nodes: copper_nodes,
782 transform: IDENTITY,
783 opacity: 1.0,
784 clip_id: None,
785 meta: SourceMeta {
786 semantic_role: "pcb:copper".into(),
787 ..Default::default()
788 },
789 };
790 page.nodes.push(copper_group);
791
792 sink.consume(page)?;
793 Ok(warnings)
794}
795
796#[derive(Clone, Debug)]
797enum GerberElement {
798 Line {
799 start: (f64, f64),
800 end: (f64, f64),
801 width: f64,
802 polarity: Polarity,
803 },
804 Arc {
805 start: (f64, f64),
806 end: (f64, f64),
807 center_offset: (f64, f64),
808 clockwise: bool,
809 width: f64,
810 polarity: Polarity,
811 },
812 Polygon {
813 points: Vec<(f64, f64)>,
814 polarity: Polarity,
815 },
816 Flash {
817 x: f64,
818 y: f64,
819 aperture: Aperture,
820 polarity: Polarity,
821 },
822}
823
824fn accumulate_aperture_bbox(x: f64, y: f64, ap: &Aperture, bbox: &mut BBox) {
825 match ap {
826 Aperture::Circle { diameter, .. } => {
827 let r = diameter / 2.0;
828 bbox.update(x - r, y - r);
829 bbox.update(x + r, y + r);
830 }
831 Aperture::Rectangle { width, height, .. } | Aperture::Obround { width, height, .. } => {
832 let hw = width / 2.0;
833 let hh = height / 2.0;
834 bbox.update(x - hw, y - hh);
835 bbox.update(x + hw, y + hh);
836 }
837 Aperture::Polygon { diameter, .. } => {
838 let r = diameter / 2.0;
839 bbox.update(x - r, y - r);
840 bbox.update(x + r, y + r);
841 }
842 }
843}
844
845fn parse_format_spec(s: &str, fmt: &mut CoordinateFormat) {
846 if s.contains('L') {
847 fmt.suppress_leading_zeros = true;
848 } else if s.contains('T') {
849 fmt.suppress_leading_zeros = false;
850 }
851 if let Some(x_pos) = s.find('X') {
852 let after = &s[x_pos + 1..];
853 let digits: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
854 if digits.len() == 2 {
855 let b = digits.as_bytes();
856 fmt.x_int = (b[0] - b'0') as usize;
857 fmt.x_dec = (b[1] - b'0') as usize;
858 }
859 }
860 if let Some(y_pos) = s.find('Y') {
861 let after = &s[y_pos + 1..];
862 let digits: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
863 if digits.len() == 2 {
864 let b = digits.as_bytes();
865 fmt.y_int = (b[0] - b'0') as usize;
866 fmt.y_dec = (b[1] - b'0') as usize;
867 }
868 }
869}
870
871fn parse_aperture_definition(s: &str, apertures: &mut HashMap<u32, Aperture>) {
872 let rest = s.trim_start_matches("ADD");
874 let mut id_str = String::new();
875 let mut chars = rest.chars().peekable();
876 while let Some(&c) = chars.peek() {
877 if c.is_ascii_digit() {
878 id_str.push(c);
879 chars.next();
880 } else {
881 break;
882 }
883 }
884 let id: u32 = match id_str.parse() {
885 Ok(v) => v,
886 Err(_) => return,
887 };
888
889 let shape_type = match chars.next() {
890 Some(c) => c,
891 None => return,
892 };
893 if chars.next() != Some(',') {
894 return;
895 }
896
897 let mods_str: String = chars.collect();
898 let params: Vec<f64> = mods_str
899 .split(['X', 'x'])
900 .filter_map(parse_cad_float)
901 .collect();
902
903 let ap = match shape_type {
904 'C' => {
905 let diameter = params.first().copied().unwrap_or(0.1);
906 let hole_diameter = params.get(1).copied();
907 Aperture::Circle {
908 diameter,
909 hole_diameter,
910 }
911 }
912 'R' => {
913 let width = params.first().copied().unwrap_or(0.1);
914 let height = params.get(1).copied().unwrap_or(width);
915 let hole_diameter = params.get(2).copied();
916 Aperture::Rectangle {
917 width,
918 height,
919 hole_diameter,
920 }
921 }
922 'O' => {
923 let width = params.first().copied().unwrap_or(0.1);
924 let height = params.get(1).copied().unwrap_or(width);
925 let hole_diameter = params.get(2).copied();
926 Aperture::Obround {
927 width,
928 height,
929 hole_diameter,
930 }
931 }
932 'P' => {
933 let diameter = params.first().copied().unwrap_or(0.1);
934 let vertices = params.get(1).copied().unwrap_or(3.0) as usize;
935 let rotation = params.get(2).copied();
936 let hole_diameter = params.get(3).copied();
937 Aperture::Polygon {
938 diameter,
939 vertices,
940 rotation,
941 hole_diameter,
942 }
943 }
944 _ => return,
945 };
946
947 apertures.insert(id, ap);
948}
949
950#[cfg(test)]
951mod tests {
952 use super::*;
953 use std::io::Cursor;
954
955 struct DummySink(pub Vec<Page>);
956 impl PageConsumer for DummySink {
957 fn consume(&mut self, page: Page) -> Result<()> {
958 self.0.push(page);
959 Ok(())
960 }
961 }
962
963 #[test]
964 fn parses_simple_gerber() {
965 let gerber_text = r#"%FSLAX24Y24*%
966%MOMM*%
967%ADD10C,0.5000*%
968%ADD11R,1.0000X2.0000*%
969D10*
970X000000Y000000D02*
971X010000Y010000D01*
972D11*
973X020000Y020000D03*
974M02*
975"#;
976 let mut sink = DummySink(Vec::new());
977 let options = ConvertOptions::default();
978 let warnings =
979 convert(Cursor::new(gerber_text), &options, &mut sink).expect("convert gerber");
980 assert!(warnings.is_empty());
981 assert_eq!(sink.0.len(), 1);
982 let page = &sink.0[0];
983 assert_eq!(page.nodes.len(), 2); }
985
986 #[test]
987 fn parses_gerber_arc_and_polarity_and_aperture_holes() {
988 let gerber_text = r#"%FSLAX24Y24*%
989%MOMM*%
990%ADD10C,0.5000*%
991%ADD11C,2.0000X0.8000*%
992D10*
993X000000Y000000D02*
994G02*
995X010000Y010000I010000J000000D01*
996G01*
997D11*
998X020000Y020000D03*
999%LPC*%
1000X015000Y015000D03*
1001%LPD*%
1002M02*
1003"#;
1004 let mut sink = DummySink(Vec::new());
1005 let options = ConvertOptions::default();
1006 let warnings =
1007 convert(Cursor::new(gerber_text), &options, &mut sink).expect("convert gerber");
1008 assert!(warnings.is_empty());
1009 assert_eq!(sink.0.len(), 1);
1010 let page = &sink.0[0];
1011 assert_eq!(page.nodes.len(), 2);
1012 if let Node::Group { nodes, .. } = &page.nodes[1] {
1013 assert!(!nodes.is_empty());
1015 } else {
1016 panic!("expected copper group");
1017 }
1018 }
1019
1020 #[test]
1021 fn parses_gerber_obround_polygon_and_comments() {
1022 let gerber_text = r#"%FSLAX24Y24*%
1023%MOMM*%
1024G04 Created by CAD tool on 2024-01-01 with D01 and X100Y100*
1025%AMMACRO*
10261,1,$1,$2,0,0,0*
1027%
1028%ADD12O,2.0000X1.0000X0.5000*%
1029%ADD13P,2.0000X8X22.5000X0.8000*%
1030D12*
1031X010000Y010000D03*
1032D13*
1033X020000Y020000D03*
1034G04 Trailing comment*
1035M02*
1036"#;
1037 let mut sink = DummySink(Vec::new());
1038 let options = ConvertOptions::default();
1039 let warnings =
1040 convert(Cursor::new(gerber_text), &options, &mut sink).expect("convert gerber");
1041 assert!(warnings.is_empty());
1042 assert_eq!(sink.0.len(), 1);
1043 let page = &sink.0[0];
1044 if let Node::Group { nodes, .. } = &page.nodes[1] {
1045 assert_eq!(nodes.len(), 4);
1047 if let Node::Path { d, .. } = &nodes[0] {
1048 assert!(d.contains('A'));
1050 } else {
1051 panic!("expected path node for obround");
1052 }
1053 } else {
1054 panic!("expected copper group");
1055 }
1056 }
1057}