Skip to main content

altium_format/ops/
pcbdoc.rs

1// SPDX-License-Identifier: GPL-3.0-only
2// SPDX-FileCopyrightText: 2026 Alexander Kiselev <alex@akiselev.com>
3//
4//! PCB document operations.
5//!
6//! High-level operations for exploring and editing Altium PCB document (.PcbDoc) files.
7
8use std::collections::HashMap;
9use std::fs::File;
10use std::io::BufReader;
11use std::path::{Path, PathBuf};
12
13use crate::edit::pcb_placement::{
14    parse_coord as placement_parse_coord, parse_offset, parse_position,
15};
16use crate::edit::{BoardEdge, PcbPlacementEngine, PlacementAnchor};
17use crate::io::PcbDoc;
18use crate::ops::output::*;
19use crate::records::pcb::{
20    HatchStyle, PcbBoard, PcbPolygon, PcbRule, PolygonVertex, PolygonVertexKind, RuleKind,
21};
22use crate::types::{Coord, CoordPoint, Layer};
23
24/// Open a PcbDoc file.
25fn open_pcbdoc(path: &Path) -> Result<PcbDoc, String> {
26    let file = File::open(path).map_err(|e| format!("Error opening file: {}", e))?;
27    PcbDoc::open(BufReader::new(file)).map_err(|e| format!("Error parsing PcbDoc: {:?}", e))
28}
29
30/// Parse a coordinate string like "10mil" or "0.254mm".
31fn parse_coord(s: &str) -> Result<Coord, String> {
32    let s = s.trim().to_lowercase();
33
34    if s.ends_with("mil") {
35        let val: f64 = s
36            .trim_end_matches("mil")
37            .trim()
38            .parse()
39            .map_err(|_| format!("Invalid coordinate: {}", s))?;
40        Ok(Coord::from_mils(val))
41    } else if s.ends_with("mm") {
42        let val: f64 = s
43            .trim_end_matches("mm")
44            .trim()
45            .parse()
46            .map_err(|_| format!("Invalid coordinate: {}", s))?;
47        Ok(Coord::from_mms(val))
48    } else {
49        // Try parsing as mils by default
50        let val: f64 = s
51            .parse()
52            .map_err(|_| format!("Invalid coordinate: {} (use '10mil' or '0.254mm')", s))?;
53        Ok(Coord::from_mils(val))
54    }
55}
56
57/// Get rule kind name for display.
58fn rule_kind_display(kind: &RuleKind) -> String {
59    format!("{}", kind)
60}
61
62// ═══════════════════════════════════════════════════════════════════════════
63// HIGH-LEVEL COMMANDS
64// ═══════════════════════════════════════════════════════════════════════════
65
66/// Complete document overview.
67pub fn cmd_overview(path: &Path) -> Result<PcbDocOverview, Box<dyn std::error::Error>> {
68    let pcb = open_pcbdoc(path)?;
69
70    // Summary statistics
71    let summary = PcbDocSummary {
72        components: pcb.components.len(),
73        nets: pcb.nets.len(),
74        rules: pcb.rules.len(),
75        primitives: pcb.primitives.len(),
76        tracks: pcb.track_count(),
77        vias: pcb.via_count(),
78    };
79
80    // Design rules by category
81    let mut rules_by_kind: HashMap<String, Vec<&PcbRule>> = HashMap::new();
82    for rule in &pcb.rules {
83        rules_by_kind
84            .entry(rule_kind_display(&rule.kind))
85            .or_default()
86            .push(rule);
87    }
88
89    let mut rules_by_category: Vec<(String, Vec<RuleSummary>)> = Vec::new();
90    let mut categories: Vec<_> = rules_by_kind.keys().cloned().collect();
91    categories.sort();
92
93    for category in categories {
94        let rules = &rules_by_kind[&category];
95        let rule_summaries: Vec<RuleSummary> = rules
96            .iter()
97            .map(|rule| RuleSummary {
98                name: rule.name.clone(),
99                priority: rule.priority,
100                enabled: rule.enabled,
101            })
102            .collect();
103        rules_by_category.push((category, rule_summaries));
104    }
105
106    // Component preview (first 10)
107    let components_preview: Vec<ComponentPreview> = pcb
108        .components
109        .iter()
110        .take(10)
111        .map(|comp| ComponentPreview {
112            designator: comp.designator.clone(),
113            pattern: comp.pattern.clone(),
114            comment: comp.comment.clone(),
115        })
116        .collect();
117
118    // Nets preview (first 10)
119    let nets_preview: Vec<String> = pcb.nets.iter().take(10).cloned().collect();
120
121    Ok(PcbDocOverview {
122        path: path.display().to_string(),
123        summary,
124        rules_by_category,
125        components_preview,
126        nets_preview,
127    })
128}
129
130/// Document info and statistics.
131pub fn cmd_info(path: &Path) -> Result<PcbDocInfo, Box<dyn std::error::Error>> {
132    let pcb = open_pcbdoc(path)?;
133
134    Ok(PcbDocInfo {
135        path: path.display().to_string(),
136        component_count: pcb.components.len(),
137        net_count: pcb.nets.len(),
138        rule_count: pcb.rules.len(),
139        primitive_count: pcb.primitives.len(),
140        track_count: pcb.track_count(),
141        via_count: pcb.via_count(),
142    })
143}
144
145// ═══════════════════════════════════════════════════════════════════════════
146// DESIGN RULE COMMANDS
147// ═══════════════════════════════════════════════════════════════════════════
148
149/// List all design rules.
150pub fn cmd_rules(
151    path: &Path,
152    kind_filter: Option<String>,
153    verbose: bool,
154) -> Result<PcbDocRuleList, Box<dyn std::error::Error>> {
155    let pcb = open_pcbdoc(path)?;
156
157    let kind_filter_lower = kind_filter.as_ref().map(|s| s.to_lowercase());
158
159    let filtered_rules: Vec<_> = pcb
160        .rules
161        .iter()
162        .filter(|rule| {
163            if let Some(ref filter) = kind_filter_lower {
164                rule_kind_display(&rule.kind)
165                    .to_lowercase()
166                    .contains(filter)
167            } else {
168                true
169            }
170        })
171        .collect();
172
173    let rules: Vec<RuleInfo> = filtered_rules
174        .iter()
175        .map(|rule| {
176            let parameters = if verbose {
177                Some(
178                    rule.params
179                        .iter()
180                        .map(|(k, v)| (k.to_string(), v.to_string()))
181                        .collect(),
182                )
183            } else {
184                None
185            };
186
187            RuleInfo {
188                name: rule.name.clone(),
189                kind: rule_kind_display(&rule.kind),
190                enabled: rule.enabled,
191                priority: rule.priority,
192                scope1_expression: rule.scope1_expression.clone(),
193                scope2_expression: rule.scope2_expression.clone(),
194                comment: rule.comment.clone(),
195                parameters,
196            }
197        })
198        .collect();
199
200    Ok(PcbDocRuleList {
201        path: path.display().to_string(),
202        filter: kind_filter,
203        total_rules: rules.len(),
204        rules,
205    })
206}
207
208/// Show details for a specific rule.
209pub fn cmd_rule(
210    path: &Path,
211    name: &str,
212    _show_params: bool,
213) -> Result<PcbDocRuleDetail, Box<dyn std::error::Error>> {
214    let pcb = open_pcbdoc(path)?;
215
216    let name_lower = name.to_lowercase();
217    let rule = pcb
218        .rules
219        .iter()
220        .find(|r| r.name.to_lowercase() == name_lower)
221        .ok_or_else(|| format!("Rule '{}' not found", name))?;
222
223    Ok(PcbDocRuleDetail {
224        name: rule.name.clone(),
225        kind: rule_kind_display(&rule.kind),
226        enabled: rule.enabled,
227        priority: rule.priority,
228        scope1_expression: rule.scope1_expression.clone(),
229        scope2_expression: rule.scope2_expression.clone(),
230        comment: rule.comment.clone(),
231        parameters: rule
232            .params
233            .iter()
234            .map(|(k, v)| (k.to_string(), v.to_string()))
235            .collect(),
236    })
237}
238
239/// Add a new design rule.
240#[allow(clippy::too_many_arguments)]
241pub fn cmd_add_rule(
242    path: &Path,
243    kind_str: &str,
244    name: &str,
245    priority: i32,
246    scope1: &str,
247    scope2: &str,
248    gap: Option<String>,
249    min_width: Option<String>,
250    max_width: Option<String>,
251    pref_width: Option<String>,
252    comment: Option<String>,
253    disabled: bool,
254) -> Result<(), String> {
255    let mut pcb = open_pcbdoc(path)?;
256
257    // Check if rule already exists
258    if pcb
259        .rules
260        .iter()
261        .any(|r| r.name.to_lowercase() == name.to_lowercase())
262    {
263        return Err(format!("Rule '{}' already exists", name));
264    }
265
266    // Parse rule kind
267    let kind = RuleKind::from_name(kind_str)
268        .ok_or_else(|| format!("Unknown rule kind: '{}'. Valid kinds: Clearance, Width, RoutingLayers, RoutingVias, etc.", kind_str))?;
269
270    // Create the rule
271    let mut rule = PcbRule::new(kind, name);
272    rule.enabled = !disabled;
273    rule.priority = priority;
274    rule.scope1_expression = scope1.to_string();
275    rule.scope2_expression = scope2.to_string();
276
277    // Generate a unique ID (8 uppercase chars)
278    rule.unique_id = format!(
279        "{:08X}",
280        std::time::SystemTime::now()
281            .duration_since(std::time::UNIX_EPOCH)
282            .unwrap_or_default()
283            .as_secs() as u32
284    );
285
286    if let Some(ref c) = comment {
287        rule.comment = c.clone();
288    }
289
290    // Set parameters based on rule kind
291    if let Some(ref gap_str) = gap {
292        let coord = parse_coord(gap_str)?;
293        rule.params.add_coord("GAP", coord);
294    }
295    if let Some(ref min_str) = min_width {
296        let coord = parse_coord(min_str)?;
297        rule.params.add_coord("MINWIDTH", coord);
298    }
299    if let Some(ref max_str) = max_width {
300        let coord = parse_coord(max_str)?;
301        rule.params.add_coord("MAXWIDTH", coord);
302    }
303    if let Some(ref pref_str) = pref_width {
304        let coord = parse_coord(pref_str)?;
305        rule.params.add_coord("PREFWIDTH", coord);
306    }
307
308    // Add common fields that Altium expects
309    rule.params.add("SELECTION", "FALSE");
310    rule.params.add("LAYER", "UNKNOWN");
311    rule.params.add("LOCKED", "FALSE");
312    rule.params.add("POLYGONOUTLINE", "FALSE");
313    rule.params.add("USERROUTED", "TRUE");
314    rule.params.add("KEEPOUT", "FALSE");
315    rule.params.add("UNIONINDEX", "0");
316
317    pcb.add_rule(rule);
318
319    // Save the file
320    pcb.save_to_file(path)
321        .map_err(|e| format!("Error saving file: {:?}", e))?;
322
323    println!("Added rule '{}' ({}) to {}", name, kind_str, path.display());
324    println!("Total rules: {}", pcb.rules.len());
325
326    Ok(())
327}
328
329/// Modify an existing design rule.
330#[allow(clippy::too_many_arguments)]
331pub fn cmd_modify_rule(
332    path: &Path,
333    name: &str,
334    priority: Option<i32>,
335    gap: Option<String>,
336    min_width: Option<String>,
337    max_width: Option<String>,
338    pref_width: Option<String>,
339    comment: Option<String>,
340    enable: bool,
341    disable: bool,
342) -> Result<(), String> {
343    let mut pcb = open_pcbdoc(path)?;
344
345    let name_lower = name.to_lowercase();
346    let rule = pcb
347        .rules
348        .iter_mut()
349        .find(|r| r.name.to_lowercase() == name_lower)
350        .ok_or_else(|| format!("Rule '{}' not found", name))?;
351
352    let mut changes = Vec::new();
353
354    if let Some(p) = priority {
355        rule.priority = p;
356        changes.push(format!("priority={}", p));
357    }
358
359    if enable && disable {
360        return Err("Cannot use both --enable and --disable".to_string());
361    }
362    if enable {
363        rule.enabled = true;
364        changes.push("enabled=true".to_string());
365    }
366    if disable {
367        rule.enabled = false;
368        changes.push("enabled=false".to_string());
369    }
370
371    if let Some(ref c) = comment {
372        rule.comment = c.clone();
373        changes.push(format!("comment={}", c));
374    }
375
376    if let Some(ref gap_str) = gap {
377        let coord = parse_coord(gap_str)?;
378        rule.params.add_coord("GAP", coord);
379        changes.push(format!("GAP={}", gap_str));
380    }
381    if let Some(ref min_str) = min_width {
382        let coord = parse_coord(min_str)?;
383        rule.params.add_coord("MINWIDTH", coord);
384        changes.push(format!("MINWIDTH={}", min_str));
385    }
386    if let Some(ref max_str) = max_width {
387        let coord = parse_coord(max_str)?;
388        rule.params.add_coord("MAXWIDTH", coord);
389        changes.push(format!("MAXWIDTH={}", max_str));
390    }
391    if let Some(ref pref_str) = pref_width {
392        let coord = parse_coord(pref_str)?;
393        rule.params.add_coord("PREFWIDTH", coord);
394        changes.push(format!("PREFWIDTH={}", pref_str));
395    }
396
397    if changes.is_empty() {
398        println!("No changes specified");
399        return Ok(());
400    }
401
402    // Save the file
403    pcb.save_to_file(path)
404        .map_err(|e| format!("Error saving file: {:?}", e))?;
405
406    println!("Modified rule '{}' in {}", name, path.display());
407    for change in changes {
408        println!("  {}", change);
409    }
410
411    Ok(())
412}
413
414/// Delete a design rule.
415pub fn cmd_delete_rule(path: &Path, name: &str) -> Result<(), String> {
416    let mut pcb = open_pcbdoc(path)?;
417
418    let name_lower = name.to_lowercase();
419    let original_count = pcb.rules.len();
420
421    pcb.rules.retain(|r| r.name.to_lowercase() != name_lower);
422
423    if pcb.rules.len() == original_count {
424        return Err(format!("Rule '{}' not found", name));
425    }
426
427    // Save the file
428    pcb.save_to_file(path)
429        .map_err(|e| format!("Error saving file: {:?}", e))?;
430
431    println!("Deleted rule '{}' from {}", name, path.display());
432    println!("Remaining rules: {}", pcb.rules.len());
433
434    Ok(())
435}
436
437/// Export as JSON.
438pub fn cmd_json(
439    path: &Path,
440    full: bool,
441    _pretty: bool,
442) -> Result<PcbDocJson, Box<dyn std::error::Error>> {
443    let pcb = open_pcbdoc(path)?;
444
445    let summary = PcbDocSummary {
446        components: pcb.components.len(),
447        nets: pcb.nets.len(),
448        rules: pcb.rules.len(),
449        primitives: pcb.primitives.len(),
450        tracks: pcb.track_count(),
451        vias: pcb.via_count(),
452    };
453
454    let rules: Option<Vec<RuleInfo>> = if full {
455        Some(
456            pcb.rules
457                .iter()
458                .map(|rule| RuleInfo {
459                    name: rule.name.clone(),
460                    kind: rule_kind_display(&rule.kind),
461                    enabled: rule.enabled,
462                    priority: rule.priority,
463                    scope1_expression: rule.scope1_expression.clone(),
464                    scope2_expression: rule.scope2_expression.clone(),
465                    comment: rule.comment.clone(),
466                    parameters: Some(
467                        rule.params
468                            .iter()
469                            .map(|(k, v)| (k.to_string(), v.to_string()))
470                            .collect(),
471                    ),
472                })
473                .collect(),
474        )
475    } else {
476        None
477    };
478
479    let components: Option<Vec<PcbComponentInfo>> = if full {
480        Some(
481            pcb.components
482                .iter()
483                .map(|c| {
484                    let locked = c
485                        .params
486                        .get("LOCKED")
487                        .map(|v| v.to_string() == "T")
488                        .unwrap_or(false);
489                    PcbComponentInfo {
490                        designator: c.designator.clone(),
491                        pattern: c.pattern.clone(),
492                        comment: c.comment.clone(),
493                        x: c.x().map(|coord| format!("{:.3}mm", coord.to_mms())),
494                        y: c.y().map(|coord| format!("{:.3}mm", coord.to_mms())),
495                        rotation: c.rotation(),
496                        layer: c.layer().name().to_string(),
497                        locked,
498                    }
499                })
500                .collect(),
501        )
502    } else {
503        None
504    };
505
506    let nets = if full { Some(pcb.nets.clone()) } else { None };
507
508    Ok(PcbDocJson {
509        file: path.display().to_string(),
510        summary,
511        rules,
512        components,
513        nets,
514        layers: None, // Not included in this implementation
515    })
516}
517
518// ═══════════════════════════════════════════════════════════════════════════
519// COMPONENT COMMAND IMPLEMENTATIONS
520// ═══════════════════════════════════════════════════════════════════════════
521
522/// List all components.
523pub fn cmd_components(
524    path: &Path,
525    _verbose: bool,
526    layer_filter: Option<String>,
527) -> Result<PcbDocComponentList, Box<dyn std::error::Error>> {
528    let pcb = open_pcbdoc(path)?;
529
530    // Filter by layer if specified
531    let layer_filter_lower = layer_filter.as_ref().map(|s| s.to_lowercase());
532
533    let components: Vec<PcbComponentInfo> = pcb
534        .components
535        .iter()
536        .filter(|component| {
537            if let Some(ref filter) = layer_filter_lower {
538                let layer_name = component.layer().name();
539                layer_name.to_lowercase().contains(filter)
540            } else {
541                true
542            }
543        })
544        .map(|component| {
545            let locked = component
546                .params
547                .get("LOCKED")
548                .map(|v| v.to_string() == "T")
549                .unwrap_or(false);
550            PcbComponentInfo {
551                designator: component.designator.clone(),
552                pattern: component.pattern.clone(),
553                comment: component.comment.clone(),
554                x: component.x().map(|c| format!("{:.3}mm", c.to_mms())),
555                y: component.y().map(|c| format!("{:.3}mm", c.to_mms())),
556                rotation: component.rotation(),
557                layer: component.layer().name().to_string(),
558                locked,
559            }
560        })
561        .collect();
562
563    Ok(PcbDocComponentList {
564        path: path.display().to_string(),
565        total_components: components.len(),
566        layer_filter,
567        components,
568    })
569}
570
571/// Show component details.
572pub fn cmd_component(
573    path: &Path,
574    designator: &str,
575    _show_params: bool,
576) -> Result<PcbDocComponentDetail, Box<dyn std::error::Error>> {
577    let pcb = open_pcbdoc(path)?;
578
579    let component = pcb
580        .find_component(designator)
581        .ok_or_else(|| format!("Component '{}' not found", designator))?;
582
583    // Count pads in component
584    let pad_count = component
585        .primitives
586        .iter()
587        .filter(|p| matches!(p, crate::records::pcb::PcbRecord::Pad(_)))
588        .count();
589
590    let locked = component
591        .params
592        .get("LOCKED")
593        .map(|v| v.to_string() == "T")
594        .unwrap_or(false);
595    let source_designator = component
596        .params
597        .get("SOURCEDESIGNATOR")
598        .map(|v| v.to_string())
599        .unwrap_or_default();
600    let source_footprint = component
601        .params
602        .get("SOURCEFOOTPRINTLIBRARY")
603        .map(|v| v.to_string())
604        .unwrap_or_default();
605    let unique_id = component
606        .params
607        .get("UNIQUEID")
608        .map(|v| v.to_string())
609        .unwrap_or_default();
610
611    Ok(PcbDocComponentDetail {
612        designator: component.designator.clone(),
613        pattern: component.pattern.clone(),
614        comment: component.comment.clone(),
615        source_designator,
616        source_footprint,
617        x: component.x().map(|c| format!("{:.4}mm", c.to_mms())),
618        y: component.y().map(|c| format!("{:.4}mm", c.to_mms())),
619        rotation: component.rotation(),
620        layer: component.layer().name().to_string(),
621        locked,
622        pad_count,
623        unique_id,
624    })
625}
626
627/// Place (move) a component.
628#[allow(clippy::too_many_arguments)]
629pub fn cmd_place_component(
630    path: &Path,
631    designator: &str,
632    at: Option<String>,
633    near: Option<String>,
634    align_x: Option<String>,
635    align_y: Option<String>,
636    edge: Option<String>,
637    offset: Option<String>,
638    rotation: Option<f64>,
639    layer: Option<String>,
640    grid: Option<String>,
641    force: bool,
642) -> Result<(), String> {
643    let mut pcb = open_pcbdoc(path)?;
644
645    // Check component exists
646    if pcb.find_component(designator).is_none() {
647        return Err(format!("Component '{}' not found", designator));
648    }
649
650    // Set up placement engine
651    let mut engine = PcbPlacementEngine::new();
652
653    // Configure grid
654    if let Some(ref grid_str) = grid {
655        let grid_coord = placement_parse_coord(grid_str)?;
656        engine.set_grid(crate::edit::types::Grid {
657            spacing: grid_coord,
658            snap_enabled: true,
659        });
660    }
661
662    // Calculate board bounds for edge alignment
663    engine.calculate_board_bounds(&pcb);
664
665    // Check for connected routes
666    let connected = engine.find_connected_routes(&pcb, designator);
667    if connected.has_connections() && !force {
668        return Err(format!(
669            "Component '{}' has {} connected routes ({} tracks, {} vias). Use --force to move anyway.",
670            designator,
671            connected.count(),
672            connected.tracks.len(),
673            connected.vias.len()
674        ));
675    }
676
677    // Get current position
678    let current_pos = engine.get_component_position(&pcb, designator);
679
680    // Parse offset if provided
681    let offset_point = if let Some(ref offset_str) = offset {
682        parse_offset(offset_str)?
683    } else {
684        CoordPoint::ZERO
685    };
686
687    // Determine placement anchor
688    let anchor = if let Some(ref at_str) = at {
689        PlacementAnchor::Absolute(parse_position(at_str)?)
690    } else if let Some(ref near_str) = near {
691        PlacementAnchor::NearComponent {
692            designator: near_str.clone(),
693            offset: offset_point,
694        }
695    } else if let Some(ref align_x_str) = align_x {
696        PlacementAnchor::AlignX {
697            designator: align_x_str.clone(),
698            offset: offset_point.x,
699        }
700    } else if let Some(ref align_y_str) = align_y {
701        PlacementAnchor::AlignY {
702            designator: align_y_str.clone(),
703            offset: offset_point.y,
704        }
705    } else if let Some(ref edge_str) = edge {
706        let board_edge = BoardEdge::try_parse(edge_str).ok_or_else(|| {
707            format!(
708                "Invalid edge: '{}'. Use: left, right, top, bottom",
709                edge_str
710            )
711        })?;
712        PlacementAnchor::BoardEdge {
713            edge: board_edge,
714            offset: offset_point.x, // Use X offset for edge alignment
715        }
716    } else if rotation.is_some() || layer.is_some() {
717        // Just updating rotation or layer, keep current position
718        if let Some(ref pos) = current_pos {
719            PlacementAnchor::Absolute(CoordPoint::new(pos.x, pos.y))
720        } else {
721            return Err("No position specified and component has no current position".to_string());
722        }
723    } else {
724        return Err(
725            "No position specified. Use --at, --near, --align-x, --align-y, or --edge".to_string(),
726        );
727    };
728
729    // Resolve the target position
730    let target_pos = engine.resolve_anchor(&pcb, &anchor, current_pos.as_ref())?;
731
732    // Update the component
733    {
734        let component = pcb
735            .find_component_mut(designator)
736            .ok_or_else(|| format!("Component '{}' not found", designator))?;
737
738        component.set_position(target_pos.x, target_pos.y);
739
740        if let Some(rot) = rotation {
741            component.set_rotation(rot);
742        }
743
744        if let Some(ref layer_str) = layer {
745            let new_layer = Layer::from_name(layer_str).ok_or_else(|| {
746                format!(
747                    "Invalid layer: '{}'. Use: TopLayer, BottomLayer, etc.",
748                    layer_str
749                )
750            })?;
751            component.set_layer(new_layer);
752        }
753    }
754
755    // Save the file
756    pcb.save_with_components(path)
757        .map_err(|e| format!("Error saving file: {:?}", e))?;
758
759    // Print result
760    println!("Moved component '{}' in {}", designator, path.display());
761    println!(
762        "  New position: {:.4}mm, {:.4}mm",
763        target_pos.x.to_mms(),
764        target_pos.y.to_mms()
765    );
766    if let Some(rot) = rotation {
767        println!("  Rotation: {:.1} degrees", rot);
768    }
769    if let Some(ref layer_str) = layer {
770        println!("  Layer: {}", layer_str);
771    }
772    if connected.has_connections() && force {
773        println!(
774            "\n  Warning: {} connected routes may need to be re-routed.",
775            connected.count()
776        );
777    }
778
779    Ok(())
780}
781
782/// Add a component from schematic.
783pub fn cmd_add_component(
784    path: &Path,
785    schematic: &Path,
786    designator: &str,
787    _footprint_lib: Option<PathBuf>,
788    footprint: Option<String>,
789    at: Option<String>,
790    layer: &str,
791) -> Result<(), String> {
792    // Open the schematic to find component info
793    let sch_file = File::open(schematic).map_err(|e| format!("Error opening schematic: {}", e))?;
794    let sch = crate::io::SchDoc::open(BufReader::new(sch_file))
795        .map_err(|e| format!("Error parsing schematic: {:?}", e))?;
796
797    // Find the component in the schematic by looking through designator records
798    let mut found_component: Option<&crate::records::sch::SchComponent> = None;
799    let mut component_comment = String::new();
800
801    // First, find the designator record that matches
802    for record in &sch.primitives {
803        if let crate::records::sch::SchRecord::Designator(d) = record {
804            if d.text().eq_ignore_ascii_case(designator) {
805                // Found the designator, now find the component it belongs to
806                let owner_index = d.param.label.graphical.base.owner_index;
807                if owner_index >= 0 && (owner_index as usize) < sch.primitives.len() {
808                    if let crate::records::sch::SchRecord::Component(c) =
809                        &sch.primitives[owner_index as usize]
810                    {
811                        found_component = Some(c);
812                    }
813                }
814                break;
815            }
816        }
817    }
818
819    // Also look for any comment/parameter with the value
820    for record in &sch.primitives {
821        if let crate::records::sch::SchRecord::Parameter(p) = record {
822            if p.name.to_uppercase() == "VALUE" || p.name.to_uppercase() == "COMMENT" {
823                if let Some(comp) = found_component {
824                    if p.label.graphical.base.owner_index == comp.graphical.base.owner_index {
825                        component_comment = p.value().to_string();
826                    }
827                }
828            }
829        }
830    }
831
832    let sch_component = found_component
833        .ok_or_else(|| format!("Component '{}' not found in schematic", designator))?;
834
835    // Get footprint from command line (schematic footprint info is in a separate record)
836    let footprint_name = footprint.ok_or_else(|| {
837        format!(
838            "No footprint specified for component '{}'. Use --footprint.",
839            designator
840        )
841    })?;
842
843    // Open the PCB document
844    let mut pcb = open_pcbdoc(path)?;
845
846    // Check if component already exists
847    if pcb.find_component(designator).is_some() {
848        return Err(format!("Component '{}' already exists in PCB", designator));
849    }
850
851    // Parse initial position
852    let position = if let Some(ref at_str) = at {
853        parse_position(at_str)?
854    } else {
855        CoordPoint::from_mms(25.0, 25.0) // Default position
856    };
857
858    // Parse layer
859    let pcb_layer = Layer::from_name(layer)
860        .or_else(|| match layer.to_lowercase().as_str() {
861            "top" => Some(Layer::TOP_LAYER),
862            "bottom" => Some(Layer::BOTTOM_LAYER),
863            _ => None,
864        })
865        .ok_or_else(|| {
866            format!(
867                "Invalid layer: '{}'. Use: TOP, BOTTOM, TopLayer, BottomLayer",
868                layer
869            )
870        })?;
871
872    // Create a new component
873    let mut params = crate::types::ParameterCollection::new();
874    params.add("SELECTION", "FALSE");
875    params.add("LAYER", pcb_layer.name());
876    params.add("LOCKED", "FALSE");
877    params.add("POLYGONOUTLINE", "FALSE");
878    params.add("USERROUTED", "TRUE");
879    params.add("KEEPOUT", "FALSE");
880    params.add("PRIMITIVELOCK", "FALSE");
881    params.add_coord("X", position.x);
882    params.add_coord("Y", position.y);
883    params.add("PATTERN", &footprint_name);
884    params.add("NAMEON", "TRUE");
885    params.add("COMMENTON", "TRUE");
886    params.add("GROUPNUM", "0");
887    params.add("COUNT", "0");
888    params.add("ROTATION", "0.00000000000000E+0000");
889    params.add("SOURCEDESIGNATOR", designator);
890    params.add("SOURCELIBREFERENCE", &sch_component.lib_reference);
891    params.add(
892        "UNIQUEID",
893        &format!(
894            "{:08X}",
895            std::time::SystemTime::now()
896                .duration_since(std::time::UNIX_EPOCH)
897                .unwrap_or_default()
898                .as_secs() as u32
899        ),
900    );
901
902    if !component_comment.is_empty() {
903        params.add("COMMENT", &component_comment);
904    }
905
906    let new_component = crate::io::PcbDocComponent {
907        designator: designator.to_string(),
908        pattern: footprint_name.clone(),
909        comment: component_comment.clone(),
910        params,
911        primitives: Vec::new(),
912    };
913
914    pcb.components.push(new_component);
915
916    // Save the file
917    pcb.save_with_components(path)
918        .map_err(|e| format!("Error saving file: {:?}", e))?;
919
920    println!("Added component '{}' to {}", designator, path.display());
921    println!("  Footprint: {}", footprint_name);
922    println!(
923        "  Position:  {:.4}mm, {:.4}mm",
924        position.x.to_mms(),
925        position.y.to_mms()
926    );
927    println!("  Layer:     {}", pcb_layer.name());
928    if !component_comment.is_empty() {
929        println!("  Comment:   {}", component_comment);
930    }
931    println!("\nNote: Component pads need to be populated from a footprint library.");
932
933    Ok(())
934}
935
936// ═══════════════════════════════════════════════════════════════════════════
937// CREATION COMMAND IMPLEMENTATIONS
938// ═══════════════════════════════════════════════════════════════════════════
939
940/// Embedded blank PcbDoc template.
941/// This is the binary content of data/blank/PCB1.PcbDoc.
942const BLANK_PCBDOC_TEMPLATE: &[u8] = include_bytes!("../../data/PCB1.PcbDoc");
943
944/// Create a new empty PcbDoc file.
945pub fn cmd_create(path: &Path, template: Option<PathBuf>) -> Result<(), String> {
946    if path.exists() {
947        return Err(format!("File already exists: {}", path.display()));
948    }
949
950    match template {
951        Some(template_path) => {
952            // Copy from user-specified template
953            std::fs::copy(&template_path, path)
954                .map_err(|e| format!("Error copying template: {}", e))?;
955            println!("Created PcbDoc from template: {}", path.display());
956            println!("  Template: {}", template_path.display());
957        }
958        None => {
959            // Use embedded blank template
960            std::fs::write(path, BLANK_PCBDOC_TEMPLATE)
961                .map_err(|e| format!("Error creating file: {}", e))?;
962            println!("Created empty PcbDoc: {}", path.display());
963        }
964    }
965
966    // Verify the file was created correctly
967    let pcb = open_pcbdoc(path)?;
968    println!("  Rules: {}", pcb.rules.len());
969    println!("  Classes: {}", pcb.classes.len());
970
971    Ok(())
972}
973
974// ═══════════════════════════════════════════════════════════════════════════
975// BOARD OUTLINE COMMANDS
976// ═══════════════════════════════════════════════════════════════════════════
977
978/// Display the board outline.
979pub fn cmd_outline(path: &Path, _json: bool) -> Result<PcbDocOutline, Box<dyn std::error::Error>> {
980    let pcb = open_pcbdoc(path)?;
981    let board = PcbBoard::from_params(&pcb.board_params);
982
983    let vertices: Vec<OutlineVertex> = board
984        .outline
985        .iter()
986        .map(|v| {
987            let is_arc = matches!(v.kind, PolygonVertexKind::Arc);
988            OutlineVertex {
989                x_mm: v.x.to_mms(),
990                y_mm: v.y.to_mms(),
991                kind: match v.kind {
992                    PolygonVertexKind::Line => "line".to_string(),
993                    PolygonVertexKind::Arc => "arc".to_string(),
994                },
995                center_x_mm: if is_arc {
996                    Some(v.center_x.to_mms())
997                } else {
998                    None
999                },
1000                center_y_mm: if is_arc {
1001                    Some(v.center_y.to_mms())
1002                } else {
1003                    None
1004                },
1005                radius_mm: if is_arc {
1006                    Some(v.radius.to_mms())
1007                } else {
1008                    None
1009                },
1010            }
1011        })
1012        .collect();
1013
1014    let (width, height) = calculate_outline_dimensions(&board.outline);
1015
1016    Ok(PcbDocOutline {
1017        vertex_count: board.outline.len(),
1018        width_mm: width,
1019        height_mm: height,
1020        vertices,
1021    })
1022}
1023
1024/// Calculate width and height from outline vertices.
1025fn calculate_outline_dimensions(vertices: &[PolygonVertex]) -> (f64, f64) {
1026    if vertices.is_empty() {
1027        return (0.0, 0.0);
1028    }
1029
1030    let min_x = vertices
1031        .iter()
1032        .map(|v| v.x.to_mms())
1033        .fold(f64::INFINITY, f64::min);
1034    let max_x = vertices
1035        .iter()
1036        .map(|v| v.x.to_mms())
1037        .fold(f64::NEG_INFINITY, f64::max);
1038    let min_y = vertices
1039        .iter()
1040        .map(|v| v.y.to_mms())
1041        .fold(f64::INFINITY, f64::min);
1042    let max_y = vertices
1043        .iter()
1044        .map(|v| v.y.to_mms())
1045        .fold(f64::NEG_INFINITY, f64::max);
1046
1047    (max_x - min_x, max_y - min_y)
1048}
1049
1050/// Set board outline to a rectangle.
1051pub fn cmd_set_outline_rect(
1052    path: &Path,
1053    width: &str,
1054    height: &str,
1055    origin_x: &str,
1056    origin_y: &str,
1057) -> Result<(), String> {
1058    let w = parse_coord(width)?;
1059    let h = parse_coord(height)?;
1060    let ox = parse_coord(origin_x)?;
1061    let oy = parse_coord(origin_y)?;
1062
1063    let mut pcb = open_pcbdoc(path)?;
1064
1065    // Create rectangular outline
1066    let vertices = vec![
1067        PolygonVertex {
1068            kind: PolygonVertexKind::Line,
1069            x: ox,
1070            y: oy,
1071            ..Default::default()
1072        },
1073        PolygonVertex {
1074            kind: PolygonVertexKind::Line,
1075            x: Coord::from_raw(ox.to_raw() + w.to_raw()),
1076            y: oy,
1077            ..Default::default()
1078        },
1079        PolygonVertex {
1080            kind: PolygonVertexKind::Line,
1081            x: Coord::from_raw(ox.to_raw() + w.to_raw()),
1082            y: Coord::from_raw(oy.to_raw() + h.to_raw()),
1083            ..Default::default()
1084        },
1085        PolygonVertex {
1086            kind: PolygonVertexKind::Line,
1087            x: ox,
1088            y: Coord::from_raw(oy.to_raw() + h.to_raw()),
1089            ..Default::default()
1090        },
1091    ];
1092
1093    // Update board params with new outline
1094    update_board_outline(&mut pcb.board_params, &vertices);
1095
1096    // Save the file
1097    pcb.save_board_to_file(path)
1098        .map_err(|e| format!("Error saving file: {:?}", e))?;
1099
1100    println!(
1101        "Set board outline to rectangle: {:.3}mm x {:.3}mm",
1102        w.to_mms(),
1103        h.to_mms()
1104    );
1105    println!("  Origin: ({:.3}mm, {:.3}mm)", ox.to_mms(), oy.to_mms());
1106
1107    Ok(())
1108}
1109
1110/// Set board outline from vertices.
1111pub fn cmd_set_outline(path: &Path, vertices_str: &str) -> Result<(), String> {
1112    let mut vertices = Vec::new();
1113
1114    for part in vertices_str.split_whitespace() {
1115        let coords: Vec<&str> = part.split(',').collect();
1116        if coords.len() != 2 {
1117            return Err(format!(
1118                "Invalid vertex format: '{}'. Use 'x,y' format.",
1119                part
1120            ));
1121        }
1122
1123        let x = parse_coord(coords[0])?;
1124        let y = parse_coord(coords[1])?;
1125
1126        vertices.push(PolygonVertex {
1127            kind: PolygonVertexKind::Line,
1128            x,
1129            y,
1130            ..Default::default()
1131        });
1132    }
1133
1134    if vertices.len() < 3 {
1135        return Err("Board outline requires at least 3 vertices.".to_string());
1136    }
1137
1138    let mut pcb = open_pcbdoc(path)?;
1139    update_board_outline(&mut pcb.board_params, &vertices);
1140    pcb.save_board_to_file(path)
1141        .map_err(|e| format!("Error saving file: {:?}", e))?;
1142
1143    println!("Set board outline with {} vertices", vertices.len());
1144
1145    Ok(())
1146}
1147
1148/// Update board params with outline vertices.
1149fn update_board_outline(
1150    params: &mut crate::types::ParameterCollection,
1151    vertices: &[PolygonVertex],
1152) {
1153    // First, remove any existing vertex params
1154    let mut idx = 0;
1155    loop {
1156        let vx_key = format!("VX{}", idx);
1157        if !params.contains(&vx_key) {
1158            break;
1159        }
1160        params.remove(&vx_key);
1161        params.remove(&format!("VY{}", idx));
1162        params.remove(&format!("KIND{}", idx));
1163        params.remove(&format!("CX{}", idx));
1164        params.remove(&format!("CY{}", idx));
1165        params.remove(&format!("SA{}", idx));
1166        params.remove(&format!("EA{}", idx));
1167        params.remove(&format!("R{}", idx));
1168        idx += 1;
1169    }
1170
1171    // Add new vertices
1172    for (i, v) in vertices.iter().enumerate() {
1173        params.add_int(&format!("KIND{}", i), v.kind.to_int());
1174        params.add_coord(&format!("VX{}", i), v.x);
1175        params.add_coord(&format!("VY{}", i), v.y);
1176        params.add_coord(&format!("CX{}", i), v.center_x);
1177        params.add_coord(&format!("CY{}", i), v.center_y);
1178        params.add_double(&format!("SA{}", i), v.start_angle, 14);
1179        params.add_double(&format!("EA{}", i), v.end_angle, 14);
1180        params.add_coord(&format!("R{}", i), v.radius);
1181    }
1182}
1183
1184// ═══════════════════════════════════════════════════════════════════════════
1185// BOARD SETTINGS COMMANDS
1186// ═══════════════════════════════════════════════════════════════════════════
1187
1188/// Display board settings.
1189pub fn cmd_settings(
1190    path: &Path,
1191    _json: bool,
1192) -> Result<PcbDocSettings, Box<dyn std::error::Error>> {
1193    let pcb = open_pcbdoc(path)?;
1194    let board = PcbBoard::from_params(&pcb.board_params);
1195
1196    let unit = if board.is_metric() { "mm" } else { "mil" };
1197
1198    Ok(PcbDocSettings {
1199        display_unit: unit.to_string(),
1200        snap_grid: format!("{:.6}{}", board.snap_grid_size, unit),
1201        visible_grid: format!("{:.6}{}", board.visible_grid_size, unit),
1202        component_grid: format!("{:.6}{}", board.component_grid_size, unit),
1203        track_grid: Some(format!("{:.6}{}", board.track_grid_size, unit)),
1204        via_grid: Some(format!("{:.6}{}", board.via_grid_size, unit)),
1205        track_width: Some(format!("{:.3}mm", board.track_width.to_mms())),
1206        origin_x: format!("{:.3}mm", board.origin_x.to_mms()),
1207        origin_y: format!("{:.3}mm", board.origin_y.to_mms()),
1208    })
1209}
1210
1211/// Modify board settings.
1212#[allow(clippy::too_many_arguments)]
1213pub fn cmd_set_settings(
1214    path: &Path,
1215    metric: bool,
1216    imperial: bool,
1217    snap_grid: Option<String>,
1218    visible_grid: Option<String>,
1219    component_grid: Option<String>,
1220    track_grid: Option<String>,
1221    via_grid: Option<String>,
1222    track_width: Option<String>,
1223    origin_x: Option<String>,
1224    origin_y: Option<String>,
1225) -> Result<(), String> {
1226    use crate::records::pcb::DisplayUnit;
1227
1228    let mut pcb = open_pcbdoc(path)?;
1229    let mut changes = Vec::new();
1230
1231    if metric && imperial {
1232        return Err("Cannot use both --metric and --imperial".to_string());
1233    }
1234
1235    if metric {
1236        pcb.board_params
1237            .add_int("DISPLAYUNIT", DisplayUnit::Metric.to_int());
1238        changes.push("display_unit=metric".to_string());
1239    }
1240    if imperial {
1241        pcb.board_params
1242            .add_int("DISPLAYUNIT", DisplayUnit::Imperial.to_int());
1243        changes.push("display_unit=imperial".to_string());
1244    }
1245
1246    if let Some(ref v) = snap_grid {
1247        let coord = parse_coord(v)?;
1248        let val = if v.contains("mm") {
1249            coord.to_mms()
1250        } else {
1251            coord.to_mils()
1252        };
1253        pcb.board_params.add_double("SNAPGRIDSIZE", val, 6);
1254        pcb.board_params.add_double("SNAPGRIDSIZEX", val, 6);
1255        pcb.board_params.add_double("SNAPGRIDSIZEY", val, 6);
1256        changes.push(format!("snap_grid={}", v));
1257    }
1258
1259    if let Some(ref v) = visible_grid {
1260        let coord = parse_coord(v)?;
1261        let val = if v.contains("mm") {
1262            coord.to_mms()
1263        } else {
1264            coord.to_mils()
1265        };
1266        pcb.board_params.add_double("VISIBLEGRIDSIZE", val, 6);
1267        changes.push(format!("visible_grid={}", v));
1268    }
1269
1270    if let Some(ref v) = component_grid {
1271        let coord = parse_coord(v)?;
1272        let val = if v.contains("mm") {
1273            coord.to_mms()
1274        } else {
1275            coord.to_mils()
1276        };
1277        pcb.board_params.add_double("COMPONENTGRIDSIZE", val, 6);
1278        changes.push(format!("component_grid={}", v));
1279    }
1280
1281    if let Some(ref v) = track_grid {
1282        let coord = parse_coord(v)?;
1283        let val = if v.contains("mm") {
1284            coord.to_mms()
1285        } else {
1286            coord.to_mils()
1287        };
1288        pcb.board_params.add_double("TRACKGRIDSIZE", val, 6);
1289        changes.push(format!("track_grid={}", v));
1290    }
1291
1292    if let Some(ref v) = via_grid {
1293        let coord = parse_coord(v)?;
1294        let val = if v.contains("mm") {
1295            coord.to_mms()
1296        } else {
1297            coord.to_mils()
1298        };
1299        pcb.board_params.add_double("VIAGRIDSIZE", val, 6);
1300        changes.push(format!("via_grid={}", v));
1301    }
1302
1303    if let Some(ref v) = track_width {
1304        let coord = parse_coord(v)?;
1305        pcb.board_params.add_coord("TRACKWIDTH", coord);
1306        changes.push(format!("track_width={}", v));
1307    }
1308
1309    if let Some(ref v) = origin_x {
1310        let coord = parse_coord(v)?;
1311        pcb.board_params.add_coord("ORIGINX", coord);
1312        changes.push(format!("origin_x={}", v));
1313    }
1314
1315    if let Some(ref v) = origin_y {
1316        let coord = parse_coord(v)?;
1317        pcb.board_params.add_coord("ORIGINY", coord);
1318        changes.push(format!("origin_y={}", v));
1319    }
1320
1321    if changes.is_empty() {
1322        println!("No changes specified");
1323        return Ok(());
1324    }
1325
1326    pcb.save_board_to_file(path)
1327        .map_err(|e| format!("Error saving file: {:?}", e))?;
1328
1329    println!("Modified board settings in {}", path.display());
1330    for change in changes {
1331        println!("  {}", change);
1332    }
1333
1334    Ok(())
1335}
1336
1337// ═══════════════════════════════════════════════════════════════════════════
1338// LAYER STACK COMMANDS
1339// ═══════════════════════════════════════════════════════════════════════════
1340
1341/// Display the layer stack.
1342pub fn cmd_layers(path: &Path, all: bool) -> Result<PcbDocLayers, Box<dyn std::error::Error>> {
1343    let pcb = open_pcbdoc(path)?;
1344
1345    // Collect layers used in primitives
1346    let mut used_layers: std::collections::HashSet<u8> = std::collections::HashSet::new();
1347    for prim in &pcb.primitives {
1348        match prim {
1349            crate::records::pcb::PcbRecord::Track(t) => {
1350                used_layers.insert(t.common.layer.to_byte());
1351            }
1352            crate::records::pcb::PcbRecord::Arc(a) => {
1353                used_layers.insert(a.common.layer.to_byte());
1354            }
1355            crate::records::pcb::PcbRecord::Via(_) => {
1356                used_layers.insert(Layer::MULTI_LAYER.to_byte());
1357            }
1358            crate::records::pcb::PcbRecord::Fill(f) => {
1359                used_layers.insert(f.base.common.layer.to_byte());
1360            }
1361            crate::records::pcb::PcbRecord::Region(r) => {
1362                used_layers.insert(r.common.layer.to_byte());
1363            }
1364            _ => {}
1365        }
1366    }
1367
1368    // Standard layer categories
1369    let signal_layers = [
1370        (Layer::TOP_LAYER, "Top Layer", "signal"),
1371        (Layer::MID_LAYER_1, "Mid Layer 1", "signal"),
1372        (Layer::MID_LAYER_2, "Mid Layer 2", "signal"),
1373        (Layer::BOTTOM_LAYER, "Bottom Layer", "signal"),
1374    ];
1375
1376    let plane_layers = [
1377        (Layer::INTERNAL_PLANE_1, "Internal Plane 1", "plane"),
1378        (Layer::INTERNAL_PLANE_2, "Internal Plane 2", "plane"),
1379    ];
1380
1381    let mask_layers = [
1382        (Layer::TOP_SOLDER, "Top Solder Mask", "mask"),
1383        (Layer::BOTTOM_SOLDER, "Bottom Solder Mask", "mask"),
1384        (Layer::TOP_PASTE, "Top Paste", "mask"),
1385        (Layer::BOTTOM_PASTE, "Bottom Paste", "mask"),
1386    ];
1387
1388    let silk_layers = [
1389        (Layer::TOP_OVERLAY, "Top Silkscreen", "silkscreen"),
1390        (Layer::BOTTOM_OVERLAY, "Bottom Silkscreen", "silkscreen"),
1391    ];
1392
1393    let mech_layers = [
1394        (Layer::MECHANICAL_1, "Mechanical 1", "mechanical"),
1395        (Layer::MECHANICAL_2, "Mechanical 2", "mechanical"),
1396        (Layer::MECHANICAL_3, "Mechanical 3", "mechanical"),
1397        (Layer::MECHANICAL_4, "Mechanical 4", "mechanical"),
1398    ];
1399
1400    let special_layers = [
1401        (Layer::KEEP_OUT_LAYER, "Keep-Out Layer", "special"),
1402        (Layer::MULTI_LAYER, "Multi-Layer", "special"),
1403        (Layer::DRILL_GUIDE, "Drill Guide", "special"),
1404        (Layer::DRILL_DRAWING, "Drill Drawing", "special"),
1405    ];
1406
1407    let mut layers: Vec<LayerInfo> = Vec::new();
1408
1409    for (layer, name, layer_type) in signal_layers
1410        .iter()
1411        .chain(plane_layers.iter())
1412        .chain(mask_layers.iter())
1413        .chain(silk_layers.iter())
1414        .chain(mech_layers.iter())
1415        .chain(special_layers.iter())
1416    {
1417        let is_used = used_layers.contains(&layer.to_byte());
1418        if all || is_used {
1419            layers.push(LayerInfo {
1420                id: layer.to_byte(),
1421                name: name.to_string(),
1422                layer_type: layer_type.to_string(),
1423                used: is_used,
1424                enabled: true,
1425                copper_thickness: None,
1426                dielectric_constant: None,
1427                dielectric_thickness: None,
1428            });
1429        }
1430    }
1431
1432    Ok(PcbDocLayers {
1433        path: path.display().to_string(),
1434        total_layers: layers.len(),
1435        show_all: all,
1436        layers,
1437    })
1438}
1439
1440// ═══════════════════════════════════════════════════════════════════════════
1441// KEEPOUT COMMANDS
1442// ═══════════════════════════════════════════════════════════════════════════
1443
1444/// List all keepout regions.
1445pub fn cmd_keepouts(
1446    path: &Path,
1447    layer_filter: Option<String>,
1448) -> Result<PcbDocKeepouts, Box<dyn std::error::Error>> {
1449    let pcb = open_pcbdoc(path)?;
1450
1451    // Find keepout regions
1452    let mut keepouts: Vec<(usize, &crate::records::pcb::PcbRegion)> = Vec::new();
1453    for (i, prim) in pcb.primitives.iter().enumerate() {
1454        if let crate::records::pcb::PcbRecord::Region(r) = prim {
1455            // Check if this is a keepout (on KeepOutLayer or has keepout flag)
1456            if r.common.layer == Layer::KEEP_OUT_LAYER || r.common.is_keepout() {
1457                if let Some(ref filter) = layer_filter {
1458                    let filter_layer = Layer::from_name(filter);
1459                    if let Some(fl) = filter_layer {
1460                        if r.common.layer != fl {
1461                            continue;
1462                        }
1463                    }
1464                }
1465                keepouts.push((i, r));
1466            }
1467        }
1468    }
1469
1470    let keepout_infos: Vec<KeepoutInfo> = keepouts
1471        .iter()
1472        .map(|(i, r)| {
1473            let bounds = r.calculate_bounds();
1474            KeepoutInfo {
1475                index: *i,
1476                layer: r.common.layer.name().to_string(),
1477                x1: format!("{:.4}mm", bounds.location1.x.to_mms()),
1478                y1: format!("{:.4}mm", bounds.location1.y.to_mms()),
1479                x2: format!("{:.4}mm", bounds.location2.x.to_mms()),
1480                y2: format!("{:.4}mm", bounds.location2.y.to_mms()),
1481                kind: "region".to_string(),
1482            }
1483        })
1484        .collect();
1485
1486    Ok(PcbDocKeepouts {
1487        path: path.display().to_string(),
1488        total_keepouts: keepout_infos.len(),
1489        layer_filter,
1490        keepouts: keepout_infos,
1491    })
1492}
1493
1494/// Add a rectangular keepout region.
1495pub fn cmd_add_keepout(
1496    path: &Path,
1497    layer_str: &str,
1498    x1: &str,
1499    y1: &str,
1500    x2: &str,
1501    y2: &str,
1502) -> Result<(), String> {
1503    let layer = Layer::from_name(layer_str)
1504        .ok_or_else(|| format!("Unknown layer: '{}'. Valid layers: TopLayer, BottomLayer, KeepOutLayer, MultiLayer, etc.", layer_str))?;
1505
1506    let x1_coord = parse_coord(x1)?;
1507    let y1_coord = parse_coord(y1)?;
1508    let x2_coord = parse_coord(x2)?;
1509    let y2_coord = parse_coord(y2)?;
1510
1511    let mut pcb = open_pcbdoc(path)?;
1512
1513    // Create rectangular keepout region
1514    let region = crate::records::pcb::PcbRegion {
1515        common: crate::records::pcb::PcbPrimitiveCommon {
1516            layer,
1517            flags: crate::records::pcb::PcbFlags::KEEPOUT,
1518            ..Default::default()
1519        },
1520        parameters: crate::types::ParameterCollection::new(),
1521        outline: vec![
1522            crate::types::CoordPoint {
1523                x: x1_coord,
1524                y: y1_coord,
1525            },
1526            crate::types::CoordPoint {
1527                x: x2_coord,
1528                y: y1_coord,
1529            },
1530            crate::types::CoordPoint {
1531                x: x2_coord,
1532                y: y2_coord,
1533            },
1534            crate::types::CoordPoint {
1535                x: x1_coord,
1536                y: y2_coord,
1537            },
1538        ],
1539    };
1540
1541    pcb.primitives
1542        .push(crate::records::pcb::PcbRecord::Region(region));
1543
1544    // Save the file
1545    pcb.save_regions_to_file(path)
1546        .map_err(|e| format!("Error saving file: {:?}", e))?;
1547
1548    println!(
1549        "Added keepout region on {} at ({:.3}mm, {:.3}mm) to ({:.3}mm, {:.3}mm)",
1550        layer.name(),
1551        x1_coord.to_mms(),
1552        y1_coord.to_mms(),
1553        x2_coord.to_mms(),
1554        y2_coord.to_mms()
1555    );
1556
1557    Ok(())
1558}
1559
1560// ═══════════════════════════════════════════════════════════════════════════
1561// CUTOUT COMMANDS
1562// ═══════════════════════════════════════════════════════════════════════════
1563
1564/// List all board cutouts.
1565pub fn cmd_cutouts(path: &Path) -> Result<PcbDocCutouts, Box<dyn std::error::Error>> {
1566    let pcb = open_pcbdoc(path)?;
1567
1568    // Find cutout regions (regions on MultiLayer that are cutouts)
1569    let mut cutouts: Vec<(usize, &crate::records::pcb::PcbRegion)> = Vec::new();
1570    for (i, prim) in pcb.primitives.iter().enumerate() {
1571        if let crate::records::pcb::PcbRecord::Region(r) = prim {
1572            // Check if this is a cutout (on multi-layer with specific parameters)
1573            // Cutouts are typically on multi-layer or have polygon_type=Cutout
1574            if r.common.layer == Layer::MULTI_LAYER && !r.common.is_keepout() {
1575                cutouts.push((i, r));
1576            }
1577        }
1578    }
1579
1580    let cutout_infos: Vec<CutoutInfo> = cutouts
1581        .iter()
1582        .map(|(i, r)| {
1583            let bounds = r.calculate_bounds();
1584            CutoutInfo {
1585                index: *i,
1586                vertex_count: r.outline.len(),
1587                bounds: BoundsInfo {
1588                    x1: format!("{:.4}mm", bounds.location1.x.to_mms()),
1589                    y1: format!("{:.4}mm", bounds.location1.y.to_mms()),
1590                    x2: format!("{:.4}mm", bounds.location2.x.to_mms()),
1591                    y2: format!("{:.4}mm", bounds.location2.y.to_mms()),
1592                },
1593            }
1594        })
1595        .collect();
1596
1597    Ok(PcbDocCutouts {
1598        path: path.display().to_string(),
1599        total_cutouts: cutout_infos.len(),
1600        cutouts: cutout_infos,
1601    })
1602}
1603
1604/// Add a rectangular board cutout.
1605pub fn cmd_add_cutout(path: &Path, x1: &str, y1: &str, x2: &str, y2: &str) -> Result<(), String> {
1606    let x1_coord = parse_coord(x1)?;
1607    let y1_coord = parse_coord(y1)?;
1608    let x2_coord = parse_coord(x2)?;
1609    let y2_coord = parse_coord(y2)?;
1610
1611    let mut pcb = open_pcbdoc(path)?;
1612
1613    // Create rectangular cutout region on MultiLayer
1614    let region = crate::records::pcb::PcbRegion {
1615        common: crate::records::pcb::PcbPrimitiveCommon {
1616            layer: Layer::MULTI_LAYER,
1617            ..Default::default()
1618        },
1619        parameters: crate::types::ParameterCollection::new(),
1620        outline: vec![
1621            crate::types::CoordPoint {
1622                x: x1_coord,
1623                y: y1_coord,
1624            },
1625            crate::types::CoordPoint {
1626                x: x2_coord,
1627                y: y1_coord,
1628            },
1629            crate::types::CoordPoint {
1630                x: x2_coord,
1631                y: y2_coord,
1632            },
1633            crate::types::CoordPoint {
1634                x: x1_coord,
1635                y: y2_coord,
1636            },
1637        ],
1638    };
1639
1640    pcb.primitives
1641        .push(crate::records::pcb::PcbRecord::Region(region));
1642
1643    // Save the file
1644    pcb.save_regions_to_file(path)
1645        .map_err(|e| format!("Error saving file: {:?}", e))?;
1646
1647    println!(
1648        "Added board cutout at ({:.3}mm, {:.3}mm) to ({:.3}mm, {:.3}mm)",
1649        x1_coord.to_mms(),
1650        y1_coord.to_mms(),
1651        x2_coord.to_mms(),
1652        y2_coord.to_mms()
1653    );
1654    let width_mm = ((x2_coord.to_raw() - x1_coord.to_raw()).abs() as f64) / 10000.0 * 0.0254;
1655    let height_mm = ((y2_coord.to_raw() - y1_coord.to_raw()).abs() as f64) / 10000.0 * 0.0254;
1656    println!("  Size: {:.3}mm x {:.3}mm", width_mm, height_mm);
1657
1658    Ok(())
1659}
1660
1661// ═══════════════════════════════════════════════════════════════════════════
1662// POLYGON (COPPER POUR) COMMANDS
1663// ═══════════════════════════════════════════════════════════════════════════
1664
1665/// List all polygons (copper pours).
1666pub fn cmd_polygons(
1667    path: &Path,
1668    layer_filter: Option<String>,
1669    net_filter: Option<String>,
1670) -> Result<PcbDocPolygons, Box<dyn std::error::Error>> {
1671    let pcb = open_pcbdoc(path)?;
1672
1673    // Find all polygons
1674    let mut polygons: Vec<(usize, &PcbPolygon)> = Vec::new();
1675    for (i, prim) in pcb.primitives.iter().enumerate() {
1676        if let crate::records::pcb::PcbRecord::Polygon(p) = prim {
1677            // Apply filters
1678            if let Some(ref filter) = layer_filter {
1679                let filter_layer = Layer::from_name(filter);
1680                if let Some(fl) = filter_layer {
1681                    if p.layer != fl {
1682                        continue;
1683                    }
1684                }
1685            }
1686            if let Some(ref filter) = net_filter {
1687                if !p.net_name.eq_ignore_ascii_case(filter) {
1688                    continue;
1689                }
1690            }
1691            polygons.push((i, p));
1692        }
1693    }
1694
1695    let polygon_summaries: Vec<PolygonSummary> = polygons
1696        .iter()
1697        .map(|(i, p)| PolygonSummary {
1698            index: *i,
1699            layer: p.layer.name().to_string(),
1700            net: p.net_name.clone(),
1701            vertex_count: p.vertices.len(),
1702            pour_over: p.pour_over,
1703            remove_dead: p.remove_dead,
1704            hatch_style: p.hatch_style.as_str().to_string(),
1705        })
1706        .collect();
1707
1708    Ok(PcbDocPolygons {
1709        path: path.display().to_string(),
1710        total_polygons: polygon_summaries.len(),
1711        layer_filter,
1712        net_filter,
1713        polygons: polygon_summaries,
1714    })
1715}
1716
1717/// Show details for a specific polygon.
1718pub fn cmd_polygon(
1719    path: &Path,
1720    index: usize,
1721) -> Result<PcbDocPolygonDetail, Box<dyn std::error::Error>> {
1722    let pcb = open_pcbdoc(path)?;
1723
1724    // Find the polygon at the specified index
1725    let mut polygon: Option<&PcbPolygon> = None;
1726    let mut poly_index = 0;
1727    for prim in &pcb.primitives {
1728        if let crate::records::pcb::PcbRecord::Polygon(p) = prim {
1729            if poly_index == index {
1730                polygon = Some(p);
1731                break;
1732            }
1733            poly_index += 1;
1734        }
1735    }
1736
1737    let p = polygon.ok_or_else(|| format!("Polygon index {} not found", index))?;
1738
1739    let vertices: Vec<PolygonVertexInfo> = p
1740        .vertices
1741        .iter()
1742        .map(|v| PolygonVertexInfo {
1743            x: format!("{:.4}mm", v.x.to_mms()),
1744            y: format!("{:.4}mm", v.y.to_mms()),
1745            kind: match v.kind {
1746                PolygonVertexKind::Line => "line".to_string(),
1747                PolygonVertexKind::Arc => "arc".to_string(),
1748            },
1749        })
1750        .collect();
1751
1752    Ok(PcbDocPolygonDetail {
1753        index,
1754        layer: p.layer.name().to_string(),
1755        net: p.net_name.clone(),
1756        vertex_count: p.vertices.len(),
1757        pour_over: p.pour_over,
1758        remove_dead: p.remove_dead,
1759        hatch_style: p.hatch_style.as_str().to_string(),
1760        vertices,
1761    })
1762}
1763
1764/// Add a polygon (copper pour) to the PCB.
1765pub fn cmd_add_polygon(
1766    path: &Path,
1767    layer_str: &str,
1768    net: &str,
1769    vertices_str: &str,
1770    pour_over: bool,
1771    remove_dead: bool,
1772    hatch_style_str: &str,
1773) -> Result<(), String> {
1774    let layer = Layer::from_name(layer_str).ok_or_else(|| {
1775        format!(
1776            "Unknown layer: '{}'. Valid layers: TopLayer, BottomLayer, InternalPlane1, etc.",
1777            layer_str
1778        )
1779    })?;
1780
1781    let hatch_style = HatchStyle::parse(hatch_style_str);
1782
1783    // Parse vertices from string "x1,y1 x2,y2 x3,y3 ..."
1784    let mut vertices: Vec<PolygonVertex> = Vec::new();
1785    for vertex_str in vertices_str.split_whitespace() {
1786        let parts: Vec<&str> = vertex_str.split(',').collect();
1787        if parts.len() != 2 {
1788            return Err(format!(
1789                "Invalid vertex format: '{}'. Expected 'x,y'",
1790                vertex_str
1791            ));
1792        }
1793        let x = parse_coord(parts[0])?;
1794        let y = parse_coord(parts[1])?;
1795        vertices.push(PolygonVertex {
1796            kind: PolygonVertexKind::Line,
1797            x,
1798            y,
1799            center_x: Coord::default(),
1800            center_y: Coord::default(),
1801            start_angle: 0.0,
1802            end_angle: 0.0,
1803            radius: Coord::default(),
1804        });
1805    }
1806
1807    if vertices.len() < 3 {
1808        return Err("At least 3 vertices are required to create a polygon".to_string());
1809    }
1810
1811    let vertex_count = vertices.len();
1812
1813    let mut pcb = open_pcbdoc(path)?;
1814
1815    // Get default board settings for polygon defaults
1816    let board = PcbBoard::from_params(&pcb.board_params);
1817
1818    // Create polygon
1819    let polygon = PcbPolygon {
1820        layer,
1821        net_name: net.to_string(),
1822        vertices,
1823        polygon_type: crate::records::pcb::PolygonType::Polygon,
1824        hatch_style,
1825        pour_over,
1826        remove_dead,
1827        grid_size: board.grid_size,
1828        track_width: board.track_width,
1829        use_octagons: board.use_octagons,
1830        min_prim_length: board.min_prim_length,
1831        locked: false,
1832        polygon_outline: false,
1833        user_routed: true,
1834        keepout: false,
1835        union_index: -1,
1836        primitive_lock: false,
1837        unique_id: String::new(),
1838        params: crate::types::ParameterCollection::new(),
1839    };
1840
1841    pcb.primitives
1842        .push(crate::records::pcb::PcbRecord::Polygon(polygon));
1843
1844    // Save the file
1845    pcb.save_polygons_to_file(path)
1846        .map_err(|e| format!("Error saving file: {:?}", e))?;
1847
1848    println!(
1849        "Added polygon (copper pour) on {} for net '{}'",
1850        layer.name(),
1851        net
1852    );
1853    println!("  Vertices:      {}", vertex_count);
1854    println!("  Hatch Style:   {}", hatch_style_str);
1855    println!("  Pour Over:     {}", pour_over);
1856    println!("  Remove Dead:   {}", remove_dead);
1857
1858    Ok(())
1859}
1860
1861// ═══════════════════════════════════════════════════════════════════════════
1862// TRACK COMMANDS
1863// ═══════════════════════════════════════════════════════════════════════════
1864
1865/// List all tracks.
1866pub fn cmd_tracks(
1867    path: &Path,
1868    layer_filter: Option<String>,
1869) -> Result<PcbDocTracks, Box<dyn std::error::Error>> {
1870    let pcb = open_pcbdoc(path)?;
1871
1872    let layer = layer_filter.as_ref().and_then(|l| Layer::from_name(l));
1873
1874    let tracks: Vec<_> = pcb
1875        .iter_tracks()
1876        .filter(|t| layer.is_none_or(|l| t.common.layer == l))
1877        .collect();
1878
1879    let track_infos: Vec<TrackInfo> = tracks
1880        .iter()
1881        .enumerate()
1882        .map(|(i, t)| TrackInfo {
1883            index: i,
1884            layer: t.common.layer.name().to_string(),
1885            start_x: format!("{:.4}mm", t.start.x.to_mms()),
1886            start_y: format!("{:.4}mm", t.start.y.to_mms()),
1887            end_x: format!("{:.4}mm", t.end.x.to_mms()),
1888            end_y: format!("{:.4}mm", t.end.y.to_mms()),
1889            width: format!("{:.4}mm", t.width.to_mms()),
1890            net: String::new(),
1891        })
1892        .collect();
1893
1894    Ok(PcbDocTracks {
1895        path: path.display().to_string(),
1896        total_tracks: track_infos.len(),
1897        layer_filter,
1898        tracks: track_infos,
1899    })
1900}
1901
1902/// Add a track.
1903#[allow(clippy::too_many_arguments)]
1904pub fn cmd_add_track(
1905    path: &Path,
1906    start: Option<String>,
1907    end: Option<String>,
1908    start_pad: Option<String>,
1909    end_pad: Option<String>,
1910    width: Option<String>,
1911    layer_str: &str,
1912    net: Option<String>,
1913) -> Result<(), String> {
1914    use crate::edit::PcbEditSession;
1915
1916    let mut session =
1917        PcbEditSession::open(path).map_err(|e| format!("Error opening file: {:?}", e))?;
1918
1919    let layer =
1920        Layer::from_name(layer_str).ok_or_else(|| format!("Invalid layer: {}", layer_str))?;
1921    session.set_default_layer(layer);
1922
1923    let track_width = width.as_ref().map(|w| parse_coord(w)).transpose()?;
1924
1925    // Determine start position
1926    let start_point = if let Some(pad_ref) = &start_pad {
1927        let parts: Vec<&str> = pad_ref.split('.').collect();
1928        if parts.len() != 2 {
1929            return Err("Pad reference must be in format 'U1.1'".to_string());
1930        }
1931        session
1932            .resolve_position(&crate::edit::Position::RelativeToPad {
1933                component: parts[0].to_string(),
1934                pad: parts[1].to_string(),
1935                offset: CoordPoint::default(),
1936            })
1937            .map_err(|e| format!("Error resolving start pad: {:?}", e))?
1938    } else if let Some(start_str) = &start {
1939        let parts: Vec<&str> = start_str.split(',').collect();
1940        if parts.len() != 2 {
1941            return Err("Start position must be in format 'x,y'".to_string());
1942        }
1943        CoordPoint::new(parse_coord(parts[0])?, parse_coord(parts[1])?)
1944    } else {
1945        return Err("Either --start or --start-pad must be specified".to_string());
1946    };
1947
1948    // Determine end position
1949    let end_point = if let Some(pad_ref) = &end_pad {
1950        let parts: Vec<&str> = pad_ref.split('.').collect();
1951        if parts.len() != 2 {
1952            return Err("Pad reference must be in format 'U1.1'".to_string());
1953        }
1954        session
1955            .resolve_position(&crate::edit::Position::RelativeToPad {
1956                component: parts[0].to_string(),
1957                pad: parts[1].to_string(),
1958                offset: CoordPoint::default(),
1959            })
1960            .map_err(|e| format!("Error resolving end pad: {:?}", e))?
1961    } else if let Some(end_str) = &end {
1962        let parts: Vec<&str> = end_str.split(',').collect();
1963        if parts.len() != 2 {
1964            return Err("End position must be in format 'x,y'".to_string());
1965        }
1966        CoordPoint::new(parse_coord(parts[0])?, parse_coord(parts[1])?)
1967    } else {
1968        return Err("Either --end or --end-pad must be specified".to_string());
1969    };
1970
1971    let idx = session
1972        .add_track(
1973            start_point,
1974            end_point,
1975            track_width,
1976            Some(layer),
1977            net.as_deref(),
1978        )
1979        .map_err(|e| format!("Error adding track: {:?}", e))?;
1980
1981    session
1982        .save_to_original()
1983        .map_err(|e| format!("Error saving file: {:?}", e))?;
1984
1985    println!("Added track at index {}", idx);
1986    println!("  Layer:  {}", layer.name());
1987    println!(
1988        "  Start:  ({:.3}mm, {:.3}mm)",
1989        start_point.x.to_mms(),
1990        start_point.y.to_mms()
1991    );
1992    println!(
1993        "  End:    ({:.3}mm, {:.3}mm)",
1994        end_point.x.to_mms(),
1995        end_point.y.to_mms()
1996    );
1997    println!(
1998        "  Width:  {:.3}mm",
1999        track_width.unwrap_or(Coord::from_mils(10.0)).to_mms()
2000    );
2001    if let Some(n) = &net {
2002        println!("  Net:    {}", n);
2003    }
2004
2005    Ok(())
2006}
2007
2008/// Add multiple connected track segments.
2009pub fn cmd_add_track_path(
2010    path: &Path,
2011    vertices_str: &str,
2012    width: Option<String>,
2013    layer_str: &str,
2014    net: Option<String>,
2015) -> Result<(), String> {
2016    use crate::edit::PcbEditSession;
2017
2018    let mut session =
2019        PcbEditSession::open(path).map_err(|e| format!("Error opening file: {:?}", e))?;
2020
2021    let layer =
2022        Layer::from_name(layer_str).ok_or_else(|| format!("Invalid layer: {}", layer_str))?;
2023    session.set_default_layer(layer);
2024
2025    let track_width = width.as_ref().map(|w| parse_coord(w)).transpose()?;
2026
2027    let mut vertices = Vec::new();
2028    for vertex_str in vertices_str.split_whitespace() {
2029        let parts: Vec<&str> = vertex_str.split(',').collect();
2030        if parts.len() != 2 {
2031            return Err(format!(
2032                "Invalid vertex format '{}', expected 'x,y'",
2033                vertex_str
2034            ));
2035        }
2036        vertices.push(CoordPoint::new(
2037            parse_coord(parts[0])?,
2038            parse_coord(parts[1])?,
2039        ));
2040    }
2041
2042    if vertices.len() < 2 {
2043        return Err("At least 2 vertices are required for a track path".to_string());
2044    }
2045
2046    let indices = session
2047        .add_track_path(&vertices, track_width, Some(layer), net.as_deref())
2048        .map_err(|e| format!("Error adding track path: {:?}", e))?;
2049
2050    session
2051        .save_to_original()
2052        .map_err(|e| format!("Error saving file: {:?}", e))?;
2053
2054    println!(
2055        "Added {} track segments (indices {:?})",
2056        indices.len(),
2057        indices
2058    );
2059    println!("  Layer:    {}", layer.name());
2060    println!("  Vertices: {}", vertices.len());
2061    println!(
2062        "  Width:    {:.3}mm",
2063        track_width.unwrap_or(Coord::from_mils(10.0)).to_mms()
2064    );
2065
2066    Ok(())
2067}
2068
2069// ═══════════════════════════════════════════════════════════════════════════
2070// VIA COMMANDS
2071// ═══════════════════════════════════════════════════════════════════════════
2072
2073/// List all vias.
2074pub fn cmd_vias(path: &Path) -> Result<PcbDocVias, Box<dyn std::error::Error>> {
2075    let pcb = open_pcbdoc(path)?;
2076
2077    let vias: Vec<_> = pcb.iter_vias().collect();
2078
2079    let via_infos: Vec<ViaInfo> = vias
2080        .iter()
2081        .enumerate()
2082        .map(|(i, v)| ViaInfo {
2083            index: i,
2084            x: format!("{:.4}mm", v.location.x.to_mms()),
2085            y: format!("{:.4}mm", v.location.y.to_mms()),
2086            diameter: format!("{:.4}mm", v.diameter().to_mms()),
2087            hole_size: format!("{:.4}mm", v.hole_size.to_mms()),
2088            from_layer: v.from_layer.name().to_string(),
2089            to_layer: v.to_layer.name().to_string(),
2090            net: String::new(),
2091        })
2092        .collect();
2093
2094    Ok(PcbDocVias {
2095        path: path.display().to_string(),
2096        total_vias: via_infos.len(),
2097        vias: via_infos,
2098    })
2099}
2100
2101/// Add a via.
2102#[allow(clippy::too_many_arguments)]
2103pub fn cmd_add_via(
2104    path: &Path,
2105    at: Option<String>,
2106    at_pad: Option<String>,
2107    diameter: Option<String>,
2108    hole: Option<String>,
2109    from_layer_str: &str,
2110    to_layer_str: &str,
2111    net: Option<String>,
2112) -> Result<(), String> {
2113    use crate::edit::PcbEditSession;
2114
2115    let mut session =
2116        PcbEditSession::open(path).map_err(|e| format!("Error opening file: {:?}", e))?;
2117
2118    let from_layer = Layer::from_name(from_layer_str)
2119        .ok_or_else(|| format!("Invalid from layer: {}", from_layer_str))?;
2120    let to_layer = Layer::from_name(to_layer_str)
2121        .ok_or_else(|| format!("Invalid to layer: {}", to_layer_str))?;
2122
2123    let via_diameter = diameter.as_ref().map(|d| parse_coord(d)).transpose()?;
2124    let via_hole = hole.as_ref().map(|h| parse_coord(h)).transpose()?;
2125
2126    let location = if let Some(pad_ref) = &at_pad {
2127        let parts: Vec<&str> = pad_ref.split('.').collect();
2128        if parts.len() != 2 {
2129            return Err("Pad reference must be in format 'U1.1'".to_string());
2130        }
2131        session
2132            .resolve_position(&crate::edit::Position::RelativeToPad {
2133                component: parts[0].to_string(),
2134                pad: parts[1].to_string(),
2135                offset: CoordPoint::default(),
2136            })
2137            .map_err(|e| format!("Error resolving pad: {:?}", e))?
2138    } else if let Some(at_str) = &at {
2139        let parts: Vec<&str> = at_str.split(',').collect();
2140        if parts.len() != 2 {
2141            return Err("Position must be in format 'x,y'".to_string());
2142        }
2143        CoordPoint::new(parse_coord(parts[0])?, parse_coord(parts[1])?)
2144    } else {
2145        return Err("Either --at or --at-pad must be specified".to_string());
2146    };
2147
2148    let idx = session
2149        .add_via(
2150            location,
2151            via_diameter,
2152            via_hole,
2153            Some(from_layer),
2154            Some(to_layer),
2155            net.as_deref(),
2156        )
2157        .map_err(|e| format!("Error adding via: {:?}", e))?;
2158
2159    session
2160        .save_to_original()
2161        .map_err(|e| format!("Error saving file: {:?}", e))?;
2162
2163    println!("Added via at index {}", idx);
2164    println!(
2165        "  Position: ({:.3}mm, {:.3}mm)",
2166        location.x.to_mms(),
2167        location.y.to_mms()
2168    );
2169    println!(
2170        "  Diameter: {:.3}mm",
2171        via_diameter.unwrap_or(Coord::from_mils(50.0)).to_mms()
2172    );
2173    println!(
2174        "  Hole:     {:.3}mm",
2175        via_hole.unwrap_or(Coord::from_mils(28.0)).to_mms()
2176    );
2177    println!("  Layers:   {} -> {}", from_layer.name(), to_layer.name());
2178
2179    Ok(())
2180}
2181
2182// ═══════════════════════════════════════════════════════════════════════════
2183// ARC COMMANDS
2184// ═══════════════════════════════════════════════════════════════════════════
2185
2186/// List all arcs.
2187pub fn cmd_arcs(
2188    path: &Path,
2189    layer_filter: Option<String>,
2190) -> Result<PcbDocArcs, Box<dyn std::error::Error>> {
2191    let pcb = open_pcbdoc(path)?;
2192
2193    let layer = layer_filter.as_ref().and_then(|l| Layer::from_name(l));
2194
2195    let arcs: Vec<_> = pcb
2196        .iter_arcs()
2197        .filter(|a| layer.is_none_or(|l| a.common.layer == l))
2198        .collect();
2199
2200    let arc_infos: Vec<ArcInfo> = arcs
2201        .iter()
2202        .enumerate()
2203        .map(|(i, a)| ArcInfo {
2204            index: i,
2205            layer: a.common.layer.name().to_string(),
2206            center_x: format!("{:.4}mm", a.location.x.to_mms()),
2207            center_y: format!("{:.4}mm", a.location.y.to_mms()),
2208            radius: format!("{:.4}mm", a.radius.to_mms()),
2209            start_angle: a.start_angle,
2210            end_angle: a.end_angle,
2211            width: format!("{:.4}mm", a.width.to_mms()),
2212            net: String::new(),
2213        })
2214        .collect();
2215
2216    Ok(PcbDocArcs {
2217        path: path.display().to_string(),
2218        total_arcs: arc_infos.len(),
2219        layer_filter,
2220        arcs: arc_infos,
2221    })
2222}
2223
2224/// Add an arc.
2225#[allow(clippy::too_many_arguments)]
2226pub fn cmd_add_arc(
2227    path: &Path,
2228    center_str: &str,
2229    radius_str: &str,
2230    start_angle: f64,
2231    end_angle: f64,
2232    width: Option<String>,
2233    layer_str: &str,
2234    net: Option<String>,
2235) -> Result<(), String> {
2236    use crate::edit::PcbEditSession;
2237
2238    let mut session =
2239        PcbEditSession::open(path).map_err(|e| format!("Error opening file: {:?}", e))?;
2240
2241    let layer =
2242        Layer::from_name(layer_str).ok_or_else(|| format!("Invalid layer: {}", layer_str))?;
2243    session.set_default_layer(layer);
2244
2245    let center_parts: Vec<&str> = center_str.split(',').collect();
2246    if center_parts.len() != 2 {
2247        return Err("Center must be in format 'x,y'".to_string());
2248    }
2249    let center = CoordPoint::new(parse_coord(center_parts[0])?, parse_coord(center_parts[1])?);
2250    let radius = parse_coord(radius_str)?;
2251    let arc_width = width.as_ref().map(|w| parse_coord(w)).transpose()?;
2252
2253    let idx = session
2254        .add_arc(
2255            center,
2256            radius,
2257            start_angle,
2258            end_angle,
2259            arc_width,
2260            Some(layer),
2261            net.as_deref(),
2262        )
2263        .map_err(|e| format!("Error adding arc: {:?}", e))?;
2264
2265    session
2266        .save_to_original()
2267        .map_err(|e| format!("Error saving file: {:?}", e))?;
2268
2269    println!("Added arc at index {}", idx);
2270    println!("  Layer:   {}", layer.name());
2271    println!(
2272        "  Center:  ({:.3}mm, {:.3}mm)",
2273        center.x.to_mms(),
2274        center.y.to_mms()
2275    );
2276    println!("  Radius:  {:.3}mm", radius.to_mms());
2277    println!("  Angles:  {:.1}deg - {:.1}deg", start_angle, end_angle);
2278    println!(
2279        "  Width:   {:.3}mm",
2280        arc_width.unwrap_or(Coord::from_mils(10.0)).to_mms()
2281    );
2282
2283    Ok(())
2284}
2285
2286// ═══════════════════════════════════════════════════════════════════════════
2287// FILL COMMANDS
2288// ═══════════════════════════════════════════════════════════════════════════
2289
2290/// List all fills.
2291pub fn cmd_fills(
2292    path: &Path,
2293    layer_filter: Option<String>,
2294) -> Result<PcbDocFills, Box<dyn std::error::Error>> {
2295    let pcb = open_pcbdoc(path)?;
2296
2297    let layer = layer_filter.as_ref().and_then(|l| Layer::from_name(l));
2298
2299    let fills: Vec<_> = pcb
2300        .iter_fills()
2301        .filter(|f| layer.is_none_or(|l| f.base.common.layer == l))
2302        .collect();
2303
2304    let fill_infos: Vec<FillInfo> = fills
2305        .iter()
2306        .enumerate()
2307        .map(|(i, f)| FillInfo {
2308            index: i,
2309            layer: f.base.common.layer.name().to_string(),
2310            x1: format!("{:.4}mm", f.base.corner1.x.to_mms()),
2311            y1: format!("{:.4}mm", f.base.corner1.y.to_mms()),
2312            x2: format!("{:.4}mm", f.base.corner2.x.to_mms()),
2313            y2: format!("{:.4}mm", f.base.corner2.y.to_mms()),
2314            rotation: f.base.rotation,
2315            net: String::new(),
2316        })
2317        .collect();
2318
2319    Ok(PcbDocFills {
2320        path: path.display().to_string(),
2321        total_fills: fill_infos.len(),
2322        layer_filter,
2323        fills: fill_infos,
2324    })
2325}
2326
2327/// Add a fill.
2328pub fn cmd_add_fill(
2329    path: &Path,
2330    x1y1_str: &str,
2331    x2y2_str: &str,
2332    layer_str: &str,
2333    rotation: f64,
2334    net: Option<String>,
2335) -> Result<(), String> {
2336    use crate::edit::PcbEditSession;
2337
2338    let mut session =
2339        PcbEditSession::open(path).map_err(|e| format!("Error opening file: {:?}", e))?;
2340
2341    let layer =
2342        Layer::from_name(layer_str).ok_or_else(|| format!("Invalid layer: {}", layer_str))?;
2343
2344    let parts1: Vec<&str> = x1y1_str.split(',').collect();
2345    let parts2: Vec<&str> = x2y2_str.split(',').collect();
2346    if parts1.len() != 2 || parts2.len() != 2 {
2347        return Err("Coordinates must be in format 'x,y'".to_string());
2348    }
2349
2350    let corner1 = CoordPoint::new(parse_coord(parts1[0])?, parse_coord(parts1[1])?);
2351    let corner2 = CoordPoint::new(parse_coord(parts2[0])?, parse_coord(parts2[1])?);
2352
2353    let idx = session
2354        .add_fill(
2355            corner1,
2356            corner2,
2357            Some(layer),
2358            Some(rotation),
2359            net.as_deref(),
2360        )
2361        .map_err(|e| format!("Error adding fill: {:?}", e))?;
2362
2363    session
2364        .save_to_original()
2365        .map_err(|e| format!("Error saving file: {:?}", e))?;
2366
2367    println!("Added fill at index {}", idx);
2368    println!("  Layer:    {}", layer.name());
2369    println!(
2370        "  Corner 1: ({:.3}mm, {:.3}mm)",
2371        corner1.x.to_mms(),
2372        corner1.y.to_mms()
2373    );
2374    println!(
2375        "  Corner 2: ({:.3}mm, {:.3}mm)",
2376        corner2.x.to_mms(),
2377        corner2.y.to_mms()
2378    );
2379    println!("  Rotation: {:.1}deg", rotation);
2380
2381    Ok(())
2382}
2383
2384// ═══════════════════════════════════════════════════════════════════════════
2385// TEXT COMMANDS
2386// ═══════════════════════════════════════════════════════════════════════════
2387
2388/// List all text annotations.
2389pub fn cmd_texts(
2390    path: &Path,
2391    layer_filter: Option<String>,
2392) -> Result<PcbDocTexts, Box<dyn std::error::Error>> {
2393    let pcb = open_pcbdoc(path)?;
2394
2395    let layer = layer_filter.as_ref().and_then(|l| Layer::from_name(l));
2396
2397    let texts: Vec<_> = pcb
2398        .primitives
2399        .iter()
2400        .filter_map(|p| {
2401            if let crate::records::pcb::PcbRecord::Text(t) = p {
2402                if layer.is_none_or(|l| t.base.common.layer == l) {
2403                    Some(t)
2404                } else {
2405                    None
2406                }
2407            } else {
2408                None
2409            }
2410        })
2411        .collect();
2412
2413    let text_infos: Vec<TextInfo> = texts
2414        .iter()
2415        .enumerate()
2416        .map(|(i, t)| TextInfo {
2417            index: i,
2418            text: t.text.clone(),
2419            layer: t.base.common.layer.name().to_string(),
2420            x: format!("{:.4}mm", t.base.corner1.x.to_mms()),
2421            y: format!("{:.4}mm", t.base.corner1.y.to_mms()),
2422            height: format!("{:.4}mm", t.height().to_mms()),
2423            rotation: t.base.rotation,
2424        })
2425        .collect();
2426
2427    Ok(PcbDocTexts {
2428        path: path.display().to_string(),
2429        total_texts: text_infos.len(),
2430        layer_filter,
2431        texts: text_infos,
2432    })
2433}
2434
2435/// Add text annotation.
2436pub fn cmd_add_text(
2437    path: &Path,
2438    text: &str,
2439    at_str: &str,
2440    height: Option<String>,
2441    layer_str: &str,
2442    rotation: f64,
2443) -> Result<(), String> {
2444    use crate::edit::PcbEditSession;
2445
2446    let mut session =
2447        PcbEditSession::open(path).map_err(|e| format!("Error opening file: {:?}", e))?;
2448
2449    let layer =
2450        Layer::from_name(layer_str).ok_or_else(|| format!("Invalid layer: {}", layer_str))?;
2451
2452    let parts: Vec<&str> = at_str.split(',').collect();
2453    if parts.len() != 2 {
2454        return Err("Position must be in format 'x,y'".to_string());
2455    }
2456
2457    let location = CoordPoint::new(parse_coord(parts[0])?, parse_coord(parts[1])?);
2458    let text_height = height
2459        .as_ref()
2460        .map(|h| parse_coord(h))
2461        .transpose()?
2462        .unwrap_or(Coord::from_mms(1.0));
2463
2464    let idx = session
2465        .add_text(
2466            text,
2467            location,
2468            text_height,
2469            Some(layer),
2470            Some(rotation),
2471            None,
2472        )
2473        .map_err(|e| format!("Error adding text: {:?}", e))?;
2474
2475    session
2476        .save_to_original()
2477        .map_err(|e| format!("Error saving file: {:?}", e))?;
2478
2479    println!("Added text at index {}", idx);
2480    println!("  Text:     \"{}\"", text);
2481    println!("  Layer:    {}", layer.name());
2482    println!(
2483        "  Position: ({:.3}mm, {:.3}mm)",
2484        location.x.to_mms(),
2485        location.y.to_mms()
2486    );
2487    println!("  Height:   {:.3}mm", text_height.to_mms());
2488    println!("  Rotation: {:.1}deg", rotation);
2489
2490    Ok(())
2491}
2492
2493// ═══════════════════════════════════════════════════════════════════════════
2494// REGION COMMANDS
2495// ═══════════════════════════════════════════════════════════════════════════
2496
2497/// List all regions.
2498pub fn cmd_regions(
2499    path: &Path,
2500    layer_filter: Option<String>,
2501) -> Result<PcbDocRegions, Box<dyn std::error::Error>> {
2502    let pcb = open_pcbdoc(path)?;
2503
2504    let layer = layer_filter.as_ref().and_then(|l| Layer::from_name(l));
2505
2506    let regions: Vec<_> = pcb
2507        .iter_regions()
2508        .filter(|r| layer.is_none_or(|l| r.common.layer == l))
2509        .collect();
2510
2511    let region_infos: Vec<RegionInfo> = regions
2512        .iter()
2513        .enumerate()
2514        .map(|(i, r)| {
2515            RegionInfo {
2516                index: i,
2517                layer: r.common.layer.name().to_string(),
2518                vertex_count: r.outline.len(),
2519                is_keepout: r.common.is_keepout(),
2520                net: String::new(), // Regions don't typically have nets in Altium
2521            }
2522        })
2523        .collect();
2524
2525    Ok(PcbDocRegions {
2526        path: path.display().to_string(),
2527        total_regions: region_infos.len(),
2528        layer_filter,
2529        regions: region_infos,
2530    })
2531}
2532
2533/// Add a region.
2534pub fn cmd_add_region(
2535    path: &Path,
2536    vertices_str: &str,
2537    layer_str: &str,
2538    keepout: bool,
2539    net: Option<String>,
2540) -> Result<(), String> {
2541    use crate::edit::PcbEditSession;
2542
2543    let mut session =
2544        PcbEditSession::open(path).map_err(|e| format!("Error opening file: {:?}", e))?;
2545
2546    let layer =
2547        Layer::from_name(layer_str).ok_or_else(|| format!("Invalid layer: {}", layer_str))?;
2548
2549    let mut vertices = Vec::new();
2550    for vertex_str in vertices_str.split_whitespace() {
2551        let parts: Vec<&str> = vertex_str.split(',').collect();
2552        if parts.len() != 2 {
2553            return Err(format!(
2554                "Invalid vertex format '{}', expected 'x,y'",
2555                vertex_str
2556            ));
2557        }
2558        vertices.push(CoordPoint::new(
2559            parse_coord(parts[0])?,
2560            parse_coord(parts[1])?,
2561        ));
2562    }
2563
2564    if vertices.len() < 3 {
2565        return Err("At least 3 vertices are required for a region".to_string());
2566    }
2567
2568    let idx = session
2569        .add_region(&vertices, layer, keepout, net.as_deref())
2570        .map_err(|e| format!("Error adding region: {:?}", e))?;
2571
2572    session
2573        .save_to_original()
2574        .map_err(|e| format!("Error saving file: {:?}", e))?;
2575
2576    let region_type = if keepout {
2577        "keepout region"
2578    } else {
2579        "copper region"
2580    };
2581    println!("Added {} at index {}", region_type, idx);
2582    println!("  Layer:    {}", layer.name());
2583    println!("  Vertices: {}", vertices.len());
2584
2585    Ok(())
2586}
2587
2588// ═══════════════════════════════════════════════════════════════════════════
2589// DELETE COMMANDS
2590// ═══════════════════════════════════════════════════════════════════════════
2591
2592/// Delete a primitive by index.
2593pub fn cmd_delete_primitive(path: &Path, index: usize) -> Result<(), String> {
2594    use crate::edit::PcbEditSession;
2595
2596    let mut session =
2597        PcbEditSession::open(path).map_err(|e| format!("Error opening file: {:?}", e))?;
2598
2599    session
2600        .delete_primitive(index)
2601        .map_err(|e| format!("Error deleting primitive: {:?}", e))?;
2602
2603    session
2604        .save_to_original()
2605        .map_err(|e| format!("Error saving file: {:?}", e))?;
2606
2607    println!("Deleted primitive at index {}", index);
2608
2609    Ok(())
2610}
2611
2612/// Delete all tracks on a layer.
2613pub fn cmd_delete_tracks(path: &Path, layer_str: &str) -> Result<(), String> {
2614    use crate::edit::PcbEditSession;
2615
2616    let mut session =
2617        PcbEditSession::open(path).map_err(|e| format!("Error opening file: {:?}", e))?;
2618
2619    let layer =
2620        Layer::from_name(layer_str).ok_or_else(|| format!("Invalid layer: {}", layer_str))?;
2621
2622    let count = session
2623        .delete_tracks_on_layer(layer)
2624        .map_err(|e| format!("Error deleting tracks: {:?}", e))?;
2625
2626    session
2627        .save_to_original()
2628        .map_err(|e| format!("Error saving file: {:?}", e))?;
2629
2630    println!("Deleted {} tracks on {}", count, layer.name());
2631
2632    Ok(())
2633}
2634
2635/// Delete all vias.
2636pub fn cmd_delete_vias(path: &Path) -> Result<(), String> {
2637    use crate::edit::PcbEditSession;
2638
2639    let mut session =
2640        PcbEditSession::open(path).map_err(|e| format!("Error opening file: {:?}", e))?;
2641
2642    let count = session
2643        .delete_all_vias()
2644        .map_err(|e| format!("Error deleting vias: {:?}", e))?;
2645
2646    session
2647        .save_to_original()
2648        .map_err(|e| format!("Error saving file: {:?}", e))?;
2649
2650    println!("Deleted {} vias", count);
2651
2652    Ok(())
2653}
2654
2655// ═══════════════════════════════════════════════════════════════════════════
2656// NET COMMANDS
2657// ═══════════════════════════════════════════════════════════════════════════
2658
2659/// List all nets.
2660pub fn cmd_nets(path: &Path) -> Result<PcbDocNets, Box<dyn std::error::Error>> {
2661    let pcb = open_pcbdoc(path)?;
2662
2663    Ok(PcbDocNets {
2664        path: path.display().to_string(),
2665        total_nets: pcb.nets.len(),
2666        nets: pcb.nets.clone(),
2667    })
2668}
2669
2670/// Add a new net.
2671pub fn cmd_add_net(path: &Path, name: &str) -> Result<(), String> {
2672    use crate::edit::PcbEditSession;
2673
2674    let mut session =
2675        PcbEditSession::open(path).map_err(|e| format!("Error opening file: {:?}", e))?;
2676
2677    session
2678        .add_net(name)
2679        .map_err(|e| format!("Error adding net: {:?}", e))?;
2680
2681    session
2682        .save_to_original()
2683        .map_err(|e| format!("Error saving file: {:?}", e))?;
2684
2685    println!("Added net '{}'", name);
2686
2687    Ok(())
2688}