Skip to content

feat(router): synthesize copper pours for high-current nets#679

Merged
ecto merged 3 commits into
mainfrom
claude/loving-solomon-7300a6
Jul 25, 2026
Merged

feat(router): synthesize copper pours for high-current nets#679
ecto merged 3 commits into
mainfrom
claude/loving-solomon-7300a6

Conversation

@ecto

@ecto ecto commented Jul 25, 2026

Copy link
Copy Markdown
Owner

The gap

vcad's router synthesizes traces only. copper_pour::fill_zones can fill a zone that already exists, but nothing ever created a zone definition — so a board whose power nets carry tens of amps got hairline copper where a human designer floods a region.

The CM5 benchmark hid this: its fixture carries 121 hand-authored zones. The motor-controller fixtures expose it — moteus.brd is Eagle, whose copper polygons the importer drops entirely, so its power nets arrived with no pour at all.

What this adds

New crate module vcad_ecad_pcb::pour_synth, deliberately split so the judgement is inspectable separately from the drawing — per the brief's design note, so it can later be driven by explicit design intent rather than heuristics.

PolicyPourPolicy + decide_pours: which nets, which layers, and why (PourReason). No geometry, no board mutation, every knob a number you can argue with.

  • A declared current (net_current_a) is sized through the same IPC-2221 closed form size_trace_for_current evaluates — ipc2221_width_mm is unit-tested against that tool's own numbers to 1e-9, so "how wide for this current?" means the same thing to the router as to an agent asking the tool.
  • Fallbacks for imported boards, which carry no current annotation: a net class already sized far wider than default, then a power-rail name.
  • A net qualifies on pad count or pad copper — a battery input is two 8×11 mm terminals, not twenty small pads.
  • Layer choice minimises stitching: pour where the net already lives, and never flood a layer fewer than half its pads sit on. Every pad not on the poured layer costs a stitching via.

Geometrysynthesize_outlines: region floods (a margined box), promoted to a whole-layer flood once a net spans the board, clipped to the outline eroded by edge clearance. Erosion and dilation reuse the pour engine's own capsule primitive rather than adding a second offsetting path.

  • A whole-layer flood owns its layer — carving one board-wide flood out of another leaves confetti, not a plane.
  • Local pours may share a layer, claimed smallest-first, cut back by clearance, with the net's own pad discs given back (a neighbouring pour is voided there regardless).
  • One plane per candidate, and if any pad cannot reach it the candidate is dropped. The router treats a poured net as connected through its plane and drops its ratsnest, so a pour that missed a pad would silently strand it. Fail closed — that net keeps its traces.

Stitching, DRC and fill are all existing machinery: a synthesized zone is an ordinary Zone. Zones come back in RouteAllResult::zones, and every caller applies them (cm5_bench, the WASM binding, route_nets) — the routing assumes them, so dropping them would leave those nets connected to nothing.

cargo run -p vcad-ecad-pcb --example pour_report -- <board> shows the decisions and the resulting outlines without routing.

Three legality fixes the pours surfaced

Each stands on its own, independent of synthesis:

  1. Escape/stitch vias are hole-to-hole checked at placement, against the board's drilled holes and the vias the pass has already placed — instead of being committed and cleaned up afterwards.
  2. Post-route legalization demoted a whole net on one bad drill. Right for a routed path; catastrophic for a plane-stitched net, whose copper is dozens of independent pad→plane vias — on moteus a single hole conflict cost all 32 GND stitches and left the pour connected to nothing. A stitch via is now dropped on its own, with its dog-bone stub, and the pad it served is honestly reported as UnstitchedPad.
  3. fill_zones prunes orphan islands — fill fragments reaching none of their own net's copper. Floating metal carries no current and is a NetIslands error; every EDA tool drops it before plotting. The largest piece is always kept, so a zone poured before its net is routed still renders.

Plus: is_power_net folds away word separators, so V_SUPPLY reads as the rail it is.

Measured

VCAD_POUR_SYNTH=0 on cm5_bench is the A/B control. Re-measured after rebasing onto #676/#677/#678/#680/#681.

moteus (.scratch/moteus.brd) pours off pours on
routability 0.872 0.891
vias 256 (0.73x human) 140 (0.40x)
copper 1.19x human 0.78x
Clearance 100 76
Short 177 121
SameNetBypass 3 0
MinTraceWidth 12 11
NetIslands 0 0
UnstitchedPad 0 0
total violations 445 359

GND floods F.Cu as a single plane — 40 of its 45 pads flood directly, 3 stitching vias reach the rest. Fewer vias, less copper, better routability, fewer violations in every category.

VESC (.scratch/VESC_4.kicad_pcb): bit-identical with synthesis on and off (0.794, 223 violations). Its 13 hand-authored zones already cover ~80% of both copper layers, so the policy's one candidate (VCC on F.Cu) is correctly dropped by the geometry — what is left of the layer is not a plane every pad can reach.

CM5 (.scratch/CM5RevEng.kicad_pcb, top 150 nets): flat — routability 0.982 -> 0.981, 2912 -> 2927 violations (+0.5%, within this router's run-to-run variation; the delta is almost entirely MinTraceWidth 254 -> 270, which a zone cannot cause directly). Synthesis adds exactly one 49 mm2 zone, for a rail that owned none, and duplicates nothing.

The one number that goes up, honestly

On moteus the pour's own copper is provably legal: the Clearance count is identical with and without the zone (76 both), so it touches no foreign copper — and the DRC models a plane as never bonding to other-net copper. Against the same routed board with zones stripped, Short does rise 97 -> 121: that is the plane unifying GND's copper, so nets already shorted to GND get reported pairwise rather than individually. Against the real control the net effect is still strongly positive (177 -> 121).

Neither fixture is DRC-clean at import — VESC arrives with 72 shorts and 33 clearance violations before any routing — so absolute cleanliness was never on the table; the bar is no new violations attributable to the pours, which holds.

Notes

  • VESC_4.kicad_pcb imports as a 2-layer board: its inner layers are named GND/GND2, and parse_layer only knows In1.CuIn8.Cu, so both the layers and their zones are dropped. Left alone here — fixing it would change the fixture's routing problem entirely — but it is a real importer gap.
  • Default policy is on. Small point-to-point boards are untouched: min_pads: 6 and min_pad_area_mm2: 20.0 keep synthesis off nets that a trace already serves, and all 270 existing crate tests pass unchanged.

Verification

  • cargo test -p vcad-ecad-pcb — 282 passed (18 new, covering ampacity vs. the shared model, each policy signal, outline synthesis, layer arbitration, orphan pruning, and surgical stitch removal)
  • cargo clippy -p vcad-ecad-pcb --features gpu -- -D warnings — clean
  • cargo fmt --all --check — clean
  • npm test -w @vcad/mcp — 253 ecad tests pass; tsc --noEmit clean

🤖 Generated with Claude Code

@chojiai

chojiai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Choji has used up today's reviews

Your team has reached the free plan's limit of 5 reviews per day, so Choji stepped aside on this pull request. That's a plan limit, not a reflection of the change itself.

Your free reviews reset tomorrow. To have Choji review every push without waiting, upgrade to Pro for unlimited reviews.

You're on the free plan · 5 reviews/day.

@vercel

vercel Bot commented Jul 25, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
vcad-mcp Building Building Preview, Comment Jul 25, 2026 2:33pm
3 Skipped Deployments
Project Deployment Actions Updated (UTC)
mecheval Ignored Ignored Jul 25, 2026 2:33pm
vcad Ignored Ignored Jul 25, 2026 2:33pm
vcad-docs Ignored Ignored Jul 25, 2026 2:33pm

Request Review

The router synthesized traces only. copper_pour::fill_zones could fill a
zone that already existed, but nothing ever created a zone *definition* —
so a board whose power nets carry tens of amps got hairline copper where a
human designer floods a region. The CM5 fixture hid this behind 121
hand-authored zones; the moteus fixture (Eagle, whose polygons the importer
drops) exposes it.

New `vcad_ecad_pcb::pour_synth`, split so the judgement is inspectable
separately from the drawing:

* Policy (`PourPolicy` / `decide_pours`) — which nets, which layers, and
  why. A declared current is sized through the *same* IPC-2221 closed form
  the `size_trace_for_current` MCP tool evaluates; a wide net class and a
  power-rail name are the fallbacks for imported boards, which carry no
  current annotation. Pad count *or* pad copper qualifies a net, since a
  battery input is two big terminals rather than twenty small ones. A layer
  is chosen to minimise stitching: pour where the net already lives, and
  never flood a layer fewer than half its pads sit on.
* Geometry (`synthesize_outlines`) — region floods, promoted to a whole
  layer once a net spans the board, clipped to the outline eroded by edge
  clearance. A whole-layer flood owns its layer; local pours share one,
  smallest-claims-first. One plane per candidate, and if any pad cannot
  reach it the candidate is dropped and that net keeps its traces: the
  router treats a poured net as connected *through* its plane, so a pour
  that missed a pad would silently strand it.

Zones come back in `RouteAllResult::zones` and every caller (cm5_bench, the
WASM binding, `route_nets`) applies them — the routing assumes them.

Three legality fixes the pours surfaced, each of which stands on its own:

* Escape/stitch vias are now checked against the board's drilled holes when
  they are placed, instead of being committed and cleaned up afterwards.
* Post-route legalization demoted a whole net on one bad drill. That is
  right for a routed path and catastrophic for a plane-stitched net, whose
  copper is dozens of independent pad->plane vias: on moteus a single hole
  conflict cost all 32 GND stitches and left the pour connected to nothing.
  A stitch via is now dropped on its own, with its dog-bone stub.
* `fill_zones` now prunes orphan islands — fill fragments that reach none of
  their own net's copper. Floating metal carries no current and is a
  `NetIslands` error; the largest piece is always kept so a zone poured
  before its net is routed still renders.

`is_power_net` folds away word separators, so `V_SUPPLY` reads as the rail
it is.

Measured on this base (VCAD_POUR_SYNTH=0 is the A/B control):

  moteus  routability 0.872 -> 0.891, vias 256 -> 140 (0.73x -> 0.40x
          human), copper 1.19x -> 0.78x human, Clearance 100 -> 76, Short
          177 -> 121, SameNetBypass 3 -> 0, NetIslands 0 -> 0,
          UnstitchedPad 0 -> 0; 445 -> 359 violations overall. GND floods
          F.Cu as one plane, reached by 3 stitching vias.
  VESC    bit-identical with synthesis on and off (0.794, 223 violations).
          Its 13 hand-authored zones already cover ~80% of both layers, so
          the geometry correctly drops the one candidate the policy picks.
  CM5     (top 150 nets) flat: routability 0.982 -> 0.981, 2912 -> 2927
          violations. Synthesis adds one 49mm^2 zone for a rail that owned
          none and duplicates nothing.

On moteus the pour's own copper is provably legal: the Clearance count is
identical with and without the zone (76 both), so it touches no foreign
copper. It does raise Short against the same routed board with zones
stripped (97 -> 121) — that is the plane unifying GND's copper, so nets
already shorted to GND get reported pairwise. Against the real control the
net effect is still strongly positive (177 -> 121).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ecto and others added 2 commits July 25, 2026 10:16
phyz 0.3 deprecated `contact_forces_implicit`, which turned `cargo clippy
-- -D warnings` into a hard error and took main (and every open PR) red.

The deprecation points at the convex solve (`assemble` + `solve_contacts`),
which couples contacts through the Delassus operator instead of resolving
each in isolation. That is a different contact model, not a rename: adopting
it would move every drift number `fit_physics` measures and silently
re-grade the mecheval F-suite, whose task tolerances were baselined against
the penalty path.

So this pins the existing behaviour with `#[allow(deprecated)]` and records
why, rather than swapping solvers inside a CI fix. Migrating is worth doing
on its own, with the F-suite re-baselined against the new numbers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`a_wandering_loop_never_returns_a_worse_board_than_it_found` has been
failing on main since the fix loop learned to restore the best board it saw.

The test's contract is its name: however badly the loop wanders, the board
it hands back must never be worse than the best one it found. The assertion
encoded one particular way of honouring that — regress, then name the
regression — and required `connectivity.regression() > 0`. With the restore
in place the loop no longer regresses at all (0 on arrival, 0 on completion)
and fails closed with "the fix loop stopped improving ... the best board the
loop saw was restored". That satisfies the contract more strongly than the
assertion it trips.

So the assertion now allows either shape — no regression, or a regression
that is named — and still forbids the one thing that matters: clearing
violations by deleting copper and reporting it as progress. The added
`blocker.is_some()` check keeps a silent pass from slipping through the
weaker predicate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ecto
ecto merged commit c5938bc into main Jul 25, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant