Skip to main content

altium_format/ops/
pcblib.rs

1// SPDX-License-Identifier: GPL-3.0-only
2// SPDX-FileCopyrightText: 2026 Alexander Kiselev <alex@akiselev.com>
3//
4//! PCB footprint library operations.
5//!
6//! High-level operations for exploring and manipulating Altium PCB footprint library (.PcbLib) files.
7
8// cmd_* functions mix presentation and business logic; separation punted until usage patterns clarify abstraction boundaries (premature abstraction risk)
9
10use std::collections::HashMap;
11use std::fs::File;
12use std::io::{BufReader, Cursor};
13use std::path::{Path, PathBuf};
14
15use png;
16use resvg;
17use serde::{Deserialize, Serialize};
18use serde_json;
19
20use crate::dump::fmt_coord_val;
21use crate::footprint::{
22    AsciiOptions, FootprintBuilder, PadRowDirection, SvgOptions, render_ascii, render_svg,
23};
24use crate::io::PcbLib;
25use crate::records::pcb::{PcbPad, PcbPadShape, PcbRecord, PcbText};
26use crate::types::{Layer, Unit};
27
28use super::util::alphanumeric_sort;
29use crate::ops::output::*;
30
31fn open_pcblib(path: &Path) -> Result<PcbLib, Box<dyn std::error::Error>> {
32    let file = File::open(path)?;
33    Ok(PcbLib::open(BufReader::new(file))?)
34}
35
36/// Categorize a footprint by its pattern name.
37fn categorize_footprint(pattern: &str, description: &str) -> &'static str {
38    let pattern_lower = pattern.to_lowercase();
39    let desc_lower = description.to_lowercase();
40
41    // Package types
42    if pattern_lower.contains("qfp")
43        || pattern_lower.contains("tqfp")
44        || pattern_lower.contains("lqfp")
45    {
46        return "QFP";
47    }
48    if pattern_lower.contains("qfn")
49        || pattern_lower.contains("dfn")
50        || pattern_lower.contains("mlf")
51    {
52        return "QFN/DFN";
53    }
54    if pattern_lower.contains("bga")
55        || pattern_lower.contains("csbga")
56        || pattern_lower.contains("wlcsp")
57    {
58        return "BGA";
59    }
60    if pattern_lower.contains("soic")
61        || pattern_lower.contains("so-")
62        || pattern_lower.contains("sop")
63    {
64        return "SOIC/SOP";
65    }
66    if pattern_lower.contains("ssop")
67        || pattern_lower.contains("tssop")
68        || pattern_lower.contains("msop")
69    {
70        return "SSOP/TSSOP";
71    }
72    if pattern_lower.contains("sot") {
73        return "SOT";
74    }
75    if pattern_lower.contains("dip") || pattern_lower.contains("pdip") {
76        return "DIP";
77    }
78    if pattern_lower.contains("to-")
79        || pattern_lower.contains("to2")
80        || pattern_lower.contains("to3")
81        || pattern_lower.contains("dpak")
82        || pattern_lower.contains("d2pak")
83    {
84        return "TO/DPAK";
85    }
86
87    // Passive components
88    if pattern_lower.starts_with("0402")
89        || pattern_lower.starts_with("0603")
90        || pattern_lower.starts_with("0805")
91        || pattern_lower.starts_with("1206")
92        || pattern_lower.starts_with("1210")
93        || pattern_lower.starts_with("0201")
94        || pattern_lower.starts_with("1812")
95        || pattern_lower.starts_with("2010")
96        || pattern_lower.starts_with("2512")
97    {
98        return "Chip (SMD)";
99    }
100    if pattern_lower.contains("cap") || desc_lower.contains("capacitor") {
101        return "Capacitor";
102    }
103    if pattern_lower.contains("res") || desc_lower.contains("resistor") {
104        return "Resistor";
105    }
106    if pattern_lower.contains("ind")
107        || pattern_lower.contains("ferrite")
108        || desc_lower.contains("inductor")
109    {
110        return "Inductor";
111    }
112
113    // Connectors
114    if pattern_lower.contains("header")
115        || pattern_lower.contains("conn")
116        || pattern_lower.contains("socket")
117        || pattern_lower.contains("pin")
118        || pattern_lower.contains("terminal")
119    {
120        return "Connector";
121    }
122    if pattern_lower.contains("usb") {
123        return "USB";
124    }
125    if pattern_lower.contains("rj45") || pattern_lower.contains("ethernet") {
126        return "RJ45/Ethernet";
127    }
128
129    // Diodes/LEDs
130    if pattern_lower.contains("diode")
131        || pattern_lower.contains("sod")
132        || pattern_lower.contains("sma")
133        || pattern_lower.contains("smb")
134        || pattern_lower.contains("smc")
135    {
136        return "Diode";
137    }
138    if pattern_lower.contains("led") {
139        return "LED";
140    }
141
142    // Crystal/Oscillator
143    if pattern_lower.contains("crystal")
144        || pattern_lower.contains("xtal")
145        || pattern_lower.contains("osc")
146    {
147        return "Crystal/Oscillator";
148    }
149
150    // Test points
151    if pattern_lower.contains("test") || pattern_lower.contains("tp") {
152        return "Test Point";
153    }
154
155    // Through-hole
156    if pattern_lower.contains("th")
157        || pattern_lower.contains("axial")
158        || pattern_lower.contains("radial")
159    {
160        return "Through-Hole";
161    }
162
163    "Other"
164}
165
166/// Get pad shape name.
167fn pad_shape_name(shape: PcbPadShape) -> &'static str {
168    shape.name()
169}
170
171/// Get record type name.
172fn record_type_name(record: &PcbRecord) -> &'static str {
173    match record {
174        PcbRecord::Arc(_) => "Arc",
175        PcbRecord::Pad(_) => "Pad",
176        PcbRecord::Via(_) => "Via",
177        PcbRecord::Track(_) => "Track",
178        PcbRecord::Text(_) => "Text",
179        PcbRecord::Fill(_) => "Fill",
180        PcbRecord::Region(_) => "Region",
181        PcbRecord::ComponentBody(_) => "ComponentBody",
182        PcbRecord::Polygon(_) => "Polygon",
183        PcbRecord::Unknown { .. } => "Unknown",
184    }
185}
186
187/// Format layer name.
188fn layer_name(layer: &Layer) -> String {
189    match layer.to_byte() {
190        1 => "Top".to_string(),
191        32 => "Bottom".to_string(),
192        74 => "Multi".to_string(),
193        _ => format!("L{}", layer.to_byte()),
194    }
195}
196
197// ═══════════════════════════════════════════════════════════════════════════
198// HIGH-LEVEL COMMANDS
199// ═══════════════════════════════════════════════════════════════════════════
200
201/// Complete library overview.
202pub fn cmd_overview(path: &Path) -> Result<PcbLibOverview, Box<dyn std::error::Error>> {
203    let lib = open_pcblib(path)?;
204
205    // ─────────────────────────────────────────────────────────────────────────
206    // 1. FOOTPRINTS BY CATEGORY
207    // ─────────────────────────────────────────────────────────────────────────
208    let mut categories: HashMap<&'static str, Vec<FootprintSummaryExt>> = HashMap::new();
209
210    for comp in lib.iter() {
211        let category = categorize_footprint(&comp.pattern, &comp.description);
212        categories
213            .entry(category)
214            .or_default()
215            .push(FootprintSummaryExt {
216                name: comp.pattern.clone(),
217                description: comp.description.clone(),
218                pad_count: comp.pad_count(),
219            });
220    }
221
222    // Sort categories by importance
223    let category_order = [
224        "QFP",
225        "QFN/DFN",
226        "BGA",
227        "SOIC/SOP",
228        "SSOP/TSSOP",
229        "SOT",
230        "DIP",
231        "TO/DPAK",
232        "Chip (SMD)",
233        "Capacitor",
234        "Resistor",
235        "Inductor",
236        "Connector",
237        "USB",
238        "RJ45/Ethernet",
239        "Diode",
240        "LED",
241        "Crystal/Oscillator",
242        "Test Point",
243        "Through-Hole",
244        "Other",
245    ];
246
247    let mut footprints_by_category = Vec::new();
248    for category in category_order.iter() {
249        if let Some(footprints) = categories.remove(*category) {
250            footprints_by_category.push((category.to_string(), footprints));
251        }
252    }
253
254    // Add any uncategorized
255    for (category, footprints) in categories {
256        if !footprints.is_empty() {
257            footprints_by_category.push((category.to_string(), footprints));
258        }
259    }
260
261    // ─────────────────────────────────────────────────────────────────────────
262    // 2. PAD STATISTICS
263    // ─────────────────────────────────────────────────────────────────────────
264    let mut total_pads = 0;
265    let mut smd_pads = 0;
266    let mut th_pads = 0;
267    let mut pad_shapes: HashMap<&'static str, usize> = HashMap::new();
268
269    for comp in lib.iter() {
270        for pad in comp.pads() {
271            total_pads += 1;
272            if pad.has_hole() {
273                th_pads += 1;
274            } else {
275                smd_pads += 1;
276            }
277            *pad_shapes
278                .entry(pad_shape_name(pad.shape_top()))
279                .or_insert(0) += 1;
280        }
281    }
282
283    let mut pad_shapes_vec: Vec<_> = pad_shapes
284        .into_iter()
285        .map(|(k, v)| (k.to_string(), v))
286        .collect();
287    pad_shapes_vec.sort_by(|a, b| b.1.cmp(&a.1));
288
289    // ─────────────────────────────────────────────────────────────────────────
290    // 3. COMMON HOLE SIZES
291    // ─────────────────────────────────────────────────────────────────────────
292    let mut hole_sizes: HashMap<String, usize> = HashMap::new();
293    for comp in lib.iter() {
294        for pad in comp.pads() {
295            if pad.has_hole() && pad.hole_size.to_raw() > 0 {
296                let size_str = fmt_coord_val(&pad.hole_size);
297                *hole_sizes.entry(size_str).or_insert(0) += 1;
298            }
299        }
300    }
301
302    let mut hole_sizes_vec: Vec<_> = hole_sizes.into_iter().collect();
303    hole_sizes_vec.sort_by(|a, b| b.1.cmp(&a.1));
304
305    // ─────────────────────────────────────────────────────────────────────────
306    // 4. LARGEST FOOTPRINTS
307    // ─────────────────────────────────────────────────────────────────────────
308    let mut by_pads: Vec<_> = lib.iter().collect();
309    by_pads.sort_by_key(|b| std::cmp::Reverse(b.pad_count()));
310
311    let largest_footprints = by_pads
312        .iter()
313        .take(10)
314        .map(|comp| FootprintSummaryExt {
315            name: comp.pattern.clone(),
316            description: comp.description.clone(),
317            pad_count: comp.pad_count(),
318        })
319        .collect();
320
321    Ok(PcbLibOverview {
322        path: path.display().to_string(),
323        total_footprints: lib.components.len(),
324        unique_id: lib.unique_id.clone(),
325        footprints_by_category,
326        pad_statistics: PadStatistics {
327            total_pads,
328            smd_pads,
329            th_pads,
330            pad_shapes: pad_shapes_vec,
331        },
332        hole_sizes: hole_sizes_vec.into_iter().take(10).collect(),
333        largest_footprints,
334    })
335}
336
337/// List all footprints.
338pub fn cmd_list(path: &Path) -> Result<PcbLibFootprintList, Box<dyn std::error::Error>> {
339    let lib = open_pcblib(path)?;
340
341    let footprints = lib
342        .iter()
343        .map(|comp| FootprintSummaryExt {
344            name: comp.pattern.clone(),
345            description: comp.description.clone(),
346            pad_count: comp.pad_count(),
347        })
348        .collect();
349
350    Ok(PcbLibFootprintList {
351        path: path.display().to_string(),
352        total_footprints: lib.components.len(),
353        footprints,
354    })
355}
356
357/// Search for footprints.
358pub fn cmd_search(
359    path: &Path,
360    query: &str,
361) -> Result<PcbLibSearchResults, Box<dyn std::error::Error>> {
362    let lib = open_pcblib(path)?;
363
364    let query_lower = query.to_lowercase();
365    let has_wildcard = query.contains('*');
366
367    let matches: Vec<_> = lib
368        .iter()
369        .filter(|comp| {
370            let name = comp.pattern.to_lowercase();
371            let desc = comp.description.to_lowercase();
372
373            if has_wildcard {
374                let pattern = query_lower.replace('*', "");
375                name.contains(&pattern) || desc.contains(&pattern)
376            } else {
377                name.contains(&query_lower) || desc.contains(&query_lower)
378            }
379        })
380        .map(|comp| FootprintSummaryExt {
381            name: comp.pattern.clone(),
382            description: comp.description.clone(),
383            pad_count: comp.pad_count(),
384        })
385        .collect();
386
387    Ok(PcbLibSearchResults {
388        query: query.to_string(),
389        total_matches: matches.len(),
390        results: matches,
391    })
392}
393
394// ═══════════════════════════════════════════════════════════════════════════
395// DETAILED COMMANDS
396// ═══════════════════════════════════════════════════════════════════════════
397
398/// Library info and statistics.
399pub fn cmd_info(path: &Path) -> Result<PcbLibInfo, Box<dyn std::error::Error>> {
400    let lib = open_pcblib(path)?;
401
402    // Count primitive types across all footprints
403    let mut primitive_counts: HashMap<&'static str, usize> = HashMap::new();
404    let mut total_primitives = 0;
405
406    for comp in lib.iter() {
407        for prim in &comp.primitives {
408            let name = record_type_name(prim);
409            *primitive_counts.entry(name).or_insert(0) += 1;
410            total_primitives += 1;
411        }
412    }
413
414    let mut primitive_types: Vec<_> = primitive_counts
415        .into_iter()
416        .map(|(k, v)| (k.to_string(), v))
417        .collect();
418    primitive_types.sort_by(|a, b| b.1.cmp(&a.1));
419
420    Ok(PcbLibInfo {
421        path: path.display().to_string(),
422        footprint_count: lib.components.len(),
423        unique_id: lib.unique_id.clone(),
424        total_primitives,
425        primitive_types,
426    })
427}
428
429/// Footprint details.
430pub fn cmd_footprint(
431    path: &Path,
432    name: &str,
433    show_primitives: bool,
434) -> Result<PcbLibFootprintDetail, Box<dyn std::error::Error>> {
435    let lib = open_pcblib(path)?;
436
437    let name_lower = name.to_lowercase();
438    let comp = lib
439        .iter()
440        .find(|c| c.pattern.to_lowercase() == name_lower)
441        .ok_or_else(|| format!("Footprint '{}' not found", name))?;
442
443    // Bounds
444    let bounds = comp.calculate_bounds();
445
446    // List pads
447    let mut pads: Vec<&PcbPad> = comp.pads().collect();
448    pads.sort_by(|a, b| alphanumeric_sort(&a.designator, &b.designator));
449
450    let pad_details = pads
451        .iter()
452        .map(|pad| {
453            let size = pad.size_top();
454            let size_str = format!("{}x{}", fmt_coord_val(&size.x), fmt_coord_val(&size.y));
455            let hole_str = if pad.has_hole() {
456                Some(fmt_coord_val(&pad.hole_size))
457            } else {
458                None
459            };
460            PadDetail {
461                designator: pad.designator.clone(),
462                shape: pad_shape_name(pad.shape_top()).to_string(),
463                size: size_str,
464                hole: hole_str,
465                layer: layer_name(&pad.common.layer),
466            }
467        })
468        .collect();
469
470    let primitive_counts = if show_primitives {
471        let mut prim_counts: HashMap<&'static str, usize> = HashMap::new();
472        for prim in &comp.primitives {
473            *prim_counts.entry(record_type_name(prim)).or_insert(0) += 1;
474        }
475        let mut counts: Vec<_> = prim_counts
476            .into_iter()
477            .map(|(k, v)| (k.to_string(), v))
478            .collect();
479        counts.sort_by(|a, b| b.1.cmp(&a.1));
480        Some(counts)
481    } else {
482        None
483    };
484
485    Ok(PcbLibFootprintDetail {
486        pattern: comp.pattern.clone(),
487        description: comp.description.clone(),
488        height: if comp.height.to_raw() > 0 {
489            fmt_coord_val(&comp.height)
490        } else {
491            String::new()
492        },
493        pad_count: comp.pad_count(),
494        total_primitives: comp.primitive_count(),
495        bounding_box: BoundingBox {
496            width: fmt_coord_val(&bounds.width()),
497            height: fmt_coord_val(&bounds.height()),
498        },
499        pads: pad_details,
500        primitive_counts,
501    })
502}
503
504/// List pads.
505pub fn cmd_pads(
506    path: &Path,
507    footprint_filter: Option<String>,
508    by_shape: bool,
509) -> Result<PcbLibPadList, Box<dyn std::error::Error>> {
510    let lib = open_pcblib(path)?;
511
512    let filter_lower = footprint_filter.as_ref().map(|s| s.to_lowercase());
513
514    let mut all_pads: Vec<PadWithFootprint> = Vec::new();
515
516    for comp in lib.iter() {
517        if let Some(ref filter) = filter_lower {
518            if !comp.pattern.to_lowercase().contains(filter) {
519                continue;
520            }
521        }
522
523        for pad in comp.pads() {
524            let size = pad.size_top();
525            let size_str = format!("{}x{}", fmt_coord_val(&size.x), fmt_coord_val(&size.y));
526            let hole_str = if pad.has_hole() {
527                Some(fmt_coord_val(&pad.hole_size))
528            } else {
529                None
530            };
531            all_pads.push(PadWithFootprint {
532                footprint_name: comp.pattern.clone(),
533                designator: pad.designator.clone(),
534                size: size_str,
535                hole: hole_str,
536                shape: pad_shape_name(pad.shape_top()).to_string(),
537            });
538        }
539    }
540
541    let pads_by_shape = if by_shape {
542        let mut by_shape: HashMap<String, Vec<PadWithFootprint>> = HashMap::new();
543        for pad in &all_pads {
544            by_shape
545                .entry(pad.shape.clone())
546                .or_default()
547                .push(pad.clone());
548        }
549
550        let shape_order = ["Round", "Rectangular", "Rounded Rect", "Octagonal"];
551        let mut result = Vec::new();
552        for shape in shape_order {
553            if let Some(pads) = by_shape.remove(shape) {
554                result.push((shape.to_string(), pads));
555            }
556        }
557        // Add remaining shapes
558        for (shape, pads) in by_shape {
559            result.push((shape, pads));
560        }
561        Some(result)
562    } else {
563        None
564    };
565
566    Ok(PcbLibPadList {
567        path: path.display().to_string(),
568        total_pads: all_pads.len(),
569        pads: all_pads,
570        pads_by_shape,
571    })
572}
573
574/// Show primitives for a footprint.
575pub fn cmd_primitives(
576    path: &Path,
577    name: &str,
578) -> Result<PcbLibPrimitiveList, Box<dyn std::error::Error>> {
579    let lib = open_pcblib(path)?;
580
581    let name_lower = name.to_lowercase();
582    let comp = lib
583        .iter()
584        .find(|c| c.pattern.to_lowercase() == name_lower)
585        .ok_or_else(|| format!("Footprint '{}' not found", name))?;
586
587    let primitives = comp
588        .primitives
589        .iter()
590        .map(|prim| match prim {
591            PcbRecord::Pad(p) => {
592                let size = p.size_top();
593                let hole = if p.has_hole() {
594                    Some(fmt_coord_val(&p.hole_size))
595                } else {
596                    None
597                };
598                PrimitiveDetail::Pad {
599                    designator: p.designator.clone(),
600                    shape: pad_shape_name(p.shape_top()).to_string(),
601                    size: format!("{}x{}", fmt_coord_val(&size.x), fmt_coord_val(&size.y)),
602                    hole,
603                }
604            }
605            PcbRecord::Track(t) => PrimitiveDetail::Track {
606                start_x: fmt_coord_val(&t.start.x),
607                start_y: fmt_coord_val(&t.start.y),
608                end_x: fmt_coord_val(&t.end.x),
609                end_y: fmt_coord_val(&t.end.y),
610                width: fmt_coord_val(&t.width),
611            },
612            PcbRecord::Arc(a) => PrimitiveDetail::Arc {
613                center_x: fmt_coord_val(&a.location.x),
614                center_y: fmt_coord_val(&a.location.y),
615                radius: fmt_coord_val(&a.radius),
616                start_angle: a.start_angle,
617                end_angle: a.end_angle,
618            },
619            PcbRecord::Text(t) => PrimitiveDetail::Text {
620                text: t.text.clone(),
621                x: fmt_coord_val(&t.base.corner1.x),
622                y: fmt_coord_val(&t.base.corner1.y),
623            },
624            PcbRecord::Fill(f) => PrimitiveDetail::Fill {
625                x1: fmt_coord_val(&f.base.corner1.x),
626                y1: fmt_coord_val(&f.base.corner1.y),
627                x2: fmt_coord_val(&f.base.corner2.x),
628                y2: fmt_coord_val(&f.base.corner2.y),
629            },
630            PcbRecord::Region(r) => PrimitiveDetail::Region {
631                vertex_count: r.outline.len(),
632                layer: layer_name(&r.common.layer),
633            },
634            PcbRecord::ComponentBody(b) => PrimitiveDetail::ComponentBody {
635                vertex_count: b.outline.len(),
636                height: fmt_coord_val(&b.overall_height),
637            },
638            _ => PrimitiveDetail::Other {
639                primitive_type: record_type_name(prim).to_string(),
640            },
641        })
642        .collect();
643
644    Ok(PcbLibPrimitiveList {
645        footprint_name: comp.pattern.clone(),
646        total_primitives: comp.primitive_count(),
647        primitives,
648    })
649}
650
651/// Analyze hole sizes.
652pub fn cmd_holes(path: &Path) -> Result<PcbLibHoleAnalysis, Box<dyn std::error::Error>> {
653    let lib = open_pcblib(path)?;
654
655    let mut hole_sizes: HashMap<String, Vec<String>> = HashMap::new();
656
657    for comp in lib.iter() {
658        for pad in comp.pads() {
659            if pad.has_hole() && pad.hole_size.to_raw() > 0 {
660                let size_str = fmt_coord_val(&pad.hole_size);
661                hole_sizes
662                    .entry(size_str)
663                    .or_default()
664                    .push(comp.pattern.clone());
665            }
666        }
667    }
668
669    let mut hole_size_infos: Vec<_> = hole_sizes
670        .into_iter()
671        .map(|(size, footprints)| {
672            // Deduplicate footprint names
673            let unique_footprints: std::collections::HashSet<_> = footprints.into_iter().collect();
674            let count = unique_footprints.len();
675            let example_footprints: Vec<_> = unique_footprints.into_iter().take(3).collect();
676
677            HoleSizeInfo {
678                size,
679                count,
680                example_footprints,
681            }
682        })
683        .collect();
684
685    // Sort by count (descending)
686    hole_size_infos.sort_by(|a, b| b.count.cmp(&a.count));
687
688    Ok(PcbLibHoleAnalysis {
689        path: path.display().to_string(),
690        hole_sizes: hole_size_infos,
691    })
692}
693
694/// Export as JSON.
695pub fn cmd_json(path: &Path, full: bool) -> Result<PcbLibJson, Box<dyn std::error::Error>> {
696    let lib = open_pcblib(path)?;
697
698    let footprints: Vec<FootprintJsonData> = lib
699        .iter()
700        .map(|comp| {
701            let pads = if full {
702                Some(
703                    comp.pads()
704                        .map(|pad| {
705                            let size = pad.size_top();
706                            PadJsonData {
707                                designator: pad.designator.clone(),
708                                shape: pad_shape_name(pad.shape_top()).to_string(),
709                                size_x: fmt_coord_val(&size.x),
710                                size_y: fmt_coord_val(&size.y),
711                                hole_size: if pad.has_hole() {
712                                    Some(fmt_coord_val(&pad.hole_size))
713                                } else {
714                                    None
715                                },
716                                layer: layer_name(&pad.common.layer),
717                            }
718                        })
719                        .collect(),
720                )
721            } else {
722                None
723            };
724
725            FootprintJsonData {
726                name: comp.pattern.clone(),
727                description: comp.description.clone(),
728                pad_count: comp.pad_count(),
729                primitive_count: comp.primitive_count(),
730                pads,
731            }
732        })
733        .collect();
734
735    Ok(PcbLibJson {
736        file: path.display().to_string(),
737        footprint_count: lib.components.len(),
738        unique_id: lib.unique_id.clone(),
739        footprints,
740    })
741}
742
743// ═══════════════════════════════════════════════════════════════════════════
744// MEASUREMENT COMMAND IMPLEMENTATION
745// ═══════════════════════════════════════════════════════════════════════════
746
747use crate::footprint::{
748    Measurement, analyze_pitch, generate_report, measure_dimensions, measure_pad,
749    measure_pad_distance, minimum_pad_clearance, pad_to_silkscreen_clearance,
750};
751
752/// Measure distances and dimensions in a footprint.
753pub fn cmd_measure(
754    path: &Path,
755    name: &str,
756    measure_type: &str,
757    pad1: Option<String>,
758    pad2: Option<String>,
759    pad: Option<String>,
760    output_json: bool,
761) -> Result<(), Box<dyn std::error::Error>> {
762    let lib = open_pcblib(path)?;
763
764    let name_lower = name.to_lowercase();
765    let component = lib
766        .iter()
767        .find(|c| c.pattern.to_lowercase() == name_lower || matches_pattern(&c.pattern, name))
768        .ok_or_else(|| format!("Footprint '{}' not found", name))?;
769
770    match measure_type.to_lowercase().as_str() {
771        "all" | "report" => {
772            let report = generate_report(component);
773
774            if output_json {
775                print_measurement_report_json(&report)?;
776            } else {
777                print_measurement_report(&report);
778            }
779        }
780        "distance" | "dist" => {
781            let p1 = pad1.ok_or("--pad1 required for distance measurement")?;
782            let p2 = pad2.ok_or("--pad2 required for distance measurement")?;
783
784            let dist = measure_pad_distance(component, &p1, &p2).ok_or_else(|| {
785                format!("Could not measure distance between pads {} and {}", p1, p2)
786            })?;
787
788            if output_json {
789                print_distance_json(&dist)?;
790            } else {
791                println!("Distance: {} to {}", dist.pad1, dist.pad2);
792                println!("  Center-to-center: {}", dist.center_to_center.display());
793                println!("  Edge-to-edge:     {}", dist.edge_to_edge.display());
794            }
795        }
796        "pitch" => {
797            let pitches = analyze_pitch(component);
798
799            if output_json {
800                print_pitch_json(&pitches)?;
801            } else if pitches.is_empty() {
802                println!("No regular pitch detected (footprint may have irregular pad spacing)");
803            } else {
804                println!("Pitch Analysis for: {}", component.pattern);
805                println!("═══════════════════════════════════════════════════════════════");
806                for pitch_info in &pitches {
807                    println!(
808                        "\n{} pitch: {}",
809                        pitch_info.direction,
810                        pitch_info.pitch.display()
811                    );
812                    println!("  {} pad pairs with this spacing", pitch_info.count);
813                    for (p1, p2, dist) in pitch_info.pad_pairs.iter().take(5) {
814                        println!("    {} ↔ {}: {}", p1, p2, dist.display());
815                    }
816                    if pitch_info.pad_pairs.len() > 5 {
817                        println!("    ... and {} more pairs", pitch_info.pad_pairs.len() - 5);
818                    }
819                }
820            }
821        }
822        "dimensions" | "dims" | "bounds" => {
823            let dims = measure_dimensions(component);
824
825            if output_json {
826                print_dimensions_json(&dims)?;
827            } else {
828                println!("Dimensions for: {}", component.pattern);
829                println!("═══════════════════════════════════════════════════════════════");
830                println!("  Width:  {}", dims.width.display());
831                println!("  Height: {}", dims.height.display());
832                println!(
833                    "  X range: {} to {}",
834                    dims.min_x.display(),
835                    dims.max_x.display()
836                );
837                println!(
838                    "  Y range: {} to {}",
839                    dims.min_y.display(),
840                    dims.max_y.display()
841                );
842            }
843        }
844        "clearance" | "clear" => {
845            let pad_clear = minimum_pad_clearance(component);
846            let silk_clear = pad_to_silkscreen_clearance(component);
847
848            if output_json {
849                print_clearance_json(pad_clear.as_ref(), silk_clear.as_ref())?;
850            } else {
851                println!("Clearance Analysis for: {}", component.pattern);
852                println!("═══════════════════════════════════════════════════════════════");
853
854                if let Some(pc) = pad_clear {
855                    println!("\nMinimum pad-to-pad clearance: {}", pc.clearance.display());
856                    println!("  Location: {}", pc.location);
857                } else {
858                    println!("\nNo pad-to-pad clearance (single pad or overlapping pads)");
859                }
860
861                if let Some(sc) = silk_clear {
862                    println!("\nPad-to-silkscreen clearance: {}", sc.clearance.display());
863                    println!("  Location: {}", sc.location);
864                } else {
865                    println!("\nNo silkscreen elements found");
866                }
867            }
868        }
869        "pad" => {
870            let des = pad.ok_or("--pad required for pad measurement")?;
871            let info =
872                measure_pad(component, &des).ok_or_else(|| format!("Pad '{}' not found", des))?;
873
874            if output_json {
875                print_pad_json(&info)?;
876            } else {
877                println!("Pad {} info:", info.designator);
878                println!("═══════════════════════════════════════════════════════════════");
879                println!("  Position: ({}, {})", info.x.display(), info.y.display());
880                println!(
881                    "  Size:     {} x {}",
882                    info.width.display(),
883                    info.height.display()
884                );
885                println!("  Shape:    {}", info.shape);
886                if let Some(hole) = &info.hole {
887                    println!("  Hole:     {}", hole.display());
888                } else {
889                    println!("  Type:     SMD");
890                }
891            }
892        }
893        "pads" => {
894            let report = generate_report(component);
895
896            if output_json {
897                print_all_pads_json(&report.pads)?;
898            } else {
899                println!("All Pads for: {}", component.pattern);
900                println!("═══════════════════════════════════════════════════════════════");
901                println!(
902                    "\n{:<6} {:>10} {:>10} {:>10} {:>10} {:>10} Shape",
903                    "Pad", "X (mm)", "Y (mm)", "W (mm)", "H (mm)", "Hole"
904                );
905                println!(
906                    "{:-<6} {:->10} {:->10} {:->10} {:->10} {:->10} {:-<12}",
907                    "", "", "", "", "", "", ""
908                );
909
910                for pad_info in &report.pads {
911                    let hole_str = pad_info
912                        .hole
913                        .as_ref()
914                        .map(|h| format!("{:.3}", h.mm))
915                        .unwrap_or_else(|| "-".to_string());
916
917                    println!(
918                        "{:<6} {:>10.3} {:>10.3} {:>10.3} {:>10.3} {:>10} {}",
919                        pad_info.designator,
920                        pad_info.x.mm,
921                        pad_info.y.mm,
922                        pad_info.width.mm,
923                        pad_info.height.mm,
924                        hole_str,
925                        pad_info.shape
926                    );
927                }
928            }
929        }
930        _ => {
931            return Err(format!(
932                "Unknown measurement type: '{}'. Use: all, distance, pitch, dimensions, clearance, pad, pads",
933                measure_type
934            ).into());
935        }
936    }
937
938    Ok(())
939}
940
941/// Print full measurement report in human-readable format.
942fn print_measurement_report(report: &crate::footprint::MeasurementReport) {
943    println!("╔═══════════════════════════════════════════════════════════════╗");
944    println!("║                  FOOTPRINT MEASUREMENT REPORT                  ║");
945    println!("╚═══════════════════════════════════════════════════════════════╝");
946    println!("\nFootprint: {}", report.name);
947
948    // Dimensions
949    println!("\n┌─────────────────────────────────────────────────────────────────┐");
950    println!("│ DIMENSIONS                                                       │");
951    println!("└─────────────────────────────────────────────────────────────────┘");
952    println!("  Width:  {}", report.dimensions.width.display());
953    println!("  Height: {}", report.dimensions.height.display());
954
955    if let Some(span) = &report.row_span {
956        println!("  Row span: {}", span.display());
957    }
958
959    // Pads summary
960    println!("\n┌─────────────────────────────────────────────────────────────────┐");
961    println!(
962        "│ PADS ({} total)                                                   │",
963        report.pads.len()
964    );
965    println!("└─────────────────────────────────────────────────────────────────┘");
966
967    println!(
968        "\n{:<6} {:>10} {:>10} {:>10} {:>10} Shape",
969        "Pad", "X (mm)", "Y (mm)", "W (mm)", "H (mm)"
970    );
971    println!(
972        "{:-<6} {:->10} {:->10} {:->10} {:->10} {:-<12}",
973        "", "", "", "", "", ""
974    );
975
976    for pad in &report.pads {
977        println!(
978            "{:<6} {:>10.3} {:>10.3} {:>10.3} {:>10.3} {}",
979            pad.designator, pad.x.mm, pad.y.mm, pad.width.mm, pad.height.mm, pad.shape
980        );
981    }
982
983    // Pitch analysis
984    if !report.pitch.is_empty() {
985        println!("\n┌─────────────────────────────────────────────────────────────────┐");
986        println!("│ PITCH ANALYSIS                                                   │");
987        println!("└─────────────────────────────────────────────────────────────────┘");
988
989        for pitch_info in &report.pitch {
990            println!(
991                "\n  {} pitch: {}",
992                pitch_info.direction,
993                pitch_info.pitch.display()
994            );
995            println!("    {} adjacent pad pairs", pitch_info.count);
996        }
997    }
998
999    // Clearances
1000    println!("\n┌─────────────────────────────────────────────────────────────────┐");
1001    println!("│ CLEARANCES                                                       │");
1002    println!("└─────────────────────────────────────────────────────────────────┘");
1003
1004    if let Some(pc) = &report.min_pad_clearance {
1005        println!("\n  Minimum pad-to-pad gap: {}", pc.clearance.display());
1006        println!("    {}", pc.location);
1007    }
1008
1009    if let Some(sc) = &report.silkscreen_clearance {
1010        println!("\n  Pad-to-silkscreen: {}", sc.clearance.display());
1011        println!("    {}", sc.location);
1012    }
1013}
1014
1015// JSON output helpers
1016
1017fn print_measurement_report_json(
1018    report: &crate::footprint::MeasurementReport,
1019) -> Result<(), Box<dyn std::error::Error>> {
1020    #[derive(Serialize)]
1021    struct MeasurementJson {
1022        mm: f64,
1023        mils: f64,
1024    }
1025
1026    impl From<&Measurement> for MeasurementJson {
1027        fn from(m: &Measurement) -> Self {
1028            MeasurementJson {
1029                mm: m.mm,
1030                mils: m.mils,
1031            }
1032        }
1033    }
1034
1035    #[derive(Serialize)]
1036    struct PadInfoJson {
1037        designator: String,
1038        x_mm: f64,
1039        y_mm: f64,
1040        width_mm: f64,
1041        height_mm: f64,
1042        hole_mm: Option<f64>,
1043        shape: String,
1044    }
1045
1046    #[derive(Serialize)]
1047    struct PitchJson {
1048        pitch: MeasurementJson,
1049        direction: String,
1050        count: usize,
1051    }
1052
1053    #[derive(Serialize)]
1054    struct ClearanceJson {
1055        feature1: String,
1056        feature2: String,
1057        clearance: MeasurementJson,
1058        location: String,
1059    }
1060
1061    #[derive(Serialize)]
1062    struct ReportJson {
1063        name: String,
1064        dimensions: DimensionsJson,
1065        pads: Vec<PadInfoJson>,
1066        pitch: Vec<PitchJson>,
1067        min_pad_clearance: Option<ClearanceJson>,
1068        silkscreen_clearance: Option<ClearanceJson>,
1069        row_span: Option<MeasurementJson>,
1070    }
1071
1072    #[derive(Serialize)]
1073    struct DimensionsJson {
1074        width: MeasurementJson,
1075        height: MeasurementJson,
1076        min_x: MeasurementJson,
1077        max_x: MeasurementJson,
1078        min_y: MeasurementJson,
1079        max_y: MeasurementJson,
1080    }
1081
1082    let output = ReportJson {
1083        name: report.name.clone(),
1084        dimensions: DimensionsJson {
1085            width: (&report.dimensions.width).into(),
1086            height: (&report.dimensions.height).into(),
1087            min_x: (&report.dimensions.min_x).into(),
1088            max_x: (&report.dimensions.max_x).into(),
1089            min_y: (&report.dimensions.min_y).into(),
1090            max_y: (&report.dimensions.max_y).into(),
1091        },
1092        pads: report
1093            .pads
1094            .iter()
1095            .map(|p| PadInfoJson {
1096                designator: p.designator.clone(),
1097                x_mm: p.x.mm,
1098                y_mm: p.y.mm,
1099                width_mm: p.width.mm,
1100                height_mm: p.height.mm,
1101                hole_mm: p.hole.as_ref().map(|h| h.mm),
1102                shape: p.shape.clone(),
1103            })
1104            .collect(),
1105        pitch: report
1106            .pitch
1107            .iter()
1108            .map(|p| PitchJson {
1109                pitch: (&p.pitch).into(),
1110                direction: p.direction.clone(),
1111                count: p.count,
1112            })
1113            .collect(),
1114        min_pad_clearance: report.min_pad_clearance.as_ref().map(|c| ClearanceJson {
1115            feature1: c.feature1.clone(),
1116            feature2: c.feature2.clone(),
1117            clearance: (&c.clearance).into(),
1118            location: c.location.clone(),
1119        }),
1120        silkscreen_clearance: report.silkscreen_clearance.as_ref().map(|c| ClearanceJson {
1121            feature1: c.feature1.clone(),
1122            feature2: c.feature2.clone(),
1123            clearance: (&c.clearance).into(),
1124            location: c.location.clone(),
1125        }),
1126        row_span: report.row_span.as_ref().map(|s| s.into()),
1127    };
1128
1129    let json = serde_json::to_string_pretty(&output).map_err(|e| e.to_string())?;
1130    println!("{}", json);
1131    Ok(())
1132}
1133
1134fn print_distance_json(
1135    dist: &crate::footprint::PadDistance,
1136) -> Result<(), Box<dyn std::error::Error>> {
1137    #[derive(Serialize)]
1138    struct DistanceJson {
1139        pad1: String,
1140        pad2: String,
1141        center_to_center_mm: f64,
1142        center_to_center_mils: f64,
1143        edge_to_edge_mm: f64,
1144        edge_to_edge_mils: f64,
1145    }
1146
1147    let output = DistanceJson {
1148        pad1: dist.pad1.clone(),
1149        pad2: dist.pad2.clone(),
1150        center_to_center_mm: dist.center_to_center.mm,
1151        center_to_center_mils: dist.center_to_center.mils,
1152        edge_to_edge_mm: dist.edge_to_edge.mm,
1153        edge_to_edge_mils: dist.edge_to_edge.mils,
1154    };
1155
1156    let json = serde_json::to_string_pretty(&output).map_err(|e| e.to_string())?;
1157    println!("{}", json);
1158    Ok(())
1159}
1160
1161fn print_pitch_json(
1162    pitches: &[crate::footprint::PitchAnalysis],
1163) -> Result<(), Box<dyn std::error::Error>> {
1164    #[derive(Serialize)]
1165    struct PitchJson {
1166        direction: String,
1167        pitch_mm: f64,
1168        pitch_mils: f64,
1169        pad_pair_count: usize,
1170    }
1171
1172    let output: Vec<PitchJson> = pitches
1173        .iter()
1174        .map(|p| PitchJson {
1175            direction: p.direction.clone(),
1176            pitch_mm: p.pitch.mm,
1177            pitch_mils: p.pitch.mils,
1178            pad_pair_count: p.count,
1179        })
1180        .collect();
1181
1182    let json = serde_json::to_string_pretty(&output).map_err(|e| e.to_string())?;
1183    println!("{}", json);
1184    Ok(())
1185}
1186
1187fn print_dimensions_json(
1188    dims: &crate::footprint::FootprintDimensions,
1189) -> Result<(), Box<dyn std::error::Error>> {
1190    #[derive(Serialize)]
1191    struct DimsJson {
1192        width_mm: f64,
1193        width_mils: f64,
1194        height_mm: f64,
1195        height_mils: f64,
1196        min_x_mm: f64,
1197        max_x_mm: f64,
1198        min_y_mm: f64,
1199        max_y_mm: f64,
1200    }
1201
1202    let output = DimsJson {
1203        width_mm: dims.width.mm,
1204        width_mils: dims.width.mils,
1205        height_mm: dims.height.mm,
1206        height_mils: dims.height.mils,
1207        min_x_mm: dims.min_x.mm,
1208        max_x_mm: dims.max_x.mm,
1209        min_y_mm: dims.min_y.mm,
1210        max_y_mm: dims.max_y.mm,
1211    };
1212
1213    let json = serde_json::to_string_pretty(&output).map_err(|e| e.to_string())?;
1214    println!("{}", json);
1215    Ok(())
1216}
1217
1218fn print_clearance_json(
1219    pad_clear: Option<&crate::footprint::ClearanceResult>,
1220    silk_clear: Option<&crate::footprint::ClearanceResult>,
1221) -> Result<(), Box<dyn std::error::Error>> {
1222    #[derive(Serialize)]
1223    struct ClearanceJson {
1224        feature1: String,
1225        feature2: String,
1226        clearance_mm: f64,
1227        clearance_mils: f64,
1228        location: String,
1229    }
1230
1231    #[derive(Serialize)]
1232    struct Output {
1233        pad_to_pad: Option<ClearanceJson>,
1234        pad_to_silkscreen: Option<ClearanceJson>,
1235    }
1236
1237    let output = Output {
1238        pad_to_pad: pad_clear.map(|c| ClearanceJson {
1239            feature1: c.feature1.clone(),
1240            feature2: c.feature2.clone(),
1241            clearance_mm: c.clearance.mm,
1242            clearance_mils: c.clearance.mils,
1243            location: c.location.clone(),
1244        }),
1245        pad_to_silkscreen: silk_clear.map(|c| ClearanceJson {
1246            feature1: c.feature1.clone(),
1247            feature2: c.feature2.clone(),
1248            clearance_mm: c.clearance.mm,
1249            clearance_mils: c.clearance.mils,
1250            location: c.location.clone(),
1251        }),
1252    };
1253
1254    let json = serde_json::to_string_pretty(&output).map_err(|e| e.to_string())?;
1255    println!("{}", json);
1256    Ok(())
1257}
1258
1259fn print_pad_json(info: &crate::footprint::PadInfo) -> Result<(), Box<dyn std::error::Error>> {
1260    #[derive(Serialize)]
1261    struct PadJson {
1262        designator: String,
1263        x_mm: f64,
1264        y_mm: f64,
1265        width_mm: f64,
1266        height_mm: f64,
1267        hole_mm: Option<f64>,
1268        shape: String,
1269        is_smd: bool,
1270    }
1271
1272    let output = PadJson {
1273        designator: info.designator.clone(),
1274        x_mm: info.x.mm,
1275        y_mm: info.y.mm,
1276        width_mm: info.width.mm,
1277        height_mm: info.height.mm,
1278        hole_mm: info.hole.as_ref().map(|h| h.mm),
1279        shape: info.shape.clone(),
1280        is_smd: info.hole.is_none(),
1281    };
1282
1283    let json = serde_json::to_string_pretty(&output).map_err(|e| e.to_string())?;
1284    println!("{}", json);
1285    Ok(())
1286}
1287
1288fn print_all_pads_json(
1289    pads: &[crate::footprint::PadInfo],
1290) -> Result<(), Box<dyn std::error::Error>> {
1291    #[derive(Serialize)]
1292    struct PadJson {
1293        designator: String,
1294        x_mm: f64,
1295        y_mm: f64,
1296        width_mm: f64,
1297        height_mm: f64,
1298        hole_mm: Option<f64>,
1299        shape: String,
1300    }
1301
1302    let output: Vec<PadJson> = pads
1303        .iter()
1304        .map(|p| PadJson {
1305            designator: p.designator.clone(),
1306            x_mm: p.x.mm,
1307            y_mm: p.y.mm,
1308            width_mm: p.width.mm,
1309            height_mm: p.height.mm,
1310            hole_mm: p.hole.as_ref().map(|h| h.mm),
1311            shape: p.shape.clone(),
1312        })
1313        .collect();
1314
1315    let json = serde_json::to_string_pretty(&output).map_err(|e| e.to_string())?;
1316    println!("{}", json);
1317    Ok(())
1318}
1319
1320// ═══════════════════════════════════════════════════════════════════════════
1321// CREATION/EDITING COMMAND IMPLEMENTATIONS
1322// ═══════════════════════════════════════════════════════════════════════════
1323
1324/// Embedded blank PcbLib template.
1325const BLANK_PCBLIB_TEMPLATE: &[u8] = include_bytes!("../../data/blank/PcbLib1.PcbLib");
1326
1327use crate::footprint::{ChipSpec, IpcDensity};
1328use crate::records::pcb::{PcbArc, PcbComponent, PcbFlags, PcbPrimitiveCommon, PcbTrack};
1329use crate::types::{Coord, CoordPoint};
1330
1331/// Create a new empty PcbLib file.
1332pub fn cmd_create(path: &Path) -> Result<(), Box<dyn std::error::Error>> {
1333    if path.exists() {
1334        return Err(format!("File already exists: {}", path.display()).into());
1335    }
1336
1337    std::fs::write(path, BLANK_PCBLIB_TEMPLATE)
1338        .map_err(|e| format!("Error creating file: {}", e))?;
1339
1340    println!("Created empty PcbLib: {}", path.display());
1341    Ok(())
1342}
1343
1344fn load_blank_pcblib() -> Result<PcbLib, Box<dyn std::error::Error>> {
1345    Ok(PcbLib::open(Cursor::new(BLANK_PCBLIB_TEMPLATE))?)
1346}
1347
1348/// Add a new footprint to a library.
1349pub fn cmd_add_footprint(
1350    path: &Path,
1351    name: &str,
1352    description: Option<String>,
1353) -> Result<(), Box<dyn std::error::Error>> {
1354    let mut lib = open_or_create_pcblib(path)?;
1355
1356    // Check if footprint already exists
1357    if lib.components.iter().any(|c| c.pattern == name) {
1358        return Err(format!("Footprint '{}' already exists", name).into());
1359    }
1360
1361    let mut det = ();
1362    let mut component = PcbComponent::new_deterministic(name, &mut det);
1363    if let Some(desc) = description {
1364        component.set_description(desc);
1365    }
1366
1367    lib.components.push(component);
1368    save_pcblib(path, &lib)?;
1369
1370    println!("Added footprint '{}' to {}", name, path.display());
1371    Ok(())
1372}
1373
1374/// Add a pad to a footprint.
1375#[allow(clippy::too_many_arguments)]
1376pub fn cmd_add_pad(
1377    path: &Path,
1378    footprint: &str,
1379    designator: &str,
1380    x: f64,
1381    y: f64,
1382    width: f64,
1383    height: f64,
1384    shape_str: &str,
1385    hole: f64,
1386) -> Result<(), Box<dyn std::error::Error>> {
1387    let mut lib = open_pcblib(path)?;
1388
1389    let component = lib
1390        .components
1391        .iter_mut()
1392        .find(|c| c.pattern == footprint)
1393        .ok_or_else(|| format!("Footprint '{}' not found", footprint))?;
1394
1395    // Parse shape
1396    let shape = match shape_str.to_lowercase().as_str() {
1397        "round" => PcbPadShape::Round,
1398        "rectangular" | "rect" => PcbPadShape::Rectangular,
1399        "rounded_rect" | "roundedrect" => PcbPadShape::RoundedRectangle,
1400        "octagonal" | "oct" => PcbPadShape::Octagonal,
1401        _ => return Err(format!("Unknown pad shape: {}", shape_str).into()),
1402    };
1403
1404    // Create pad using FootprintBuilder helper
1405    let mut builder = FootprintBuilder::new(footprint);
1406    if hole > 0.0 {
1407        builder.add_th_pad(designator, x, y, width.max(height), hole, shape);
1408    } else {
1409        builder.add_smd_pad(designator, x, y, width, height, shape);
1410    }
1411
1412    // Extract the pad from the built component
1413    let mut det = ();
1414    let temp = builder.build_deterministic(&mut det);
1415    if let Some(PcbRecord::Pad(pad)) = temp.primitives.into_iter().next() {
1416        component.add_primitive(PcbRecord::Pad(pad));
1417    }
1418
1419    save_pcblib(path, &lib)?;
1420    println!(
1421        "Added pad '{}' to footprint '{}' at ({}, {}) mm",
1422        designator, footprint, x, y
1423    );
1424    Ok(())
1425}
1426
1427/// Add a silkscreen line to a footprint.
1428pub fn cmd_add_silkscreen(
1429    path: &Path,
1430    footprint: &str,
1431    x1: f64,
1432    y1: f64,
1433    x2: f64,
1434    y2: f64,
1435    width: f64,
1436) -> Result<(), Box<dyn std::error::Error>> {
1437    let mut lib = open_pcblib(path)?;
1438
1439    let component = lib
1440        .components
1441        .iter_mut()
1442        .find(|c| c.pattern == footprint)
1443        .ok_or_else(|| format!("Footprint '{}' not found", footprint))?;
1444
1445    let track = PcbTrack {
1446        common: PcbPrimitiveCommon {
1447            layer: Layer::TOP_OVERLAY,
1448            flags: PcbFlags::UNLOCKED | PcbFlags::UNKNOWN8,
1449            unique_id: None,
1450        },
1451        start: CoordPoint::from_mms(x1, y1),
1452        end: CoordPoint::from_mms(x2, y2),
1453        width: Coord::from_mms(width),
1454        unknown: vec![0u8; 16],
1455    };
1456
1457    component.add_primitive(PcbRecord::Track(track));
1458    save_pcblib(path, &lib)?;
1459
1460    println!("Added silkscreen line to footprint '{}'", footprint);
1461    Ok(())
1462}
1463
1464/// Add a silkscreen arc to a footprint.
1465#[allow(clippy::too_many_arguments)]
1466pub fn cmd_add_arc(
1467    path: &Path,
1468    footprint: &str,
1469    x: f64,
1470    y: f64,
1471    radius: f64,
1472    start_angle: f64,
1473    end_angle: f64,
1474    width: f64,
1475) -> Result<(), Box<dyn std::error::Error>> {
1476    let mut lib = open_pcblib(path)?;
1477
1478    let component = lib
1479        .components
1480        .iter_mut()
1481        .find(|c| c.pattern == footprint)
1482        .ok_or_else(|| format!("Footprint '{}' not found", footprint))?;
1483
1484    let arc = PcbArc {
1485        common: PcbPrimitiveCommon {
1486            layer: Layer::TOP_OVERLAY,
1487            flags: PcbFlags::UNLOCKED | PcbFlags::UNKNOWN8,
1488            unique_id: None,
1489        },
1490        location: CoordPoint::from_mms(x, y),
1491        radius: Coord::from_mms(radius),
1492        start_angle,
1493        end_angle,
1494        width: Coord::from_mms(width),
1495    };
1496
1497    component.add_primitive(PcbRecord::Arc(arc));
1498    save_pcblib(path, &lib)?;
1499
1500    println!(
1501        "Added silkscreen arc to footprint '{}' (center: ({}, {}) mm, radius: {} mm, {:.0}° to {:.0}°)",
1502        footprint, x, y, radius, start_angle, end_angle
1503    );
1504    Ok(())
1505}
1506
1507/// Generate a standard chip/passive footprint.
1508pub fn cmd_gen_chip(
1509    path: &Path,
1510    size: &str,
1511    density_str: &str,
1512) -> Result<(), Box<dyn std::error::Error>> {
1513    let mut lib = open_or_create_pcblib(path)?;
1514
1515    let spec = match size.to_uppercase().as_str() {
1516        "0201" => ChipSpec::chip_0201(),
1517        "0402" => ChipSpec::chip_0402(),
1518        "0603" => ChipSpec::chip_0603(),
1519        "0805" => ChipSpec::chip_0805(),
1520        "1206" => ChipSpec::chip_1206(),
1521        _ => {
1522            return Err(format!(
1523                "Unknown chip size: {}. Supported: 0201, 0402, 0603, 0805, 1206",
1524                size
1525            )
1526            .into());
1527        }
1528    };
1529
1530    let density = parse_density(density_str)?;
1531    let mut det = ();
1532    let component = spec.to_footprint(density).build_deterministic(&mut det);
1533    let name = component.pattern.clone();
1534
1535    // Check if already exists
1536    if lib.components.iter().any(|c| c.pattern == name) {
1537        return Err(format!("Footprint '{}' already exists", name).into());
1538    }
1539
1540    lib.components.push(component);
1541    save_pcblib(path, &lib)?;
1542
1543    println!(
1544        "Generated chip footprint '{}' with {} density",
1545        name, density_str
1546    );
1547    Ok(())
1548}
1549
1550fn parse_density(s: &str) -> Result<IpcDensity, Box<dyn std::error::Error>> {
1551    match s.to_lowercase().as_str() {
1552        "most" | "a" | "dense" => Ok(IpcDensity::MostDense),
1553        "nominal" | "b" | "normal" => Ok(IpcDensity::Nominal),
1554        "least" | "c" | "loose" => Ok(IpcDensity::LeastDense),
1555        _ => Err(format!("Unknown density: {}. Use: most, nominal, least", s).into()),
1556    }
1557}
1558
1559pub fn cmd_render_svg(
1560    path: &Path,
1561    name: &str,
1562    output: Option<PathBuf>,
1563    scale: f64,
1564    light: bool,
1565    no_grid: bool,
1566    no_designators: bool,
1567) -> Result<(), Box<dyn std::error::Error>> {
1568    use std::fs;
1569
1570    let lib = open_pcblib(path)?;
1571
1572    // Find the footprint
1573    let name_lower = name.to_lowercase();
1574    let component = lib
1575        .iter()
1576        .find(|c| c.pattern.to_lowercase() == name_lower || matches_pattern(&c.pattern, name))
1577        .ok_or_else(|| format!("Footprint '{}' not found", name))?;
1578
1579    // Build options
1580    let mut options = if light {
1581        SvgOptions::light()
1582    } else {
1583        SvgOptions::default()
1584    };
1585    options.scale = scale;
1586    options.show_grid = !no_grid;
1587    options.show_designators = !no_designators;
1588
1589    // Render to SVG
1590    let svg = render_svg(component, &options);
1591
1592    // Determine output path
1593    let output_path = output.unwrap_or_else(|| {
1594        PathBuf::from(format!(
1595            "{}.svg",
1596            component.pattern.replace(['/', '\\', ' '], "_")
1597        ))
1598    });
1599
1600    // Write to file
1601    fs::write(&output_path, &svg).map_err(|e| format!("Error writing SVG: {}", e))?;
1602
1603    println!(
1604        "Rendered footprint '{}' to {}",
1605        component.pattern,
1606        output_path.display()
1607    );
1608    println!("  Size: {} bytes", svg.len());
1609    println!("  Theme: {}", if light { "light" } else { "dark" });
1610    println!("  Scale: {} px/mil", scale);
1611
1612    Ok(())
1613}
1614
1615pub fn cmd_render_png(
1616    path: &Path,
1617    name: &str,
1618    output: Option<PathBuf>,
1619    scale: f64,
1620    target_width: Option<u32>,
1621) -> Result<(), Box<dyn std::error::Error>> {
1622    use std::fs::File;
1623    use std::io::BufWriter;
1624
1625    let lib = open_pcblib(path)?;
1626
1627    // Find the footprint
1628    let name_lower = name.to_lowercase();
1629    let component = lib
1630        .iter()
1631        .find(|c| c.pattern.to_lowercase() == name_lower || matches_pattern(&c.pattern, name))
1632        .ok_or_else(|| format!("Footprint '{}' not found", name))?;
1633
1634    // Use Altium-style colors (dark background, colored layers)
1635    let options = SvgOptions {
1636        scale,
1637        show_grid: false, // No grid for PNG
1638        show_designators: true,
1639        ..Default::default()
1640    };
1641
1642    // Render to SVG first
1643    let svg_data = render_svg(component, &options);
1644
1645    // Parse SVG and render to PNG using resvg
1646    let tree = resvg::usvg::Tree::from_str(&svg_data, &resvg::usvg::Options::default())
1647        .map_err(|e| format!("Error parsing SVG: {}", e))?;
1648
1649    // Calculate dimensions
1650    let svg_size = tree.size();
1651    let (width, height) = if let Some(w) = target_width {
1652        let h = (w as f32 * svg_size.height() / svg_size.width()) as u32;
1653        (w, h)
1654    } else {
1655        (svg_size.width() as u32, svg_size.height() as u32)
1656    };
1657
1658    // Create pixmap and render
1659    let mut pixmap = resvg::tiny_skia::Pixmap::new(width, height)
1660        .ok_or_else(|| "Failed to create pixmap".to_string())?;
1661
1662    // Fill with dark background
1663    pixmap.fill(resvg::tiny_skia::Color::from_rgba8(30, 30, 30, 255));
1664
1665    // Render SVG onto pixmap
1666    let scale_x = width as f32 / svg_size.width();
1667    let scale_y = height as f32 / svg_size.height();
1668    let transform = resvg::tiny_skia::Transform::from_scale(scale_x, scale_y);
1669
1670    resvg::render(&tree, transform, &mut pixmap.as_mut());
1671
1672    // Determine output path
1673    let output_path = output.unwrap_or_else(|| {
1674        PathBuf::from(format!(
1675            "{}.png",
1676            component.pattern.replace(['/', '\\', ' '], "_")
1677        ))
1678    });
1679
1680    // Write PNG
1681    let file = File::create(&output_path).map_err(|e| format!("Error creating file: {}", e))?;
1682    let writer = BufWriter::new(file);
1683    let mut encoder = png::Encoder::new(writer, width, height);
1684    encoder.set_color(png::ColorType::Rgba);
1685    encoder.set_depth(png::BitDepth::Eight);
1686
1687    let mut png_writer = encoder
1688        .write_header()
1689        .map_err(|e| format!("Error writing PNG header: {}", e))?;
1690    png_writer
1691        .write_image_data(pixmap.data())
1692        .map_err(|e| format!("Error writing PNG data: {}", e))?;
1693
1694    println!(
1695        "Rendered footprint '{}' to {}",
1696        component.pattern,
1697        output_path.display()
1698    );
1699    println!("  Size: {}x{} pixels", width, height);
1700
1701    Ok(())
1702}
1703
1704pub fn cmd_render_ascii(
1705    path: &Path,
1706    name: &str,
1707    max_width: usize,
1708    max_height: usize,
1709) -> Result<(), Box<dyn std::error::Error>> {
1710    let lib = open_pcblib(path)?;
1711
1712    // Find the footprint
1713    let name_lower = name.to_lowercase();
1714    let component = lib
1715        .iter()
1716        .find(|c| c.pattern.to_lowercase() == name_lower || matches_pattern(&c.pattern, name))
1717        .ok_or_else(|| format!("Footprint '{}' not found", name))?;
1718
1719    // Build options
1720    let options = AsciiOptions {
1721        max_width,
1722        max_height,
1723        ..Default::default()
1724    };
1725
1726    // Render and print
1727    let ascii = render_ascii(component, &options);
1728    println!("{}", ascii);
1729
1730    Ok(())
1731}
1732
1733// Helper functions
1734
1735fn open_or_create_pcblib(path: &Path) -> Result<PcbLib, Box<dyn std::error::Error>> {
1736    if path.exists() {
1737        open_pcblib(path)
1738    } else {
1739        load_blank_pcblib()
1740    }
1741}
1742
1743fn save_pcblib(path: &Path, lib: &PcbLib) -> Result<(), Box<dyn std::error::Error>> {
1744    Ok(lib.save_to_file(path)?)
1745}
1746
1747/// Simple wildcard pattern matching (supports * and ?).
1748fn matches_pattern(text: &str, pattern: &str) -> bool {
1749    let text = text.to_lowercase();
1750    let pattern = pattern.to_lowercase();
1751
1752    fn matches(text: &[char], pattern: &[char]) -> bool {
1753        match (text.first(), pattern.first()) {
1754            (None, None) => true,
1755            (None, Some('*')) => matches(text, &pattern[1..]),
1756            (None, Some(_)) => false,
1757            (Some(_), None) => false,
1758            (Some(_), Some('*')) => {
1759                // * can match zero or more characters
1760                matches(text, &pattern[1..]) || matches(&text[1..], pattern)
1761            }
1762            (Some(_), Some('?')) => {
1763                // ? matches exactly one character
1764                matches(&text[1..], &pattern[1..])
1765            }
1766            (Some(t), Some(p)) => *t == *p && matches(&text[1..], &pattern[1..]),
1767        }
1768    }
1769
1770    let text_chars: Vec<char> = text.chars().collect();
1771    let pattern_chars: Vec<char> = pattern.chars().collect();
1772    matches(&text_chars, &pattern_chars)
1773}
1774
1775// ═══════════════════════════════════════════════════════════════════════════
1776// JSON INPUT STRUCTURES (for LLM tool calling and structured output)
1777// ═══════════════════════════════════════════════════════════════════════════
1778
1779/// JSON schema for a pad in a footprint.
1780/// All coordinates are in millimeters.
1781#[derive(Debug, Clone, Deserialize, Serialize)]
1782pub struct PadJson {
1783    /// Pad designator (e.g., "1", "2", "A1")
1784    pub designator: String,
1785    /// X position in mm (can be negative)
1786    pub x: f64,
1787    /// Y position in mm (can be negative)
1788    pub y: f64,
1789    /// Pad width in mm
1790    pub width: f64,
1791    /// Pad height in mm
1792    pub height: f64,
1793    /// Pad shape: "round", "rectangular", "rounded_rect", "octagonal"
1794    #[serde(default = "default_pad_shape")]
1795    pub shape: String,
1796    /// Hole diameter in mm (0 or omit for SMD pad)
1797    #[serde(default)]
1798    pub hole: f64,
1799    /// Rotation angle in degrees (optional)
1800    #[serde(default)]
1801    pub rotation: f64,
1802}
1803
1804fn default_pad_shape() -> String {
1805    "rectangular".to_string()
1806}
1807
1808/// JSON schema for a silkscreen line.
1809/// All coordinates are in millimeters.
1810#[derive(Debug, Clone, Deserialize, Serialize)]
1811pub struct LineJson {
1812    /// Start X in mm
1813    pub x1: f64,
1814    /// Start Y in mm
1815    pub y1: f64,
1816    /// End X in mm
1817    pub x2: f64,
1818    /// End Y in mm
1819    pub y2: f64,
1820    /// Line width in mm
1821    #[serde(default = "default_line_width")]
1822    pub width: f64,
1823}
1824
1825fn default_line_width() -> f64 {
1826    0.15
1827}
1828
1829/// JSON schema for a silkscreen arc.
1830/// All coordinates are in millimeters.
1831#[derive(Debug, Clone, Deserialize, Serialize)]
1832pub struct ArcJson {
1833    /// Center X in mm
1834    pub x: f64,
1835    /// Center Y in mm
1836    pub y: f64,
1837    /// Radius in mm
1838    pub radius: f64,
1839    /// Start angle in degrees (0 = right, 90 = up)
1840    pub start_angle: f64,
1841    /// End angle in degrees
1842    pub end_angle: f64,
1843    /// Line width in mm
1844    #[serde(default = "default_line_width")]
1845    pub width: f64,
1846}
1847
1848/// JSON schema for text on a PCB footprint.
1849/// All coordinates are in millimeters.
1850#[derive(Debug, Clone, Deserialize, Serialize)]
1851pub struct TextJson {
1852    /// X position in mm
1853    pub x: f64,
1854    /// Y position in mm
1855    pub y: f64,
1856    /// Text content
1857    pub text: String,
1858    /// Text height in mm
1859    #[serde(default = "default_text_height")]
1860    pub height: f64,
1861    /// Rotation angle in degrees
1862    #[serde(default)]
1863    pub rotation: f64,
1864    /// Stroke width in mm (for stroke font)
1865    #[serde(default = "default_stroke_width")]
1866    pub stroke_width: f64,
1867    /// Layer: "top_overlay", "bottom_overlay", "top", "bottom"
1868    #[serde(default = "default_text_layer")]
1869    pub layer: String,
1870    /// Mirror the text
1871    #[serde(default)]
1872    pub mirrored: bool,
1873}
1874
1875fn default_text_height() -> f64 {
1876    1.0
1877}
1878
1879fn default_stroke_width() -> f64 {
1880    0.15
1881}
1882
1883fn default_text_layer() -> String {
1884    "top_overlay".to_string()
1885}
1886
1887// ═══════════════════════════════════════════════════════════════════════════
1888// HIGH-LEVEL JSON STRUCTURES (datasheet-style specifications)
1889// ═══════════════════════════════════════════════════════════════════════════
1890
1891/// JSON schema for a row of pads.
1892/// Values can include unit suffixes (mm, mil, in) or be plain numbers (interpreted as mm).
1893#[derive(Debug, Clone, Deserialize, Serialize)]
1894pub struct PadRowJson {
1895    /// Number of pads
1896    pub count: usize,
1897    /// Center-to-center distance between pads (with optional unit: "0.5mm", "50mil")
1898    pub pitch: String,
1899    /// Pad width (with optional unit)
1900    pub pad_width: String,
1901    /// Pad height (with optional unit)
1902    pub pad_height: String,
1903    /// Row direction: "horizontal" or "vertical"
1904    #[serde(default = "default_direction")]
1905    pub direction: String,
1906    /// Starting pad designator number
1907    #[serde(default = "default_start")]
1908    pub start: u32,
1909    /// X position of first pad (with optional unit, default "0mm")
1910    #[serde(default)]
1911    pub x: String,
1912    /// Y position of first pad (with optional unit, default "0mm")
1913    #[serde(default)]
1914    pub y: String,
1915    /// Pad shape
1916    #[serde(default = "default_pad_shape_str")]
1917    pub shape: String,
1918    /// Hole diameter for through-hole pads (with optional unit, omit or "0" for SMD)
1919    #[serde(default)]
1920    pub hole: String,
1921    /// Use spacing (edge-to-edge) instead of pitch (center-to-center)
1922    #[serde(default)]
1923    pub use_spacing: bool,
1924}
1925
1926/// JSON schema for dual rows of pads (SOIC, DIP style).
1927#[derive(Debug, Clone, Deserialize, Serialize)]
1928pub struct DualRowJson {
1929    /// Number of pads on each side
1930    pub pads_per_side: usize,
1931    /// Center-to-center distance between adjacent pads (with optional unit)
1932    pub pitch: String,
1933    /// Distance between row centers / lead span (with optional unit)
1934    pub row_spacing: String,
1935    /// Pad width for SMD (with optional unit)
1936    #[serde(default)]
1937    pub pad_width: Option<String>,
1938    /// Pad height for SMD (with optional unit)
1939    #[serde(default)]
1940    pub pad_height: Option<String>,
1941    /// Pad diameter for through-hole (with optional unit)
1942    #[serde(default)]
1943    pub pad_diameter: Option<String>,
1944    /// Hole diameter for through-hole (with optional unit, omit for SMD)
1945    #[serde(default)]
1946    pub hole: Option<String>,
1947    /// Pad shape
1948    #[serde(default = "default_pad_shape_str")]
1949    pub shape: String,
1950}
1951
1952/// JSON schema for quad pads (QFP style).
1953#[derive(Debug, Clone, Deserialize, Serialize)]
1954pub struct QuadPadsJson {
1955    /// Number of pads on each side
1956    pub pads_per_side: usize,
1957    /// Center-to-center distance between adjacent pads (with optional unit)
1958    pub pitch: String,
1959    /// Distance between opposite row centers / lead span (with optional unit)
1960    pub span: String,
1961    /// Pad width - perpendicular to body edge (with optional unit)
1962    pub pad_width: String,
1963    /// Pad height - along body edge (with optional unit)
1964    pub pad_height: String,
1965    /// Pad shape
1966    #[serde(default = "default_pad_shape_str")]
1967    pub shape: String,
1968}
1969
1970/// JSON schema for a grid of pads (BGA style).
1971#[derive(Debug, Clone, Deserialize, Serialize)]
1972pub struct PadGridJson {
1973    /// Number of rows (A, B, C, ...)
1974    pub rows: usize,
1975    /// Number of columns (1, 2, 3, ...)
1976    pub cols: usize,
1977    /// Center-to-center distance between pads (with optional unit)
1978    pub pitch: String,
1979    /// Pad diameter (with optional unit)
1980    pub pad_diameter: String,
1981    /// Pad shape (default: "round")
1982    #[serde(default = "default_round_shape")]
1983    pub shape: String,
1984    /// Skip pads within this radius from center (with optional unit, for thermal pad)
1985    #[serde(default)]
1986    pub skip_center: String,
1987}
1988
1989fn default_direction() -> String {
1990    "horizontal".to_string()
1991}
1992
1993fn default_start() -> u32 {
1994    1
1995}
1996
1997fn default_pad_shape_str() -> String {
1998    "rectangular".to_string()
1999}
2000
2001fn default_round_shape() -> String {
2002    "round".to_string()
2003}
2004
2005/// JSON schema for a complete footprint definition.
2006/// This is the top-level structure for the add-json command.
2007///
2008/// Supports both low-level (individual pads) and high-level (datasheet-style) specifications.
2009/// High-level constructs are processed first, then individual pads are added.
2010#[derive(Debug, Clone, Deserialize, Serialize)]
2011pub struct FootprintJson {
2012    /// Footprint name (pattern)
2013    pub name: String,
2014    /// Footprint description (optional)
2015    #[serde(default)]
2016    pub description: String,
2017
2018    // ─── Low-level primitives (individual elements) ───
2019    /// List of individual pads (coordinates in mm)
2020    #[serde(default)]
2021    pub pads: Vec<PadJson>,
2022    /// List of silkscreen lines
2023    #[serde(default)]
2024    pub lines: Vec<LineJson>,
2025    /// List of silkscreen arcs
2026    #[serde(default)]
2027    pub arcs: Vec<ArcJson>,
2028    /// List of text elements
2029    #[serde(default)]
2030    pub texts: Vec<TextJson>,
2031
2032    // ─── High-level constructs (datasheet-style, with unit support) ───
2033    /// Rows of equally-spaced pads
2034    #[serde(default)]
2035    pub pad_rows: Vec<PadRowJson>,
2036    /// Dual rows of pads (SOIC, DIP style)
2037    #[serde(default)]
2038    pub dual_rows: Vec<DualRowJson>,
2039    /// Quad arrangements of pads (QFP style)
2040    #[serde(default)]
2041    pub quad_pads: Vec<QuadPadsJson>,
2042    /// Grids of pads (BGA style)
2043    #[serde(default)]
2044    pub pad_grids: Vec<PadGridJson>,
2045}
2046
2047/// Add a complete footprint from JSON input.
2048pub fn cmd_add_json(
2049    path: &Path,
2050    json_file: Option<String>,
2051    json_str: Option<String>,
2052) -> Result<(), Box<dyn std::error::Error>> {
2053    use std::io::{self, Read as IoRead};
2054
2055    // Read JSON from file, stdin, or command line
2056    let json_content = match (json_file, json_str) {
2057        (_, Some(s)) => s,
2058        (Some(ref path), None) if path == "-" => {
2059            let mut buffer = String::new();
2060            io::stdin()
2061                .read_to_string(&mut buffer)
2062                .map_err(|e| format!("Error reading from stdin: {}", e))?;
2063            buffer
2064        }
2065        (Some(ref file_path), None) => std::fs::read_to_string(file_path)
2066            .map_err(|e| format!("Error reading file '{}': {}", file_path, e))?,
2067        (None, None) => {
2068            return Err("Must provide either --file <path> or --json <string>"
2069                .to_string()
2070                .into());
2071        }
2072    };
2073
2074    // Parse JSON
2075    let footprint_def: FootprintJson =
2076        serde_json::from_str(&json_content).map_err(|e| format!("Invalid JSON: {}", e))?;
2077
2078    // Open or create library
2079    let mut lib = open_or_create_pcblib(path)?;
2080
2081    // Check if footprint already exists
2082    if lib
2083        .components
2084        .iter()
2085        .any(|c| c.pattern == footprint_def.name)
2086    {
2087        return Err(format!("Footprint '{}' already exists", footprint_def.name).into());
2088    }
2089
2090    // Create footprint using FootprintBuilder
2091    let mut builder = FootprintBuilder::new(&footprint_def.name);
2092
2093    if !footprint_def.description.is_empty() {
2094        builder = builder.description(&footprint_def.description);
2095    }
2096
2097    // ─── Process high-level constructs first (datasheet-style) ───
2098
2099    // Add pad rows
2100    for row in &footprint_def.pad_rows {
2101        let pitch_mm = parse_unit_value_or_mm(&row.pitch)?;
2102        let pad_width_mm = parse_unit_value_or_mm(&row.pad_width)?;
2103        let pad_height_mm = parse_unit_value_or_mm(&row.pad_height)?;
2104        let x_mm = if row.x.is_empty() {
2105            0.0
2106        } else {
2107            parse_unit_value_or_mm(&row.x)?
2108        };
2109        let y_mm = if row.y.is_empty() {
2110            0.0
2111        } else {
2112            parse_unit_value_or_mm(&row.y)?
2113        };
2114        let hole_mm = if row.hole.is_empty() {
2115            0.0
2116        } else {
2117            parse_unit_value_or_mm(&row.hole)?
2118        };
2119        let dir = PadRowDirection::try_parse(&row.direction)
2120            .ok_or_else(|| format!("Invalid direction '{}' in pad_row", row.direction))?;
2121        let shape = parse_pad_shape(&row.shape)?;
2122
2123        if hole_mm > 0.0 {
2124            let pad_diameter = pad_width_mm.max(pad_height_mm);
2125            if row.use_spacing {
2126                let pad_along_row = match dir {
2127                    PadRowDirection::Horizontal => pad_width_mm,
2128                    PadRowDirection::Vertical => pad_height_mm,
2129                };
2130                let effective_pitch = pitch_mm + pad_along_row;
2131                builder.add_th_pad_row(
2132                    row.count,
2133                    effective_pitch,
2134                    pad_diameter,
2135                    hole_mm,
2136                    x_mm,
2137                    y_mm,
2138                    dir,
2139                    row.start,
2140                    shape,
2141                );
2142            } else {
2143                builder.add_th_pad_row(
2144                    row.count,
2145                    pitch_mm,
2146                    pad_diameter,
2147                    hole_mm,
2148                    x_mm,
2149                    y_mm,
2150                    dir,
2151                    row.start,
2152                    shape,
2153                );
2154            }
2155        } else if row.use_spacing {
2156            builder.add_pad_row_with_spacing(
2157                row.count,
2158                pitch_mm,
2159                pad_width_mm,
2160                pad_height_mm,
2161                x_mm,
2162                y_mm,
2163                dir,
2164                row.start,
2165                shape,
2166            );
2167        } else {
2168            builder.add_pad_row(
2169                row.count,
2170                pitch_mm,
2171                pad_width_mm,
2172                pad_height_mm,
2173                x_mm,
2174                y_mm,
2175                dir,
2176                row.start,
2177                shape,
2178            );
2179        }
2180    }
2181
2182    // Add dual rows
2183    for dual in &footprint_def.dual_rows {
2184        let pitch_mm = parse_unit_value_or_mm(&dual.pitch)?;
2185        let row_spacing_mm = parse_unit_value_or_mm(&dual.row_spacing)?;
2186        let shape = parse_pad_shape(&dual.shape)?;
2187
2188        if let Some(ref hole_str) = dual.hole {
2189            // Through-hole
2190            let hole_mm = parse_unit_value_or_mm(hole_str)?;
2191            let pad_dia_mm = if let Some(ref d) = dual.pad_diameter {
2192                parse_unit_value_or_mm(d)?
2193            } else if let Some(ref w) = dual.pad_width {
2194                parse_unit_value_or_mm(w)?
2195            } else {
2196                return Err("Through-hole dual_row requires pad_diameter or pad_width"
2197                    .to_string()
2198                    .into());
2199            };
2200            builder.add_dual_row_th(
2201                dual.pads_per_side,
2202                pitch_mm,
2203                row_spacing_mm,
2204                pad_dia_mm,
2205                hole_mm,
2206                shape,
2207            );
2208        } else {
2209            // SMD
2210            let pad_width_mm = dual
2211                .pad_width
2212                .as_ref()
2213                .ok_or("SMD dual_row requires pad_width")?;
2214            let pad_height_mm = dual
2215                .pad_height
2216                .as_ref()
2217                .ok_or("SMD dual_row requires pad_height")?;
2218            let pad_width_mm = parse_unit_value_or_mm(pad_width_mm)?;
2219            let pad_height_mm = parse_unit_value_or_mm(pad_height_mm)?;
2220            builder.add_dual_row_smd(
2221                dual.pads_per_side,
2222                pitch_mm,
2223                row_spacing_mm,
2224                pad_width_mm,
2225                pad_height_mm,
2226                shape,
2227            );
2228        }
2229    }
2230
2231    // Add quad pads
2232    for quad in &footprint_def.quad_pads {
2233        let pitch_mm = parse_unit_value_or_mm(&quad.pitch)?;
2234        let span_mm = parse_unit_value_or_mm(&quad.span)?;
2235        let pad_width_mm = parse_unit_value_or_mm(&quad.pad_width)?;
2236        let pad_height_mm = parse_unit_value_or_mm(&quad.pad_height)?;
2237        let shape = parse_pad_shape(&quad.shape)?;
2238        builder.add_quad_pads_smd(
2239            quad.pads_per_side,
2240            pitch_mm,
2241            span_mm,
2242            pad_width_mm,
2243            pad_height_mm,
2244            shape,
2245        );
2246    }
2247
2248    // Add pad grids
2249    for grid in &footprint_def.pad_grids {
2250        let pitch_mm = parse_unit_value_or_mm(&grid.pitch)?;
2251        let pad_diameter_mm = parse_unit_value_or_mm(&grid.pad_diameter)?;
2252        let skip_center_mm = if grid.skip_center.is_empty() {
2253            0.0
2254        } else {
2255            parse_unit_value_or_mm(&grid.skip_center)?
2256        };
2257        let shape = parse_pad_shape(&grid.shape)?;
2258        builder.add_pad_grid(
2259            grid.rows,
2260            grid.cols,
2261            pitch_mm,
2262            pad_diameter_mm,
2263            shape,
2264            skip_center_mm,
2265        );
2266    }
2267
2268    // ─── Process low-level primitives (individual pads, lines, etc.) ───
2269
2270    // Add individual pads
2271    for pad in &footprint_def.pads {
2272        let shape = parse_pad_shape(&pad.shape)?;
2273
2274        if pad.hole > 0.0 {
2275            builder.add_th_pad(
2276                &pad.designator,
2277                pad.x,
2278                pad.y,
2279                pad.width.max(pad.height),
2280                pad.hole,
2281                shape,
2282            );
2283        } else {
2284            builder.add_smd_pad(&pad.designator, pad.x, pad.y, pad.width, pad.height, shape);
2285        }
2286    }
2287
2288    // Add silkscreen lines
2289    for line in &footprint_def.lines {
2290        builder.add_silkscreen_line(line.x1, line.y1, line.x2, line.y2, line.width);
2291    }
2292
2293    // Add arcs
2294    for arc in &footprint_def.arcs {
2295        builder.add_silkscreen_arc(
2296            arc.x,
2297            arc.y,
2298            arc.radius,
2299            arc.start_angle,
2300            arc.end_angle,
2301            arc.width,
2302        );
2303    }
2304
2305    let mut det = ();
2306    let mut component = builder.build_deterministic(&mut det);
2307
2308    // Add text elements (not supported by FootprintBuilder, add directly)
2309    for text_def in &footprint_def.texts {
2310        let layer = parse_pcb_layer(&text_def.layer)?;
2311
2312        let text = PcbText::new(
2313            text_def.x,
2314            text_def.y,
2315            &text_def.text,
2316            text_def.height,
2317            text_def.stroke_width,
2318            text_def.rotation,
2319            text_def.mirrored,
2320            layer,
2321        );
2322        component.add_primitive(PcbRecord::Text(text));
2323    }
2324
2325    let pad_count = component.pad_count();
2326    let line_count = footprint_def.lines.len();
2327    let arc_count = footprint_def.arcs.len();
2328    let text_count = footprint_def.texts.len();
2329
2330    lib.components.push(component);
2331    save_pcblib(path, &lib)?;
2332
2333    // Build summary of added primitives
2334    let mut parts = vec![format!("{} pads", pad_count)];
2335    if line_count > 0 {
2336        parts.push(format!("{} lines", line_count));
2337    }
2338    if arc_count > 0 {
2339        parts.push(format!("{} arcs", arc_count));
2340    }
2341    if text_count > 0 {
2342        parts.push(format!("{} texts", text_count));
2343    }
2344
2345    println!(
2346        "Added footprint '{}' with {} to {}",
2347        footprint_def.name,
2348        parts.join(", "),
2349        path.display()
2350    );
2351
2352    Ok(())
2353}
2354
2355fn parse_pcb_layer(s: &str) -> Result<Layer, String> {
2356    match s.to_lowercase().replace('_', "").as_str() {
2357        "topoverlay" | "silkscreen" | "top_overlay" => Ok(Layer::TOP_OVERLAY),
2358        "bottomoverlay" | "bottom_overlay" => Ok(Layer::BOTTOM_OVERLAY),
2359        "top" | "toplayer" => Ok(Layer::TOP_LAYER),
2360        "bottom" | "bottomlayer" => Ok(Layer::BOTTOM_LAYER),
2361        _ => Err(format!(
2362            "Unknown layer: {}. Use: top_overlay, bottom_overlay, top, bottom",
2363            s
2364        )),
2365    }
2366}
2367
2368fn parse_pad_shape(s: &str) -> Result<PcbPadShape, String> {
2369    match s.to_lowercase().as_str() {
2370        "round" => Ok(PcbPadShape::Round),
2371        "rectangular" | "rect" => Ok(PcbPadShape::Rectangular),
2372        "rounded_rect" | "roundedrect" | "rounded_rectangle" => Ok(PcbPadShape::RoundedRectangle),
2373        "octagonal" | "oct" => Ok(PcbPadShape::Octagonal),
2374        _ => Err(format!(
2375            "Unknown pad shape: {}. Use: round, rectangular, rounded_rect, octagonal",
2376            s
2377        )),
2378    }
2379}
2380
2381// ═══════════════════════════════════════════════════════════════════════════
2382// HIGH-LEVEL PAD COMMANDS
2383// ═══════════════════════════════════════════════════════════════════════════
2384
2385/// Parse a value with unit suffix (e.g., "0.5mm", "50mil", "0.05in").
2386fn parse_unit_value(s: &str) -> Result<f64, String> {
2387    let (coord, _unit) =
2388        Unit::parse_with_unit(s).map_err(|e| format!("Invalid value '{}': {:?}", s, e))?;
2389    Ok(coord.to_mms())
2390}
2391
2392/// Parse a value with optional unit suffix, defaulting to mm for plain numbers.
2393/// Handles: "0.5mm", "50mil", "0.05in", "0.5" (interpreted as mm)
2394fn parse_unit_value_or_mm(s: &str) -> Result<f64, String> {
2395    let s = s.trim();
2396
2397    // Try parsing with unit suffix first
2398    if let Ok((coord, _unit)) = Unit::parse_with_unit(s) {
2399        return Ok(coord.to_mms());
2400    }
2401
2402    // If no unit suffix, try as plain number (interpreted as mm)
2403    s.parse::<f64>().map_err(|_| {
2404        format!(
2405            "Invalid value '{}': expected number with optional unit (e.g., '0.5mm', '50mil')",
2406            s
2407        )
2408    })
2409}
2410
2411/// Add a row of pads.
2412#[allow(clippy::too_many_arguments)]
2413pub fn cmd_add_pad_row(
2414    path: &Path,
2415    footprint: &str,
2416    count: usize,
2417    pitch: &str,
2418    pad_width: &str,
2419    pad_height: &str,
2420    direction: &str,
2421    start: u32,
2422    x: &str,
2423    y: &str,
2424    shape_str: &str,
2425    hole: &str,
2426    use_spacing: bool,
2427) -> Result<(), Box<dyn std::error::Error>> {
2428    let mut lib = open_pcblib(path)?;
2429
2430    let component = lib
2431        .components
2432        .iter_mut()
2433        .find(|c| c.pattern == footprint)
2434        .ok_or_else(|| format!("Footprint '{}' not found", footprint))?;
2435
2436    // Parse all values with units
2437    let pitch_mm = parse_unit_value(pitch)?;
2438    let pad_width_mm = parse_unit_value(pad_width)?;
2439    let pad_height_mm = parse_unit_value(pad_height)?;
2440    let x_mm = parse_unit_value(x)?;
2441    let y_mm = parse_unit_value(y)?;
2442    let hole_mm = parse_unit_value(hole)?;
2443
2444    let dir = PadRowDirection::try_parse(direction).ok_or_else(|| {
2445        format!(
2446            "Invalid direction '{}'. Use: horizontal (h/x) or vertical (v/y)",
2447            direction
2448        )
2449    })?;
2450
2451    let shape = parse_pad_shape(shape_str)?;
2452
2453    // Create pad row using FootprintBuilder
2454    let mut builder = FootprintBuilder::new(footprint);
2455
2456    if hole_mm > 0.0 {
2457        // Through-hole pads
2458        let pad_diameter = pad_width_mm.max(pad_height_mm);
2459        if use_spacing {
2460            // Convert spacing to pitch
2461            let pad_along_row = match dir {
2462                PadRowDirection::Horizontal => pad_width_mm,
2463                PadRowDirection::Vertical => pad_height_mm,
2464            };
2465            let effective_pitch = pitch_mm + pad_along_row;
2466            builder.add_th_pad_row(
2467                count,
2468                effective_pitch,
2469                pad_diameter,
2470                hole_mm,
2471                x_mm,
2472                y_mm,
2473                dir,
2474                start,
2475                shape,
2476            );
2477        } else {
2478            builder.add_th_pad_row(
2479                count,
2480                pitch_mm,
2481                pad_diameter,
2482                hole_mm,
2483                x_mm,
2484                y_mm,
2485                dir,
2486                start,
2487                shape,
2488            );
2489        }
2490    } else {
2491        // SMD pads
2492        if use_spacing {
2493            builder.add_pad_row_with_spacing(
2494                count,
2495                pitch_mm,
2496                pad_width_mm,
2497                pad_height_mm,
2498                x_mm,
2499                y_mm,
2500                dir,
2501                start,
2502                shape,
2503            );
2504        } else {
2505            builder.add_pad_row(
2506                count,
2507                pitch_mm,
2508                pad_width_mm,
2509                pad_height_mm,
2510                x_mm,
2511                y_mm,
2512                dir,
2513                start,
2514                shape,
2515            );
2516        }
2517    }
2518
2519    // Extract pads from the built component and add to existing footprint
2520    let mut det = ();
2521    let temp = builder.build_deterministic(&mut det);
2522    for prim in temp.primitives {
2523        component.add_primitive(prim);
2524    }
2525
2526    save_pcblib(path, &lib)?;
2527
2528    let term = if use_spacing { "spacing" } else { "pitch" };
2529    println!(
2530        "Added {} pads to '{}' ({} {} {}, direction: {})",
2531        count,
2532        footprint,
2533        pitch,
2534        term,
2535        if hole_mm > 0.0 { "through-hole" } else { "SMD" },
2536        direction
2537    );
2538
2539    Ok(())
2540}
2541
2542/// Add dual rows of pads (SOIC, DIP style).
2543#[allow(clippy::too_many_arguments)]
2544pub fn cmd_add_dual_row(
2545    path: &Path,
2546    footprint: &str,
2547    pads_per_side: usize,
2548    pitch: &str,
2549    row_spacing: &str,
2550    pad_width: Option<&str>,
2551    pad_height: Option<&str>,
2552    pad_diameter: Option<&str>,
2553    hole: Option<&str>,
2554    shape_str: &str,
2555) -> Result<(), Box<dyn std::error::Error>> {
2556    let mut lib = open_pcblib(path)?;
2557
2558    let component = lib
2559        .components
2560        .iter_mut()
2561        .find(|c| c.pattern == footprint)
2562        .ok_or_else(|| format!("Footprint '{}' not found", footprint))?;
2563
2564    let pitch_mm = parse_unit_value(pitch)?;
2565    let row_spacing_mm = parse_unit_value(row_spacing)?;
2566    let shape = parse_pad_shape(shape_str)?;
2567
2568    let mut builder = FootprintBuilder::new(footprint);
2569
2570    // Determine if through-hole or SMD based on hole parameter
2571    if let Some(hole_str) = hole {
2572        // Through-hole
2573        let hole_mm = parse_unit_value(hole_str)?;
2574        let pad_dia_mm = if let Some(d) = pad_diameter {
2575            parse_unit_value(d)?
2576        } else if let Some(w) = pad_width {
2577            parse_unit_value(w)?
2578        } else {
2579            return Err("Through-hole pads require --pad-diameter or --pad-width"
2580                .to_string()
2581                .into());
2582        };
2583
2584        builder.add_dual_row_th(
2585            pads_per_side,
2586            pitch_mm,
2587            row_spacing_mm,
2588            pad_dia_mm,
2589            hole_mm,
2590            shape,
2591        );
2592    } else {
2593        // SMD
2594        let pad_w = pad_width.ok_or("SMD pads require --pad-width")?;
2595        let pad_h = pad_height.ok_or("SMD pads require --pad-height")?;
2596        let pad_width_mm = parse_unit_value(pad_w)?;
2597        let pad_height_mm = parse_unit_value(pad_h)?;
2598
2599        builder.add_dual_row_smd(
2600            pads_per_side,
2601            pitch_mm,
2602            row_spacing_mm,
2603            pad_width_mm,
2604            pad_height_mm,
2605            shape,
2606        );
2607    }
2608
2609    // Add pads to existing footprint
2610    let mut det = ();
2611    let temp = builder.build_deterministic(&mut det);
2612    for prim in temp.primitives {
2613        component.add_primitive(prim);
2614    }
2615
2616    save_pcblib(path, &lib)?;
2617
2618    let total_pads = pads_per_side * 2;
2619    let pad_type = if hole.is_some() {
2620        "through-hole"
2621    } else {
2622        "SMD"
2623    };
2624    println!(
2625        "Added dual row ({} {} pads, {} per side) to '{}' (pitch: {}, row spacing: {})",
2626        total_pads, pad_type, pads_per_side, footprint, pitch, row_spacing
2627    );
2628
2629    Ok(())
2630}
2631
2632/// Add quad arrangement of pads (QFP style).
2633#[allow(clippy::too_many_arguments)]
2634pub fn cmd_add_quad_pads(
2635    path: &Path,
2636    footprint: &str,
2637    pads_per_side: usize,
2638    pitch: &str,
2639    span: &str,
2640    pad_width: &str,
2641    pad_height: &str,
2642    shape_str: &str,
2643) -> Result<(), Box<dyn std::error::Error>> {
2644    let mut lib = open_pcblib(path)?;
2645
2646    let component = lib
2647        .components
2648        .iter_mut()
2649        .find(|c| c.pattern == footprint)
2650        .ok_or_else(|| format!("Footprint '{}' not found", footprint))?;
2651
2652    let pitch_mm = parse_unit_value(pitch)?;
2653    let span_mm = parse_unit_value(span)?;
2654    let pad_width_mm = parse_unit_value(pad_width)?;
2655    let pad_height_mm = parse_unit_value(pad_height)?;
2656    let shape = parse_pad_shape(shape_str)?;
2657
2658    let mut builder = FootprintBuilder::new(footprint);
2659    builder.add_quad_pads_smd(
2660        pads_per_side,
2661        pitch_mm,
2662        span_mm,
2663        pad_width_mm,
2664        pad_height_mm,
2665        shape,
2666    );
2667
2668    // Add pads to existing footprint
2669    let mut det = ();
2670    let temp = builder.build_deterministic(&mut det);
2671    for prim in temp.primitives {
2672        component.add_primitive(prim);
2673    }
2674
2675    save_pcblib(path, &lib)?;
2676
2677    let total_pads = pads_per_side * 4;
2678    println!(
2679        "Added quad arrangement ({} SMD pads, {} per side) to '{}' (pitch: {}, span: {})",
2680        total_pads, pads_per_side, footprint, pitch, span
2681    );
2682
2683    Ok(())
2684}
2685
2686/// Add a grid of pads (BGA style).
2687#[allow(clippy::too_many_arguments)]
2688pub fn cmd_add_pad_grid(
2689    path: &Path,
2690    footprint: &str,
2691    rows: usize,
2692    cols: usize,
2693    pitch: &str,
2694    pad_diameter: &str,
2695    shape_str: &str,
2696    skip_center: &str,
2697) -> Result<(), Box<dyn std::error::Error>> {
2698    let mut lib = open_pcblib(path)?;
2699
2700    let component = lib
2701        .components
2702        .iter_mut()
2703        .find(|c| c.pattern == footprint)
2704        .ok_or_else(|| format!("Footprint '{}' not found", footprint))?;
2705
2706    let pitch_mm = parse_unit_value(pitch)?;
2707    let pad_diameter_mm = parse_unit_value(pad_diameter)?;
2708    let skip_center_mm = parse_unit_value(skip_center)?;
2709    let shape = parse_pad_shape(shape_str)?;
2710
2711    let mut builder = FootprintBuilder::new(footprint);
2712    builder.add_pad_grid(rows, cols, pitch_mm, pad_diameter_mm, shape, skip_center_mm);
2713
2714    // Add pads to existing footprint
2715    let mut det = ();
2716    let temp = builder.build_deterministic(&mut det);
2717    let pad_count = temp.primitives.len();
2718    for prim in temp.primitives {
2719        component.add_primitive(prim);
2720    }
2721
2722    save_pcblib(path, &lib)?;
2723
2724    let max_pads = rows * cols;
2725    let skipped = max_pads - pad_count;
2726    if skipped > 0 {
2727        println!(
2728            "Added {}x{} grid ({} pads, {} skipped in center) to '{}' (pitch: {})",
2729            rows, cols, pad_count, skipped, footprint, pitch
2730        );
2731    } else {
2732        println!(
2733            "Added {}x{} grid ({} pads) to '{}' (pitch: {})",
2734            rows, cols, pad_count, footprint, pitch
2735        );
2736    }
2737
2738    Ok(())
2739}