From fe6971f3f97002b174fa008d07f3f138df8c01e2 Mon Sep 17 00:00:00 2001 From: Knowit Date: Wed, 29 Apr 2026 01:16:39 +0800 Subject: [PATCH] =?UTF-8?q?tools/epro2:=20add=20std/=20writer=20=E2=80=94?= =?UTF-8?q?=20EPRO2=20=E2=86=92=20EasyEDA=20Std-format=20JSON=20for=20down?= =?UTF-8?q?stream?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The downstream colleague consumes oshwhub Std (lceda) dict-format JSON, not KiCad. The EPRO2 decryption part (per-doc plaintext .epro2 streams in data/raw//source/) is what we already provide; the missing piece is converting EPRO2 op-streams into the same `dataStr.shape` tilde-delimited format their parser already speaks. New tools/epro2/std/ module, peer of tools/epro2/kicad/, kept deliberately separate so the KiCad path stays untouched: - pcb_writer.write_pcb_std() — high-fidelity, validated against a Std PCB sample at data/raw/oshwhub/3e2f893d.../25931ddab8.json. Maps LINE→TRACK, VIA→VIA, POUR→COPPERAREA (with SVG `M..L..Z` path), POLY→CIRCLE/SOLIDREGION, COMPONENT+FOOTPRINT→LIB nested with #@$-separated PADs (placement rotation + translate applied so pad coords land at PCB-absolute positions). Layer-id mapping (EPRO2 5↔7 flipped vs Std solder/paste, 11→10 outline, 12→11 multi, SIGNAL inner 15+ → Std 21+) noted inline. - sch_writer.write_sch_std() — best-effort. Our corpus has zero Std schematic samples (docType=1) so verb field orders follow the EasyEDA Std public spec, not direct observation. Emits W (wire), N (net flag, including the 5-Voltage Global Net Name power-port pattern), T (text), LIB (placement with #@$-nested PIN/T). If downstream's parser bails the fix is almost certainly a positional field tweak, not a re-architecture. - __main__.py — flat output `.json` per doc directly under --out (mirrors Std's own data layout); --all-pcb / --all-sch / --all. Smoke test on ESP-VoCat: 6 PCB + 9 SCH = 15 JSON files, libs_unresolved=0 across the board. Compact JSON (separators=(",",":")) matches Std's single-line format. Numbers use _num() — integers without trailing .0, floats trimmed. 71 → 82 unit tests pass. Open questions for downstream: (1) confirm SCH verb field orders, (2) do they want any of the upstream metadata fields we drop (master, owner, created_at, etc — those live on the crawler side, not the schematic itself)? Co-Authored-By: Claude Opus 4.7 (1M context) --- log.md | 84 +++++ tools/epro2/std/__init__.py | 0 tools/epro2/std/__main__.py | 106 ++++++ tools/epro2/std/pcb_writer.py | 513 ++++++++++++++++++++++++++ tools/epro2/std/sch_writer.py | 250 +++++++++++++ tools/epro2/tests/test_std_writers.py | 202 ++++++++++ 6 files changed, 1155 insertions(+) create mode 100644 tools/epro2/std/__init__.py create mode 100644 tools/epro2/std/__main__.py create mode 100644 tools/epro2/std/pcb_writer.py create mode 100644 tools/epro2/std/sch_writer.py create mode 100644 tools/epro2/tests/test_std_writers.py diff --git a/log.md b/log.md index 3f15e86..046c4c4 100644 --- a/log.md +++ b/log.md @@ -4,6 +4,90 @@ --- +## 2026-04-29 04:00 Std-format JSON 转换器:EPRO2 → 下游同学 Wokwi pipeline 的输入格式 + +**Claude 会话** + +KiCad 那条路下游同学不需要——他们的 Wokwi pipeline 吃 oshwhub Std (lceda) 的 JSON dict-format。EPRO2 解密我们已经搞定(per-doc 流就在 `data/raw//source/`),现在缺的是把 EPRO2 op-stream 翻成 Std 的 `dataStr.shape` 字符串数组。 + +### 新增 `tools/epro2/std/`(跟 `kicad/` 平级,旧的不动) + +参照 `data/raw/oshwhub/3e2f893d.../25931ddab8.json` 一个 Std PCB 实样反推协议: +- 信封:`{success, code, result: {uuid, puuid, title, docType, components, dataStr: {head, canvas, shape, layers, ...}}}` +- shape 字符串:`VERB~field1~field2~...`,`~` 分隔 +- LIB(footprint placement)下面挂 PAD/TEXT 用 `#@$` 分隔器嵌套 + +#### 已实现 verb 映射 + +**PCB(docType=3,高保真,对照实样)**: +| EPRO2 op | Std verb | 备注 | +|---|---|---| +| LINE | TRACK | layer 单独映射 | +| VIA | VIA | 字段顺序 `x~y~outerD~net~innerD~uuid~lock` | +| POUR | COPPERAREA | path 转成 SVG `M..L..Z` | +| FILL | SOLIDREGION | 同 SVG path | +| POLY (CIRCLE) | CIRCLE | | +| COMPONENT + FOOTPRINT.PADs | LIB...#@$PAD...#@$PAD... | 内层 PAD 坐标做了 placement rotate + translate | + +**SCH(docType=1,best-effort,无实样)**: +- LINE → W(wire 段) +- LINE.lineGroup → WIRE.NET → 在端点放一个 N(net flag) +- COMPONENT → LIB...#@$P...(嵌 PIN/TEXT,包括我们之前发现的 5-Voltage 电源占位符的 Global Net Name) +- TEXT → T + +**重要 caveat**:我们 corpus 里所有 Std 项目都只有 PCB(docType=3),没有 SCH(docType=1)实样。SCH 的 verb 字段顺序是按 EasyEDA Std 公开 spec 写的,**可能跟下游 parser 实际期望的字段顺序有出入**。下游同学 review 后给反馈,错的位移修一下就行。 + +### Layer 映射(重要,跟 KiCad 不一样) + +EPRO2 跟 Std 的 layer id 不完全对齐: +- EPRO2 layer 5 (TOP_SOLDER_MASK) → Std 7 +- EPRO2 layer 7 (TOP_PASTE_MASK) → Std 5 ← 跟 5 互换! +- EPRO2 layer 11 (OUTLINE) → Std 10 (BoardOutLine) +- EPRO2 layer 12 (MULTI) → Std 11 (Multi-Layer) + +inner SIGNAL 层:EPRO2 15+ → Std 21+ (Inner1 起步)。 + +### CLI 平铺输出 + +``` +uv run python -m tools.epro2.std --all --out +``` + +输出按 Std 习惯**平铺**:`/.json`,不分 board 子目录。三个互斥模式:`--all-pcb` / `--all-sch` / `--all`。 + +### ESP-VoCat 实测 + +15 个 doc → 15 个 JSON: +| 类型 | 数量 | 实测产物 | +|---|---:|---| +| PCB(docType=3)| 6 | tracks 2K+, vias 700+, copperareas 19, libs 206, pads 807 | +| SCH(docType=1)| 9 | wires 814, libs 477, netflags 838 (含 power-port), texts 71 | + +`libs_unresolved=0` 全过——FOOTPRINT/SYMBOL doc 跨文档解析全部命中。 + +JSON 信封跟 Std 实样对比:top-level keys 一致(`success/code/result`);`result` 缺 `master/owner/created_at/...` 这些**爬取层 metadata**(不是数据本体,下游应该不需要);`dataStr.shape/layers/canvas/head` 全有。 + +### 决策(Why) + +- **不替换 KiCad 那套**:用户说"原先那套页不要换"——保留 `tools/epro2/kicad/`,新写 `tools/epro2/std/` 平级,命令行也独立 `python -m tools.epro2.std` vs `python -m tools.epro2.kicad`。 +- **`json.dumps` 用 `separators=(",",":")` 不缩进**:实样 Std 文件就是单行紧凑 JSON,没换行也没缩进,节省空间也方便 diff。 +- **数字格式 `_num()`**:实样 Std 输出整数不带 `.0`(`4303` 不是 `4303.0`),用 `math.isclose(f, int(f))` 判断后选择 int repr,跟 Std 风格对齐。 + +### 测试 + +71 → 82 单测全过:std_writers 11 个新(信封 / TRACK 字段顺序 / VIA 字段顺序 / COPPERAREA SVG path / LIB 嵌 PAD via `#@$` / docType=1 / W+N 配对 / power-port netflag / json.dumps round-trip)。 + +### 下游交付 + +15 个 ESP-VoCat JSON 已经在 `/tmp/std_json/`。要给下游同学的最小 deliverable: +``` +data/processed/std_json//.json +``` + +下一步:跑剩 4 块 Pro 项目(X86主板 / 220V电源 / 泰山派 / 梁山派)—— Pro 2.x 那两块仍然不行,需要 Pro 2.x JSON 解析器。 + +--- + ## 2026-04-29 03:30 rate-limit benchmark 整理成正式报告 **Claude 会话** diff --git a/tools/epro2/std/__init__.py b/tools/epro2/std/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/epro2/std/__main__.py b/tools/epro2/std/__main__.py new file mode 100644 index 0000000..bd3da7b --- /dev/null +++ b/tools/epro2/std/__main__.py @@ -0,0 +1,106 @@ +"""CLI: convert EPRO2 docs to EasyEDA Std-format JSON files. + +Mirrors the layout of Std project sources: one ``.json`` per +document, flat in ``--out``. Use this for downstream consumers that +already speak Std (Wokwi-based pipelines, dataStr parsers, etc.) — the +KiCad writer at ``tools.epro2.kicad`` is the alternate target for +downstream that wants kicad_sch / kicad_pcb instead. + +Usage: + uv run python -m tools.epro2.std --all-pcb --out + uv run python -m tools.epro2.std --all-sch --out + uv run python -m tools.epro2.std --all --out +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +from ..project_relations import ProjectRelations +from ..replay import Project, replay_project +from .pcb_writer import write_pcb_std +from .sch_writer import write_sch_std + + +def _convert_pcbs(proj: Project, out_dir: Path, pr: ProjectRelations) -> int: + pcb_uuids = [u for u, d in proj.documents.items() if d.doc_type == "PCB"] + if not pcb_uuids: + return 0 + print(f"PCB: converting {len(pcb_uuids)} doc(s) → {out_dir}") + for u in pcb_uuids: + try: + payload = write_pcb_std(proj.documents[u], project_relations=pr) + except Exception as e: # noqa: BLE001 + print(f" FAIL {u[:12]}: {e}", file=sys.stderr) + continue + # Stamp puuid so downstream can wire docs back to a project + payload["result"]["puuid"] = proj.project_uuid or "" + (out_dir / f"{u}.json").write_text( + json.dumps(payload, ensure_ascii=False, separators=(",", ":")), + encoding="utf-8", + ) + s = getattr(write_pcb_std, "last_stats", None) + if s: + print( + f" {u[:12]}.json: tracks={s.tracks} vias={s.vias} " + f"copperareas={s.copperareas} libs={s.libs} pads={s.pads} " + f"libs_unresolved={s.libs_unresolved}" + ) + return len(pcb_uuids) + + +def _convert_schs(proj: Project, out_dir: Path, pr: ProjectRelations) -> int: + sch_uuids = [u for u, d in proj.documents.items() if d.doc_type == "SCH_PAGE"] + if not sch_uuids: + return 0 + print(f"SCH: converting {len(sch_uuids)} doc(s) → {out_dir}") + for u in sch_uuids: + try: + payload = write_sch_std(proj.documents[u], project_relations=pr) + except Exception as e: # noqa: BLE001 + print(f" FAIL {u[:12]}: {e}", file=sys.stderr) + continue + payload["result"]["puuid"] = proj.project_uuid or "" + (out_dir / f"{u}.json").write_text( + json.dumps(payload, ensure_ascii=False, separators=(",", ":")), + encoding="utf-8", + ) + s = getattr(write_sch_std, "last_stats", None) + if s: + print( + f" {u[:12]}.json: wires={s.wires} libs={s.libs} " + f"netflags={s.netflags} texts={s.texts} libs_unresolved={s.libs_unresolved}" + ) + return len(sch_uuids) + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description="EPRO2 → EasyEDA Std JSON exporter") + ap.add_argument("project_dir", type=Path) + g = ap.add_mutually_exclusive_group(required=True) + g.add_argument("--all-pcb", action="store_true", help="convert every PCB doc to Std JSON") + g.add_argument("--all-sch", action="store_true", help="convert every SCH_PAGE doc to Std JSON") + g.add_argument("--all", action="store_true", help="convert both PCB and SCH_PAGE docs") + ap.add_argument("--out", type=Path, default=Path("data/processed/std_json")) + args = ap.parse_args(argv) + + proj = replay_project(args.project_dir) + args.out.mkdir(parents=True, exist_ok=True) + pr = ProjectRelations.build(proj) + + n = 0 + if args.all_pcb or args.all: + n += _convert_pcbs(proj, args.out, pr) + if args.all_sch or args.all: + n += _convert_schs(proj, args.out, pr) + if n == 0: + print("nothing to convert (no PCB / SCH_PAGE docs found)", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/epro2/std/pcb_writer.py b/tools/epro2/std/pcb_writer.py new file mode 100644 index 0000000..c62db75 --- /dev/null +++ b/tools/epro2/std/pcb_writer.py @@ -0,0 +1,513 @@ +"""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} diff --git a/tools/epro2/std/sch_writer.py b/tools/epro2/std/sch_writer.py new file mode 100644 index 0000000..c192c16 --- /dev/null +++ b/tools/epro2/std/sch_writer.py @@ -0,0 +1,250 @@ +"""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} diff --git a/tools/epro2/tests/test_std_writers.py b/tools/epro2/tests/test_std_writers.py new file mode 100644 index 0000000..6138ea2 --- /dev/null +++ b/tools/epro2/tests/test_std_writers.py @@ -0,0 +1,202 @@ +"""Std writer regression: synthetic EPRO2 docs → Std-format JSON dicts.""" + +import json +from collections import Counter + +from tools.epro2.project_relations import ProjectRelations +from tools.epro2.replay import Document, Project +from tools.epro2.std.pcb_writer import write_pcb_std +from tools.epro2.std.sch_writer import write_sch_std + + +def _doc(typ, uuid="d"): + d = Document(doc_uuid=uuid, doc_type=typ) + d.head = {"docType": typ} + return d + + +def _empty_pr(*docs): + p = Project(project_uuid="p") + for doc in docs: + p.documents[doc.doc_uuid] = doc + return ProjectRelations.build(p) + + +def _verbs(payload): + return Counter(s.split("~")[0] for s in payload["result"]["dataStr"]["shape"]) + + +# -- PCB --------------------------------------------------------------- + + +def test_pcb_envelope_matches_std_shape(): + """Top-level envelope must be `{success, code, result}` with + `result.docType == 3` and `result.dataStr.shape` as a list — that's + the contract Std parsers key off. Anything else and downstream's + parser bails before the shape array is even read.""" + d = _doc("PCB", "p1") + d.objects["META"] = {"_type": "META", "title": "Test"} + payload = write_pcb_std(d, project_relations=_empty_pr(d)) + assert payload["success"] is True + assert payload["code"] == 0 + r = payload["result"] + assert r["docType"] == 3 + assert r["uuid"] == "p1" + assert isinstance(r["dataStr"]["shape"], list) + # Inner SIGNAL layers extension keeps the layer block consistent + assert any("TopLayer" in s for s in r["dataStr"]["layers"]) + + +def test_pcb_line_emits_track_with_layer_and_net(): + """LINE on a copper layer becomes a Std TRACK string. Field order is + `TRACK~width~layer~net~points~uuid~locked` — same as Std produces; + a wrong order means tracks land on the wrong layer in downstream + renders even if the parser doesn't crash.""" + d = _doc("PCB", "p1") + d.objects["ln1"] = { + "_type": "LINE", "layerId": 1, "netName": "GND", "width": 6, + "startX": 100, "startY": 200, "endX": 500, "endY": 200, + } + payload = write_pcb_std(d, project_relations=_empty_pr(d)) + tracks = [s for s in payload["result"]["dataStr"]["shape"] if s.startswith("TRACK~")] + assert len(tracks) == 1 + fields = tracks[0].split("~") + assert fields[0] == "TRACK" + assert fields[1] == "6" # width + assert fields[2] == "1" # std layer 1 = TopLayer + assert fields[3] == "GND" # net name + assert "100 200 500 200" in fields[4] + + +def test_pcb_via_emits_correct_field_order(): + d = _doc("PCB", "p1") + d.objects["v1"] = { + "_type": "VIA", "centerX": 100, "centerY": 200, + "viaDiameter": 24, "holeDiameter": 12, "netName": "VCC", + } + payload = write_pcb_std(d, project_relations=_empty_pr(d)) + via = next(s for s in payload["result"]["dataStr"]["shape"] if s.startswith("VIA~")) + f = via.split("~") + # VIA~x~y~outerD~net~innerD~uuid~locked + assert f[1] == "100" + assert f[2] == "200" + assert f[3] == "24" + assert f[4] == "VCC" + assert f[5] == "12" + + +def test_pcb_pour_rectangle_becomes_copperarea_with_svg_path(): + """POUR on a copper layer must emit a COPPERAREA with an SVG `M..L..Z` + path — Std uses SVG path syntax for filled regions, and downstream + fills are computed from this path. A `R x y w h` rectangle expands + to an explicit four-corner Z-closed polygon.""" + d = _doc("PCB", "p1") + d.objects["p1"] = { + "_type": "POUR", "layerId": 1, "netName": "GND", + "path": [["R", 0, 0, 1000, 1000]], + } + payload = write_pcb_std(d, project_relations=_empty_pr(d)) + ca = next(s for s in payload["result"]["dataStr"]["shape"] if s.startswith("COPPERAREA~")) + assert "M 0 0" in ca + assert " Z" in ca + assert "GND" in ca + + +def test_pcb_lib_nests_pads_via_separator(): + """A footprint placement must emit a LIB outer string with PAD inner + shapes joined by `#@$` — that's how Std writes one symbol-with-pads + per shape entry. If we emit pads as separate top-level shapes, + downstream's symbol-grouping breaks (pads end up unowned).""" + fp = _doc("FOOTPRINT", "fp1") + fp.objects["META"] = {"_type": "META", "title": "0402"} + fp.objects["pad1"] = { + "_type": "PAD", "num": "1", "centerX": -20, "centerY": 0, + "padAngle": 0, "layerId": 1, "hole": None, + "defaultPad": {"padType": "RECT", "width": 30, "height": 20}, + } + pcb = _doc("PCB", "pcb1") + pcb.objects["C1"] = {"_type": "COMPONENT", "x": 100, "y": 100, "angle": 0} + pcb.objects["a1"] = { + "_type": "ATTR", "parentId": "C1", "key": "Footprint", "value": "fp1", + } + payload = write_pcb_std(pcb, project_relations=_empty_pr(fp, pcb)) + libs = [s for s in payload["result"]["dataStr"]["shape"] if s.startswith("LIB~")] + assert len(libs) == 1 + # Nested children separated by '#@$' + parts = libs[0].split("#@$") + assert parts[0].startswith("LIB~") + assert any(p.startswith("PAD~") for p in parts[1:]) + # Std treats each LIB-rooted block as the unit shape entry, not the + # nested PADs — verify no top-level PAD leaked + assert not any(s.startswith("PAD~") for s in payload["result"]["dataStr"]["shape"]) + + +# -- SCH --------------------------------------------------------------- + + +def test_sch_envelope_carries_doctype_1(): + """Std schematic docs are docType=1. Downstream filters on this to + pick which parser to invoke (PCB parser vs SCH parser); a wrong + docType silently routes the file to the wrong parser.""" + d = _doc("SCH_PAGE", "s1") + d.objects["META"] = {"_type": "META", "title": "Test"} + payload = write_sch_std(d, project_relations=_empty_pr(d)) + assert payload["result"]["docType"] == 1 + + +def test_sch_named_wire_emits_wire_plus_netflag(): + """A LINE whose lineGroup carries a NET attr must produce both a W + (the wire segment) and an N (a net flag at one endpoint, named + after the net). Same-named flags on distinct wire segments is how + Std unifies named nets — without the N, the wire is anonymous.""" + d = _doc("SCH_PAGE", "s1") + d.objects["w1"] = {"_type": "WIRE"} + d.objects["a1"] = {"_type": "ATTR", "parentId": "w1", "key": "NET", "value": "GND"} + d.objects["ln1"] = { + "_type": "LINE", "lineGroup": "w1", + "startX": 0, "startY": 0, "endX": 100, "endY": 0, + } + payload = write_sch_std(d, project_relations=_empty_pr(d)) + v = _verbs(payload) + assert v["W"] == 1 + assert v["N"] == 1 + + +def test_sch_power_port_component_emits_extra_netflag(): + """The 5-Voltage / generic placeholder COMPONENT (Global Net Name + ATTR carries the rail name) must emit an N flag at the placement + so the symbol's pin connects to the global rail. Same fix as the + KiCad path's global_label handling.""" + sym = _doc("SYMBOL", "sym1") + sym.objects["pid8a0e77bacb214e"] = {"_type": "PART", "title": ""} + sym.objects["pin1"] = { + "_type": "PIN", "partId": "pid8a0e77bacb214e", + "x": 0, "y": 0, "length": 5, "rotation": 0, + } + sch = _doc("SCH_PAGE", "s1") + sch.objects["e1"] = { + "_type": "COMPONENT", "partId": "pid8a0e77bacb214e", + "x": 100, "y": 50, "rotation": 0, + } + sch.objects["a1"] = { + "_type": "ATTR", "parentId": "e1", + "key": "Global Net Name", "value": "VBUS", + } + payload = write_sch_std(sch, project_relations=_empty_pr(sym, sch)) + flags = [s for s in payload["result"]["dataStr"]["shape"] if s.startswith("N~")] + assert any("VBUS" in s for s in flags), \ + "expected an N flag named VBUS for the power-port placement" + + +def test_writers_round_trip_through_json_dump(): + """Whatever we build has to survive json.dumps without errors — + ints/floats/strings/lists only, no datetime / Decimal / bytes + sneaking in. Catches type leaks early.""" + d = _doc("PCB", "p1") + d.objects["META"] = {"_type": "META", "title": "Test"} + payload = write_pcb_std(d, project_relations=_empty_pr(d)) + json.dumps(payload) + d2 = _doc("SCH_PAGE", "s1") + d2.objects["META"] = {"_type": "META", "title": "Test"} + payload2 = write_sch_std(d2, project_relations=_empty_pr(d2)) + json.dumps(payload2)