Files
boxsim-fileformat-rev/boxsim/project.py
T
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

162 lines
5.0 KiB
Python

"""Write a complete KiCad project directory.
Emits the schematic, a `.kicad_pro`, and project-local library tables pointing
at the Visaton library so the project opens with every symbol and footprint
resolved. No board is generated: KiCad's own *Update PCB from Schematic* does
that, and every Visaton symbol already carries a `Footprint` property.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from . import symbols as symbols_mod
from .catalog import Catalog
from .model import Project
from .schematic import DEFAULT_PITCH_MM, SchematicBuilder, Sheet
from .symbols import Resolution, Resolver, SymbolLibrary
LIB_NICKNAME = "Visaton"
@dataclass
class BuildResult:
directory: Path
schematic: Path
project_file: Path
sheet: Sheet
resolutions: list[Resolution]
@property
def notes(self) -> list[str]:
out = []
for r in self.resolutions:
for note in r.notes:
out.append(f"Part{r.part.slot}: {note}")
return out
def _pro_contents(name: str) -> dict:
"""Minimal but complete project file. KiCad fills in the rest on open."""
return {
"board": {"design_settings": {}, "layer_presets": [], "viewports": []},
"boards": [],
"cvpcb": {"equivalence_files": []},
"libraries": {"pinned_footprint_libs": [], "pinned_symbol_libs": []},
"meta": {"filename": f"{name}.kicad_pro", "version": 3},
"net_settings": {
"classes": [
{
"bus_width": 12,
"clearance": 0.2,
"diff_pair_gap": 0.25,
"diff_pair_width": 0.2,
"line_style": 0,
"microvia_diameter": 0.3,
"microvia_drill": 0.1,
"name": "Default",
"pcb_color": "rgba(0, 0, 0, 0.000)",
"schematic_color": "rgba(0, 0, 0, 0.000)",
"track_width": 0.8,
"via_diameter": 1.2,
"via_drill": 0.6,
"wire_width": 6,
}
],
"meta": {"version": 4},
},
"pcbnew": {"page_layout_descr_file": ""},
"schematic": {
"legacy_lib_dir": "",
"legacy_lib_list": [],
"meta": {"version": 1},
"page_layout_descr_file": "",
},
"sheets": [],
"text_variables": {},
}
def _lib_table(kind: str, entries: list[tuple[str, str]]) -> str:
"""Render a sym-lib-table or fp-lib-table."""
lines = [f"({kind}", "\t(version 7)"]
for nickname, uri in entries:
lines.append(
f'\t(lib (name "{nickname}")(type "KiCad")(uri "{uri}")'
f'(options "")(descr "Visaton crossover parts"))'
)
lines.append(")")
return "\n".join(lines) + "\n"
def resolve_library_root(library: SymbolLibrary) -> Path:
"""The checkout root, derived from the symbol file location."""
path = library.path.resolve()
if path.parent.name == "symbols":
return path.parent.parent
return path.parent
def build(
project: Project,
raw_name: str,
outdir: Path,
catalog: Catalog,
library: SymbolLibrary,
*,
pitch: float = DEFAULT_PITCH_MM,
crossover_index: int = 0,
) -> BuildResult:
if not project.crossovers:
raise ValueError("project contains no crossover")
if project.amp_count > 1:
raise NotImplementedError(
f"project has {project.amp_count} amplifier channels; "
"this version handles single-channel projects only"
)
crossover = project.crossovers[crossover_index]
resolver = Resolver(catalog, library)
resolutions = resolver.resolve_all(crossover.components)
builder = SchematicBuilder(library, pitch=pitch)
sheet = builder.build(project, crossover, resolutions, title=raw_name)
text = builder.render(sheet, raw_name)
outdir.mkdir(parents=True, exist_ok=True)
sch_path = outdir / f"{raw_name}.kicad_sch"
sch_path.write_text(text, encoding="utf-8")
pro_path = outdir / f"{raw_name}.kicad_pro"
pro_path.write_text(json.dumps(_pro_contents(raw_name), indent=2) + "\n", encoding="utf-8")
root = resolve_library_root(library)
(outdir / "sym-lib-table").write_text(
_lib_table("sym_lib_table", [(LIB_NICKNAME, str(root / "symbols" / "Visaton.kicad_sym"))]),
encoding="utf-8",
)
(outdir / "fp-lib-table").write_text(
_lib_table("fp_lib_table", [(LIB_NICKNAME, str(root / "footprints" / "Visaton.pretty"))]),
encoding="utf-8",
)
return BuildResult(
directory=outdir,
schematic=sch_path,
project_file=pro_path,
sheet=sheet,
resolutions=resolutions,
)
def load_parts(
catalog_path: str | None = None, library_path: str | None = None
) -> tuple[Catalog, SymbolLibrary]:
from . import catalog as catalog_mod
cat = catalog_mod.load(catalog_path)
lib = symbols_mod.load(library_path)
return cat, lib