Skip to main content

document_svg/cad/gerber/
mod.rs

1//! Gerber RS-274X (Extended Gerber) PCB layout converter to Page IR.
2
3#![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        // Direct decimal support
84        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; // Default mm
125    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    // Gerber RS-274X: percent signs '%' delimit parameter blocks (odd segments).
140    // Even segments are standard drawing commands.
141    for (seg_idx, segment) in buffer.split('%').enumerate() {
142        if stop_parsing {
143            break;
144        }
145        if seg_idx % 2 == 1 {
146            // Parameter block
147            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        // Standard commands block
167        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            // Ignore comments and handle program termination
180            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            // Standard commands
189            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, // G04 comment terminates remainder of command
210                            "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                                    // Draw operation (exposure on)
248                                    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, // fallback mm
262                                        };
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                                    // Move operation (exposure off)
295                                    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                                    // Flash operation
303                                    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; // inches to mm
339                        }
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; // inches to mm
356                        }
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; // Y inverted for SVG
428        (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                // In inverted Y coordinate space, clockwise direction reverses sweep
514                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    // Format: ADD<id><type>,<mods>
873    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); // substrate + copper group
984    }
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            // Should contain arc path, donut pad (outer + hole), and clear pad
1014            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            // Should contain obround outer + hole, and octagonal polygon outer + hole
1046            assert_eq!(nodes.len(), 4);
1047            if let Node::Path { d, .. } = &nodes[0] {
1048                // Obround path contains 'A' arc commands
1049                assert!(d.contains('A'));
1050            } else {
1051                panic!("expected path node for obround");
1052            }
1053        } else {
1054            panic!("expected copper group");
1055        }
1056    }
1057}