tools/epro2: add std/ writer — EPRO2 → EasyEDA Std-format JSON for downstream

The downstream colleague consumes oshwhub Std (lceda) dict-format JSON,
not KiCad. The EPRO2 decryption part (per-doc plaintext .epro2 streams
in data/raw/<uuid>/source/) is what we already provide; the missing
piece is converting EPRO2 op-streams into the same `dataStr.shape`
tilde-delimited format their parser already speaks.

New tools/epro2/std/ module, peer of tools/epro2/kicad/, kept
deliberately separate so the KiCad path stays untouched:

  - pcb_writer.write_pcb_std() — high-fidelity, validated against a Std
    PCB sample at data/raw/oshwhub/3e2f893d.../25931ddab8.json. Maps
    LINE→TRACK, VIA→VIA, POUR→COPPERAREA (with SVG `M..L..Z` path),
    POLY→CIRCLE/SOLIDREGION, COMPONENT+FOOTPRINT→LIB nested with
    #@$-separated PADs (placement rotation + translate applied so pad
    coords land at PCB-absolute positions). Layer-id mapping (EPRO2 5↔7
    flipped vs Std solder/paste, 11→10 outline, 12→11 multi, SIGNAL
    inner 15+ → Std 21+) noted inline.

  - sch_writer.write_sch_std() — best-effort. Our corpus has zero Std
    schematic samples (docType=1) so verb field orders follow the
    EasyEDA Std public spec, not direct observation. Emits W (wire),
    N (net flag, including the 5-Voltage Global Net Name power-port
    pattern), T (text), LIB (placement with #@$-nested PIN/T). If
    downstream's parser bails the fix is almost certainly a positional
    field tweak, not a re-architecture.

  - __main__.py — flat output `<doc_uuid>.json` per doc directly under
    --out (mirrors Std's own data layout); --all-pcb / --all-sch / --all.

Smoke test on ESP-VoCat: 6 PCB + 9 SCH = 15 JSON files, libs_unresolved=0
across the board. Compact JSON (separators=(",",":")) matches Std's
single-line format. Numbers use _num() — integers without trailing .0,
floats trimmed.

71 → 82 unit tests pass.

Open questions for downstream: (1) confirm SCH verb field orders, (2)
do they want any of the upstream metadata fields we drop (master,
owner, created_at, etc — those live on the crawler side, not the
schematic itself)?

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-29 01:16:39 +08:00
parent ed713fa557
commit fe6971f3f9
6 changed files with 1155 additions and 0 deletions

106
tools/epro2/std/__main__.py Normal file
View File

@@ -0,0 +1,106 @@
"""CLI: convert EPRO2 docs to EasyEDA Std-format JSON files.
Mirrors the layout of Std project sources: one ``<doc_uuid>.json`` per
document, flat in ``--out``. Use this for downstream consumers that
already speak Std (Wokwi-based pipelines, dataStr parsers, etc.) — the
KiCad writer at ``tools.epro2.kicad`` is the alternate target for
downstream that wants kicad_sch / kicad_pcb instead.
Usage:
uv run python -m tools.epro2.std <project_dir> --all-pcb --out <dir>
uv run python -m tools.epro2.std <project_dir> --all-sch --out <dir>
uv run python -m tools.epro2.std <project_dir> --all --out <dir>
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from ..project_relations import ProjectRelations
from ..replay import Project, replay_project
from .pcb_writer import write_pcb_std
from .sch_writer import write_sch_std
def _convert_pcbs(proj: Project, out_dir: Path, pr: ProjectRelations) -> int:
pcb_uuids = [u for u, d in proj.documents.items() if d.doc_type == "PCB"]
if not pcb_uuids:
return 0
print(f"PCB: converting {len(pcb_uuids)} doc(s) → {out_dir}")
for u in pcb_uuids:
try:
payload = write_pcb_std(proj.documents[u], project_relations=pr)
except Exception as e: # noqa: BLE001
print(f" FAIL {u[:12]}: {e}", file=sys.stderr)
continue
# Stamp puuid so downstream can wire docs back to a project
payload["result"]["puuid"] = proj.project_uuid or ""
(out_dir / f"{u}.json").write_text(
json.dumps(payload, ensure_ascii=False, separators=(",", ":")),
encoding="utf-8",
)
s = getattr(write_pcb_std, "last_stats", None)
if s:
print(
f" {u[:12]}.json: tracks={s.tracks} vias={s.vias} "
f"copperareas={s.copperareas} libs={s.libs} pads={s.pads} "
f"libs_unresolved={s.libs_unresolved}"
)
return len(pcb_uuids)
def _convert_schs(proj: Project, out_dir: Path, pr: ProjectRelations) -> int:
sch_uuids = [u for u, d in proj.documents.items() if d.doc_type == "SCH_PAGE"]
if not sch_uuids:
return 0
print(f"SCH: converting {len(sch_uuids)} doc(s) → {out_dir}")
for u in sch_uuids:
try:
payload = write_sch_std(proj.documents[u], project_relations=pr)
except Exception as e: # noqa: BLE001
print(f" FAIL {u[:12]}: {e}", file=sys.stderr)
continue
payload["result"]["puuid"] = proj.project_uuid or ""
(out_dir / f"{u}.json").write_text(
json.dumps(payload, ensure_ascii=False, separators=(",", ":")),
encoding="utf-8",
)
s = getattr(write_sch_std, "last_stats", None)
if s:
print(
f" {u[:12]}.json: wires={s.wires} libs={s.libs} "
f"netflags={s.netflags} texts={s.texts} libs_unresolved={s.libs_unresolved}"
)
return len(sch_uuids)
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description="EPRO2 → EasyEDA Std JSON exporter")
ap.add_argument("project_dir", type=Path)
g = ap.add_mutually_exclusive_group(required=True)
g.add_argument("--all-pcb", action="store_true", help="convert every PCB doc to Std JSON")
g.add_argument("--all-sch", action="store_true", help="convert every SCH_PAGE doc to Std JSON")
g.add_argument("--all", action="store_true", help="convert both PCB and SCH_PAGE docs")
ap.add_argument("--out", type=Path, default=Path("data/processed/std_json"))
args = ap.parse_args(argv)
proj = replay_project(args.project_dir)
args.out.mkdir(parents=True, exist_ok=True)
pr = ProjectRelations.build(proj)
n = 0
if args.all_pcb or args.all:
n += _convert_pcbs(proj, args.out, pr)
if args.all_sch or args.all:
n += _convert_schs(proj, args.out, pr)
if n == 0:
print("nothing to convert (no PCB / SCH_PAGE docs found)", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())