traceforge-pcb 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- traceforge/__init__.py +3 -0
- traceforge/__main__.py +3 -0
- traceforge/acp_server.py +570 -0
- traceforge/agent.py +680 -0
- traceforge/altium/__init__.py +6 -0
- traceforge/altium/cfb.py +70 -0
- traceforge/altium/cli.py +60 -0
- traceforge/altium/model.py +163 -0
- traceforge/altium/outputs.py +24 -0
- traceforge/altium/pcb.py +301 -0
- traceforge/altium/project.py +328 -0
- traceforge/altium/schematic.py +400 -0
- traceforge/altium/tools.py +166 -0
- traceforge/approval.py +219 -0
- traceforge/attachments.py +346 -0
- traceforge/background.py +253 -0
- traceforge/bench/__init__.py +5 -0
- traceforge/bench/adapters.py +337 -0
- traceforge/bench/checks.py +410 -0
- traceforge/bench/cli.py +121 -0
- traceforge/bench/design.py +439 -0
- traceforge/bench/kicad.py +99 -0
- traceforge/bench/partscheck.py +159 -0
- traceforge/bench/review.py +100 -0
- traceforge/bench/runner.py +226 -0
- traceforge/bench/score.py +204 -0
- traceforge/bench/tasks.py +102 -0
- traceforge/chat.py +1185 -0
- traceforge/checks.py +328 -0
- traceforge/checks_sides.py +118 -0
- traceforge/cli.py +2224 -0
- traceforge/clipboard.py +107 -0
- traceforge/config.py +299 -0
- traceforge/context.py +276 -0
- traceforge/design/__init__.py +1 -0
- traceforge/design/constraints.py +397 -0
- traceforge/design/generate.py +1789 -0
- traceforge/design/outline.py +905 -0
- traceforge/design/schematic_layout.py +932 -0
- traceforge/design/schematic_lint.py +329 -0
- traceforge/design/schematic_route.py +207 -0
- traceforge/design/sheets.py +854 -0
- traceforge/design/sides.py +328 -0
- traceforge/design/spec.py +462 -0
- traceforge/design/specsheet.py +2351 -0
- traceforge/design/specsheet_render.py +183 -0
- traceforge/desktop/__init__.py +109 -0
- traceforge/desktop/keys.py +90 -0
- traceforge/desktop/project.py +443 -0
- traceforge/desktop/review.py +20 -0
- traceforge/desktop/server.py +1277 -0
- traceforge/desktop/static/app.css +223 -0
- traceforge/desktop/static/app.js +683 -0
- traceforge/desktop/static/highlight.js +77 -0
- traceforge/desktop/static/index.html +89 -0
- traceforge/desktop/static/md.js +90 -0
- traceforge/desktop/static/viewer.js +213 -0
- traceforge/desktop/symbols.py +212 -0
- traceforge/desktop/update.py +65 -0
- traceforge/diagnostics.py +67 -0
- traceforge/errata.py +219 -0
- traceforge/fab.py +108 -0
- traceforge/fab_layers.py +44 -0
- traceforge/features/__init__.py +6 -0
- traceforge/features/candidates.py +117 -0
- traceforge/features/cli.py +200 -0
- traceforge/features/package.py +433 -0
- traceforge/features/revdiff.py +224 -0
- traceforge/features/sarif.py +145 -0
- traceforge/features/viewer.py +265 -0
- traceforge/firmware/__init__.py +3 -0
- traceforge/firmware/cli.py +174 -0
- traceforge/firmware/drivers.py +372 -0
- traceforge/firmware/drivers_data.py +256 -0
- traceforge/firmware/mcus.py +177 -0
- traceforge/firmware/netlist.py +55 -0
- traceforge/firmware/pinmap.py +296 -0
- traceforge/firmware/scaffold.py +466 -0
- traceforge/firmware/toolchain.py +262 -0
- traceforge/hooks.py +237 -0
- traceforge/kicad/__init__.py +0 -0
- traceforge/kicad/cli.py +519 -0
- traceforge/kicad/diffpair.py +174 -0
- traceforge/kicad/edgegeom.py +191 -0
- traceforge/kicad/edit.py +405 -0
- traceforge/kicad/escape.py +1410 -0
- traceforge/kicad/formats.py +1073 -0
- traceforge/kicad/impedance.py +320 -0
- traceforge/kicad/install.py +75 -0
- traceforge/kicad/libraries.py +490 -0
- traceforge/kicad/models3d.py +942 -0
- traceforge/kicad/outputs.py +81 -0
- traceforge/kicad/pcb.py +787 -0
- traceforge/kicad/planes.py +1242 -0
- traceforge/kicad/project.py +136 -0
- traceforge/kicad/route.py +2698 -0
- traceforge/kicad/routegeom.py +164 -0
- traceforge/kicad/schematic.py +268 -0
- traceforge/kicad/sides.py +34 -0
- traceforge/kicad/stackup.py +424 -0
- traceforge/kicad_plugin/__init__.py +12 -0
- traceforge/kicad_plugin/actions.py +92 -0
- traceforge/kicad_plugin/cli.py +66 -0
- traceforge/kicad_plugin/client.py +86 -0
- traceforge/kicad_plugin/context.py +257 -0
- traceforge/kicad_plugin/discovery.py +81 -0
- traceforge/kicad_plugin/install.py +203 -0
- traceforge/kicad_plugin/overlay.py +60 -0
- traceforge/kicad_plugin/payload/action_plugin.py +65 -0
- traceforge/kicad_plugin/payload/ipc_main.py +42 -0
- traceforge/kicad_plugin/payload/plugin.json +90 -0
- traceforge/kicad_plugin/payload/requirements.txt +1 -0
- traceforge/kicad_plugin/views.py +202 -0
- traceforge/langchain.py +57 -0
- traceforge/local.py +580 -0
- traceforge/mcp_server.py +304 -0
- traceforge/models.py +310 -0
- traceforge/next_steps.py +110 -0
- traceforge/parts/__init__.py +52 -0
- traceforge/parts/alternates.py +488 -0
- traceforge/parts/bom.py +423 -0
- traceforge/parts/brief.py +705 -0
- traceforge/parts/catalog.py +297 -0
- traceforge/parts/cli_sourcing.py +156 -0
- traceforge/parts/data/duty_rates.yaml +37 -0
- traceforge/parts/data/hs_codes.yaml +103 -0
- traceforge/parts/datasheet.py +278 -0
- traceforge/parts/fx.py +100 -0
- traceforge/parts/model.py +185 -0
- traceforge/parts/net.py +187 -0
- traceforge/parts/offers.py +46 -0
- traceforge/parts/packages.py +397 -0
- traceforge/parts/pinout.py +714 -0
- traceforge/parts/providers/__init__.py +17 -0
- traceforge/parts/providers/base.py +100 -0
- traceforge/parts/providers/jlc.py +364 -0
- traceforge/parts/providers/keyed.py +661 -0
- traceforge/parts/report.py +508 -0
- traceforge/parts/supply.py +330 -0
- traceforge/parts/verify.py +455 -0
- traceforge/parts/web.py +575 -0
- traceforge/plans.py +70 -0
- traceforge/preferences.py +127 -0
- traceforge/prices.py +130 -0
- traceforge/prices.yaml +28 -0
- traceforge/prompts/__init__.py +54 -0
- traceforge/prompts/system.md +103 -0
- traceforge/providers.py +764 -0
- traceforge/remote/__init__.py +33 -0
- traceforge/remote/client.py +422 -0
- traceforge/remote/oidc.py +240 -0
- traceforge/remote/orgs.py +510 -0
- traceforge/remote/server.py +690 -0
- traceforge/remote/store.py +100 -0
- traceforge/review_changes.py +187 -0
- traceforge/routing.py +99 -0
- traceforge/runtime.py +435 -0
- traceforge/sessions.py +324 -0
- traceforge/sexpr.py +279 -0
- traceforge/sim/__init__.py +17 -0
- traceforge/sim/acmath.py +234 -0
- traceforge/sim/circuit.py +499 -0
- traceforge/sim/cli.py +162 -0
- traceforge/sim/engine.py +172 -0
- traceforge/sim/ltspice.py +175 -0
- traceforge/sim/measure.py +123 -0
- traceforge/sim/models.py +76 -0
- traceforge/sim/project.py +218 -0
- traceforge/sim/rawfile.py +123 -0
- traceforge/sim/results.py +298 -0
- traceforge/sim/tune.py +172 -0
- traceforge/skill_commands.py +165 -0
- traceforge/skill_library/circuit-simulation/SKILL.md +30 -0
- traceforge/skill_library/compliance-review/SKILL.md +51 -0
- traceforge/skill_library/design-review/SKILL.md +47 -0
- traceforge/skill_library/errata-hunt/SKILL.md +30 -0
- traceforge/skill_library/firmware/SKILL.md +26 -0
- traceforge/skill_library/power-budget/SKILL.md +27 -0
- traceforge/skill_library/release-package/SKILL.md +29 -0
- traceforge/skill_library/sourcing-brief/SKILL.md +24 -0
- traceforge/skill_library/thermal-review/SKILL.md +15 -0
- traceforge/skills.py +131 -0
- traceforge/thermal.py +557 -0
- traceforge/tools/__init__.py +48 -0
- traceforge/tools/agent_tools.py +213 -0
- traceforge/tools/base.py +303 -0
- traceforge/tools/batch.py +155 -0
- traceforge/tools/connections.py +245 -0
- traceforge/tools/files.py +777 -0
- traceforge/tools/firmware_tools.py +218 -0
- traceforge/tools/kicad_tools.py +1072 -0
- traceforge/tools/models3d_tools.py +141 -0
- traceforge/tools/parts_tools.py +473 -0
- traceforge/tools/processes.py +246 -0
- traceforge/tools/review_tools.py +178 -0
- traceforge/tools/sim_tools.py +230 -0
- traceforge/tui.py +620 -0
- traceforge/updates.py +124 -0
- traceforge/validate/__init__.py +77 -0
- traceforge/validate/builtins.py +1091 -0
- traceforge/validate/circuits.py +1240 -0
- traceforge/validate/config.py +410 -0
- traceforge/validate/default.yaml +113 -0
- traceforge/validate/design.py +191 -0
- traceforge/validate/explain.py +112 -0
- traceforge/validate/grounding.py +169 -0
- traceforge/validate/ipc2221.py +350 -0
- traceforge/validate/multilayer.py +805 -0
- traceforge/validate/netmodel.py +261 -0
- traceforge/validate/partcheck.py +472 -0
- traceforge/validate/report.py +280 -0
- traceforge/validate/review.py +322 -0
- traceforge/validate/runner.py +662 -0
- traceforge/workspace.py +171 -0
- traceforge_pcb-1.0.0.dist-info/METADATA +463 -0
- traceforge_pcb-1.0.0.dist-info/RECORD +219 -0
- traceforge_pcb-1.0.0.dist-info/WHEEL +4 -0
- traceforge_pcb-1.0.0.dist-info/entry_points.txt +2 -0
- traceforge_pcb-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
"""Release package: fabrication files, BOM, placement, a viewer and a measured manufacturability report.
|
|
2
|
+
|
|
3
|
+
``measure`` reads the smallest real feature sizes off the board (track width, via and drill sizes,
|
|
4
|
+
annular rings, copper to edge, track to track clearance, silkscreen text) and ``dfm_rows`` sets them
|
|
5
|
+
against a fab profile with the margin left, so "DRC is clean" becomes "this board is inside the fab's
|
|
6
|
+
published limits by this much". It is an offline check of the numbers a fab's own portal tests; the
|
|
7
|
+
fab's review still has the last word. ``build_package`` writes the bundle with a SHA-256 manifest.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import csv
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import shutil
|
|
16
|
+
import zipfile
|
|
17
|
+
from collections import defaultdict
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from traceforge.fab import FabProfile, get_fab
|
|
22
|
+
from traceforge.kicad.cli import KicadCli, KicadCliError
|
|
23
|
+
from traceforge.kicad.edgegeom import _seg_seg_dist
|
|
24
|
+
from traceforge.kicad.outputs import write_jlc_cpl
|
|
25
|
+
from traceforge.kicad.pcb import Board, read_board
|
|
26
|
+
|
|
27
|
+
CELL = 2.0 # mm, spatial hash for the clearance scan
|
|
28
|
+
WARN_MARGIN = 0.05 # mm: closer than this to a limit is a warning, not a pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _ref_key(ref: str) -> tuple[str, int]:
|
|
32
|
+
prefix = ref.rstrip("0123456789")
|
|
33
|
+
digits = ref[len(prefix) :]
|
|
34
|
+
return (prefix, int(digits) if digits.isdigit() else 0)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _track_clearance(board: Board) -> tuple[float | None, tuple[str, str] | None]:
|
|
38
|
+
"""Smallest copper gap between tracks of different nets on the same layer, mm."""
|
|
39
|
+
cells: dict[tuple[str, int, int], list[int]] = defaultdict(list)
|
|
40
|
+
tracks = board.tracks
|
|
41
|
+
for i, t in enumerate(tracks):
|
|
42
|
+
reach = t.width / 2
|
|
43
|
+
x0, x1 = sorted((t.x1, t.x2))
|
|
44
|
+
y0, y1 = sorted((t.y1, t.y2))
|
|
45
|
+
for cx in range(int((x0 - reach) // CELL), int((x1 + reach) // CELL) + 1):
|
|
46
|
+
for cy in range(int((y0 - reach) // CELL), int((y1 + reach) // CELL) + 1):
|
|
47
|
+
cells[(t.layer, cx, cy)].append(i)
|
|
48
|
+
best: float | None = None
|
|
49
|
+
where: tuple[str, str] | None = None
|
|
50
|
+
seen: set[tuple[int, int]] = set()
|
|
51
|
+
for members in cells.values():
|
|
52
|
+
for ai in range(len(members)):
|
|
53
|
+
for bi in range(ai + 1, len(members)):
|
|
54
|
+
i, j = members[ai], members[bi]
|
|
55
|
+
pair = (i, j) if i < j else (j, i)
|
|
56
|
+
if pair in seen:
|
|
57
|
+
continue
|
|
58
|
+
seen.add(pair)
|
|
59
|
+
a, b = tracks[i], tracks[j]
|
|
60
|
+
if a.net == b.net:
|
|
61
|
+
continue
|
|
62
|
+
gap = (
|
|
63
|
+
_seg_seg_dist((a.x1, a.y1, a.x2, a.y2), (b.x1, b.y1, b.x2, b.y2))
|
|
64
|
+
- (a.width + b.width) / 2
|
|
65
|
+
)
|
|
66
|
+
if best is None or gap < best:
|
|
67
|
+
best, where = gap, (a.net, b.net)
|
|
68
|
+
return best, where
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def measure(board: Board) -> dict[str, Any]:
|
|
72
|
+
"""The smallest feature sizes on the board (None where the board has none of that feature)."""
|
|
73
|
+
widths = [t.width for t in board.tracks]
|
|
74
|
+
via_dia = [v.size for v in board.vias]
|
|
75
|
+
drills = [v.drill for v in board.vias]
|
|
76
|
+
rings = [(v.size - v.drill) / 2 for v in board.vias if v.drill > 0]
|
|
77
|
+
for fp in board.footprints:
|
|
78
|
+
for pad in fp.pads:
|
|
79
|
+
if pad.drill > 0 and pad.kind == "thru_hole":
|
|
80
|
+
drills.append(pad.drill)
|
|
81
|
+
rings.append((min(pad.width, pad.height) - pad.drill) / 2)
|
|
82
|
+
elif pad.drill > 0:
|
|
83
|
+
drills.append(pad.drill)
|
|
84
|
+
edge = None
|
|
85
|
+
geom = board.edge_geometry
|
|
86
|
+
if geom is not None:
|
|
87
|
+
gaps = []
|
|
88
|
+
for t in board.tracks:
|
|
89
|
+
for x, y in ((t.x1, t.y1), (t.x2, t.y2)):
|
|
90
|
+
gaps.append(geom.distance(x, y) - t.width / 2)
|
|
91
|
+
for v in board.vias:
|
|
92
|
+
gaps.append(geom.distance(v.x, v.y) - v.size / 2)
|
|
93
|
+
edge = min(gaps) if gaps else None
|
|
94
|
+
clearance, clear_nets = _track_clearance(board)
|
|
95
|
+
silk = [h for layer, _text, h in board.texts if "Silk" in layer and h > 0]
|
|
96
|
+
for fp in board.footprints:
|
|
97
|
+
silk += [h for layer, _t, h in fp.texts if "Silk" in layer and h > 0]
|
|
98
|
+
top = sum(1 for f in board.footprints if f.layer == "F.Cu" and not f.reference.startswith("#"))
|
|
99
|
+
bottom = sum(
|
|
100
|
+
1 for f in board.footprints if f.layer == "B.Cu" and not f.reference.startswith("#")
|
|
101
|
+
)
|
|
102
|
+
fiducials = [
|
|
103
|
+
f.reference
|
|
104
|
+
for f in board.footprints
|
|
105
|
+
if "fiducial" in f.lib_id.lower() or f.reference.upper().startswith("FID")
|
|
106
|
+
]
|
|
107
|
+
parts = [f for f in board.footprints if not f.reference.startswith("#")]
|
|
108
|
+
no_lcsc = sorted(
|
|
109
|
+
(
|
|
110
|
+
f.reference
|
|
111
|
+
for f in parts
|
|
112
|
+
if not (f.fields.get("LCSC") or f.fields.get("LCSC Part") or f.fields.get("lcsc"))
|
|
113
|
+
and not ({"exclude_from_bom", "board_only"} & set(f.attrs))
|
|
114
|
+
),
|
|
115
|
+
key=_ref_key,
|
|
116
|
+
)
|
|
117
|
+
smd = sum(1 for f in parts for p in f.pads if p.kind == "smd")
|
|
118
|
+
return {
|
|
119
|
+
"copper_layers": len(board.copper_layers),
|
|
120
|
+
"thickness_mm": board.thickness,
|
|
121
|
+
"size_mm": [round(v, 2) for v in board.size_mm] if board.size_mm else None,
|
|
122
|
+
"min_track_width": min(widths) if widths else None,
|
|
123
|
+
"min_via_diameter": min(via_dia) if via_dia else None,
|
|
124
|
+
"min_drill": min(drills) if drills else None,
|
|
125
|
+
"min_annular_ring": min(rings) if rings else None,
|
|
126
|
+
"min_copper_to_edge": edge,
|
|
127
|
+
"min_track_clearance": clearance,
|
|
128
|
+
"min_track_clearance_nets": list(clear_nets) if clear_nets else None,
|
|
129
|
+
"min_silk_text_height": min(silk) if silk else None,
|
|
130
|
+
"parts_top": top,
|
|
131
|
+
"parts_bottom": bottom,
|
|
132
|
+
"fiducials": fiducials,
|
|
133
|
+
"parts_without_lcsc": no_lcsc,
|
|
134
|
+
"smd_pads": smd,
|
|
135
|
+
"dnp": sorted((f.reference for f in parts if f.dnp), key=_ref_key),
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _limit_row(
|
|
140
|
+
rid: str,
|
|
141
|
+
label: str,
|
|
142
|
+
measured: float | None,
|
|
143
|
+
limit: float,
|
|
144
|
+
note: str = "",
|
|
145
|
+
lower_is_bad: bool = True,
|
|
146
|
+
) -> dict[str, Any]:
|
|
147
|
+
if measured is None:
|
|
148
|
+
return {
|
|
149
|
+
"id": rid,
|
|
150
|
+
"label": label,
|
|
151
|
+
"measured": None,
|
|
152
|
+
"limit": limit,
|
|
153
|
+
"unit": "mm",
|
|
154
|
+
"status": "n/a",
|
|
155
|
+
"margin": None,
|
|
156
|
+
"note": "no such feature on this board",
|
|
157
|
+
}
|
|
158
|
+
margin = measured - limit if lower_is_bad else limit - measured
|
|
159
|
+
status = "fail" if margin < -1e-6 else ("warn" if margin < WARN_MARGIN else "pass")
|
|
160
|
+
return {
|
|
161
|
+
"id": rid,
|
|
162
|
+
"label": label,
|
|
163
|
+
"measured": round(measured, 4),
|
|
164
|
+
"limit": limit,
|
|
165
|
+
"unit": "mm",
|
|
166
|
+
"status": status,
|
|
167
|
+
"margin": round(margin, 4),
|
|
168
|
+
"note": note,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def dfm_rows(m: dict[str, Any], fab: FabProfile) -> list[dict[str, Any]]:
|
|
173
|
+
"""The measurements set against ``fab``: one row each with pass, warn (close) or fail."""
|
|
174
|
+
rows = [
|
|
175
|
+
_limit_row("dfm.track", "narrowest track", m["min_track_width"], fab.min_track),
|
|
176
|
+
_limit_row(
|
|
177
|
+
"dfm.clearance",
|
|
178
|
+
"track to track clearance (different nets)",
|
|
179
|
+
m["min_track_clearance"],
|
|
180
|
+
fab.min_clearance,
|
|
181
|
+
note=(
|
|
182
|
+
"nets " + " / ".join(m["min_track_clearance_nets"])
|
|
183
|
+
if m["min_track_clearance_nets"]
|
|
184
|
+
else "pads and zones are checked by KiCad DRC"
|
|
185
|
+
),
|
|
186
|
+
),
|
|
187
|
+
_limit_row("dfm.via", "smallest via diameter", m["min_via_diameter"], fab.min_via_diameter),
|
|
188
|
+
_limit_row("dfm.drill", "smallest drill", m["min_drill"], fab.min_drill),
|
|
189
|
+
_limit_row(
|
|
190
|
+
"dfm.ring", "smallest annular ring", m["min_annular_ring"], fab.min_annular_ring
|
|
191
|
+
),
|
|
192
|
+
_limit_row(
|
|
193
|
+
"dfm.edge",
|
|
194
|
+
"copper to board edge",
|
|
195
|
+
m["min_copper_to_edge"],
|
|
196
|
+
fab.min_copper_edge,
|
|
197
|
+
),
|
|
198
|
+
_limit_row(
|
|
199
|
+
"dfm.silk",
|
|
200
|
+
"smallest silkscreen text",
|
|
201
|
+
m["min_silk_text_height"],
|
|
202
|
+
fab.min_silk_text_height,
|
|
203
|
+
),
|
|
204
|
+
]
|
|
205
|
+
size = m["size_mm"]
|
|
206
|
+
if size:
|
|
207
|
+
big = max(fab.max_board_mm)
|
|
208
|
+
small = min(fab.max_board_mm)
|
|
209
|
+
over = max(size) > big or min(size) > small
|
|
210
|
+
rows.append(
|
|
211
|
+
{
|
|
212
|
+
"id": "dfm.size",
|
|
213
|
+
"label": "board size",
|
|
214
|
+
"measured": size,
|
|
215
|
+
"limit": list(fab.max_board_mm),
|
|
216
|
+
"unit": "mm",
|
|
217
|
+
"status": "fail" if over else "pass",
|
|
218
|
+
"margin": None,
|
|
219
|
+
"note": "",
|
|
220
|
+
}
|
|
221
|
+
)
|
|
222
|
+
top, bottom = m["parts_top"], m["parts_bottom"]
|
|
223
|
+
if m["smd_pads"]:
|
|
224
|
+
have = len(m["fiducials"])
|
|
225
|
+
rows.append(
|
|
226
|
+
{
|
|
227
|
+
"id": "asm.fiducials",
|
|
228
|
+
"label": "fiducials for pick and place",
|
|
229
|
+
"measured": have,
|
|
230
|
+
"limit": 2,
|
|
231
|
+
"unit": "count",
|
|
232
|
+
"status": "pass" if have >= 2 else "warn",
|
|
233
|
+
"margin": None,
|
|
234
|
+
"note": "" if have >= 2 else "assembly houses align on 2 or more fiducials",
|
|
235
|
+
}
|
|
236
|
+
)
|
|
237
|
+
rows.append(
|
|
238
|
+
{
|
|
239
|
+
"id": "asm.sides",
|
|
240
|
+
"label": "assembly sides",
|
|
241
|
+
"measured": {"top": top, "bottom": bottom},
|
|
242
|
+
"limit": None,
|
|
243
|
+
"unit": "parts",
|
|
244
|
+
"status": "warn" if top and bottom else "pass",
|
|
245
|
+
"margin": None,
|
|
246
|
+
"note": "parts on both sides: assembly is usually quoted per side"
|
|
247
|
+
if top and bottom
|
|
248
|
+
else "",
|
|
249
|
+
}
|
|
250
|
+
)
|
|
251
|
+
miss = m["parts_without_lcsc"]
|
|
252
|
+
rows.append(
|
|
253
|
+
{
|
|
254
|
+
"id": "asm.lcsc",
|
|
255
|
+
"label": "parts with a supplier part number",
|
|
256
|
+
"measured": len(miss),
|
|
257
|
+
"limit": 0,
|
|
258
|
+
"unit": "missing",
|
|
259
|
+
"status": "pass" if not miss else "warn",
|
|
260
|
+
"margin": None,
|
|
261
|
+
"note": ("missing: " + ", ".join(miss[:12]) + (" ..." if len(miss) > 12 else ""))
|
|
262
|
+
if miss
|
|
263
|
+
else "",
|
|
264
|
+
}
|
|
265
|
+
)
|
|
266
|
+
return rows
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _bom_rows(board: Board) -> list[dict[str, str]]:
|
|
270
|
+
groups: dict[tuple[str, str, str], list[str]] = defaultdict(list)
|
|
271
|
+
for fp in board.footprints:
|
|
272
|
+
if (
|
|
273
|
+
fp.reference.startswith("#")
|
|
274
|
+
or ({"exclude_from_bom", "board_only"} & set(fp.attrs))
|
|
275
|
+
or fp.dnp
|
|
276
|
+
):
|
|
277
|
+
continue
|
|
278
|
+
lcsc = fp.fields.get("LCSC") or fp.fields.get("LCSC Part") or fp.fields.get("lcsc") or ""
|
|
279
|
+
groups[(fp.value, fp.lib_id.split(":")[-1], lcsc)].append(fp.reference)
|
|
280
|
+
rows = []
|
|
281
|
+
for (value, footprint, lcsc), refs in sorted(
|
|
282
|
+
groups.items(), key=lambda kv: _ref_key(min(kv[1], key=_ref_key))
|
|
283
|
+
):
|
|
284
|
+
refs.sort(key=_ref_key)
|
|
285
|
+
rows.append(
|
|
286
|
+
{
|
|
287
|
+
"Comment": value,
|
|
288
|
+
"Designator": ",".join(refs),
|
|
289
|
+
"Footprint": footprint,
|
|
290
|
+
"LCSC Part #": lcsc,
|
|
291
|
+
"Quantity": str(len(refs)),
|
|
292
|
+
}
|
|
293
|
+
)
|
|
294
|
+
return rows
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _write_bom(board: Board, out: Path) -> Path:
|
|
298
|
+
rows = _bom_rows(board)
|
|
299
|
+
with out.open("w", newline="", encoding="utf-8") as handle:
|
|
300
|
+
writer = csv.DictWriter(
|
|
301
|
+
handle, ["Comment", "Designator", "Footprint", "LCSC Part #", "Quantity"]
|
|
302
|
+
)
|
|
303
|
+
writer.writeheader()
|
|
304
|
+
writer.writerows(rows)
|
|
305
|
+
return out
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def dfm_markdown(name: str, fab: FabProfile, rows: list[dict[str, Any]]) -> str:
|
|
309
|
+
icon = {"pass": "pass", "warn": "WARN", "fail": "FAIL", "n/a": "-"}
|
|
310
|
+
lines = [
|
|
311
|
+
f"# Manufacturability: {name} against {fab.name}",
|
|
312
|
+
"",
|
|
313
|
+
f"Profile: {fab.notes or fab.name}. Numbers are read off the board file; the fab's own review decides.",
|
|
314
|
+
"",
|
|
315
|
+
"| check | measured | limit | margin | result | note |",
|
|
316
|
+
"|---|---|---|---|---|---|",
|
|
317
|
+
]
|
|
318
|
+
for r in rows:
|
|
319
|
+
meas = r["measured"] if r["measured"] is not None else "-"
|
|
320
|
+
limit = r["limit"] if r["limit"] is not None else "-"
|
|
321
|
+
margin = "" if r["margin"] is None else f"{r['margin']:+.3f}"
|
|
322
|
+
lines.append(
|
|
323
|
+
f"| {r['label']} | {meas} | {limit} | {margin} | {icon[r['status']]} | {r['note']} |"
|
|
324
|
+
)
|
|
325
|
+
fails = [r for r in rows if r["status"] == "fail"]
|
|
326
|
+
warns = [r for r in rows if r["status"] == "warn"]
|
|
327
|
+
lines += [
|
|
328
|
+
"",
|
|
329
|
+
f"{len(fails)} fail, {len(warns)} warn, {sum(r['status'] == 'pass' for r in rows)} pass.",
|
|
330
|
+
"",
|
|
331
|
+
]
|
|
332
|
+
return "\n".join(lines)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _sha(path: Path) -> str:
|
|
336
|
+
digest = hashlib.sha256()
|
|
337
|
+
with path.open("rb") as handle:
|
|
338
|
+
for block in iter(lambda: handle.read(1 << 20), b""):
|
|
339
|
+
digest.update(block)
|
|
340
|
+
return digest.hexdigest()
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
_README = """{name} - fabrication package
|
|
344
|
+
|
|
345
|
+
Made by traceforge {version} from {pcb} for {fab}.
|
|
346
|
+
|
|
347
|
+
gerbers/ copper, mask, silk, edge and drill files (KiCad export)
|
|
348
|
+
{name}-bom.csv bill of materials in the column layout the assembly service takes
|
|
349
|
+
{name}-cpl.csv component placement list (centre, side, rotation)
|
|
350
|
+
dfm_report.md measured feature sizes against {fab}'s limits (also dfm_report.json)
|
|
351
|
+
viewer.html open in a browser: layers, nets, BOM, no KiCad needed
|
|
352
|
+
MANIFEST.sha256 SHA-256 of every file here; check with: sha256sum -c MANIFEST.sha256
|
|
353
|
+
|
|
354
|
+
Before ordering: read dfm_report.md, confirm the stackup and finish on the fab's order page, and let the
|
|
355
|
+
fab's own review run. Rotation and origin conventions of the placement list follow the fab's documentation
|
|
356
|
+
at the time of writing; check a first assembly order against its preview.
|
|
357
|
+
"""
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def build_package(
|
|
361
|
+
pcb: str | Path,
|
|
362
|
+
out_dir: str | Path,
|
|
363
|
+
fab: str = "jlcpcb",
|
|
364
|
+
name: str | None = None,
|
|
365
|
+
make_zip: bool = True,
|
|
366
|
+
gerbers: bool = True,
|
|
367
|
+
) -> dict[str, Any]:
|
|
368
|
+
"""Write the release package; returns the report (files, checks, warnings)."""
|
|
369
|
+
from traceforge import __version__
|
|
370
|
+
from traceforge.features.viewer import write_viewer
|
|
371
|
+
|
|
372
|
+
pcb = Path(pcb)
|
|
373
|
+
profile = get_fab(fab)
|
|
374
|
+
board = read_board(pcb)
|
|
375
|
+
name = name or pcb.stem
|
|
376
|
+
out = Path(out_dir)
|
|
377
|
+
if out.exists():
|
|
378
|
+
shutil.rmtree(out)
|
|
379
|
+
out.mkdir(parents=True)
|
|
380
|
+
notes: list[str] = []
|
|
381
|
+
files: list[Path] = []
|
|
382
|
+
|
|
383
|
+
if gerbers:
|
|
384
|
+
cli = KicadCli()
|
|
385
|
+
if cli.available:
|
|
386
|
+
try:
|
|
387
|
+
files += cli.gerbers(pcb, out / "gerbers")
|
|
388
|
+
except KicadCliError as exc:
|
|
389
|
+
notes.append(f"gerbers not written: {exc}")
|
|
390
|
+
else:
|
|
391
|
+
notes.append("gerbers not written: kicad-cli is not installed")
|
|
392
|
+
files.append(_write_bom(board, out / f"{name}-bom.csv"))
|
|
393
|
+
files.append(write_jlc_cpl(pcb, out / f"{name}-cpl.csv"))
|
|
394
|
+
|
|
395
|
+
measured = measure(board)
|
|
396
|
+
rows = dfm_rows(measured, profile)
|
|
397
|
+
(out / "dfm_report.json").write_text(
|
|
398
|
+
json.dumps({"fab": profile.name, "measured": measured, "checks": rows}, indent=1),
|
|
399
|
+
encoding="utf-8",
|
|
400
|
+
)
|
|
401
|
+
(out / "dfm_report.md").write_text(dfm_markdown(name, profile, rows), encoding="utf-8")
|
|
402
|
+
files += [out / "dfm_report.json", out / "dfm_report.md"]
|
|
403
|
+
files.append(write_viewer(pcb, out / "viewer.html"))
|
|
404
|
+
(out / "README.txt").write_text(
|
|
405
|
+
_README.format(name=name, version=__version__, pcb=pcb.name, fab=profile.name),
|
|
406
|
+
encoding="utf-8",
|
|
407
|
+
)
|
|
408
|
+
files.append(out / "README.txt")
|
|
409
|
+
|
|
410
|
+
manifest = out / "MANIFEST.sha256"
|
|
411
|
+
entries = sorted(p for p in out.rglob("*") if p.is_file() and p != manifest)
|
|
412
|
+
manifest.write_text(
|
|
413
|
+
"".join(f"{_sha(p)} {p.relative_to(out).as_posix()}\n" for p in entries), encoding="utf-8"
|
|
414
|
+
)
|
|
415
|
+
zip_path = None
|
|
416
|
+
if make_zip:
|
|
417
|
+
zip_path = out.parent / f"{name}-fab-package.zip"
|
|
418
|
+
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as archive:
|
|
419
|
+
for p in sorted(out.rglob("*")):
|
|
420
|
+
if p.is_file():
|
|
421
|
+
archive.write(p, f"{name}/{p.relative_to(out).as_posix()}")
|
|
422
|
+
fails = [r["id"] for r in rows if r["status"] == "fail"]
|
|
423
|
+
return {
|
|
424
|
+
"package_dir": str(out),
|
|
425
|
+
"zip": str(zip_path) if zip_path else None,
|
|
426
|
+
"files": len(entries) + 1,
|
|
427
|
+
"fab": profile.name,
|
|
428
|
+
"checks": rows,
|
|
429
|
+
"failed": fails,
|
|
430
|
+
"warned": [r["id"] for r in rows if r["status"] == "warn"],
|
|
431
|
+
"notes": notes,
|
|
432
|
+
"ready": not fails and not any("not written" in n for n in notes),
|
|
433
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Compare two revisions of a board: parts, connectivity, stackup, routing and size.
|
|
2
|
+
|
|
3
|
+
``diff_boards(old, new)`` returns plain data; ``to_markdown`` renders it for a pull request. Parts are
|
|
4
|
+
matched by reference designator. A moved part is reported only when it moved by more than
|
|
5
|
+
``move_tol`` mm or turned. Connectivity compares which nets each pad is on, so a swapped pin shows
|
|
6
|
+
up even when no part changed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from traceforge.kicad.pcb import Board, read_board
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _pad_nets(board: Board) -> dict[str, str]:
|
|
18
|
+
return {
|
|
19
|
+
f"{fp.reference}.{pad.number}": pad.net
|
|
20
|
+
for fp in board.footprints
|
|
21
|
+
if not fp.reference.startswith("#")
|
|
22
|
+
for pad in fp.pads
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _routing(board: Board) -> dict[str, Any]:
|
|
27
|
+
per_layer: dict[str, dict[str, float]] = {}
|
|
28
|
+
for t in board.tracks:
|
|
29
|
+
entry = per_layer.setdefault(t.layer, {"tracks": 0, "length_mm": 0.0})
|
|
30
|
+
entry["tracks"] += 1
|
|
31
|
+
entry["length_mm"] += t.length
|
|
32
|
+
widths = sorted({round(t.width, 3) for t in board.tracks})
|
|
33
|
+
vias = sorted({(round(v.size, 3), round(v.drill, 3)) for v in board.vias})
|
|
34
|
+
return {
|
|
35
|
+
"layers": {
|
|
36
|
+
k: {"tracks": v["tracks"], "length_mm": round(v["length_mm"], 2)}
|
|
37
|
+
for k, v in per_layer.items()
|
|
38
|
+
},
|
|
39
|
+
"track_widths_mm": widths,
|
|
40
|
+
"via_count": len(board.vias),
|
|
41
|
+
"via_sizes": [list(v) for v in vias],
|
|
42
|
+
"total_length_mm": round(sum(v["length_mm"] for v in per_layer.values()), 2),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def diff_boards(old: Board, new: Board, move_tol: float = 0.05) -> dict[str, Any]:
|
|
47
|
+
"""What changed from ``old`` to ``new``."""
|
|
48
|
+
a = {f.reference: f for f in old.footprints if not f.reference.startswith("#")}
|
|
49
|
+
b = {f.reference: f for f in new.footprints if not f.reference.startswith("#")}
|
|
50
|
+
added = sorted(set(b) - set(a))
|
|
51
|
+
removed = sorted(set(a) - set(b))
|
|
52
|
+
changed: list[dict[str, Any]] = []
|
|
53
|
+
for ref in sorted(set(a) & set(b)):
|
|
54
|
+
x, y = a[ref], b[ref]
|
|
55
|
+
what: dict[str, Any] = {}
|
|
56
|
+
if x.value != y.value:
|
|
57
|
+
what["value"] = [x.value, y.value]
|
|
58
|
+
if x.lib_id != y.lib_id:
|
|
59
|
+
what["footprint"] = [x.lib_id, y.lib_id]
|
|
60
|
+
if x.layer != y.layer:
|
|
61
|
+
what["side"] = [x.layer, y.layer]
|
|
62
|
+
if round((x.angle - y.angle) % 360, 3) not in (0.0, 360.0):
|
|
63
|
+
what["angle"] = [round(x.angle, 2), round(y.angle, 2)]
|
|
64
|
+
dist = ((x.x - y.x) ** 2 + (x.y - y.y) ** 2) ** 0.5
|
|
65
|
+
if dist > move_tol:
|
|
66
|
+
what["moved_mm"] = round(dist, 2)
|
|
67
|
+
if x.dnp != y.dnp:
|
|
68
|
+
what["dnp"] = [x.dnp, y.dnp]
|
|
69
|
+
if what:
|
|
70
|
+
changed.append({"ref": ref, **what})
|
|
71
|
+
pa, pb = _pad_nets(old), _pad_nets(new)
|
|
72
|
+
rewired = [
|
|
73
|
+
{"pad": pad, "from": pa[pad], "to": pb[pad]}
|
|
74
|
+
for pad in sorted(set(pa) & set(pb))
|
|
75
|
+
if pa[pad] != pb[pad]
|
|
76
|
+
]
|
|
77
|
+
nets_old = {n for n in pa.values() if n}
|
|
78
|
+
nets_new = {n for n in pb.values() if n}
|
|
79
|
+
ra, rb = _routing(old), _routing(new)
|
|
80
|
+
stack: dict[str, Any] = {}
|
|
81
|
+
if old.copper_layers != new.copper_layers:
|
|
82
|
+
stack["copper_layers"] = [old.copper_layers, new.copper_layers]
|
|
83
|
+
if round(old.thickness, 3) != round(new.thickness, 3):
|
|
84
|
+
stack["thickness_mm"] = [old.thickness, new.thickness]
|
|
85
|
+
sa = [round(v, 2) for v in old.size_mm] if old.size_mm else None
|
|
86
|
+
sb = [round(v, 2) for v in new.size_mm] if new.size_mm else None
|
|
87
|
+
zones_old = sorted((z["net"], tuple(z["layers"])) for z in old.zones)
|
|
88
|
+
zones_new = sorted((z["net"], tuple(z["layers"])) for z in new.zones)
|
|
89
|
+
result = {
|
|
90
|
+
"parts": {
|
|
91
|
+
"old": len(a),
|
|
92
|
+
"new": len(b),
|
|
93
|
+
"added": [{"ref": r, "value": b[r].value, "footprint": b[r].lib_id} for r in added],
|
|
94
|
+
"removed": [{"ref": r, "value": a[r].value, "footprint": a[r].lib_id} for r in removed],
|
|
95
|
+
"changed": changed,
|
|
96
|
+
},
|
|
97
|
+
"nets": {
|
|
98
|
+
"added": sorted(nets_new - nets_old),
|
|
99
|
+
"removed": sorted(nets_old - nets_new),
|
|
100
|
+
"rewired_pads": rewired,
|
|
101
|
+
},
|
|
102
|
+
"stackup": stack,
|
|
103
|
+
"board_size_mm": [sa, sb] if sa != sb else None,
|
|
104
|
+
"zones": {
|
|
105
|
+
"added": [list(z) for z in sorted(set(zones_new) - set(zones_old))],
|
|
106
|
+
"removed": [list(z) for z in sorted(set(zones_old) - set(zones_new))],
|
|
107
|
+
},
|
|
108
|
+
"routing": {"old": ra, "new": rb},
|
|
109
|
+
}
|
|
110
|
+
result["summary"] = summarize(result)
|
|
111
|
+
return result
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def summarize(d: dict[str, Any]) -> dict[str, Any]:
|
|
115
|
+
p, n = d["parts"], d["nets"]
|
|
116
|
+
r_old, r_new = d["routing"]["old"], d["routing"]["new"]
|
|
117
|
+
return {
|
|
118
|
+
"parts_added": len(p["added"]),
|
|
119
|
+
"parts_removed": len(p["removed"]),
|
|
120
|
+
"parts_changed": len(p["changed"]),
|
|
121
|
+
"nets_added": len(n["added"]),
|
|
122
|
+
"nets_removed": len(n["removed"]),
|
|
123
|
+
"pads_rewired": len(n["rewired_pads"]),
|
|
124
|
+
"vias_delta": r_new["via_count"] - r_old["via_count"],
|
|
125
|
+
"track_length_delta_mm": round(r_new["total_length_mm"] - r_old["total_length_mm"], 2),
|
|
126
|
+
"stackup_changed": bool(d["stackup"]),
|
|
127
|
+
"board_size_changed": d["board_size_mm"] is not None,
|
|
128
|
+
"identical": not (
|
|
129
|
+
p["added"]
|
|
130
|
+
or p["removed"]
|
|
131
|
+
or p["changed"]
|
|
132
|
+
or n["added"]
|
|
133
|
+
or n["removed"]
|
|
134
|
+
or n["rewired_pads"]
|
|
135
|
+
or d["stackup"]
|
|
136
|
+
or d["board_size_mm"]
|
|
137
|
+
or d["zones"]["added"]
|
|
138
|
+
or d["zones"]["removed"]
|
|
139
|
+
or r_old != r_new
|
|
140
|
+
),
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def diff_files(old: str | Path, new: str | Path, move_tol: float = 0.05) -> dict[str, Any]:
|
|
145
|
+
return diff_boards(read_board(Path(old)), read_board(Path(new)), move_tol)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _row(*cells: object) -> str:
|
|
149
|
+
return "| " + " | ".join(str(c) for c in cells) + " |"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def to_markdown(d: dict[str, Any], old_name: str = "old", new_name: str = "new") -> str:
|
|
153
|
+
s = d["summary"]
|
|
154
|
+
lines = [f"# Board changes: {old_name} to {new_name}", ""]
|
|
155
|
+
if s["identical"]:
|
|
156
|
+
return "\n".join([*lines, "No differences found.", ""])
|
|
157
|
+
lines += [
|
|
158
|
+
f"{s['parts_added']} parts added, {s['parts_removed']} removed, {s['parts_changed']} changed; "
|
|
159
|
+
f"{s['nets_added']} nets added, {s['nets_removed']} removed, {s['pads_rewired']} pads rewired; "
|
|
160
|
+
f"{s['vias_delta']:+d} vias, {s['track_length_delta_mm']:+.1f} mm of track.",
|
|
161
|
+
"",
|
|
162
|
+
]
|
|
163
|
+
p = d["parts"]
|
|
164
|
+
if p["added"] or p["removed"]:
|
|
165
|
+
lines += [
|
|
166
|
+
"## Parts added and removed",
|
|
167
|
+
"",
|
|
168
|
+
_row("", "ref", "value", "footprint"),
|
|
169
|
+
_row("---", "---", "---", "---"),
|
|
170
|
+
]
|
|
171
|
+
lines += [_row("added", x["ref"], x["value"], x["footprint"]) for x in p["added"]]
|
|
172
|
+
lines += [_row("removed", x["ref"], x["value"], x["footprint"]) for x in p["removed"]]
|
|
173
|
+
lines.append("")
|
|
174
|
+
if p["changed"]:
|
|
175
|
+
lines += ["## Parts changed", "", _row("ref", "what"), _row("---", "---")]
|
|
176
|
+
for c in p["changed"]:
|
|
177
|
+
what = ", ".join(
|
|
178
|
+
f"{k}: {v[0]} -> {v[1]}" if isinstance(v, list) else f"{k} {v} mm"
|
|
179
|
+
for k, v in c.items()
|
|
180
|
+
if k != "ref"
|
|
181
|
+
)
|
|
182
|
+
lines.append(_row(c["ref"], what))
|
|
183
|
+
lines.append("")
|
|
184
|
+
n = d["nets"]
|
|
185
|
+
if n["added"] or n["removed"] or n["rewired_pads"]:
|
|
186
|
+
lines += ["## Connectivity", ""]
|
|
187
|
+
if n["added"]:
|
|
188
|
+
lines.append("Nets added: " + ", ".join(n["added"]))
|
|
189
|
+
if n["removed"]:
|
|
190
|
+
lines.append("Nets removed: " + ", ".join(n["removed"]))
|
|
191
|
+
if n["rewired_pads"]:
|
|
192
|
+
lines += ["", _row("pad", "was", "now"), _row("---", "---", "---")]
|
|
193
|
+
lines += [_row(x["pad"], x["from"] or "-", x["to"] or "-") for x in n["rewired_pads"]]
|
|
194
|
+
lines.append("")
|
|
195
|
+
if d["stackup"] or d["board_size_mm"]:
|
|
196
|
+
lines += ["## Board", ""]
|
|
197
|
+
if d["stackup"].get("copper_layers"):
|
|
198
|
+
a, b = d["stackup"]["copper_layers"]
|
|
199
|
+
lines.append(f"Copper layers: {len(a)} -> {len(b)}")
|
|
200
|
+
if d["stackup"].get("thickness_mm"):
|
|
201
|
+
a, b = d["stackup"]["thickness_mm"]
|
|
202
|
+
lines.append(f"Thickness: {a} mm -> {b} mm")
|
|
203
|
+
if d["board_size_mm"]:
|
|
204
|
+
a, b = d["board_size_mm"]
|
|
205
|
+
lines.append(f"Size: {a[0]} x {a[1]} mm -> {b[0]} x {b[1]} mm")
|
|
206
|
+
lines.append("")
|
|
207
|
+
z = d["zones"]
|
|
208
|
+
if z["added"] or z["removed"]:
|
|
209
|
+
lines += ["## Zones", ""]
|
|
210
|
+
lines += [f"- added: {net} on {', '.join(layers)}" for net, layers in z["added"]]
|
|
211
|
+
lines += [f"- removed: {net} on {', '.join(layers)}" for net, layers in z["removed"]]
|
|
212
|
+
lines.append("")
|
|
213
|
+
ro, rn = d["routing"]["old"], d["routing"]["new"]
|
|
214
|
+
if ro != rn:
|
|
215
|
+
lines += ["## Routing", "", _row("", "old", "new"), _row("---", "---", "---")]
|
|
216
|
+
lines.append(_row("vias", ro["via_count"], rn["via_count"]))
|
|
217
|
+
lines.append(_row("track length (mm)", ro["total_length_mm"], rn["total_length_mm"]))
|
|
218
|
+
for layer in sorted(set(ro["layers"]) | set(rn["layers"])):
|
|
219
|
+
lo = ro["layers"].get(layer, {"tracks": 0, "length_mm": 0})
|
|
220
|
+
ln = rn["layers"].get(layer, {"tracks": 0, "length_mm": 0})
|
|
221
|
+
lines.append(_row(f"{layer} length (mm)", lo["length_mm"], ln["length_mm"]))
|
|
222
|
+
lines.append(_row("track widths (mm)", ro["track_widths_mm"], rn["track_widths_mm"]))
|
|
223
|
+
lines.append("")
|
|
224
|
+
return "\n".join(lines).rstrip() + "\n"
|