tools/epro2: add std/ writer — EPRO2 → EasyEDA Std-format JSON for downstream
The downstream colleague consumes oshwhub Std (lceda) dict-format JSON,
not KiCad. The EPRO2 decryption part (per-doc plaintext .epro2 streams
in data/raw/<uuid>/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 `<doc_uuid>.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) <noreply@anthropic.com>
This commit is contained in:
84
log.md
84
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/<uuid>/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 <project_dir> --all --out <dst>
|
||||||
|
```
|
||||||
|
|
||||||
|
输出按 Std 习惯**平铺**:`<dst>/<doc_uuid>.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/<project_uuid>/<doc_uuid>.json
|
||||||
|
```
|
||||||
|
|
||||||
|
下一步:跑剩 4 块 Pro 项目(X86主板 / 220V电源 / 泰山派 / 梁山派)—— Pro 2.x 那两块仍然不行,需要 Pro 2.x JSON 解析器。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-04-29 03:30 rate-limit benchmark 整理成正式报告
|
## 2026-04-29 03:30 rate-limit benchmark 整理成正式报告
|
||||||
|
|
||||||
**Claude 会话**
|
**Claude 会话**
|
||||||
|
|||||||
0
tools/epro2/std/__init__.py
Normal file
0
tools/epro2/std/__init__.py
Normal file
106
tools/epro2/std/__main__.py
Normal file
106
tools/epro2/std/__main__.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
"""CLI: convert EPRO2 docs to EasyEDA Std-format JSON files.
|
||||||
|
|
||||||
|
Mirrors the layout of Std project sources: one ``<doc_uuid>.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 <project_dir> --all-pcb --out <dir>
|
||||||
|
uv run python -m tools.epro2.std <project_dir> --all-sch --out <dir>
|
||||||
|
uv run python -m tools.epro2.std <project_dir> --all --out <dir>
|
||||||
|
"""
|
||||||
|
|
||||||
|
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())
|
||||||
513
tools/epro2/std/pcb_writer.py
Normal file
513
tools/epro2/std/pcb_writer.py
Normal file
@@ -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": <doc_uuid>, "puuid": <project_uuid>, "title": "...",
|
||||||
|
"docType": 3,
|
||||||
|
"components": {<lib_uuid>: <ref_count>, ...},
|
||||||
|
"dataStr": {
|
||||||
|
"head": {"docType":"3","editorVersion":"...","x":...,"y":...},
|
||||||
|
"canvas": "CA~<w>~<h>~<bg>~...",
|
||||||
|
"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}
|
||||||
250
tools/epro2/std/sch_writer.py
Normal file
250
tools/epro2/std/sch_writer.py
Normal file
@@ -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~~#@$<inner shapes>
|
||||||
|
|
||||||
|
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}
|
||||||
202
tools/epro2/tests/test_std_writers.py
Normal file
202
tools/epro2/tests/test_std_writers.py
Normal file
@@ -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)
|
||||||
Reference in New Issue
Block a user