tools/epro2/std: rewrite to Option 2 (objects dump) per downstream spec

Downstream came back with concrete requirements: don't pre-compute Std
shape[] tilde strings, just dump the raw EPRO2 `objects: {id: payload}`
dict and they'll write a ~100-LoC adapter on their side. Pulling the
tilde-mapping work back saves us from second-guessing positional fields
without their parser to verify against, and shortens our pcb_writer
from ~500 lines to ~40.

Output shape (Std envelope intact, just no `shape[]`):

    {
      "success": true, "code": 0,
      "result": {
        "uuid", "puuid", "title",
        "docType": 3 | 1,
        "components": {},
        "dataStr": {
          "head": {
            "docType": "3" | "1",
            "editorVersion": "facere-epro2/0.1 (epro2 <X.Y.Z>)",
            "units": "mil",
            "epro2_doc_uuid": ...,
            "epro2_editor_version": ...,
          },
          "BBox": {x, y, width, height},   # mil
          "layers": [...],                  # Std layer-string array
          "objects": dict(doc.objects),     # raw EPRO2, 1:1
          "preference": {}, "netColors": [], "DRCRULE": {},
        }
      }
    }

Per-doc spec downstream gave us:
  - shape[] dropped (empty placeholder misleads adapter)
  - all units mil (no mm conversion — Std canvas already declares mil)
  - head.units="mil" so adapter doesn't have to guess
  - BBox min/max across known x/y/startX/endX/centerX fields; adapter
    can refine by walking path arrays itself
  - layers[] keeps Std's 17-line default + inner SIGNAL layers actually
    used (21~Inner1.., 22~Inner2..)
  - empty stubs preference/netColors/DRCRULE for grep-based triage

New: docs/sources/epro2_to_std_mapping.md with the full EPRO2 OPTYPE →
Std verb table that downstream's adapter authors will copy from. Tables
include the layer-id remapping (the 5↔7 paste/mask flip, 11→10 outline,
12→11 multi, SIGNAL 15+→21+), PCB op mappings, SCH op mappings (marked
best-effort: no Std SCH samples in our corpus), and the 5-Voltage
placeholder COMPONENT → extra net flag trick. Extracted from the
previous Option-3 writer (commit fe6971f) so adapter writers don't
have to reverse-engineer it from source.

ESP-VoCat smoke: 6 PCB + 9 SCH = 15 JSON files, head.units=mil
preserved, no shape[] field present. 82 → 84 unit tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-29 01:41:12 +08:00
parent c6fd111d6d
commit 3866e24189
6 changed files with 639 additions and 860 deletions

View File

@@ -1,15 +1,19 @@
"""CLI: convert EPRO2 docs to EasyEDA Std-format JSON files.
"""CLI: dump EPRO2 docs to Std-shaped JSON files for downstream consumers.
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.
The output is "Option 2" per the downstream colleague's spec: Std envelope
with a raw EPRO2 ``objects: {id: payload}`` dict in place of the usual
``shape[]`` tilde-string array. Their ~100-LoC adapter walks ``objects``
and dispatches by ``_type`` to build real Std shapes — see
``docs/sources/epro2_to_std_mapping.md`` for the OPTYPE → Std verb table.
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>
Output: flat ``<doc_uuid>.json`` per doc — mirrors Std's own data layout
so a downstream pipeline that already iterates ``source/*.json`` works
unchanged.
"""
from __future__ import annotations
@@ -19,85 +23,81 @@ 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:
def _dump(payload: dict, out_path: Path, project_uuid: str) -> None:
payload["result"]["puuid"] = project_uuid or ""
out_path.write_text(
json.dumps(payload, ensure_ascii=False, separators=(",", ":")),
encoding="utf-8",
)
def _convert_pcbs(proj: Project, out_dir: Path) -> int:
uuids = [u for u, d in proj.documents.items() if d.doc_type == "PCB"]
if not uuids:
return 0
print(f"PCB: converting {len(pcb_uuids)} doc(s) → {out_dir}")
for u in pcb_uuids:
print(f"PCB: dumping {len(uuids)} doc(s) → {out_dir}")
for u in uuids:
try:
payload = write_pcb_std(proj.documents[u], project_relations=pr)
payload = write_pcb_std(proj.documents[u])
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",
)
_dump(payload, out_dir / f"{u}.json", proj.project_uuid or "")
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}"
f" {u[:12]}.json: objects={s.objects} layers={s.layers_emitted} "
f"BBox=({s.bbox_x:g},{s.bbox_y:g},{s.bbox_w:g},{s.bbox_h:g})"
)
return len(pcb_uuids)
return len(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:
def _convert_schs(proj: Project, out_dir: Path) -> int:
uuids = [u for u, d in proj.documents.items() if d.doc_type == "SCH_PAGE"]
if not uuids:
return 0
print(f"SCH: converting {len(sch_uuids)} doc(s) → {out_dir}")
for u in sch_uuids:
print(f"SCH: dumping {len(uuids)} doc(s) → {out_dir}")
for u in uuids:
try:
payload = write_sch_std(proj.documents[u], project_relations=pr)
payload = write_sch_std(proj.documents[u])
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",
)
_dump(payload, out_dir / f"{u}.json", proj.project_uuid or "")
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}"
f" {u[:12]}.json: objects={s.objects} "
f"BBox=({s.bbox_x:g},{s.bbox_y:g},{s.bbox_w:g},{s.bbox_h:g})"
)
return len(sch_uuids)
return len(uuids)
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description="EPRO2 → EasyEDA Std JSON exporter")
ap = argparse.ArgumentParser(description="EPRO2 → EasyEDA Std-shaped JSON dump")
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")
g.add_argument("--all-pcb", action="store_true", help="dump every PCB doc")
g.add_argument("--all-sch", action="store_true", help="dump every SCH_PAGE doc")
g.add_argument("--all", action="store_true", help="dump 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)
n += _convert_pcbs(proj, args.out)
if args.all_sch or args.all:
n += _convert_schs(proj, args.out, pr)
n += _convert_schs(proj, args.out)
if n == 0:
print("nothing to convert (no PCB / SCH_PAGE docs found)", file=sys.stderr)
print("nothing to dump (no PCB / SCH_PAGE docs found)", file=sys.stderr)
return 1
return 0