Skip to content

PnR (FUG-138): place-and-route engine — ingestion, constraints, placement + orientation - #126

Open
issuefleet[bot] wants to merge 37 commits into
mainfrom
pnr-system
Open

issuefleet[bot] wants to merge 37 commits into
mainfrom
pnr-system

Conversation

@issuefleet

@issuefleet issuefleet Bot commented Aug 21, 2026 •

Copy link
Copy Markdown
Contributor

Algorithmic place-and-route for the atopile PCBs (FUG-138)

Builds out the PnR system on the FUG-131 atopile boards per
docs/hardware/pnr-system.md. Carries the design doc plus Task 0 → Phase 3 of
the phased plan.

Task 0 — foundations

  • torch under Bazel — torch (CPU) + pyyaml in the root lockfile, gated by
    //hardware/pnr:torch_smoke_test. Pinned <2.4 so the single markerless
    requirements.lock stays CPU-only and cross-platform (2.4+ ships CUDA aarch64
    wheels that would break macOS resolution). Resolves to torch==2.3.1.
  • Cypress license — recorded in the WORKLOG: do not vendor NVlabs/Cypress
    (repo is AGPLv3; Cypress is likely NVIDIA-source non-commercial, unverifiable
    offline) — reimplement its math clean-room, the design's fallback.

Phase 1 — ingestion + constraints (hardware/pnr/pnr/)

  • graph.py — neutral BoardGraph (§2), stdlib-only: the JSON seam between the
    KiCad pcbnew interpreter and the torch interpreter.
  • ingest.py — pcbnew .kicad_pcb → BoardGraph + pure dump_svg. Uses the
    text-excluded footprint bbox as the courtyard (these atomic parts carry no
    courtyard layer; the default bbox included silk/text and inflated parts ~5×).
  • constraints.py — constraints.yaml (§3) → hard barriers / soft penalties.
  • testdata/splanc_dev/ — frozen fixture: built .kicad_pcb, graph.json
    (79 comp / 71 net / 338 pad), hand-written constraints.yaml.

Phase 2 — placement MVP (hardware/pnr/pnr/place/)

  • model.py — differentiable global placement (torch, CPU, seeded, single-
    threaded for reproducibility): log-sum-exp wirelength + spreading + outline
    containment + soft edge-align + soft grouping + keep-out penalty; Adam.
  • legalize.py — grid nearest-free-fit legalizer (numpy): disjoint blocks ⇒
    0 overlaps + in-outline by construction.
  • metrics.py / geometry.py / placer.py / __main__.py (runnable
    //hardware/pnr:place).

Phase 3 — orientation search

  • Each movable part co-optimizes a 90° rotation via a temperature-annealed
    softmax over {0,90,180,270} (deterministic Concrete/Gumbel-Softmax); pin
    offsets + courtyard extents become the expected value under the distribution,
    snapped to the arg-max angle at the end. place(orient=True) by default.
  • Result on splanc_dev: legal (0 overlaps / 0 outside / 0 fixed-off / 0
    keep-out); HPWL 15832 → 2023 (position-only) → 1836 mm with orientation
    (88% below the ato row)
    ; 61 parts rotated; deterministic.

Verification

bazelisk --output_base=$HOME/.cache/bazel-atopile test //hardware/pnr/... →
6/6 pass (torch_smoke_test, graph_test, constraints_test, ingest_test,
placement_test, orientation_test). All prek hooks pass.

Next (tracked in hardware/pnr/WORKLOG.md)

Phase 4 — FLUTE+FastRoute lookahead global route + PathFinder-history place↔route
feedback loop. Then Phase 5 detailed route + DRC + fab export. Documented MVP
simplifications: single-sided legalization (side_pref is a soft term but parts
stay top), keep-out rel-to-fixed / absolute polygon, no hMETIS partition seed yet.

Closes-Linear: FUG-138 (https://linear.app/fughilli/issue/FUG-138/hardware-build-out-pnr-system)

Claude Agent and others added 13 commits August 20, 2026 02:50
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
hardware/splanc_dev — complete board as code, builds hermetically via the
rules_atopile toolchain (ato build -> resolved .kicad_pcb, 79 footprints, no
network at build): ESP32-C6-WROOM-1 + USB-C native-USB, TLV62569 3V3 buck,
TPS61088 5V boost, 2x monitored/switched LED channels (INA226 + P-FET load
switch + 74LVC1T45 level shift + connector), MPU6050 + BMP280 sensors on I2C,
MCP73831 charger + MAX17048 fuel gauge, buttons, and the 2x10 EoL connector.
Every part is a pre-picked atomic LCSC component (elec/src/parts + passives.ato).

firmware — //firmware/player_app:splanc_dev (-DLM_BOARD_SPLANC_DEV pin map),
verified to build a 2.28MB C6 image.

hardware/tools — local picker catalog + part-generation and footprint-sanitize
scripts (the sanitizer works around an atopile 0.15.8 layout crash on footprints
whose silkscreen is only circles).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Expose the atopile PCB build as a runnable target. rules_atopile's Nix toolchain
uses root-only rules_nixpkgs tags that don't compose as a bazel_dep, so instead
of a fragile MODULE.bazel integration, ato_build.sh fetches rules_atopile at a
pinned commit, re-pins the aarch64-linux venvHash, sanitizes the footprints, and
drives ato + kicad-cli through its nix develop shell. Verified end-to-end:
bazel run //hardware/splanc_dev:build{, -- pdf, -- gerber} produces the resolved
.kicad_pcb, a layout PDF, and Gerbers + drill.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
hardware/splanc_eol_tester — the End-of-Line test fixture as code, builds
hermetically (bazel run //hardware/splanc_eol_tester:build): ESP32-C6 sequencer,
mates the dev module J_EOL (2x10), VBUS-inject switch, isolated button drivers,
per-channel SHORT+NOMINAL simulated loads, rail/CSA dividers into the MCU ADC,
and level-shifted digital taps to a header for the external HITL-style FX2/
fx2lafw 24MHz analyzer. Reuses the dev module's atomic parts; FX2 (external
dongle) + opto isolation + power shunts use documented stand-ins where a
footprint wouldn't generate.

hardware/README.md — atopile setup, build commands, authoring flow, the 0.15.8
workarounds, and known first-spin simplifications.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e-splanc-dev-module

# Conflicts:
#	firmware/player_app/led_config.h
Replace the per-board sh_binary(build) wrapper around ato_build.sh with real
atopile_project targets (//hardware/<board>:<board> + .pdf/.gerber/.view/...),
driven by a Nix-provided toolchain — no PATH lookups.

- MODULE.bazel: pull rules_atopile as a bazel_dep (git_override) and build
  ato/kicad-cli/freerouting + a py3.12 (pcbnew) from this repo's rules_nixpkgs,
  pinned to rules_atopile's nixpkgs (ac62194c; has python3.14 + matching
  venvHash). Register the toolchains.
- hardware/atopile: atopile_toolchain binding those Nix tools (per exec platform).
- Three patches to rules_atopile (applied via git_override):
  * composable-dep: minimal MODULE.bazel so it composes as a dependency
    (its rules_nixpkgs tags are root-only and error otherwise).
  * venvhash: re-pin the drifted aarch64-linux uv-venv FOD hash.
  * autoroute-kicad-python: a kicad_python toolchain field + derive KiCad's
    pcbnew site-packages for the autoroute step (strict_action_env blocks env
    injection); adds route_max_passes to bound FreeRouting.
- Boards: outline_margin_mm=4 + autoroute=True (route_max_passes=3) — a framed,
  headless-routed PREVIEW (auto-placed row -> long routing; ~3 min/build), not a
  fab layout. README documents the flow + the planned algorithmic PnR system.

Verified in-container: both boards build .kicad_pcb; splanc_dev .pdf (outline +
routed) and .gerber build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs/hardware/pnr-system.md — problem space, SOTA survey (traditional +
neural), and an implementation sketch for a single build target that goes from
a netlist + spatial constraints to a DRC-clean, fabricatable PCB + manufacturing
files.

Core plan: constraint model (fixed/edge/orientation/side/keep-out) -> a
DREAMPlace/Cypress-style differentiable graph-relaxation placement (LSE
wirelength + per-side density + net-crossing routability, Gumbel-Softmax
orientation) -> FastRoute/FLUTE lookahead global routing -> a place<->route
feedback loop made convergent with a PathFinder-style history term (region
inflation a la RePlAce) -> FreeRouting detailed route -> DRC -> fab export via
kicad-cli/pcbnew. Sober ML take: borrow autodiff placement + a cheap routability
signal; route classically; treat RL/learned-router work as inspiration, not
dependencies. Grounded in cited primary sources (Cypress ISPD'25, DREAMPlace,
ePlace/RePlAce, PathFinder, FLUTE/FastRoute, AlphaChip + its critique).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the design doc pick-up-able by a fresh worker agent:
- docs/hardware/pnr-system.md: new "Handoff / bootstrapping" section (repo
  mechanics, proposed hardware/pnr/ layout, language+PyTorch-under-Bazel
  decision, Task 0 blockers, first vertical slice); a concrete "Ingestion
  contract" (pcbnew field->internal-graph mapping, verified against
  splanc_dev.kicad_pcb) and a "Concrete constraint file (v0)" YAML with a worked
  splanc_dev example (usbc/buttons/ESP32-C6 antenna keep-out); per-phase
  acceptance tests + a fixture-extraction recipe. Fix a prettier-mangled bullet
  in the build-integration paragraph.
- hardware/pnr/WORKLOG.md: seed the code location with a newest-first handoff log
  (current state = design only, no code; Task 0 blockers = Cypress license +
  torch-under-Bazel; Phase 1 first slice).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The atopile uv-venv fixed-output hash for aarch64-linux drifts as its
transitive deps release (documented in hardware/README.md). Re-pin it to the
current value so //hardware/splanc_dev builds again — needed to regenerate the
PnR ingestion fixture. The venv FOD is reproducible, so the pin holds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The algorithmic place-and-route engine's differentiable placement core is a
PyTorch autograd program (docs/hardware/pnr-system.md §4/§8); pyyaml parses the
sidecar constraints.yaml. CPU-first by design.

torch is capped <2.4 on purpose: from 2.4 PyTorch ships CUDA-enabled aarch64
(sbsa) wheels, so resolving on this aarch64 host would pull ~20 linux-only
nvidia-*/triton wheels into the single, markerless requirements.lock — which
then fails to resolve on macOS-arm64. Under 2.4 the aarch64 wheel is CPU-only
and the x86_64 nvidia deps are platform-marked, so the lock stays clean and
cross-platform. Resolves to torch==2.3.1; lock change is purely additive.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First code for the algorithmic place-and-route engine (design doc
docs/hardware/pnr-system.md). Lands the ingestion bridge and constraint front
end so a later phase can wire the differentiable placer over them.

- pnr/graph.py: the neutral BoardGraph (design §2) — components, pads, nets,
  outline. stdlib-only on purpose: it is the JSON seam between the KiCad pcbnew
  interpreter (ingest) and the hermetic rules_python interpreter (torch), which
  never share a process. Coordinates normalized to float so JSON round-trips
  byte-stably.
- pnr/ingest.py: pcbnew .kicad_pcb -> BoardGraph (nm/y-down -> mm/y-up,
  bottom-left origin), plus a pure dump_svg ratsnest renderer. pcbnew is imported
  lazily (only under @kicad_python), so the module imports fine without KiCad and
  the graph/SVG logic stays unit-testable.
- pnr/constraints.py: constraints.yaml (design §3) -> hard barriers / soft
  penalty terms; globs expand against the netlist, unknown refs warn. Front end
  only (no torch math yet).
- testdata/splanc_dev/: frozen fixture — the built splanc_dev.kicad_pcb, its
  ingested graph.json (79 components / 71 nets / 338 pads), and a hand-written
  constraints.yaml. Tests never rebuild atopile.
- tests + BUILD: torch_smoke_test (Task 0 gate — torch imports + autograd on
  CPU), graph_test, constraints_test, ingest_test (frozen counts + a
  KiCad-gated live re-extraction). All green: 4/4 under
  bazelisk --output_base=... test //hardware/pnr/...

Task 0 decisions recorded in hardware/pnr/WORKLOG.md: torch under Bazel done;
Cypress NOT vendored (repo is AGPLv3, NVlabs/Cypress is likely NVIDIA-source
non-commercial and unverifiable offline) — reimplement its math clean-room, the
design's stated fallback. Next: Phase 2 placement MVP (design §9.2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The atomic EasyEDA footprints have no F/B.CrtYd courtyard layer, so ingest was
falling back to the graphical bounding box *including silkscreen + reference
text* — inflating every part ~5x (a 0402 read as 4.1x5.1 mm; total 3580 mm^2).
Use pcbnew's text-excluded GetBoundingBox(False) instead (0402 -> 1.9x1.2 mm;
total 1490 mm^2), which is the real placement extent.

Regenerate the frozen splanc_dev graph.json (component/net/pad counts
unchanged: 79/71/338). Grow the fixture outline to 60x50 mm and fix U5 (the
ESP32-C6 RF module) at the north edge so its antenna keep-out is a well-defined
region — both needed so Phase 2 placement is feasible and its keep-out is
meaningful.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reflows the atopile row placement into a legal, compact layout (design §9.2).

- pnr/place/model.py — differentiable global placement (torch, CPU, seeded):
  loss = log-sum-exp wirelength + pairwise spreading + outline containment +
  soft edge-align + soft grouping + keep-out penalty; Adam. Fixed parts are held
  as constants but still anchor the wirelength. The DREAMPlace 'placement is
  training a network' reframing in plain PyTorch. No orientation search yet.
- pnr/place/legalize.py — grid nearest-free-fit legalizer (numpy): rasterize the
  outline, block fixed courtyards + keep-outs, place movable parts biggest-first
  into the free slot nearest their continuous target. Disjoint blocks sized
  ceil((courtyard+clearance)/g) give 0 overlaps + in-outline by construction.
- pnr/place/geometry.py (Rect, courtyard, pins, edge-pose/keep-out resolution),
  metrics.py (HPWL, overlaps, hard-violation checks), placer.py (place() +
  PlacementReport), __main__.py (CLI: python -m pnr.place / //hardware/pnr:place).
- test_placement.py acceptance (green): on splanc_dev the result is legal
  (0 overlaps / 0 outside / 0 fixed-off / 0 keep-out), HPWL 15832 -> 2023 mm
  (87% shorter than the ato row), and deterministic under a fixed seed.

BUILD: split :pnr (core, torch-free) from :pnr_place (torch+numpy) so the schema
tests stay light; added the placement_test and a runnable :place binary.

MVP simplifications tracked in WORKLOG for Phase 3+: no orientation search,
single-sided legalization (side_pref is a soft term but parts stay top), keep-out
supports rel-to-fixed + absolute polygon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@issuefleet issuefleet Bot changed the title PnR (FUG-138): place-and-route engine foundation — ingestion, constraints, torch gate PnR (FUG-138): place-and-route engine — ingestion, constraints, differentiable placement MVP Aug 21, 2026
The placer now co-optimizes a 90-degree rotation per movable part alongside
position (design §9.3).

- pnr/place/model.py — each movable part carries a categorical over
  {0,90,180,270}, relaxed to a temperature-annealed softmax (a deterministic
  Concrete/Gumbel-Softmax relaxation, Cypress-style). Pin offsets and courtyard
  extents become the *expected* offset/extent under that distribution, so
  orientation is differentiable and co-optimized with position; at the end we
  snap to the arg-max angle. Fixed parts keep their constrained angle. Runs torch
  single-threaded (set at import) so the placement is reproducible.
- placer.place() gains orient= (default True); writes the chosen rotations into
  the graph before legalization (which already honors rotated courtyards).
  PlacementReport gains a rotated count.
- tests/test_orientation.py (Phase 3 acceptance, green): orientations settle to
  legal discrete angles, the result stays fully legal, orientation is exercised,
  HPWL improves vs. the position-only Phase 2 placement, and it's deterministic.
  The Phase 2 placement_test now pins orient=False for position-only semantics.

Result on splanc_dev: legal, HPWL 2023 -> 1836 mm (orientation on vs off; 88%
below the ato row), 61 parts rotated. Full suite 6/6 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@issuefleet issuefleet Bot changed the title PnR (FUG-138): place-and-route engine — ingestion, constraints, differentiable placement MVP PnR (FUG-138): place-and-route engine — ingestion, constraints, placement + orientation Aug 21, 2026
# Conflicts:
#	MODULE.bazel.lock
#	firmware/player_app/led_config.h
…-138)

Integrates the design's remaining phases so a single build target takes the
splanc module from netlist to a placed, routed, fab-ready board:

    bazel build //hardware/splanc_dev:splanc_dev.fab

Phase 4 (pnr/route/): RMST net decomposition + a coarse gcell global router with
PathFinder present/history negotiated congestion, and the place<->route feedback
loop — global-route overflow drives RePlAce-style per-part inflation (weighted by
an accumulated history term) until the board is routable. Threads an `inflation`
hook through place()/global_place()/legalize(). Tests: route_test (Steiner,
capacity/overflow, PathFinder history, inflation) + route_feedback_test
(overflow->0, terminates, non-increasing, placement stays legal, deterministic).

Phase 5: pnr/writeback.py applies the optimized placement back onto the board via
pcbnew (poses/orientation/side, clears stale preview tracks) and strips Edge.Cuts
as text so board_outline.py reframes cleanly (works around a broken
BOARD.GetDrawings() SWIG iterator in this KiCad 9 python). The atopile_pnr rule
(hardware/pnr/pnr.bzl) orchestrates ingest -> place+route -> writeback -> framing
-> FreeRouting -> DRC across the two interpreters (@kicad_python + the hermetic
torch pnr_fab binary) and re-provides AtopileLayoutInfo to the existing kicad-cli
exporters, which emit the Gerber/drill/BOM/pick-place bundle.

Verified: splanc_dev.fab builds clean — place<->route converges (HPWL
15832->1849 mm, 61 parts rotated, 0 overflow), FreeRouting lays 1004 tracks +
80 vias over 79 footprints, and the full fab bundle is produced. Engine suite
9/9 green.

Docs: docs/hardware/pnr-inputs.md (the constraints.yaml guidance-input
reference), hardware/README.md (the two layout paths), design-doc §9 marks
Phases 4-5 done.

Also re-pins the atopile aarch64 venvHash (known FOD drift) so the .fab build
realizes the toolchain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…y pass (FUG-138)

Adds the design's quality passes (§9.6) to the .fab flow.

Schema (constraints.py): net_class (per-class trace width/clearance over net-name
globs), diff_pair (p/n + skew tolerance), length_match (net group + tolerance).
compile_routing_rules() expands the net globs against the real netlist into a
stdlib rules.json — the seam the pcbnew steps consume without pyyaml/torch.

Widths reach the router (writeback.apply_net_classes): net classes are applied to
the board's KiCad net settings (default class + named classes via
SetNetclassPatternAssignment) BEFORE apply_placement, so BuildConnectivity() keeps
them and FreeRouting's DSN carries per-class widths. Verified: power rails route
at 0.4 mm, signal nets at 0.2 mm.

Quality pass (pnr/quality.py): reads the routed board via pcbnew (per-net track
length + via count) and a pure analyze() scores routed length, vias, diff-pair
length skew, length-match spread vs tolerance, and per-net-class length roll-up ->
quality.txt in the fab bundle. Advisory by default; quality_gate=True fails the
build on a diff-pair/length-match miss.

Wiring (pnr.bzl): pnr_fab --dump-rules, writeback --rules, a quality step, and a
PnrReportsInfo provider so the bundle collects drc.rpt + quality.txt.

splanc_dev constraints.yaml gains a `power` net class (rails -> 0.4 mm). The board
has no genuine diff-pair/length-match need (USB is inside the WROOM module), so
those are covered in the docs, tests, and fixture rather than forced onto it.

Verified end-to-end: splanc_dev.fab builds, quality PASS, power nets widened,
quality.txt shipped. Engine suite 10/10 green. The optional learned routability
predictor is intentionally deferred (the analytical loop already converges).

Docs: docs/hardware/pnr-inputs.md (routing-rule reference), design-doc §9.6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…erhang, routing gate (FUG-138)

An EE review of the first routed splanc_dev.fab caught real defects. Fixes:

1. Outline clipped parts (ESP32 pads outside Edge.Cuts). The outline was framed
   from footprint origins (atopile board_outline.py), discarding the placement's
   containment. Now writeback.frame_region stamps Edge.Cuts at the placement
   region [0,W]x[0,H] — which the placer keeps every courtyard inside — as text,
   so all pads are inside by construction. Dropped board_outline.py from the flow.

2. 2-vs-4 layer mismatch. constraints.layers now actually sets the board copper
   layer count (SetCopperLayerCount) via rules.json; gerbers export In1/In2 and
   FreeRouting routes on all four layers.

3. Connector edge overhang. New overhang_mm on fixed/edge_align (geometry.
   _edge_pose) so an edge connector protrudes a set distance past the board edge
   for cable mating; USB1 = 1.5 mm. Fixed parts excluded from the outside-outline
   check (metrics.outside_outline(exclude=…)) since the overhang is intentional.

4. Routing-completeness gate (the key behavior change): pnr.quality counts
   unrouted ratsnest (GetUnconnectedCount) and the build FAILS if any net is
   unrouted (require_routed, default True); route_max_passes=0 routes to
   completion. No more green build on a partial route.

Tests: geometry_test (overhang + outline exclude) + quality unrouted gate; suite
11/11 green.

KNOWN-OPEN: the board does not yet fully route — on 4 layers FreeRouting leaves
~81 connections unrouted, so splanc_dev.fab CORRECTLY FAILS. The place<->route
loop's coarse global-route lookahead reads overflow 0 while the detailed router
can't route the dense placement; making the board routable (realistic lookahead
so the loop spreads, bigger/less-dense board, channel-aware legalization, or
detailed-route-in-loop) is the next work. See hardware/pnr/WORKLOG.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Routability investigation from the EE review, and the kickoff of our own detailed
router (Kevin's call: FreeRouting + naive planes can't get a dense fine-pitch
board DRC-clean).

Diagnosis (union-find over tracks+pads reproduces pcbnew's ratsnest exactly at
81): 55/81 unrouted (68%) are high-fanout ground/power — `lv` ground is 75 pads /
32 unrouted — that must be planes, not traces.

Planes (validated, connect the high-fanout nets):
- net_class.plane_layer schema (+ rules.json, board layers).
- writeback.apply_planes: split-plane pour (several rails share an inner layer,
  each = bbox of its own pads, priority-carved) + via-stitch pads to the plane;
  plane layers typed LT_POWER so the router keeps signals on F/B.
- splanc_dev: In1 = solid `lv` ground, In2 = split {p3v3,p5v,vsys,vbat,hv}.

KNOWN-OPEN (-> phase R1): via-in-pad stitching shorts on 0.5mm-pitch parts (612
DRC violations). Fix is dog-bone fanout (offset via + short trace), the standard
for >=0.5mm pitch; that's the first router phase.

Design: docs/hardware/pnr-system.md gains the detailed-router phased plan R1-R5
(dog-bone plane fanout -> geometry/pin-access model -> escape routing ->
PathFinder negotiated signal router -> close the loop with real congestion),
SOTA-grounded. Until it lands, splanc_dev.fab correctly FAILS the require_routed
gate.

Also: overhang_mm connector edge protrusion + plane_layer docs. Suite 11/11 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…irst) (FUG-138)

Starts the detailed router at R1 (DRC-clean plane fanout). writeback.
_dogbone_fanout_net places an offset via + short trace per plane-net pad (the
>=0.5mm-pitch standard) instead of via-in-pad, clearance-checked against other-net
pads/tracks (_collect_obstacles / _clear_of_obstacles), via-in-pad only for big
pads. _collect_obstacles guards GetTracks() (flaky SWIG iterator in this KiCad-9
python — usually works, sometimes not; falls back to pads-only).

FINDING (recorded in WORKLOG): a naive outward offset is still NOT DRC-clean
(~28 shorts on the dense fixture) because the fanout *trace* isn't collision-
routed and via geometry needs the proper model. So R1 depends on R2 — clean
fanout needs the geometry/obstacle model + a real trace router. Build order is
R2 (geometry + pin access) -> R1/R3 (fanout/escape) -> R4 (signal router) -> R5
(close the loop). The scaffold + obstacle framework here is the foundation.

splanc_dev.fab still correctly fails the require_routed gate. Engine suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…r) (FUG-138)

The own detailed router now has its engine — pure Python/numpy on the placed
graph (like the placer), fast + unit-tested, no pcbnew in the loop.

R2 — pnr/route/detail/grid.py: RouteGrid, a per-signal-layer occupancy grid built
from the placed graph. Grid pitch >= track+clearance => DRC-by-construction. Pads
(now carry `size` — added to graph.Pad + ingest; fixture graph.json regenerated
with sizes, counts unchanged) become own-net access cells; other-net pads / keep-
outs are obstacles; the outline is the grid bound. detail_grid_test green (incl.
building on the real 338-pad fixture).

R4 — pnr/route/detail/maze.py: a multi-layer A* + PathFinder negotiated router.
In-plane + via moves (via surcharge), multi-pin nets grow a tree (Prim on the
grid), cost (1+history)*present; rip-up-&-reroute until no grid cell is shared by
two nets => DRC-clean at the pitch. detail_maze_test green: 2-pin, via-when-
needed, obstacle avoidance, TWO CROSSING NETS NEGOTIATE DRC-clean (no shared
cells), deterministic, other-net-pad blocks. This engine subsumes R1/R3 fanout
(a dog-bone is a short route + via).

Also adds pad `size` to the ingest contract (graph.Pad.size, ingest reads
GetSize) and geometry.pad_rects (rotation-aware pad rectangles). Suite 13/13
green. Design doc marks R2 + R4-core done; WORKLOG has the R5 integration plan.
splanc_dev.fab still correctly fails the gate until R5 lands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…FUG-138)

route_board (pnr/route/detail/router.py) ties R2 (grid) + R4 (maze) into one call
over a placed graph: build the grid, exclude the plane nets, negotiated-route the
signals, and emit mm-space tracks/vias ready for write-back.

route() now GREEDY-FINALIZES to a DRC-clean result always: contested nets are
dropped to unrouted rather than emitted, so the routing handed back is never a
short — it is honest ground truth (routed + unrouted) for the place<->route loop.
A* sources are iterated in sorted order for determinism.

detail_route_test: places the real splanc_dev fixture and routes its signals
(power/ground on planes, excluded) — asserts DRC-clean by construction (no grid
cell shared by two nets), deterministic (two fresh routes match), emits geometry,
routes a meaningful fraction on the dense 2-signal-layer board. Fixture
constraints.yaml gains plane_layer net classes. Suite 14/14 green.

Design doc + WORKLOG: R5 in progress with the finish plan (emit via writeback +
plane fanout via the engine, then close the loop with real congestion + tuning to
reach 100%). splanc_dev.fab still correctly fails the gate until that lands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…path wired (FUG-138)

The own detailed router now fully routes the splanc_dev fixture: pnr_fab reports
`detail route: 48/48 nets, 0 unrouted, 8 passes` at 0.3mm pitch (6 power/ground
nets are planes). This is the board FreeRouting could not finish. FreeRouting is
removed from the pipeline; the own engine (grid model + negotiated multi-layer
maze + PathFinder) is the router.

Emit path wired end-to-end:
- pnr.route --dump-routes runs route_board on the placed board and dumps mm-space
  tracks/vias + pad->cell access stubs (routes.json)
- pnr.writeback --routes emits them (emit_routes); net codes read up front via
  _net_code_map since FindNet/GetFootprints flake mid-session; _set_design_rules
  stamps the board default clearance/width (0.13/0.15) to match the grid
- pnr.bzl drops the FreeRouting step + route_max_passes/freerouting attrs

KNOWN-OPEN (R3): the emitted pcbnew board is not DRC-clean yet (~1584 DRC) — via
geometry, plane fanout shorts, pin-access stub alignment, and track clearance
margin. The routing decisions are DRC-clean in the grid model; turning them into
DRC-clean copper is the remaining R3 work. splanc_dev.fab correctly still fails
the routing-completeness + DRC gates until then.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Validated against kicad-cli pcb drc: placement-only (zero routing) already reports
66 violations, ALL pad-vs-pad inside the source footprints (U2 exposed thermal pad
vs its thermal PTH; USB1 overlapping connector pads). Adding the full signal route
adds only ~13 more — 4 of them an EN-1/en-1 net-name case collision (atopile
naming, not a real short) — i.e. ~9 genuine routing violations, each a tight
via-near-pad edge case. The router no longer emits shorts/clearance failures.
(Started the session at 1097 full-board violations.)

Six fixes got there:

1. Design rules must be stamped into the .kicad_pro, not the board. DRC reads
   board constraints + net-class clearances from the PROJECT file, and
   apply_placement (SetCopperLayerCount/BuildConnectivity) detaches the board's
   live settings from the project SaveBoard writes — so GetDesignSettings() never
   reached DRC. writeback.patch_project_rules() patches the .kicad_pro JSON as the
   last pipeline step (pnr.planes), mirroring how frame_region stamps Edge.Cuts as
   text. Rule set (_RULE_SET_MM): clr 0.13, hole/hole-to-hole/edge 0.20, via Ø0.45,
   drill 0.20, annular 0.0 — manufacturable and grid-satisfiable; drill/annular
   loosened only to tolerate source footprints (U2 0.2 drills, USB1 ~0 annulus).

2. Pad y-flip frame bug: pcbnew footprint-local coords are y-DOWN, the engine
   y-UP; ingest stored the pad offset unflipped, so every pad's router geometry was
   mirrored about the part centre (halo/access on the wrong pad → shorts). Fix:
   offset=(x, -y) in ingest. Pad-position delta vs emitted pcbnew is now 0.0000 mm
   across all 79 parts.

3. Pad clearance halos (grid.add_pad): reserve own-net cells out to
   clearance + via_radius so other-net tracks AND vias keep clear.

4. Via keep-out (maze._footprint): Ø0.45/0.25 vias + a radius-1 keep-out halo on
   both layers around every via ⇒ via-via ≥0.6 mm centre-to-centre and via-track
   both DRC-clean at 0.3 mm pitch; folded into PathFinder occupancy and the greedy
   finalize (a net whose footprint overlaps an accepted net drops to unrouted).

5. Through-hole pads on ALL signal layers; no-net copper is a hard obstacle;
   custom-pad true extent (U2 pad 21: Ø0.01 anchor but 4.97x2.04 real copper — use
   GetBoundingBox); edge inset (grid.block_edge_inset).

6. ingest board name strips .kicad_pcb.

detail_route_test now asserts the footprint-level DRC-clean invariant (cells + via
keep-out halos, no cross-net overlap) and a realistic routed fraction. Suite 14/14.
Fixture graph.json regenerated (through_hole + custom-pad sizes + y-flip). OPEN
(R5): DRC-clean routed fraction is ~14-22/48 on 2 signal layers — feed the detailed
router's congestion back into placement inflation to close it; until then
splanc_dev.fab correctly fails the routing-completeness gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… routing (FUG-138)

Loop closure: route_and_place(detail_rules=...) (+ pnr.route --detail-loop, wired
into pnr.bzl) now uses the DRC-clean detailed router as the loop's ground truth —
it re-routes every round and #unrouted signals is the objective, with
detail_congestion() stamping each failed net's pad bbox into the placement
inflation map. This replaces the global lookahead, which reported overflow 0
(perfectly routable) on placements the DRC-clean router cannot finish. This is the
user's directive: a failed route must steer the next placement cycle.

4-layer signal routing: router._signal_layers/_mark_plane_regions build a 4-layer
grid and block the split-plane regions on the inners; maze via moves treat a
through-via as an antipad pass-through (only the target layer must be clear — a
track can't sit under plane copper, a via may pass through it); emit_routes maps
In1/In2. Signals now route the inner-layer gaps between the split planes.
Validated: In2 signal tracks are 0/0 DRC against the plane.

Routability ceiling, honestly characterized: ~40% of signals (16-22/48), firm
across pitch (0.25->17, 0.3->20, 0.4->14) and negotiation depth (12 vs 40 passes
-> ~18); the detail-loop oscillates (32->29->36->32) and does not converge. Root
cause is routing RESOURCE, not the router: the ground net's 75 pads span the whole
board so its plane consumes ~all of In1, and the power split-planes cover much of
In2 -> a 4-layer/2-plane stack gives ~2 effective signal layers here. A fully
routed board needs 6-layer, a bigger outline, or a ground-plane strategy that frees
an inner layer. Until then splanc_dev.fab correctly FAILS the routing-completeness
gate (loop-until-success-or-fail, as requested).

detail_route_test exercises 4-layer (allows In1/In2 track layers). Suite 14/14.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…outline (FUG-138)

Two levers toward a fully-routed board (user chose "invest in a stronger router"
and OK'd growing the outline):

1. Rip-up-&-reroute finalize (maze.route): the finalize was greedy — a net that
   contended for even one cell during PathFinder negotiation was dropped whole.
   Now it's two passes: (1) commit the negotiated routes that don't collide
   (preserves the spread paths); (2) for every dropped net, re-route it from
   scratch AROUND all committed copper (hard-blocked A*, threaded via a `blocked`
   set through _astar/_route_one), shortest-span-first. A net stays unrouted only
   if it genuinely can't path around the committed set — the difference between a
   greedy grid router and a real one. Still DRC-clean by construction.

2. Rubber-band board outline (route_and_place auto_outline, pnr.route
   --auto-outline / --outline-max-scale): the constraints' board.outline is an
   approximate target, not a hard size — a too-small board is simply unroutable.
   When the loop doesn't fully route at the target, the outline scales up (aspect
   preserved) and the whole loop retries, up to --outline-max-scale; the smallest
   outline that fully routes wins. place() already stamps placed.outline from the
   (now rubber-banded) constraint size, so write-back frames to it.

Findings: RRR alone barely moves splanc_dev (~19-20/48) — the leftovers genuinely
can't fit, so it's congestion not greedy-drop. A bigger board helps globally
(pitch 0.4: 14/48 at 1.0x -> 21/48 at 1.6x / 2.56x area) but the harder wall is
LOCAL pin-escape at fine-pitch parts (needs finer pitch there, and the inflation
loop needs >1 round to spread dense clusters). Both levers are correct and
composable; tuning the combination (bigger board + pitch 0.3 + multi-round
inflation) is the next step. Unit suite green (maze/grid/feedback).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t (FUG-138)

Diagnosis of the routability wall: the 29 unrouted nets concentrate on the dense
parts (U5/ESP32 11, H1/20-pin 7, U2/QFN 5, U11 5) — fine-pitch PIN ESCAPE plus
long nets that can't cross the dense channel. Key constraint recorded: the
DRC-clean grid pitch has a hard floor at track+clearance ~0.28mm, and a 0.15mm
track cannot fit between 0.5mm-pitch pads, so inner-row pins can't escape at ANY
board size. The next lever is signal escape to the inner layers (via in/adjacent
to a stuck pad down to an inner-layer gap — the QFN/BGA dogbone, for signals).

detail_route_test: the RRR pass-2 re-routes + 4-layer grid made it time out at
size=medium; run at pitch 0.5/max_iters 10 (still exercises 4-layer + RRR +
DRC-clean-by-construction) and bump to size=large. Green in ~115s.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude Agent and others added 2 commits August 22, 2026 04:12
Adds a `fab:` block (FabProfile) to the constraints: track width, clearance, via
diameter/drill, hole/edge clearance, min through-drill, via annular. Defaults are
the conservative JLC-class 4-layer set (behavior-preserving); a fab house that
supports finer geometry can tighten them for escape headroom — the user's "DRC may
be less strict; fab houses may support better than we chose."

One source of truth: the profile flows through rules.json ("fab") into
  - the grid pitch (auto = track+clearance DRC-clean floor; a tighter fab routes
    finer) and the grid track/clearance/via_radius,
  - the via keep-out radius, now DERIVED from the geometry (ceil((via_d+clr)/pitch)
    - 1) instead of hardcoded 1 — so via-via stays DRC-clean at any profile (a
    tighter fab needs a wider halo; caught that a naive radius-1 shorts at 0.35 via
    / 0.20 pitch),
  - the emitted via geometry (emit_routes) and the authoritative .kicad_pro rule
    set (patch_project_rules now reads the fab profile).

--route-pitch default is now 0 = auto-from-fab (explicit value still overrides).
Measured (splanc_dev): default fab -> pitch 0.30, 23/48; tight 0.10/0.10 fab ->
pitch 0.20, 21/48 (finer pitch helps tracks but the wider via keep-out offsets it).
E1 is the knob; the via-in-pad / dogbone escape (E2/E3) is the real fine-pitch fix.

NOTE: DRC fast-loop re-validation of the emitted board is pending kicad-python
re-realization (the nix store paths were GC'd this session — relaunch-gated). The
writeback/DRC path is the same code as the extensively-validated default-fab run,
now reading the profile; routing side verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…onstruction (FUG-138)

New pnr/route/detail/escape.py: per-pad escape planning fed into route_board.
For each routable pad, in increasing cost:
  - on-layer: keep the pad-centre access cell when the net can leave laterally
    (the maze then vias where it needs to — subsumes a local dog-bone);
  - via-in-pad (E2): if the pad is boxed on its layer, drop a via IN the pad down
    to another layer that has room — GATED by _via_clean so the via's keep-out
    touches no other net's copper (a Ø0.45 via in a 0.5 mm-pitch pad field would
    short its neighbours; correctly rejected there). Reserves its keep-out.
  - dog-bone (E3): stub outward to an offset cell + via, with the stub path
    _line_clear-checked and _reserve_line'd so it's DRC-clean like a routed seg.
Escape vias/stubs reserve their footprint in the grid up front, so the maze and
later escapes route around them — the emitted escape geometry is DRC-clean by
construction (validated: escapes ON vs OFF give identical pcbnew DRC).

Honest scope: on the congestion-limited splanc_dev the planner is ~a no-op — its
dense parts are boxed by OTHER-NET pads, where a full-size via-in-pad legitimately
can't go (needs micro-vias: set a tighter via in the fab profile, E1). It fires for
pads boxed by obstacles (plane regions / keep-outs / edges) → via down to a gap. A
dog-bone as a distinct routing CHOICE (not just a local fallback) needs
group-terminal maze routing — the documented next step. --no-escape A/B flag added.

detail_escape_test covers on-layer / via-in-pad(obstacle-boxed) / disabled. Suite
15/15.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… board

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude Agent and others added 3 commits August 22, 2026 15:28
…und loop (FUG-138)

Visual diagnosis (rendered the routed board) showed F.Cu nearly empty — almost all
routing on B.Cu — because the pad clearance halo was VIA-sized (clearance +
via_radius ~0.355mm) everywhere, walling off the pad-dense top layer for tracks and
forcing routing to the back (paying via cost). A track only needs clearance +
½track (~0.205mm).

Fix: two-tier halo in the grid. add_pad now reserves a narrow TRACK halo (own-net,
in pad_net — a foreign track shorts the pad) and a separate wider VIA halo (in a new
via_halo map — a foreign via, being fatter, must stay outside it, but a track may
enter). New grid.via_passable() = passable AND clear of other nets' via_halo; the
maze via move and the escape via drops use it, track moves use passable. Result:
F.Cu segments 782 -> 1171 (now balanced with B.Cu 1250), routed 23 -> 25/48, and
router-copper DRC 20 -> 18 (still DRC-clean by construction; validated vs kicad DRC).

Also: the place<->route loop now tracks and returns the BEST round (fewest
unrouted), not the last — the inflation feedback can overshoot/oscillate
(19->24 seen), and returning the last round threw away the better placement.

At the fixed 60x50 the board is still congested (25/48 at 12 or 40 iters); the
freed top layer pays off with the rubber-band (bigger board) — testing next. Suite
15/15 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ent spread (FUG-138)

Rendered the routed board and diagnosed with an isolation test (PNR_DIAG_UNROUTED):
21 of 23 unrouted nets route FINE in isolation — so the board is routable; the
ROUTER was giving up. Two real bugs found and fixed:

1. Negotiation was mis-designed. It rebuilt congestion incrementally in a fixed net
   order (so the first net never negotiated) and grew the present-sharing penalty
   MULTIPLICATIVELY (pres_fac *= 1.8) — which explodes, so MORE iterations made it
   WORSE (30 iters gave 24, fewer than 12's 26). Rewrote maze.route() as proper
   McMurchie-Ebeling: rip up ONE net at a time and reroute it against the OTHERS'
   current congestion (symmetric negotiation), with ADDITIVE penalty growth. Now
   more iterations help/plateau instead of diverging.

2. Greedy finalize dropped routable nets. Added iterative rip-up-&-reroute (pass 3):
   a still-unrouted net may cross committed copper at a rising penalty; it rips the
   crossed nets ONLY IF every one can reroute cleanly around the new commit (so a
   rip never loses a net — committed count only grows). soft-cost overlay threaded
   through _astar/_route_one.

Result: 25 -> 30/48 at the default 60x50 board, DRC-clean by construction
(detail_route_test green). The remaining 16/18 unrouted still route in isolation —
more router work (deeper RRR / negotiation tuning) will close it further.

Also: two-tier pad halo already freed the top layer (F.Cu balanced with B.Cu);
placement `spread` knob (global overlap-term inflation, legalize capped) added but
counterproductive on a fixed board (longer traces) — kept as an off-by-default knob
for use with the rubber-band. --no-escape / --place-spread / PNR_DIAG_UNROUTED
diagnostics added. Suite 15/15.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-limit mapped (FUG-138)

Replaced the single-level "reroute-or-undo" RRR with a proper negotiated queue
rip-up-&-reroute: a still-unrouted net crosses committed copper at a penalty that
RISES with how often that net has itself been ripped, rips the crossed nets, and
re-queues them; we snapshot the best (most-routed) DRC-clean state and return it, so
churn never corrupts the result. apply_planes now sets an explicit zone clearance so
a signal routed on a plane layer stays carved-clear.

Extensive experiments pin the wall precisely (all measured, not guessed):
- Isolation diagnostic (PNR_DIAG_UNROUTED): 16 of 18 unrouted nets route FINE
  alone. So the board is routable; the router can't fit them SIMULTANEOUSLY.
- 4 full signal layers (plane-signals experiment) plateaus at the SAME ~30 — so
  it's the negotiation converging to a local optimum, not layer/space resource.
- Placement spread, via-cost, more iters, bigger board, bigger rip-ups: none break
  past ~30 at the fixed board. The negotiation fix (prior commit) was the real
  lever (25->30).

Net: the detailed router is genuinely improved and correct (DRC-clean by
construction, suite 15/15) but tops out at 30/48 on splanc_dev. Closing to 100%
needs escaping the negotiation local optimum (stronger search / routability-driven
placement) and/or fixing the plane-fanout DRC to expose clean 4-layer capacity —
both are substantial, well-scoped next steps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ego_order seam

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…amperage) (FUG-138)

Two of the three review asks:

1. Power-domain spatial grouping (global_place). Added a plane-compactness term
   (minimise each plane net's pad-bbox AREA) + an inter-domain separation term
   (penalise overlap between different planes' bboxes), so the split planes come out
   compact and disjoint instead of sprawling overlapping rectangles. NOTE on
   splanc_dev: separation is inherently limited because H1 (a 20-pin header) exposes
   4 power rails, anchoring those 4 domains to one spot — measured 42/50
   power-touching parts are single-domain, but the 8 bridge parts (header + power
   ICs) keep the domains overlapping near H1. The compactness still shrinks each
   plane, and priority-carving already prevents copper shorts. Routing-neutral at the
   tuned weights (~29-30/48).

2. Track-width classes by type/amperage. NetClass gains current_a; a new
   width_for_current() sizes the trace per IPC-2221 (A=(I/(k·ΔT^0.44))^(1/0.725)),
   used when width_mm isn't given. compile_routing_rules resolves each class width;
   the router emits every net's tracks at its class width, and — crucially — a
   wider-than-signal net reserves a proportional TRACK HALO (maze _footprint
   track_halo) instead of coarsening the whole grid (a naive "pitch = widest track"
   collapsed routing 30->6). Verified: a 0.25 mm class emits 205 wide segs alongside
   0.15 mm signals on a fine grid, DRC-clean (14 router-copper over the 66-footprint
   baseline); 3 A resolves to 1.37 mm correctly.

Suite 15/15. (Third ask — 45° routing — is next; needs a design choice, octile A*
vs corner-chamfer post-pass, flagged separately.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The maze router now takes 45° diagonal steps, not just Manhattan. A diagonal move
(cost ×√2, its true length) is allowed only when BOTH orthogonal corner cells are
free (no corner-cutting), and the net's footprint RESERVES those two corners — so
the diagonal, and any opposite diagonal that would cross it (an X), stay DRC-clean.
The A* heuristic is now octile (admissible with diagonals). Routes are tracked as
edges (new _Route: cells + edges) so _to_geometry emits the exact 45° segments the
router chose and the finalize/RRR reserve diagonal corners; the writeback needs no
change (a diagonal track is just a track with diagonal endpoints).

Measured on splanc_dev: 690 of 1750 track segments are 45° diagonals (~39%),
validated DRC-clean vs kicad-cli (10 router-copper over the 66-footprint baseline —
no diagonal shorts). The board now reads like hand-routed 45° work instead of
staircases. Routed fraction dips 29->24/48 (the corner reservations consume grid
room) — the routability-driven spreading step (next) gives that room back by moving
loosely-coupled parts into the open board area.

detail_maze_test's connectivity walk updated to follow diagonal adjacency. Suite
15/15.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

This branch had an error being deployed

1 failed deployment
HITL — 5e65cc36 Deployed Aug 22, 2026 by fughilli via hitl_tests #344
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