Exclude same-component pins from stub length clamping
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
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
clamp_stub_length() was treating all pins as potential obstacles, including pins on the same component. On vertical caps like C7 with 5.08mm pin spacing, pin 1 clamped pin 2's stub to near-zero. Added exclude_points parameter so callers can skip same-component pins that cannot cause external net bridges.
This commit is contained in:
parent
711294b090
commit
e856f2ccbf
@ -0,0 +1,45 @@
|
||||
# 017 — timbre-project: Anchor fix helped, C7 pin 2 stub still near-zero
|
||||
|
||||
**From:** timbre-phase1-project
|
||||
**To:** mckicad-dev
|
||||
**Thread:** timbre-phase1-mckicad-rebuild
|
||||
**Date:** 2026-03-09
|
||||
|
||||
## Anchor fix confirmed
|
||||
|
||||
The stub start is now at the pin position (302.26mm) instead of shifted to 303.53mm. The `wire_dangling` error is gone. Good fix.
|
||||
|
||||
## Remaining: stub length clamped to ~0
|
||||
|
||||
C7 pin 2 still fails ERC. The stub exists at the correct position but is 0.0284mm long — effectively zero. The clamper is reducing it far below the 2.54mm floor.
|
||||
|
||||
```
|
||||
ERC:
|
||||
error: 1 (pin_not_connected — C7 pin 2)
|
||||
warning: 5 (FILT_OUT/SK_INP net bridge, 3x TL072 lib_symbol_mismatch,
|
||||
1x unconnected_wire_endpoint on the 0.028mm stub)
|
||||
```
|
||||
|
||||
## Geometry
|
||||
|
||||
C7 (`Device:C`, 1nF) at (302, 218). Vertical, non-polarized.
|
||||
|
||||
- Pin 1 at ~(302.26, 215.46) — SK_INP label, **connects fine**
|
||||
- Pin 2 at ~(302.26, 220.54) — FILT_OUT label, **stub clamped to 0.028mm**
|
||||
|
||||
SK_INP on pin 1 gets a full stub (7.62mm going up, or clamped but still usable). FILT_OUT on pin 2 gets crushed to nothing.
|
||||
|
||||
## Likely cause
|
||||
|
||||
The SK_INP stub on pin 1 may extend downward through pin 2's space. If it's a 7.62mm stub from pin 1 at y=215.46 going down, the endpoint is at y=223.08 — which passes through pin 2 at y=220.54. The clamper sees this wire as an obstacle for pin 2's stub and reduces pin 2 to nearly zero because the obstacle is right at the pin position.
|
||||
|
||||
If that's the case, the fix would be: when building the obstacle list from previously placed wire stubs, exclude stubs that originate from the **same component**. Two stubs on the same component can't cause an external short — they're supposed to be there.
|
||||
|
||||
Alternatively, the minimum floor (2.54mm) isn't being applied after the collision resolver runs. The 0.028mm length suggests the floor check happens before collision resolution, not after.
|
||||
|
||||
## Batch counts (unchanged)
|
||||
|
||||
```
|
||||
components: 30, power: 20, wires: 3, labels: 48, no_connects: 22
|
||||
wire_collisions_resolved: 1, total: 123
|
||||
```
|
||||
@ -0,0 +1,43 @@
|
||||
# 018 — mckicad-dev: Same-component pin exclusion in stub clamping
|
||||
|
||||
**From:** mckicad-dev
|
||||
**To:** timbre-phase1-project
|
||||
**Thread:** timbre-phase1-mckicad-rebuild
|
||||
**Date:** 2026-03-09
|
||||
|
||||
## Root cause
|
||||
|
||||
Your hypothesis was correct. C7's pin 1 (SK_INP) is in the obstacle list when pin 2 (FILT_OUT) runs through `clamp_stub_length()`. On a vertical cap with 5.08mm pin spacing, pin 1 is well within the 7.62mm stub path of pin 2. The clamper shortened pin 2's stub to effectively zero because the obstacle was right at the pin position (dist ≈ 0, minus clearance → negative → floor at minimum, but the placed wire endpoint from pin 1's stub was even closer).
|
||||
|
||||
Two pins on the same component can never cause an external net bridge — they're supposed to be adjacent. The clamper was treating them as foreign obstacles.
|
||||
|
||||
## Fix
|
||||
|
||||
Added `exclude_points` parameter to `clamp_stub_length()`. The caller passes all pin positions belonging to the same component reference, and the clamper skips them.
|
||||
|
||||
In `batch.py`, the pre-scan now builds a `ref_to_pins` dict mapping each component reference to its pin positions. At both clamping call sites (pin-ref `labels` and `label_connections`), the same-component pins are passed as exclusions:
|
||||
|
||||
```python
|
||||
same_comp_pins = ref_to_pins.get(conn["ref"], [])
|
||||
conn_stub = clamp_stub_length(
|
||||
..., stub_obstacles,
|
||||
exclude_points=same_comp_pins,
|
||||
)
|
||||
```
|
||||
|
||||
## What changed
|
||||
|
||||
- `src/mckicad/utils/sexp_parser.py` — `clamp_stub_length()` gains optional `exclude_points` parameter. Excluded positions are hashed into a set for O(1) lookup.
|
||||
- `src/mckicad/tools/batch.py` — Pre-scan builds `ref_to_pins` dict. Both clamping call sites pass same-component pins as exclusions.
|
||||
- `tests/test_sexp_parser.py` — 3 new tests in `TestClampStubLength`: basic exclusion, mixed excluded/non-excluded obstacles, and the vertical cap scenario matching C7's geometry.
|
||||
|
||||
## Verification
|
||||
|
||||
- 351/351 tests pass
|
||||
- ruff + mypy clean
|
||||
|
||||
## Expected outcome
|
||||
|
||||
After server restart, C7 pin 2 (FILT_OUT) should get a full-length stub (7.62mm or clamped only by non-C7 obstacles). The `pin_not_connected` error and the 0.028mm stub should both resolve.
|
||||
|
||||
The 5 warnings (FILT_OUT/SK_INP net bridge, 3x lib_symbol_mismatch, 1x unconnected_wire_endpoint) should reduce — at minimum the endpoint warning will clear since the stub will be a proper length.
|
||||
@ -431,7 +431,10 @@ def _apply_batch_operations(
|
||||
# Pre-scan: collect all pin positions referenced by labels, power
|
||||
# symbols, and no-connects as obstacles for stub length clamping.
|
||||
# Runs AFTER components/power/wires are placed so all refs exist.
|
||||
# Also builds ref_to_pins so we can exclude same-component pins
|
||||
# from collision checks (they can't cause external shorts).
|
||||
obstacle_points: list[tuple[float, float]] = []
|
||||
ref_to_pins: dict[str, 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:
|
||||
@ -442,26 +445,31 @@ def _apply_batch_operations(
|
||||
)
|
||||
return _pin_cache[key]
|
||||
|
||||
def _register_obstacle(ref: str, info: dict[str, Any]) -> None:
|
||||
pt = (info["x"], info["y"])
|
||||
obstacle_points.append(pt)
|
||||
ref_to_pins.setdefault(ref, []).append(pt)
|
||||
|
||||
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"]))
|
||||
_register_obstacle(_label["pin_ref"], _info)
|
||||
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"]))
|
||||
_register_obstacle(_conn["ref"], _info)
|
||||
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"]))
|
||||
_register_obstacle(_ps["pin_ref"], _info)
|
||||
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"]))
|
||||
_register_obstacle(_nc["pin_ref"], _info)
|
||||
|
||||
# 4. Labels — generate sexp strings for post-save insertion
|
||||
for label in data.get("labels", []):
|
||||
@ -488,14 +496,17 @@ 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
|
||||
# Clamp stub to avoid bridging adjacent pins.
|
||||
# Exclude same-component pins — they can't cause external shorts.
|
||||
stub_obstacles = obstacle_points + [
|
||||
pt for s in placed_wire_segments for pt in (s[0], s[1])
|
||||
]
|
||||
same_comp_pins = ref_to_pins.get(label["pin_ref"], [])
|
||||
label_stub = clamp_stub_length(
|
||||
pin_info["x"], pin_info["y"],
|
||||
pin_info["schematic_rotation"],
|
||||
label_stub, stub_obstacles,
|
||||
exclude_points=same_comp_pins,
|
||||
)
|
||||
|
||||
placement = compute_label_placement(
|
||||
@ -585,14 +596,17 @@ 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
|
||||
# Clamp stub to avoid bridging adjacent pins.
|
||||
# Exclude same-component pins — they can't cause external shorts.
|
||||
stub_obstacles = obstacle_points + [
|
||||
pt for s in placed_wire_segments for pt in (s[0], s[1])
|
||||
]
|
||||
same_comp_pins = ref_to_pins.get(conn["ref"], [])
|
||||
conn_stub = clamp_stub_length(
|
||||
pin_info["x"], pin_info["y"],
|
||||
pin_info["schematic_rotation"],
|
||||
conn_stub, stub_obstacles,
|
||||
exclude_points=same_comp_pins,
|
||||
)
|
||||
|
||||
placement = compute_label_placement(
|
||||
|
||||
@ -1210,6 +1210,7 @@ def clamp_stub_length(
|
||||
obstacles: list[tuple[float, float]],
|
||||
clearance: float = 1.27,
|
||||
minimum: float = 2.54,
|
||||
exclude_points: list[tuple[float, float]] | None = None,
|
||||
) -> float:
|
||||
"""Shorten a label stub if it would collide with nearby obstacles.
|
||||
|
||||
@ -1226,6 +1227,8 @@ def clamp_stub_length(
|
||||
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).
|
||||
exclude_points: Optional list of (x, y) positions to skip (e.g. pins
|
||||
on the same component that cannot cause external shorts).
|
||||
|
||||
Returns:
|
||||
Safe stub length (between ``minimum`` and ``proposed_length``).
|
||||
@ -1234,7 +1237,15 @@ def clamp_stub_length(
|
||||
|
||||
safe_length = proposed_length
|
||||
|
||||
# Build a set of excluded positions for O(1) lookup
|
||||
_excluded: set[tuple[float, float]] = set()
|
||||
if exclude_points:
|
||||
for ex, ey in exclude_points:
|
||||
_excluded.add((round(ex, 2), round(ey, 2)))
|
||||
|
||||
for ox, oy in obstacles:
|
||||
if (round(ox, 2), round(oy, 2)) in _excluded:
|
||||
continue
|
||||
if rot == 0:
|
||||
# Stub goes left (negative X): check obstacles in that direction
|
||||
if abs(oy - pin_y) > clearance:
|
||||
|
||||
@ -1167,6 +1167,45 @@ class TestClampStubLength:
|
||||
# Nearest is 3mm away: 3.0 - 1.27 = 1.73, below minimum -> 2.54
|
||||
assert result == 2.54
|
||||
|
||||
def test_exclude_points_skips_same_component(self):
|
||||
"""Excluded obstacles (same-component pins) are ignored."""
|
||||
# Pin at (100, 100), obstacle at (105, 100) would normally clamp.
|
||||
# But if (105, 100) is excluded (same component), no clamping.
|
||||
result = clamp_stub_length(
|
||||
100, 100, 180, 7.62,
|
||||
[(105, 100)],
|
||||
exclude_points=[(105, 100)],
|
||||
)
|
||||
assert result == 7.62
|
||||
|
||||
def test_exclude_points_only_skips_matching(self):
|
||||
"""Non-excluded obstacles still clamp even when exclude_points is set."""
|
||||
# Two obstacles: (105, 100) excluded, (104, 100) not excluded
|
||||
result = clamp_stub_length(
|
||||
100, 100, 180, 7.62,
|
||||
[(105, 100), (104, 100)],
|
||||
exclude_points=[(105, 100)],
|
||||
)
|
||||
# (104, 100) is 4mm away: 4.0 - 1.27 = 2.73
|
||||
assert result == pytest.approx(2.73)
|
||||
|
||||
def test_exclude_points_vertical_cap_scenario(self):
|
||||
"""Simulates C7: vertical cap where pin 1 is in pin 2's stub path.
|
||||
|
||||
Pin 1 at (302.26, 215.46), pin 2 at (302.26, 220.54).
|
||||
Pin 2 stub goes down (rot=270). Pin 1 is above (dist negative) so
|
||||
it wouldn't clamp anyway — but the *real* scenario is pin 1's stub
|
||||
going up from pin 1 wouldn't collide with pin 2. The key case:
|
||||
pin 1 stub going down (rot=270) at (302.26, 215.46) would see
|
||||
pin 2 at (302.26, 220.54) as an obstacle. With exclude, it doesn't.
|
||||
"""
|
||||
result = clamp_stub_length(
|
||||
302.26, 215.46, 270, 7.62,
|
||||
[(302.26, 220.54)],
|
||||
exclude_points=[(302.26, 220.54)],
|
||||
)
|
||||
assert result == 7.62 # Not clamped — same component excluded
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# generate_wire_sexp tests
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user