tools/epro2/std: rewrite to Option 2 (objects dump) per downstream spec
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 <X.Y.Z>)",
"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) <noreply@anthropic.com>
This commit is contained in:
241
docs/sources/epro2_to_std_mapping.md
Normal file
241
docs/sources/epro2_to_std_mapping.md
Normal file
@@ -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` ← `"<startX> <startY> <endX> <endY>"` (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", <component_id>, <pin_num>, <pad_id>]` → `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 `<x1> <y1> <x2> <y2>` 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`<symbol_title>`~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.
|
||||||
64
log.md
64
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 <doc.head.editVersion>)`,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 的输入格式
|
## 2026-04-29 04:00 Std-format JSON 转换器:EPRO2 → 下游同学 Wokwi pipeline 的输入格式
|
||||||
|
|
||||||
**Claude 会话**
|
**Claude 会话**
|
||||||
|
|||||||
@@ -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 ``<doc_uuid>.json`` per
|
The output is "Option 2" per the downstream colleague's spec: Std envelope
|
||||||
document, flat in ``--out``. Use this for downstream consumers that
|
with a raw EPRO2 ``objects: {id: payload}`` dict in place of the usual
|
||||||
already speak Std (Wokwi-based pipelines, dataStr parsers, etc.) — the
|
``shape[]`` tilde-string array. Their ~100-LoC adapter walks ``objects``
|
||||||
KiCad writer at ``tools.epro2.kicad`` is the alternate target for
|
and dispatches by ``_type`` to build real Std shapes — see
|
||||||
downstream that wants kicad_sch / kicad_pcb instead.
|
``docs/sources/epro2_to_std_mapping.md`` for the OPTYPE → Std verb table.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
uv run python -m tools.epro2.std <project_dir> --all-pcb --out <dir>
|
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-sch --out <dir>
|
||||||
uv run python -m tools.epro2.std <project_dir> --all --out <dir>
|
uv run python -m tools.epro2.std <project_dir> --all --out <dir>
|
||||||
|
|
||||||
|
Output: flat ``<doc_uuid>.json`` per doc — mirrors Std's own data layout
|
||||||
|
so a downstream pipeline that already iterates ``source/*.json`` works
|
||||||
|
unchanged.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -19,85 +23,81 @@ import json
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ..project_relations import ProjectRelations
|
|
||||||
from ..replay import Project, replay_project
|
from ..replay import Project, replay_project
|
||||||
from .pcb_writer import write_pcb_std
|
from .pcb_writer import write_pcb_std
|
||||||
from .sch_writer import write_sch_std
|
from .sch_writer import write_sch_std
|
||||||
|
|
||||||
|
|
||||||
def _convert_pcbs(proj: Project, out_dir: Path, pr: ProjectRelations) -> int:
|
def _dump(payload: dict, out_path: Path, project_uuid: str) -> None:
|
||||||
pcb_uuids = [u for u, d in proj.documents.items() if d.doc_type == "PCB"]
|
payload["result"]["puuid"] = project_uuid or ""
|
||||||
if not pcb_uuids:
|
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
|
return 0
|
||||||
print(f"PCB: converting {len(pcb_uuids)} doc(s) → {out_dir}")
|
print(f"PCB: dumping {len(uuids)} doc(s) → {out_dir}")
|
||||||
for u in pcb_uuids:
|
for u in uuids:
|
||||||
try:
|
try:
|
||||||
payload = write_pcb_std(proj.documents[u], project_relations=pr)
|
payload = write_pcb_std(proj.documents[u])
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
print(f" FAIL {u[:12]}: {e}", file=sys.stderr)
|
print(f" FAIL {u[:12]}: {e}", file=sys.stderr)
|
||||||
continue
|
continue
|
||||||
# Stamp puuid so downstream can wire docs back to a project
|
_dump(payload, out_dir / f"{u}.json", proj.project_uuid or "")
|
||||||
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)
|
s = getattr(write_pcb_std, "last_stats", None)
|
||||||
if s:
|
if s:
|
||||||
print(
|
print(
|
||||||
f" {u[:12]}.json: tracks={s.tracks} vias={s.vias} "
|
f" {u[:12]}.json: objects={s.objects} layers={s.layers_emitted} "
|
||||||
f"copperareas={s.copperareas} libs={s.libs} pads={s.pads} "
|
f"BBox=({s.bbox_x:g},{s.bbox_y:g},{s.bbox_w:g},{s.bbox_h:g})"
|
||||||
f"libs_unresolved={s.libs_unresolved}"
|
|
||||||
)
|
)
|
||||||
return len(pcb_uuids)
|
return len(uuids)
|
||||||
|
|
||||||
|
|
||||||
def _convert_schs(proj: Project, out_dir: Path, pr: ProjectRelations) -> int:
|
def _convert_schs(proj: Project, out_dir: Path) -> int:
|
||||||
sch_uuids = [u for u, d in proj.documents.items() if d.doc_type == "SCH_PAGE"]
|
uuids = [u for u, d in proj.documents.items() if d.doc_type == "SCH_PAGE"]
|
||||||
if not sch_uuids:
|
if not uuids:
|
||||||
return 0
|
return 0
|
||||||
print(f"SCH: converting {len(sch_uuids)} doc(s) → {out_dir}")
|
print(f"SCH: dumping {len(uuids)} doc(s) → {out_dir}")
|
||||||
for u in sch_uuids:
|
for u in uuids:
|
||||||
try:
|
try:
|
||||||
payload = write_sch_std(proj.documents[u], project_relations=pr)
|
payload = write_sch_std(proj.documents[u])
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
print(f" FAIL {u[:12]}: {e}", file=sys.stderr)
|
print(f" FAIL {u[:12]}: {e}", file=sys.stderr)
|
||||||
continue
|
continue
|
||||||
payload["result"]["puuid"] = proj.project_uuid or ""
|
_dump(payload, out_dir / f"{u}.json", 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)
|
s = getattr(write_sch_std, "last_stats", None)
|
||||||
if s:
|
if s:
|
||||||
print(
|
print(
|
||||||
f" {u[:12]}.json: wires={s.wires} libs={s.libs} "
|
f" {u[:12]}.json: objects={s.objects} "
|
||||||
f"netflags={s.netflags} texts={s.texts} libs_unresolved={s.libs_unresolved}"
|
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:
|
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)
|
ap.add_argument("project_dir", type=Path)
|
||||||
g = ap.add_mutually_exclusive_group(required=True)
|
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-pcb", action="store_true", help="dump every PCB doc")
|
||||||
g.add_argument("--all-sch", action="store_true", help="convert every SCH_PAGE doc to Std JSON")
|
g.add_argument("--all-sch", action="store_true", help="dump every SCH_PAGE doc")
|
||||||
g.add_argument("--all", action="store_true", help="convert both PCB and SCH_PAGE docs")
|
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"))
|
ap.add_argument("--out", type=Path, default=Path("data/processed/std_json"))
|
||||||
args = ap.parse_args(argv)
|
args = ap.parse_args(argv)
|
||||||
|
|
||||||
proj = replay_project(args.project_dir)
|
proj = replay_project(args.project_dir)
|
||||||
args.out.mkdir(parents=True, exist_ok=True)
|
args.out.mkdir(parents=True, exist_ok=True)
|
||||||
pr = ProjectRelations.build(proj)
|
|
||||||
|
|
||||||
n = 0
|
n = 0
|
||||||
if args.all_pcb or args.all:
|
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:
|
if args.all_sch or args.all:
|
||||||
n += _convert_schs(proj, args.out, pr)
|
n += _convert_schs(proj, args.out)
|
||||||
if n == 0:
|
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 1
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
{
|
Field choices (per downstream's spec, 2026-04-29):
|
||||||
"success": true, "code": 0,
|
- units = "mil" (Std's internal canvas unit; no mm conversion here)
|
||||||
"result": {
|
- BBox in mil too (same units as objects)
|
||||||
"uuid": <doc_uuid>, "puuid": <project_uuid>, "title": "...",
|
- head.editorVersion taken from EPRO2 doc.head.editVersion
|
||||||
"docType": 3,
|
- head.docType "3" (PCB) so adapter selects the PCB branch
|
||||||
"components": {<lib_uuid>: <ref_count>, ...},
|
- shape[] OMITTED — empty placeholder would mislead the adapter
|
||||||
"dataStr": {
|
- preference / netColors / DRCRULE kept as empty stubs so grep paths
|
||||||
"head": {"docType":"3","editorVersion":"...","x":...,"y":...},
|
are stable for failure triage
|
||||||
"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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import math
|
import math
|
||||||
import uuid as _uuid
|
from dataclasses import dataclass
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from ..relations import Relations
|
from ..relations import Relations
|
||||||
from ..replay import Document
|
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 layer-block format. Same content the EasyEDA editor writes — keeping
|
||||||
#
|
# the textual layout 1:1 means downstream layer-id lookups don't have to
|
||||||
# Std uses a different numbering than EPRO2. Probed from a Std PCB file's
|
# special-case our output. Inner SIGNAL layers (21+) get appended on demand
|
||||||
# `layers` block; mismatches in the 5/6/7/8 (mask/paste) range are real.
|
# from the actual EPRO2 SIGNAL layer ids that carry geometry on this board.
|
||||||
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] = [
|
_DEFAULT_STD_LAYERS: list[str] = [
|
||||||
"1~TopLayer~#FF0000~true~true~true~",
|
"1~TopLayer~#FF0000~true~true~true~",
|
||||||
"2~BottomLayer~#0000FF~true~false~true~",
|
"2~BottomLayer~#0000FF~true~false~true~",
|
||||||
@@ -86,428 +61,119 @@ _DEFAULT_STD_LAYERS: list[str] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
# EPRO2 fields that carry x/y coordinates we should consider for BBox
|
||||||
class WriteStats:
|
# computation. Most ops follow one of two conventions: ``(centerX,
|
||||||
tracks: int = 0
|
# centerY)`` for symmetric primitives (VIA, FILL, PAD), and
|
||||||
vias: int = 0
|
# ``(startX..endX, startY..endY)`` for line segments.
|
||||||
copperareas: int = 0
|
_BBOX_POINT_FIELDS: list[tuple[str, str]] = [
|
||||||
rects: int = 0
|
("x", "y"),
|
||||||
circles: int = 0
|
("startX", "startY"),
|
||||||
solidregions: int = 0
|
("endX", "endY"),
|
||||||
libs: int = 0
|
("centerX", "centerY"),
|
||||||
libs_unresolved: int = 0
|
]
|
||||||
pads: int = 0
|
|
||||||
texts: int = 0
|
|
||||||
holes: int = 0
|
|
||||||
skipped: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
def _gge() -> str:
|
def _gather_bbox_points(doc: Document) -> tuple[float, float, float, float]:
|
||||||
"""Std prefixes ids with `gge<8 hex>`. We use uuid4 hex slice for
|
"""Best-effort BBox from every numeric x/y pair we recognize.
|
||||||
uniqueness; downstream tools accept any unique opaque string here."""
|
|
||||||
return "gge" + _uuid.uuid4().hex[:8]
|
|
||||||
|
|
||||||
|
Skips ``path`` arrays — they're heterogeneous (rectangles use ``['R',
|
||||||
def _num(v) -> str:
|
x, y, w, h]``, polylines mix verb tokens with numbers). Adapters can
|
||||||
"""Format a number like Std does (no trailing .0, but keep precision)."""
|
refine BBox once they parse paths; ours is the gross outer rectangle
|
||||||
if v is None:
|
that's good enough for canvas centering.
|
||||||
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"))
|
xs: list[float] = []
|
||||||
py = _num(comp.get("y"))
|
ys: list[float] = []
|
||||||
rot = _num(comp.get("angle") or 0)
|
for obj in doc.objects.values():
|
||||||
designator = str(attrs.get("Designator") or "")
|
for fx, fy in _BBOX_POINT_FIELDS:
|
||||||
fp_title = (fp_doc.objects.get("META") or {}).get("title") or fp_doc.doc_uuid[:8]
|
x = obj.get(fx)
|
||||||
package = f"{fp_title}`" # Std emits a trailing backtick after package name
|
y = obj.get(fy)
|
||||||
|
if x is None or y is None:
|
||||||
# Outer LIB: x, y, package_name, rotation, ?, uuid, display, ?, ?, locked,
|
continue
|
||||||
# ?, yes, ?
|
try:
|
||||||
outer = (
|
xs.append(float(x))
|
||||||
f"LIB~{px}~{py}~{package}~{rot}~~{_gge()}~1~~~0~~yes~~"
|
ys.append(float(y))
|
||||||
)
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
# Build inner PAD blocks per FOOTPRINT.PAD with its abs (footprint-local)
|
if not xs:
|
||||||
# coords offset to the placement origin. Std stores pad coords as
|
return (0.0, 0.0, 0.0, 0.0)
|
||||||
# absolute board coords; we therefore translate from footprint-local
|
x0, x1 = min(xs), max(xs)
|
||||||
# to PCB absolute here.
|
y0, y1 = min(ys), max(ys)
|
||||||
rel_fp = Relations.build(fp_doc)
|
return (x0, y0, x1 - x0, y1 - y0)
|
||||||
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(
|
def _used_inner_signal_layers(doc: Document) -> list[int]:
|
||||||
doc: Document,
|
"""EPRO2 SIGNAL inner layer ids 15+ that have actual primitives on
|
||||||
*,
|
them — those are the ones we add to the Std layer block as
|
||||||
project_relations: "ProjectRelations" | None = None,
|
Inner1..InnerN. Layer ops that only declare 'use=True' but carry no
|
||||||
) -> dict:
|
geometry don't need to leak into the Std layers list."""
|
||||||
"""EPRO2 PCB Document → Std-format JSON dict (ready for json.dump)."""
|
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":
|
if doc.doc_type != "PCB":
|
||||||
raise ValueError(f"expected PCB doc, got {doc.doc_type!r}")
|
raise ValueError(f"expected PCB doc, got {doc.doc_type!r}")
|
||||||
|
|
||||||
rel = Relations.build(doc)
|
bbox_x, bbox_y, bbox_w, bbox_h = _gather_bbox_points(doc)
|
||||||
signal_inner_map = _build_signal_inner_map(doc)
|
inner_signal_ids = _used_inner_signal_layers(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)
|
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")
|
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 = {
|
result = {
|
||||||
"uuid": doc.doc_uuid,
|
"uuid": doc.doc_uuid,
|
||||||
"puuid": "", # filled in by caller if known
|
"puuid": "",
|
||||||
"title": title,
|
"title": title,
|
||||||
"description": "",
|
"description": "",
|
||||||
"docType": 3,
|
"docType": 3,
|
||||||
"components": components_dict,
|
"components": {},
|
||||||
"dataStr": {
|
"dataStr": {
|
||||||
"head": {
|
"head": {
|
||||||
"docType": "3",
|
"docType": "3",
|
||||||
"editorVersion": "facere-epro2/0.1",
|
"editorVersion": f"facere-epro2/0.1 (epro2 {epro2_editor})",
|
||||||
"newgId": True,
|
"units": "mil",
|
||||||
"c_para": [],
|
"epro2_doc_uuid": doc.doc_uuid,
|
||||||
"x": canvas_x,
|
"epro2_editor_version": epro2_editor,
|
||||||
"y": canvas_y,
|
},
|
||||||
"hasIdFlag": True,
|
"BBox": {
|
||||||
"importFlag": 0,
|
"x": bbox_x,
|
||||||
"transformList": "",
|
"y": bbox_y,
|
||||||
|
"width": bbox_w,
|
||||||
|
"height": bbox_h,
|
||||||
},
|
},
|
||||||
"canvas": canvas,
|
|
||||||
"shape": shape,
|
|
||||||
"layers": layers,
|
"layers": layers,
|
||||||
"objects": [],
|
"objects": dict(doc.objects),
|
||||||
"BBox": {"x": 0, "y": 0, "width": 0, "height": 0},
|
# Empty stubs the downstream pipeline checks for presence:
|
||||||
"preference": {},
|
"preference": {},
|
||||||
"DRCRULE": {},
|
|
||||||
"netColors": [],
|
"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]
|
write_pcb_std.last_stats = stats # type: ignore[attr-defined]
|
||||||
return {"success": True, "code": 0, "result": result}
|
return {"success": True, "code": 0, "result": result}
|
||||||
|
|||||||
@@ -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
|
Same Option-2 contract as ``pcb_writer.py``: hand the raw EPRO2
|
||||||
only Std PCBs (docType=3), no Std schematic samples to validate field
|
``objects`` dict to a downstream adapter; don't pre-compute Std
|
||||||
order against. The verbs below match the public EasyEDA Std schematic
|
``shape[]`` strings ourselves. docType=1, layers omitted (schematic
|
||||||
spec (LIB / W / J / N / T / R / C / A / PL / PG); the field orders are
|
has no copper stack-up), BBox in mil, ``head.units = "mil"``.
|
||||||
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import math
|
|
||||||
import uuid as _uuid
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from ..relations import Relations
|
|
||||||
from ..replay import Document
|
from ..replay import Document
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from ..project_relations import ProjectRelations
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class WriteStats:
|
class WriteStats:
|
||||||
libs: int = 0
|
objects: int = 0
|
||||||
libs_unresolved: int = 0
|
bbox_x: float = 0.0
|
||||||
wires: int = 0
|
bbox_y: float = 0.0
|
||||||
netflags: int = 0
|
bbox_w: float = 0.0
|
||||||
texts: int = 0
|
bbox_h: float = 0.0
|
||||||
graphics: int = 0
|
|
||||||
skipped: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
def _gge() -> str:
|
_BBOX_POINT_FIELDS: list[tuple[str, str]] = [
|
||||||
return "gge" + _uuid.uuid4().hex[:8]
|
("x", "y"),
|
||||||
|
("startX", "startY"),
|
||||||
|
("endX", "endY"),
|
||||||
|
("centerX", "centerY"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _num(v) -> str:
|
def _gather_bbox_points(doc: Document) -> tuple[float, float, float, float]:
|
||||||
if v is None:
|
xs: list[float] = []
|
||||||
return "0"
|
ys: list[float] = []
|
||||||
try:
|
for obj in doc.objects.values():
|
||||||
f = float(v)
|
for fx, fy in _BBOX_POINT_FIELDS:
|
||||||
except (TypeError, ValueError):
|
x = obj.get(fx)
|
||||||
return "0"
|
y = obj.get(fy)
|
||||||
if math.isclose(f, int(f), abs_tol=1e-9):
|
if x is None or y is None:
|
||||||
return str(int(f))
|
continue
|
||||||
return f"{f:.4f}".rstrip("0").rstrip(".")
|
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:
|
def write_sch_std(doc: Document) -> dict:
|
||||||
"""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":
|
if doc.doc_type != "SCH_PAGE":
|
||||||
raise ValueError(f"expected SCH_PAGE doc, got {doc.doc_type!r}")
|
raise ValueError(f"expected SCH_PAGE doc, got {doc.doc_type!r}")
|
||||||
|
|
||||||
rel = Relations.build(doc)
|
bbox_x, bbox_y, bbox_w, bbox_h = _gather_bbox_points(doc)
|
||||||
stats = WriteStats()
|
epro2_editor = (doc.head or {}).get("editVersion", "")
|
||||||
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]
|
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 = {
|
result = {
|
||||||
"uuid": doc.doc_uuid,
|
"uuid": doc.doc_uuid,
|
||||||
"puuid": "",
|
"puuid": "",
|
||||||
@@ -234,17 +67,28 @@ def write_sch_std(
|
|||||||
"dataStr": {
|
"dataStr": {
|
||||||
"head": {
|
"head": {
|
||||||
"docType": "1",
|
"docType": "1",
|
||||||
"editorVersion": "facere-epro2/0.1",
|
"editorVersion": f"facere-epro2/0.1 (epro2 {epro2_editor})",
|
||||||
"newgId": True,
|
"units": "mil",
|
||||||
"c_para": [],
|
"epro2_doc_uuid": doc.doc_uuid,
|
||||||
"hasIdFlag": True,
|
"epro2_editor_version": epro2_editor,
|
||||||
"importFlag": 0,
|
|
||||||
"transformList": "",
|
|
||||||
},
|
},
|
||||||
"canvas": canvas,
|
"BBox": {
|
||||||
"shape": shape,
|
"x": bbox_x,
|
||||||
"BBox": {"x": 0, "y": 0, "width": 0, "height": 0},
|
"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]
|
write_sch_std.last_stats = stats # type: ignore[attr-defined]
|
||||||
return {"success": True, "code": 0, "result": result}
|
return {"success": True, "code": 0, "result": result}
|
||||||
|
|||||||
@@ -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
|
import json
|
||||||
from collections import Counter
|
|
||||||
|
|
||||||
from tools.epro2.project_relations import ProjectRelations
|
from tools.epro2.replay import Document
|
||||||
from tools.epro2.replay import Document, Project
|
|
||||||
from tools.epro2.std.pcb_writer import write_pcb_std
|
from tools.epro2.std.pcb_writer import write_pcb_std
|
||||||
from tools.epro2.std.sch_writer import write_sch_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 = Document(doc_uuid=uuid, doc_type=typ)
|
||||||
d.head = {"docType": typ}
|
d.head = {"docType": typ, "editVersion": "3.2.91"}
|
||||||
return d
|
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 ---------------------------------------------------------------
|
# -- PCB ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_pcb_envelope_matches_std_shape():
|
def test_pcb_envelope_has_required_keys():
|
||||||
"""Top-level envelope must be `{success, code, result}` with
|
"""Downstream's adapter checks for `success`/`code`/`result` at the
|
||||||
`result.docType == 3` and `result.dataStr.shape` as a list — that's
|
top level and `head`/`BBox`/`layers`/`objects` inside `dataStr` —
|
||||||
the contract Std parsers key off. Anything else and downstream's
|
the ~100 LoC adapter's first job is to find those keys, so missing
|
||||||
parser bails before the shape array is even read."""
|
any is a hard failure for the entire pipeline."""
|
||||||
d = _doc("PCB", "p1")
|
d = _doc("PCB", "p1")
|
||||||
d.objects["META"] = {"_type": "META", "title": "Test"}
|
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["success"] is True
|
||||||
assert payload["code"] == 0
|
assert payload["code"] == 0
|
||||||
r = payload["result"]
|
r = payload["result"]
|
||||||
assert r["docType"] == 3
|
assert r["docType"] == 3
|
||||||
assert r["uuid"] == "p1"
|
assert r["uuid"] == "p1"
|
||||||
assert isinstance(r["dataStr"]["shape"], list)
|
ds = r["dataStr"]
|
||||||
# Inner SIGNAL layers extension keeps the layer block consistent
|
for required in ("head", "BBox", "layers", "objects",
|
||||||
assert any("TopLayer" in s for s in r["dataStr"]["layers"])
|
"preference", "netColors", "DRCRULE"):
|
||||||
|
assert required in ds, f"missing dataStr.{required}"
|
||||||
|
|
||||||
|
|
||||||
def test_pcb_line_emits_track_with_layer_and_net():
|
def test_pcb_no_shape_field():
|
||||||
"""LINE on a copper layer becomes a Std TRACK string. Field order is
|
"""No `shape` array in our output. Downstream said `shape` empty
|
||||||
`TRACK~width~layer~net~points~uuid~locked` — same as Std produces;
|
placeholder is misleading — they'll generate it themselves."""
|
||||||
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 = _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"] = {
|
d.objects["ln1"] = {
|
||||||
"_type": "LINE", "layerId": 1, "netName": "GND", "width": 6,
|
"_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))
|
payload = write_pcb_std(d)
|
||||||
tracks = [s for s in payload["result"]["dataStr"]["shape"] if s.startswith("TRACK~")]
|
objs = payload["result"]["dataStr"]["objects"]
|
||||||
assert len(tracks) == 1
|
assert objs["e0"] == {"_type": "COMPONENT", "x": 100, "y": -50, "angle": 90}
|
||||||
fields = tracks[0].split("~")
|
assert objs["ln1"]["netName"] == "GND"
|
||||||
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():
|
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 = _doc("PCB", "p1")
|
||||||
d.objects["v1"] = {
|
d.objects["v1"] = {"_type": "VIA", "centerX": -100, "centerY": 50}
|
||||||
"_type": "VIA", "centerX": 100, "centerY": 200,
|
d.objects["ln1"] = {
|
||||||
"viaDiameter": 24, "holeDiameter": 12, "netName": "VCC",
|
"_type": "LINE", "startX": 0, "startY": -200,
|
||||||
|
"endX": 300, "endY": 100,
|
||||||
}
|
}
|
||||||
payload = write_pcb_std(d, project_relations=_empty_pr(d))
|
payload = write_pcb_std(d)
|
||||||
via = next(s for s in payload["result"]["dataStr"]["shape"] if s.startswith("VIA~"))
|
b = payload["result"]["dataStr"]["BBox"]
|
||||||
f = via.split("~")
|
assert b["x"] == -100 # min x across all points
|
||||||
# VIA~x~y~outerD~net~innerD~uuid~locked
|
assert b["y"] == -200 # min y
|
||||||
assert f[1] == "100"
|
assert b["width"] == 400 # 300 - (-100)
|
||||||
assert f[2] == "200"
|
assert b["height"] == 300 # 100 - (-200)
|
||||||
assert f[3] == "24"
|
|
||||||
assert f[4] == "VCC"
|
|
||||||
assert f[5] == "12"
|
|
||||||
|
|
||||||
|
|
||||||
def test_pcb_pour_rectangle_becomes_copperarea_with_svg_path():
|
def test_pcb_layers_appends_used_inner_signals():
|
||||||
"""POUR on a copper layer must emit a COPPERAREA with an SVG `M..L..Z`
|
"""An EPRO2 4-layer board with SIGNAL inner ids 15 + 16 carrying real
|
||||||
path — Std uses SVG path syntax for filled regions, and downstream
|
geometry must add Std layer entries `21~Inner1...` and `22~Inner2...`
|
||||||
fills are computed from this path. A `R x y w h` rectangle expands
|
— the adapter relies on these being present to know the stack-up.
|
||||||
to an explicit four-corner Z-closed polygon."""
|
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 = _doc("PCB", "p1")
|
||||||
d.objects["p1"] = {
|
d.objects["ln1"] = {"_type": "LINE", "layerId": 15,
|
||||||
"_type": "POUR", "layerId": 1, "netName": "GND",
|
"startX": 0, "startY": 0, "endX": 1, "endY": 0}
|
||||||
"path": [["R", 0, 0, 1000, 1000]],
|
d.objects["ln2"] = {"_type": "LINE", "layerId": 16,
|
||||||
}
|
"startX": 0, "startY": 0, "endX": 1, "endY": 0}
|
||||||
payload = write_pcb_std(d, project_relations=_empty_pr(d))
|
payload = write_pcb_std(d)
|
||||||
ca = next(s for s in payload["result"]["dataStr"]["shape"] if s.startswith("COPPERAREA~"))
|
layers = payload["result"]["dataStr"]["layers"]
|
||||||
assert "M 0 0" in ca
|
inner_lines = [s for s in layers if "Inner" in s]
|
||||||
assert " Z" in ca
|
assert any(s.startswith("21~Inner1") for s in inner_lines)
|
||||||
assert "GND" in ca
|
assert any(s.startswith("22~Inner2") for s in inner_lines)
|
||||||
|
|
||||||
|
|
||||||
def test_pcb_lib_nests_pads_via_separator():
|
def test_pcb_non_pcb_doc_rejected():
|
||||||
"""A footprint placement must emit a LIB outer string with PAD inner
|
d = _doc("SCH_PAGE", "x")
|
||||||
shapes joined by `#@$` — that's how Std writes one symbol-with-pads
|
try:
|
||||||
per shape entry. If we emit pads as separate top-level shapes,
|
write_pcb_std(d)
|
||||||
downstream's symbol-grouping breaks (pads end up unowned)."""
|
except ValueError:
|
||||||
fp = _doc("FOOTPRINT", "fp1")
|
return
|
||||||
fp.objects["META"] = {"_type": "META", "title": "0402"}
|
raise AssertionError("expected ValueError for non-PCB doc")
|
||||||
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 ---------------------------------------------------------------
|
# -- SCH ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_sch_envelope_carries_doctype_1():
|
def test_sch_envelope_carries_doctype_1():
|
||||||
"""Std schematic docs are docType=1. Downstream filters on this to
|
"""docType=1 routes the file to downstream's schematic parser
|
||||||
pick which parser to invoke (PCB parser vs SCH parser); a wrong
|
instead of the PCB one."""
|
||||||
docType silently routes the file to the wrong parser."""
|
|
||||||
d = _doc("SCH_PAGE", "s1")
|
d = _doc("SCH_PAGE", "s1")
|
||||||
d.objects["META"] = {"_type": "META", "title": "Test"}
|
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"]["docType"] == 1
|
||||||
|
assert payload["result"]["dataStr"]["head"]["docType"] == "1"
|
||||||
|
|
||||||
|
|
||||||
def test_sch_named_wire_emits_wire_plus_netflag():
|
def test_sch_layers_empty():
|
||||||
"""A LINE whose lineGroup carries a NET attr must produce both a W
|
"""Schematic has no copper stack-up; layers[] is empty."""
|
||||||
(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 = _doc("SCH_PAGE", "s1")
|
||||||
d.objects["w1"] = {"_type": "WIRE"}
|
payload = write_sch_std(d)
|
||||||
d.objects["a1"] = {"_type": "ATTR", "parentId": "w1", "key": "NET", "value": "GND"}
|
assert payload["result"]["dataStr"]["layers"] == []
|
||||||
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():
|
def test_sch_objects_dict_preserved():
|
||||||
"""The 5-Voltage / generic placeholder COMPONENT (Global Net Name
|
d = _doc("SCH_PAGE", "s1")
|
||||||
ATTR carries the rail name) must emit an N flag at the placement
|
d.objects["e1"] = {"_type": "COMPONENT", "partId": "ABC.1",
|
||||||
so the symbol's pin connects to the global rail. Same fix as the
|
"x": 100, "y": 200, "rotation": 0}
|
||||||
KiCad path's global_label handling."""
|
d.objects["a1"] = {"_type": "ATTR", "parentId": "e1",
|
||||||
sym = _doc("SYMBOL", "sym1")
|
"key": "Designator", "value": "U1"}
|
||||||
sym.objects["pid8a0e77bacb214e"] = {"_type": "PART", "title": ""}
|
payload = write_sch_std(d)
|
||||||
sym.objects["pin1"] = {
|
objs = payload["result"]["dataStr"]["objects"]
|
||||||
"_type": "PIN", "partId": "pid8a0e77bacb214e",
|
assert objs["e1"]["partId"] == "ABC.1"
|
||||||
"x": 0, "y": 0, "length": 5, "rotation": 0,
|
assert objs["a1"]["value"] == "U1"
|
||||||
}
|
|
||||||
sch = _doc("SCH_PAGE", "s1")
|
|
||||||
sch.objects["e1"] = {
|
# -- json round-trip ---------------------------------------------------
|
||||||
"_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():
|
def test_writers_round_trip_through_json_dump():
|
||||||
"""Whatever we build has to survive json.dumps without errors —
|
"""Our payloads must survive json.dumps without TypeError — catches
|
||||||
ints/floats/strings/lists only, no datetime / Decimal / bytes
|
Decimal / datetime / bytes leaks early."""
|
||||||
sneaking in. Catches type leaks early."""
|
d_pcb = _doc("PCB", "p1")
|
||||||
d = _doc("PCB", "p1")
|
d_pcb.objects["META"] = {"_type": "META", "title": "Test"}
|
||||||
d.objects["META"] = {"_type": "META", "title": "Test"}
|
json.dumps(write_pcb_std(d_pcb))
|
||||||
payload = write_pcb_std(d, project_relations=_empty_pr(d))
|
d_sch = _doc("SCH_PAGE", "s1")
|
||||||
json.dumps(payload)
|
d_sch.objects["META"] = {"_type": "META", "title": "Test"}
|
||||||
d2 = _doc("SCH_PAGE", "s1")
|
json.dumps(write_sch_std(d_sch))
|
||||||
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