"""Convert one EPRO2 PCB Document → an EasyEDA Std-shaped JSON file that hands the raw EPRO2 ``objects`` dict to a downstream adapter. This is the **Option 2** form the downstream consumer asked for: we keep the Std envelope (``success`` / ``code`` / ``result.dataStr.head / BBox / layers / objects``) but **don't** emit a ``shape[]`` array of tilde-delimited strings. Their adapter walks ``objects`` and dispatches by ``_type`` to build the actual Std shape strings — see ``docs/sources/epro2_to_std_mapping.md`` for the EPRO2 OPTYPE → Std verb table they should follow. Field choices (per downstream's spec, 2026-04-29): - units = "mil" (Std's internal canvas unit; no mm conversion here) - BBox in mil too (same units as objects) - head.editorVersion taken from EPRO2 doc.head.editVersion - head.docType "3" (PCB) so adapter selects the PCB branch - shape[] OMITTED — empty placeholder would mislead the adapter - preference / netColors / DRCRULE kept as empty stubs so grep paths are stable for failure triage """ from __future__ import annotations import math from dataclasses import dataclass from ..relations import Relations from ..replay import Document @dataclass class WriteStats: objects: int = 0 bbox_x: float = 0.0 bbox_y: float = 0.0 bbox_w: float = 0.0 bbox_h: float = 0.0 layers_emitted: int = 0 # Std layer-block format. Same content the EasyEDA editor writes — keeping # the textual layout 1:1 means downstream layer-id lookups don't have to # special-case our output. Inner SIGNAL layers (21+) get appended on demand # from the actual EPRO2 SIGNAL layer ids that carry geometry on this board. _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~", ] # EPRO2 fields that carry x/y coordinates we should consider for BBox # computation. Most ops follow one of two conventions: ``(centerX, # centerY)`` for symmetric primitives (VIA, FILL, PAD), and # ``(startX..endX, startY..endY)`` for line segments. _BBOX_POINT_FIELDS: list[tuple[str, str]] = [ ("x", "y"), ("startX", "startY"), ("endX", "endY"), ("centerX", "centerY"), ] def _gather_bbox_points(doc: Document) -> tuple[float, float, float, float]: """Best-effort BBox from every numeric x/y pair we recognize. Skips ``path`` arrays — they're heterogeneous (rectangles use ``['R', x, y, w, h]``, polylines mix verb tokens with numbers). Adapters can refine BBox once they parse paths; ours is the gross outer rectangle that's good enough for canvas centering. """ 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) x0, x1 = min(xs), max(xs) y0, y1 = min(ys), max(ys) return (x0, y0, x1 - x0, y1 - y0) def _used_inner_signal_layers(doc: Document) -> list[int]: """EPRO2 SIGNAL inner layer ids 15+ that have actual primitives on them — those are the ones we add to the Std layer block as Inner1..InnerN. Layer ops that only declare 'use=True' but carry no geometry don't need to leak into the Std layers list.""" used: set[int] = set() for obj in doc.objects.values(): lid = obj.get("layerId") if lid is None: continue try: n = int(lid) except (TypeError, ValueError): continue if n >= 15: used.add(n) return sorted(used) def write_pcb_std(doc: Document) -> dict: """EPRO2 PCB Document → Std-shaped JSON dict (ready for json.dump). Returns the raw envelope; CLI is responsible for json.dumps + write. """ if doc.doc_type != "PCB": raise ValueError(f"expected PCB doc, got {doc.doc_type!r}") bbox_x, bbox_y, bbox_w, bbox_h = _gather_bbox_points(doc) inner_signal_ids = _used_inner_signal_layers(doc) layers = list(_DEFAULT_STD_LAYERS) for i, eid in enumerate(inner_signal_ids): std_id = 21 + i layers.append(f"{std_id}~Inner{i+1}~#999966~true~false~true~0~Signal") # editVersion is on the EPRO2 head dict we filled in replay.py. epro2_editor = (doc.head or {}).get("editVersion", "") title = (doc.objects.get("META") or {}).get("title") or doc.doc_uuid[:12] result = { "uuid": doc.doc_uuid, "puuid": "", "title": title, "description": "", "docType": 3, "components": {}, "dataStr": { "head": { "docType": "3", "editorVersion": f"facere-epro2/0.1 (epro2 {epro2_editor})", "units": "mil", "epro2_doc_uuid": doc.doc_uuid, "epro2_editor_version": epro2_editor, }, "BBox": { "x": bbox_x, "y": bbox_y, "width": bbox_w, "height": bbox_h, }, "layers": layers, "objects": dict(doc.objects), # Empty stubs the downstream pipeline checks for presence: "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, layers_emitted=len(layers), ) write_pcb_std.last_stats = stats # type: ignore[attr-defined] return {"success": True, "code": 0, "result": result}