Skip to main content

kiutils_kicad/
footprint.rs

1use std::fs;
2use std::path::Path;
3
4use kiutils_sexpr::{parse_one, Atom, CstDocument, Node};
5
6use crate::diagnostic::{Diagnostic, Severity};
7use crate::sexpr_edit::{
8    atom_quoted, atom_symbol, ensure_root_head_any, mutate_root_and_refresh,
9    remove_property as remove_property_node, root_head, upsert_property_preserve_tail,
10    upsert_scalar,
11};
12use crate::sexpr_utils::{
13    atom_as_f64, atom_as_string, head_of, list_child_head_count, second_atom_bool, second_atom_f64,
14    second_atom_i32, second_atom_string,
15};
16use crate::version_diag::collect_version_diagnostics;
17use crate::{Error, UnknownNode, WriteMode};
18
19// --- Footprint pad types ---
20
21#[derive(Debug, Clone, PartialEq)]
22#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
23pub struct FpPadNet {
24    pub code: Option<i32>,
25    pub name: Option<String>,
26}
27
28#[derive(Debug, Clone, PartialEq)]
29#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
30pub struct FpPadDrill {
31    pub shape: Option<String>,
32    pub diameter: Option<f64>,
33    pub width: Option<f64>,
34    pub offset: Option<[f64; 2]>,
35}
36
37#[derive(Debug, Clone, PartialEq)]
38#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
39pub struct FpPad {
40    pub number: Option<String>,
41    pub pad_type: Option<String>,
42    pub shape: Option<String>,
43    pub at: Option<[f64; 2]>,
44    pub rotation: Option<f64>,
45    pub size: Option<[f64; 2]>,
46    pub layers: Vec<String>,
47    pub net: Option<FpPadNet>,
48    pub drill: Option<FpPadDrill>,
49    pub uuid: Option<String>,
50    pub pin_function: Option<String>,
51    pub pin_type: Option<String>,
52    pub locked: bool,
53    pub property: Option<String>,
54    pub remove_unused_layers: bool,
55    pub keep_end_layers: bool,
56    pub roundrect_rratio: Option<f64>,
57    pub chamfer_ratio: Option<f64>,
58    pub chamfer: Vec<String>,
59    pub die_length: Option<f64>,
60    pub solder_mask_margin: Option<f64>,
61    pub solder_paste_margin: Option<f64>,
62    pub solder_paste_margin_ratio: Option<f64>,
63    pub clearance: Option<f64>,
64    pub zone_connect: Option<i32>,
65    pub thermal_width: Option<f64>,
66    pub thermal_gap: Option<f64>,
67    pub custom_clearance: Option<String>,
68    pub custom_anchor: Option<String>,
69    pub custom_primitives: usize,
70}
71
72// --- Footprint graphic types ---
73
74#[derive(Debug, Clone, PartialEq)]
75#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
76pub struct FpGraphic {
77    pub token: String,
78    pub layer: Option<String>,
79    pub text: Option<String>,
80    pub start: Option<[f64; 2]>,
81    pub end: Option<[f64; 2]>,
82    pub center: Option<[f64; 2]>,
83    pub uuid: Option<String>,
84    pub locked: bool,
85    pub width: Option<f64>,
86    pub stroke_type: Option<String>,
87    pub fill_type: Option<String>,
88    pub at: Option<[f64; 2]>,
89    pub angle: Option<f64>,
90    pub font_size: Option<[f64; 2]>,
91    pub font_thickness: Option<f64>,
92}
93
94// --- Footprint model type ---
95
96#[derive(Debug, Clone, PartialEq)]
97#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
98pub struct FpModel {
99    pub path: Option<String>,
100    pub at: Option<[f64; 3]>,
101    pub scale: Option<[f64; 3]>,
102    pub rotate: Option<[f64; 3]>,
103    pub hide: bool,
104}
105
106// --- Footprint zone type ---
107
108#[derive(Debug, Clone, PartialEq)]
109#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
110pub struct FpZone {
111    pub net: Option<i32>,
112    pub net_name: Option<String>,
113    pub name: Option<String>,
114    pub layer: Option<String>,
115    pub layers: Vec<String>,
116    pub hatch: Option<String>,
117    pub fill_enabled: Option<bool>,
118    pub polygon_count: usize,
119    pub filled_polygon_count: usize,
120    pub has_keepout: bool,
121}
122
123// --- Footprint group type ---
124
125#[derive(Debug, Clone, PartialEq, Eq)]
126#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
127pub struct FpGroup {
128    pub name: Option<String>,
129    pub group_id: Option<String>,
130    pub member_count: usize,
131}
132
133#[derive(Debug, Clone, PartialEq, Eq)]
134#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
135pub struct FpProperty {
136    pub key: String,
137    pub value: String,
138}
139
140#[derive(Debug, Clone, PartialEq)]
141#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
142pub struct FootprintAst {
143    pub lib_id: Option<String>,
144    pub version: Option<i32>,
145    pub tedit: Option<String>,
146    pub generator: Option<String>,
147    pub generator_version: Option<String>,
148    pub layer: Option<String>,
149    pub descr: Option<String>,
150    pub tags: Option<String>,
151    pub property_count: usize,
152    pub attr_present: bool,
153    pub locked_present: bool,
154    pub private_layers_present: bool,
155    pub net_tie_pad_groups_present: bool,
156    pub embedded_fonts_present: bool,
157    pub has_embedded_files: bool,
158    pub embedded_file_count: usize,
159    pub clearance: Option<String>,
160    pub solder_mask_margin: Option<String>,
161    pub solder_paste_margin: Option<String>,
162    pub solder_paste_margin_ratio: Option<String>,
163    pub duplicate_pad_numbers_are_jumpers: Option<bool>,
164    pub pad_count: usize,
165    pub pads: Vec<FpPad>,
166    pub model_count: usize,
167    pub models: Vec<FpModel>,
168    pub zone_count: usize,
169    pub zones: Vec<FpZone>,
170    pub group_count: usize,
171    pub groups: Vec<FpGroup>,
172    pub fp_line_count: usize,
173    pub fp_rect_count: usize,
174    pub fp_circle_count: usize,
175    pub fp_arc_count: usize,
176    pub fp_poly_count: usize,
177    pub fp_curve_count: usize,
178    pub fp_text_count: usize,
179    pub fp_text_box_count: usize,
180    pub dimension_count: usize,
181    pub graphic_count: usize,
182    pub graphics: Vec<FpGraphic>,
183    pub attr: Vec<String>,
184    pub locked: bool,
185    pub placed: bool,
186    pub private_layers: Vec<String>,
187    pub net_tie_pad_groups: Vec<Vec<String>>,
188    pub reference: Option<String>,
189    pub value: Option<String>,
190    pub properties: Vec<FpProperty>,
191    pub unknown_nodes: Vec<UnknownNode>,
192}
193#[derive(Debug, Clone)]
194pub struct FootprintDocument {
195    ast: FootprintAst,
196    cst: CstDocument,
197    diagnostics: Vec<Diagnostic>,
198    ast_dirty: bool,
199}
200
201impl FootprintDocument {
202    pub fn ast(&self) -> &FootprintAst {
203        &self.ast
204    }
205
206    pub fn ast_mut(&mut self) -> &mut FootprintAst {
207        self.ast_dirty = true;
208        &mut self.ast
209    }
210
211    pub fn set_lib_id<S: Into<String>>(&mut self, lib_id: S) -> &mut Self {
212        let lib_id = lib_id.into();
213        self.mutate_root_items(|items| {
214            let value = atom_quoted(lib_id);
215            if let Some(current) = items.get(1) {
216                if *current == value {
217                    false
218                } else {
219                    items[1] = value;
220                    true
221                }
222            } else {
223                items.push(value);
224                true
225            }
226        })
227    }
228
229    pub fn set_version(&mut self, version: i32) -> &mut Self {
230        self.mutate_root_items(|items| {
231            upsert_scalar(items, "version", atom_symbol(version.to_string()), 2)
232        })
233    }
234
235    pub fn set_generator<S: Into<String>>(&mut self, generator: S) -> &mut Self {
236        self.mutate_root_items(|items| {
237            upsert_scalar(items, "generator", atom_symbol(generator.into()), 2)
238        })
239    }
240
241    pub fn set_generator_version<S: Into<String>>(&mut self, generator_version: S) -> &mut Self {
242        self.mutate_root_items(|items| {
243            upsert_scalar(
244                items,
245                "generator_version",
246                atom_quoted(generator_version.into()),
247                2,
248            )
249        })
250    }
251
252    pub fn set_layer<S: Into<String>>(&mut self, layer: S) -> &mut Self {
253        self.mutate_root_items(|items| upsert_scalar(items, "layer", atom_quoted(layer.into()), 2))
254    }
255
256    pub fn set_descr<S: Into<String>>(&mut self, descr: S) -> &mut Self {
257        self.mutate_root_items(|items| upsert_scalar(items, "descr", atom_quoted(descr.into()), 2))
258    }
259
260    pub fn set_tags<S: Into<String>>(&mut self, tags: S) -> &mut Self {
261        self.mutate_root_items(|items| upsert_scalar(items, "tags", atom_quoted(tags.into()), 2))
262    }
263
264    pub fn set_reference<S: Into<String>>(&mut self, value: S) -> &mut Self {
265        self.upsert_property("Reference", value)
266    }
267
268    pub fn set_value<S: Into<String>>(&mut self, value: S) -> &mut Self {
269        self.upsert_property("Value", value)
270    }
271
272    pub fn upsert_property<K: Into<String>, V: Into<String>>(
273        &mut self,
274        key: K,
275        value: V,
276    ) -> &mut Self {
277        let key = key.into();
278        let value = value.into();
279        self.mutate_root_items(|items| upsert_property_preserve_tail(items, &key, &value, 2))
280    }
281
282    pub fn remove_property(&mut self, key: &str) -> &mut Self {
283        let key = key.to_string();
284        self.mutate_root_items(|items| remove_property_node(items, &key, 2))
285    }
286
287    pub fn cst(&self) -> &CstDocument {
288        &self.cst
289    }
290
291    pub fn diagnostics(&self) -> &[Diagnostic] {
292        &self.diagnostics
293    }
294
295    pub fn write<P: AsRef<Path>>(&self, path: P) -> Result<(), Error> {
296        self.write_mode(path, WriteMode::Lossless)
297    }
298
299    pub fn write_mode<P: AsRef<Path>>(&self, path: P, mode: WriteMode) -> Result<(), Error> {
300        if self.ast_dirty {
301            return Err(Error::Validation(
302                "ast_mut changes are not serializable; use document setter APIs".to_string(),
303            ));
304        }
305        match mode {
306            WriteMode::Lossless => fs::write(path, self.cst.to_lossless_string())?,
307            WriteMode::Canonical => fs::write(path, self.cst.to_canonical_string())?,
308        }
309        Ok(())
310    }
311
312    fn mutate_root_items<F>(&mut self, mutate: F) -> &mut Self
313    where
314        F: FnOnce(&mut Vec<Node>) -> bool,
315    {
316        mutate_root_and_refresh(
317            &mut self.cst,
318            &mut self.ast,
319            &mut self.diagnostics,
320            mutate,
321            parse_ast,
322            |cst, ast| collect_diagnostics(cst, ast.version),
323        );
324        self.ast_dirty = false;
325        self
326    }
327}
328
329pub struct FootprintFile;
330
331impl FootprintFile {
332    pub fn read<P: AsRef<Path>>(path: P) -> Result<FootprintDocument, Error> {
333        let raw = fs::read_to_string(path)?;
334        let cst = parse_one(&raw)?;
335        ensure_root_head_any(&cst, &["footprint", "module"])?;
336        let ast = parse_ast(&cst);
337        let diagnostics = collect_diagnostics(&cst, ast.version);
338        Ok(FootprintDocument {
339            ast,
340            cst,
341            diagnostics,
342            ast_dirty: false,
343        })
344    }
345}
346
347fn collect_diagnostics(cst: &CstDocument, version: Option<i32>) -> Vec<Diagnostic> {
348    let mut diagnostics = collect_version_diagnostics(version);
349    if root_head(cst) == Some("module") {
350        diagnostics.push(Diagnostic {
351            severity: Severity::Warning,
352            code: "legacy_root",
353            message: "legacy root token `module` detected; parsing in compatibility mode"
354                .to_string(),
355            span: None,
356            hint: Some("save from newer KiCad to normalize root token to `footprint`".to_string()),
357        });
358    }
359    diagnostics
360}
361
362fn parse_ast(cst: &CstDocument) -> FootprintAst {
363    let mut lib_id = None;
364    let mut version = None;
365    let mut tedit = None;
366    let mut generator = None;
367    let mut generator_version = None;
368    let mut layer = None;
369    let mut descr = None;
370    let mut tags = None;
371    let mut property_count = 0usize;
372    let mut attr_present = false;
373    let mut locked_present = false;
374    let mut private_layers_present = false;
375    let mut net_tie_pad_groups_present = false;
376    let mut embedded_fonts_present = false;
377    let mut has_embedded_files = false;
378    let mut embedded_file_count = 0usize;
379    let mut clearance = None;
380    let mut solder_mask_margin = None;
381    let mut solder_paste_margin = None;
382    let mut solder_paste_margin_ratio = None;
383    let mut duplicate_pad_numbers_are_jumpers = None;
384    let mut pad_count = 0usize;
385    let mut pads = Vec::new();
386    let mut model_count = 0usize;
387    let mut models = Vec::new();
388    let mut zone_count = 0usize;
389    let mut zones = Vec::new();
390    let mut group_count = 0usize;
391    let mut groups = Vec::new();
392    let mut fp_line_count = 0usize;
393    let mut fp_rect_count = 0usize;
394    let mut fp_circle_count = 0usize;
395    let mut fp_arc_count = 0usize;
396    let mut fp_poly_count = 0usize;
397    let mut fp_curve_count = 0usize;
398    let mut fp_text_count = 0usize;
399    let mut fp_text_box_count = 0usize;
400    let mut dimension_count = 0usize;
401    let mut graphic_count = 0usize;
402    let mut graphics = Vec::new();
403    let mut attr = Vec::new();
404    let mut locked = false;
405    let mut placed = false;
406    let mut private_layers = Vec::new();
407    let mut net_tie_pad_groups = Vec::new();
408    let mut reference = None;
409    let mut value = None;
410    let mut properties = Vec::new();
411    let mut unknown_nodes = Vec::new();
412
413    if let Some(Node::List { items, .. }) = cst.nodes.first() {
414        lib_id = items.get(1).and_then(atom_as_string);
415        for item in items.iter().skip(2) {
416            match head_of(item) {
417                Some("version") => version = second_atom_i32(item),
418                Some("tedit") => tedit = second_atom_string(item),
419                Some("generator") => generator = second_atom_string(item),
420                Some("generator_version") => generator_version = second_atom_string(item),
421                Some("layer") => layer = second_atom_string(item),
422                Some("descr") => descr = second_atom_string(item),
423                Some("tags") => tags = second_atom_string(item),
424                Some("property") => {
425                    property_count += 1;
426                    if let Node::List { items: props, .. } = item {
427                        let key = props.get(1).and_then(atom_as_string);
428                        let val = props.get(2).and_then(atom_as_string);
429                        if let (Some(k), Some(v)) = (key.clone(), val.clone()) {
430                            properties.push(FpProperty { key: k, value: v });
431                        }
432                        match key.as_deref() {
433                            Some("Reference") => reference = val,
434                            Some("Value") => value = val,
435                            _ => {}
436                        }
437                    }
438                }
439                Some("attr") => {
440                    attr_present = true;
441                    if let Node::List { items: inner, .. } = item {
442                        attr = inner.iter().skip(1).filter_map(atom_as_string).collect();
443                    }
444                }
445                Some("locked") => {
446                    locked_present = true;
447                    locked = true;
448                }
449                Some("placed") => placed = true,
450                Some("private_layers") => {
451                    private_layers_present = true;
452                    if let Node::List { items: inner, .. } = item {
453                        private_layers = inner.iter().skip(1).filter_map(atom_as_string).collect();
454                    }
455                }
456                Some("net_tie_pad_groups") => {
457                    net_tie_pad_groups_present = true;
458                    if let Node::List { items: inner, .. } = item {
459                        for child in inner.iter().skip(1) {
460                            if let Node::List { items: grp, .. } = child {
461                                let group: Vec<String> =
462                                    grp.iter().filter_map(atom_as_string).collect();
463                                if !group.is_empty() {
464                                    net_tie_pad_groups.push(group);
465                                }
466                            }
467                        }
468                    }
469                }
470                Some("embedded_fonts") => embedded_fonts_present = true,
471                Some("embedded_files") => {
472                    has_embedded_files = true;
473                    embedded_file_count = list_child_head_count(item, "file");
474                }
475                Some("clearance") => clearance = second_atom_string(item),
476                Some("solder_mask_margin") => solder_mask_margin = second_atom_string(item),
477                Some("solder_paste_margin") => solder_paste_margin = second_atom_string(item),
478                Some("solder_paste_margin_ratio") => {
479                    solder_paste_margin_ratio = second_atom_string(item)
480                }
481                Some("duplicate_pad_numbers_are_jumpers") => {
482                    duplicate_pad_numbers_are_jumpers =
483                        second_atom_string(item).and_then(|s| match s.as_str() {
484                            "yes" => Some(true),
485                            "no" => Some(false),
486                            _ => None,
487                        })
488                }
489                Some("pad") => {
490                    pad_count += 1;
491                    pads.push(parse_fp_pad(item));
492                }
493                Some("model") => {
494                    model_count += 1;
495                    models.push(parse_fp_model(item));
496                }
497                Some("zone") => {
498                    zone_count += 1;
499                    zones.push(parse_fp_zone(item));
500                }
501                Some("group") => {
502                    group_count += 1;
503                    groups.push(parse_fp_group(item));
504                }
505                Some("fp_line") => {
506                    fp_line_count += 1;
507                    graphic_count += 1;
508                    graphics.push(parse_fp_graphic(item, "fp_line"));
509                }
510                Some("fp_rect") => {
511                    fp_rect_count += 1;
512                    graphic_count += 1;
513                    graphics.push(parse_fp_graphic(item, "fp_rect"));
514                }
515                Some("fp_circle") => {
516                    fp_circle_count += 1;
517                    graphic_count += 1;
518                    graphics.push(parse_fp_graphic(item, "fp_circle"));
519                }
520                Some("fp_arc") => {
521                    fp_arc_count += 1;
522                    graphic_count += 1;
523                    graphics.push(parse_fp_graphic(item, "fp_arc"));
524                }
525                Some("fp_poly") => {
526                    fp_poly_count += 1;
527                    graphic_count += 1;
528                    graphics.push(parse_fp_graphic(item, "fp_poly"));
529                }
530                Some("fp_curve") => {
531                    fp_curve_count += 1;
532                    graphic_count += 1;
533                    graphics.push(parse_fp_graphic(item, "fp_curve"));
534                }
535                Some("fp_text") => {
536                    fp_text_count += 1;
537                    graphic_count += 1;
538                    graphics.push(parse_fp_graphic(item, "fp_text"));
539                }
540                Some("fp_text_box") => {
541                    fp_text_box_count += 1;
542                    graphic_count += 1;
543                    graphics.push(parse_fp_graphic(item, "fp_text_box"));
544                }
545                Some("dimension") => dimension_count += 1,
546                _ => {
547                    if let Some(unknown) = UnknownNode::from_node(item) {
548                        unknown_nodes.push(unknown);
549                    }
550                }
551            }
552        }
553    }
554
555    FootprintAst {
556        lib_id,
557        version,
558        tedit,
559        generator,
560        generator_version,
561        layer,
562        descr,
563        tags,
564        property_count,
565        attr_present,
566        locked_present,
567        private_layers_present,
568        net_tie_pad_groups_present,
569        embedded_fonts_present,
570        has_embedded_files,
571        embedded_file_count,
572        clearance,
573        solder_mask_margin,
574        solder_paste_margin,
575        solder_paste_margin_ratio,
576        duplicate_pad_numbers_are_jumpers,
577        pad_count,
578        pads,
579        model_count,
580        models,
581        zone_count,
582        zones,
583        group_count,
584        groups,
585        fp_line_count,
586        fp_rect_count,
587        fp_circle_count,
588        fp_arc_count,
589        fp_poly_count,
590        fp_curve_count,
591        fp_text_count,
592        fp_text_box_count,
593        dimension_count,
594        graphic_count,
595        graphics,
596        attr,
597        locked,
598        placed,
599        private_layers,
600        net_tie_pad_groups,
601        reference,
602        value,
603        properties,
604        unknown_nodes,
605    }
606}
607
608fn parse_fp_pad(node: &Node) -> FpPad {
609    let Node::List { items, .. } = node else {
610        return FpPad {
611            number: None,
612            pad_type: None,
613            shape: None,
614            at: None,
615            rotation: None,
616            size: None,
617            layers: Vec::new(),
618            net: None,
619            drill: None,
620            uuid: None,
621            pin_function: None,
622            pin_type: None,
623            locked: false,
624            property: None,
625            remove_unused_layers: false,
626            keep_end_layers: false,
627            roundrect_rratio: None,
628            chamfer_ratio: None,
629            chamfer: Vec::new(),
630            die_length: None,
631            solder_mask_margin: None,
632            solder_paste_margin: None,
633            solder_paste_margin_ratio: None,
634            clearance: None,
635            zone_connect: None,
636            thermal_width: None,
637            thermal_gap: None,
638            custom_clearance: None,
639            custom_anchor: None,
640            custom_primitives: 0,
641        };
642    };
643    let number = items.get(1).and_then(atom_as_string);
644    let pad_type = items.get(2).and_then(atom_as_string);
645    let shape = items.get(3).and_then(atom_as_string);
646    let mut at = None;
647    let mut rotation = None;
648    let mut size = None;
649    let mut layers = Vec::new();
650    let mut net = None;
651    let mut drill = None;
652    let mut uuid = None;
653    let mut pin_function = None;
654    let mut pin_type = None;
655    let mut locked = items
656        .iter()
657        .any(|n| matches!(n, Node::Atom { atom: Atom::Symbol(s), .. } if s == "locked"));
658    let mut property = None;
659    let mut remove_unused_layers = false;
660    let mut keep_end_layers = false;
661    let mut roundrect_rratio = None;
662    let mut chamfer_ratio = None;
663    let mut chamfer = Vec::new();
664    let mut die_length = None;
665    let mut solder_mask_margin = None;
666    let mut solder_paste_margin = None;
667    let mut solder_paste_margin_ratio = None;
668    let mut clearance = None;
669    let mut zone_connect = None;
670    let mut thermal_width = None;
671    let mut thermal_gap = None;
672    let mut custom_clearance = None;
673    let mut custom_anchor = None;
674    let mut custom_primitives = 0usize;
675
676    for child in items.iter().skip(4) {
677        match head_of(child) {
678            Some("at") => {
679                let (xy, rot) = parse_fp_xy_and_angle(child);
680                at = xy;
681                rotation = rot;
682            }
683            Some("size") => size = parse_fp_xy(child),
684            Some("layers") => {
685                if let Node::List { items: inner, .. } = child {
686                    layers = inner.iter().skip(1).filter_map(atom_as_string).collect();
687                }
688            }
689            Some("net") => {
690                if let Node::List { items: inner, .. } = child {
691                    net = Some(FpPadNet {
692                        code: inner
693                            .get(1)
694                            .and_then(atom_as_string)
695                            .and_then(|s| s.parse().ok()),
696                        name: inner.get(2).and_then(atom_as_string),
697                    });
698                }
699            }
700            Some("drill") => drill = Some(parse_fp_drill(child)),
701            Some("uuid") => uuid = second_atom_string(child),
702            Some("pinfunction") => pin_function = second_atom_string(child),
703            Some("pintype") => pin_type = second_atom_string(child),
704            Some("locked") => locked = true,
705            Some("property") => property = second_atom_string(child),
706            Some("remove_unused_layer") | Some("remove_unused_layers") => {
707                remove_unused_layers = true
708            }
709            Some("keep_end_layers") => keep_end_layers = true,
710            Some("roundrect_rratio") => roundrect_rratio = second_atom_f64(child),
711            Some("chamfer_ratio") => chamfer_ratio = second_atom_f64(child),
712            Some("chamfer") => {
713                if let Node::List { items: inner, .. } = child {
714                    chamfer = inner.iter().skip(1).filter_map(atom_as_string).collect();
715                }
716            }
717            Some("die_length") => die_length = second_atom_f64(child),
718            Some("solder_mask_margin") => solder_mask_margin = second_atom_f64(child),
719            Some("solder_paste_margin") => solder_paste_margin = second_atom_f64(child),
720            Some("solder_paste_margin_ratio") => solder_paste_margin_ratio = second_atom_f64(child),
721            Some("clearance") => clearance = second_atom_f64(child),
722            Some("zone_connect") => {
723                zone_connect = second_atom_string(child).and_then(|s| s.parse().ok())
724            }
725            Some("thermal_width") => thermal_width = second_atom_f64(child),
726            Some("thermal_gap") => thermal_gap = second_atom_f64(child),
727            Some("options") => {
728                if let Node::List { items: inner, .. } = child {
729                    for opt in inner.iter().skip(1) {
730                        match head_of(opt) {
731                            Some("clearance") => custom_clearance = second_atom_string(opt),
732                            Some("anchor") => custom_anchor = second_atom_string(opt),
733                            _ => {}
734                        }
735                    }
736                }
737            }
738            Some("primitives") => {
739                if let Node::List { items: inner, .. } = child {
740                    custom_primitives = inner.len().saturating_sub(1);
741                }
742            }
743            _ => {}
744        }
745    }
746    FpPad {
747        number,
748        pad_type,
749        shape,
750        at,
751        rotation,
752        size,
753        layers,
754        net,
755        drill,
756        uuid,
757        pin_function,
758        pin_type,
759        locked,
760        property,
761        remove_unused_layers,
762        keep_end_layers,
763        roundrect_rratio,
764        chamfer_ratio,
765        chamfer,
766        die_length,
767        solder_mask_margin,
768        solder_paste_margin,
769        solder_paste_margin_ratio,
770        clearance,
771        zone_connect,
772        thermal_width,
773        thermal_gap,
774        custom_clearance,
775        custom_anchor,
776        custom_primitives,
777    }
778}
779
780fn parse_fp_drill(node: &Node) -> FpPadDrill {
781    let Node::List { items, .. } = node else {
782        return FpPadDrill {
783            shape: None,
784            diameter: None,
785            width: None,
786            offset: None,
787        };
788    };
789    let mut shape = None;
790    let mut diameter = None;
791    let mut width = None;
792    let mut offset = None;
793    for child in items.iter().skip(1) {
794        match child {
795            Node::List { .. } => {
796                if matches!(head_of(child), Some("offset")) {
797                    offset = parse_fp_xy(child);
798                }
799            }
800            Node::Atom { .. } => {
801                if let Some(value) = atom_as_f64(child) {
802                    if diameter.is_none() {
803                        diameter = Some(value);
804                    } else if width.is_none() {
805                        width = Some(value);
806                    }
807                } else if let Some(token) = atom_as_string(child) {
808                    shape = Some(token);
809                }
810            }
811        }
812    }
813    FpPadDrill {
814        shape,
815        diameter,
816        width,
817        offset,
818    }
819}
820
821fn parse_fp_graphic(node: &Node, token: &str) -> FpGraphic {
822    let mut layer = None;
823    let mut text = None;
824    let mut start = None;
825    let mut end = None;
826    let mut center = None;
827    let mut uuid = None;
828    let mut locked = false;
829    let mut width = None;
830    let mut stroke_type = None;
831    let mut fill_type = None;
832    let mut at = None;
833    let mut angle = None;
834    let mut font_size = None;
835    let mut font_thickness = None;
836    if let Node::List { items, .. } = node {
837        if matches!(token, "fp_text" | "fp_text_box") {
838            text = items.get(1).and_then(atom_as_string);
839        }
840        locked = items
841            .iter()
842            .any(|n| matches!(n, Node::Atom { atom: Atom::Symbol(s), .. } if s == "locked"));
843        for child in items.iter().skip(1) {
844            match head_of(child) {
845                Some("layer") => layer = second_atom_string(child),
846                Some("start") => start = parse_fp_xy(child),
847                Some("end") => end = parse_fp_xy(child),
848                Some("center") => center = parse_fp_xy(child),
849                Some("uuid") => uuid = second_atom_string(child),
850                Some("locked") => locked = true,
851                Some("width") => width = second_atom_f64(child),
852                Some("stroke") => {
853                    if let Node::List { items: inner, .. } = child {
854                        for s in inner.iter().skip(1) {
855                            match head_of(s) {
856                                Some("width") => width = second_atom_f64(s),
857                                Some("type") => stroke_type = second_atom_string(s),
858                                _ => {}
859                            }
860                        }
861                    }
862                }
863                Some("fill") => {
864                    if let Node::List { items: inner, .. } = child {
865                        for f in inner.iter().skip(1) {
866                            if head_of(f) == Some("type") {
867                                fill_type = second_atom_string(f);
868                            }
869                        }
870                    }
871                }
872                Some("at") => {
873                    let (xy, rot) = parse_fp_xy_and_angle(child);
874                    at = xy;
875                    angle = rot;
876                }
877                Some("effects") => {
878                    if let Node::List { items: inner, .. } = child {
879                        for e in inner.iter().skip(1) {
880                            if head_of(e) == Some("font") {
881                                if let Node::List {
882                                    items: font_items, ..
883                                } = e
884                                {
885                                    for fi in font_items.iter().skip(1) {
886                                        match head_of(fi) {
887                                            Some("size") => font_size = parse_fp_xy(fi),
888                                            Some("thickness") => {
889                                                font_thickness = second_atom_f64(fi)
890                                            }
891                                            _ => {}
892                                        }
893                                    }
894                                }
895                            }
896                        }
897                    }
898                }
899                _ => {}
900            }
901        }
902    }
903    FpGraphic {
904        token: token.to_string(),
905        layer,
906        text,
907        start,
908        end,
909        center,
910        uuid,
911        locked,
912        width,
913        stroke_type,
914        fill_type,
915        at,
916        angle,
917        font_size,
918        font_thickness,
919    }
920}
921
922fn parse_fp_model(node: &Node) -> FpModel {
923    let Node::List { items, .. } = node else {
924        return FpModel {
925            path: None,
926            at: None,
927            scale: None,
928            rotate: None,
929            hide: false,
930        };
931    };
932    let path = items.get(1).and_then(atom_as_string);
933    let mut at = None;
934    let mut scale = None;
935    let mut rotate = None;
936    let hide = items
937        .iter()
938        .any(|n| matches!(n, Node::Atom { atom: Atom::Symbol(s), .. } if s == "hide"));
939    for child in items.iter().skip(2) {
940        match head_of(child) {
941            Some("at") | Some("offset") => at = parse_fp_model_xyz(child),
942            Some("scale") => scale = parse_fp_model_xyz(child),
943            Some("rotate") => rotate = parse_fp_model_xyz(child),
944            _ => {}
945        }
946    }
947    FpModel {
948        path,
949        at,
950        scale,
951        rotate,
952        hide,
953    }
954}
955
956fn parse_fp_model_xyz(node: &Node) -> Option<[f64; 3]> {
957    let Node::List { items, .. } = node else {
958        return None;
959    };
960    for child in items.iter().skip(1) {
961        if head_of(child) == Some("xyz") {
962            if let Node::List {
963                items: xyz_items, ..
964            } = child
965            {
966                let x = xyz_items.get(1).and_then(atom_as_f64)?;
967                let y = xyz_items.get(2).and_then(atom_as_f64)?;
968                let z = xyz_items.get(3).and_then(atom_as_f64)?;
969                return Some([x, y, z]);
970            }
971        }
972    }
973    None
974}
975
976fn parse_fp_zone(node: &Node) -> FpZone {
977    let mut net = None;
978    let mut net_name = None;
979    let mut name = None;
980    let mut layer = None;
981    let mut layers = Vec::new();
982    let mut hatch = None;
983    let mut fill_enabled = None;
984    let mut polygon_count = 0usize;
985    let mut filled_polygon_count = 0usize;
986    let mut has_keepout = false;
987    if let Node::List { items, .. } = node {
988        for child in items.iter().skip(1) {
989            match head_of(child) {
990                Some("net") => net = second_atom_string(child).and_then(|s| s.parse().ok()),
991                Some("net_name") => net_name = second_atom_string(child),
992                Some("name") => name = second_atom_string(child),
993                Some("layer") => layer = second_atom_string(child),
994                Some("layers") => {
995                    if let Node::List { items: inner, .. } = child {
996                        layers = inner.iter().skip(1).filter_map(atom_as_string).collect();
997                    }
998                }
999                Some("hatch") => hatch = second_atom_string(child),
1000                Some("fill") => fill_enabled = second_atom_bool(child),
1001                Some("polygon") => polygon_count += 1,
1002                Some("filled_polygon") => filled_polygon_count += 1,
1003                Some("keepout") => has_keepout = true,
1004                _ => {}
1005            }
1006        }
1007    }
1008    FpZone {
1009        net,
1010        net_name,
1011        name,
1012        layer,
1013        layers,
1014        hatch,
1015        fill_enabled,
1016        polygon_count,
1017        filled_polygon_count,
1018        has_keepout,
1019    }
1020}
1021
1022fn parse_fp_group(node: &Node) -> FpGroup {
1023    let mut name = None;
1024    let mut group_id = None;
1025    let mut member_count = 0usize;
1026    if let Node::List { items, .. } = node {
1027        for child in items.iter().skip(1) {
1028            match head_of(child) {
1029                Some("name") => name = second_atom_string(child),
1030                Some("id") => group_id = second_atom_string(child),
1031                Some("members") => {
1032                    if let Node::List { items: inner, .. } = child {
1033                        member_count = inner.len().saturating_sub(1);
1034                    }
1035                }
1036                _ => {}
1037            }
1038        }
1039    }
1040    FpGroup {
1041        name,
1042        group_id,
1043        member_count,
1044    }
1045}
1046
1047fn parse_fp_xy(node: &Node) -> Option<[f64; 2]> {
1048    let Node::List { items, .. } = node else {
1049        return None;
1050    };
1051    let x = items.get(1).and_then(atom_as_string)?.parse::<f64>().ok()?;
1052    let y = items.get(2).and_then(atom_as_string)?.parse::<f64>().ok()?;
1053    Some([x, y])
1054}
1055
1056fn parse_fp_xy_and_angle(node: &Node) -> (Option<[f64; 2]>, Option<f64>) {
1057    let Node::List { items, .. } = node else {
1058        return (None, None);
1059    };
1060    let x = items.get(1).and_then(atom_as_f64);
1061    let y = items.get(2).and_then(atom_as_f64);
1062    let rot = items.get(3).and_then(atom_as_f64);
1063    match (x, y) {
1064        (Some(x), Some(y)) => (Some([x, y]), rot),
1065        _ => (None, rot),
1066    }
1067}
1068
1069#[cfg(test)]
1070mod tests {
1071    use std::path::PathBuf;
1072    use std::time::{SystemTime, UNIX_EPOCH};
1073
1074    use super::*;
1075
1076    fn tmp_file(name: &str) -> PathBuf {
1077        let nanos = SystemTime::now()
1078            .duration_since(UNIX_EPOCH)
1079            .expect("clock")
1080            .as_nanos();
1081        std::env::temp_dir().join(format!("{name}_{nanos}.kicad_mod"))
1082    }
1083
1084    #[test]
1085    fn read_footprint_and_preserve_lossless() {
1086        let path = tmp_file("footprint_read_ok");
1087        let src = "(footprint \"R_0603\" (version 20260101) (generator pcbnew))\n";
1088        fs::write(&path, src).expect("write fixture");
1089
1090        let doc = FootprintFile::read(&path).expect("read");
1091        assert_eq!(doc.ast().lib_id.as_deref(), Some("R_0603"));
1092        assert_eq!(doc.ast().version, Some(20260101));
1093        assert_eq!(doc.ast().generator.as_deref(), Some("pcbnew"));
1094        assert!(doc.ast().unknown_nodes.is_empty());
1095        assert_eq!(doc.cst().to_lossless_string(), src);
1096
1097        let _ = fs::remove_file(path);
1098    }
1099
1100    #[test]
1101    fn read_footprint_warns_on_future_version() {
1102        let path = tmp_file("footprint_future");
1103        fs::write(
1104            &path,
1105            "(footprint \"R\" (version 20270101) (generator pcbnew))\n",
1106        )
1107        .expect("write fixture");
1108
1109        let doc = FootprintFile::read(&path).expect("read");
1110        assert_eq!(doc.diagnostics().len(), 1);
1111
1112        let _ = fs::remove_file(path);
1113    }
1114
1115    #[test]
1116    fn read_footprint_warns_on_legacy_version() {
1117        let path = tmp_file("footprint_legacy");
1118        fs::write(
1119            &path,
1120            "(footprint \"R\" (version 20221018) (generator pcbnew))\n",
1121        )
1122        .expect("write fixture");
1123
1124        let doc = FootprintFile::read(&path).expect("read");
1125        assert_eq!(doc.diagnostics().len(), 1);
1126        assert_eq!(doc.diagnostics()[0].code, "legacy_format");
1127
1128        let _ = fs::remove_file(path);
1129    }
1130
1131    #[test]
1132    fn read_footprint_accepts_legacy_module_root() {
1133        let path = tmp_file("footprint_module_root");
1134        let src = "(module R_0603 (layer F.Cu) (tedit 5F0C7995) (attr smd))\n";
1135        fs::write(&path, src).expect("write fixture");
1136
1137        let doc = FootprintFile::read(&path).expect("read");
1138        assert_eq!(doc.ast().lib_id.as_deref(), Some("R_0603"));
1139        assert_eq!(doc.ast().tedit.as_deref(), Some("5F0C7995"));
1140        assert!(doc.ast().attr_present);
1141        assert_eq!(doc.diagnostics().len(), 1);
1142        assert_eq!(doc.diagnostics()[0].code, "legacy_root");
1143
1144        let _ = fs::remove_file(path);
1145    }
1146
1147    #[test]
1148    fn read_footprint_captures_unknown_nodes() {
1149        let path = tmp_file("footprint_unknown");
1150        let src =
1151            "(footprint \"R\" (version 20260101) (generator pcbnew) (future_shape foo bar))\n";
1152        fs::write(&path, src).expect("write fixture");
1153
1154        let doc = FootprintFile::read(&path).expect("read");
1155        assert_eq!(doc.ast().unknown_nodes.len(), 1);
1156        assert_eq!(
1157            doc.ast().unknown_nodes[0].head.as_deref(),
1158            Some("future_shape")
1159        );
1160
1161        let _ = fs::remove_file(path);
1162    }
1163
1164    #[test]
1165    fn read_footprint_parses_top_level_counts() {
1166        let path = tmp_file("footprint_counts");
1167        let src = "(footprint \"X\" (version 20260101) (generator pcbnew) (generator_version \"10.0\") (layer \"F.Cu\")\n  (descr \"demo\")\n  (tags \"a b\")\n  (property \"Reference\" \"R?\")\n  (property \"Value\" \"X\")\n  (attr smd)\n  (private_layers \"In1.Cu\")\n  (net_tie_pad_groups \"1,2\")\n  (solder_mask_margin 0.02)\n  (solder_paste_margin -0.01)\n  (solder_paste_margin_ratio -0.2)\n  (duplicate_pad_numbers_are_jumpers yes)\n  (fp_text reference \"R1\" (at 0 0) (layer \"F.SilkS\"))\n  (fp_line (start 0 0) (end 1 1) (layer \"F.SilkS\"))\n  (pad \"1\" smd rect (at 0 0) (size 1 1) (layers \"F.Cu\" \"F.Mask\"))\n  (model \"foo.step\")\n  (zone)\n  (group (id \"g1\"))\n  (dimension)\n)\n";
1168        fs::write(&path, src).expect("write fixture");
1169
1170        let doc = FootprintFile::read(&path).expect("read");
1171        assert_eq!(doc.ast().lib_id.as_deref(), Some("X"));
1172        assert_eq!(doc.ast().generator_version.as_deref(), Some("10.0"));
1173        assert_eq!(doc.ast().layer.as_deref(), Some("F.Cu"));
1174        assert_eq!(doc.ast().property_count, 2);
1175        assert!(doc.ast().attr_present);
1176        assert!(!doc.ast().locked_present);
1177        assert!(doc.ast().private_layers_present);
1178        assert!(doc.ast().net_tie_pad_groups_present);
1179        assert!(!doc.ast().embedded_fonts_present);
1180        assert!(!doc.ast().has_embedded_files);
1181        assert_eq!(doc.ast().embedded_file_count, 0);
1182        assert_eq!(doc.ast().clearance, None);
1183        assert_eq!(doc.ast().solder_mask_margin.as_deref(), Some("0.02"));
1184        assert_eq!(doc.ast().solder_paste_margin.as_deref(), Some("-0.01"));
1185        assert_eq!(doc.ast().solder_paste_margin_ratio.as_deref(), Some("-0.2"));
1186        assert_eq!(doc.ast().duplicate_pad_numbers_are_jumpers, Some(true));
1187        assert_eq!(doc.ast().fp_text_count, 1);
1188        assert_eq!(doc.ast().fp_line_count, 1);
1189        assert_eq!(doc.ast().graphic_count, 2);
1190        assert_eq!(doc.ast().pad_count, 1);
1191        assert_eq!(doc.ast().model_count, 1);
1192        assert_eq!(doc.ast().zone_count, 1);
1193        assert_eq!(doc.ast().group_count, 1);
1194        assert_eq!(doc.ast().dimension_count, 1);
1195        assert!(doc.ast().unknown_nodes.is_empty());
1196
1197        let _ = fs::remove_file(path);
1198    }
1199
1200    #[test]
1201    fn parses_embedded_fonts_regression() {
1202        let path = tmp_file("footprint_embedded_fonts");
1203        let src = "(footprint \"X\" (version 20260101) (generator pcbnew) (embedded_fonts no))\n";
1204        fs::write(&path, src).expect("write fixture");
1205
1206        let doc = FootprintFile::read(&path).expect("read");
1207        assert!(doc.ast().embedded_fonts_present);
1208        assert!(doc.ast().unknown_nodes.is_empty());
1209
1210        let _ = fs::remove_file(path);
1211    }
1212
1213    #[test]
1214    fn parses_locked_regression() {
1215        let path = tmp_file("footprint_locked");
1216        let src = "(footprint \"X\" (locked) (version 20260101) (generator pcbnew))\n";
1217        fs::write(&path, src).expect("write fixture");
1218
1219        let doc = FootprintFile::read(&path).expect("read");
1220        assert!(doc.ast().locked_present);
1221        assert!(doc.ast().unknown_nodes.is_empty());
1222
1223        let _ = fs::remove_file(path);
1224    }
1225
1226    #[test]
1227    fn parses_solder_margins_and_jumpers_regression() {
1228        let path = tmp_file("footprint_margins_jumpers");
1229        let src = "(footprint \"X\" (version 20260101) (generator pcbnew)\n  (clearance 0.15)\n  (solder_mask_margin 0.03)\n  (solder_paste_margin -0.02)\n  (solder_paste_margin_ratio -0.3)\n  (duplicate_pad_numbers_are_jumpers no)\n)\n";
1230        fs::write(&path, src).expect("write fixture");
1231
1232        let doc = FootprintFile::read(&path).expect("read");
1233        assert_eq!(doc.ast().clearance.as_deref(), Some("0.15"));
1234        assert_eq!(doc.ast().solder_mask_margin.as_deref(), Some("0.03"));
1235        assert_eq!(doc.ast().solder_paste_margin.as_deref(), Some("-0.02"));
1236        assert_eq!(doc.ast().solder_paste_margin_ratio.as_deref(), Some("-0.3"));
1237        assert_eq!(doc.ast().duplicate_pad_numbers_are_jumpers, Some(false));
1238        assert!(doc.ast().unknown_nodes.is_empty());
1239
1240        let _ = fs::remove_file(path);
1241    }
1242
1243    #[test]
1244    fn parses_embedded_files_regression() {
1245        let path = tmp_file("footprint_embedded_files");
1246        let src = "(footprint \"X\" (version 20260101) (generator pcbnew)\n  (embedded_files\n    (file \"A\" \"base64\")\n    (file \"B\" \"base64\")\n  )\n)\n";
1247        fs::write(&path, src).expect("write fixture");
1248
1249        let doc = FootprintFile::read(&path).expect("read");
1250        assert!(doc.ast().has_embedded_files);
1251        assert_eq!(doc.ast().embedded_file_count, 2);
1252        assert!(doc.ast().unknown_nodes.is_empty());
1253
1254        let _ = fs::remove_file(path);
1255    }
1256
1257    #[test]
1258    fn edit_roundtrip_updates_core_fields_and_properties() {
1259        let path = tmp_file("footprint_edit_input");
1260        let src = "(footprint \"Old\" (version 20241229) (generator pcbnew) (layer \"F.Cu\")\n  (property \"Reference\" \"R1\")\n  (property \"Value\" \"10k\")\n  (future_shape foo bar)\n)\n";
1261        fs::write(&path, src).expect("write fixture");
1262
1263        let mut doc = FootprintFile::read(&path).expect("read");
1264        doc.set_lib_id("New_Footprint")
1265            .set_version(20260101)
1266            .set_generator("kiutils")
1267            .set_generator_version("dev")
1268            .set_layer("B.Cu")
1269            .set_descr("demo footprint")
1270            .set_tags("r c passives")
1271            .set_reference("R99")
1272            .set_value("22k")
1273            .upsert_property("LCSC", "C1234")
1274            .remove_property("DoesNotExist");
1275
1276        let out = tmp_file("footprint_edit_output");
1277        doc.write(&out).expect("write");
1278        let written = fs::read_to_string(&out).expect("read out");
1279        assert!(written.contains("(future_shape foo bar)"));
1280        assert!(written.contains("(property \"LCSC\" \"C1234\")"));
1281
1282        let reread = FootprintFile::read(&out).expect("reread");
1283        assert_eq!(reread.ast().lib_id.as_deref(), Some("New_Footprint"));
1284        assert_eq!(reread.ast().version, Some(20260101));
1285        assert_eq!(reread.ast().generator.as_deref(), Some("kiutils"));
1286        assert_eq!(reread.ast().generator_version.as_deref(), Some("dev"));
1287        assert_eq!(reread.ast().layer.as_deref(), Some("B.Cu"));
1288        assert_eq!(reread.ast().descr.as_deref(), Some("demo footprint"));
1289        assert_eq!(reread.ast().tags.as_deref(), Some("r c passives"));
1290        assert_eq!(reread.ast().property_count, 3);
1291        assert_eq!(reread.ast().unknown_nodes.len(), 1);
1292
1293        let _ = fs::remove_file(path);
1294        let _ = fs::remove_file(out);
1295    }
1296
1297    #[test]
1298    fn remove_property_roundtrip_removes_entry() {
1299        let path = tmp_file("footprint_remove_property");
1300        let src = "(footprint \"X\" (version 20260101) (generator pcbnew)\n  (property \"Reference\" \"R1\")\n  (property \"Value\" \"10k\")\n)\n";
1301        fs::write(&path, src).expect("write fixture");
1302
1303        let mut doc = FootprintFile::read(&path).expect("read");
1304        doc.remove_property("Value");
1305
1306        let out = tmp_file("footprint_remove_property_out");
1307        doc.write(&out).expect("write");
1308        let reread = FootprintFile::read(&out).expect("reread");
1309        assert_eq!(reread.ast().property_count, 1);
1310
1311        let _ = fs::remove_file(path);
1312        let _ = fs::remove_file(out);
1313    }
1314
1315    #[test]
1316    fn no_op_setter_keeps_lossless_raw_unchanged() {
1317        let path = tmp_file("footprint_noop_setter");
1318        let src = "(footprint \"X\" (version   20260101) (generator pcbnew))\n";
1319        fs::write(&path, src).expect("write fixture");
1320
1321        let mut doc = FootprintFile::read(&path).expect("read");
1322        doc.set_version(20260101);
1323
1324        let out = tmp_file("footprint_noop_setter_out");
1325        doc.write(&out).expect("write");
1326        let written = fs::read_to_string(&out).expect("read out");
1327        assert_eq!(written, src);
1328
1329        let _ = fs::remove_file(path);
1330        let _ = fs::remove_file(out);
1331    }
1332}