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:
@@ -1,202 +1,166 @@
|
||||
"""Std writer regression: synthetic EPRO2 docs → Std-format JSON dicts."""
|
||||
"""Std writer regression: synthetic EPRO2 docs → Option-2 Std-shaped JSON."""
|
||||
|
||||
import json
|
||||
from collections import Counter
|
||||
|
||||
from tools.epro2.project_relations import ProjectRelations
|
||||
from tools.epro2.replay import Document, Project
|
||||
from tools.epro2.replay import Document
|
||||
from tools.epro2.std.pcb_writer import write_pcb_std
|
||||
from tools.epro2.std.sch_writer import write_sch_std
|
||||
|
||||
|
||||
def _doc(typ, uuid="d"):
|
||||
def _doc(typ, uuid="d") -> Document:
|
||||
d = Document(doc_uuid=uuid, doc_type=typ)
|
||||
d.head = {"docType": typ}
|
||||
d.head = {"docType": typ, "editVersion": "3.2.91"}
|
||||
return d
|
||||
|
||||
|
||||
def _empty_pr(*docs):
|
||||
p = Project(project_uuid="p")
|
||||
for doc in docs:
|
||||
p.documents[doc.doc_uuid] = doc
|
||||
return ProjectRelations.build(p)
|
||||
|
||||
|
||||
def _verbs(payload):
|
||||
return Counter(s.split("~")[0] for s in payload["result"]["dataStr"]["shape"])
|
||||
|
||||
|
||||
# -- PCB ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_pcb_envelope_matches_std_shape():
|
||||
"""Top-level envelope must be `{success, code, result}` with
|
||||
`result.docType == 3` and `result.dataStr.shape` as a list — that's
|
||||
the contract Std parsers key off. Anything else and downstream's
|
||||
parser bails before the shape array is even read."""
|
||||
def test_pcb_envelope_has_required_keys():
|
||||
"""Downstream's adapter checks for `success`/`code`/`result` at the
|
||||
top level and `head`/`BBox`/`layers`/`objects` inside `dataStr` —
|
||||
the ~100 LoC adapter's first job is to find those keys, so missing
|
||||
any is a hard failure for the entire pipeline."""
|
||||
d = _doc("PCB", "p1")
|
||||
d.objects["META"] = {"_type": "META", "title": "Test"}
|
||||
payload = write_pcb_std(d, project_relations=_empty_pr(d))
|
||||
payload = write_pcb_std(d)
|
||||
assert payload["success"] is True
|
||||
assert payload["code"] == 0
|
||||
r = payload["result"]
|
||||
assert r["docType"] == 3
|
||||
assert r["uuid"] == "p1"
|
||||
assert isinstance(r["dataStr"]["shape"], list)
|
||||
# Inner SIGNAL layers extension keeps the layer block consistent
|
||||
assert any("TopLayer" in s for s in r["dataStr"]["layers"])
|
||||
ds = r["dataStr"]
|
||||
for required in ("head", "BBox", "layers", "objects",
|
||||
"preference", "netColors", "DRCRULE"):
|
||||
assert required in ds, f"missing dataStr.{required}"
|
||||
|
||||
|
||||
def test_pcb_line_emits_track_with_layer_and_net():
|
||||
"""LINE on a copper layer becomes a Std TRACK string. Field order is
|
||||
`TRACK~width~layer~net~points~uuid~locked` — same as Std produces;
|
||||
a wrong order means tracks land on the wrong layer in downstream
|
||||
renders even if the parser doesn't crash."""
|
||||
def test_pcb_no_shape_field():
|
||||
"""No `shape` array in our output. Downstream said `shape` empty
|
||||
placeholder is misleading — they'll generate it themselves."""
|
||||
d = _doc("PCB", "p1")
|
||||
payload = write_pcb_std(d)
|
||||
assert "shape" not in payload["result"]["dataStr"]
|
||||
|
||||
|
||||
def test_pcb_head_carries_units_and_editor_version():
|
||||
"""`head.units = "mil"` is the explicit hint the adapter keys off
|
||||
to skip mm conversion. `head.editorVersion` exposes the EPRO2
|
||||
editor build the source was authored with — used by Wokwi to pick
|
||||
its parser branch."""
|
||||
d = _doc("PCB", "p1")
|
||||
payload = write_pcb_std(d)
|
||||
head = payload["result"]["dataStr"]["head"]
|
||||
assert head["units"] == "mil"
|
||||
assert head["docType"] == "3"
|
||||
assert "3.2.91" in head["editorVersion"]
|
||||
|
||||
|
||||
def test_pcb_objects_dict_is_full_document_objects():
|
||||
"""The whole point of Option 2: pass the raw EPRO2 objects through
|
||||
untouched so the adapter can dispatch on `_type` and access every
|
||||
field without going through us. Verify the dict is preserved
|
||||
1:1 (not just shallow keys)."""
|
||||
d = _doc("PCB", "p1")
|
||||
d.objects["e0"] = {"_type": "COMPONENT", "x": 100, "y": -50, "angle": 90}
|
||||
d.objects["ln1"] = {
|
||||
"_type": "LINE", "layerId": 1, "netName": "GND", "width": 6,
|
||||
"startX": 100, "startY": 200, "endX": 500, "endY": 200,
|
||||
"startX": 0, "startY": 0, "endX": 100, "endY": 0,
|
||||
}
|
||||
payload = write_pcb_std(d, project_relations=_empty_pr(d))
|
||||
tracks = [s for s in payload["result"]["dataStr"]["shape"] if s.startswith("TRACK~")]
|
||||
assert len(tracks) == 1
|
||||
fields = tracks[0].split("~")
|
||||
assert fields[0] == "TRACK"
|
||||
assert fields[1] == "6" # width
|
||||
assert fields[2] == "1" # std layer 1 = TopLayer
|
||||
assert fields[3] == "GND" # net name
|
||||
assert "100 200 500 200" in fields[4]
|
||||
payload = write_pcb_std(d)
|
||||
objs = payload["result"]["dataStr"]["objects"]
|
||||
assert objs["e0"] == {"_type": "COMPONENT", "x": 100, "y": -50, "angle": 90}
|
||||
assert objs["ln1"]["netName"] == "GND"
|
||||
|
||||
|
||||
def test_pcb_via_emits_correct_field_order():
|
||||
def test_pcb_bbox_is_min_max_of_numeric_x_y_pairs():
|
||||
"""BBox is best-effort min/max across known x/y/startX/startY/...
|
||||
fields. Adapter can refine by walking `path` arrays itself; we just
|
||||
give it a gross outer rectangle good enough for canvas centering.
|
||||
|
||||
All coords stay in mil (no mm conversion) — `head.units` says so."""
|
||||
d = _doc("PCB", "p1")
|
||||
d.objects["v1"] = {
|
||||
"_type": "VIA", "centerX": 100, "centerY": 200,
|
||||
"viaDiameter": 24, "holeDiameter": 12, "netName": "VCC",
|
||||
d.objects["v1"] = {"_type": "VIA", "centerX": -100, "centerY": 50}
|
||||
d.objects["ln1"] = {
|
||||
"_type": "LINE", "startX": 0, "startY": -200,
|
||||
"endX": 300, "endY": 100,
|
||||
}
|
||||
payload = write_pcb_std(d, project_relations=_empty_pr(d))
|
||||
via = next(s for s in payload["result"]["dataStr"]["shape"] if s.startswith("VIA~"))
|
||||
f = via.split("~")
|
||||
# VIA~x~y~outerD~net~innerD~uuid~locked
|
||||
assert f[1] == "100"
|
||||
assert f[2] == "200"
|
||||
assert f[3] == "24"
|
||||
assert f[4] == "VCC"
|
||||
assert f[5] == "12"
|
||||
payload = write_pcb_std(d)
|
||||
b = payload["result"]["dataStr"]["BBox"]
|
||||
assert b["x"] == -100 # min x across all points
|
||||
assert b["y"] == -200 # min y
|
||||
assert b["width"] == 400 # 300 - (-100)
|
||||
assert b["height"] == 300 # 100 - (-200)
|
||||
|
||||
|
||||
def test_pcb_pour_rectangle_becomes_copperarea_with_svg_path():
|
||||
"""POUR on a copper layer must emit a COPPERAREA with an SVG `M..L..Z`
|
||||
path — Std uses SVG path syntax for filled regions, and downstream
|
||||
fills are computed from this path. A `R x y w h` rectangle expands
|
||||
to an explicit four-corner Z-closed polygon."""
|
||||
def test_pcb_layers_appends_used_inner_signals():
|
||||
"""An EPRO2 4-layer board with SIGNAL inner ids 15 + 16 carrying real
|
||||
geometry must add Std layer entries `21~Inner1...` and `22~Inner2...`
|
||||
— the adapter relies on these being present to know the stack-up.
|
||||
Unused SIGNAL inners (declared in LAYER ops but no primitives)
|
||||
don't get an entry; they'd just be noise."""
|
||||
d = _doc("PCB", "p1")
|
||||
d.objects["p1"] = {
|
||||
"_type": "POUR", "layerId": 1, "netName": "GND",
|
||||
"path": [["R", 0, 0, 1000, 1000]],
|
||||
}
|
||||
payload = write_pcb_std(d, project_relations=_empty_pr(d))
|
||||
ca = next(s for s in payload["result"]["dataStr"]["shape"] if s.startswith("COPPERAREA~"))
|
||||
assert "M 0 0" in ca
|
||||
assert " Z" in ca
|
||||
assert "GND" in ca
|
||||
d.objects["ln1"] = {"_type": "LINE", "layerId": 15,
|
||||
"startX": 0, "startY": 0, "endX": 1, "endY": 0}
|
||||
d.objects["ln2"] = {"_type": "LINE", "layerId": 16,
|
||||
"startX": 0, "startY": 0, "endX": 1, "endY": 0}
|
||||
payload = write_pcb_std(d)
|
||||
layers = payload["result"]["dataStr"]["layers"]
|
||||
inner_lines = [s for s in layers if "Inner" in s]
|
||||
assert any(s.startswith("21~Inner1") for s in inner_lines)
|
||||
assert any(s.startswith("22~Inner2") for s in inner_lines)
|
||||
|
||||
|
||||
def test_pcb_lib_nests_pads_via_separator():
|
||||
"""A footprint placement must emit a LIB outer string with PAD inner
|
||||
shapes joined by `#@$` — that's how Std writes one symbol-with-pads
|
||||
per shape entry. If we emit pads as separate top-level shapes,
|
||||
downstream's symbol-grouping breaks (pads end up unowned)."""
|
||||
fp = _doc("FOOTPRINT", "fp1")
|
||||
fp.objects["META"] = {"_type": "META", "title": "0402"}
|
||||
fp.objects["pad1"] = {
|
||||
"_type": "PAD", "num": "1", "centerX": -20, "centerY": 0,
|
||||
"padAngle": 0, "layerId": 1, "hole": None,
|
||||
"defaultPad": {"padType": "RECT", "width": 30, "height": 20},
|
||||
}
|
||||
pcb = _doc("PCB", "pcb1")
|
||||
pcb.objects["C1"] = {"_type": "COMPONENT", "x": 100, "y": 100, "angle": 0}
|
||||
pcb.objects["a1"] = {
|
||||
"_type": "ATTR", "parentId": "C1", "key": "Footprint", "value": "fp1",
|
||||
}
|
||||
payload = write_pcb_std(pcb, project_relations=_empty_pr(fp, pcb))
|
||||
libs = [s for s in payload["result"]["dataStr"]["shape"] if s.startswith("LIB~")]
|
||||
assert len(libs) == 1
|
||||
# Nested children separated by '#@$'
|
||||
parts = libs[0].split("#@$")
|
||||
assert parts[0].startswith("LIB~")
|
||||
assert any(p.startswith("PAD~") for p in parts[1:])
|
||||
# Std treats each LIB-rooted block as the unit shape entry, not the
|
||||
# nested PADs — verify no top-level PAD leaked
|
||||
assert not any(s.startswith("PAD~") for s in payload["result"]["dataStr"]["shape"])
|
||||
def test_pcb_non_pcb_doc_rejected():
|
||||
d = _doc("SCH_PAGE", "x")
|
||||
try:
|
||||
write_pcb_std(d)
|
||||
except ValueError:
|
||||
return
|
||||
raise AssertionError("expected ValueError for non-PCB doc")
|
||||
|
||||
|
||||
# -- SCH ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_sch_envelope_carries_doctype_1():
|
||||
"""Std schematic docs are docType=1. Downstream filters on this to
|
||||
pick which parser to invoke (PCB parser vs SCH parser); a wrong
|
||||
docType silently routes the file to the wrong parser."""
|
||||
"""docType=1 routes the file to downstream's schematic parser
|
||||
instead of the PCB one."""
|
||||
d = _doc("SCH_PAGE", "s1")
|
||||
d.objects["META"] = {"_type": "META", "title": "Test"}
|
||||
payload = write_sch_std(d, project_relations=_empty_pr(d))
|
||||
payload = write_sch_std(d)
|
||||
assert payload["result"]["docType"] == 1
|
||||
assert payload["result"]["dataStr"]["head"]["docType"] == "1"
|
||||
|
||||
|
||||
def test_sch_named_wire_emits_wire_plus_netflag():
|
||||
"""A LINE whose lineGroup carries a NET attr must produce both a W
|
||||
(the wire segment) and an N (a net flag at one endpoint, named
|
||||
after the net). Same-named flags on distinct wire segments is how
|
||||
Std unifies named nets — without the N, the wire is anonymous."""
|
||||
def test_sch_layers_empty():
|
||||
"""Schematic has no copper stack-up; layers[] is empty."""
|
||||
d = _doc("SCH_PAGE", "s1")
|
||||
d.objects["w1"] = {"_type": "WIRE"}
|
||||
d.objects["a1"] = {"_type": "ATTR", "parentId": "w1", "key": "NET", "value": "GND"}
|
||||
d.objects["ln1"] = {
|
||||
"_type": "LINE", "lineGroup": "w1",
|
||||
"startX": 0, "startY": 0, "endX": 100, "endY": 0,
|
||||
}
|
||||
payload = write_sch_std(d, project_relations=_empty_pr(d))
|
||||
v = _verbs(payload)
|
||||
assert v["W"] == 1
|
||||
assert v["N"] == 1
|
||||
payload = write_sch_std(d)
|
||||
assert payload["result"]["dataStr"]["layers"] == []
|
||||
|
||||
|
||||
def test_sch_power_port_component_emits_extra_netflag():
|
||||
"""The 5-Voltage / generic placeholder COMPONENT (Global Net Name
|
||||
ATTR carries the rail name) must emit an N flag at the placement
|
||||
so the symbol's pin connects to the global rail. Same fix as the
|
||||
KiCad path's global_label handling."""
|
||||
sym = _doc("SYMBOL", "sym1")
|
||||
sym.objects["pid8a0e77bacb214e"] = {"_type": "PART", "title": ""}
|
||||
sym.objects["pin1"] = {
|
||||
"_type": "PIN", "partId": "pid8a0e77bacb214e",
|
||||
"x": 0, "y": 0, "length": 5, "rotation": 0,
|
||||
}
|
||||
sch = _doc("SCH_PAGE", "s1")
|
||||
sch.objects["e1"] = {
|
||||
"_type": "COMPONENT", "partId": "pid8a0e77bacb214e",
|
||||
"x": 100, "y": 50, "rotation": 0,
|
||||
}
|
||||
sch.objects["a1"] = {
|
||||
"_type": "ATTR", "parentId": "e1",
|
||||
"key": "Global Net Name", "value": "VBUS",
|
||||
}
|
||||
payload = write_sch_std(sch, project_relations=_empty_pr(sym, sch))
|
||||
flags = [s for s in payload["result"]["dataStr"]["shape"] if s.startswith("N~")]
|
||||
assert any("VBUS" in s for s in flags), \
|
||||
"expected an N flag named VBUS for the power-port placement"
|
||||
def test_sch_objects_dict_preserved():
|
||||
d = _doc("SCH_PAGE", "s1")
|
||||
d.objects["e1"] = {"_type": "COMPONENT", "partId": "ABC.1",
|
||||
"x": 100, "y": 200, "rotation": 0}
|
||||
d.objects["a1"] = {"_type": "ATTR", "parentId": "e1",
|
||||
"key": "Designator", "value": "U1"}
|
||||
payload = write_sch_std(d)
|
||||
objs = payload["result"]["dataStr"]["objects"]
|
||||
assert objs["e1"]["partId"] == "ABC.1"
|
||||
assert objs["a1"]["value"] == "U1"
|
||||
|
||||
|
||||
# -- json round-trip ---------------------------------------------------
|
||||
|
||||
|
||||
def test_writers_round_trip_through_json_dump():
|
||||
"""Whatever we build has to survive json.dumps without errors —
|
||||
ints/floats/strings/lists only, no datetime / Decimal / bytes
|
||||
sneaking in. Catches type leaks early."""
|
||||
d = _doc("PCB", "p1")
|
||||
d.objects["META"] = {"_type": "META", "title": "Test"}
|
||||
payload = write_pcb_std(d, project_relations=_empty_pr(d))
|
||||
json.dumps(payload)
|
||||
d2 = _doc("SCH_PAGE", "s1")
|
||||
d2.objects["META"] = {"_type": "META", "title": "Test"}
|
||||
payload2 = write_sch_std(d2, project_relations=_empty_pr(d2))
|
||||
json.dumps(payload2)
|
||||
"""Our payloads must survive json.dumps without TypeError — catches
|
||||
Decimal / datetime / bytes leaks early."""
|
||||
d_pcb = _doc("PCB", "p1")
|
||||
d_pcb.objects["META"] = {"_type": "META", "title": "Test"}
|
||||
json.dumps(write_pcb_std(d_pcb))
|
||||
d_sch = _doc("SCH_PAGE", "s1")
|
||||
d_sch.objects["META"] = {"_type": "META", "title": "Test"}
|
||||
json.dumps(write_sch_std(d_sch))
|
||||
|
||||
Reference in New Issue
Block a user