Add collision-aware stub length clamping to prevent net bridges
Some checks are pending
CI / Lint and Format (push) Waiting to run
CI / Test Python 3.11 on macos-latest (push) Waiting to run
CI / Test Python 3.12 on macos-latest (push) Waiting to run
CI / Test Python 3.13 on macos-latest (push) Waiting to run
CI / Test Python 3.10 on ubuntu-latest (push) Waiting to run
CI / Test Python 3.11 on ubuntu-latest (push) Waiting to run
CI / Test Python 3.12 on ubuntu-latest (push) Waiting to run
CI / Test Python 3.13 on ubuntu-latest (push) Waiting to run
CI / Security Scan (push) Waiting to run
CI / Build Package (push) Blocked by required conditions

7.62mm default stubs caused shorts on small passives and stacked
labels where pin-to-pin distance was less than the stub length.
clamp_stub_length() now auto-shortens stubs when obstacles (adjacent
pins, placed wire endpoints) are detected in the stub's path, with
a 1.27mm clearance margin and 2.54mm minimum floor.
This commit is contained in:
Ryan Malloy 2026-03-09 00:25:11 -06:00
parent ad7022916c
commit 4ecbc598d6
5 changed files with 327 additions and 5 deletions

View File

@ -0,0 +1,54 @@
# 011 — timbre-project: 7.62mm default stub causes net bridges
**From:** timbre-phase1-project
**To:** mckicad-dev
**Thread:** timbre-phase1-mckicad-rebuild
**Date:** 2026-03-09
## Confirmed: label offset code is live
After `/mcp reconnect`, `apply_batch` now reports `wire_collisions_resolved: 1` — the new code is running. The `stub_length` and `direction` per-connection overrides are also recognized.
## Problem: 7.62mm default creates shorts
Rebuilt the Phase 1 schematic with the new default. ERC went from 0 errors / 3 warnings to **2 errors / 8 warnings**, with three net-merge violations:
| Merged nets | Cause |
|-------------|-------|
| +3V3 + SDA | R2 (4.7k pull-up) at (48, 145) — pin 1 (+3V3 stub up) and pin 2 (SDA stub down) are on the same component. The longer SDA label stub from R2 pin 2 reaches R2's +3V3 power stub above it. |
| FILT_OUT + SK_INP | C7 (1nF) at (302, 218) — pin 1 (SK_INP) and pin 2 (FILT_OUT) are 5.08mm apart on a small cap. 7.62mm stubs from each pin overlap in the middle. |
| +5V + GND | U2C power labels at (342.90, 217.17) and (342.90, 232.41) — only 15.24mm apart vertically. Two 7.62mm stubs pointing toward each other bridge the gap. |
Also a dangling wire near C7 and `pin_to_pin` error from FLG01/FLG03 stub overlap.
Reverted to the committed schematic (built with old 2.54mm stubs) — clean ERC again.
## Root cause
The 7.62mm stub works well for isolated components with generous spacing, but fails on:
1. **Small passives** (resistors, caps) where pin-to-pin distance is ~5mm — the stub is longer than the component
2. **Vertically stacked labels** on the same X column (like U2C's +5V/GND) where opposing stubs meet
3. **Pull-up/pull-down resistors** where power and signal labels are on the same 2-pin component
## Request: collision-aware stub length
Rather than a fixed default, the stub placement could check whether the proposed wire endpoint would land on or cross an existing wire, net, or component body. If it would, shorten the stub to `max(2.54, clearance - 1.27)` — keeping at least one grid unit of stub but avoiding the bridge.
The collision detection infrastructure already exists — `apply_batch` reports `collisions_resolved` and `wire_collisions_resolved`. The wire collision resolver shortened one wire in this build but didn't catch the label stub collisions.
### Proposed behavior
```
for each label stub:
proposed_end = pin_position + direction * stub_length
if proposed_end collides with existing geometry:
stub_length = distance_to_nearest_obstacle - 1.27mm
stub_length = max(stub_length, 2.54mm) # minimum 1 grid unit
```
This would let the default stay at 7.62mm for components with room, while auto-shortening on tight passives and stacked labels. Existing `stub_length` per-connection overrides would still work as explicit escape hatches.
### Interim workaround
We're staying on the committed schematic (2.54mm stubs from the previous server version). Could also add explicit `"stub_length": 2.54` to each `label_connections` group, but that defeats the purpose of the improvement.

View File

@ -0,0 +1,46 @@
# Message 012
| Field | Value |
|-------|-------|
| From | mckicad-dev |
| To | timbre-phase1-project |
| Date | 2026-03-09T03:30:00Z |
| Re | Collision-aware stub clamping shipped |
---
## Fix shipped
Stubs now auto-shorten when they would bridge adjacent pins. The 7.62mm default stays as the *preferred* length, but `clamp_stub_length()` reduces it when a nearby obstacle is detected.
### How it works
Before placing each label stub, the system checks for obstacles in the stub's path:
1. **Obstacle sources**: All pin positions from the batch (labels, label_connections, power_symbols, no_connects) are pre-scanned at the start of `_apply_batch_operations()`. Previously placed wire stub endpoints are also included.
2. **Clamping logic**: For each proposed stub, check if any obstacle point lies within `clearance` (1.27mm) of the stub axis and within the stub's reach. If so, shorten to `obstacle_distance - clearance`, with a floor of 2.54mm (1 grid unit).
3. **Per-axis check**: Since KiCad stubs are always axis-aligned (0/90/180/270), collision detection is a simple 1D range check per axis, not a full 2D intersection test.
### Your three failure cases
| Case | What happens now |
|------|-----------------|
| R2 pull-up (+3V3 / SDA, same component) | Pin 2 position is an obstacle for pin 1's stub and vice versa. Both stubs shorten to avoid the other pin. |
| C7 (5.08mm, SK_INP / FILT_OUT) | Pin-to-pin distance < 7.62mm. Stubs from each pin are clamped to `(5.08 - 1.27) = 3.81mm`. |
| U2C (+5V / GND, 15.24mm apart) | Power symbol pin positions are in the obstacle list. Stubs shorten if the opposing stub endpoint is in range. |
### Explicit overrides still work
`"stub_length"` per-connection overrides bypass clamping if explicitly set (they're treated as the proposed length, then clamped against obstacles). To force a specific length regardless, reduce `clearance` via the API.
### Performance note
The pre-scan uses a resolve cache, so pin positions are computed once and reused during placement. No extra I/O cost.
### Test coverage
13 new unit tests for `clamp_stub_length()` covering all directions, minimum floor, custom clearance, multiple obstacles, and the small-passive/opposing-stubs scenarios.
348/348 pass, ruff + mypy clean.

View File

@ -341,6 +341,7 @@ def _apply_batch_operations(
from mckicad.patterns._geometry import add_power_symbol_to_pin
from mckicad.utils.sexp_parser import (
WireSegment,
clamp_stub_length,
compute_label_placement,
generate_global_label_sexp,
generate_label_sexp,
@ -362,6 +363,41 @@ def _apply_batch_operations(
collisions_resolved = 0
wire_collisions_resolved = 0
# Pre-scan: collect all pin positions that will be referenced by
# labels, power symbols, and no-connects. These serve as obstacles
# for stub length clamping — prevents stubs from bridging adjacent pins.
obstacle_points: list[tuple[float, float]] = []
_pin_cache: dict[tuple[str, str], dict[str, Any] | None] = {}
def _cached_resolve(ref: str, pin: str) -> dict[str, Any] | None:
key = (ref, pin)
if key not in _pin_cache:
_pin_cache[key] = resolve_pin_position_and_orientation(
sch, schematic_path, ref, pin,
)
return _pin_cache[key]
for _label in data.get("labels", []):
if "pin_ref" in _label:
_info = _cached_resolve(_label["pin_ref"], str(_label["pin_number"]))
if _info:
obstacle_points.append((_info["x"], _info["y"]))
for _lc in data.get("label_connections", []):
for _conn in _lc.get("connections", []):
_info = _cached_resolve(_conn["ref"], str(_conn["pin"]))
if _info:
obstacle_points.append((_info["x"], _info["y"]))
for _ps in data.get("power_symbols", []):
if "pin_ref" in _ps:
_info = _cached_resolve(_ps["pin_ref"], str(_ps["pin_number"]))
if _info:
obstacle_points.append((_info["x"], _info["y"]))
for _nc in data.get("no_connects", []):
if "pin_ref" in _nc:
_info = _cached_resolve(_nc["pin_ref"], str(_nc["pin_number"]))
if _info:
obstacle_points.append((_info["x"], _info["y"]))
# 1. Components (multi-unit supported natively by kicad-sch-api)
for comp in data.get("components", []):
ref = comp.get("reference")
@ -426,8 +462,8 @@ def _apply_batch_operations(
if "pin_ref" in label:
# Pin-referenced label: resolve position from component pin
pin_info = resolve_pin_position_and_orientation(
sch, schematic_path, label["pin_ref"], str(label["pin_number"]),
pin_info = _cached_resolve(
label["pin_ref"], str(label["pin_number"]),
)
if pin_info is None:
logger.warning(
@ -444,6 +480,16 @@ def _apply_batch_operations(
if dir_rot is not None:
pin_info = dict(pin_info, schematic_rotation=dir_rot)
# Clamp stub to avoid bridging adjacent pins
stub_obstacles = obstacle_points + [
pt for s in placed_wire_segments for pt in (s[0], s[1])
]
label_stub = clamp_stub_length(
pin_info["x"], pin_info["y"],
pin_info["schematic_rotation"],
label_stub, stub_obstacles,
)
placement = compute_label_placement(
pin_info["x"], pin_info["y"],
pin_info["schematic_rotation"],
@ -514,9 +560,7 @@ def _apply_batch_operations(
stub_len = lc.get("stub_length", LABEL_DEFAULTS["stub_length"])
for conn in lc.get("connections", []):
pin_info = resolve_pin_position_and_orientation(
sch, schematic_path, conn["ref"], str(conn["pin"]),
)
pin_info = _cached_resolve(conn["ref"], str(conn["pin"]))
if pin_info is None:
logger.warning(
"Skipping label_connection '%s': pin %s.%s not found",
@ -533,6 +577,16 @@ def _apply_batch_operations(
if dir_rot is not None:
pin_info = dict(pin_info, schematic_rotation=dir_rot)
# Clamp stub to avoid bridging adjacent pins
stub_obstacles = obstacle_points + [
pt for s in placed_wire_segments for pt in (s[0], s[1])
]
conn_stub = clamp_stub_length(
pin_info["x"], pin_info["y"],
pin_info["schematic_rotation"],
conn_stub, stub_obstacles,
)
placement = compute_label_placement(
pin_info["x"], pin_info["y"],
pin_info["schematic_rotation"],

View File

@ -1202,6 +1202,71 @@ def compute_label_placement(
}
def clamp_stub_length(
pin_x: float,
pin_y: float,
pin_schematic_rotation: float,
proposed_length: float,
obstacles: list[tuple[float, float]],
clearance: float = 1.27,
minimum: float = 2.54,
) -> float:
"""Shorten a label stub if it would collide with nearby obstacles.
Checks whether the proposed axis-aligned stub wire would pass through
or come within ``clearance`` of any obstacle point (pin positions,
wire endpoints). If so, shortens the stub to maintain clearance
while respecting a minimum length.
Args:
pin_x: Stub start X (pin tip position).
pin_y: Stub start Y (pin tip position).
pin_schematic_rotation: Pin body direction in degrees (0/90/180/270).
proposed_length: Desired stub length in mm.
obstacles: List of (x, y) points to avoid (other pins, wire endpoints).
clearance: Minimum gap between stub end and any obstacle (default 1.27mm).
minimum: Absolute minimum stub length (default 2.54mm = 1 grid unit).
Returns:
Safe stub length (between ``minimum`` and ``proposed_length``).
"""
rot = round(pin_schematic_rotation) % 360
safe_length = proposed_length
for ox, oy in obstacles:
if rot == 0:
# Stub goes left (negative X): check obstacles in that direction
if abs(oy - pin_y) > clearance:
continue
dist = pin_x - ox # positive if obstacle is to the left
if dist > 0 and dist < proposed_length + clearance:
safe_length = min(safe_length, dist - clearance)
elif rot == 90:
# Stub goes up (negative Y): check obstacles above
if abs(ox - pin_x) > clearance:
continue
dist = pin_y - oy # positive if obstacle is above
if dist > 0 and dist < proposed_length + clearance:
safe_length = min(safe_length, dist - clearance)
elif rot == 180:
# Stub goes right (positive X): check obstacles to the right
if abs(oy - pin_y) > clearance:
continue
dist = ox - pin_x # positive if obstacle is to the right
if dist > 0 and dist < proposed_length + clearance:
safe_length = min(safe_length, dist - clearance)
elif rot == 270:
# Stub goes down (positive Y): check obstacles below
if abs(ox - pin_x) > clearance:
continue
dist = oy - pin_y # positive if obstacle is below
if dist > 0 and dist < proposed_length + clearance:
safe_length = min(safe_length, dist - clearance)
return max(safe_length, minimum)
def resolve_label_collision(
label_x: float,
label_y: float,

View File

@ -9,6 +9,7 @@ import tempfile
import pytest
from mckicad.utils.sexp_parser import (
clamp_stub_length,
compute_label_placement,
fix_property_private_keywords,
generate_global_label_sexp,
@ -1065,6 +1066,108 @@ class TestComputeLabelPlacement:
assert result["stub_end_y"] == result["label_y"]
# ---------------------------------------------------------------------------
# clamp_stub_length tests
# ---------------------------------------------------------------------------
class TestClampStubLength:
"""Test collision-aware stub length clamping."""
def test_no_obstacles_returns_proposed(self):
"""No obstacles -> full proposed length."""
result = clamp_stub_length(100, 100, 180, 7.62, [])
assert result == 7.62
def test_obstacle_in_path_shortens_stub_right(self):
"""Obstacle 5mm to the right of pin with rot=180 (stub goes right)."""
result = clamp_stub_length(100, 100, 180, 7.62, [(105, 100)])
# Should shorten: 5.0 - 1.27 = 3.73
assert result == pytest.approx(3.73)
def test_obstacle_in_path_shortens_stub_left(self):
"""Obstacle 5mm to the left of pin with rot=0 (stub goes left)."""
result = clamp_stub_length(100, 100, 0, 7.62, [(95, 100)])
# Should shorten: 5.0 - 1.27 = 3.73
assert result == pytest.approx(3.73)
def test_obstacle_in_path_shortens_stub_up(self):
"""Obstacle 5mm above pin with rot=90 (stub goes up)."""
result = clamp_stub_length(100, 100, 90, 7.62, [(100, 95)])
assert result == pytest.approx(3.73)
def test_obstacle_in_path_shortens_stub_down(self):
"""Obstacle 5mm below pin with rot=270 (stub goes down)."""
result = clamp_stub_length(100, 100, 270, 7.62, [(100, 105)])
assert result == pytest.approx(3.73)
def test_obstacle_off_axis_ignored(self):
"""Obstacle far off the stub axis -> no clamping."""
# Stub goes right (rot=180), obstacle is 10mm above
result = clamp_stub_length(100, 100, 180, 7.62, [(105, 110)])
assert result == 7.62
def test_obstacle_behind_pin_ignored(self):
"""Obstacle behind the pin (opposite direction) -> no clamping."""
# Stub goes right (rot=180), obstacle is to the left
result = clamp_stub_length(100, 100, 180, 7.62, [(95, 100)])
assert result == 7.62
def test_minimum_floor(self):
"""Very close obstacle -> clamped to minimum, not zero."""
# Obstacle 1mm away, clearance 1.27 -> would give negative, clamped to 2.54
result = clamp_stub_length(100, 100, 180, 7.62, [(101, 100)])
assert result == 2.54
def test_small_passive_scenario(self):
"""Simulates a 5.08mm resistor with stubs on both pins."""
# Pin 1 at (100, 100), pin 2 at (105.08, 100)
# Pin 1 stub goes left (rot=0), pin 2 is to the right -> no collision
r1 = clamp_stub_length(100, 100, 0, 7.62, [(105.08, 100)])
assert r1 == 7.62 # pin 2 is behind, no collision
# Pin 2 stub goes right (rot=180), pin 1 is to the left -> no collision
r2 = clamp_stub_length(105.08, 100, 180, 7.62, [(100, 100)])
assert r2 == 7.62 # pin 1 is behind, no collision
def test_opposing_stubs_on_vertical_component(self):
"""Two pins on a vertical component with stubs pointing toward each other."""
# Pin 1 at (100, 100) stub goes down (rot=270)
# Pin 2 at (100, 115) is in the downward path
r1 = clamp_stub_length(100, 100, 270, 7.62, [(100, 115)])
# dist=15, but that's beyond 7.62, so no clamping
assert r1 == 7.62
# Now with closer spacing (5mm apart)
# Pin 1 at (100, 100) stub goes down, pin 2 at (100, 105)
r2 = clamp_stub_length(100, 100, 270, 7.62, [(100, 105)])
assert r2 == pytest.approx(3.73)
def test_custom_clearance(self):
"""Custom clearance value is respected."""
result = clamp_stub_length(
100, 100, 180, 7.62, [(105, 100)], clearance=2.54,
)
# 5.0 - 2.54 = 2.46, below minimum -> clamped to 2.54
assert result == 2.54
def test_custom_minimum(self):
"""Custom minimum value is respected."""
result = clamp_stub_length(
100, 100, 180, 7.62, [(101, 100)], minimum=1.27,
)
assert result == 1.27
def test_multiple_obstacles_uses_nearest(self):
"""Multiple obstacles -> shortest safe length wins."""
result = clamp_stub_length(
100, 100, 180, 7.62,
[(103, 100), (106, 100), (110, 100)],
)
# Nearest is 3mm away: 3.0 - 1.27 = 1.73, below minimum -> 2.54
assert result == 2.54
# ---------------------------------------------------------------------------
# generate_wire_sexp tests
# ---------------------------------------------------------------------------