Created
June 11, 2025 23:30
-
-
Save vishnubob/ebd4d6d54697d77eb8ceb05b8691bf8d to your computer and use it in GitHub Desktop.
BRD to SVG converter written by Claude Opus 4.0
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
""" | |
EAGLE BRD to SVG Converter | |
Converts Autodesk EAGLE board files (.brd) to Scalable Vector Graphics (.svg) format. | |
Supports XML-based BRD files (EAGLE v5.91.0 and later). | |
Author: BRD2SVG Converter | |
License: MIT | |
""" | |
import xml.etree.ElementTree as ET | |
import svgwrite | |
import argparse | |
import logging | |
import sys | |
from dataclasses import dataclass | |
from typing import Dict, List, Tuple, Optional, Any | |
from pathlib import Path | |
import math | |
# Layer definitions based on EAGLE standard layers | |
EAGLE_LAYERS = { | |
1: {'name': 'Top', 'color': '#CC0000', 'type': 'copper'}, | |
2: {'name': 'Route2', 'color': '#0000CC', 'type': 'copper'}, | |
3: {'name': 'Route3', 'color': '#00CC00', 'type': 'copper'}, | |
4: {'name': 'Route4', 'color': '#CCCC00', 'type': 'copper'}, | |
5: {'name': 'Route5', 'color': '#CC00CC', 'type': 'copper'}, | |
6: {'name': 'Route6', 'color': '#00CCCC', 'type': 'copper'}, | |
7: {'name': 'Route7', 'color': '#666666', 'type': 'copper'}, | |
8: {'name': 'Route8', 'color': '#999999', 'type': 'copper'}, | |
9: {'name': 'Route9', 'color': '#333333', 'type': 'copper'}, | |
10: {'name': 'Route10', 'color': '#996633', 'type': 'copper'}, | |
11: {'name': 'Route11', 'color': '#336699', 'type': 'copper'}, | |
12: {'name': 'Route12', 'color': '#669933', 'type': 'copper'}, | |
13: {'name': 'Route13', 'color': '#993366', 'type': 'copper'}, | |
14: {'name': 'Route14', 'color': '#663399', 'type': 'copper'}, | |
15: {'name': 'Route15', 'color': '#339966', 'type': 'copper'}, | |
16: {'name': 'Bottom', 'color': '#0000CC', 'type': 'copper'}, | |
17: {'name': 'Pads', 'color': '#00CC00', 'type': 'pads'}, | |
18: {'name': 'Vias', 'color': '#CCCC00', 'type': 'vias'}, | |
19: {'name': 'Unrouted', 'color': '#CC00CC', 'type': 'airwires'}, | |
20: {'name': 'Dimension', 'color': '#808080', 'type': 'dimension'}, | |
21: {'name': 'tPlace', 'color': '#FFFFCC', 'type': 'silkscreen'}, | |
22: {'name': 'bPlace', 'color': '#CCFFFF', 'type': 'silkscreen'}, | |
23: {'name': 'tOrigins', 'color': '#FFCCCC', 'type': 'origins'}, | |
24: {'name': 'bOrigins', 'color': '#CCCCFF', 'type': 'origins'}, | |
25: {'name': 'tNames', 'color': '#FFFFFF', 'type': 'names'}, | |
26: {'name': 'bNames', 'color': '#FFFFFF', 'type': 'names'}, | |
27: {'name': 'tValues', 'color': '#FFFFFF', 'type': 'values'}, | |
28: {'name': 'bValues', 'color': '#FFFFFF', 'type': 'values'}, | |
29: {'name': 'tStop', 'color': '#00CC00', 'type': 'soldermask'}, | |
30: {'name': 'bStop', 'color': '#00CC00', 'type': 'soldermask'}, | |
31: {'name': 'tCream', 'color': '#999999', 'type': 'paste'}, | |
32: {'name': 'bCream', 'color': '#999999', 'type': 'paste'}, | |
33: {'name': 'tFinish', 'color': '#CCCC00', 'type': 'finish'}, | |
34: {'name': 'bFinish', 'color': '#CCCC00', 'type': 'finish'}, | |
35: {'name': 'tGlue', 'color': '#CC0000', 'type': 'glue'}, | |
36: {'name': 'bGlue', 'color': '#CC0000', 'type': 'glue'}, | |
39: {'name': 'tKeepout', 'color': '#FF0000', 'type': 'keepout'}, | |
40: {'name': 'bKeepout', 'color': '#FF0000', 'type': 'keepout'}, | |
41: {'name': 'tRestrict', 'color': '#FF0000', 'type': 'restrict'}, | |
42: {'name': 'bRestrict', 'color': '#FF0000', 'type': 'restrict'}, | |
43: {'name': 'vRestrict', 'color': '#FF0000', 'type': 'restrict'}, | |
44: {'name': 'Drills', 'color': '#000000', 'type': 'drills'}, | |
45: {'name': 'Holes', 'color': '#000000', 'type': 'holes'}, | |
46: {'name': 'Milling', 'color': '#0000FF', 'type': 'milling'}, | |
47: {'name': 'Measures', 'color': '#808080', 'type': 'measures'}, | |
48: {'name': 'Document', 'color': '#808080', 'type': 'document'}, | |
49: {'name': 'Reference', 'color': '#808080', 'type': 'reference'}, | |
51: {'name': 'tDocu', 'color': '#808080', 'type': 'documentation'}, | |
52: {'name': 'bDocu', 'color': '#808080', 'type': 'documentation'}, | |
} | |
@dataclass | |
class Point: | |
"""Represents a 2D point.""" | |
x: float | |
y: float | |
@dataclass | |
class BoundingBox: | |
"""Represents a bounding box.""" | |
min_x: float | |
min_y: float | |
max_x: float | |
max_y: float | |
@property | |
def width(self) -> float: | |
return self.max_x - self.min_x | |
@property | |
def height(self) -> float: | |
return self.max_y - self.min_y | |
class BRDParser: | |
"""Parser for EAGLE BRD XML files.""" | |
def __init__(self, filepath: str): | |
self.filepath = filepath | |
self.tree = None | |
self.root = None | |
self.board = None | |
self.layers = {} | |
self.libraries = {} | |
self.elements = [] | |
self.signals = [] | |
self.plain_objects = [] | |
def parse(self) -> None: | |
"""Parse the BRD file.""" | |
try: | |
self.tree = ET.parse(self.filepath) | |
self.root = self.tree.getroot() | |
if self.root.tag != 'eagle': | |
raise ValueError("Not a valid EAGLE file") | |
# Find the drawing/board element | |
drawing = self.root.find('drawing') | |
if drawing is None: | |
raise ValueError("No drawing element found") | |
self.board = drawing.find('board') | |
if self.board is None: | |
raise ValueError("No board element found") | |
# Parse layers | |
self._parse_layers() | |
# Parse libraries | |
self._parse_libraries() | |
# Parse elements (components) | |
self._parse_elements() | |
# Parse signals (traces/wires) | |
self._parse_signals() | |
# Parse plain objects (lines, rectangles, circles, etc.) | |
self._parse_plain() | |
except ET.ParseError as e: | |
raise ValueError(f"Failed to parse XML: {e}") | |
def _parse_layers(self) -> None: | |
"""Parse layer definitions.""" | |
layers_elem = self.board.find('layers') | |
if layers_elem is not None: | |
for layer in layers_elem.findall('layer'): | |
layer_id = int(layer.get('number')) | |
self.layers[layer_id] = { | |
'name': layer.get('name'), | |
'color': layer.get('color', EAGLE_LAYERS.get(layer_id, {}).get('color', '#000000')), | |
'fill': layer.get('fill', '1'), | |
'visible': layer.get('visible', 'yes') == 'yes', | |
'active': layer.get('active', 'yes') == 'yes' | |
} | |
def _infer_layers(self) -> None: | |
"""Infer layers from elements in the board if no layers are explicitly defined.""" | |
used_layers = set() | |
# Check all elements for layer usage | |
def check_layer(obj): | |
if isinstance(obj, dict) and 'layer' in obj: | |
used_layers.add(int(obj['layer'])) | |
# Scan through all parsed data | |
for lib in self.libraries.values(): | |
for pkg in lib['packages'].values(): | |
for wire in pkg.get('wires', []): | |
check_layer(wire) | |
for text in pkg.get('texts', []): | |
check_layer(text) | |
for rect in pkg.get('rectangles', []): | |
check_layer(rect) | |
for circle in pkg.get('circles', []): | |
check_layer(circle) | |
for polygon in pkg.get('polygons', []): | |
check_layer(polygon) | |
for smd in pkg.get('smds', []): | |
check_layer(smd) | |
for signal in self.signals: | |
for wire in signal.get('wires', []): | |
check_layer(wire) | |
for polygon in signal.get('polygons', []): | |
check_layer(polygon) | |
if self.plain_objects: | |
for wire in self.plain_objects.get('wires', []): | |
check_layer(wire) | |
for text in self.plain_objects.get('texts', []): | |
check_layer(text) | |
for rect in self.plain_objects.get('rectangles', []): | |
check_layer(rect) | |
for circle in self.plain_objects.get('circles', []): | |
check_layer(circle) | |
for polygon in self.plain_objects.get('polygons', []): | |
check_layer(polygon) | |
# Add default layers for pads and vias | |
if self.elements: | |
used_layers.add(17) # Pads | |
if any(signal.get('vias') for signal in self.signals): | |
used_layers.add(18) # Vias | |
# Create layer entries for used layers | |
for layer_id in used_layers: | |
if layer_id not in self.layers and layer_id in EAGLE_LAYERS: | |
self.layers[layer_id] = { | |
'name': EAGLE_LAYERS[layer_id]['name'], | |
'color': EAGLE_LAYERS[layer_id]['color'], | |
'fill': '1', | |
'visible': True, | |
'active': True | |
} | |
def _parse_libraries(self) -> None: | |
"""Parse library definitions.""" | |
libraries_elem = self.board.find('libraries') | |
if libraries_elem is not None: | |
for library in libraries_elem.findall('library'): | |
lib_name = library.get('name') | |
self.libraries[lib_name] = { | |
'packages': self._parse_packages(library) | |
} | |
def _parse_packages(self, library) -> Dict: | |
"""Parse package definitions from a library.""" | |
packages = {} | |
packages_elem = library.find('packages') | |
if packages_elem is not None: | |
for package in packages_elem.findall('package'): | |
pkg_name = package.get('name') | |
packages[pkg_name] = { | |
'pads': self._parse_pads(package), | |
'smds': self._parse_smds(package), | |
'wires': self._parse_wires(package), | |
'texts': self._parse_texts(package), | |
'rectangles': self._parse_rectangles(package), | |
'circles': self._parse_circles(package), | |
'polygons': self._parse_polygons(package) | |
} | |
return packages | |
def _parse_pads(self, parent) -> List[Dict]: | |
"""Parse pad elements.""" | |
pads = [] | |
for pad in parent.findall('.//pad'): | |
pads.append({ | |
'name': pad.get('name'), | |
'x': float(pad.get('x', 0)), | |
'y': float(pad.get('y', 0)), | |
'drill': float(pad.get('drill', 0)), | |
'diameter': float(pad.get('diameter', 0)), | |
'shape': pad.get('shape', 'round'), | |
'rot': pad.get('rot', 'R0') | |
}) | |
return pads | |
def _parse_smds(self, parent) -> List[Dict]: | |
"""Parse SMD pad elements.""" | |
smds = [] | |
for smd in parent.findall('.//smd'): | |
smds.append({ | |
'name': smd.get('name'), | |
'x': float(smd.get('x', 0)), | |
'y': float(smd.get('y', 0)), | |
'dx': float(smd.get('dx', 0)), | |
'dy': float(smd.get('dy', 0)), | |
'layer': int(smd.get('layer', 1)), | |
'rot': smd.get('rot', 'R0'), | |
'roundness': int(smd.get('roundness', 0)) | |
}) | |
return smds | |
def _parse_wires(self, parent) -> List[Dict]: | |
"""Parse wire elements.""" | |
wires = [] | |
for wire in parent.findall('.//wire'): | |
wires.append({ | |
'x1': float(wire.get('x1', 0)), | |
'y1': float(wire.get('y1', 0)), | |
'x2': float(wire.get('x2', 0)), | |
'y2': float(wire.get('y2', 0)), | |
'width': float(wire.get('width', 0)), | |
'layer': int(wire.get('layer', 1)), | |
'curve': float(wire.get('curve', 0)) if wire.get('curve') else None | |
}) | |
return wires | |
def _parse_texts(self, parent) -> List[Dict]: | |
"""Parse text elements.""" | |
texts = [] | |
for text in parent.findall('.//text'): | |
texts.append({ | |
'x': float(text.get('x', 0)), | |
'y': float(text.get('y', 0)), | |
'size': float(text.get('size', 1.27)), | |
'layer': int(text.get('layer', 1)), | |
'font': text.get('font', 'proportional'), | |
'ratio': int(text.get('ratio', 8)), | |
'rot': text.get('rot', 'R0'), | |
'align': text.get('align', 'bottom-left'), | |
'text': text.text or '' | |
}) | |
return texts | |
def _parse_rectangles(self, parent) -> List[Dict]: | |
"""Parse rectangle elements.""" | |
rectangles = [] | |
for rect in parent.findall('.//rectangle'): | |
rectangles.append({ | |
'x1': float(rect.get('x1', 0)), | |
'y1': float(rect.get('y1', 0)), | |
'x2': float(rect.get('x2', 0)), | |
'y2': float(rect.get('y2', 0)), | |
'layer': int(rect.get('layer', 1)), | |
'rot': rect.get('rot', 'R0') | |
}) | |
return rectangles | |
def _parse_circles(self, parent) -> List[Dict]: | |
"""Parse circle elements.""" | |
circles = [] | |
for circle in parent.findall('.//circle'): | |
circles.append({ | |
'x': float(circle.get('x', 0)), | |
'y': float(circle.get('y', 0)), | |
'radius': float(circle.get('radius', 0)), | |
'width': float(circle.get('width', 0)), | |
'layer': int(circle.get('layer', 1)) | |
}) | |
return circles | |
def _parse_polygons(self, parent) -> List[Dict]: | |
"""Parse polygon elements.""" | |
polygons = [] | |
for polygon in parent.findall('.//polygon'): | |
vertices = [] | |
for vertex in polygon.findall('vertex'): | |
vertices.append({ | |
'x': float(vertex.get('x', 0)), | |
'y': float(vertex.get('y', 0)), | |
'curve': float(vertex.get('curve', 0)) if vertex.get('curve') else None | |
}) | |
polygons.append({ | |
'width': float(polygon.get('width', 0)), | |
'layer': int(polygon.get('layer', 1)), | |
'pour': polygon.get('pour', 'solid'), | |
'vertices': vertices | |
}) | |
return polygons | |
def _parse_elements(self) -> None: | |
"""Parse element (component) placements.""" | |
elements_elem = self.board.find('elements') | |
if elements_elem is not None: | |
for element in elements_elem.findall('element'): | |
self.elements.append({ | |
'name': element.get('name'), | |
'library': element.get('library'), | |
'package': element.get('package'), | |
'value': element.get('value', ''), | |
'x': float(element.get('x', 0)), | |
'y': float(element.get('y', 0)), | |
'locked': element.get('locked', 'no') == 'yes', | |
'rot': element.get('rot', 'R0'), | |
'attributes': self._parse_attributes(element) | |
}) | |
def _parse_attributes(self, element) -> Dict: | |
"""Parse element attributes.""" | |
attributes = {} | |
for attr in element.findall('attribute'): | |
attributes[attr.get('name')] = { | |
'value': attr.get('value'), | |
'x': float(attr.get('x', 0)), | |
'y': float(attr.get('y', 0)), | |
'size': float(attr.get('size', 1.27)), | |
'layer': int(attr.get('layer', 1)), | |
'display': attr.get('display', 'value'), | |
'rot': attr.get('rot', 'R0') | |
} | |
return attributes | |
def _parse_signals(self) -> None: | |
"""Parse signals (nets/traces).""" | |
signals_elem = self.board.find('signals') | |
if signals_elem is not None: | |
for signal in signals_elem.findall('signal'): | |
signal_data = { | |
'name': signal.get('name'), | |
'class': signal.get('class', '0'), | |
'airwireshidden': signal.get('airwireshidden', 'no') == 'yes', | |
'wires': self._parse_wires(signal), | |
'vias': self._parse_vias(signal), | |
'polygons': self._parse_polygons(signal), | |
'contactrefs': self._parse_contactrefs(signal) | |
} | |
self.signals.append(signal_data) | |
def _parse_vias(self, parent) -> List[Dict]: | |
"""Parse via elements.""" | |
vias = [] | |
for via in parent.findall('via'): | |
vias.append({ | |
'x': float(via.get('x', 0)), | |
'y': float(via.get('y', 0)), | |
'extent': via.get('extent', '1-16'), | |
'drill': float(via.get('drill', 0)), | |
'diameter': float(via.get('diameter', 0)), | |
'shape': via.get('shape', 'round'), | |
'alwaysstop': via.get('alwaysstop', 'no') == 'yes' | |
}) | |
return vias | |
def _parse_contactrefs(self, parent) -> List[Dict]: | |
"""Parse contact references.""" | |
contactrefs = [] | |
for ref in parent.findall('contactref'): | |
contactrefs.append({ | |
'element': ref.get('element'), | |
'pad': ref.get('pad'), | |
'route': ref.get('route', 'all') | |
}) | |
return contactrefs | |
def _parse_plain(self) -> None: | |
"""Parse plain objects (board outline, etc.).""" | |
plain_elem = self.board.find('plain') | |
if plain_elem is not None: | |
self.plain_objects = { | |
'wires': self._parse_wires(plain_elem), | |
'texts': self._parse_texts(plain_elem), | |
'rectangles': self._parse_rectangles(plain_elem), | |
'circles': self._parse_circles(plain_elem), | |
'polygons': self._parse_polygons(plain_elem), | |
'dimensions': self._parse_dimensions(plain_elem), | |
'holes': self._parse_holes(plain_elem) | |
} | |
def _parse_dimensions(self, parent) -> List[Dict]: | |
"""Parse dimension elements.""" | |
dimensions = [] | |
for dim in parent.findall('dimension'): | |
dimensions.append({ | |
'x1': float(dim.get('x1', 0)), | |
'y1': float(dim.get('y1', 0)), | |
'x2': float(dim.get('x2', 0)), | |
'y2': float(dim.get('y2', 0)), | |
'x3': float(dim.get('x3', 0)), | |
'y3': float(dim.get('y3', 0)), | |
'layer': int(dim.get('layer', 20)), | |
'dtype': dim.get('dtype', 'parallel'), | |
'width': float(dim.get('width', 0.13)), | |
'textsize': float(dim.get('textsize', 1.27)) | |
}) | |
return dimensions | |
def _parse_holes(self, parent) -> List[Dict]: | |
"""Parse hole elements.""" | |
holes = [] | |
for hole in parent.findall('hole'): | |
holes.append({ | |
'x': float(hole.get('x', 0)), | |
'y': float(hole.get('y', 0)), | |
'drill': float(hole.get('drill', 0)) | |
}) | |
return holes | |
def get_board_bounds(self) -> BoundingBox: | |
"""Calculate the board bounding box.""" | |
min_x = float('inf') | |
min_y = float('inf') | |
max_x = float('-inf') | |
max_y = float('-inf') | |
# Check plain wires (usually board outline) | |
if self.plain_objects and 'wires' in self.plain_objects: | |
for wire in self.plain_objects['wires']: | |
if wire['layer'] == 20: # Dimension layer | |
min_x = min(min_x, wire['x1'], wire['x2']) | |
min_y = min(min_y, wire['y1'], wire['y2']) | |
max_x = max(max_x, wire['x1'], wire['x2']) | |
max_y = max(max_y, wire['y1'], wire['y2']) | |
# If no dimension layer found, use all objects | |
if min_x == float('inf'): | |
# Include all elements | |
for elem in self.elements: | |
min_x = min(min_x, elem['x']) | |
min_y = min(min_y, elem['y']) | |
max_x = max(max_x, elem['x']) | |
max_y = max(max_y, elem['y']) | |
# Include all signals | |
for signal in self.signals: | |
for wire in signal['wires']: | |
min_x = min(min_x, wire['x1'], wire['x2']) | |
min_y = min(min_y, wire['y1'], wire['y2']) | |
max_x = max(max_x, wire['x1'], wire['x2']) | |
max_y = max(max_y, wire['y1'], wire['y2']) | |
# Include all plain objects | |
if self.plain_objects: | |
for wire in self.plain_objects.get('wires', []): | |
min_x = min(min_x, wire['x1'], wire['x2']) | |
min_y = min(min_y, wire['y1'], wire['y2']) | |
max_x = max(max_x, wire['x1'], wire['x2']) | |
max_y = max(max_y, wire['y1'], wire['y2']) | |
# If still no bounds found, use default | |
if min_x == float('inf'): | |
min_x, min_y, max_x, max_y = 0, 0, 100, 100 | |
# Add padding | |
padding = 5.0 | |
return BoundingBox( | |
min_x - padding, | |
min_y - padding, | |
max_x + padding, | |
max_y + padding | |
) | |
class SVGRenderer: | |
"""Renders parsed BRD data to SVG.""" | |
def __init__(self, parser: BRDParser, scale: float = 10.0): | |
self.parser = parser | |
self.scale = scale # Scale factor for converting mm to pixels | |
self.svg = None | |
self.bounds = None | |
self.layer_groups = {} | |
def render(self, output_file: str, layers: Optional[List[int]] = None) -> None: | |
"""Render the BRD to SVG.""" | |
# Get board bounds | |
self.bounds = self.parser.get_board_bounds() | |
# Create SVG | |
width = max(self.bounds.width * self.scale, 100) # Minimum size | |
height = max(self.bounds.height * self.scale, 100) # Minimum size | |
self.svg = svgwrite.Drawing( | |
output_file, | |
size=(f'{width}px', f'{height}px'), | |
viewBox=f'0 0 {width} {height}' | |
) | |
# Add styles | |
self._add_styles() | |
# Create layer groups | |
self._create_layer_groups(layers) | |
# Render board outline | |
self._render_board_outline() | |
# Render plain objects | |
self._render_plain_objects() | |
# Render elements | |
self._render_elements() | |
# Render signals | |
self._render_signals() | |
# Save SVG | |
self.svg.save() | |
def _add_styles(self) -> None: | |
"""Add CSS styles to SVG.""" | |
styles = """ | |
.wire { fill: none; } | |
.pad { fill: #00CC00; } | |
.smd { fill: #CC0000; } | |
.via { fill: #CCCC00; } | |
.hole { fill: #000000; } | |
.text { font-family: Arial, sans-serif; } | |
""" | |
self.svg.defs.add(self.svg.style(styles)) | |
def _create_layer_groups(self, layers: Optional[List[int]]) -> None: | |
"""Create groups for each layer.""" | |
if layers is None: | |
# Use parsed layers or fall back to common EAGLE layers | |
if self.parser.layers: | |
layers = list(self.parser.layers.keys()) | |
else: | |
# Use common layers if none defined | |
layers = [1, 16, 17, 18, 20, 21, 22, 25, 26, 27, 28, 29, 30, 45] | |
for layer_id in layers: | |
# Get layer info from parsed layers or defaults | |
if layer_id in self.parser.layers: | |
layer_info = self.parser.layers[layer_id] | |
elif layer_id in EAGLE_LAYERS: | |
layer_info = EAGLE_LAYERS[layer_id] | |
else: | |
layer_info = {'name': f'Layer{layer_id}', 'color': '#000000', 'visible': True} | |
group = self.svg.g( | |
id=f"layer{layer_id}", | |
class_=f"layer-{layer_info['name']}", | |
opacity=1.0 if layer_info.get('visible', True) else 0.5 | |
) | |
self.svg.add(group) | |
self.layer_groups[layer_id] = group | |
def _transform_point(self, x: float, y: float) -> Tuple[float, float]: | |
"""Transform EAGLE coordinates to SVG coordinates.""" | |
svg_x = (x - self.bounds.min_x) * self.scale | |
svg_y = (self.bounds.max_y - y) * self.scale # Flip Y axis | |
return svg_x, svg_y | |
def _parse_rotation(self, rot: str) -> float: | |
"""Parse rotation string (e.g., 'R90', 'MR180') to degrees.""" | |
if not rot: | |
return 0 | |
# Remove mirror flag if present | |
if rot.startswith('M'): | |
rot = rot[1:] | |
# Extract rotation angle | |
if rot.startswith('R'): | |
try: | |
return float(rot[1:]) | |
except ValueError: | |
return 0 | |
return 0 | |
def _render_board_outline(self) -> None: | |
"""Render the board outline.""" | |
if not self.parser.plain_objects or 'wires' not in self.parser.plain_objects: | |
return | |
# Find dimension layer wires | |
outline_wires = [w for w in self.parser.plain_objects['wires'] if w['layer'] == 20] | |
if outline_wires and 20 in self.layer_groups: | |
group = self.layer_groups[20] | |
for wire in outline_wires: | |
self._render_wire(wire, group) | |
def _render_wire(self, wire: Dict, group: Any) -> None: | |
"""Render a wire element.""" | |
x1, y1 = self._transform_point(wire['x1'], wire['y1']) | |
x2, y2 = self._transform_point(wire['x2'], wire['y2']) | |
layer_id = wire['layer'] | |
color = self._get_layer_color(layer_id) | |
if wire.get('curve') is not None and wire['curve'] != 0: | |
# Curved wire (arc) | |
path = self._create_arc_path(x1, y1, x2, y2, wire['curve']) | |
group.add(self.svg.path( | |
d=path, | |
stroke=color, | |
stroke_width=wire['width'] * self.scale, | |
class_='wire curved' | |
)) | |
else: | |
# Straight wire | |
group.add(self.svg.line( | |
start=(x1, y1), | |
end=(x2, y2), | |
stroke=color, | |
stroke_width=wire['width'] * self.scale, | |
class_='wire' | |
)) | |
def _create_arc_path(self, x1: float, y1: float, x2: float, y2: float, curve: float) -> str: | |
"""Create SVG path for curved wire.""" | |
# Calculate arc parameters | |
dx = x2 - x1 | |
dy = y2 - y1 | |
chord_length = math.sqrt(dx*dx + dy*dy) | |
if chord_length == 0: | |
return f'M {x1},{y1} L {x2},{y2}' | |
# Convert curve angle to radians | |
angle_rad = math.radians(curve) | |
# Calculate radius | |
radius = chord_length / (2 * math.sin(abs(angle_rad) / 2)) | |
# Large arc flag | |
large_arc = 1 if abs(curve) > 180 else 0 | |
# Sweep flag | |
sweep = 1 if curve > 0 else 0 | |
return f'M {x1},{y1} A {radius},{radius} 0 {large_arc},{sweep} {x2},{y2}' | |
def _render_plain_objects(self) -> None: | |
"""Render plain objects.""" | |
if not self.parser.plain_objects: | |
return | |
# Render texts | |
for text in self.parser.plain_objects.get('texts', []): | |
self._render_text(text) | |
# Render rectangles | |
for rect in self.parser.plain_objects.get('rectangles', []): | |
self._render_rectangle(rect) | |
# Render circles | |
for circle in self.parser.plain_objects.get('circles', []): | |
self._render_circle(circle) | |
# Render polygons | |
for polygon in self.parser.plain_objects.get('polygons', []): | |
self._render_polygon(polygon) | |
# Render holes | |
for hole in self.parser.plain_objects.get('holes', []): | |
self._render_hole(hole) | |
def _render_text(self, text: Dict) -> None: | |
"""Render a text element.""" | |
layer_id = text['layer'] | |
if layer_id not in self.layer_groups: | |
return | |
x, y = self._transform_point(text['x'], text['y']) | |
group = self.layer_groups[layer_id] | |
color = self._get_layer_color(layer_id) | |
# Calculate rotation | |
rotation = self._parse_rotation(text.get('rot', 'R0')) | |
text_elem = self.svg.text( | |
text['text'], | |
insert=(x, y), | |
fill=color, | |
font_size=text['size'] * self.scale, | |
class_='text', | |
text_anchor='middle' if 'center' in text.get('align', '') else 'start' | |
) | |
if rotation != 0: | |
text_elem.rotate(rotation, center=(x, y)) | |
group.add(text_elem) | |
def _render_rectangle(self, rect: Dict) -> None: | |
"""Render a rectangle element.""" | |
layer_id = rect['layer'] | |
if layer_id not in self.layer_groups: | |
return | |
x1, y1 = self._transform_point(rect['x1'], rect['y1']) | |
x2, y2 = self._transform_point(rect['x2'], rect['y2']) | |
# Ensure correct orientation | |
x = min(x1, x2) | |
y = min(y1, y2) | |
width = abs(x2 - x1) | |
height = abs(y2 - y1) | |
group = self.layer_groups[layer_id] | |
color = self._get_layer_color(layer_id) | |
rect_elem = self.svg.rect( | |
insert=(x, y), | |
size=(width, height), | |
fill=color, | |
fill_opacity=0.5, | |
stroke=color, | |
stroke_width=0.1 * self.scale | |
) | |
# Handle rotation | |
rotation = self._parse_rotation(rect.get('rot', 'R0')) | |
if rotation != 0: | |
cx = x + width / 2 | |
cy = y + height / 2 | |
rect_elem.rotate(rotation, center=(cx, cy)) | |
group.add(rect_elem) | |
def _render_circle(self, circle: Dict) -> None: | |
"""Render a circle element.""" | |
layer_id = circle['layer'] | |
if layer_id not in self.layer_groups: | |
return | |
x, y = self._transform_point(circle['x'], circle['y']) | |
group = self.layer_groups[layer_id] | |
color = self._get_layer_color(layer_id) | |
if circle['width'] > 0: | |
# Circle outline | |
circle_elem = self.svg.circle( | |
center=(x, y), | |
r=circle['radius'] * self.scale, | |
fill='none', | |
stroke=color, | |
stroke_width=circle['width'] * self.scale | |
) | |
else: | |
# Filled circle | |
circle_elem = self.svg.circle( | |
center=(x, y), | |
r=circle['radius'] * self.scale, | |
fill=color | |
) | |
group.add(circle_elem) | |
def _render_polygon(self, polygon: Dict) -> None: | |
"""Render a polygon element.""" | |
layer_id = polygon['layer'] | |
if layer_id not in self.layer_groups: | |
return | |
group = self.layer_groups[layer_id] | |
color = self._get_layer_color(layer_id) | |
# Build path from vertices | |
path_data = [] | |
for i, vertex in enumerate(polygon['vertices']): | |
x, y = self._transform_point(vertex['x'], vertex['y']) | |
if i == 0: | |
path_data.append(f'M {x},{y}') | |
else: | |
if vertex.get('curve') is not None and vertex['curve'] != 0: | |
# Add arc | |
prev_vertex = polygon['vertices'][i-1] | |
prev_x, prev_y = self._transform_point(prev_vertex['x'], prev_vertex['y']) | |
arc_path = self._create_arc_path(prev_x, prev_y, x, y, vertex['curve']) | |
# Extract arc portion from path | |
arc_part = arc_path.split(' ', 2)[2] | |
path_data.append(f'A {arc_part}') | |
else: | |
path_data.append(f'L {x},{y}') | |
path_data.append('Z') # Close path | |
poly_elem = self.svg.path( | |
d=' '.join(path_data), | |
fill=color if polygon['pour'] == 'solid' else 'none', | |
fill_opacity=0.7 if polygon['pour'] == 'solid' else 0, | |
stroke=color, | |
stroke_width=polygon['width'] * self.scale | |
) | |
group.add(poly_elem) | |
def _render_hole(self, hole: Dict) -> None: | |
"""Render a hole element.""" | |
if 45 not in self.layer_groups: # Holes layer | |
return | |
x, y = self._transform_point(hole['x'], hole['y']) | |
group = self.layer_groups[45] | |
hole_elem = self.svg.circle( | |
center=(x, y), | |
r=hole['drill'] * self.scale / 2, | |
fill='black', | |
class_='hole' | |
) | |
group.add(hole_elem) | |
def _render_elements(self) -> None: | |
"""Render component elements.""" | |
for element in self.parser.elements: | |
self._render_element(element) | |
def _render_element(self, element: Dict) -> None: | |
"""Render a single component element.""" | |
# Get package definition | |
library = self.parser.libraries.get(element['library'], {}) | |
package = library.get('packages', {}).get(element['package'], {}) | |
if not package: | |
logging.warning(f"Package {element['package']} not found for element {element['name']}") | |
return | |
# Transform to element position | |
elem_x = element['x'] | |
elem_y = element['y'] | |
rotation = self._parse_rotation(element.get('rot', 'R0')) | |
# Render package components | |
# Pads | |
for pad in package.get('pads', []): | |
self._render_pad(pad, elem_x, elem_y, rotation) | |
# SMDs | |
for smd in package.get('smds', []): | |
self._render_smd(smd, elem_x, elem_y, rotation) | |
# Package wires | |
for wire in package.get('wires', []): | |
self._render_package_wire(wire, elem_x, elem_y, rotation) | |
# Package texts | |
for text in package.get('texts', []): | |
if text['text'] == '>NAME': | |
text = text.copy() | |
text['text'] = element['name'] | |
elif text['text'] == '>VALUE': | |
text = text.copy() | |
text['text'] = element.get('value', '') | |
if text['text']: | |
self._render_package_text(text, elem_x, elem_y, rotation) | |
def _rotate_point(self, x: float, y: float, angle: float, cx: float = 0, cy: float = 0) -> Tuple[float, float]: | |
"""Rotate a point around a center.""" | |
if angle == 0: | |
return x, y | |
angle_rad = math.radians(angle) | |
cos_a = math.cos(angle_rad) | |
sin_a = math.sin(angle_rad) | |
# Translate to origin | |
x -= cx | |
y -= cy | |
# Rotate | |
new_x = x * cos_a - y * sin_a | |
new_y = x * sin_a + y * cos_a | |
# Translate back | |
new_x += cx | |
new_y += cy | |
return new_x, new_y | |
def _render_pad(self, pad: Dict, elem_x: float, elem_y: float, rotation: float) -> None: | |
"""Render a through-hole pad.""" | |
if 17 not in self.layer_groups: # Pads layer | |
return | |
# Calculate pad position | |
pad_x, pad_y = self._rotate_point(pad['x'], pad['y'], rotation) | |
x, y = self._transform_point(elem_x + pad_x, elem_y + pad_y) | |
group = self.layer_groups[17] | |
# Pad shape | |
if pad['shape'] == 'square': | |
size = pad['diameter'] * self.scale | |
pad_elem = self.svg.rect( | |
insert=(x - size/2, y - size/2), | |
size=(size, size), | |
fill='#00CC00', | |
class_='pad square' | |
) | |
elif pad['shape'] == 'octagon': | |
# Approximate octagon with circle | |
pad_elem = self.svg.circle( | |
center=(x, y), | |
r=pad['diameter'] * self.scale / 2, | |
fill='#00CC00', | |
class_='pad octagon' | |
) | |
else: # round | |
pad_elem = self.svg.circle( | |
center=(x, y), | |
r=pad['diameter'] * self.scale / 2, | |
fill='#00CC00', | |
class_='pad' | |
) | |
group.add(pad_elem) | |
# Drill hole | |
if pad['drill'] > 0: | |
hole = self.svg.circle( | |
center=(x, y), | |
r=pad['drill'] * self.scale / 2, | |
fill='black', | |
class_='drill' | |
) | |
group.add(hole) | |
def _render_smd(self, smd: Dict, elem_x: float, elem_y: float, rotation: float) -> None: | |
"""Render an SMD pad.""" | |
layer_id = smd['layer'] | |
if layer_id not in self.layer_groups: | |
return | |
# Calculate SMD position | |
smd_x, smd_y = self._rotate_point(smd['x'], smd['y'], rotation) | |
x, y = self._transform_point(elem_x + smd_x, elem_y + smd_y) | |
# Calculate dimensions | |
width = smd['dx'] * self.scale | |
height = smd['dy'] * self.scale | |
group = self.layer_groups[layer_id] | |
color = '#CC0000' if layer_id == 1 else '#0000CC' # Red for top, blue for bottom | |
# Create rectangle with optional rounded corners | |
if smd['roundness'] > 0: | |
rx = width * smd['roundness'] / 200 | |
ry = height * smd['roundness'] / 200 | |
smd_elem = self.svg.rect( | |
insert=(x - width/2, y - height/2), | |
size=(width, height), | |
fill=color, | |
rx=rx, | |
ry=ry, | |
class_='smd' | |
) | |
else: | |
smd_elem = self.svg.rect( | |
insert=(x - width/2, y - height/2), | |
size=(width, height), | |
fill=color, | |
class_='smd' | |
) | |
# Apply rotation | |
total_rotation = rotation + self._parse_rotation(smd.get('rot', 'R0')) | |
if total_rotation != 0: | |
smd_elem.rotate(total_rotation, center=(x, y)) | |
group.add(smd_elem) | |
def _render_package_wire(self, wire: Dict, elem_x: float, elem_y: float, rotation: float) -> None: | |
"""Render a wire from a package.""" | |
layer_id = wire['layer'] | |
if layer_id not in self.layer_groups: | |
return | |
# Transform wire endpoints | |
x1, y1 = self._rotate_point(wire['x1'], wire['y1'], rotation) | |
x2, y2 = self._rotate_point(wire['x2'], wire['y2'], rotation) | |
wire_copy = wire.copy() | |
wire_copy['x1'] = elem_x + x1 | |
wire_copy['y1'] = elem_y + y1 | |
wire_copy['x2'] = elem_x + x2 | |
wire_copy['y2'] = elem_y + y2 | |
self._render_wire(wire_copy, self.layer_groups[layer_id]) | |
def _render_package_text(self, text: Dict, elem_x: float, elem_y: float, rotation: float) -> None: | |
"""Render text from a package.""" | |
# Transform text position | |
text_x, text_y = self._rotate_point(text['x'], text['y'], rotation) | |
text_copy = text.copy() | |
text_copy['x'] = elem_x + text_x | |
text_copy['y'] = elem_y + text_y | |
# Combine rotations | |
text_rotation = self._parse_rotation(text.get('rot', 'R0')) | |
text_copy['rot'] = f'R{(rotation + text_rotation) % 360}' | |
self._render_text(text_copy) | |
def _render_signals(self) -> None: | |
"""Render signal traces.""" | |
for signal in self.parser.signals: | |
# Render wires | |
for wire in signal['wires']: | |
layer_id = wire['layer'] | |
if layer_id in self.layer_groups: | |
self._render_wire(wire, self.layer_groups[layer_id]) | |
# Render vias | |
for via in signal['vias']: | |
self._render_via(via) | |
# Render polygons | |
for polygon in signal['polygons']: | |
self._render_polygon(polygon) | |
def _render_via(self, via: Dict) -> None: | |
"""Render a via.""" | |
if 18 not in self.layer_groups: # Vias layer | |
return | |
x, y = self._transform_point(via['x'], via['y']) | |
group = self.layer_groups[18] | |
# Via pad | |
via_elem = self.svg.circle( | |
center=(x, y), | |
r=via['diameter'] * self.scale / 2, | |
fill='#CCCC00', | |
class_='via' | |
) | |
group.add(via_elem) | |
# Drill hole | |
if via['drill'] > 0: | |
hole = self.svg.circle( | |
center=(x, y), | |
r=via['drill'] * self.scale / 2, | |
fill='black', | |
class_='drill' | |
) | |
group.add(hole) | |
def _get_layer_color(self, layer_id: int) -> str: | |
"""Get color for a layer.""" | |
if layer_id in self.parser.layers: | |
return self.parser.layers[layer_id].get('color', '#000000') | |
elif layer_id in EAGLE_LAYERS: | |
return EAGLE_LAYERS[layer_id]['color'] | |
else: | |
return '#000000' | |
def main(): | |
"""Main entry point.""" | |
parser = argparse.ArgumentParser( | |
description='Convert EAGLE BRD files to SVG format', | |
formatter_class=argparse.RawDescriptionHelpFormatter, | |
epilog=''' | |
Examples: | |
%(prog)s board.brd # Convert to board.svg | |
%(prog)s board.brd -o output.svg # Specify output file | |
%(prog)s board.brd -l 1,16,17,21 # Only render specific layers | |
%(prog)s board.brd -s 20 # Set scale factor | |
%(prog)s board.brd -v # Enable verbose logging | |
''' | |
) | |
parser.add_argument('input', help='Input BRD file') | |
parser.add_argument('-o', '--output', help='Output SVG file (default: input.svg)') | |
parser.add_argument('-l', '--layers', help='Comma-separated list of layer numbers to render') | |
parser.add_argument('-s', '--scale', type=float, default=10.0, | |
help='Scale factor (default: 10.0)') | |
parser.add_argument('-v', '--verbose', action='store_true', | |
help='Enable verbose logging') | |
args = parser.parse_args() | |
# Setup logging | |
log_level = logging.DEBUG if args.verbose else logging.INFO | |
logging.basicConfig( | |
level=log_level, | |
format='%(asctime)s - %(levelname)s - %(message)s' | |
) | |
# Determine output file | |
if args.output: | |
output_file = args.output | |
else: | |
output_file = Path(args.input).with_suffix('.svg') | |
# Parse layers | |
layers = None | |
if args.layers: | |
try: | |
layers = [int(x.strip()) for x in args.layers.split(',')] | |
except ValueError: | |
logging.error("Invalid layer specification. Use comma-separated integers.") | |
sys.exit(1) | |
try: | |
# Parse BRD file | |
logging.info(f"Parsing BRD file: {args.input}") | |
brd_parser = BRDParser(args.input) | |
brd_parser.parse() | |
logging.info(f"Found {len(brd_parser.elements)} elements") | |
logging.info(f"Found {len(brd_parser.signals)} signals") | |
logging.info(f"Found {len(brd_parser.layers)} layers") | |
# Render to SVG | |
logging.info(f"Rendering to SVG: {output_file}") | |
renderer = SVGRenderer(brd_parser, scale=args.scale) | |
renderer.render(str(output_file), layers=layers) | |
logging.info("Conversion complete!") | |
except Exception as e: | |
logging.error(f"Error: {e}") | |
if args.verbose: | |
import traceback | |
traceback.print_exc() | |
sys.exit(1) | |
if __name__ == '__main__': | |
main() | |
# Save as brd2svg.py for command-line usage |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment