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

@@ -1,72 +1,47 @@
"""Convert one EPRO2 PCB Document → an EasyEDA Std-format PCB JSON.
"""Convert one EPRO2 PCB Document → an EasyEDA Std-shaped JSON file
that hands the raw EPRO2 ``objects`` dict to a downstream adapter.
Std PCB format (probed on `data/raw/oshwhub/3e2f893d.../25931ddab8.json`):
This is the **Option 2** form the downstream consumer asked for: we
keep the Std envelope (``success`` / ``code`` / ``result.dataStr.head /
BBox / layers / objects``) but **don't** emit a ``shape[]`` array of
tilde-delimited strings. Their adapter walks ``objects`` and dispatches
by ``_type`` to build the actual Std shape strings — see
``docs/sources/epro2_to_std_mapping.md`` for the EPRO2 OPTYPE → Std verb
table they should follow.
{
"success": true, "code": 0,
"result": {
"uuid": <doc_uuid>, "puuid": <project_uuid>, "title": "...",
"docType": 3,
"components": {<lib_uuid>: <ref_count>, ...},
"dataStr": {
"head": {"docType":"3","editorVersion":"...","x":...,"y":...},
"canvas": "CA~<w>~<h>~<bg>~...",
"shape": ["TRACK~...", "PAD~...", "LIB~...#@$PAD~...#@$TEXT~...", ...],
"layers": ["1~TopLayer~#FF0000~true~true~true~", ...],
"objects": [], "BBox": {...}
}
}
}
Each shape verb is a tilde-delimited string. LIB shapes nest inner PAD/TEXT
via the ``#@$`` separator so a footprint placement is one outer LIB string
plus its body.
Phase-1 scope (mirrors the KiCad PCB writer): TRACK / VIA / COPPERAREA /
RECT / CIRCLE / SOLIDREGION / LIB(+PAD+TEXT). Skipped: SVGNODE bitmaps,
manual FILL on copper (rare), TEARDROP fillets (cosmetic).
Field choices (per downstream's spec, 2026-04-29):
- units = "mil" (Std's internal canvas unit; no mm conversion here)
- BBox in mil too (same units as objects)
- head.editorVersion taken from EPRO2 doc.head.editVersion
- head.docType "3" (PCB) so adapter selects the PCB branch
- shape[] OMITTED — empty placeholder would mislead the adapter
- preference / netColors / DRCRULE kept as empty stubs so grep paths
are stable for failure triage
"""
from __future__ import annotations
import json
import math
import uuid as _uuid
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from dataclasses import dataclass
from ..relations import Relations
from ..replay import Document
if TYPE_CHECKING:
from ..project_relations import ProjectRelations
@dataclass
class WriteStats:
objects: int = 0
bbox_x: float = 0.0
bbox_y: float = 0.0
bbox_w: float = 0.0
bbox_h: float = 0.0
layers_emitted: int = 0
# -- EPRO2 layer id → Std layer id --------------------------------------
#
# Std uses a different numbering than EPRO2. Probed from a Std PCB file's
# `layers` block; mismatches in the 5/6/7/8 (mask/paste) range are real.
EPRO_TO_STD_LAYER: dict[int, int] = {
1: 1, # TOP_LAYER
2: 2, # BOTTOM_LAYER
3: 3, # TOP_SILK
4: 4, # BOT_SILK
5: 7, # TOP_SOLDER_MASK (Std 7, EPRO2 5 — flipped vs PASTE)
6: 8, # BOT_SOLDER_MASK
7: 5, # TOP_PASTE_MASK (Std 5, EPRO2 7)
8: 6, # BOT_PASTE_MASK
9: 13, # TOP_ASSEMBLY
10: 14, # BOT_ASSEMBLY
11: 10, # OUTLINE → BoardOutLine
12: 11, # MULTI → Multi-Layer
13: 12, # DOCUMENT
14: 15, # MECHANICAL
}
# EPRO2 SIGNAL inner layers 15..46 → Std Inner1..Inner30 = layer ids 21..50.
# Default Std layer block — we emit the standard ones plus any inner layers
# the board actually uses. Matches the "layers" format `id~name~color~visible
# ~active~locked~clearance/type`.
# Std layer-block format. Same content the EasyEDA editor writes — keeping
# the textual layout 1:1 means downstream layer-id lookups don't have to
# special-case our output. Inner SIGNAL layers (21+) get appended on demand
# from the actual EPRO2 SIGNAL layer ids that carry geometry on this board.
_DEFAULT_STD_LAYERS: list[str] = [
"1~TopLayer~#FF0000~true~true~true~",
"2~BottomLayer~#0000FF~true~false~true~",
@@ -86,428 +61,119 @@ _DEFAULT_STD_LAYERS: list[str] = [
]
@dataclass
class WriteStats:
tracks: int = 0
vias: int = 0
copperareas: int = 0
rects: int = 0
circles: int = 0
solidregions: int = 0
libs: int = 0
libs_unresolved: int = 0
pads: int = 0
texts: int = 0
holes: int = 0
skipped: int = 0
# EPRO2 fields that carry x/y coordinates we should consider for BBox
# computation. Most ops follow one of two conventions: ``(centerX,
# centerY)`` for symmetric primitives (VIA, FILL, PAD), and
# ``(startX..endX, startY..endY)`` for line segments.
_BBOX_POINT_FIELDS: list[tuple[str, str]] = [
("x", "y"),
("startX", "startY"),
("endX", "endY"),
("centerX", "centerY"),
]
def _gge() -> str:
"""Std prefixes ids with `gge<8 hex>`. We use uuid4 hex slice for
uniqueness; downstream tools accept any unique opaque string here."""
return "gge" + _uuid.uuid4().hex[:8]
def _gather_bbox_points(doc: Document) -> tuple[float, float, float, float]:
"""Best-effort BBox from every numeric x/y pair we recognize.
def _num(v) -> str:
"""Format a number like Std does (no trailing .0, but keep precision)."""
if v is None:
return "0"
try:
f = float(v)
except (TypeError, ValueError):
return "0"
if math.isclose(f, int(f), abs_tol=1e-9):
return str(int(f))
return f"{f:.4f}".rstrip("0").rstrip(".")
def _layer(epro_layer_id, signal_inner_map: dict[int, int]) -> str:
if epro_layer_id is None:
return "0"
try:
lid = int(epro_layer_id)
except (TypeError, ValueError):
return "0"
if lid in EPRO_TO_STD_LAYER:
return str(EPRO_TO_STD_LAYER[lid])
if lid in signal_inner_map:
return str(signal_inner_map[lid])
return str(lid)
def _build_signal_inner_map(doc: Document) -> dict[int, int]:
"""EPRO2 SIGNAL inner layer ids (15+) → Std inner ids (21..50)."""
inner_ids: list[int] = []
for oid, obj in doc.objects.items():
if obj.get("_type") != "LAYER":
continue
if obj.get("layerType") != "SIGNAL":
continue
if oid.startswith('["LAYER",'):
try:
cid = json.loads(oid)
lid = int(cid[1])
if obj.get("use") and lid >= 15:
inner_ids.append(lid)
except (ValueError, IndexError, TypeError):
continue
inner_ids.sort()
return {lid: 21 + i for i, lid in enumerate(inner_ids)}
def _track(line: dict, signal_inner_map: dict[int, int]) -> str | None:
layer = _layer(line.get("layerId"), signal_inner_map)
width = _num(line.get("width") or 6)
net = str(line.get("netName") or "")
pts = (
f"{_num(line.get('startX'))} {_num(line.get('startY'))} "
f"{_num(line.get('endX'))} {_num(line.get('endY'))}"
)
return f"TRACK~{width}~{layer}~{net}~{pts}~{_gge()}~0"
def _via(via: dict) -> str:
cx = _num(via.get("centerX"))
cy = _num(via.get("centerY"))
outer = _num(via.get("viaDiameter"))
inner = _num(via.get("holeDiameter"))
net = str(via.get("netName") or "")
return f"VIA~{cx}~{cy}~{outer}~{net}~{inner}~{_gge()}~0"
def _path_to_svg(path) -> str:
"""EPRO2 path tokens → SVG-ish 'M x y L x y ...' string used by Std
COPPERAREA / SOLIDREGION shapes. ARC tokens collapse to chord
segments (Phase-1 same call as the KiCad writer). Numbers are
formatted via ``_num`` (no trailing ``.0`` on integers) so the
output matches Std's typographic conventions exactly."""
if not isinstance(path, list) or not path:
return ""
if isinstance(path[0], list):
# path is wrapped in an extra outer list, like POUR.path = [[...]]
path = path[0]
head = path[0] if path else None
if isinstance(head, str) and head.upper() == "R" and len(path) >= 5:
try:
x = float(path[1]); y = float(path[2])
w = float(path[3]); h = float(path[4])
return (
f"M {_num(x)} {_num(y)} L {_num(x + w)} {_num(y)} "
f"L {_num(x + w)} {_num(y + h)} L {_num(x)} {_num(y + h)} Z"
)
except (TypeError, ValueError):
pass
if isinstance(head, str) and head.upper() == "CIRCLE" and len(path) >= 4:
try:
cx = float(path[1]); cy = float(path[2]); r = float(path[3])
pts = []
for i in range(24):
a = 2 * math.pi * i / 24
pts.append((cx + r * math.cos(a), cy + r * math.sin(a)))
return "M " + " L ".join(f"{_num(x)} {_num(y)}" for x, y in pts) + " Z"
except (TypeError, ValueError):
pass
out: list[str] = []
i = 0
started = False
while i < len(path):
tok = path[i]
if isinstance(tok, str):
if tok.upper() == "ARC":
try:
ex = float(path[i + 2]); ey = float(path[i + 3])
out.append(f"L {_num(ex)} {_num(ey)}")
except (TypeError, ValueError, IndexError):
pass
i += 4
continue
i += 1
continue
try:
x = float(path[i]); y = float(path[i + 1])
out.append(("M" if not started else "L") + f" {_num(x)} {_num(y)}")
started = True
i += 2
except (TypeError, ValueError, IndexError):
i += 1
return " ".join(out)
def _copperarea(pour: dict, signal_inner_map: dict[int, int]) -> str | None:
layer = _layer(pour.get("layerId"), signal_inner_map)
net = str(pour.get("netName") or "")
svg = _path_to_svg(pour.get("path"))
if not svg:
return None
width = _num(pour.get("width") or 1)
# COPPERAREA~clearance~layer~net~svgPath~strokeWidth~~~~~~~uuid?
return f"COPPERAREA~1~{layer}~{net}~{svg}~{width}~~~~~~~{_gge()}~0"
def _circle(obj: dict, signal_inner_map: dict[int, int]) -> str | None:
"""Used for non-copper graphic POLYs whose path is `['CIRCLE', cx, cy, r]`."""
path = obj.get("path") or []
head = path[0] if path else None
if isinstance(head, list):
head = head[0] if head else None
if not (isinstance(head, str) and head.upper() == "CIRCLE"):
return None
inner = path[0] if isinstance(path[0], list) else path
try:
cx = _num(inner[1]); cy = _num(inner[2]); r = _num(inner[3])
except (TypeError, IndexError, ValueError):
return None
layer = _layer(obj.get("layerId"), signal_inner_map)
width = _num(obj.get("width") or 1)
return f"CIRCLE~{cx}~{cy}~{r}~{width}~{layer}~{_gge()}~0~~"
def _solidregion(obj: dict) -> str | None:
"""EPRO2 FILL → Std SOLIDREGION."""
svg = _path_to_svg(obj.get("path"))
if not svg:
return None
return f"SOLIDREGION~99~~{svg}~solid~{_gge()}~~~~0"
def _pad_for_lib(pad: dict, comp_id: str, pcb_rel: Relations,
signal_inner_map: dict[int, int]) -> str | None:
"""Std nested PAD inside a LIB, format:
PAD~shape~x~y~width~height~layer~net~num~drillSize~~rot~uuid~~~plated~?~?~clearance~paste"""
default_pad = pad.get("defaultPad") or {}
shape_map = {"RECT": "RECT", "ELLIPSE": "ELLIPSE", "OVAL": "OVAL", "POLYGON": "POLYGON"}
shape = shape_map.get(default_pad.get("padType"))
if shape is None:
return None
cx = _num(pad.get("centerX"))
cy = _num(pad.get("centerY"))
w = _num(default_pad.get("width"))
h = _num(default_pad.get("height"))
layer = _layer(pad.get("layerId"), signal_inner_map)
rot = _num(pad.get("padAngle") or 0)
pin_num = str(pad.get("num") or "")
# Net via PCB-level PAD_NET (cross-doc, like in footprint_writer)
net_name = ""
pad_id = next(
(pid for pid in pcb_rel.pads if pcb_rel.pads[pid] is pad), # rare path; usually pad_id is the dict key
None,
)
# The standard path: walk pad_nets_by_pad keyed by the pad's id.
# We don't have the id here; caller passes it via outer loop.
net_name = ""
# Hole
hole = pad.get("hole")
drill = "0"
if hole:
drill = _num(hole.get("width") or 0)
# Phase-1 leaves the trailing free-form metadata empty but keeps the
# field count; downstream Std parsers can tolerate empties but balk
# at missing positional fields.
return (
f"PAD~{shape}~{cx}~{cy}~{w}~{h}~{layer}~{net_name}~{pin_num}~{drill}~~"
f"{rot}~{_gge()}~0~~Y~0~0~0.2~{cx},{cy}"
)
def _lib(comp_id: str, comp: dict, attrs: dict, fp_doc: Document,
pcb_rel: Relations, signal_inner_map: dict[int, int]) -> tuple[str, int]:
"""Build a Std LIB shape with nested PAD / TEXT children separated by `#@$`.
Returns ``(lib_string, pad_count_for_stats)``.
Skips ``path`` arrays — they're heterogeneous (rectangles use ``['R',
x, y, w, h]``, polylines mix verb tokens with numbers). Adapters can
refine BBox once they parse paths; ours is the gross outer rectangle
that's good enough for canvas centering.
"""
px = _num(comp.get("x"))
py = _num(comp.get("y"))
rot = _num(comp.get("angle") or 0)
designator = str(attrs.get("Designator") or "")
fp_title = (fp_doc.objects.get("META") or {}).get("title") or fp_doc.doc_uuid[:8]
package = f"{fp_title}`" # Std emits a trailing backtick after package name
# Outer LIB: x, y, package_name, rotation, ?, uuid, display, ?, ?, locked,
# ?, yes, ?
outer = (
f"LIB~{px}~{py}~{package}~{rot}~~{_gge()}~1~~~0~~yes~~"
)
# Build inner PAD blocks per FOOTPRINT.PAD with its abs (footprint-local)
# coords offset to the placement origin. Std stores pad coords as
# absolute board coords; we therefore translate from footprint-local
# to PCB absolute here.
rel_fp = Relations.build(fp_doc)
inners: list[str] = []
pad_count = 0
px_f = float(comp.get("x") or 0)
py_f = float(comp.get("y") or 0)
rot_f = math.radians(float(comp.get("angle") or 0))
cos_a, sin_a = math.cos(rot_f), math.sin(rot_f)
for pad_id, pad in rel_fp.pads.items():
local_x = float(pad.get("centerX") or 0)
local_y = float(pad.get("centerY") or 0)
# Apply placement rotation to the local (x, y) then translate.
abs_x = px_f + local_x * cos_a - local_y * sin_a
abs_y = py_f + local_x * sin_a + local_y * cos_a
default_pad = pad.get("defaultPad") or {}
shape_kind = default_pad.get("padType") or "RECT"
w = _num(default_pad.get("width"))
h = _num(default_pad.get("height"))
layer = _layer(pad.get("layerId"), signal_inner_map)
pad_rot = _num((float(pad.get("padAngle") or 0) + float(comp.get("angle") or 0)) % 360)
pin_num = str(pad.get("num") or "")
# Net resolution via PCB PAD_NET (cross-doc)
net_name = ""
for record in pcb_rel.pad_nets_by_pad.get(pad_id, []):
if record.get("comp") == comp_id:
net_name = record.get("net_name") or ""
break
hole = pad.get("hole")
drill = _num(hole.get("width")) if hole else "0"
inners.append(
f"PAD~{shape_kind}~{_num(abs_x)}~{_num(abs_y)}~{w}~{h}~{layer}~"
f"{net_name}~{pin_num}~{drill}~~{pad_rot}~{_gge()}~0~~Y~0~0~0.2~"
f"{_num(abs_x)},{_num(abs_y)}"
)
pad_count += 1
# Designator text (Std treats it as P=property)
if designator:
inners.append(
f"TEXT~P~{px}~{py}~0.7~0~0~3~~4.5~{designator}~~~"
)
body = "#@$".join([outer] + inners)
return body, pad_count
xs: list[float] = []
ys: list[float] = []
for obj in doc.objects.values():
for fx, fy in _BBOX_POINT_FIELDS:
x = obj.get(fx)
y = obj.get(fy)
if x is None or y is None:
continue
try:
xs.append(float(x))
ys.append(float(y))
except (TypeError, ValueError):
pass
if not xs:
return (0.0, 0.0, 0.0, 0.0)
x0, x1 = min(xs), max(xs)
y0, y1 = min(ys), max(ys)
return (x0, y0, x1 - x0, y1 - y0)
def write_pcb_std(
doc: Document,
*,
project_relations: "ProjectRelations" | None = None,
) -> dict:
"""EPRO2 PCB Document → Std-format JSON dict (ready for json.dump)."""
def _used_inner_signal_layers(doc: Document) -> list[int]:
"""EPRO2 SIGNAL inner layer ids 15+ that have actual primitives on
them — those are the ones we add to the Std layer block as
Inner1..InnerN. Layer ops that only declare 'use=True' but carry no
geometry don't need to leak into the Std layers list."""
used: set[int] = set()
for obj in doc.objects.values():
lid = obj.get("layerId")
if lid is None:
continue
try:
n = int(lid)
except (TypeError, ValueError):
continue
if n >= 15:
used.add(n)
return sorted(used)
def write_pcb_std(doc: Document) -> dict:
"""EPRO2 PCB Document → Std-shaped JSON dict (ready for json.dump).
Returns the raw envelope; CLI is responsible for json.dumps + write.
"""
if doc.doc_type != "PCB":
raise ValueError(f"expected PCB doc, got {doc.doc_type!r}")
rel = Relations.build(doc)
signal_inner_map = _build_signal_inner_map(doc)
stats = WriteStats()
shape: list[str] = []
# Tracks
for oid, obj in doc.objects.items():
if obj.get("_type") != "LINE":
continue
# Std TRACK is for any layer (copper or silk), unlike KiCad which
# splits copper→segment / silk→gr_line. Std uses the same verb,
# disambiguated by layer id.
track = _track(obj, signal_inner_map)
if track:
shape.append(track)
stats.tracks += 1
# Vias
for oid, obj in doc.objects.items():
if obj.get("_type") == "VIA":
shape.append(_via(obj))
stats.vias += 1
# Copper pours
for oid, obj in doc.objects.items():
if obj.get("_type") != "POUR":
continue
s = _copperarea(obj, signal_inner_map)
if s:
shape.append(s)
stats.copperareas += 1
else:
stats.skipped += 1
# POLY graphics: circles vs polygons → CIRCLE / SOLIDREGION
for oid, obj in doc.objects.items():
if obj.get("_type") != "POLY":
continue
c = _circle(obj, signal_inner_map)
if c:
shape.append(c)
stats.circles += 1
continue
s = _solidregion(obj)
if s:
shape.append(s)
stats.solidregions += 1
# FILL (manual filled regions) → SOLIDREGION
for oid, obj in doc.objects.items():
if obj.get("_type") != "FILL":
continue
s = _solidregion(obj)
if s:
shape.append(s)
stats.solidregions += 1
# Footprint placements (LIB with nested PAD/TEXT)
components_dict: dict[str, int] = {}
if project_relations is not None:
for cid, comp in rel.components.items():
fp_uuid = project_relations.resolve_footprint_doc(doc.doc_uuid, cid)
if not fp_uuid or fp_uuid not in project_relations.project.documents:
stats.libs_unresolved += 1
continue
fp_doc = project_relations.project.documents[fp_uuid]
attrs = rel.attrs_dict(cid)
try:
lib_str, pad_count = _lib(cid, comp, attrs, fp_doc, rel, signal_inner_map)
except Exception:
stats.skipped += 1
continue
shape.append(lib_str)
stats.libs += 1
stats.pads += pad_count
components_dict[fp_uuid] = components_dict.get(fp_uuid, 0) + 1
# ---- envelope ----
title = (doc.objects.get("META") or {}).get("title") or doc.doc_uuid[:12]
canvas_x = "4000"
canvas_y = "3000"
canvas = (
f"CA~1000~1000~#000000~yes~#FFFFFF~0.1~1000~1000~line~0.1~mm~"
f"4.499991~45~visible~0.1~{canvas_x}~{canvas_y}~0~yes"
)
# Add inner SIGNAL layers Std actually saw on this board
bbox_x, bbox_y, bbox_w, bbox_h = _gather_bbox_points(doc)
inner_signal_ids = _used_inner_signal_layers(doc)
layers = list(_DEFAULT_STD_LAYERS)
for i, std_id in enumerate(sorted(signal_inner_map.values())):
for i, eid in enumerate(inner_signal_ids):
std_id = 21 + i
layers.append(f"{std_id}~Inner{i+1}~#999966~true~false~true~0~Signal")
# editVersion is on the EPRO2 head dict we filled in replay.py.
epro2_editor = (doc.head or {}).get("editVersion", "")
title = (doc.objects.get("META") or {}).get("title") or doc.doc_uuid[:12]
result = {
"uuid": doc.doc_uuid,
"puuid": "", # filled in by caller if known
"puuid": "",
"title": title,
"description": "",
"docType": 3,
"components": components_dict,
"components": {},
"dataStr": {
"head": {
"docType": "3",
"editorVersion": "facere-epro2/0.1",
"newgId": True,
"c_para": [],
"x": canvas_x,
"y": canvas_y,
"hasIdFlag": True,
"importFlag": 0,
"transformList": "",
"editorVersion": f"facere-epro2/0.1 (epro2 {epro2_editor})",
"units": "mil",
"epro2_doc_uuid": doc.doc_uuid,
"epro2_editor_version": epro2_editor,
},
"BBox": {
"x": bbox_x,
"y": bbox_y,
"width": bbox_w,
"height": bbox_h,
},
"canvas": canvas,
"shape": shape,
"layers": layers,
"objects": [],
"BBox": {"x": 0, "y": 0, "width": 0, "height": 0},
"objects": dict(doc.objects),
# Empty stubs the downstream pipeline checks for presence:
"preference": {},
"DRCRULE": {},
"netColors": [],
"DRCRULE": {},
},
}
stats = WriteStats(
objects=len(doc.objects),
bbox_x=bbox_x, bbox_y=bbox_y,
bbox_w=bbox_w, bbox_h=bbox_h,
layers_emitted=len(layers),
)
write_pcb_std.last_stats = stats # type: ignore[attr-defined]
return {"success": True, "code": 0, "result": result}