"""Convert one EPRO2 PCB Document → an EasyEDA Std-format PCB JSON. Std PCB format (probed on `data/raw/oshwhub/3e2f893d.../25931ddab8.json`): { "success": true, "code": 0, "result": { "uuid": , "puuid": , "title": "...", "docType": 3, "components": {: , ...}, "dataStr": { "head": {"docType":"3","editorVersion":"...","x":...,"y":...}, "canvas": "CA~~~~...", "shape": ["TRACK~...", "PAD~...", "LIB~...#@$PAD~...#@$TEXT~...", ...], "layers": ["1~TopLayer~#FF0000~true~true~true~", ...], "objects": [], "BBox": {...} } } } Each shape verb is a tilde-delimited string. LIB shapes nest inner PAD/TEXT via the ``#@$`` separator so a footprint placement is one outer LIB string plus its body. Phase-1 scope (mirrors the KiCad PCB writer): TRACK / VIA / COPPERAREA / RECT / CIRCLE / SOLIDREGION / LIB(+PAD+TEXT). Skipped: SVGNODE bitmaps, manual FILL on copper (rare), TEARDROP fillets (cosmetic). """ from __future__ import annotations import json import math import uuid as _uuid from dataclasses import dataclass, field from typing import TYPE_CHECKING from ..relations import Relations from ..replay import Document if TYPE_CHECKING: from ..project_relations import ProjectRelations # -- EPRO2 layer id → Std layer id -------------------------------------- # # Std uses a different numbering than EPRO2. Probed from a Std PCB file's # `layers` block; mismatches in the 5/6/7/8 (mask/paste) range are real. EPRO_TO_STD_LAYER: dict[int, int] = { 1: 1, # TOP_LAYER 2: 2, # BOTTOM_LAYER 3: 3, # TOP_SILK 4: 4, # BOT_SILK 5: 7, # TOP_SOLDER_MASK (Std 7, EPRO2 5 — flipped vs PASTE) 6: 8, # BOT_SOLDER_MASK 7: 5, # TOP_PASTE_MASK (Std 5, EPRO2 7) 8: 6, # BOT_PASTE_MASK 9: 13, # TOP_ASSEMBLY 10: 14, # BOT_ASSEMBLY 11: 10, # OUTLINE → BoardOutLine 12: 11, # MULTI → Multi-Layer 13: 12, # DOCUMENT 14: 15, # MECHANICAL } # EPRO2 SIGNAL inner layers 15..46 → Std Inner1..Inner30 = layer ids 21..50. # Default Std layer block — we emit the standard ones plus any inner layers # the board actually uses. Matches the "layers" format `id~name~color~visible # ~active~locked~clearance/type`. _DEFAULT_STD_LAYERS: list[str] = [ "1~TopLayer~#FF0000~true~true~true~", "2~BottomLayer~#0000FF~true~false~true~", "3~TopSilkLayer~#FFCC00~true~false~true~", "4~BottomSilkLayer~#66CC33~true~false~true~", "5~TopPasteMaskLayer~#808080~true~false~true~", "6~BottomPasteMaskLayer~#800000~true~false~true~", "7~TopSolderMaskLayer~#800080~true~false~true~0.3", "8~BottomSolderMaskLayer~#AA00FF~true~false~true~0.3", "9~Ratlines~#6464FF~true~false~true~", "10~BoardOutLine~#FF00FF~true~false~true~", "11~Multi-Layer~#C0C0C0~true~false~true~", "12~Document~#FFFFFF~true~false~true~", "13~TopAssembly~#33CC99~false~false~false~", "14~BottomAssembly~#5555FF~false~false~false~", "15~Mechanical~#F022F0~false~false~false~", ] @dataclass class WriteStats: tracks: int = 0 vias: int = 0 copperareas: int = 0 rects: int = 0 circles: int = 0 solidregions: int = 0 libs: int = 0 libs_unresolved: int = 0 pads: int = 0 texts: int = 0 holes: int = 0 skipped: int = 0 def _gge() -> str: """Std prefixes ids with `gge<8 hex>`. We use uuid4 hex slice for uniqueness; downstream tools accept any unique opaque string here.""" return "gge" + _uuid.uuid4().hex[:8] def _num(v) -> str: """Format a number like Std does (no trailing .0, but keep precision).""" 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 _layer(epro_layer_id, signal_inner_map: dict[int, int]) -> str: if epro_layer_id is None: return "0" try: lid = int(epro_layer_id) except (TypeError, ValueError): return "0" if lid in EPRO_TO_STD_LAYER: return str(EPRO_TO_STD_LAYER[lid]) if lid in signal_inner_map: return str(signal_inner_map[lid]) return str(lid) def _build_signal_inner_map(doc: Document) -> dict[int, int]: """EPRO2 SIGNAL inner layer ids (15+) → Std inner ids (21..50).""" inner_ids: list[int] = [] for oid, obj in doc.objects.items(): if obj.get("_type") != "LAYER": continue if obj.get("layerType") != "SIGNAL": continue if oid.startswith('["LAYER",'): try: cid = json.loads(oid) lid = int(cid[1]) if obj.get("use") and lid >= 15: inner_ids.append(lid) except (ValueError, IndexError, TypeError): continue inner_ids.sort() return {lid: 21 + i for i, lid in enumerate(inner_ids)} def _track(line: dict, signal_inner_map: dict[int, int]) -> str | None: layer = _layer(line.get("layerId"), signal_inner_map) width = _num(line.get("width") or 6) net = str(line.get("netName") or "") pts = ( f"{_num(line.get('startX'))} {_num(line.get('startY'))} " f"{_num(line.get('endX'))} {_num(line.get('endY'))}" ) return f"TRACK~{width}~{layer}~{net}~{pts}~{_gge()}~0" def _via(via: dict) -> str: cx = _num(via.get("centerX")) cy = _num(via.get("centerY")) outer = _num(via.get("viaDiameter")) inner = _num(via.get("holeDiameter")) net = str(via.get("netName") or "") return f"VIA~{cx}~{cy}~{outer}~{net}~{inner}~{_gge()}~0" def _path_to_svg(path) -> str: """EPRO2 path tokens → SVG-ish 'M x y L x y ...' string used by Std COPPERAREA / SOLIDREGION shapes. ARC tokens collapse to chord segments (Phase-1 same call as the KiCad writer). Numbers are formatted via ``_num`` (no trailing ``.0`` on integers) so the output matches Std's typographic conventions exactly.""" if not isinstance(path, list) or not path: return "" if isinstance(path[0], list): # path is wrapped in an extra outer list, like POUR.path = [[...]] path = path[0] head = path[0] if path else None if isinstance(head, str) and head.upper() == "R" and len(path) >= 5: try: x = float(path[1]); y = float(path[2]) w = float(path[3]); h = float(path[4]) return ( f"M {_num(x)} {_num(y)} L {_num(x + w)} {_num(y)} " f"L {_num(x + w)} {_num(y + h)} L {_num(x)} {_num(y + h)} Z" ) except (TypeError, ValueError): pass if isinstance(head, str) and head.upper() == "CIRCLE" and len(path) >= 4: try: cx = float(path[1]); cy = float(path[2]); r = float(path[3]) pts = [] for i in range(24): a = 2 * math.pi * i / 24 pts.append((cx + r * math.cos(a), cy + r * math.sin(a))) return "M " + " L ".join(f"{_num(x)} {_num(y)}" for x, y in pts) + " Z" except (TypeError, ValueError): pass out: list[str] = [] i = 0 started = False while i < len(path): tok = path[i] if isinstance(tok, str): if tok.upper() == "ARC": try: ex = float(path[i + 2]); ey = float(path[i + 3]) out.append(f"L {_num(ex)} {_num(ey)}") except (TypeError, ValueError, IndexError): pass i += 4 continue i += 1 continue try: x = float(path[i]); y = float(path[i + 1]) out.append(("M" if not started else "L") + f" {_num(x)} {_num(y)}") started = True i += 2 except (TypeError, ValueError, IndexError): i += 1 return " ".join(out) def _copperarea(pour: dict, signal_inner_map: dict[int, int]) -> str | None: layer = _layer(pour.get("layerId"), signal_inner_map) net = str(pour.get("netName") or "") svg = _path_to_svg(pour.get("path")) if not svg: return None width = _num(pour.get("width") or 1) # COPPERAREA~clearance~layer~net~svgPath~strokeWidth~~~~~~~uuid? return f"COPPERAREA~1~{layer}~{net}~{svg}~{width}~~~~~~~{_gge()}~0" def _circle(obj: dict, signal_inner_map: dict[int, int]) -> str | None: """Used for non-copper graphic POLYs whose path is `['CIRCLE', cx, cy, r]`.""" path = obj.get("path") or [] head = path[0] if path else None if isinstance(head, list): head = head[0] if head else None if not (isinstance(head, str) and head.upper() == "CIRCLE"): return None inner = path[0] if isinstance(path[0], list) else path try: cx = _num(inner[1]); cy = _num(inner[2]); r = _num(inner[3]) except (TypeError, IndexError, ValueError): return None layer = _layer(obj.get("layerId"), signal_inner_map) width = _num(obj.get("width") or 1) return f"CIRCLE~{cx}~{cy}~{r}~{width}~{layer}~{_gge()}~0~~" def _solidregion(obj: dict) -> str | None: """EPRO2 FILL → Std SOLIDREGION.""" svg = _path_to_svg(obj.get("path")) if not svg: return None return f"SOLIDREGION~99~~{svg}~solid~{_gge()}~~~~0" def _pad_for_lib(pad: dict, comp_id: str, pcb_rel: Relations, signal_inner_map: dict[int, int]) -> str | None: """Std nested PAD inside a LIB, format: PAD~shape~x~y~width~height~layer~net~num~drillSize~~rot~uuid~~~plated~?~?~clearance~paste""" default_pad = pad.get("defaultPad") or {} shape_map = {"RECT": "RECT", "ELLIPSE": "ELLIPSE", "OVAL": "OVAL", "POLYGON": "POLYGON"} shape = shape_map.get(default_pad.get("padType")) if shape is None: return None cx = _num(pad.get("centerX")) cy = _num(pad.get("centerY")) w = _num(default_pad.get("width")) h = _num(default_pad.get("height")) layer = _layer(pad.get("layerId"), signal_inner_map) rot = _num(pad.get("padAngle") or 0) pin_num = str(pad.get("num") or "") # Net via PCB-level PAD_NET (cross-doc, like in footprint_writer) net_name = "" pad_id = next( (pid for pid in pcb_rel.pads if pcb_rel.pads[pid] is pad), # rare path; usually pad_id is the dict key None, ) # The standard path: walk pad_nets_by_pad keyed by the pad's id. # We don't have the id here; caller passes it via outer loop. net_name = "" # Hole hole = pad.get("hole") drill = "0" if hole: drill = _num(hole.get("width") or 0) # Phase-1 leaves the trailing free-form metadata empty but keeps the # field count; downstream Std parsers can tolerate empties but balk # at missing positional fields. return ( f"PAD~{shape}~{cx}~{cy}~{w}~{h}~{layer}~{net_name}~{pin_num}~{drill}~~" f"{rot}~{_gge()}~0~~Y~0~0~0.2~{cx},{cy}" ) def _lib(comp_id: str, comp: dict, attrs: dict, fp_doc: Document, pcb_rel: Relations, signal_inner_map: dict[int, int]) -> tuple[str, int]: """Build a Std LIB shape with nested PAD / TEXT children separated by `#@$`. Returns ``(lib_string, pad_count_for_stats)``. """ px = _num(comp.get("x")) py = _num(comp.get("y")) rot = _num(comp.get("angle") or 0) designator = str(attrs.get("Designator") or "") fp_title = (fp_doc.objects.get("META") or {}).get("title") or fp_doc.doc_uuid[:8] package = f"{fp_title}`" # Std emits a trailing backtick after package name # Outer LIB: x, y, package_name, rotation, ?, uuid, display, ?, ?, locked, # ?, yes, ? outer = ( f"LIB~{px}~{py}~{package}~{rot}~~{_gge()}~1~~~0~~yes~~" ) # Build inner PAD blocks per FOOTPRINT.PAD with its abs (footprint-local) # coords offset to the placement origin. Std stores pad coords as # absolute board coords; we therefore translate from footprint-local # to PCB absolute here. rel_fp = Relations.build(fp_doc) inners: list[str] = [] pad_count = 0 px_f = float(comp.get("x") or 0) py_f = float(comp.get("y") or 0) rot_f = math.radians(float(comp.get("angle") or 0)) cos_a, sin_a = math.cos(rot_f), math.sin(rot_f) for pad_id, pad in rel_fp.pads.items(): local_x = float(pad.get("centerX") or 0) local_y = float(pad.get("centerY") or 0) # Apply placement rotation to the local (x, y) then translate. abs_x = px_f + local_x * cos_a - local_y * sin_a abs_y = py_f + local_x * sin_a + local_y * cos_a default_pad = pad.get("defaultPad") or {} shape_kind = default_pad.get("padType") or "RECT" w = _num(default_pad.get("width")) h = _num(default_pad.get("height")) layer = _layer(pad.get("layerId"), signal_inner_map) pad_rot = _num((float(pad.get("padAngle") or 0) + float(comp.get("angle") or 0)) % 360) pin_num = str(pad.get("num") or "") # Net resolution via PCB PAD_NET (cross-doc) net_name = "" for record in pcb_rel.pad_nets_by_pad.get(pad_id, []): if record.get("comp") == comp_id: net_name = record.get("net_name") or "" break hole = pad.get("hole") drill = _num(hole.get("width")) if hole else "0" inners.append( f"PAD~{shape_kind}~{_num(abs_x)}~{_num(abs_y)}~{w}~{h}~{layer}~" f"{net_name}~{pin_num}~{drill}~~{pad_rot}~{_gge()}~0~~Y~0~0~0.2~" f"{_num(abs_x)},{_num(abs_y)}" ) pad_count += 1 # Designator text (Std treats it as P=property) if designator: inners.append( f"TEXT~P~{px}~{py}~0.7~0~0~3~~4.5~{designator}~~~" ) body = "#@$".join([outer] + inners) return body, pad_count def write_pcb_std( doc: Document, *, project_relations: "ProjectRelations" | None = None, ) -> dict: """EPRO2 PCB Document → Std-format JSON dict (ready for json.dump).""" if doc.doc_type != "PCB": raise ValueError(f"expected PCB doc, got {doc.doc_type!r}") rel = Relations.build(doc) signal_inner_map = _build_signal_inner_map(doc) stats = WriteStats() shape: list[str] = [] # Tracks for oid, obj in doc.objects.items(): if obj.get("_type") != "LINE": continue # Std TRACK is for any layer (copper or silk), unlike KiCad which # splits copper→segment / silk→gr_line. Std uses the same verb, # disambiguated by layer id. track = _track(obj, signal_inner_map) if track: shape.append(track) stats.tracks += 1 # Vias for oid, obj in doc.objects.items(): if obj.get("_type") == "VIA": shape.append(_via(obj)) stats.vias += 1 # Copper pours for oid, obj in doc.objects.items(): if obj.get("_type") != "POUR": continue s = _copperarea(obj, signal_inner_map) if s: shape.append(s) stats.copperareas += 1 else: stats.skipped += 1 # POLY graphics: circles vs polygons → CIRCLE / SOLIDREGION for oid, obj in doc.objects.items(): if obj.get("_type") != "POLY": continue c = _circle(obj, signal_inner_map) if c: shape.append(c) stats.circles += 1 continue s = _solidregion(obj) if s: shape.append(s) stats.solidregions += 1 # FILL (manual filled regions) → SOLIDREGION for oid, obj in doc.objects.items(): if obj.get("_type") != "FILL": continue s = _solidregion(obj) if s: shape.append(s) stats.solidregions += 1 # Footprint placements (LIB with nested PAD/TEXT) components_dict: dict[str, int] = {} if project_relations is not None: for cid, comp in rel.components.items(): fp_uuid = project_relations.resolve_footprint_doc(doc.doc_uuid, cid) if not fp_uuid or fp_uuid not in project_relations.project.documents: stats.libs_unresolved += 1 continue fp_doc = project_relations.project.documents[fp_uuid] attrs = rel.attrs_dict(cid) try: lib_str, pad_count = _lib(cid, comp, attrs, fp_doc, rel, signal_inner_map) except Exception: stats.skipped += 1 continue shape.append(lib_str) stats.libs += 1 stats.pads += pad_count components_dict[fp_uuid] = components_dict.get(fp_uuid, 0) + 1 # ---- envelope ---- title = (doc.objects.get("META") or {}).get("title") or doc.doc_uuid[:12] canvas_x = "4000" canvas_y = "3000" canvas = ( f"CA~1000~1000~#000000~yes~#FFFFFF~0.1~1000~1000~line~0.1~mm~" f"4.499991~45~visible~0.1~{canvas_x}~{canvas_y}~0~yes" ) # Add inner SIGNAL layers Std actually saw on this board layers = list(_DEFAULT_STD_LAYERS) for i, std_id in enumerate(sorted(signal_inner_map.values())): layers.append(f"{std_id}~Inner{i+1}~#999966~true~false~true~0~Signal") result = { "uuid": doc.doc_uuid, "puuid": "", # filled in by caller if known "title": title, "description": "", "docType": 3, "components": components_dict, "dataStr": { "head": { "docType": "3", "editorVersion": "facere-epro2/0.1", "newgId": True, "c_para": [], "x": canvas_x, "y": canvas_y, "hasIdFlag": True, "importFlag": 0, "transformList": "", }, "canvas": canvas, "shape": shape, "layers": layers, "objects": [], "BBox": {"x": 0, "y": 0, "width": 0, "height": 0}, "preference": {}, "DRCRULE": {}, "netColors": [], }, } write_pcb_std.last_stats = stats # type: ignore[attr-defined] return {"success": True, "code": 0, "result": result}