From 3866e241898ab940410f95a87ce5c715e980c851 Mon Sep 17 00:00:00 2001 From: Knowit Date: Wed, 29 Apr 2026 01:41:12 +0800 Subject: [PATCH] tools/epro2/std: rewrite to Option 2 (objects dump) per downstream spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 )", "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) --- docs/sources/epro2_to_std_mapping.md | 241 +++++++++++ log.md | 64 +++ tools/epro2/std/__main__.py | 90 ++-- tools/epro2/std/pcb_writer.py | 572 ++++++-------------------- tools/epro2/std/sch_writer.py | 270 +++--------- tools/epro2/tests/test_std_writers.py | 262 +++++------- 6 files changed, 639 insertions(+), 860 deletions(-) create mode 100644 docs/sources/epro2_to_std_mapping.md diff --git a/docs/sources/epro2_to_std_mapping.md b/docs/sources/epro2_to_std_mapping.md new file mode 100644 index 0000000..e1e492c --- /dev/null +++ b/docs/sources/epro2_to_std_mapping.md @@ -0,0 +1,241 @@ +# EPRO2 OPTYPE → EasyEDA Std shape verb mapping + +For downstream adapters that consume `tools/epro2/std/`'s Option-2 output +(raw `objects: {id: payload}` dict in the `dataStr` field) and need to +produce real Std `shape[]` tilde strings. + +This table is the same mapping our previous Phase-3 writer encoded +inline (`fe6971f:tools/epro2/std/pcb_writer.py`); we extracted it here +so adapter authors don't have to reverse-engineer it from the writer +source. + +All EPRO2 coordinate fields are in **mil**; Std `dataStr.canvas` declares +`mil` as its unit, so the adapter copies coords through unchanged. + +## Layer id remapping + +EPRO2 and Std agree on most copper layer ids, but differ on the mask / +paste layers (5↔7 swapped) and have different numbering for OUTLINE / +MULTI / inner SIGNAL. + +| EPRO2 id | EPRO2 type | Std id | Std name | +|---------:|------------------------|-------:|-----------------------| +| 1 | TOP | 1 | TopLayer | +| 2 | BOTTOM | 2 | BottomLayer | +| 3 | TOP_SILK | 3 | TopSilkLayer | +| 4 | BOT_SILK | 4 | BottomSilkLayer | +| 5 | TOP_SOLDER_MASK | **7** | TopSolderMaskLayer | +| 6 | BOT_SOLDER_MASK | **8** | BottomSolderMaskLayer | +| 7 | TOP_PASTE_MASK | **5** | TopPasteMaskLayer | +| 8 | BOT_PASTE_MASK | **6** | BottomPasteMaskLayer | +| 9 | TOP_ASSEMBLY | 13 | TopAssembly | +| 10 | BOT_ASSEMBLY | 14 | BottomAssembly | +| 11 | OUTLINE | 10 | BoardOutLine | +| 12 | MULTI (THT pads) | 11 | Multi-Layer | +| 13 | DOCUMENT | 12 | Document | +| 14 | MECHANICAL | 15 | Mechanical | +| 15..46 | SIGNAL inner (in use) | 21..50 | Inner1..InnerN | + +The 21..50 inner mapping is dense — assign Std `21` to the lowest-numbered +EPRO2 SIGNAL id actually carrying geometry on this board, `22` to the +next, etc. EPRO2 SIGNAL layers declared in LAYER ops but unused don't +need a Std slot. + +## PCB OPTYPE → Std shape verb (docType=3) + +### LINE (copper trace, silk line, ...) → `TRACK` + +``` +TRACK~width~layer~net~points~uuid~locked +``` +- `width` ← `LINE.width` (mil) +- `layer` ← `_layer(LINE.layerId)` via the table above +- `net` ← `LINE.netName` (string, may be empty for non-net graphics) +- `points` ← `" "` (mil, space-separated) +- `uuid` ← any unique `gge<8 hex>` id; downstream usually mints fresh +- `locked` ← `0` + +EPRO2 doesn't distinguish copper trace from silk line at the op level — +both are LINE with a different `layerId`. Std uses `TRACK` for both; +the layer id is what disambiguates. + +### VIA → `VIA` + +``` +VIA~x~y~outerD~net~innerD~uuid~locked +``` +- `x` `y` ← `VIA.centerX/centerY` +- `outerD` ← `VIA.viaDiameter` +- `innerD` ← `VIA.holeDiameter` +- `net` ← `VIA.netName` + +### POUR → `COPPERAREA` + +``` +COPPERAREA~1~layer~net~svgPath~strokeWidth~~~~~~~uuid~locked +``` +- `1` is the `id` slot Std uses; any int works +- `svgPath` ← convert `POUR.path` to SVG `M..L..Z` string. Three + EPRO2 path encodings: + - rectangle `[['R', x, y, w, h, ...]]` → 4-corner closed polygon + - circle `[['CIRCLE', cx, cy, r]]` → 24-segment polygon approximation + - polyline `[[x1, y1, 'L', x2, y2, ..., 'ARC', radius, endX, endY, ...]]` + → walk numeric pairs as `M x y` (first) / `L x y` (rest); ARC verbs + chord-approximate to `L endX endY` (good enough for fill connectivity, + Phase-2 sticks with this; precise arc chord recovery is a follow-up) +- `strokeWidth` ← `POUR.width` + +### FILL (manual filled region) → `SOLIDREGION` + +``` +SOLIDREGION~99~~svgPath~solid~uuid~~~~locked +``` +- Same SVG-path encoding as COPPERAREA +- `99` is the `id` slot; the `~~` after it is an empty layer field + (FILL on EPRO2 carries `layerId` but Std SOLIDREGION leaves it blank + for "uses the path's natural color"; this is fine for downstream) + +### POLY with `path[0] == 'CIRCLE'` → `CIRCLE` + +``` +CIRCLE~cx~cy~radius~strokeWidth~layer~uuid~locked~~ +``` + +### POLY with polyline path → `SOLIDREGION` (graphic polygon) + +Same as FILL. + +### COMPONENT (+ its FOOTPRINT.PADs) → `LIB...#@$PAD...#@$TEXT...` + +The Std `LIB` shape is one outer string plus N inner shapes joined by +the literal three-byte separator `#@$`. The outer carries placement; each +inner is a real PAD / TEXT shape with the **PCB-absolute coords** that +result from rotating + translating the FOOTPRINT-local pad positions. + +Outer: +``` +LIB~x~y~package_name`~rotation~~uuid~display~~~locked~~yes~~ +``` +- `x` `y` ← `COMPONENT.x/y` (mil) +- `package_name` ← FOOTPRINT META.title (then a literal trailing backtick) +- `rotation` ← `COMPONENT.angle` (degrees) +- `display` `1`, `locked` `0` + +Inner PAD (one per FOOTPRINT.PAD owned by this COMPONENT): +``` +PAD~shape~x~y~width~height~layer~net~num~drillSize~~rotation~uuid~0~~Y~0~0~0.2~x,y +``` +- `shape` ← `defaultPad.padType` ∈ {`RECT`, `ELLIPSE`, `OVAL`, `POLYGON`} +- `x` `y` ← absolute coords: + ``` + abs_x = comp.x + pad.centerX * cos(comp.angle) − pad.centerY * sin(comp.angle) + abs_y = comp.y + pad.centerX * sin(comp.angle) + pad.centerY * cos(comp.angle) + ``` +- `width` `height` ← `defaultPad.width/height` +- `layer` ← `_layer(pad.layerId)` (typically 1=TOP, 2=BOTTOM, 11=Multi for THT) +- `net` ← resolve via PCB-level `PAD_NET` op: + the PCB doc has ops with composite ids + `["PAD_NET", , , ]` → `padNet` payload + is the net name. Cross-doc lookup; the FOOTPRINT itself doesn't know + the net of any specific instance. +- `num` ← `pad.num` (pin number, string) +- `drillSize` ← `pad.hole.width` if hole present, else `0` +- `rotation` ← `(pad.padAngle + comp.angle) % 360` + +Inner TEXT (designator + value, one each if attrs present): +``` +TEXT~P~x~y~strokeWidth~rotation~mirror~layer~font~size~content~svgPath~visible +``` +- `P` flag = property text (vs `L` for label) +- `content` ← attrs.Designator / attrs.Value pulled from ATTR ops with + `parentId = component_id` + +The downstream adapter doesn't need a separate ATTR walk — by the time +it has the COMPONENT's ATTR-derived attrs (Designator, Value, Footprint, +...), those are typically already collapsed into a `attrs_dict` map +(`tools.epro2.relations.Relations.attrs_dict(parent_id)` does this). + +## Schematic OPTYPE → Std verb (docType=1, **best-effort**) + +We have zero Std schematic samples in `data/raw/oshwhub/*/source/` (all +the projects we crawled are PCB-only Std exports), so the field orders +below follow the **EasyEDA Std public schematic spec**, not direct +observation. Adapter authors should expect to tweak field positions if +their parser rejects a verb. + +### LINE → `W` (wire segment) + +``` +W~strokeColor~strokeWidth~strokeStyle~points~uuid~locked +``` +- `points` ← same ` ` form as TRACK + +### LINE.lineGroup with parent WIRE.NET attr → also emit `N` (net flag) + +``` +N~x~y~rotation~text~uuid~locked +``` +EPRO2 binds wire segments by NET name, not just geometry. Place one N +flag at each LINE's start endpoint, with the `text` set to the parent +WIRE op's `NET` ATTR value. Same-named flags on physically distinct +wire segments is how Std unifies a multi-segment named net. + +### COMPONENT (+ its SYMBOL primitives) → `LIB...#@$P...` + +Outer: +``` +LIB~x~y~package``~rotation~~uuid~display~~~locked~~yes~~ +``` + +Inner per SYMBOL.PIN: +``` +P~show~0~~x~y~rotation~uuid^^pin_number^^pin_name^^length +``` + +(Note: PIN field separator inside the inner string uses `^^` not `~`, +per spec — but this varies by editor version. If downstream's parser +rejects PIN, this is the most likely culprit.) + +### Power-port placeholder → `LIB` + extra `N` + +EPRO2 represents power rails (VBUS / GND / VCC / VBAT_IN / ...) as a +generic placeholder COMPONENT with `partId = "pid8a0e77bacb214e"` whose +**Global Net Name** ATTR carries the rail name. For each such instance, +emit the regular `LIB` placement *plus* an `N` flag at the placement +coords with the Global Net Name as `text` — that's how the symbol's pin +binds to the global rail. (This mirrors the same fix our KiCad path uses +to emit a `(global_label)` for these.) + +### TEXT → `T` + +``` +T~x~y~rotation~text~uuid~locked +``` + +## Skipped / "not yet supported" + +These exist in EPRO2 but our writer doesn't address them — adapters can +choose to skip silently or emit best-effort placeholders: + +| EPRO2 op | Std target | Notes | +|------------|------------|--------------------------------------------------------| +| TEARDROP | (drop) | Cosmetic fillets at via/pad-trace junctions | +| ARC (PCB) | `ARC` | Std verb exists; we emit only chord-approximated ones | +| IMAGE | `SVGNODE` | Bitmap logos; Std stores as embedded SVG JSON | +| STRING (PCB) | `TEXT` | Board-level text; field order distinct from PCB TEXT-in-LIB | +| BUS / BE (SCH) | `BUS` / `BE` | Bus + bus entry — no EPRO2 sample in our corpus | + +## Provenance fields the adapter can rely on + +In addition to `objects`, our writer always emits: +- `result.dataStr.head.docType` `"3"` (PCB) or `"1"` (SCH) — same string + encoding Std uses +- `result.dataStr.head.units` `"mil"` — explicit unit hint so the + adapter doesn't have to guess +- `result.dataStr.head.editorVersion` `"facere-epro2/0.1 (epro2 X.Y.Z)"` + where X.Y.Z is the EPRO2 doc's `editVersion`. Useful for triage when + a board exhibits version-specific quirks. +- `result.dataStr.BBox` `{x, y, width, height}` — gross outer rectangle + from min/max of every numeric `x/y/startX/startY/endX/endY/centerX/centerY` + field across `objects`. Adapters that want a tighter BBox can refine + by walking `path` arrays themselves. diff --git a/log.md b/log.md index 046c4c4..1887d82 100644 --- a/log.md +++ b/log.md @@ -4,6 +4,70 @@ --- +## 2026-04-29 04:30 std/ writer 翻 Option 2:raw objects dump + mapping doc + +**Claude 会话** + +接 `fe6971f`。下游同学回了具体规格:选 Option 2(objects dict 直接 dump),不要我们做 tilde 串映射,他自己写 ~100 LoC adapter 翻。把之前 Option 3(full Std `shape: ["TRACK~...", ...]`)那套删了,重写。 + +### 下游确认的规格 + +1. **`shape[]` 不要保留**——adapter 从 `objects` 重建,空占位反而误导 +2. **全 mil**——不转 mm,BBox 也 mil(Std `canvas` 那一行 `~mil~` 已经写了) +3. **`head` 必带**: + - `editorVersion` ← `facere-epro2/0.1 (epro2 )`,Wokwi 据此选解析分支 + - `docType` ← `"3"` (PCB) / `"1"` (SCH) + - `units` ← `"mil"` +4. **`layers[]`** 沿用 Std 53-layer 字符串数组格式(`"1~TopLayer~#FF0000~..."`),inner SIGNAL 有用到才追加 +5. **保留空 stub** `preference / netColors / DRCRULE`——失败时 grep 路径稳定 +6. **per-doc 一个 .json**,平铺,不合并 + +### 写完三件 + +#### `tools/epro2/std/pcb_writer.py` 重写 +40 行核心逻辑(之前 Option 3 是 500 行): +- `_gather_bbox_points`:扫所有 op 的 `x/y/startX/...` 等坐标字段,min/max +- `_used_inner_signal_layers`:找真正有 primitive 的 SIGNAL 内层 id +- envelope 直接 dump `dict(doc.objects)` + +#### `tools/epro2/std/sch_writer.py` 重写 +更短——schematic 没 copper layer,layers=[]。其它结构跟 PCB 一样。 + +#### `docs/sources/epro2_to_std_mapping.md` 新增 +**这是给下游 adapter 的关键文档**——他写 100 LoC adapter 时按这个表查。内容: +- EPRO2 layer id → Std layer id 重映射表(最坑:5↔7 mask/paste 反着、11→10 outline、12→11 multi、SIGNAL 15+→Std 21+) +- PCB OPTYPE → Std verb 全表(TRACK / VIA / COPPERAREA / SOLIDREGION / CIRCLE / LIB+#@$PAD+TEXT) +- SCH OPTYPE → Std verb 全表(W / N / T / LIB+#@$P)—— 标了 best-effort,没 Std SCH 实样 +- COMPONENT placement 旋转 + 平移公式(footprint-local → PCB-absolute) +- 5-Voltage 占位符 `pid8a0e77bacb214e` 的 Global Net Name → 额外 N flag 的 trick + +mapping doc 直接从 `fe6971f` 那个 Option 3 writer 提炼出来——他不用读我们代码,照表填就行。 + +### 实测 + +ESP-VoCat 6 PCB + 9 SCH = 15 JSON: +- 一个典型 PCB: `objects=1719, layers=17, BBox=(-2293, -900, 2997×1899)` +- `head.units = "mil"`、`head.editorVersion = "facere-epro2/0.1 (epro2 3.2.91)"`、`head.docType = "3"` +- `shape` 字段已确认**不存在** +- `objects` 原 payload 1:1 保留(含 LAYER ops 的 activeColor 等所有字段) + +### 决策(Why) + +- **不留空 `shape: []`**——下游说"误导 adapter"。明确不存在,比空数组更诚实 +- **`head.editorVersion` 加前缀 `facere-epro2/0.1`**——区分我们的输出 vs lceda 真实 Std;adapter 看到这个能猜出是 EPRO2 转过来的 +- **保留 `preference/netColors/DRCRULE` 空 stub**——下游说失败 grep 排查方便 +- **mapping doc 单独成文不混在 README**——adapter 作者一个文件就够,不用读源码 + +### 测试 + +82 → 84 单测全过:原 Option 3 的 11 个测试改成验 Option 2(envelope 必带 key / 无 shape / head units&version / objects 1:1 / BBox min-max / 内层 SIGNAL append / docType reject)。 + +### Push + +`fe6971f` 的 Option 3 已被这次 commit 覆盖 / 简化掉。下游回来如果说 mapping table 有错位再修——但他自己拿表填 adapter,跑通后给反馈我们再迭代。 + +--- + ## 2026-04-29 04:00 Std-format JSON 转换器:EPRO2 → 下游同学 Wokwi pipeline 的输入格式 **Claude 会话** diff --git a/tools/epro2/std/__main__.py b/tools/epro2/std/__main__.py index bd3da7b..30a8ab9 100644 --- a/tools/epro2/std/__main__.py +++ b/tools/epro2/std/__main__.py @@ -1,15 +1,19 @@ -"""CLI: convert EPRO2 docs to EasyEDA Std-format JSON files. +"""CLI: dump EPRO2 docs to Std-shaped JSON files for downstream consumers. -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. +The output is "Option 2" per the downstream colleague's spec: Std envelope +with a raw EPRO2 ``objects: {id: payload}`` dict in place of the usual +``shape[]`` tilde-string array. Their ~100-LoC adapter walks ``objects`` +and dispatches by ``_type`` to build real Std shapes — see +``docs/sources/epro2_to_std_mapping.md`` for the OPTYPE → Std verb table. 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 + +Output: flat ``.json`` per doc — mirrors Std's own data layout +so a downstream pipeline that already iterates ``source/*.json`` works +unchanged. """ from __future__ import annotations @@ -19,85 +23,81 @@ 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: +def _dump(payload: dict, out_path: Path, project_uuid: str) -> None: + payload["result"]["puuid"] = project_uuid or "" + out_path.write_text( + json.dumps(payload, ensure_ascii=False, separators=(",", ":")), + encoding="utf-8", + ) + + +def _convert_pcbs(proj: Project, out_dir: Path) -> int: + uuids = [u for u, d in proj.documents.items() if d.doc_type == "PCB"] + if not uuids: return 0 - print(f"PCB: converting {len(pcb_uuids)} doc(s) → {out_dir}") - for u in pcb_uuids: + print(f"PCB: dumping {len(uuids)} doc(s) → {out_dir}") + for u in uuids: try: - payload = write_pcb_std(proj.documents[u], project_relations=pr) + payload = write_pcb_std(proj.documents[u]) 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", - ) + _dump(payload, out_dir / f"{u}.json", proj.project_uuid or "") 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}" + f" {u[:12]}.json: objects={s.objects} layers={s.layers_emitted} " + f"BBox=({s.bbox_x:g},{s.bbox_y:g},{s.bbox_w:g},{s.bbox_h:g})" ) - return len(pcb_uuids) + return len(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: +def _convert_schs(proj: Project, out_dir: Path) -> int: + uuids = [u for u, d in proj.documents.items() if d.doc_type == "SCH_PAGE"] + if not uuids: return 0 - print(f"SCH: converting {len(sch_uuids)} doc(s) → {out_dir}") - for u in sch_uuids: + print(f"SCH: dumping {len(uuids)} doc(s) → {out_dir}") + for u in uuids: try: - payload = write_sch_std(proj.documents[u], project_relations=pr) + payload = write_sch_std(proj.documents[u]) 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", - ) + _dump(payload, out_dir / f"{u}.json", proj.project_uuid or "") 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}" + f" {u[:12]}.json: objects={s.objects} " + f"BBox=({s.bbox_x:g},{s.bbox_y:g},{s.bbox_w:g},{s.bbox_h:g})" ) - return len(sch_uuids) + return len(uuids) def main(argv: list[str] | None = None) -> int: - ap = argparse.ArgumentParser(description="EPRO2 → EasyEDA Std JSON exporter") + ap = argparse.ArgumentParser(description="EPRO2 → EasyEDA Std-shaped JSON dump") 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") + g.add_argument("--all-pcb", action="store_true", help="dump every PCB doc") + g.add_argument("--all-sch", action="store_true", help="dump every SCH_PAGE doc") + g.add_argument("--all", action="store_true", help="dump 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) + n += _convert_pcbs(proj, args.out) if args.all_sch or args.all: - n += _convert_schs(proj, args.out, pr) + n += _convert_schs(proj, args.out) if n == 0: - print("nothing to convert (no PCB / SCH_PAGE docs found)", file=sys.stderr) + print("nothing to dump (no PCB / SCH_PAGE docs found)", file=sys.stderr) return 1 return 0 diff --git a/tools/epro2/std/pcb_writer.py b/tools/epro2/std/pcb_writer.py index c62db75..f470707 100644 --- a/tools/epro2/std/pcb_writer.py +++ b/tools/epro2/std/pcb_writer.py @@ -1,72 +1,47 @@ -"""Convert one EPRO2 PCB Document → an EasyEDA Std-format PCB JSON. +"""Convert one EPRO2 PCB Document → an EasyEDA Std-shaped JSON file +that hands the raw EPRO2 ``objects`` dict to a downstream adapter. -Std PCB format (probed on `data/raw/oshwhub/3e2f893d.../25931ddab8.json`): +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. - { - "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). +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 json import math -import uuid as _uuid -from dataclasses import dataclass, field -from typing import TYPE_CHECKING +from dataclasses import dataclass from ..relations import Relations from ..replay import Document -if TYPE_CHECKING: - from ..project_relations import ProjectRelations + +@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 -# -- 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`. +# 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~", @@ -86,428 +61,119 @@ _DEFAULT_STD_LAYERS: list[str] = [ ] -@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 +# 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 _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 _gather_bbox_points(doc: Document) -> tuple[float, float, float, float]: + """Best-effort BBox from every numeric x/y pair we recognize. - -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)``. + 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. """ - 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 + 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 write_pcb_std( - doc: Document, - *, - project_relations: "ProjectRelations" | None = None, -) -> dict: - """EPRO2 PCB Document → Std-format JSON dict (ready for json.dump).""" +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}") - 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 + 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, std_id in enumerate(sorted(signal_inner_map.values())): + 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": "", # filled in by caller if known + "puuid": "", "title": title, "description": "", "docType": 3, - "components": components_dict, + "components": {}, "dataStr": { "head": { "docType": "3", - "editorVersion": "facere-epro2/0.1", - "newgId": True, - "c_para": [], - "x": canvas_x, - "y": canvas_y, - "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, + }, + "BBox": { + "x": bbox_x, + "y": bbox_y, + "width": bbox_w, + "height": bbox_h, }, - "canvas": canvas, - "shape": shape, "layers": layers, - "objects": [], - "BBox": {"x": 0, "y": 0, "width": 0, "height": 0}, + "objects": dict(doc.objects), + # Empty stubs the downstream pipeline checks for presence: "preference": {}, - "DRCRULE": {}, "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} diff --git a/tools/epro2/std/sch_writer.py b/tools/epro2/std/sch_writer.py index c192c16..3e12d13 100644 --- a/tools/epro2/std/sch_writer.py +++ b/tools/epro2/std/sch_writer.py @@ -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~~#@$ - - 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} diff --git a/tools/epro2/tests/test_std_writers.py b/tools/epro2/tests/test_std_writers.py index 6138ea2..66e3bcf 100644 --- a/tools/epro2/tests/test_std_writers.py +++ b/tools/epro2/tests/test_std_writers.py @@ -1,202 +1,166 @@ -"""Std writer regression: synthetic EPRO2 docs → Std-format JSON dicts.""" +"""Std writer regression: synthetic EPRO2 docs → Option-2 Std-shaped JSON.""" import json -from collections import Counter -from tools.epro2.project_relations import ProjectRelations -from tools.epro2.replay import Document, Project +from tools.epro2.replay import Document 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"): +def _doc(typ, uuid="d") -> Document: d = Document(doc_uuid=uuid, doc_type=typ) - d.head = {"docType": typ} + d.head = {"docType": typ, "editVersion": "3.2.91"} 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.""" +def test_pcb_envelope_has_required_keys(): + """Downstream's adapter checks for `success`/`code`/`result` at the + top level and `head`/`BBox`/`layers`/`objects` inside `dataStr` — + the ~100 LoC adapter's first job is to find those keys, so missing + any is a hard failure for the entire pipeline.""" d = _doc("PCB", "p1") d.objects["META"] = {"_type": "META", "title": "Test"} - payload = write_pcb_std(d, project_relations=_empty_pr(d)) + payload = write_pcb_std(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"]) + ds = r["dataStr"] + for required in ("head", "BBox", "layers", "objects", + "preference", "netColors", "DRCRULE"): + assert required in ds, f"missing dataStr.{required}" -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.""" +def test_pcb_no_shape_field(): + """No `shape` array in our output. Downstream said `shape` empty + placeholder is misleading — they'll generate it themselves.""" d = _doc("PCB", "p1") + payload = write_pcb_std(d) + assert "shape" not in payload["result"]["dataStr"] + + +def test_pcb_head_carries_units_and_editor_version(): + """`head.units = "mil"` is the explicit hint the adapter keys off + to skip mm conversion. `head.editorVersion` exposes the EPRO2 + editor build the source was authored with — used by Wokwi to pick + its parser branch.""" + d = _doc("PCB", "p1") + payload = write_pcb_std(d) + head = payload["result"]["dataStr"]["head"] + assert head["units"] == "mil" + assert head["docType"] == "3" + assert "3.2.91" in head["editorVersion"] + + +def test_pcb_objects_dict_is_full_document_objects(): + """The whole point of Option 2: pass the raw EPRO2 objects through + untouched so the adapter can dispatch on `_type` and access every + field without going through us. Verify the dict is preserved + 1:1 (not just shallow keys).""" + d = _doc("PCB", "p1") + d.objects["e0"] = {"_type": "COMPONENT", "x": 100, "y": -50, "angle": 90} d.objects["ln1"] = { "_type": "LINE", "layerId": 1, "netName": "GND", "width": 6, - "startX": 100, "startY": 200, "endX": 500, "endY": 200, + "startX": 0, "startY": 0, "endX": 100, "endY": 0, } - 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] + payload = write_pcb_std(d) + objs = payload["result"]["dataStr"]["objects"] + assert objs["e0"] == {"_type": "COMPONENT", "x": 100, "y": -50, "angle": 90} + assert objs["ln1"]["netName"] == "GND" -def test_pcb_via_emits_correct_field_order(): +def test_pcb_bbox_is_min_max_of_numeric_x_y_pairs(): + """BBox is best-effort min/max across known x/y/startX/startY/... + fields. Adapter can refine by walking `path` arrays itself; we just + give it a gross outer rectangle good enough for canvas centering. + + All coords stay in mil (no mm conversion) — `head.units` says so.""" d = _doc("PCB", "p1") - d.objects["v1"] = { - "_type": "VIA", "centerX": 100, "centerY": 200, - "viaDiameter": 24, "holeDiameter": 12, "netName": "VCC", + d.objects["v1"] = {"_type": "VIA", "centerX": -100, "centerY": 50} + d.objects["ln1"] = { + "_type": "LINE", "startX": 0, "startY": -200, + "endX": 300, "endY": 100, } - 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" + payload = write_pcb_std(d) + b = payload["result"]["dataStr"]["BBox"] + assert b["x"] == -100 # min x across all points + assert b["y"] == -200 # min y + assert b["width"] == 400 # 300 - (-100) + assert b["height"] == 300 # 100 - (-200) -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.""" +def test_pcb_layers_appends_used_inner_signals(): + """An EPRO2 4-layer board with SIGNAL inner ids 15 + 16 carrying real + geometry must add Std layer entries `21~Inner1...` and `22~Inner2...` + — the adapter relies on these being present to know the stack-up. + Unused SIGNAL inners (declared in LAYER ops but no primitives) + don't get an entry; they'd just be noise.""" 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 + d.objects["ln1"] = {"_type": "LINE", "layerId": 15, + "startX": 0, "startY": 0, "endX": 1, "endY": 0} + d.objects["ln2"] = {"_type": "LINE", "layerId": 16, + "startX": 0, "startY": 0, "endX": 1, "endY": 0} + payload = write_pcb_std(d) + layers = payload["result"]["dataStr"]["layers"] + inner_lines = [s for s in layers if "Inner" in s] + assert any(s.startswith("21~Inner1") for s in inner_lines) + assert any(s.startswith("22~Inner2") for s in inner_lines) -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"]) +def test_pcb_non_pcb_doc_rejected(): + d = _doc("SCH_PAGE", "x") + try: + write_pcb_std(d) + except ValueError: + return + raise AssertionError("expected ValueError for non-PCB doc") # -- 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.""" + """docType=1 routes the file to downstream's schematic parser + instead of the PCB one.""" d = _doc("SCH_PAGE", "s1") d.objects["META"] = {"_type": "META", "title": "Test"} - payload = write_sch_std(d, project_relations=_empty_pr(d)) + payload = write_sch_std(d) assert payload["result"]["docType"] == 1 + assert payload["result"]["dataStr"]["head"]["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.""" +def test_sch_layers_empty(): + """Schematic has no copper stack-up; layers[] is empty.""" 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 + payload = write_sch_std(d) + assert payload["result"]["dataStr"]["layers"] == [] -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_sch_objects_dict_preserved(): + d = _doc("SCH_PAGE", "s1") + d.objects["e1"] = {"_type": "COMPONENT", "partId": "ABC.1", + "x": 100, "y": 200, "rotation": 0} + d.objects["a1"] = {"_type": "ATTR", "parentId": "e1", + "key": "Designator", "value": "U1"} + payload = write_sch_std(d) + objs = payload["result"]["dataStr"]["objects"] + assert objs["e1"]["partId"] == "ABC.1" + assert objs["a1"]["value"] == "U1" + + +# -- json round-trip --------------------------------------------------- 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) + """Our payloads must survive json.dumps without TypeError — catches + Decimal / datetime / bytes leaks early.""" + d_pcb = _doc("PCB", "p1") + d_pcb.objects["META"] = {"_type": "META", "title": "Test"} + json.dumps(write_pcb_std(d_pcb)) + d_sch = _doc("SCH_PAGE", "s1") + d_sch.objects["META"] = {"_type": "META", "title": "Test"} + json.dumps(write_sch_std(d_sch))