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:
250
tools/epro2/std/sch_writer.py
Normal file
250
tools/epro2/std/sch_writer.py
Normal 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}
|
||||
Reference in New Issue
Block a user