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,229 +1,62 @@
"""Convert one EPRO2 SCH_PAGE Document → an EasyEDA Std-format schematic JSON.
"""Convert one EPRO2 SCH_PAGE Document → an EasyEDA Std-shaped JSON file.
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
Same Option-2 contract as ``pcb_writer.py``: hand the raw EPRO2
``objects`` dict to a downstream adapter; don't pre-compute Std
``shape[]`` strings ourselves. docType=1, layers omitted (schematic
has no copper stack-up), BBox in mil, ``head.units = "mil"``.
"""
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
objects: int = 0
bbox_x: float = 0.0
bbox_y: float = 0.0
bbox_w: float = 0.0
bbox_h: float = 0.0
def _gge() -> str:
return "gge" + _uuid.uuid4().hex[:8]
_BBOX_POINT_FIELDS: list[tuple[str, str]] = [
("x", "y"),
("startX", "startY"),
("endX", "endY"),
("centerX", "centerY"),
]
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 _gather_bbox_points(doc: Document) -> tuple[float, float, float, float]:
xs: list[float] = []
ys: list[float] = []
for obj in doc.objects.values():
for fx, fy in _BBOX_POINT_FIELDS:
x = obj.get(fx)
y = obj.get(fy)
if x is None or y is None:
continue
try:
xs.append(float(x))
ys.append(float(y))
except (TypeError, ValueError):
pass
if not xs:
return (0.0, 0.0, 0.0, 0.0)
return (min(xs), min(ys), max(xs) - min(xs), max(ys) - min(ys))
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.
"""
def write_sch_std(doc: Document) -> dict:
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
bbox_x, bbox_y, bbox_w, bbox_h = _gather_bbox_points(doc)
epro2_editor = (doc.head or {}).get("editVersion", "")
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": "",
@@ -234,17 +67,28 @@ def write_sch_std(
"dataStr": {
"head": {
"docType": "1",
"editorVersion": "facere-epro2/0.1",
"newgId": True,
"c_para": [],
"hasIdFlag": True,
"importFlag": 0,
"transformList": "",
"editorVersion": f"facere-epro2/0.1 (epro2 {epro2_editor})",
"units": "mil",
"epro2_doc_uuid": doc.doc_uuid,
"epro2_editor_version": epro2_editor,
},
"canvas": canvas,
"shape": shape,
"BBox": {"x": 0, "y": 0, "width": 0, "height": 0},
"BBox": {
"x": bbox_x,
"y": bbox_y,
"width": bbox_w,
"height": bbox_h,
},
"layers": [], # schematic has no copper stack-up
"objects": dict(doc.objects),
"preference": {},
"netColors": [],
"DRCRULE": {},
},
}
stats = WriteStats(
objects=len(doc.objects),
bbox_x=bbox_x, bbox_y=bbox_y,
bbox_w=bbox_w, bbox_h=bbox_h,
)
write_sch_std.last_stats = stats # type: ignore[attr-defined]
return {"success": True, "code": 0, "result": result}