Files
magdevandClaude Fable 5 4737a5bae8 Add boxsim CLI: pure Python codec, netlist extraction, KiCad project generation
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>
2026-09-29 11:10:43 +02:00

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)")