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

View File

@@ -0,0 +1,250 @@
"""Convert one EPRO2 SCH_PAGE Document → an EasyEDA Std-format schematic JSON.
Std schematic format (docType=1) — best-effort. Our oshwhub corpus contains
only Std PCBs (docType=3), no Std schematic samples to validate field
order against. The verbs below match the public EasyEDA Std schematic
spec (LIB / W / J / N / T / R / C / A / PL / PG); the field orders are
derived from the same spec and may need adjustment if downstream's
parser expects different positional layout.
Verbs:
LIB - symbol library reference (placement), with #@$-nested PIN/TEXT
W - wire segment
J - junction
N - net flag (port / netlabel / global label)
T - text
R/C/A/PL/PG - graphics
"""
from __future__ import annotations
import math
import uuid as _uuid
from dataclasses import dataclass
from typing import TYPE_CHECKING
from ..relations import Relations
from ..replay import Document
if TYPE_CHECKING:
from ..project_relations import ProjectRelations
@dataclass
class WriteStats:
libs: int = 0
libs_unresolved: int = 0
wires: int = 0
netflags: int = 0
texts: int = 0
graphics: int = 0
skipped: int = 0
def _gge() -> str:
return "gge" + _uuid.uuid4().hex[:8]
def _num(v) -> str:
if v is None:
return "0"
try:
f = float(v)
except (TypeError, ValueError):
return "0"
if math.isclose(f, int(f), abs_tol=1e-9):
return str(int(f))
return f"{f:.4f}".rstrip("0").rstrip(".")
def _wire(line: dict) -> str:
"""W~strokeColor~strokeWidth~strokeStyle~points~uuid~?
EPRO2 LINE on a schematic is a wire segment. Std joins multi-segment
wires by sharing endpoint coords — we emit one W per LINE and let
downstream's connectivity merge by geometry (same logic the KiCad
writer relies on)."""
pts = (
f"{_num(line.get('startX'))} {_num(line.get('startY'))} "
f"{_num(line.get('endX'))} {_num(line.get('endY'))}"
)
return f"W~#000080~1~0~{pts}~{_gge()}~0"
def _netflag(name: str, x, y, rot=0) -> str:
"""N~x~y~rot~text~uuid~?
Std uses one flag per labelled net endpoint. Same name on two flags
implies they're on the same net — same trick we used with
(global_label) in the KiCad path."""
return f"N~{_num(x)}~{_num(y)}~{_num(rot)}~{name}~{_gge()}~0"
def _text(obj: dict) -> str:
val = str(obj.get("value") or "").strip()
if not val:
return ""
return f"T~{_num(obj.get('x'))}~{_num(obj.get('y'))}~{_num(obj.get('rotation') or 0)}~{val}~{_gge()}~0"
def _lib_inner_pin(pin_obj: dict, attrs: dict) -> str:
"""Std PIN inside a LIB: PIN~display~electric~spice~rotation~configure~
partid~name~num~..."""
pin_num = str(attrs.get("Pin Number") or "")
pin_name = str(attrs.get("Pin Name") or "")
px = _num(pin_obj.get("x"))
py = _num(pin_obj.get("y"))
rot = _num(pin_obj.get("rotation") or 0)
length = _num(pin_obj.get("length"))
# Field order is best-effort — Std PIN spec varies by editor version.
return f"P~show~0~~{px}~{py}~{rot}~{_gge()}^^{pin_num}^^{pin_name}^^{length}"
def _lib(comp_id: str, comp: dict, attrs: dict, sym_doc: Document) -> str:
"""LIB~x~y~attrs~rot~~uuid~display~~~lock~~yes~~#@$<inner shapes>
For schematic, inner shapes are PIN / TEXT / RECT / POLY / CIRCLE
derived from the SYMBOL doc that this COMPONENT instantiates."""
px = _num(comp.get("x"))
py = _num(comp.get("y"))
rot = _num(comp.get("rotation") or 0)
designator = str(attrs.get("Designator") or "")
value = str(attrs.get("Value") or "")
title = (sym_doc.objects.get("META") or {}).get("title") or sym_doc.doc_uuid[:8]
outer = (
f"LIB~{px}~{py}~package`{title}`~{rot}~~{_gge()}~1~~~0~~yes~~"
)
# Inner: each PIN + a designator/value text
rel_sym = Relations.build(sym_doc)
inners: list[str] = []
for oid, obj in sym_doc.objects.items():
if obj.get("_type") != "PIN":
continue
pin_attrs = rel_sym.attrs_dict(oid)
inners.append(_lib_inner_pin(obj, pin_attrs))
# Visible designator + value (best-effort field positions)
if designator:
inners.append(f"T~P~{px}~{_num(float(comp.get('y') or 0) - 5)}~0~{designator}~{_gge()}~0")
if value:
inners.append(f"T~P~{px}~{_num(float(comp.get('y') or 0) + 5)}~0~{value}~{_gge()}~0")
return "#@$".join([outer] + inners)
def write_sch_std(
doc: Document,
*,
project_relations: "ProjectRelations" | None = None,
) -> dict:
"""EPRO2 SCH_PAGE Document → Std-format schematic JSON dict.
The output is best-effort against the public EasyEDA Std schematic
spec — we have no Std SCH samples in the corpus to verify positional
field orders. If downstream's parser rejects a verb, the fix is
almost always a field count or order tweak in the helpers above.
"""
if doc.doc_type != "SCH_PAGE":
raise ValueError(f"expected SCH_PAGE doc, got {doc.doc_type!r}")
rel = Relations.build(doc)
stats = WriteStats()
shape: list[str] = []
# Wires (LINE)
wire_net_cache: dict[str, str | None] = {}
for oid, obj in doc.objects.items():
if obj.get("_type") != "LINE":
continue
sx, sy = obj.get("startX"), obj.get("startY")
ex, ey = obj.get("endX"), obj.get("endY")
if sx is None or sy is None or ex is None or ey is None:
continue
try:
if math.isclose(float(sx), float(ex)) and math.isclose(float(sy), float(ey)):
stats.skipped += 1
continue
except (TypeError, ValueError):
pass
shape.append(_wire(obj))
stats.wires += 1
# Net flag at one endpoint, named after the parent WIRE.NET attr —
# same trick as the KiCad path's global_label emission.
wid = obj.get("lineGroup")
if not wid:
continue
wid = str(wid)
if wid not in wire_net_cache:
wire_net_cache[wid] = (rel.attrs_dict(wid) or {}).get("NET")
net = wire_net_cache[wid]
if net:
shape.append(_netflag(str(net), sx, sy))
stats.netflags += 1
# Symbol placements (LIB with nested PIN/TEXT)
if project_relations is not None:
for cid, comp in rel.components.items():
sym_doc_uuids = project_relations.resolve_symbol_docs(doc.doc_uuid, cid)
if not sym_doc_uuids:
stats.libs_unresolved += 1
continue
sym_doc = project_relations.project.documents.get(sym_doc_uuids[0])
if not sym_doc:
stats.libs_unresolved += 1
continue
attrs = rel.attrs_dict(cid)
try:
shape.append(_lib(cid, comp, attrs, sym_doc))
stats.libs += 1
except Exception:
stats.skipped += 1
# 5-Voltage power-port handling (matches sch_writer KiCad logic):
# any COMPONENT with a `Global Net Name` ATTR also gets a net
# flag at its placement so power rails connect by name.
gnn = attrs.get("Global Net Name")
if gnn:
shape.append(_netflag(str(gnn), comp.get("x"), comp.get("y")))
stats.netflags += 1
# Free TEXT objects
for oid, obj in doc.objects.items():
if obj.get("_type") != "TEXT":
continue
t = _text(obj)
if t:
shape.append(t)
stats.texts += 1
title = (doc.objects.get("META") or {}).get("title") or doc.doc_uuid[:12]
canvas = (
"CA~1000~1000~#FFFFFF~yes~#000000~5~1000~1000~line~"
"5~mm~10~45~visible~0.1~0~0~0~yes"
)
result = {
"uuid": doc.doc_uuid,
"puuid": "",
"title": title,
"description": "",
"docType": 1,
"components": {},
"dataStr": {
"head": {
"docType": "1",
"editorVersion": "facere-epro2/0.1",
"newgId": True,
"c_para": [],
"hasIdFlag": True,
"importFlag": 0,
"transformList": "",
},
"canvas": canvas,
"shape": shape,
"BBox": {"x": 0, "y": 0, "width": 0, "height": 0},
},
}
write_sch_std.last_stats = stats # type: ignore[attr-defined]
return {"success": True, "code": 0, "result": result}