Replaces the bpj2ini.py prototype with a packaged tool. The A2F codec is now reimplemented in Python, so neither Boxsim.exe nor a CPU emulator is needed to decode a container; the emulator survives as a development-only oracle that the codec is differentially tested against. Verified: - 134 containers decode byte-identically to Boxsim's own decoder, 270 pre-2.0 files rejected by both implementations (tools/difftest.py --self-test) - Default.BPJ: 22 components, 14 nets, no dangling terminal - 22 of 22 parts resolve to library symbols; one series inferred from a sibling, two DC resistance deviations reported - ERC clean and the netlist KiCad extracts from the generated sheet is an identical partition to the one derived from the Boxsim grid - container build byte-identical to the host build - 32 tests passing, ruff clean Decode time went 69 s -> 14.5 s (binding emulator hooks to trap addresses) -> 1.6 s (pure Python). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
463 lines
16 KiB
Python
463 lines
16 KiB
Python
"""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)")
|