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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user