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:
2026-04-29 01:41:12 +08:00
parent c6fd111d6d
commit 3866e24189
6 changed files with 639 additions and 860 deletions

View 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.