"""Emit a KiCad 10 schematic from a Boxsim crossover. The Boxsim crossover editor is already a schematic grid, so the layout is carried over cell for cell instead of being re-derived: a node at grid (row, col) becomes a sheet coordinate, wire slots become wire segments, and component slots place a symbol between their two nodes. Symbol placement uses KiCad's pin transform, determined empirically from the shipped demo schematics over several thousand pin placements: rot 0 pin (px, py) -> (X + px, Y - py) rot 90 pin (px, py) -> (X - py, Y - px) rot 180 pin (px, py) -> (X - px, Y + py) rot 270 pin (px, py) -> (X + py, Y + px) A symbol's origin is not necessarily its pin midpoint (the `Amplifier` symbol has both pins at px=3.81), so the origin is derived by placing the transformed pin midpoint on the slot midpoint. """ from __future__ import annotations import re import uuid from dataclasses import dataclass, field from .model import Crossover, Orientation, Part, PartType, Project from .symbols import Resolution, SymbolLibrary #: Schematic format emitted by KiCad 10.0.x. SCH_VERSION = "20260101" GENERATOR = "boxsim" GENERATOR_VERSION = "10.0" #: Must exceed the widest symbol's pin span (22.86 mm for `FC-Spule 10mH`) #: so neighbouring cells cannot overlap. DEFAULT_PITCH_MM = 25.4 MARGIN_MM = 25.4 #: KiCad standard sheets, landscape, smallest first. PAPER_SIZES = [ ("A4", 297.0, 210.0), ("A3", 420.0, 297.0), ("A2", 594.0, 420.0), ("A1", 841.0, 594.0), ("A0", 1189.0, 841.0), ] #: Reference designator prefix per component type. REFDES_PREFIX = { PartType.RESISTOR: "R", PartType.CAPACITOR: "C", PartType.INDUCTOR: "L", PartType.DRIVER: "LS", PartType.AMP_INPUT: "J", } #: Stable namespace so regenerating a project yields identical UUIDs. UUID_NS = uuid.UUID("b0c51a3e-7d61-5f0a-9c2e-0b0a5e1d4f77") _PIN_RE = re.compile( r'\(pin\s+\w+\s+\w+\s*\(at\s+([-\d.]+)\s+([-\d.]+)\s+\d+\)' r'(?:(?!\(pin\b).)*?\(number\s+"([^"]+)"', re.S, ) _PIN_NAME_RE = re.compile( r'\(pin\s+\w+\s+\w+\s*\(at\s+([-\d.]+)\s+([-\d.]+)\s+\d+\)' r'(?:(?!\(pin\b).)*?\(name\s+"([^"]*)"' r'(?:(?!\(pin\b).)*?\(number\s+"([^"]+)"', re.S, ) def stable_uuid(*parts: object) -> str: return str(uuid.uuid5(UUID_NS, "|".join(str(p) for p in parts))) def transform(rot: int, px: float, py: float) -> tuple[float, float]: if rot == 0: return px, -py if rot == 90: return -py, -px if rot == 180: return -px, py if rot == 270: return py, px raise ValueError(f"unsupported rotation {rot}") def _fmt(value: float) -> str: """KiCad writes millimetres with at most 6 decimals and no trailing zeros.""" text = f"{value:.6f}".rstrip("0").rstrip(".") return text if text not in ("", "-0") else "0" @dataclass class SymbolPins: """Pin geometry of a library symbol, in library coordinates.""" by_number: dict[str, tuple[float, float]] names: dict[str, str] @property def numbers(self) -> list[str]: return sorted(self.by_number, key=lambda n: (len(n), n)) @property def axis(self) -> Orientation: """Whether the pins are separated mainly in x or in y.""" nums = self.numbers (x1, y1), (x2, y2) = self.by_number[nums[0]], self.by_number[nums[1]] return ( Orientation.HORIZONTAL if abs(x1 - x2) >= abs(y1 - y2) else Orientation.VERTICAL ) @property def midpoint(self) -> tuple[float, float]: nums = self.numbers (x1, y1), (x2, y2) = self.by_number[nums[0]], self.by_number[nums[1]] return (x1 + x2) / 2, (y1 + y2) / 2 def positive_number(self) -> str | None: for num, name in self.names.items(): if name.strip() == "+": return num return None def symbol_pins(library: SymbolLibrary, name: str) -> SymbolPins: body = library.geometry_source(name) by_number: dict[str, tuple[float, float]] = {} names: dict[str, str] = {} for m in _PIN_RE.finditer(body): by_number.setdefault(m.group(3), (float(m.group(1)), float(m.group(2)))) for m in _PIN_NAME_RE.finditer(body): names.setdefault(m.group(4), m.group(3)) if len(by_number) < 2: raise ValueError(f"symbol {name!r} has fewer than two pins") return SymbolPins(by_number=by_number, names=names) @dataclass class Placement: """A resolved component positioned on the sheet.""" resolution: Resolution refdes: str origin: tuple[float, float] rotation: int pin_targets: dict[str, tuple[float, float]] # pin number -> node coordinate pin_positions: dict[str, tuple[float, float]] pin_nodes: dict[str, int] # pin number -> Boxsim grid node, for verification @dataclass class Sheet: """Everything that goes into one `.kicad_sch`.""" title: str paper: str placements: list[Placement] = field(default_factory=list) wires: list[tuple[tuple[float, float], tuple[float, float]]] = field( default_factory=list ) junctions: list[tuple[float, float]] = field(default_factory=list) texts: list[tuple[str, tuple[float, float]]] = field(default_factory=list) used_symbols: list[str] = field(default_factory=list) class SchematicBuilder: def __init__( self, library: SymbolLibrary, pitch: float = DEFAULT_PITCH_MM, margin: float = MARGIN_MM, ): self.library = library self.pitch = pitch self.margin = margin if pitch <= 22.86: raise ValueError( f"pitch {pitch} mm is not wider than the widest symbol (22.86 mm)" ) # -- geometry --------------------------------------------------------- def _node_xy(self, node: int, min_row: int, min_col: int) -> tuple[float, float]: from .model import COLS row, col = divmod(node, COLS) return ( self.margin + (col - min_col) * self.pitch, self.margin + (row - min_row) * self.pitch, ) def _choose_paper(self, width: float, height: float) -> str: for name, w, h in PAPER_SIZES: if width <= w and height <= h: return name return PAPER_SIZES[-1][0] # -- building --------------------------------------------------------- def build( self, project: Project, crossover: Crossover, resolutions: list[Resolution], title: str, ) -> Sheet: min_row, min_col, max_row, max_col = crossover.bounding_box() # A slot extends one node past its cell, so the node grid is one wider. max_node_row, max_node_col = max_row + 1, max_col + 1 def xy(node: int) -> tuple[float, float]: return self._node_xy(node, min_row, min_col) width = 2 * self.margin + (max_node_col - min_col) * self.pitch height = 2 * self.margin + (max_node_row - min_row) * self.pitch sheet = Sheet(title=title, paper=self._choose_paper(width, height)) endpoints: dict[tuple[float, float], int] = {} def add_wire(a: tuple[float, float], b: tuple[float, float]) -> None: if a == b: return sheet.wires.append((a, b)) endpoints[a] = endpoints.get(a, 0) + 1 endpoints[b] = endpoints.get(b, 0) + 1 # Wires and free links. for part in crossover.parts: if part.typ != PartType.WIRE: continue a, b = xy(part.node_a), xy(part.node_b) if part.orientation == Orientation.LINK and a[0] != b[0] and a[1] != b[1]: corner = (b[0], a[1]) # route orthogonally, never diagonally add_wire(a, corner) add_wire(corner, b) else: add_wire(a, b) # Components. counters: dict[str, int] = {} by_slot = {r.part.slot: r for r in resolutions} for part in sorted(crossover.components, key=lambda p: p.slot): res = by_slot.get(part.slot) if res is None: continue placement = self._place(part, res, xy, counters, project) sheet.placements.append(placement) if res.symbol not in sheet.used_symbols: sheet.used_symbols.append(res.symbol) for num, target in placement.pin_targets.items(): add_wire(placement.pin_positions[num], target) endpoints[target] = endpoints.get(target, 0) + 1 sheet.junctions = sorted(p for p, n in endpoints.items() if n >= 3) sheet.texts = self._describe(project, crossover, height) return sheet def _place( self, part: Part, res: Resolution, xy, counters: dict[str, int], project: Project, ) -> Placement: pins = symbol_pins(self.library, res.symbol) a, b = xy(part.node_a), xy(part.node_b) slot_axis = ( Orientation.HORIZONTAL if abs(a[0] - b[0]) >= abs(a[1] - b[1]) else Orientation.VERTICAL ) rotation = 0 if pins.axis == slot_axis else 90 mx, my = pins.midpoint tmx, tmy = transform(rotation, mx, my) mid = ((a[0] + b[0]) / 2, (a[1] + b[1]) / 2) origin = (mid[0] - tmx, mid[1] - tmy) positions = {} for num, (px, py) in pins.by_number.items(): tx, ty = transform(rotation, px, py) positions[num] = (round(origin[0] + tx, 6), round(origin[1] + ty, 6)) # Assign pins to nodes along the slot axis. nums = pins.numbers[:2] key = (lambda n: positions[n][0]) if slot_axis == Orientation.HORIZONTAL \ else (lambda n: positions[n][1]) first, second = sorted(nums, key=key) node_first, node_second = (a, b) if ( (a[0] <= b[0]) if slot_axis == Orientation.HORIZONTAL else (a[1] <= b[1]) ) else (b, a) targets = {first: node_first, second: node_second} # `_verpolt` reverses a driver or the amplifier input. Swap the two # terminals so the polarity marking matches Boxsim. if part.inverted and pins.positive_number() is not None: targets = {first: node_second, second: node_first} prefix = REFDES_PREFIX[part.typ] counters[prefix] = counters.get(prefix, 0) + 1 refdes = f"{prefix}{counters[prefix]}" if part.typ == PartType.DRIVER and part.chassis_index is not None: res.value_text = project.chassis_name(part.chassis_index) coord_to_node = {a: part.node_a, b: part.node_b} return Placement( resolution=res, refdes=refdes, origin=(round(origin[0], 6), round(origin[1], 6)), rotation=rotation, pin_targets=targets, pin_positions=positions, pin_nodes={num: coord_to_node[xyc] for num, xyc in targets.items()}, ) def _describe( self, project: Project, crossover: Crossover, height: float ) -> list[tuple[str, tuple[float, float]]]: """A short note recording which enclosure this crossover belongs to.""" lines = [f"Boxsim crossover {crossover.index} - {len(crossover.components)} parts"] for ch in project.chassis: enc = next((e for e in project.enclosures if e.index == ch.index), None) baf = next((b for b in project.baffles if b.index == ch.index), None) bits = [f"LS{ch.index + 1}: {ch.name}"] if enc is not None and enc.vb: kind = {1: "closed", 2: "vented", 3: "bandpass"}.get(enc.typ, f"type {enc.typ}") bits.append(f"{kind}, Vb {enc.vb * 1000:g} l") if baf is not None and baf.width and baf.height: bits.append(f"baffle {baf.width * 1000:g} x {baf.height * 1000:g} mm") lines.append(" ".join(bits)) y = height - self.margin / 2 return [(line, (self.margin, y + i * 4.0)) for i, line in enumerate(lines)] # -- serialisation ---------------------------------------------------- def render(self, sheet: Sheet, project_name: str) -> str: sheet_uuid = stable_uuid("sheet", project_name) out: list[str] = [] w = out.append w("(kicad_sch") w(f'\t(version {SCH_VERSION})') w(f'\t(generator "{GENERATOR}")') w(f'\t(generator_version "{GENERATOR_VERSION}")') w(f'\t(uuid "{sheet_uuid}")') w(f'\t(paper "{sheet.paper}")') w("\t(title_block") w(f'\t\t(title "{sheet.title}")') w('\t\t(comment 1 "Generated from a Boxsim project by boxsim")') w("\t)") w("\t(lib_symbols") for name in sheet.used_symbols: body = self.library.definition(name) body = body.replace(f'(symbol "{name}"', f'(symbol "Visaton:{name}"', 1) for line in body.splitlines(): w("\t\t" + line.strip() if not line.startswith("\t") else "\t\t" + line.lstrip("\t")) w("\t)") for (x1, y1), (x2, y2) in sheet.wires: w("\t(wire") w(f"\t\t(pts (xy {_fmt(x1)} {_fmt(y1)}) (xy {_fmt(x2)} {_fmt(y2)}))") w("\t\t(stroke (width 0) (type default))") w(f'\t\t(uuid "{stable_uuid("wire", x1, y1, x2, y2)}")') w("\t)") for x, y in sheet.junctions: w("\t(junction") w(f"\t\t(at {_fmt(x)} {_fmt(y)})") w("\t\t(diameter 0)") w("\t\t(color 0 0 0 0)") w(f'\t\t(uuid "{stable_uuid("junction", x, y)}")') w("\t)") for text, (x, y) in sheet.texts: w(f'\t(text "{text}"') w("\t\t(exclude_from_sim yes)") w(f"\t\t(at {_fmt(x)} {_fmt(y)} 0)") w("\t\t(effects (font (size 1.27 1.27)) (justify left bottom))") w(f'\t\t(uuid "{stable_uuid("text", x, y, text)}")') w("\t)") for p in sheet.placements: self._render_symbol(out, p, project_name, sheet_uuid) w("\t(sheet_instances") w('\t\t(path "/"') w('\t\t\t(page "1")') w("\t\t)") w("\t)") w("\t(embedded_fonts no)") w(")") return "\n".join(out) + "\n" def _render_symbol( self, out: list[str], p: Placement, project_name: str, sheet_uuid: str ) -> None: w = out.append res = p.resolution part = res.part x, y = p.origin sym_uuid = stable_uuid("symbol", part.slot, res.symbol) w("\t(symbol") w(f'\t\t(lib_id "Visaton:{res.symbol}")') w(f"\t\t(at {_fmt(x)} {_fmt(y)} {p.rotation})") w("\t\t(unit 1)") w("\t\t(exclude_from_sim no)") w("\t\t(in_bom yes)") w("\t\t(on_board yes)") w("\t\t(dnp no)") w(f'\t\t(uuid "{sym_uuid}")') # Keep the labels clear of the body: above and below for a horizontal # symbol, left and right for a rotated one, which would otherwise have # its value text printed straight over the component. if p.rotation == 0: ref_at, val_at = (x, y - 5.08), (x, y + 5.08) else: ref_at, val_at = (x - 7.62, y), (x + 7.62, y) props = [ ("Reference", p.refdes, ref_at), ("Value", res.value_text, val_at), ] if res.series: props.append(("Visaton_Series", res.series, (x, y))) if part.dcr is not None: props.append(("DCR_Ohm", f"{part.dcr:g}", (x, y))) if part.chassis_index is not None: props.append(("Boxsim_Chassis", str(part.chassis_index + 1), (x, y))) if part.inverted: props.append(("Boxsim_Polarity", "inverted", (x, y))) props.append(("Boxsim_Slot", str(part.slot), (x, y))) for i, (name, value, (pxx, pyy)) in enumerate(props): hidden = i >= 2 w(f'\t\t(property "{name}" "{value}"') w(f"\t\t\t(at {_fmt(pxx)} {_fmt(pyy)} 0)") if hidden: w("\t\t\t(hide yes)") w("\t\t\t(effects (font (size 1.27 1.27)))") w("\t\t)") for num in sorted(p.pin_positions): w(f'\t\t(pin "{num}"') w(f'\t\t\t(uuid "{stable_uuid("pin", part.slot, num)}")') w("\t\t)") w("\t\t(instances") w(f'\t\t\t(project "{project_name}"') w(f'\t\t\t\t(path "/{sheet_uuid}"') w(f'\t\t\t\t\t(reference "{p.refdes}")') w("\t\t\t\t\t(unit 1)") w("\t\t\t\t)") w("\t\t\t)") w("\t\t)") w("\t)")