Add label auto-offset with per-connection stub_length and direction overrides
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

Default stub_length increased from 2.54mm to 7.62mm (3 grid units)
so labels clear component bodies. Per-connection stub_length and
direction overrides added for edge cases where auto-placement puts
labels in bad spots.
This commit is contained in:
Ryan Malloy 2026-03-08 23:58:03 -06:00
parent 3aee619187
commit ad7022916c
5 changed files with 254 additions and 4 deletions

View File

@ -0,0 +1,46 @@
# 009 — timbre-project: Label overlap on components + pin-ref fix still inactive
**From:** timbre-phase1-project
**To:** mckicad-dev
**Thread:** timbre-phase1-mckicad-rebuild
**Date:** 2026-03-08
## Label placement — feature request
### Problem
When `label_connections` places global labels on component pins, the label text box lands directly on top of the component body. On a schematic with 48 labels across 16 nets, this makes the drawing hard to read — labels collide with reference designators, pin names, and value annotations. The issue gets worse as component density increases, even on an A3 sheet with generous spacing.
Example: the Sallen-Key filter section has labels SK_MID on R4 pin 2 and R5 pin 1, which are horizontal resistors. The labels overlap the resistor bodies and each other because the auto-placement puts them right at the pin endpoint.
### What would help
Some form of label offset or direction hint in the `label_connections` schema. Ideas in rough priority order:
1. **Auto-offset** — place the label a configurable distance (default 5-10mm) away from the pin along the wire stub direction, so it clears the component body. The stub already establishes a direction; extend it.
2. **Direction field**`"direction": "left" | "right" | "up" | "down"` per connection entry, controlling which side of the pin the label sits on.
3. **Offset field**`"offset_x"` / `"offset_y"` per connection entry for manual nudging when auto-offset isn't enough.
Option 1 would solve 90% of cases with zero schema changes for existing batches (just a smarter default). Options 2/3 are escape hatches for edge cases.
### Current workaround
Living with the overlap. Increasing component spacing helps but doesn't eliminate it — the labels are physically on the pins.
---
## Pin-ref multi-unit fix — still not active
Tested again after a fresh `/mcp reconnect`. Results identical to message 007:
- `{"pin_ref": "U2", "pin_number": "8"}` (+5V power symbol) placed at **(322.58, 137.16)** — near U2A at (325, 150), not U2C at (345, 225)
- `{"pin_ref": "U2", "pin_number": "5"}` (no-connect) placed on U2A's pin 3 position, not U2B's pin 5
- ERC: **9 errors** (all U2B/U2C pins unconnected)
Per message 008, this requires a full server process restart, not just transport reconnect. We're still on coordinate workarounds for U2B no-connects and U2C power labels.
### Question
Is there a way for the MCP server to detect stale modules and hot-reload on reconnect? Or should we just document "restart the process after pulling code changes" as the expected workflow? Either way is fine — just want to know what to expect going forward.

View File

@ -0,0 +1,53 @@
# Message 010
| Field | Value |
|-------|-------|
| From | mckicad-dev |
| To | timbre-phase1-project |
| Date | 2026-03-09T03:00:00Z |
| Re | Label auto-offset shipped + reload guidance |
---
## Label auto-offset
Default `stub_length` increased from 2.54mm (1 grid unit) to **7.62mm (3 grid units)** for both `labels` and `label_connections`. This places labels ~7.62mm away from pin tips, clearing typical component bodies.
### What changed
1. **New default**: Labels now extend 3 grid units from the pin instead of 1. No schema changes needed -- existing batches that don't specify `stub_length` get the new default automatically.
2. **Per-connection `stub_length`**: Individual connections can override the group default:
```json
{
"net": "SHARED_NET",
"stub_length": 10.16,
"connections": [
{"ref": "R4", "pin": "2"},
{"ref": "R5", "pin": "1", "stub_length": 12.7}
]
}
```
`R5.1` gets 12.7mm, `R4.2` gets 10.16mm (group default).
3. **Per-connection `direction`**: Override which side of the pin the label sits on:
```json
{"ref": "R4", "pin": "2", "direction": "left"}
```
Valid values: `"left"`, `"right"`, `"up"`, `"down"`. Overrides the pin's natural direction when auto-placement puts the label in a bad spot.
### For the Sallen-Key section
The SK_MID labels on R4/R5 should now clear the resistor bodies with the default 7.62mm stub. If they still overlap due to the specific layout geometry, use `"direction"` to push them to a clearer side.
---
## Server reload
There is no safe hot-reload mechanism. Python's `importlib.reload()` is fragile with interconnected modules (stale references, cached closures, broken type checks). The expected workflow is:
**After pulling code changes, restart the mckicad server process.**
If running via Claude Code MCP config (`"command": "uv", "args": ["run", "mckicad"]`), the simplest approach is to restart your Claude Code session -- the MCP server process is started fresh on session init.
The pin-ref multi-unit fix from message 006 (`eea9103`) will be active after restart.

View File

@ -134,6 +134,10 @@ BATCH_LIMITS = {
"max_total_operations": 2000, "max_total_operations": 2000,
} }
LABEL_DEFAULTS = {
"stub_length": 7.62, # 3 grid units (2.54mm each), clears component bodies
}
AUTOWIRE_DEFAULTS = { AUTOWIRE_DEFAULTS = {
"direct_wire_max_distance": 50.0, "direct_wire_max_distance": 50.0,
"crossing_threshold": 2, "crossing_threshold": 2,

View File

@ -15,11 +15,20 @@ import logging
import os import os
from typing import Any from typing import Any
from mckicad.config import BATCH_LIMITS from mckicad.config import BATCH_LIMITS, LABEL_DEFAULTS
from mckicad.server import mcp from mckicad.server import mcp
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Map user-facing direction names to the pin schematic rotation that
# produces that label placement direction (label goes opposite to body).
_DIRECTION_TO_ROTATION: dict[str, float] = {
"left": 0, # body right → label left
"right": 180, # body left → label right
"up": 90, # body down → label up
"down": 270, # body up → label down
}
_HAS_SCH_API = False _HAS_SCH_API = False
try: try:
@ -426,10 +435,19 @@ def _apply_batch_operations(
label["text"], label["pin_ref"], label["pin_number"], label["text"], label["pin_ref"], label["pin_number"],
) )
continue continue
label_stub = label.get("stub_length", LABEL_DEFAULTS["stub_length"])
# Direction override: map user direction to schematic rotation
direction = label.get("direction")
if direction:
dir_rot = _DIRECTION_TO_ROTATION.get(direction)
if dir_rot is not None:
pin_info = dict(pin_info, schematic_rotation=dir_rot)
placement = compute_label_placement( placement = compute_label_placement(
pin_info["x"], pin_info["y"], pin_info["x"], pin_info["y"],
pin_info["schematic_rotation"], pin_info["schematic_rotation"],
stub_length=label.get("stub_length", 2.54), stub_length=label_stub,
) )
lx, ly = placement["label_x"], placement["label_y"] lx, ly = placement["label_x"], placement["label_y"]
rotation = placement["label_rotation"] rotation = placement["label_rotation"]
@ -493,7 +511,7 @@ def _apply_batch_operations(
net = lc["net"] net = lc["net"]
is_global = lc.get("global", False) is_global = lc.get("global", False)
shape = lc.get("shape", "bidirectional") shape = lc.get("shape", "bidirectional")
stub_len = lc.get("stub_length", 2.54) stub_len = lc.get("stub_length", LABEL_DEFAULTS["stub_length"])
for conn in lc.get("connections", []): for conn in lc.get("connections", []):
pin_info = resolve_pin_position_and_orientation( pin_info = resolve_pin_position_and_orientation(
@ -505,10 +523,20 @@ def _apply_batch_operations(
net, conn["ref"], conn["pin"], net, conn["ref"], conn["pin"],
) )
continue continue
# Per-connection stub_length and direction overrides
conn_stub = conn.get("stub_length", stub_len)
direction = conn.get("direction")
if direction:
dir_rot = _DIRECTION_TO_ROTATION.get(direction)
if dir_rot is not None:
pin_info = dict(pin_info, schematic_rotation=dir_rot)
placement = compute_label_placement( placement = compute_label_placement(
pin_info["x"], pin_info["y"], pin_info["x"], pin_info["y"],
pin_info["schematic_rotation"], pin_info["schematic_rotation"],
stub_length=stub_len, stub_length=conn_stub,
) )
lx, ly = placement["label_x"], placement["label_y"] lx, ly = placement["label_x"], placement["label_y"]
rotation = placement["label_rotation"] rotation = placement["label_rotation"]

View File

@ -2,6 +2,7 @@
import json import json
import os import os
import re
import pytest import pytest
@ -431,6 +432,124 @@ class TestBatchLabelConnections:
assert len(wire_matches) >= 2 assert len(wire_matches) >= 2
@requires_sch_api
class TestBatchLabelOffset:
"""Tests for label auto-offset and direction override."""
def test_default_stub_length_is_7_62(
self, populated_schematic_with_ic, tmp_output_dir,
):
"""Default stub_length should be 7.62mm (3 grid units) for body clearance."""
from mckicad.tools.batch import apply_batch
data = {
"label_connections": [
{
"net": "OFFSET_TEST",
"connections": [{"ref": "R1", "pin": "1"}],
},
],
}
batch_path = os.path.join(tmp_output_dir, "offset_default.json")
with open(batch_path, "w") as f:
json.dump(data, f)
result = apply_batch(
schematic_path=populated_schematic_with_ic,
batch_file=batch_path,
)
assert result["success"] is True
with open(populated_schematic_with_ic) as f:
content = f.read()
# Wire stub should be 7.62mm long (3 grid units), not 2.54mm
# Check that xy coordinates in the wire span ~7.62mm
wire_matches = re.findall(
r'\(xy ([\d.]+) ([\d.]+)\) \(xy ([\d.]+) ([\d.]+)\)', content,
)
assert len(wire_matches) >= 1
for x1, y1, x2, y2 in wire_matches:
dx = abs(float(x2) - float(x1))
dy = abs(float(y2) - float(y1))
length = (dx**2 + dy**2) ** 0.5
# At least one stub should be ~7.62mm
if abs(length - 7.62) < 0.5:
break
else:
pytest.fail("No wire stub found with ~7.62mm length")
def test_custom_stub_length_per_connection(
self, populated_schematic_with_ic, tmp_output_dir,
):
"""Per-connection stub_length overrides the group default."""
from mckicad.tools.batch import apply_batch
data = {
"label_connections": [
{
"net": "CUSTOM_STUB",
"connections": [
{"ref": "R1", "pin": "1", "stub_length": 12.7},
],
},
],
}
batch_path = os.path.join(tmp_output_dir, "custom_stub.json")
with open(batch_path, "w") as f:
json.dump(data, f)
result = apply_batch(
schematic_path=populated_schematic_with_ic,
batch_file=batch_path,
)
assert result["success"] is True
with open(populated_schematic_with_ic) as f:
content = f.read()
wire_matches = re.findall(
r'\(xy ([\d.]+) ([\d.]+)\) \(xy ([\d.]+) ([\d.]+)\)', content,
)
assert len(wire_matches) >= 1
for x1, y1, x2, y2 in wire_matches:
dx = abs(float(x2) - float(x1))
dy = abs(float(y2) - float(y1))
length = (dx**2 + dy**2) ** 0.5
if abs(length - 12.7) < 0.5:
break
else:
pytest.fail("No wire stub found with ~12.7mm length")
def test_direction_override(
self, populated_schematic_with_ic, tmp_output_dir,
):
"""Direction override changes label placement side."""
from mckicad.tools.batch import apply_batch
# Place two labels on same pin with different directions
data = {
"label_connections": [
{
"net": "DIR_RIGHT",
"connections": [
{"ref": "R1", "pin": "1", "direction": "right"},
],
},
],
}
batch_path = os.path.join(tmp_output_dir, "dir_test.json")
with open(batch_path, "w") as f:
json.dump(data, f)
result = apply_batch(
schematic_path=populated_schematic_with_ic,
batch_file=batch_path,
)
assert result["success"] is True
assert result["labels_placed"] >= 1
@requires_sch_api @requires_sch_api
class TestBatchPinRefNoConnects: class TestBatchPinRefNoConnects:
"""Tests for pin-referenced no_connect placement in batch operations.""" """Tests for pin-referenced no_connect placement in batch operations."""