"""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~~#@$ 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}