Skip to main content

altium_format/edit/
pcb_placement.rs

1//! PCB component placement engine for programmatic modification of Altium PCB documents.
2//!
3//! This module provides placement functionality including:
4//! - Absolute and relative placement
5//! - Alignment with board edges and other components
6//! - Grid snapping
7//! - Connected route detection
8
9use std::collections::HashSet;
10
11use crate::io::PcbDoc;
12use crate::records::pcb::PcbRecord;
13use crate::types::{Coord, CoordPoint, CoordRect, Layer};
14
15use super::types::Grid;
16
17/// Anchor point for relative placement.
18#[derive(Debug, Clone)]
19pub enum PlacementAnchor {
20    /// Absolute position.
21    Absolute(CoordPoint),
22    /// Near another component with optional offset.
23    NearComponent {
24        designator: String,
25        offset: CoordPoint,
26    },
27    /// Align X coordinate with another component.
28    AlignX { designator: String, offset: Coord },
29    /// Align Y coordinate with another component.
30    AlignY { designator: String, offset: Coord },
31    /// Align to board edge.
32    BoardEdge { edge: BoardEdge, offset: Coord },
33}
34
35/// Board edge for alignment.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum BoardEdge {
38    Left,
39    Right,
40    Top,
41    Bottom,
42}
43
44impl BoardEdge {
45    /// Parse from string.
46    pub fn try_parse(s: &str) -> Option<Self> {
47        match s.to_lowercase().as_str() {
48            "left" | "left-edge" | "l" => Some(BoardEdge::Left),
49            "right" | "right-edge" | "r" => Some(BoardEdge::Right),
50            "top" | "top-edge" | "t" => Some(BoardEdge::Top),
51            "bottom" | "bottom-edge" | "b" => Some(BoardEdge::Bottom),
52            _ => None,
53        }
54    }
55}
56
57/// Result of checking for connected routes.
58#[derive(Debug, Clone)]
59pub struct ConnectedRoutes {
60    /// Track indices that connect to the component.
61    pub tracks: Vec<usize>,
62    /// Via indices that connect to the component.
63    pub vias: Vec<usize>,
64    /// Net names involved.
65    pub nets: HashSet<String>,
66}
67
68impl ConnectedRoutes {
69    /// Check if any routes are connected.
70    pub fn has_connections(&self) -> bool {
71        !self.tracks.is_empty() || !self.vias.is_empty()
72    }
73
74    /// Get total count of connected primitives.
75    pub fn count(&self) -> usize {
76        self.tracks.len() + self.vias.len()
77    }
78}
79
80/// Component position and orientation.
81#[derive(Debug, Clone)]
82pub struct ComponentPosition {
83    pub x: Coord,
84    pub y: Coord,
85    pub rotation: f64,
86    pub layer: Layer,
87}
88
89impl Default for ComponentPosition {
90    fn default() -> Self {
91        Self {
92            x: Coord::ZERO,
93            y: Coord::ZERO,
94            rotation: 0.0,
95            layer: Layer::TOP_LAYER,
96        }
97    }
98}
99
100/// PCB placement engine for managing component placement.
101pub struct PcbPlacementEngine {
102    /// Grid configuration.
103    grid: Grid,
104    /// Board bounds.
105    board_bounds: Option<CoordRect>,
106}
107
108impl Default for PcbPlacementEngine {
109    fn default() -> Self {
110        Self::new()
111    }
112}
113
114impl PcbPlacementEngine {
115    /// Create a new placement engine.
116    pub fn new() -> Self {
117        Self {
118            grid: Grid::default(),
119            board_bounds: None,
120        }
121    }
122
123    /// Set the grid configuration.
124    pub fn set_grid(&mut self, grid: Grid) {
125        self.grid = grid;
126    }
127
128    /// Set the grid from a spacing value in mm.
129    pub fn set_grid_mm(&mut self, spacing_mm: f64) {
130        self.grid = Grid {
131            spacing: Coord::from_mms(spacing_mm),
132            snap_enabled: true,
133        };
134    }
135
136    /// Get the grid configuration.
137    pub fn grid(&self) -> &Grid {
138        &self.grid
139    }
140
141    /// Set board bounds for edge alignment.
142    pub fn set_board_bounds(&mut self, bounds: CoordRect) {
143        self.board_bounds = Some(bounds);
144    }
145
146    /// Calculate board bounds from the document.
147    pub fn calculate_board_bounds(&mut self, pcb: &PcbDoc) {
148        // Try to get board outline from primitives or use component bounds
149        let mut bounds = CoordRect::EMPTY;
150
151        for component in &pcb.components {
152            if let Some(pos) = Self::get_component_position_static(component) {
153                let _point = CoordPoint::new(pos.x, pos.y);
154                if bounds.is_empty() {
155                    bounds = CoordRect::from_xywh(pos.x, pos.y, Coord::ZERO, Coord::ZERO);
156                } else {
157                    bounds =
158                        bounds.union(CoordRect::from_xywh(pos.x, pos.y, Coord::ZERO, Coord::ZERO));
159                }
160            }
161        }
162
163        // Add some margin
164        if !bounds.is_empty() {
165            let margin = Coord::from_mils(500.0);
166            bounds = CoordRect::from_points(
167                bounds.location1.x - margin,
168                bounds.location1.y - margin,
169                bounds.location2.x + margin,
170                bounds.location2.y + margin,
171            );
172        }
173
174        self.board_bounds = Some(bounds);
175    }
176
177    /// Snap a point to the grid.
178    pub fn snap_to_grid(&self, point: CoordPoint) -> CoordPoint {
179        self.grid.snap(point)
180    }
181
182    /// Get the position of a component.
183    pub fn get_component_position(
184        &self,
185        pcb: &PcbDoc,
186        designator: &str,
187    ) -> Option<ComponentPosition> {
188        pcb.components
189            .iter()
190            .find(|c| c.designator.eq_ignore_ascii_case(designator))
191            .and_then(Self::get_component_position_static)
192    }
193
194    /// Get component position from a component reference.
195    fn get_component_position_static(
196        component: &crate::io::PcbDocComponent,
197    ) -> Option<ComponentPosition> {
198        let x = component.params.get("X")?.as_coord_or(Coord::ZERO);
199        let y = component.params.get("Y")?.as_coord_or(Coord::ZERO);
200        let rotation = component
201            .params
202            .get("ROTATION")
203            .and_then(|v| v.as_str().parse::<f64>().ok())
204            .unwrap_or(0.0);
205        let layer = component
206            .params
207            .get("LAYER")
208            .and_then(|v| Layer::from_name(v.as_str()))
209            .unwrap_or(Layer::TOP_LAYER);
210
211        Some(ComponentPosition {
212            x,
213            y,
214            rotation,
215            layer,
216        })
217    }
218
219    /// Resolve a placement anchor to an absolute position.
220    pub fn resolve_anchor(
221        &self,
222        pcb: &PcbDoc,
223        anchor: &PlacementAnchor,
224        current_pos: Option<&ComponentPosition>,
225    ) -> Result<CoordPoint, String> {
226        match anchor {
227            PlacementAnchor::Absolute(point) => Ok(self.grid.snap(*point)),
228            PlacementAnchor::NearComponent { designator, offset } => {
229                let ref_pos = self
230                    .get_component_position(pcb, designator)
231                    .ok_or_else(|| format!("Component '{}' not found", designator))?;
232                let point = CoordPoint::new(ref_pos.x + offset.x, ref_pos.y + offset.y);
233                Ok(self.grid.snap(point))
234            }
235            PlacementAnchor::AlignX { designator, offset } => {
236                let ref_pos = self
237                    .get_component_position(pcb, designator)
238                    .ok_or_else(|| format!("Component '{}' not found", designator))?;
239                let current_y = current_pos.map(|p| p.y).unwrap_or(Coord::ZERO);
240                let point = CoordPoint::new(ref_pos.x + *offset, current_y);
241                Ok(self.grid.snap(point))
242            }
243            PlacementAnchor::AlignY { designator, offset } => {
244                let ref_pos = self
245                    .get_component_position(pcb, designator)
246                    .ok_or_else(|| format!("Component '{}' not found", designator))?;
247                let current_x = current_pos.map(|p| p.x).unwrap_or(Coord::ZERO);
248                let point = CoordPoint::new(current_x, ref_pos.y + *offset);
249                Ok(self.grid.snap(point))
250            }
251            PlacementAnchor::BoardEdge { edge, offset } => {
252                let bounds = self
253                    .board_bounds
254                    .ok_or_else(|| "Board bounds not set".to_string())?;
255                let current_x = current_pos.map(|p| p.x).unwrap_or(bounds.center().x);
256                let current_y = current_pos.map(|p| p.y).unwrap_or(bounds.center().y);
257
258                let point = match edge {
259                    BoardEdge::Left => CoordPoint::new(bounds.location1.x + *offset, current_y),
260                    BoardEdge::Right => CoordPoint::new(bounds.location2.x - *offset, current_y),
261                    BoardEdge::Top => CoordPoint::new(current_x, bounds.location2.y - *offset),
262                    BoardEdge::Bottom => CoordPoint::new(current_x, bounds.location1.y + *offset),
263                };
264                Ok(self.grid.snap(point))
265            }
266        }
267    }
268
269    /// Check if a component has connected routes (tracks/vias touching its pads).
270    pub fn find_connected_routes(&self, pcb: &PcbDoc, designator: &str) -> ConnectedRoutes {
271        let mut result = ConnectedRoutes {
272            tracks: Vec::new(),
273            vias: Vec::new(),
274            nets: HashSet::new(),
275        };
276
277        // Find the component
278        let component = match pcb
279            .components
280            .iter()
281            .find(|c| c.designator.eq_ignore_ascii_case(designator))
282        {
283            Some(c) => c,
284            None => return result,
285        };
286
287        // Get component position and bounds
288        let comp_pos = match Self::get_component_position_static(component) {
289            Some(p) => p,
290            None => return result,
291        };
292
293        // Get pad locations from the component
294        // For now, we'll estimate pad locations based on component position
295        // In a full implementation, we'd read the actual pad data
296        let pad_locations = self.get_component_pad_locations(pcb, component, &comp_pos);
297
298        // Tolerance for connection detection (within 1 mil)
299        let tolerance = Coord::from_mils(1.0);
300
301        // Check each track
302        for (i, primitive) in pcb.primitives.iter().enumerate() {
303            if let PcbRecord::Track(track) = primitive {
304                for pad_loc in &pad_locations {
305                    if self.point_near_point(track.start, *pad_loc, tolerance)
306                        || self.point_near_point(track.end, *pad_loc, tolerance)
307                    {
308                        result.tracks.push(i);
309                        break;
310                    }
311                }
312            }
313        }
314
315        // Check each via
316        for (i, primitive) in pcb.primitives.iter().enumerate() {
317            if let PcbRecord::Via(via) = primitive {
318                for pad_loc in &pad_locations {
319                    if self.point_near_point(via.location, *pad_loc, tolerance) {
320                        result.vias.push(i);
321                        break;
322                    }
323                }
324            }
325        }
326
327        result
328    }
329
330    /// Get estimated pad locations for a component.
331    fn get_component_pad_locations(
332        &self,
333        _pcb: &PcbDoc,
334        _component: &crate::io::PcbDocComponent,
335        comp_pos: &ComponentPosition,
336    ) -> Vec<CoordPoint> {
337        // For now, return the component center
338        // A full implementation would read actual pad positions
339        vec![CoordPoint::new(comp_pos.x, comp_pos.y)]
340    }
341
342    /// Check if two points are within tolerance.
343    fn point_near_point(&self, p1: CoordPoint, p2: CoordPoint, tolerance: Coord) -> bool {
344        let dx = (p1.x - p2.x).abs();
345        let dy = (p1.y - p2.y).abs();
346        dx.to_raw() <= tolerance.to_raw() && dy.to_raw() <= tolerance.to_raw()
347    }
348
349    /// List all components with their positions.
350    pub fn list_components(&self, pcb: &PcbDoc) -> Vec<(String, ComponentPosition)> {
351        pcb.components
352            .iter()
353            .filter_map(|c| {
354                Self::get_component_position_static(c).map(|pos| (c.designator.clone(), pos))
355            })
356            .collect()
357    }
358
359    /// Find a component by designator.
360    pub fn find_component<'a>(
361        &self,
362        pcb: &'a PcbDoc,
363        designator: &str,
364    ) -> Option<&'a crate::io::PcbDocComponent> {
365        pcb.components
366            .iter()
367            .find(|c| c.designator.eq_ignore_ascii_case(designator))
368    }
369}
370
371/// Parse a position string like "20mm,30mm" or "100mil,200mil".
372pub fn parse_position(s: &str) -> Result<CoordPoint, String> {
373    let parts: Vec<&str> = s.split(',').collect();
374    if parts.len() != 2 {
375        return Err(format!(
376            "Invalid position format: '{}'. Expected 'X,Y' (e.g., '20mm,30mm')",
377            s
378        ));
379    }
380
381    let x = parse_coord(parts[0].trim())?;
382    let y = parse_coord(parts[1].trim())?;
383
384    Ok(CoordPoint::new(x, y))
385}
386
387/// Parse a coordinate string like "20mm" or "100mil".
388pub fn parse_coord(s: &str) -> Result<Coord, String> {
389    let s = s.trim().to_lowercase();
390
391    if s.ends_with("mm") {
392        let val: f64 = s
393            .trim_end_matches("mm")
394            .trim()
395            .parse()
396            .map_err(|_| format!("Invalid coordinate: {}", s))?;
397        Ok(Coord::from_mms(val))
398    } else if s.ends_with("mil") {
399        let val: f64 = s
400            .trim_end_matches("mil")
401            .trim()
402            .parse()
403            .map_err(|_| format!("Invalid coordinate: {}", s))?;
404        Ok(Coord::from_mils(val))
405    } else if s.ends_with("in") {
406        let val: f64 = s
407            .trim_end_matches("in")
408            .trim()
409            .parse()
410            .map_err(|_| format!("Invalid coordinate: {}", s))?;
411        Ok(Coord::from_inches(val))
412    } else {
413        // Try parsing as mils by default
414        let val: f64 = s
415            .parse()
416            .map_err(|_| format!("Invalid coordinate: {} (use '10mm', '100mil', etc.)", s))?;
417        Ok(Coord::from_mils(val))
418    }
419}
420
421/// Parse an offset string like "5mm,0mm" or just "5mm" (for single axis).
422pub fn parse_offset(s: &str) -> Result<CoordPoint, String> {
423    if s.contains(',') {
424        parse_position(s)
425    } else {
426        // Single value - used for align-x/align-y offsets
427        let coord = parse_coord(s)?;
428        Ok(CoordPoint::new(coord, Coord::ZERO))
429    }
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435
436    #[test]
437    fn test_parse_coord() {
438        assert!((parse_coord("10mm").unwrap().to_mms() - 10.0).abs() < 0.001);
439        assert!((parse_coord("100mil").unwrap().to_mils() - 100.0).abs() < 0.001);
440        assert!((parse_coord("1in").unwrap().to_mils() - 1000.0).abs() < 0.001);
441        assert!((parse_coord("50").unwrap().to_mils() - 50.0).abs() < 0.001);
442    }
443
444    #[test]
445    fn test_parse_position() {
446        let pos = parse_position("10mm,20mm").unwrap();
447        assert!((pos.x.to_mms() - 10.0).abs() < 0.001);
448        assert!((pos.y.to_mms() - 20.0).abs() < 0.001);
449    }
450
451    #[test]
452    fn test_board_edge_parse() {
453        assert_eq!(BoardEdge::try_parse("left"), Some(BoardEdge::Left));
454        assert_eq!(BoardEdge::try_parse("left-edge"), Some(BoardEdge::Left));
455        assert_eq!(BoardEdge::try_parse("TOP"), Some(BoardEdge::Top));
456        assert_eq!(BoardEdge::try_parse("invalid"), None);
457    }
458}