mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-24 13:54:57 +00:00
747 lines
32 KiB
Python
747 lines
32 KiB
Python
from .Component import Component
|
|
from .GerberExporter import export_gerbers
|
|
from .drc import check_board
|
|
from .rules import LAYER_SERVICE_RULES
|
|
from .Pin import Pin
|
|
from .Via import Via
|
|
from .Zone import Zone
|
|
from .svgtools import render_text_ttf, render_svg_element
|
|
from shapely.geometry import Polygon, box
|
|
import xml.etree.ElementTree as ET
|
|
import math
|
|
import os
|
|
import re
|
|
|
|
TOP_SILK = "GTO"
|
|
BOTTOM_SILK = "GBO"
|
|
|
|
import datetime
|
|
import pprint
|
|
def log(msg, obj=None):
|
|
with open('boardforge.log', 'a', encoding='utf-8') as f:
|
|
f.write(f"{datetime.datetime.now().isoformat()} {msg}\n")
|
|
if obj is not None:
|
|
f.write(pprint.pformat(obj) + "\n")
|
|
|
|
class Board:
|
|
|
|
def __init__(self, name="Board", width=100, height=80, layer_service="2 Layer"):
|
|
log('ENTER __init__', locals())
|
|
log("Board __init__ called")
|
|
self.name = name
|
|
self.width = width
|
|
self.height = height
|
|
self.components = []
|
|
# Manufacturing ruleset to enforce during design rule checks
|
|
self.layer_service = layer_service
|
|
# Map reference designators to components for quick lookup
|
|
self._ref_map = {}
|
|
# Containers for vias, filled zones, and non-plated holes
|
|
self.vias = []
|
|
self.zones = []
|
|
self.holes = []
|
|
self.outline_geom = box(0, 0, width, height)
|
|
self.layers = {"GTO": [], "GBO": []}
|
|
self._svg_text_calls = []
|
|
self._svg_graphics_calls = []
|
|
log('EXIT __init__', {'self': self.__dict__})
|
|
|
|
@staticmethod
|
|
def _arc_params(start, end, radius, sweep):
|
|
"""Return centre coordinates and angles for an arc."""
|
|
x1, y1 = start
|
|
x2, y2 = end
|
|
dx = x2 - x1
|
|
dy = y2 - y1
|
|
chord = math.hypot(dx, dy)
|
|
if chord == 0 or 2 * radius < chord:
|
|
return None
|
|
midx = (x1 + x2) / 2
|
|
midy = (y1 + y2) / 2
|
|
h = math.sqrt(max(radius * radius - (chord / 2) ** 2, 0))
|
|
ux = -dy / chord
|
|
uy = dx / chord
|
|
if sweep > 0:
|
|
cx = midx + ux * h
|
|
cy = midy + uy * h
|
|
else:
|
|
cx = midx - ux * h
|
|
cy = midy - uy * h
|
|
start_ang = math.degrees(math.atan2(y1 - cy, x1 - cx))
|
|
end_ang = start_ang + sweep
|
|
return (cx, cy, start_ang, end_ang)
|
|
|
|
def set_layer_stack(self, layers):
|
|
log('ENTER set_layer_stack', locals())
|
|
log("set_layer_stack called")
|
|
for layer in layers:
|
|
if layer not in self.layers:
|
|
self.layers[layer] = []
|
|
log('EXIT set_layer_stack', {'self': self.__dict__})
|
|
|
|
def add_component(self, type, ref, at, rotation=0):
|
|
log('ENTER add_component', locals())
|
|
log("add_component called")
|
|
comp = Component(ref, type, at, rotation)
|
|
self.components.append(comp)
|
|
self._ref_map[ref] = comp
|
|
log('EXIT add_component', {'self': self.__dict__})
|
|
return comp
|
|
|
|
def trace(self, pin1, pin2, layer="GTL", width=1.0):
|
|
"""Add a simple straight trace between two pins."""
|
|
self.layers[layer].append(("TRACE", pin1, pin2, width))
|
|
|
|
def _find_pin(self, ref_pin):
|
|
"""Lookup a Pin object given a string like "U1:VCC"."""
|
|
if isinstance(ref_pin, Pin):
|
|
return ref_pin
|
|
if not isinstance(ref_pin, str) or ":" not in ref_pin:
|
|
raise ValueError("pin reference must be Pin or 'REF:PIN'")
|
|
ref, pin_name = ref_pin.split(":", 1)
|
|
comp = self._ref_map.get(ref)
|
|
if comp is None:
|
|
raise ValueError(f"Component {ref} not found")
|
|
pin = comp.pin(pin_name)
|
|
if pin is None:
|
|
raise ValueError(f"Pin {pin_name} not found on {ref}")
|
|
return pin
|
|
|
|
def route_trace(self, start, end, layer="GTL", width=1.0, bends=None):
|
|
"""Route a trace between two pins, optionally with bends."""
|
|
pts = [self._find_pin(start)]
|
|
if bends:
|
|
pts.extend(bends)
|
|
pts.append(self._find_pin(end))
|
|
self.trace_path(pts, layer=layer, width=width)
|
|
|
|
def trace_path(self, points, layer="GTL", width=1.0):
|
|
"""Add a trace with optional arcs or Bezier curves.
|
|
|
|
Parameters
|
|
----------
|
|
points : list
|
|
Sequence defining the path. Items may be coordinate tuples/objects
|
|
or dictionaries specifying an ``{"arc": (radius, sweep)}`` or
|
|
``{"bezier": ((cx1, cy1), (cx2, cy2))}`` between the previous and
|
|
next coordinate.
|
|
layer : str
|
|
Board layer to place the trace on. Defaults to ``"GTL"``.
|
|
width : float
|
|
Trace width in mm.
|
|
"""
|
|
|
|
def _get_xy(item):
|
|
if hasattr(item, "x") and hasattr(item, "y"):
|
|
return (item.x, item.y)
|
|
return (item[0], item[1])
|
|
|
|
segments = []
|
|
if not points:
|
|
return
|
|
|
|
prev = _get_xy(points[0])
|
|
i = 1
|
|
while i < len(points):
|
|
item = points[i]
|
|
if isinstance(item, dict) and "arc" in item:
|
|
radius, sweep = item["arc"]
|
|
i += 1
|
|
end = _get_xy(points[i])
|
|
segments.append(("ARC", prev, end, radius, sweep))
|
|
prev = end
|
|
i += 1
|
|
continue
|
|
if isinstance(item, dict) and "bezier" in item:
|
|
ctrl1, ctrl2 = item["bezier"]
|
|
i += 1
|
|
end = _get_xy(points[i])
|
|
segments.append(("BEZIER", prev, ctrl1, ctrl2, end))
|
|
prev = end
|
|
i += 1
|
|
continue
|
|
end = _get_xy(item)
|
|
segments.append(("LINE", prev, end))
|
|
prev = end
|
|
i += 1
|
|
|
|
if segments:
|
|
self.layers[layer].append(("TRACE_PATH", segments, width))
|
|
|
|
def add_via(self, x, y, from_layer="GTL", to_layer="GBL", diameter=0.6, hole=0.3):
|
|
"""Create a via connecting two layers."""
|
|
via = Via(x, y, from_layer, to_layer, diameter=diameter, hole=hole)
|
|
self.vias.append(via)
|
|
return via
|
|
|
|
def add_filled_zone(self, net=None, layer="GBL"):
|
|
"""Store information about a filled copper zone."""
|
|
zone = Zone(net, layer)
|
|
self.zones.append(zone)
|
|
return zone
|
|
|
|
def outline(self, points):
|
|
"""Define the board outline using a sequence of ``(x, y)`` points."""
|
|
self.outline_geom = Polygon(points)
|
|
return self.outline_geom
|
|
|
|
def chamfer_outline(self, width, height, chamfer):
|
|
"""Create a chamfered rectangular outline.
|
|
|
|
Parameters
|
|
----------
|
|
width : float
|
|
Overall board width.
|
|
height : float
|
|
Overall board height.
|
|
chamfer : float
|
|
Offset distance from each corner for the chamfer.
|
|
"""
|
|
self.width = width
|
|
self.height = height
|
|
pts = [
|
|
(chamfer, 0),
|
|
(width - chamfer, 0),
|
|
(width, chamfer),
|
|
(width, height - chamfer),
|
|
(width - chamfer, height),
|
|
(chamfer, height),
|
|
(0, height - chamfer),
|
|
(0, chamfer),
|
|
]
|
|
self.outline_geom = Polygon(pts)
|
|
return self.outline_geom
|
|
|
|
def oversize(self, margin):
|
|
"""Expand the stored outline by ``margin`` mm."""
|
|
if self.outline_geom is None:
|
|
self.outline_geom = box(0, 0, self.width, self.height)
|
|
self.outline_geom = self.outline_geom.buffer(margin)
|
|
return self.outline_geom
|
|
|
|
def fill(self, points, layer="GBL", net=None):
|
|
"""Create a filled copper polygon on the specified layer."""
|
|
poly = Polygon(points)
|
|
zone = Zone(net, layer, geometry=poly)
|
|
self.zones.append(zone)
|
|
cmds = []
|
|
for i, (x, y) in enumerate(poly.exterior.coords):
|
|
code = "D02*" if i == 0 else "D01*"
|
|
cmds.append(f"X{int(x*1000):07d}Y{int(y*1000):07d}{code}")
|
|
self.layers.setdefault(layer, []).extend(cmds)
|
|
return zone
|
|
|
|
def hole(self, xy, diameter, annulus=None):
|
|
"""Record a non-plated hole location.
|
|
|
|
Parameters
|
|
----------
|
|
xy : tuple
|
|
``(x, y)`` coordinates of the hole centre.
|
|
diameter : float
|
|
Diameter of the hole in mm.
|
|
annulus : float, optional
|
|
Optional copper ring around the hole.
|
|
"""
|
|
self.holes.append((xy[0], xy[1], diameter, annulus))
|
|
return (xy[0], xy[1], diameter, annulus)
|
|
|
|
def add_svg_graphic(self, svg_path, layer, scale=1.0, at=(0, 0)):
|
|
log('ENTER add_svg_graphic', locals())
|
|
log("add_svg_graphic called")
|
|
self._svg_graphics_calls.append((svg_path, layer, scale, at))
|
|
try:
|
|
tree = ET.parse(svg_path)
|
|
root = tree.getroot()
|
|
for el in root.iter():
|
|
cmds = render_svg_element(el, scale, *at)
|
|
self.layers[layer].extend(cmds)
|
|
except Exception as e:
|
|
print(f"Error adding SVG graphic {svg_path}: {e}")
|
|
log('EXIT add_svg_graphic', {'self': self.__dict__})
|
|
|
|
def add_text_ttf(self, text, font_path, at=(0, 0), size=1.0, layer="GTO"):
|
|
log('ENTER add_text_ttf', locals())
|
|
log("add_text_ttf called")
|
|
self._svg_text_calls.append((text, at, size, layer))
|
|
try:
|
|
gerber = render_text_ttf(text, font_path, at, size)
|
|
self.layers[layer].extend(gerber)
|
|
except Exception as e:
|
|
print(f"TTF render error: {e}")
|
|
log('EXIT add_text_ttf', {'self': self.__dict__})
|
|
|
|
def annotate(self, x, y, text, size=1.0, layer=TOP_SILK):
|
|
"""Add an annotation using the bundled RobotoMono font."""
|
|
font_path = os.path.join(os.path.dirname(__file__), "..", "fonts", "RobotoMono.ttf")
|
|
if hasattr(layer, "value"):
|
|
layer = layer.value
|
|
self.add_text_ttf(text, font_path=font_path, at=(x, y), size=size, layer=layer)
|
|
|
|
def logo(self, x, y, image, scale=1.0, layer=TOP_SILK):
|
|
"""Render a Pillow image onto ``layer`` as a simple bitmap graphic."""
|
|
if hasattr(layer, "value"):
|
|
layer = layer.value
|
|
img = image.convert("RGBA")
|
|
width, height = img.size
|
|
for j in range(height):
|
|
for i in range(width):
|
|
r, g, b, a = img.getpixel((i, j))
|
|
if a > 0 and (r, g, b) != (255, 255, 255):
|
|
sx = x + i * scale
|
|
sy = y + j * scale
|
|
cmds = [
|
|
f"X{int(sx*1000):07d}Y{int(sy*1000):07d}D02*",
|
|
f"X{int((sx+scale)*1000):07d}Y{int(sy*1000):07d}D01*",
|
|
f"X{int((sx+scale)*1000):07d}Y{int((sy+scale)*1000):07d}D01*",
|
|
f"X{int(sx*1000):07d}Y{int((sy+scale)*1000):07d}D01*",
|
|
f"X{int(sx*1000):07d}Y{int(sy*1000):07d}D01*",
|
|
]
|
|
self.layers[layer].extend(cmds)
|
|
|
|
|
|
def design_rule_check(self, min_trace_width=None, min_clearance=None):
|
|
"""Check design rules and raise :class:`~boardforge.drc.DRCError` on failures.
|
|
|
|
If ``min_trace_width`` or ``min_clearance`` are not provided, values
|
|
from :data:`LAYER_SERVICE_RULES` corresponding to ``self.layer_service``
|
|
will be used when available.
|
|
"""
|
|
|
|
def _parse(value):
|
|
if isinstance(value, (int, float)):
|
|
return float(value)
|
|
if isinstance(value, str):
|
|
lowered = value.lower()
|
|
if lowered in {"any", "user preference"}:
|
|
return 0.0
|
|
m = re.search(r"([0-9.]+)mm", value)
|
|
if m:
|
|
return float(m.group(1))
|
|
return 0.0
|
|
|
|
extra = {}
|
|
if self.layer_service in LAYER_SERVICE_RULES:
|
|
rules = LAYER_SERVICE_RULES[self.layer_service]
|
|
if min_trace_width is None:
|
|
min_trace_width = _parse(rules.get("Minimum track Width", 0))
|
|
if min_clearance is None:
|
|
min_clearance = _parse(rules.get("Minimum Clearance", 0))
|
|
extra["min_annular_ring"] = _parse(rules.get("Minimum Annular Ring", 0))
|
|
extra["min_via_diameter"] = _parse(rules.get("Minimum Via Diameter", 0))
|
|
extra["min_through_hole"] = _parse(rules.get("Minimum Through Hole", 0))
|
|
extra["hole_to_hole_clearance"] = _parse(rules.get("Hole to hole clearance", 0))
|
|
extra["min_text_height"] = _parse(rules.get("Silkscreen Min Text Height", 0))
|
|
extra["min_text_thickness"] = _parse(rules.get("Silkscreen Min Text Thickness", 0))
|
|
|
|
if min_trace_width is None:
|
|
min_trace_width = 0.15
|
|
if min_clearance is None:
|
|
min_clearance = 0.15
|
|
|
|
warnings = check_board(
|
|
self,
|
|
min_trace_width=min_trace_width,
|
|
min_clearance=min_clearance,
|
|
**{k: v for k, v in extra.items() if v}
|
|
)
|
|
if warnings:
|
|
from .drc import DRCError
|
|
raise DRCError(warnings)
|
|
return []
|
|
|
|
def save_svg_previews(self, outdir="."):
|
|
log('ENTER save_svg_previews', locals())
|
|
log("save_svg_previews called")
|
|
width_px = int(self.width * 10)
|
|
height_px = int(self.height * 10)
|
|
log('EXIT save_svg_previews', {'self': self.__dict__})
|
|
|
|
colors = {
|
|
"board": "#5d2292", # OSH Park purple
|
|
"pad": "#ffc100", # Gold
|
|
"ring": "#ffec80", # Lighter gold for through-hole rings
|
|
"trace": "#ffc100", # Gold
|
|
"silk": "#ffffff", # White
|
|
"hole": "#000000", # Black for board holes
|
|
}
|
|
|
|
for side, suffix in [("GTO", "top"), ("GBO", "bottom")]:
|
|
poly = self.outline_geom if self.outline_geom is not None else box(0, 0, self.width, self.height)
|
|
|
|
# Carve castellated pads out of the outline for a simple preview
|
|
from shapely.geometry import Point, box as sbox
|
|
for comp in self.components:
|
|
for pad in getattr(comp, "pads", []):
|
|
if getattr(pad, "castellated", False) and pad.edge:
|
|
r = (getattr(pad, "w", 1.2) or 1.2) / 2
|
|
if pad.edge == "bottom":
|
|
semi = Point(pad.x, pad.y).buffer(r, resolution=8).intersection(
|
|
sbox(pad.x - r, pad.y, pad.x + r, pad.y + r)
|
|
)
|
|
elif pad.edge == "top":
|
|
semi = Point(pad.x, pad.y).buffer(r, resolution=8).intersection(
|
|
sbox(pad.x - r, pad.y - r, pad.x + r, pad.y)
|
|
)
|
|
elif pad.edge == "left":
|
|
semi = Point(pad.x, pad.y).buffer(r, resolution=8).intersection(
|
|
sbox(pad.x, pad.y - r, pad.x + r, pad.y + r)
|
|
)
|
|
elif pad.edge == "right":
|
|
semi = Point(pad.x, pad.y).buffer(r, resolution=8).intersection(
|
|
sbox(pad.x - r, pad.y - r, pad.x, pad.y + r)
|
|
)
|
|
else:
|
|
semi = None
|
|
if semi is not None:
|
|
poly = poly.difference(semi)
|
|
|
|
polygons = [poly] if poly.geom_type == "Polygon" else list(poly.geoms)
|
|
svg_elements = []
|
|
for p in polygons:
|
|
pts = " ".join(f"{int(x*10)},{int(y*10)}" for x, y in p.exterior.coords)
|
|
svg_elements.append(
|
|
f'<polygon points="{pts}" fill="{colors["board"]}"/>'
|
|
)
|
|
|
|
# Traces (placeholder: draws a line for each trace)
|
|
for trace in self.layers.get("GTL" if side == "GTO" else "GBL", []):
|
|
if isinstance(trace, tuple) and trace[0] == "TRACE":
|
|
pin1, pin2 = trace[1], trace[2]
|
|
x1, y1 = int(pin1.x * 10), int(pin1.y * 10)
|
|
x2, y2 = int(pin2.x * 10), int(pin2.y * 10)
|
|
svg_elements.append(
|
|
f'<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="{colors["trace"]}" stroke-width="4"/>'
|
|
)
|
|
elif isinstance(trace, tuple) and trace[0] == "TRACE_PATH":
|
|
segments = trace[1]
|
|
width = trace[2]
|
|
path_cmds = []
|
|
move_done = False
|
|
for seg in segments:
|
|
if seg[0] == "LINE":
|
|
start, end = seg[1], seg[2]
|
|
if not move_done:
|
|
path_cmds.append(f'M{int(start[0]*10)},{int(start[1]*10)}')
|
|
move_done = True
|
|
path_cmds.append(f'L{int(end[0]*10)},{int(end[1]*10)}')
|
|
elif seg[0] == "ARC":
|
|
start, end, r, ang = seg[1], seg[2], seg[3], seg[4]
|
|
large = 1 if abs(ang) > 180 else 0
|
|
sweep = 1 if ang > 0 else 0
|
|
if not move_done:
|
|
path_cmds.append(f'M{int(start[0]*10)},{int(start[1]*10)}')
|
|
move_done = True
|
|
path_cmds.append(
|
|
f"A{int(r*10)},{int(r*10)} 0 {large},{sweep} {int(end[0]*10)},{int(end[1]*10)}"
|
|
)
|
|
elif seg[0] == "BEZIER":
|
|
start, c1, c2, end = seg[1], seg[2], seg[3], seg[4]
|
|
if not move_done:
|
|
path_cmds.append(f'M{int(start[0]*10)},{int(start[1]*10)}')
|
|
move_done = True
|
|
path_cmds.append(
|
|
f"C{int(c1[0]*10)},{int(c1[1]*10)} {int(c2[0]*10)},{int(c2[1]*10)} {int(end[0]*10)},{int(end[1]*10)}"
|
|
)
|
|
if path_cmds:
|
|
d = ' '.join(path_cmds)
|
|
svg_elements.append(
|
|
f'<path d="{d}" stroke="{colors["trace"]}" stroke-width="{max(1,int(width*4))}" fill="none"/>'
|
|
)
|
|
|
|
# Filled zones
|
|
for zone in self.zones:
|
|
if zone.layer == ("GTL" if side == "GTO" else "GBL") and zone.geometry is not None:
|
|
pts = " ".join(
|
|
f"{int(x*10)},{int(y*10)}" for x, y in zone.geometry.exterior.coords
|
|
)
|
|
svg_elements.append(
|
|
f'<polygon points="{pts}" fill="{colors["trace"]}" opacity="0.6"/>'
|
|
)
|
|
|
|
# Pads with rotation drawn above traces
|
|
for comp in self.components:
|
|
for pad in getattr(comp, "pads", []):
|
|
x = int(pad.x * 10)
|
|
y = int(pad.y * 10)
|
|
w = int((getattr(pad, "w", 1.2) or 1.2) * 10)
|
|
h = int((getattr(pad, "h", 1.2) or 1.2) * 10)
|
|
|
|
if getattr(pad, "castellated", False) and pad.edge:
|
|
r = (getattr(pad, "w", 1.2) or 1.2) / 2
|
|
from shapely.geometry import Point, box as sbox
|
|
|
|
def semi_shape(rad):
|
|
if pad.edge == "bottom":
|
|
return Point(pad.x, pad.y).buffer(rad, resolution=8).intersection(
|
|
sbox(pad.x - rad, pad.y, pad.x + rad, pad.y + rad)
|
|
)
|
|
if pad.edge == "top":
|
|
return Point(pad.x, pad.y).buffer(rad, resolution=8).intersection(
|
|
sbox(pad.x - rad, pad.y - rad, pad.x + rad, pad.y)
|
|
)
|
|
if pad.edge == "left":
|
|
return Point(pad.x, pad.y).buffer(rad, resolution=8).intersection(
|
|
sbox(pad.x, pad.y - rad, pad.x + rad, pad.y + rad)
|
|
)
|
|
if pad.edge == "right":
|
|
return Point(pad.x, pad.y).buffer(rad, resolution=8).intersection(
|
|
sbox(pad.x - rad, pad.y - rad, pad.x, pad.y + rad)
|
|
)
|
|
return Point(pad.x, pad.y).buffer(rad, resolution=8)
|
|
|
|
inner = semi_shape(r)
|
|
ring = None
|
|
if getattr(pad, "plated", True):
|
|
outer = semi_shape(r + 0.3)
|
|
ring = outer.difference(inner)
|
|
|
|
def emit_poly(shape, color, width):
|
|
if shape.is_empty:
|
|
return
|
|
polys = [shape] if shape.geom_type == "Polygon" else list(shape.geoms)
|
|
for poly in polys:
|
|
pts = " ".join(f"{int(x*10)},{int(y*10)}" for x, y in poly.exterior.coords)
|
|
svg_elements.append(
|
|
f'<polygon points="{pts}" fill="{color}" stroke="#333" stroke-width="{width}"/>'
|
|
)
|
|
|
|
if ring is not None:
|
|
emit_poly(ring, colors["ring"], 1)
|
|
emit_poly(inner, colors["pad"], 2)
|
|
|
|
else:
|
|
if abs(w - h) <= 1:
|
|
ring_r = int((w + 6) // 2)
|
|
pad_r = int(w // 2)
|
|
svg_elements.append(
|
|
f'<circle cx="{x}" cy="{y}" r="{ring_r}" fill="{colors["ring"]}" stroke="#333" stroke-width="1"/>'
|
|
)
|
|
svg_elements.append(
|
|
f'<circle cx="{x}" cy="{y}" r="{pad_r}" fill="{colors["pad"]}" stroke="#333" stroke-width="2"/>'
|
|
)
|
|
else:
|
|
svg_elements.append(
|
|
f'<rect x="{x-w//2}" y="{y-h//2}" width="{w}" height="{h}" fill="{colors["pad"]}" stroke="#333" stroke-width="2" transform="rotate({comp.rotation},{x},{y})"/>'
|
|
)
|
|
|
|
# Board holes drawn above pads
|
|
for hx, hy, dia, ann in self.holes:
|
|
x = int(hx * 10)
|
|
y = int(hy * 10)
|
|
r = int((dia / 2) * 10)
|
|
if ann is not None:
|
|
ring_r = int(((dia / 2) + ann) * 10)
|
|
svg_elements.append(
|
|
f'<circle cx="{x}" cy="{y}" r="{ring_r}" fill="{colors["ring"]}" stroke="#333" stroke-width="1"/>'
|
|
)
|
|
svg_elements.append(
|
|
f'<circle cx="{x}" cy="{y}" r="{r}" fill="{colors["hole"]}" stroke="#333" stroke-width="1"/>'
|
|
)
|
|
|
|
# Silkscreen text from _svg_text_calls
|
|
for (text, at, size, lyr) in self._svg_text_calls:
|
|
if lyr == side:
|
|
x = int(at[0] * 10)
|
|
y = int(at[1] * 10)
|
|
font_size = int(15 * size)
|
|
svg_elements.append(
|
|
f'<text x="{x}" y="{y}" fill="{colors["silk"]}" font-family="monospace" font-size="{font_size}">{text}</text>'
|
|
)
|
|
|
|
# SVG graphics from _svg_graphics_calls
|
|
for (svg_path, lyr, scale, at) in self._svg_graphics_calls:
|
|
if lyr == side and os.path.exists(svg_path):
|
|
try:
|
|
tree = ET.parse(svg_path)
|
|
g = ET.tostring(tree.getroot(), encoding="unicode")
|
|
svg_elements.append(
|
|
f'<g transform="translate({int(at[0]*10)},{int(at[1]*10)}) scale({scale})">{g}</g>'
|
|
)
|
|
except Exception as e:
|
|
print(f"Error embedding SVG {svg_path}: {e}")
|
|
|
|
# Generate SVG content with proper indentation
|
|
svg_content = [
|
|
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width_px}" height="{height_px}" viewBox="0 0 {width_px} {height_px}">'
|
|
]
|
|
svg_content.extend(f' {el}' for el in svg_elements)
|
|
svg_content.append('</svg>')
|
|
|
|
# Write to file
|
|
os.makedirs(outdir, exist_ok=True)
|
|
output_path = os.path.join(outdir, f"preview_{suffix}.svg")
|
|
try:
|
|
with open(output_path, "w", encoding="utf-8") as f:
|
|
f.write('\n'.join(svg_content))
|
|
# Convert the SVG preview to PNG for easier visual inspection
|
|
try:
|
|
from cairosvg import svg2png
|
|
from PIL import Image
|
|
png_path = os.path.join(outdir, f"preview_{suffix}.png")
|
|
svg2png(bytes('\n'.join(svg_content), 'utf-8'), write_to=png_path)
|
|
with Image.open(png_path) as im2:
|
|
if im2.mode != "RGBA":
|
|
im2.convert("RGBA").save(png_path)
|
|
# Simple verification: ensure the file was written and is not empty
|
|
if os.path.getsize(png_path) == 0:
|
|
os.remove(png_path)
|
|
raise ValueError("Generated PNG is empty")
|
|
except Exception as e:
|
|
print(f"Error converting SVG to PNG: {e}")
|
|
except Exception as e:
|
|
print(f"Error writing SVG preview to {output_path}: {e}")
|
|
|
|
def save_png_previews(self, outdir=".", scale=10):
|
|
"""Render high-quality PNG previews using Pillow.
|
|
|
|
This method draws board geometry directly to PNG images using
|
|
:mod:`Pillow` and simple geometry helpers inspired by the CuFlow
|
|
project. It does not attempt to be a full PCB renderer but provides
|
|
smoother traces and rotated pads compared to the basic SVG output.
|
|
|
|
Parameters
|
|
----------
|
|
outdir : str
|
|
Directory where the PNGs will be written.
|
|
scale : int
|
|
Pixels per mm for the generated images.
|
|
"""
|
|
os.makedirs(outdir, exist_ok=True)
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
from shapely.geometry import box
|
|
from shapely.affinity import rotate, translate
|
|
|
|
width_px = int(self.width * scale)
|
|
height_px = int(self.height * scale)
|
|
|
|
colors = {
|
|
"board": (93, 34, 146, 255), # purple board colour
|
|
"pad": (255, 193, 0, 255),
|
|
"ring": (255, 236, 128, 255),
|
|
"trace": (255, 193, 0, 255),
|
|
"silk": (255, 255, 255, 255),
|
|
"hole": (0, 0, 0, 255),
|
|
}
|
|
|
|
for side, suffix in [("GTO", "top"), ("GBO", "bottom")]:
|
|
im = Image.new("RGBA", (width_px, height_px), (0, 0, 0, 0))
|
|
draw = ImageDraw.Draw(im)
|
|
poly = self.outline_geom if self.outline_geom is not None else box(0, 0, self.width, self.height)
|
|
draw.polygon([(x * scale, y * scale) for x, y in poly.exterior.coords], fill=colors["board"])
|
|
|
|
layer = "GTL" if side == "GTO" else "GBL"
|
|
for trace in self.layers.get(layer, []):
|
|
if isinstance(trace, tuple) and trace[0] == "TRACE":
|
|
p1, p2, w = trace[1], trace[2], trace[3]
|
|
draw.line(
|
|
[(p1.x * scale, p1.y * scale), (p2.x * scale, p2.y * scale)],
|
|
fill=colors["trace"],
|
|
width=max(1, int(w * scale)),
|
|
)
|
|
elif isinstance(trace, tuple) and trace[0] == "TRACE_PATH":
|
|
segments = trace[1]
|
|
w = trace[2]
|
|
for seg in segments:
|
|
if seg[0] == "LINE":
|
|
s, e = seg[1], seg[2]
|
|
draw.line(
|
|
[(s[0] * scale, s[1] * scale), (e[0] * scale, e[1] * scale)],
|
|
fill=colors["trace"],
|
|
width=max(1, int(w * scale)),
|
|
)
|
|
elif seg[0] == "ARC":
|
|
s, e, r, ang = seg[1], seg[2], seg[3], seg[4]
|
|
params = self._arc_params(s, e, r, ang)
|
|
if params is not None:
|
|
cx, cy, a1, a2 = params
|
|
bbox = [
|
|
(cx - r) * scale,
|
|
(cy - r) * scale,
|
|
(cx + r) * scale,
|
|
(cy + r) * scale,
|
|
]
|
|
draw.arc(bbox, start=a1, end=a2, fill=colors["trace"], width=max(1, int(w * scale)))
|
|
elif seg[0] == "BEZIER":
|
|
from svg.path import CubicBezier
|
|
s, c1, c2, e = seg[1], seg[2], seg[3], seg[4]
|
|
cb = CubicBezier(complex(*s), complex(*c1), complex(*c2), complex(*e))
|
|
steps = 20
|
|
pts = [cb.point(t / steps) for t in range(steps + 1)]
|
|
draw.line(
|
|
[(pt.real * scale, pt.imag * scale) for pt in pts],
|
|
fill=colors["trace"],
|
|
width=max(1, int(w * scale)),
|
|
)
|
|
|
|
for zone in self.zones:
|
|
if zone.layer == layer and zone.geometry is not None:
|
|
pts = [(x * scale, y * scale) for (x, y) in zone.geometry.exterior.coords]
|
|
draw.polygon(pts, fill=colors["trace"], outline=None)
|
|
|
|
for comp in self.components:
|
|
for pad in getattr(comp, "pads", []):
|
|
x = pad.x * scale
|
|
y = pad.y * scale
|
|
w = (getattr(pad, "w", 1.2) or 1.2) * scale
|
|
h = (getattr(pad, "h", 1.2) or 1.2) * scale
|
|
if abs(w - h) <= scale * 0.1:
|
|
ring_r = (w + 6) / 2
|
|
pad_r = w / 2
|
|
draw.ellipse(
|
|
[x - ring_r, y - ring_r, x + ring_r, y + ring_r],
|
|
fill=colors["ring"], outline="#333"
|
|
)
|
|
draw.ellipse(
|
|
[x - pad_r, y - pad_r, x + pad_r, y + pad_r],
|
|
fill=colors["pad"], outline="#333"
|
|
)
|
|
else:
|
|
poly = box(-w / 2, -h / 2, w / 2, h / 2)
|
|
poly = rotate(poly, comp.rotation, origin=(0, 0))
|
|
poly = translate(poly, x, y)
|
|
draw.polygon(list(poly.exterior.coords), fill=colors["pad"], outline="#333")
|
|
|
|
for hx, hy, dia, ann in self.holes:
|
|
x = hx * scale
|
|
y = hy * scale
|
|
r = (dia / 2) * scale
|
|
if ann is not None:
|
|
ring_r = ((dia / 2) + ann) * scale
|
|
draw.ellipse(
|
|
[x - ring_r, y - ring_r, x + ring_r, y + ring_r],
|
|
fill=colors["ring"], outline="#333"
|
|
)
|
|
draw.ellipse(
|
|
[x - r, y - r, x + r, y + r],
|
|
fill=colors["hole"], outline="#333"
|
|
)
|
|
|
|
for (text, at, size, lyr) in self._svg_text_calls:
|
|
if lyr == side:
|
|
font_size = max(8, int(15 * size))
|
|
try:
|
|
font = ImageFont.truetype("DejaVuSans.ttf", font_size)
|
|
except Exception:
|
|
font = ImageFont.load_default()
|
|
draw.text(
|
|
(at[0] * scale, at[1] * scale),
|
|
text,
|
|
fill=colors["silk"],
|
|
font=font,
|
|
)
|
|
|
|
png_path = os.path.join(outdir, f"preview_{suffix}_hi.png")
|
|
im.save(png_path)
|
|
|
|
|
|
def export_gerbers(self, out_path):
|
|
log('ENTER export_gerbers', locals())
|
|
log("export_gerbers called")
|
|
self.design_rule_check()
|
|
export_gerbers(self, out_path)
|
|
|
|
def export_all(self, out_path):
|
|
"""Convenience method mirroring the pseudocode API."""
|
|
self.export_gerbers(out_path)
|