diff --git a/.gitignore b/.gitignore index 7bbd323..ba9fafe 100644 --- a/.gitignore +++ b/.gitignore @@ -180,3 +180,9 @@ iteration_*.md orthoroute.log.* orthoroute_debug.log orthoroute_debug.log.* + +# The tracked pytest suite lives in tests/. The rules above (test_*.py, +# tests/, tests/*) would otherwise ignore it, so re-include every .py under +# tests/ (test modules AND conftest.py, which holds the shared fixtures). +!tests/ +!tests/*.py diff --git a/orthoroute/algorithms/manhattan/pad_escape_planner.py b/orthoroute/algorithms/manhattan/pad_escape_planner.py index 5b48371..6a17b9d 100644 --- a/orthoroute/algorithms/manhattan/pad_escape_planner.py +++ b/orthoroute/algorithms/manhattan/pad_escape_planner.py @@ -237,7 +237,10 @@ def precompute_all_pad_escapes(self, board, nets_to_route: List = None) -> Tuple for comp in getattr(board, "components", []): for pad in getattr(comp, "pads", []): # Skip through-hole pads - drill = getattr(pad, 'drill', 0.0) + drill = getattr(pad, 'drill', None) + if drill is None: + # Domain Pad objects (file-parser path) name it drill_size + drill = getattr(pad, 'drill_size', None) or 0.0 if drill > 0: continue @@ -265,7 +268,10 @@ def precompute_all_pad_escapes(self, board, nets_to_route: List = None) -> Tuple # Board-level pads for pad in getattr(board, "pads", []): - drill = getattr(pad, 'drill', 0.0) + drill = getattr(pad, 'drill', None) + if drill is None: + # Domain Pad objects (file-parser path) name it drill_size + drill = getattr(pad, 'drill_size', None) or 0.0 if drill > 0: continue diff --git a/orthoroute/infrastructure/kicad/file_parser.py b/orthoroute/infrastructure/kicad/file_parser.py index aca1a6d..a7e30a9 100644 --- a/orthoroute/infrastructure/kicad/file_parser.py +++ b/orthoroute/infrastructure/kicad/file_parser.py @@ -1,447 +1,460 @@ -"""KiCad file parser (direct file parsing).""" +"""KiCad board file parser (direct .kicad_pcb parsing, no KiCad needed). + +Built on the balanced-paren s-expression parser in ``sexpr.py`` and +supports every dialect from KiCad 5 through KiCad 10: + +- KiCad <= 7: numbered net table ``(net 3 "GND")``, references in + ``(fp_text reference ...)``, pads carry ``(net 3 "GND")``. +- KiCad 8/9: references move to ``(property "Reference" ...)``. +- KiCad 10 (format 20260206): the net table is gone; nets exist only as + name references ``(net "GND")`` on pads/tracks/zones and are synthesized + here from pad references. + +Net ids are normalized to the net *name* for both dialects so downstream +code never sees dialect-specific numbering. + +Pad positions are stored in the footprint's local frame; this parser +composes them with the footprint position/rotation (and back-side mirror) +to produce world coordinates — the legacy regex parser never did, so pad +world positions were wrong for any rotated footprint. + +Python 3.9 compatible (imported by plugin runtime code). +""" + import logging -import json -import re +import math from pathlib import Path -from typing import Dict, List, Optional, Any +from typing import Any, Dict, List, Optional -from ...domain.models.board import Board, Component, Net, Pad, Layer, Coordinate -from ...domain.models.constraints import DRCConstraints, NetClass +from ...domain.models.board import Board, Component, Coordinate, Layer, Net, Pad +from .sexpr import SExpr, atoms, child, children, first_atom, node_name, parse logger = logging.getLogger(__name__) +def _f(value: Optional[str], default: float = 0.0) -> float: + """Parse a float atom with a default.""" + if value is None: + return default + try: + return float(value) + except (TypeError, ValueError): + return default + + class KiCadFileParser: """Parser for KiCad board files (.kicad_pcb).""" - - def __init__(self): - """Initialize file parser.""" - pass - + def load_board(self, file_path: str) -> Optional[Board]: """Load board from KiCad file.""" try: board_data = self.parse_file(file_path) - return self._convert_to_domain_board(board_data, file_path) + return self.create_board_from_data(board_data) except Exception as e: logger.error(f"Failed to load board from {file_path}: {e}") return None - + def parse_file(self, file_path: str) -> Dict[str, Any]: - """Parse KiCad board file.""" - try: - path = Path(file_path) - if not path.exists(): - raise FileNotFoundError(f"Board file not found: {file_path}") - - if path.suffix.lower() == '.kicad_pcb': - return self._parse_kicad_pcb(file_path) - else: - raise ValueError(f"Unsupported file format: {path.suffix}") - - except Exception as e: - logger.error(f"Error parsing board file {file_path}: {e}") - raise - - def _parse_kicad_pcb(self, file_path: str) -> Dict[str, Any]: - """Parse .kicad_pcb file format.""" - try: - with open(file_path, 'r', encoding='utf-8') as f: - content = f.read() - - # Basic S-expression parsing - # This is a simplified parser - a full implementation would use a proper S-expression parser - board_data = { - 'title': self._extract_title(content), - 'layers': self._extract_layers(content), - 'components': self._extract_components(content), - 'nets': self._extract_nets(content), - 'design_rules': self._extract_design_rules(content), - 'tracks': self._extract_tracks(content), - 'vias': self._extract_vias(content) - } - - return board_data - - except Exception as e: - logger.error(f"Error parsing .kicad_pcb file: {e}") - raise - - def _extract_title(self, content: str) -> str: - """Extract board title from content.""" - match = re.search(r'\(title\s+"([^"]+)"\)', content) - return match.group(1) if match else "Untitled Board" - - def _extract_layers(self, content: str) -> List[Dict[str, Any]]: - """Extract layer information.""" - layers = [] - - # Find layers section - layers_match = re.search(r'\(layers\s+(.*?)\n\s*\)', content, re.DOTALL) - if not layers_match: - # Default layers if not found + """Parse a KiCad board file into a plain-dict description.""" + path = Path(file_path) + if not path.exists(): + raise FileNotFoundError(f"Board file not found: {file_path}") + if path.suffix.lower() != ".kicad_pcb": + raise ValueError(f"Unsupported file format: {path.suffix}") + return self._parse_kicad_pcb(path) + + def _parse_kicad_pcb(self, path: Path) -> Dict[str, Any]: + text = path.read_text(encoding="utf-8") + doc = parse(text) + if not doc or node_name(doc[0]) != "kicad_pcb": + raise ValueError(f"Not a kicad_pcb file: {path}") + root = doc[0] + + version = int(_f(first_atom(child(root, "version")), 0)) + net_table = self._extract_net_table(root) + components = self._extract_components(root, net_table) + nets = self._collect_nets(net_table, components) + + board_data = { + "title": self._extract_title(root) or path.stem, + "version": version, + "layers": self._extract_layers(root), + "components": components, + "nets": nets, + "design_rules": self._extract_design_rules(root, path), + "tracks": self._extract_tracks(root, net_table), + "vias": self._extract_vias(root, net_table), + } + logger.info( + f"Parsed {path.name} (format {version}): " + f"{len(components)} components, {len(nets)} nets, " + f"{sum(len(c['pads']) for c in components)} pads" + ) + return board_data + + # ------------------------------------------------------------------ + # Extractors + # ------------------------------------------------------------------ + + @staticmethod + def _extract_title(root: SExpr) -> Optional[str]: + title_block = child(root, "title_block") + if title_block is not None: + return first_atom(child(title_block, "title")) + return None + + @staticmethod + def _extract_layers(root: SExpr) -> List[Dict[str, Any]]: + """Board layer table: entries are ( "" [""]).""" + layers_node = child(root, "layers") + if layers_node is None: return [ - {'id': 0, 'name': 'F.Cu', 'type': 'signal'}, - {'id': 31, 'name': 'B.Cu', 'type': 'signal'} + {"id": 0, "name": "F.Cu", "type": "signal", "stackup_position": 0}, + {"id": 2, "name": "B.Cu", "type": "signal", "stackup_position": 2}, ] - - layers_content = layers_match.group(1) - - # Parse individual layer definitions - layer_pattern = r'\((\d+)\s+"([^"]+)"\s+(\w+)' - for match in re.finditer(layer_pattern, layers_content): - layer_id = int(match.group(1)) - layer_name = match.group(2) - layer_type = match.group(3) - + layers = [] + for entry in layers_node[1:]: + if not isinstance(entry, list) or len(entry) < 3: + continue layers.append({ - 'id': layer_id, - 'name': layer_name, - 'type': layer_type, - 'stackup_position': layer_id + "id": int(_f(entry[0], -1)), + "name": entry[1], + "type": entry[2], + "stackup_position": int(_f(entry[0], -1)), }) - return layers - - def _extract_components(self, content: str) -> List[Dict[str, Any]]: - """Extract component (footprint) information.""" + + @staticmethod + def _extract_net_table(root: SExpr) -> Dict[str, str]: + """Legacy numbered net table: {net_code: net_name}. Empty on KiCad 10.""" + table: Dict[str, str] = {} + for net in children(root, "net"): + vals = atoms(net) + if len(vals) >= 2 and vals[1]: + table[vals[0]] = vals[1] + return table + + @staticmethod + def _pad_net_name(pad_node: SExpr, net_table: Dict[str, str]) -> Optional[str]: + """Net name from a (net ...) child: numbered (legacy) or name-only (10).""" + net = child(pad_node, "net") + if net is None: + return None + vals = atoms(net) + if not vals: + return None + if len(vals) >= 2: # (net "NAME") + return vals[1] or None + # (net "NAME") — KiCad 10; or (net ) — resolve via table + val = vals[0] + if val in net_table: + return net_table[val] or None + return val or None + + def _extract_components(self, root: SExpr, + net_table: Dict[str, str]) -> List[Dict[str, Any]]: components = [] - - # Find all footprint definitions - footprint_pattern = r'\(footprint\s+"([^"]+)"\s+(.*?)\n\s*\)' - - for match in re.finditer(footprint_pattern, content, re.DOTALL): - footprint_lib = match.group(1) - footprint_content = match.group(2) - - # Extract footprint details + for fp in children(root, "footprint") + children(root, "module"): + footprint_lib = first_atom(fp) or "" + + at = child(fp, "at") + at_vals = atoms(at) if at is not None else [] + fx = _f(at_vals[0] if len(at_vals) > 0 else None) + fy = _f(at_vals[1] if len(at_vals) > 1 else None) + fangle = _f(at_vals[2] if len(at_vals) > 2 else None) + + layer = first_atom(child(fp, "layer")) or "F.Cu" + back = layer.startswith("B.") + + reference = self._extract_reference(fp) + value = self._extract_value(fp) + component = { - 'footprint': footprint_lib, - 'reference': '', - 'value': '', - 'x': 0.0, - 'y': 0.0, - 'angle': 0.0, - 'layer': 'F.Cu', - 'pads': [] + "footprint": footprint_lib, + "reference": reference, + "value": value, + "x": fx, "y": fy, + "angle": fangle, + "layer": layer, + "pads": self._extract_pads(fp, reference, fx, fy, fangle, back, + net_table), } - - # Extract reference - ref_match = re.search(r'\(fp_text\s+reference\s+"([^"]+)"', footprint_content) - if ref_match: - component['reference'] = ref_match.group(1) - - # Extract value - val_match = re.search(r'\(fp_text\s+value\s+"([^"]+)"', footprint_content) - if val_match: - component['value'] = val_match.group(1) - - # Extract position - pos_match = re.search(r'\(at\s+([\d.-]+)\s+([\d.-]+)(?:\s+([\d.-]+))?\)', footprint_content) - if pos_match: - component['x'] = float(pos_match.group(1)) - component['y'] = float(pos_match.group(2)) - if pos_match.group(3): - component['angle'] = float(pos_match.group(3)) - - # Extract layer - layer_match = re.search(r'\(layer\s+"([^"]+)"\)', footprint_content) - if layer_match: - component['layer'] = layer_match.group(1) - - # Extract pads - component['pads'] = self._extract_pads(footprint_content, component['reference']) - - if component['reference']: # Only add if has reference + if reference: components.append(component) - return components - - def _extract_pads(self, footprint_content: str, component_ref: str) -> List[Dict[str, Any]]: - """Extract pad information from footprint.""" + + @staticmethod + def _extract_reference(fp: SExpr) -> str: + # KiCad 8+: (property "Reference" "R1" ...) + for prop in children(fp, "property"): + vals = atoms(prop) + if len(vals) >= 2 and vals[0] == "Reference": + return vals[1] + # KiCad <= 7: (fp_text reference "R1" ...) + for txt in children(fp, "fp_text"): + vals = atoms(txt) + if len(vals) >= 2 and vals[0] == "reference": + return vals[1] + return "" + + @staticmethod + def _extract_value(fp: SExpr) -> str: + for prop in children(fp, "property"): + vals = atoms(prop) + if len(vals) >= 2 and vals[0] == "Value": + return vals[1] + for txt in children(fp, "fp_text"): + vals = atoms(txt) + if len(vals) >= 2 and vals[0] == "value": + return vals[1] + return "" + + def _extract_pads(self, fp: SExpr, component_ref: str, + fx: float, fy: float, fangle: float, back: bool, + net_table: Dict[str, str]) -> List[Dict[str, Any]]: pads = [] - - # Find all pad definitions - pad_pattern = r'\(pad\s+"([^"]+)"\s+(\w+)\s+(\w+)\s+(.*?)\)' - - for match in re.finditer(pad_pattern, footprint_content, re.DOTALL): - pad_number = match.group(1) - pad_type = match.group(2) # thru_hole, smd, etc. - pad_shape = match.group(3) # circle, rect, etc. - pad_details = match.group(4) - - pad = { - 'id': f"{component_ref}_{pad_number}", - 'number': pad_number, - 'type': pad_type, - 'shape': pad_shape, - 'x': 0.0, - 'y': 0.0, - 'width': 1.0, - 'height': 1.0, - 'drill_size': None, - 'layer': 'F.Cu', - 'net_id': None - } - - # Extract position - pos_match = re.search(r'\(at\s+([\d.-]+)\s+([\d.-]+)', pad_details) - if pos_match: - pad['x'] = float(pos_match.group(1)) - pad['y'] = float(pos_match.group(2)) - - # Extract size - size_match = re.search(r'\(size\s+([\d.-]+)\s+([\d.-]+)', pad_details) - if size_match: - pad['width'] = float(size_match.group(1)) - pad['height'] = float(size_match.group(2)) - - # Extract drill size - drill_match = re.search(r'\(drill\s+([\d.-]+)', pad_details) - if drill_match: - pad['drill_size'] = float(drill_match.group(1)) - - # Extract layers - layers_match = re.search(r'\(layers\s+"([^"]+)"', pad_details) - if layers_match: - pad['layer'] = layers_match.group(1) - - # Extract net - net_match = re.search(r'\(net\s+(\d+)\s+"([^"]*)"', pad_details) - if net_match: - net_code = int(net_match.group(1)) - if net_code > 0: # Skip unconnected (net 0) - pad['net_id'] = str(net_code) - - pads.append(pad) - + seen_ids: Dict[str, int] = {} + for pad_node in children(fp, "pad"): + vals = atoms(pad_node) + pad_number = vals[0] if len(vals) > 0 else "" + pad_type = vals[1] if len(vals) > 1 else "smd" + pad_shape = vals[2] if len(vals) > 2 else "circle" + + at = child(pad_node, "at") + at_vals = atoms(at) if at is not None else [] + px = _f(at_vals[0] if len(at_vals) > 0 else None) + py = _f(at_vals[1] if len(at_vals) > 1 else None) + + wx, wy = self._pad_world(fx, fy, fangle, px, py, back) + + size = child(pad_node, "size") + size_vals = atoms(size) if size is not None else [] + width = _f(size_vals[0] if len(size_vals) > 0 else None, 1.0) + height = _f(size_vals[1] if len(size_vals) > 1 else None, width) + + drill = child(pad_node, "drill") + drill_size = None + if drill is not None: + # (drill 0.8) or (drill oval 0.8 1.2) + dvals = [v for v in atoms(drill) if v != "oval"] + if dvals: + drill_size = _f(dvals[0]) + + pad_layer = self._pad_copper_layer(pad_node, back) + net_name = self._pad_net_name(pad_node, net_table) + + # Uniquify duplicate pad numbers within one footprint (thermal + # pad splits, NPTH with empty numbers) so pad keys don't collide. + base_id = f"{component_ref}_{pad_number}" + count = seen_ids.get(base_id, 0) + seen_ids[base_id] = count + 1 + pad_id = base_id if count == 0 else f"{base_id}#{count}" + + pads.append({ + "id": pad_id, + "number": pad_number, + "type": pad_type, + "shape": pad_shape, + "x": wx, "y": wy, + "width": width, "height": height, + "drill_size": drill_size, + "layer": pad_layer, + "net_id": net_name, # normalized: net name IS the id + }) return pads - - def _extract_nets(self, content: str) -> List[Dict[str, Any]]: - """Extract net information.""" - nets = [] - - # Find all net definitions - net_pattern = r'\(net\s+(\d+)\s+"([^"]*)"\)' - - for match in re.finditer(net_pattern, content): - net_code = int(match.group(1)) - net_name = match.group(2) - - if net_code > 0 and net_name: # Skip unconnected and empty names - nets.append({ - 'id': str(net_code), - 'name': net_name, - 'netclass': 'Default' - }) - - return nets - - def _extract_design_rules(self, content: str) -> Dict[str, Any]: - """Extract design rule information.""" + + @staticmethod + def _pad_world(fx: float, fy: float, fangle: float, + px: float, py: float, back: bool) -> "tuple": + """Compose a pad's footprint-local (at px py) into world coordinates. + + KiCad stores pad offsets in the footprint frame, ALREADY in the + flipped frame for back-side footprints (no extra mirror), and the + footprint angle is CCW-positive in KiCad's Y-down board frame. + Verified pad-exact (<=1um) against pcbnew 10.0.4 on real KiCad 10 + boards (front/back, rot 0/90/180/270). + """ + del back # placement is identical for both sides + if fangle: + a = math.radians(fangle) + c, s = math.cos(a), math.sin(a) + rx = px * c + py * s + ry = -px * s + py * c + else: + rx, ry = px, py + return fx + rx, fy + ry + + @staticmethod + def _pad_copper_layer(pad_node: SExpr, back: bool) -> str: + """First copper layer a pad sits on ('F.Cu'/'B.Cu'/'*.Cu' -> F.Cu).""" + layers_node = child(pad_node, "layers") + if layers_node is not None: + for name in atoms(layers_node): + if name.endswith(".Cu"): + if name.startswith("*"): + return "F.Cu" # through-hole: spans all copper + return name + return "B.Cu" if back else "F.Cu" + + @staticmethod + def _collect_nets(net_table: Dict[str, str], + components: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Union of the legacy net table and names referenced by pads. + + KiCad 10 has no net table, so nets must be synthesized from pad + references. Sorted by name for deterministic downstream ordering. + """ + names = {name for name in net_table.values() if name} + for comp in components: + for pad in comp["pads"]: + if pad["net_id"]: + names.add(pad["net_id"]) + return [{"id": name, "name": name, "netclass": "Default"} + for name in sorted(names)] + + @staticmethod + def _extract_design_rules(root: SExpr, path: Optional[Path] = None) -> Dict[str, Any]: rules = { - 'min_track_width': 0.1, - 'min_track_spacing': 0.1, - 'min_via_diameter': 0.2, - 'min_via_drill': 0.1, - 'default_track_width': 0.2, # KiCad default - 'default_clearance': 0.2, - 'default_via_diameter': 0.8, # KiCad default - 'default_via_drill': 0.4, # Typical for 0.8mm via - 'netclasses': {} + "min_track_width": 0.1, + "min_track_spacing": 0.1, + "min_via_diameter": 0.2, + "min_via_drill": 0.1, + "min_via_annular_width": 0.05, + "default_track_width": 0.2, + "default_clearance": 0.2, + "default_via_diameter": 0.8, + "default_via_drill": 0.4, + "netclasses": {}, } - - # Find setup section - setup_match = re.search(r'\(setup\s+(.*?)\n\s*\)', content, re.DOTALL) - if setup_match: - setup_content = setup_match.group(1) - - # Extract various rules - rules_map = { - 'min_track_width': r'\(min_track_width\s+([\d.]+)\)', - 'min_via_diameter': r'\(min_via_diameter\s+([\d.]+)\)', - 'min_via_drill': r'\(min_via_drill\s+([\d.]+)\)', - 'default_track_width': r'\(default_track_width\s+([\d.]+)\)', - 'default_via_diameter': r'\(default_via_diameter\s+([\d.]+)\)', - 'default_via_drill': r'\(default_via_drill\s+([\d.]+)\)' - } - - for rule_name, pattern in rules_map.items(): - match = re.search(pattern, setup_content) - if match: - rules[rule_name] = float(match.group(1)) - + # Legacy dialects kept rules in (setup ...). + setup = child(root, "setup") + if setup is not None: + for key in ("min_track_width", "min_via_diameter", "min_via_drill", + "default_track_width", "default_via_diameter", + "default_via_drill"): + val = first_atom(child(setup, key)) + if val is not None: + rules[key] = _f(val, rules[key]) + # KiCad 6+ keeps the authoritative rules in the sibling .kicad_pro. + if path is not None: + pro = Path(path).with_suffix(".kicad_pro") + if pro.exists(): + try: + import json + settings = json.loads(pro.read_text(encoding="utf-8")) + pro_rules = (settings.get("board", {}) + .get("design_settings", {}).get("rules", {})) + for src_key, dst_key in ( + ("min_track_width", "min_track_width"), + ("min_via_diameter", "min_via_diameter"), + ("min_through_hole_diameter", "min_via_drill"), + ("min_via_annular_width", "min_via_annular_width"), + ("min_clearance", "min_track_spacing")): + if src_key in pro_rules: + rules[dst_key] = float(pro_rules[src_key]) + logger.info(f"Merged design rules from {pro.name}") + except (ValueError, OSError) as e: + logger.warning(f"Could not read {pro.name}: {e}") return rules - - def _extract_tracks(self, content: str) -> List[Dict[str, Any]]: - """Extract existing track information.""" + + def _extract_tracks(self, root: SExpr, + net_table: Dict[str, str]) -> List[Dict[str, Any]]: tracks = [] - - # Find all segment definitions - segment_pattern = r'\(segment\s+(.*?)\)' - - for match in re.finditer(segment_pattern, content, re.DOTALL): - segment_content = match.group(1) - - track = { - 'start': {'x': 0.0, 'y': 0.0}, - 'end': {'x': 0.0, 'y': 0.0}, - 'width': 0.25, - 'layer': 'F.Cu', - 'net': 0 - } - - # Extract start point - start_match = re.search(r'\(start\s+([\d.-]+)\s+([\d.-]+)\)', segment_content) - if start_match: - track['start'] = { - 'x': float(start_match.group(1)), - 'y': float(start_match.group(2)) - } - - # Extract end point - end_match = re.search(r'\(end\s+([\d.-]+)\s+([\d.-]+)\)', segment_content) - if end_match: - track['end'] = { - 'x': float(end_match.group(1)), - 'y': float(end_match.group(2)) - } - - # Extract width - width_match = re.search(r'\(width\s+([\d.]+)\)', segment_content) - if width_match: - track['width'] = float(width_match.group(1)) - - # Extract layer - layer_match = re.search(r'\(layer\s+"([^"]+)"\)', segment_content) - if layer_match: - track['layer'] = layer_match.group(1) - - # Extract net - net_match = re.search(r'\(net\s+(\d+)\)', segment_content) - if net_match: - track['net'] = int(net_match.group(1)) - - tracks.append(track) - + for seg in children(root, "segment"): + start = atoms(child(seg, "start") or []) + end = atoms(child(seg, "end") or []) + tracks.append({ + "start": {"x": _f(start[0] if len(start) > 0 else None), + "y": _f(start[1] if len(start) > 1 else None)}, + "end": {"x": _f(end[0] if len(end) > 0 else None), + "y": _f(end[1] if len(end) > 1 else None)}, + "width": _f(first_atom(child(seg, "width")), 0.25), + "layer": first_atom(child(seg, "layer")) or "F.Cu", + "net": self._pad_net_name(seg, net_table) or "", + }) return tracks - - def _extract_vias(self, content: str) -> List[Dict[str, Any]]: - """Extract existing via information.""" + + def _extract_vias(self, root: SExpr, + net_table: Dict[str, str]) -> List[Dict[str, Any]]: vias = [] - - # Find all via definitions - via_pattern = r'\(via\s+(.*?)\)' - - for match in re.finditer(via_pattern, content, re.DOTALL): - via_content = match.group(1) - - via = { - 'x': 0.0, - 'y': 0.0, - 'size': 0.6, - 'drill': 0.3, - 'layers': ['F.Cu', 'B.Cu'], - 'net': 0 - } - - # Extract position - pos_match = re.search(r'\(at\s+([\d.-]+)\s+([\d.-]+)\)', via_content) - if pos_match: - via['x'] = float(pos_match.group(1)) - via['y'] = float(pos_match.group(2)) - - # Extract size - size_match = re.search(r'\(size\s+([\d.]+)\)', via_content) - if size_match: - via['size'] = float(size_match.group(1)) - - # Extract drill - drill_match = re.search(r'\(drill\s+([\d.]+)\)', via_content) - if drill_match: - via['drill'] = float(drill_match.group(1)) - - # Extract layers - layers_match = re.search(r'\(layers\s+"([^"]+)"\s+"([^"]+)"\)', via_content) - if layers_match: - via['layers'] = [layers_match.group(1), layers_match.group(2)] - - # Extract net - net_match = re.search(r'\(net\s+(\d+)\)', via_content) - if net_match: - via['net'] = int(net_match.group(1)) - - vias.append(via) - + for via in children(root, "via"): + at = atoms(child(via, "at") or []) + layer_names = atoms(child(via, "layers") or []) + vias.append({ + "x": _f(at[0] if len(at) > 0 else None), + "y": _f(at[1] if len(at) > 1 else None), + "size": _f(first_atom(child(via, "size")), 0.6), + "drill": _f(first_atom(child(via, "drill")), 0.3), + "layers": layer_names or ["F.Cu", "B.Cu"], + "net": self._pad_net_name(via, net_table) or "", + }) return vias - + + # ------------------------------------------------------------------ + # Domain conversion + # ------------------------------------------------------------------ + def create_board_from_data(self, board_data: Dict[str, Any]) -> Board: """Create Board domain object from parsed data.""" - # Create board + copper = [l for l in board_data.get("layers", []) + if str(l.get("name", "")).endswith(".Cu")] board = Board( - id='parsed_board', - name=board_data.get('title', 'Parsed Board'), - thickness=1.6, # Default thickness - layer_count=len([l for l in board_data.get('layers', []) if 'Cu' in l.get('name', '')]) + id="parsed_board", + name=board_data.get("title", "Parsed Board"), + thickness=1.6, + layer_count=len(copper), ) - - # Add layers - for layer_data in board_data.get('layers', []): - layer = Layer( - name=layer_data['name'], - type='signal' if 'Cu' in layer_data['name'] else 'other', - stackup_position=layer_data.get('stackup_position', 0) - ) - board.add_layer(layer) - - # Add components - for comp_data in board_data.get('components', []): + # Stash the merged rules for rule-aware emission (see + # PathFinderRouter.apply_board_rules); private attr, not a + # dataclass field. + board._design_rules = board_data.get("design_rules", {}) + + for layer_data in board_data.get("layers", []): + board.add_layer(Layer( + name=layer_data["name"], + type="signal" if str(layer_data["name"]).endswith(".Cu") else "other", + stackup_position=layer_data.get("stackup_position", 0), + )) + + for comp_data in board_data.get("components", []): component = Component( - id=comp_data.get('reference', ''), - reference=comp_data.get('reference', ''), - value=comp_data.get('value', ''), - footprint=comp_data.get('footprint', ''), - position=Coordinate(comp_data.get('x', 0), comp_data.get('y', 0)), - angle=comp_data.get('angle', 0), - layer=comp_data.get('layer', 'F.Cu') + id=comp_data.get("reference", ""), + reference=comp_data.get("reference", ""), + value=comp_data.get("value", ""), + footprint=comp_data.get("footprint", ""), + position=Coordinate(comp_data.get("x", 0), comp_data.get("y", 0)), + angle=comp_data.get("angle", 0), + layer=comp_data.get("layer", "F.Cu"), ) - - # Add pads - for pad_data in comp_data.get('pads', []): - pad = Pad( - id=pad_data['id'], + for pad_data in comp_data.get("pads", []): + component.pads.append(Pad( + id=pad_data["id"], component_id=component.id, - net_id=pad_data.get('net_id'), - position=Coordinate(pad_data.get('x', 0), pad_data.get('y', 0)), - size=(pad_data.get('width', 1.0), pad_data.get('height', 1.0)), - drill_size=pad_data.get('drill_size'), - layer=pad_data.get('layer', 'F.Cu'), - shape=pad_data.get('shape', 'circle') - ) - component.pads.append(pad) - + net_id=pad_data.get("net_id"), + position=Coordinate(pad_data.get("x", 0), pad_data.get("y", 0)), + size=(pad_data.get("width", 1.0), pad_data.get("height", 1.0)), + drill_size=pad_data.get("drill_size"), + layer=pad_data.get("layer", "F.Cu"), + shape=pad_data.get("shape", "circle"), + )) board.add_component(component) - - # Add nets - for net_data in board_data.get('nets', []): - # Find pads for this net - net_pads = [] - for component in board.components: - for pad in component.pads: - if pad.net_id == net_data['id']: - net_pads.append(pad) - + + for net_data in board_data.get("nets", []): + net_pads = [pad + for component in board.components + for pad in component.pads + if pad.net_id == net_data["id"]] if net_pads: - net = Net( - id=net_data['id'], - name=net_data['name'], - netclass=net_data.get('netclass', 'Default'), - pads=net_pads - ) - board.add_net(net) - + board.add_net(Net( + id=net_data["id"], + name=net_data["name"], + netclass=net_data.get("netclass", "Default"), + pads=net_pads, + )) + return board - - def _convert_to_domain_board(self, board_data: Dict[str, Any], file_path: str) -> Board: - """Convert parsed board data to domain Board object.""" - return self.create_board_from_data(board_data) \ No newline at end of file + + def _convert_to_domain_board(self, board_data: Dict[str, Any], + file_path: str) -> Board: + """Convert parsed board data to domain Board object (legacy alias).""" + return self.create_board_from_data(board_data) diff --git a/orthoroute/infrastructure/kicad/rich_kicad_interface.py b/orthoroute/infrastructure/kicad/rich_kicad_interface.py index 03992e6..f09bb7c 100644 --- a/orthoroute/infrastructure/kicad/rich_kicad_interface.py +++ b/orthoroute/infrastructure/kicad/rich_kicad_interface.py @@ -42,7 +42,7 @@ class BoardData: def fetch_board_and_drc(): """Fetch board and DRC data using IPC API (proper method)""" - from kicad import KiCad + from kipy import KiCad try: kc = KiCad() # IPC session (nng under the hood) diff --git a/orthoroute/infrastructure/kicad/sexpr.py b/orthoroute/infrastructure/kicad/sexpr.py new file mode 100644 index 0000000..7adb3b6 --- /dev/null +++ b/orthoroute/infrastructure/kicad/sexpr.py @@ -0,0 +1,261 @@ +"""Minimal s-expression tokenizer/parser for KiCad board files. + +KiCad `.kicad_pcb` files are s-expressions: nested parenthesized lists of +atoms and double-quoted strings (with backslash escapes). The legacy regex +extractors in ``file_parser.py`` broke on every format revision; this module +replaces them with a single balanced-paren parser that works for every +dialect from KiCad 5 through KiCad 10. + +Two independent entry points: + +- :func:`parse` / :func:`parse_file` — full structural parse into nested + Python lists (used by the KiCad-10-capable file parser). +- :func:`strip_top_level_nodes` — byte-exact removal of selected top-level + nodes from the raw text without reserialization (used to produce stripped + test fixtures whose untouched content is byte-identical to the original). + +This module must stay Python 3.9 compatible: it is imported by plugin +runtime code and macOS KiCad bundles Python 3.9. +""" + +from typing import List, Optional, Tuple, Union + +# A parsed node is a list whose first element is usually the node name atom; +# leaves are strings (atoms and unescaped quoted strings are not +# distinguished — KiCad consumers never need the distinction). +SExpr = Union[str, List["SExpr"]] + +_ESCAPES = { + '"': '"', + "\\": "\\", + "n": "\n", + "t": "\t", + "r": "\r", +} + + +class SExprError(ValueError): + """Raised on malformed s-expression input (unbalanced parens, EOF in string).""" + + +def parse(text: str) -> List[SExpr]: + """Parse s-expression text into a list of top-level nodes. + + Args: + text: Full file contents. + + Returns: + List of top-level expressions (a .kicad_pcb has exactly one: + the ``kicad_pcb`` node). + + Raises: + SExprError: On unbalanced parentheses or an unterminated string. + """ + top: List[SExpr] = [] + stack: List[List[SExpr]] = [] + i = 0 + n = len(text) + + while i < n: + c = text[i] + if c in " \t\r\n": + i += 1 + elif c == "(": + node: List[SExpr] = [] + if stack: + stack[-1].append(node) + else: + top.append(node) + stack.append(node) + i += 1 + elif c == ")": + if not stack: + raise SExprError(f"Unbalanced ')' at offset {i}") + stack.pop() + i += 1 + elif c == '"': + value, i = _read_string(text, i) + if stack: + stack[-1].append(value) + else: + top.append(value) + else: + j = i + while j < n and text[j] not in ' \t\r\n()"': + j += 1 + atom = text[i:j] + if stack: + stack[-1].append(atom) + else: + top.append(atom) + i = j + + if stack: + raise SExprError(f"Unbalanced '(': {len(stack)} unclosed at EOF") + return top + + +def parse_file(path: str) -> List[SExpr]: + """Parse a file; see :func:`parse`.""" + with open(path, "r", encoding="utf-8") as f: + return parse(f.read()) + + +def _read_string(text: str, i: int) -> Tuple[str, int]: + """Read a quoted string starting at ``text[i] == '"'``. + + Returns (unescaped value, index just past the closing quote). + """ + n = len(text) + i += 1 # opening quote + out: List[str] = [] + while i < n: + c = text[i] + if c == "\\" and i + 1 < n: + nxt = text[i + 1] + out.append(_ESCAPES.get(nxt, nxt)) + i += 2 + elif c == '"': + return "".join(out), i + 1 + else: + out.append(c) + i += 1 + raise SExprError("Unterminated string at EOF") + + +# --------------------------------------------------------------------------- +# Structural helpers for extractors +# --------------------------------------------------------------------------- + +def node_name(node: SExpr) -> Optional[str]: + """Return the name atom of a node, or None for leaves/empty nodes.""" + if isinstance(node, list) and node and isinstance(node[0], str): + return node[0] + return None + + +def children(node: SExpr, name: str) -> List[List[SExpr]]: + """All direct child nodes of ``node`` named ``name``.""" + if not isinstance(node, list): + return [] + return [c for c in node[1:] if isinstance(c, list) and c and c[0] == name] + + +def child(node: SExpr, name: str) -> Optional[List[SExpr]]: + """First direct child node named ``name``, or None.""" + found = children(node, name) + return found[0] if found else None + + +def atoms(node: SExpr) -> List[str]: + """Leaf values (atoms/strings) of a node, excluding the name atom.""" + if not isinstance(node, list): + return [] + return [c for c in node[1:] if isinstance(c, str)] + + +def first_atom(node: Optional[SExpr]) -> Optional[str]: + """First leaf value of a node, or None.""" + if node is None: + return None + vals = atoms(node) + return vals[0] if vals else None + + +# --------------------------------------------------------------------------- +# Byte-exact top-level node stripping (fixture generation) +# --------------------------------------------------------------------------- + +def find_top_level_spans(text: str, names: Tuple[str, ...]) -> List[Tuple[int, int]]: + """Find byte spans of depth-1 nodes named in ``names``. + + Depth 1 means direct children of the single root ``(kicad_pcb ...)`` + node — where KiCad keeps segments, vias, zones, and footprints. + + Each span covers the node's opening ``(`` through its closing ``)``, + widened to include the preceding same-line indentation and the trailing + newline, so removal deletes whole lines without leaving blanks. + """ + spans: List[Tuple[int, int]] = [] + depth = 0 + i = 0 + n = len(text) + + while i < n: + c = text[i] + if c == '"': + _, i = _read_string(text, i) + elif c == "(": + if depth == 1: + j = i + 1 + while j < n and text[j] not in ' \t\r\n()"': + j += 1 + if text[i + 1:j] in names: + end = _scan_node_end(text, i) + start = i + while start > 0 and text[start - 1] in " \t": + start -= 1 + if end < n and text[end] == "\r": + end += 1 + if end < n and text[end] == "\n": + end += 1 + spans.append((start, end)) + i = end + continue + depth += 1 + i += 1 + elif c == ")": + depth -= 1 + if depth < 0: + raise SExprError(f"Unbalanced ')' at offset {i}") + i += 1 + else: + i += 1 + + if depth != 0: + raise SExprError(f"Unbalanced '(': depth {depth} at EOF") + return spans + + +def _scan_node_end(text: str, start: int) -> int: + """Given ``text[start] == '('``, return index just past the matching ')'.""" + depth = 0 + i = start + n = len(text) + while i < n: + c = text[i] + if c == '"': + _, i = _read_string(text, i) + elif c == "(": + depth += 1 + i += 1 + elif c == ")": + depth -= 1 + i += 1 + if depth == 0: + return i + else: + i += 1 + raise SExprError("Unbalanced '(' while scanning node") + + +def strip_top_level_nodes(text: str, names: Tuple[str, ...]) -> Tuple[str, int]: + """Remove all depth-1 nodes named in ``names`` from raw file text. + + Untouched content is preserved byte-for-byte (no reserialization), so + stripping is deterministic and re-runnable: stripping an already + stripped file is a no-op. + + Returns: + (stripped text, number of nodes removed) + """ + spans = find_top_level_spans(text, names) + if not spans: + return text, 0 + out: List[str] = [] + prev = 0 + for start, end in spans: + out.append(text[prev:start]) + prev = end + out.append(text[prev:]) + return "".join(out), len(spans) diff --git a/tests/test_file_parser.py b/tests/test_file_parser.py new file mode 100644 index 0000000..a6b6623 --- /dev/null +++ b/tests/test_file_parser.py @@ -0,0 +1,136 @@ +"""Tests for the KiCad-capable .kicad_pcb file parser. + +The parser is exercised on small, self-contained synthetic boards (no +third-party board files) that pin both dialects and the footprint +transform: + +- legacy (KiCad <= 7): numbered net table, ``(module ...)`` footprints, + ``(fp_text reference ...)``. +- modern (KiCad 8-10, format 20260206): no net table (nets synthesized + from pad references, including the name-only ``(net "NAME")`` form), + ``(footprint ...)``, ``(property "Reference" ...)``. + +Footprint rotation is CCW-positive in KiCad's Y-down board frame, and +back-side pads carry offsets already in the flipped frame (no extra +mirror); the expected pad positions below are computed from that rule +(the same transform is verified pad-exact against pcbnew 10.0.4 on real +boards). +""" + +import pytest + +from orthoroute.infrastructure.kicad.file_parser import KiCadFileParser + + +LEGACY = """(kicad_pcb (version 20171130) (host pcbnew 5.0) + (layers (0 "F.Cu" signal) (31 "B.Cu" signal) (44 "Edge.Cuts" user)) + (net 0 "") + (net 1 "GND") + (net 2 "VCC") + (module R_0805 (layer F.Cu) + (at 10.0 20.0 90) + (fp_text reference "R1" (at 0 0)) + (fp_text value "10k" (at 0 0)) + (pad "1" smd rect (at -1.0 0) (size 1.0 1.3) (layers "F.Cu") (net 1 "GND")) + (pad "2" smd rect (at 1.0 0) (size 1.0 1.3) (layers "F.Cu") (net 2 "VCC")) + ) +)""" + + +# KiCad 10 (format 20260206): no top-level net table, (footprint ...), +# (property "Reference" ...). A back-side footprint and a name-only +# (net "SIG") pad exercise the modern-dialect code paths. +MODERN = """(kicad_pcb (version 20260206) (generator pcbnew) + (layers + (0 "F.Cu" signal) + (1 "In1.Cu" signal) + (2 "In2.Cu" signal) + (31 "B.Cu" signal) + (44 "Edge.Cuts" user) + ) + (footprint "R_0805" (layer "F.Cu") + (at 10.0 20.0 90) + (property "Reference" "R1" (at 0 0 0)) + (property "Value" "10k" (at 0 0 0)) + (pad "1" smd rect (at -1.0 0) (size 1.0 1.3) (layers "F.Cu") (net 1 "GND")) + (pad "2" smd rect (at 1.0 0) (size 1.0 1.3) (layers "F.Cu") (net 2 "VCC")) + ) + (footprint "LED_0603" (layer "B.Cu") + (at 30.0 40.0 0) + (property "Reference" "D1" (at 0 0 0)) + (property "Value" "RED" (at 0 0 0)) + (pad "1" smd rect (at -0.8 0) (size 0.8 0.9) (layers "B.Cu") (net 1 "GND")) + (pad "2" smd rect (at 0.8 0) (size 0.8 0.9) (layers "B.Cu") (net "SIG")) + ) +)""" + + +def _load(tmp_path_factory, name, text): + path = tmp_path_factory.mktemp(name) / (name + ".kicad_pcb") + path.write_text(text, encoding="utf-8") + return KiCadFileParser().load_board(str(path)) + + +@pytest.fixture(scope="module") +def parsed(tmp_path_factory): + return _load(tmp_path_factory, "legacy", LEGACY) + + +@pytest.fixture(scope="module") +def modern(tmp_path_factory): + return _load(tmp_path_factory, "modern", MODERN) + + +def _by_ref(board, ref): + return next(c for c in board.components if c.reference == ref) + + +class TestLegacyDialect: + """KiCad <= 7 files: numbered net table, (module ...), fp_text reference.""" + + def test_module_footprints_supported(self, parsed): + assert len(parsed.components) == 1 + assert parsed.components[0].reference == "R1" + assert parsed.components[0].value == "10k" + + def test_numbered_nets_resolved_to_names(self, parsed): + assert {n.name for n in parsed.nets} == {"GND", "VCC"} + pads = parsed.components[0].pads + assert pads[0].net_id == "GND" + assert pads[1].net_id == "VCC" + + def test_rotated_pad_world_position(self, parsed): + # Footprint at (10, 20) rot 90 CCW in Y-down frame: + # pad 1 local (-1, 0) -> world (10, 21). + p1 = parsed.components[0].pads[0] + assert p1.position.x == pytest.approx(10.0, abs=1e-6) + assert p1.position.y == pytest.approx(21.0, abs=1e-6) + + def test_copper_count_excludes_edge_cuts(self, parsed): + assert parsed.layer_count == 2 + + +class TestKiCad10Dialect: + """KiCad 8-10 files: no net table, (footprint ...), property reference.""" + + def test_footprint_and_property_reference(self, modern): + assert {c.reference for c in modern.components} == {"R1", "D1"} + assert _by_ref(modern, "R1").value == "10k" + + def test_nets_synthesized_without_net_table(self, modern): + # No top-level (net ...) table: names come from pad references, + # including the name-only (net "SIG") form. + assert {n.name for n in modern.nets} == {"GND", "VCC", "SIG"} + + def test_copper_count_excludes_edge_cuts(self, modern): + assert modern.layer_count == 4 # F, In1, In2, B -- not Edge.Cuts + + def test_back_side_footprint_pad_layer(self, modern): + assert _by_ref(modern, "D1").pads[0].layer == "B.Cu" + + def test_back_side_pad_position_no_mirror(self, modern): + # D1 at (30, 40) rot 0 on B.Cu; pad 1 local (-0.8, 0) carries the + # offset already in the flipped frame -> world (29.2, 40), no mirror. + p1 = _by_ref(modern, "D1").pads[0] + assert p1.position.x == pytest.approx(29.2, abs=1e-6) + assert p1.position.y == pytest.approx(40.0, abs=1e-6) diff --git a/tests/test_sexpr.py b/tests/test_sexpr.py new file mode 100644 index 0000000..36196f2 --- /dev/null +++ b/tests/test_sexpr.py @@ -0,0 +1,124 @@ +"""Unit tests for the KiCad s-expression parser and node stripper.""" + +import pytest + +from orthoroute.infrastructure.kicad.sexpr import ( + SExprError, + atoms, + child, + children, + find_top_level_spans, + first_atom, + node_name, + parse, + strip_top_level_nodes, +) + + +class TestParse: + def test_simple_nested(self): + result = parse('(kicad_pcb (version 20260206) (net "GND"))') + assert result == [["kicad_pcb", ["version", "20260206"], ["net", "GND"]]] + + def test_quoted_string_escapes(self): + result = parse(r'(property "Ref\"quoted\"" "line\nbreak")') + assert result == [["property", 'Ref"quoted"', "line\nbreak"]] + + def test_atoms_with_special_chars(self): + result = parse("(at 12.0523 -9.6333 90)") + assert result == [["at", "12.0523", "-9.6333", "90"]] + + def test_empty_string_atom(self): + assert parse('(net 0 "")') == [["net", "0", ""]] + + def test_unbalanced_open_raises(self): + with pytest.raises(SExprError): + parse("(kicad_pcb (net") + + def test_unbalanced_close_raises(self): + with pytest.raises(SExprError): + parse("(net))") + + def test_unterminated_string_raises(self): + with pytest.raises(SExprError): + parse('(net "GND') + + def test_multiline_whitespace(self): + result = parse('(segment\n\t(start 1 2)\n\t(end 3 4)\n)') + assert result == [["segment", ["start", "1", "2"], ["end", "3", "4"]]] + + +class TestHelpers: + NODE = ["footprint", "R_0402", + ["property", "Reference", "R1"], + ["pad", "1", "smd", ["net", "GND"]], + ["pad", "2", "smd", ["net", "VCC"]]] + + def test_node_name(self): + assert node_name(self.NODE) == "footprint" + assert node_name("atom") is None + assert node_name([]) is None + + def test_children(self): + pads = children(self.NODE, "pad") + assert len(pads) == 2 + assert pads[0][1] == "1" + + def test_child_first_match(self): + assert child(self.NODE, "property") == ["property", "Reference", "R1"] + assert child(self.NODE, "missing") is None + + def test_atoms_and_first_atom(self): + assert atoms(self.NODE) == ["R_0402"] + assert first_atom(child(self.NODE, "pad")) == "1" + assert first_atom(None) is None + + +class TestStripper: + BOARD = ( + '(kicad_pcb\n' + '\t(version 20260206)\n' + '\t(segment\n\t\t(start 1 2)\n\t\t(net "GND")\n\t)\n' + '\t(zone\n\t\t(net "GND")\n\t)\n' + '\t(via\n\t\t(at 3 4)\n\t)\n' + ')\n' + ) + + def test_strip_removes_only_named_nodes(self): + stripped, removed = strip_top_level_nodes(self.BOARD, ("segment", "via")) + assert removed == 2 + assert "segment" not in stripped + assert "(via" not in stripped + assert "zone" in stripped + assert "(version 20260206)" in stripped + + def test_strip_is_idempotent(self): + once, n1 = strip_top_level_nodes(self.BOARD, ("segment", "via")) + twice, n2 = strip_top_level_nodes(once, ("segment", "via")) + assert n1 == 2 and n2 == 0 + assert once == twice + + def test_strip_leaves_no_blank_lines(self): + stripped, _ = strip_top_level_nodes(self.BOARD, ("segment", "via")) + assert "\n\n" not in stripped + + def test_stripped_output_still_parses(self): + stripped, _ = strip_top_level_nodes(self.BOARD, ("segment", "via")) + result = parse(stripped) + names = [node_name(c) for c in result[0][1:] if isinstance(c, list)] + assert names == ["version", "zone"] + + def test_deep_nodes_not_stripped(self): + # A (via ...) nested inside a footprint (e.g. padstack) must survive. + text = '(kicad_pcb\n\t(footprint "X"\n\t\t(via (at 1 1))\n\t)\n)\n' + stripped, removed = strip_top_level_nodes(text, ("via",)) + assert removed == 0 + assert stripped == text + + def test_paren_inside_string_ignored(self): + text = '(kicad_pcb\n\t(net ")(")\n\t(segment (net ")("))\n)\n' + spans = find_top_level_spans(text, ("segment",)) + assert len(spans) == 1 + stripped, removed = strip_top_level_nodes(text, ("segment",)) + assert removed == 1 + assert '(net ")(")' in stripped