Skip to content

Instantly share code, notes, and snippets.

@vishnubob
Created June 11, 2025 23:30
Show Gist options
  • Save vishnubob/ebd4d6d54697d77eb8ceb05b8691bf8d to your computer and use it in GitHub Desktop.
Save vishnubob/ebd4d6d54697d77eb8ceb05b8691bf8d to your computer and use it in GitHub Desktop.
BRD to SVG converter written by Claude Opus 4.0
#!/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