feat: add snap_to_grid schematic tool
Adds a new MCP tool that snaps wire endpoints, junction positions, and net label coordinates to the nearest grid point (default 2.54 mm). Off-grid coordinates cause wires that appear visually connected to fail ERC checks because KiCAD uses exact IU integer matching internally; this tool eliminates that class of error before running ERC. - python/commands/schematic_snap.py: core snap logic with in-place sexp mutation - python/kicad_interface.py: route + handler - python/schemas/tool_schemas.py: JSON schema (gridSize, elements params) - src/tools/schematic.ts: TypeScript MCP tool registration - tests/test_snap_to_grid.py: 20 unit + integration tests (all passing) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
199
python/commands/schematic_snap.py
Normal file
199
python/commands/schematic_snap.py
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
"""
|
||||||
|
Snap-to-grid tool for KiCAD schematics.
|
||||||
|
|
||||||
|
Snaps wire endpoints, junction positions, net labels, and optionally component
|
||||||
|
positions to the nearest grid point. Modifies the schematic file in place.
|
||||||
|
|
||||||
|
The standard KiCAD eeschema grid is 2.54 mm (0.1 inch). Off-grid coordinates
|
||||||
|
cause wires that appear visually connected to fail ERC connectivity checks
|
||||||
|
because KiCAD uses exact integer (IU) matching internally.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import sexpdata
|
||||||
|
from sexpdata import Symbol
|
||||||
|
|
||||||
|
logger = logging.getLogger("kicad_interface")
|
||||||
|
|
||||||
|
_DEFAULT_GRID_MM: float = 2.54
|
||||||
|
|
||||||
|
# Element type names exposed in the public API
|
||||||
|
_VALID_ELEMENTS = frozenset({"wires", "junctions", "labels", "components"})
|
||||||
|
|
||||||
|
# Tags treated as net labels (all have (at x y angle) structure)
|
||||||
|
_LABEL_TAGS = frozenset(
|
||||||
|
{
|
||||||
|
Symbol("label"),
|
||||||
|
Symbol("global_label"),
|
||||||
|
Symbol("hierarchical_label"),
|
||||||
|
Symbol("net_tie"),
|
||||||
|
Symbol("no_connect"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _snap_mm(value: float, grid_mm: float) -> float:
|
||||||
|
"""Snap a single coordinate to the nearest grid multiple."""
|
||||||
|
return round(value / grid_mm) * grid_mm
|
||||||
|
|
||||||
|
|
||||||
|
def _is_on_grid(value: float, grid_mm: float, eps: float = 1e-9) -> bool:
|
||||||
|
"""Return True if *value* is already within *eps* of a grid point."""
|
||||||
|
snapped = _snap_mm(value, grid_mm)
|
||||||
|
return abs(value - snapped) < eps
|
||||||
|
|
||||||
|
|
||||||
|
def _snap_xy_pair(item: list, grid_mm: float) -> int:
|
||||||
|
"""
|
||||||
|
Snap a ``(xy x y)`` S-expression item in place.
|
||||||
|
Returns 1 if at least one coordinate changed, 0 otherwise.
|
||||||
|
"""
|
||||||
|
if not (isinstance(item, list) and len(item) >= 3 and item[0] == Symbol("xy")):
|
||||||
|
return 0
|
||||||
|
x_orig, y_orig = float(item[1]), float(item[2])
|
||||||
|
x_new = _snap_mm(x_orig, grid_mm)
|
||||||
|
y_new = _snap_mm(y_orig, grid_mm)
|
||||||
|
changed = not (_is_on_grid(x_orig, grid_mm) and _is_on_grid(y_orig, grid_mm))
|
||||||
|
item[1] = x_new
|
||||||
|
item[2] = y_new
|
||||||
|
return 1 if changed else 0
|
||||||
|
|
||||||
|
|
||||||
|
def _snap_at_xy(item: list, grid_mm: float) -> int:
|
||||||
|
"""
|
||||||
|
Snap an ``(at x y ...)`` S-expression item in place (indices 1 and 2 only).
|
||||||
|
Preserves rotation / angle at index 3+ unchanged.
|
||||||
|
Returns 1 if at least one coordinate changed, 0 otherwise.
|
||||||
|
"""
|
||||||
|
if not (isinstance(item, list) and len(item) >= 3 and item[0] == Symbol("at")):
|
||||||
|
return 0
|
||||||
|
x_orig, y_orig = float(item[1]), float(item[2])
|
||||||
|
x_new = _snap_mm(x_orig, grid_mm)
|
||||||
|
y_new = _snap_mm(y_orig, grid_mm)
|
||||||
|
changed = not (_is_on_grid(x_orig, grid_mm) and _is_on_grid(y_orig, grid_mm))
|
||||||
|
item[1] = x_new
|
||||||
|
item[2] = y_new
|
||||||
|
return 1 if changed else 0
|
||||||
|
|
||||||
|
|
||||||
|
def snap_to_grid(
|
||||||
|
schematic_path: Path,
|
||||||
|
grid_size: float = _DEFAULT_GRID_MM,
|
||||||
|
elements: Optional[List[str]] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Snap element coordinates in a ``.kicad_sch`` file to the nearest grid point.
|
||||||
|
|
||||||
|
Modifies the file in place and returns statistics.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schematic_path: Path to the ``.kicad_sch`` file.
|
||||||
|
grid_size: Grid spacing in mm (default 2.54 mm = 0.1 inch).
|
||||||
|
elements: List of element types to snap. Valid values:
|
||||||
|
``"wires"``, ``"junctions"``, ``"labels"``,
|
||||||
|
``"components"``. Defaults to
|
||||||
|
``["wires", "junctions", "labels"]`` when ``None``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``{"snapped": int, "already_on_grid": int, "grid_size": float}``
|
||||||
|
where *snapped* is the number of elements that had at least one
|
||||||
|
coordinate moved.
|
||||||
|
"""
|
||||||
|
if grid_size <= 0:
|
||||||
|
raise ValueError(f"grid_size must be positive, got {grid_size}")
|
||||||
|
|
||||||
|
if elements is None:
|
||||||
|
active: frozenset = frozenset({"wires", "junctions", "labels"})
|
||||||
|
else:
|
||||||
|
unknown = set(elements) - _VALID_ELEMENTS
|
||||||
|
if unknown:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown element type(s): {sorted(unknown)}. "
|
||||||
|
f"Valid types: {sorted(_VALID_ELEMENTS)}"
|
||||||
|
)
|
||||||
|
active = frozenset(elements)
|
||||||
|
|
||||||
|
with open(schematic_path, "r", encoding="utf-8") as fh:
|
||||||
|
sch_data = sexpdata.loads(fh.read())
|
||||||
|
|
||||||
|
snapped = 0
|
||||||
|
already_on_grid = 0
|
||||||
|
|
||||||
|
snap_wires = "wires" in active
|
||||||
|
snap_junctions = "junctions" in active
|
||||||
|
snap_labels = "labels" in active
|
||||||
|
snap_components = "components" in active
|
||||||
|
|
||||||
|
for item in sch_data:
|
||||||
|
if not isinstance(item, list) or not item:
|
||||||
|
continue
|
||||||
|
tag = item[0]
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
# Wires: (wire (pts (xy x y) (xy x y)) ...)
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
if snap_wires and tag == Symbol("wire"):
|
||||||
|
changed = 0
|
||||||
|
for sub in item[1:]:
|
||||||
|
if isinstance(sub, list) and sub and sub[0] == Symbol("pts"):
|
||||||
|
for pt in sub[1:]:
|
||||||
|
changed += _snap_xy_pair(pt, grid_size)
|
||||||
|
if changed:
|
||||||
|
snapped += 1
|
||||||
|
else:
|
||||||
|
already_on_grid += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
# Junctions: (junction (at x y) ...)
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
if snap_junctions and tag == Symbol("junction"):
|
||||||
|
changed = 0
|
||||||
|
for sub in item[1:]:
|
||||||
|
changed += _snap_at_xy(sub, grid_size)
|
||||||
|
if changed:
|
||||||
|
snapped += 1
|
||||||
|
else:
|
||||||
|
already_on_grid += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
# Labels: (label|global_label|hierarchical_label|no_connect … (at x y angle) …)
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
if snap_labels and tag in _LABEL_TAGS:
|
||||||
|
changed = 0
|
||||||
|
for sub in item[1:]:
|
||||||
|
changed += _snap_at_xy(sub, grid_size)
|
||||||
|
if changed:
|
||||||
|
snapped += 1
|
||||||
|
else:
|
||||||
|
already_on_grid += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
# Components: (symbol (lib_id …) (at x y rotation) …)
|
||||||
|
# Snap only the top-level (at …) — not property sub-positions.
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
if snap_components and tag == Symbol("symbol"):
|
||||||
|
changed = 0
|
||||||
|
for sub in item[1:]:
|
||||||
|
if isinstance(sub, list) and sub and sub[0] == Symbol("at"):
|
||||||
|
changed += _snap_at_xy(sub, grid_size)
|
||||||
|
break # only the first (at …) belongs to the symbol itself
|
||||||
|
if changed:
|
||||||
|
snapped += 1
|
||||||
|
else:
|
||||||
|
already_on_grid += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
with open(schematic_path, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(sexpdata.dumps(sch_data))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"snapped": snapped,
|
||||||
|
"already_on_grid": already_on_grid,
|
||||||
|
"grid_size": grid_size,
|
||||||
|
}
|
||||||
@@ -404,6 +404,7 @@ class KiCADInterface:
|
|||||||
"get_elements_in_region": self._handle_get_elements_in_region,
|
"get_elements_in_region": self._handle_get_elements_in_region,
|
||||||
"find_wires_crossing_symbols": self._handle_find_wires_crossing_symbols,
|
"find_wires_crossing_symbols": self._handle_find_wires_crossing_symbols,
|
||||||
"find_orphaned_wires": self._handle_find_orphaned_wires,
|
"find_orphaned_wires": self._handle_find_orphaned_wires,
|
||||||
|
"snap_to_grid": self._handle_snap_to_grid,
|
||||||
"import_svg_logo": self._handle_import_svg_logo,
|
"import_svg_logo": self._handle_import_svg_logo,
|
||||||
# UI/Process management commands
|
# UI/Process management commands
|
||||||
"check_kicad_ui": self._handle_check_kicad_ui,
|
"check_kicad_ui": self._handle_check_kicad_ui,
|
||||||
@@ -2962,6 +2963,38 @@ class KiCADInterface:
|
|||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
return {"success": False, "message": str(e)}
|
return {"success": False, "message": str(e)}
|
||||||
|
|
||||||
|
def _handle_snap_to_grid(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Snap schematic element coordinates to the nearest grid point"""
|
||||||
|
logger.info("Snapping schematic elements to grid")
|
||||||
|
try:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from commands.schematic_snap import snap_to_grid
|
||||||
|
|
||||||
|
schematic_path = params.get("schematicPath")
|
||||||
|
if not schematic_path:
|
||||||
|
return {"success": False, "message": "schematicPath is required"}
|
||||||
|
|
||||||
|
grid_size = float(params.get("gridSize", 2.54))
|
||||||
|
elements = params.get("elements") # None → defaults inside snap_to_grid
|
||||||
|
|
||||||
|
result = snap_to_grid(Path(schematic_path), grid_size=grid_size, elements=elements)
|
||||||
|
total = result["snapped"] + result["already_on_grid"]
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
**result,
|
||||||
|
"message": (
|
||||||
|
f"Snapped {result['snapped']} element(s) to {grid_size} mm grid "
|
||||||
|
f"({result['already_on_grid']} of {total} were already on grid)"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error snapping to grid: {e}")
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
return {"success": False, "message": str(e)}
|
||||||
|
|
||||||
def _handle_import_svg_logo(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
def _handle_import_svg_logo(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Import an SVG file as PCB graphic polygons on the silkscreen"""
|
"""Import an SVG file as PCB graphic polygons on the silkscreen"""
|
||||||
logger.info("Importing SVG logo into PCB")
|
logger.info("Importing SVG logo into PCB")
|
||||||
|
|||||||
@@ -1814,6 +1814,52 @@ SCHEMATIC_TOOLS = [
|
|||||||
"required": ["schematicPath"],
|
"required": ["schematicPath"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "snap_to_grid",
|
||||||
|
"title": "Snap Schematic Elements to Grid",
|
||||||
|
"description": (
|
||||||
|
"Snap schematic element coordinates to the nearest grid point. "
|
||||||
|
"KiCAD eeschema uses exact integer matching (10 000 IU/mm) for connectivity, "
|
||||||
|
"so even a sub-pixel coordinate offset will make wires appear connected visually "
|
||||||
|
"but fail ERC checks. Running this tool before ERC eliminates that class of error. "
|
||||||
|
"Modifies the .kicad_sch file in place. "
|
||||||
|
"Does not require the KiCAD UI to be running."
|
||||||
|
),
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"schematicPath": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Path to the .kicad_sch schematic file",
|
||||||
|
},
|
||||||
|
"gridSize": {
|
||||||
|
"type": "number",
|
||||||
|
"description": (
|
||||||
|
"Grid spacing in mm. "
|
||||||
|
"Standard KiCAD schematic grid is 2.54 mm (0.1 inch). "
|
||||||
|
"Use 1.27 mm for high-density layouts. "
|
||||||
|
"Defaults to 2.54."
|
||||||
|
),
|
||||||
|
"default": 2.54,
|
||||||
|
},
|
||||||
|
"elements": {
|
||||||
|
"type": "array",
|
||||||
|
"description": (
|
||||||
|
"Element types to snap. "
|
||||||
|
'Valid values: "wires", "junctions", "labels", "components". '
|
||||||
|
'Defaults to ["wires", "junctions", "labels"] when omitted. '
|
||||||
|
'"components" is opt-in because moving a component without re-routing '
|
||||||
|
"its wires will create new mismatches."
|
||||||
|
),
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["wires", "junctions", "labels", "components"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["schematicPath"],
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -1392,4 +1392,36 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Snap schematic elements to grid
|
||||||
|
server.tool(
|
||||||
|
"snap_to_grid",
|
||||||
|
"Snap schematic element coordinates to the nearest grid point. " +
|
||||||
|
"KiCAD uses exact integer matching for connectivity, so off-grid coordinates cause wires " +
|
||||||
|
"that look connected to fail ERC checks. " +
|
||||||
|
"Modifies the .kicad_sch file in place. Does not require the KiCAD UI to be running.",
|
||||||
|
{
|
||||||
|
schematicPath: z.string().describe("Path to the .kicad_sch schematic file"),
|
||||||
|
gridSize: z
|
||||||
|
.number()
|
||||||
|
.optional()
|
||||||
|
.describe("Grid spacing in mm (default: 2.54 — standard KiCAD schematic grid)"),
|
||||||
|
elements: z
|
||||||
|
.array(z.enum(["wires", "junctions", "labels", "components"]))
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'Element types to snap (default: ["wires", "junctions", "labels"]). ' +
|
||||||
|
'"components" is opt-in — moving a component without re-routing wires creates new mismatches.',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
async (args: { schematicPath: string; gridSize?: number; elements?: string[] }) => {
|
||||||
|
const result = await callKicadScript("snap_to_grid", args);
|
||||||
|
if (result.success) {
|
||||||
|
return { content: [{ type: "text", text: result.message }] };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Failed: ${result.message || "Unknown error"}` }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
229
tests/test_snap_to_grid.py
Normal file
229
tests/test_snap_to_grid.py
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
"""
|
||||||
|
Tests for the snap_to_grid schematic tool.
|
||||||
|
|
||||||
|
Unit tests cover the snapping math and per-element-type logic using synthetic
|
||||||
|
S-expressions. Integration tests run against real .kicad_sch files created
|
||||||
|
from the empty template.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import sexpdata
|
||||||
|
from sexpdata import Symbol
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "python"))
|
||||||
|
|
||||||
|
from commands.schematic_snap import _is_on_grid, _snap_mm, snap_to_grid
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Shared fixture helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEMPLATE_PATH = Path(__file__).resolve().parent.parent / "python" / "templates" / "empty.kicad_sch"
|
||||||
|
|
||||||
|
|
||||||
|
def _make_temp_schematic(extra_sexp: str = "") -> Path:
|
||||||
|
"""Copy empty.kicad_sch to a temp dir, optionally injecting extra S-expressions."""
|
||||||
|
tmp = Path(tempfile.mkdtemp()) / "test.kicad_sch"
|
||||||
|
shutil.copy(TEMPLATE_PATH, tmp)
|
||||||
|
if extra_sexp:
|
||||||
|
content = tmp.read_text(encoding="utf-8")
|
||||||
|
idx = content.rfind(")")
|
||||||
|
content = content[:idx] + "\n" + extra_sexp + "\n)"
|
||||||
|
tmp.write_text(content, encoding="utf-8")
|
||||||
|
return tmp
|
||||||
|
|
||||||
|
|
||||||
|
def _wire_sexp(x1: float, y1: float, x2: float, y2: float) -> str:
|
||||||
|
u = str(uuid.uuid4())
|
||||||
|
return (
|
||||||
|
f"(wire (pts (xy {x1} {y1}) (xy {x2} {y2}))\n"
|
||||||
|
f" (stroke (width 0) (type default))\n"
|
||||||
|
f' (uuid "{u}"))'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _junction_sexp(x: float, y: float) -> str:
|
||||||
|
u = str(uuid.uuid4())
|
||||||
|
return f'(junction (at {x} {y}) (diameter 0) (color 0 0 0 0) (uuid "{u}"))'
|
||||||
|
|
||||||
|
|
||||||
|
def _label_sexp(name: str, x: float, y: float, angle: float = 0) -> str:
|
||||||
|
u = str(uuid.uuid4())
|
||||||
|
return (
|
||||||
|
f'(label "{name}" (at {x} {y} {angle})\n'
|
||||||
|
f" (effects (font (size 1.27 1.27)) (justify left bottom))\n"
|
||||||
|
f' (uuid "{u}"))'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Unit tests — pure math, no file I/O
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestSnapMath:
|
||||||
|
def test_snap_mm_already_on_grid(self):
|
||||||
|
assert _snap_mm(2.54, 2.54) == pytest.approx(2.54)
|
||||||
|
|
||||||
|
def test_snap_mm_rounds_up(self):
|
||||||
|
# 2.55 is closer to 5.08 than to 2.54 (distance 2.53 vs 0.01)
|
||||||
|
# Actually 2.55 / 2.54 = 1.0039..., rounds to 1 → 2.54
|
||||||
|
assert _snap_mm(2.55, 2.54) == pytest.approx(2.54)
|
||||||
|
|
||||||
|
def test_snap_mm_rounds_to_next(self):
|
||||||
|
# 3.81 / 2.54 = 1.5 → rounds to 2 → 5.08
|
||||||
|
assert _snap_mm(3.81, 2.54) == pytest.approx(5.08)
|
||||||
|
|
||||||
|
def test_snap_mm_negative(self):
|
||||||
|
assert _snap_mm(-2.51, 2.54) == pytest.approx(-2.54)
|
||||||
|
|
||||||
|
def test_snap_mm_zero(self):
|
||||||
|
assert _snap_mm(0.0, 2.54) == pytest.approx(0.0)
|
||||||
|
|
||||||
|
def test_snap_mm_small_grid(self):
|
||||||
|
assert _snap_mm(1.28, 1.27) == pytest.approx(1.27)
|
||||||
|
|
||||||
|
def test_is_on_grid_true(self):
|
||||||
|
assert _is_on_grid(2.54, 2.54)
|
||||||
|
assert _is_on_grid(0.0, 2.54)
|
||||||
|
assert _is_on_grid(5.08, 2.54)
|
||||||
|
|
||||||
|
def test_is_on_grid_false(self):
|
||||||
|
assert not _is_on_grid(2.55, 2.54)
|
||||||
|
assert not _is_on_grid(1.0, 2.54)
|
||||||
|
|
||||||
|
def test_snap_invalid_grid_raises(self):
|
||||||
|
with pytest.raises(ValueError, match="grid_size must be positive"):
|
||||||
|
snap_to_grid(Path("/nonexistent"), grid_size=-1.0)
|
||||||
|
|
||||||
|
def test_snap_unknown_element_raises(self):
|
||||||
|
with pytest.raises(ValueError, match="Unknown element type"):
|
||||||
|
snap_to_grid(Path("/nonexistent"), elements=["bogus"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Integration tests — real .kicad_sch files
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestSnapWires:
|
||||||
|
def test_off_grid_wire_is_snapped(self):
|
||||||
|
path = _make_temp_schematic(_wire_sexp(2.51, 5.03, 7.56, 5.03))
|
||||||
|
result = snap_to_grid(path, grid_size=2.54, elements=["wires"])
|
||||||
|
assert result["snapped"] >= 1
|
||||||
|
|
||||||
|
# Verify coordinates in the written file
|
||||||
|
data = sexpdata.loads(path.read_text(encoding="utf-8"))
|
||||||
|
wire = next(
|
||||||
|
item for item in data if isinstance(item, list) and item and item[0] == Symbol("wire")
|
||||||
|
)
|
||||||
|
pts = next(sub for sub in wire[1:] if isinstance(sub, list) and sub[0] == Symbol("pts"))
|
||||||
|
xy_pairs = [sub for sub in pts[1:] if isinstance(sub, list) and sub[0] == Symbol("xy")]
|
||||||
|
for pt in xy_pairs:
|
||||||
|
assert _is_on_grid(float(pt[1]), 2.54), f"x={pt[1]} not on grid"
|
||||||
|
assert _is_on_grid(float(pt[2]), 2.54), f"y={pt[2]} not on grid"
|
||||||
|
|
||||||
|
def test_on_grid_wire_counts_as_already_on_grid(self):
|
||||||
|
path = _make_temp_schematic(_wire_sexp(2.54, 5.08, 7.62, 5.08))
|
||||||
|
result = snap_to_grid(path, grid_size=2.54, elements=["wires"])
|
||||||
|
assert result["snapped"] == 0
|
||||||
|
assert result["already_on_grid"] >= 1
|
||||||
|
|
||||||
|
def test_wires_not_snapped_when_excluded(self):
|
||||||
|
path = _make_temp_schematic(_wire_sexp(2.51, 5.03, 7.56, 5.03))
|
||||||
|
result = snap_to_grid(path, grid_size=2.54, elements=["junctions"])
|
||||||
|
assert result["snapped"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestSnapJunctions:
|
||||||
|
def test_off_grid_junction_is_snapped(self):
|
||||||
|
path = _make_temp_schematic(_junction_sexp(2.51, 2.51))
|
||||||
|
result = snap_to_grid(path, grid_size=2.54, elements=["junctions"])
|
||||||
|
assert result["snapped"] >= 1
|
||||||
|
|
||||||
|
data = sexpdata.loads(path.read_text(encoding="utf-8"))
|
||||||
|
junc = next(
|
||||||
|
item
|
||||||
|
for item in data
|
||||||
|
if isinstance(item, list) and item and item[0] == Symbol("junction")
|
||||||
|
)
|
||||||
|
at = next(sub for sub in junc[1:] if isinstance(sub, list) and sub[0] == Symbol("at"))
|
||||||
|
assert _is_on_grid(float(at[1]), 2.54)
|
||||||
|
assert _is_on_grid(float(at[2]), 2.54)
|
||||||
|
|
||||||
|
def test_on_grid_junction_unchanged(self):
|
||||||
|
path = _make_temp_schematic(_junction_sexp(2.54, 2.54))
|
||||||
|
result = snap_to_grid(path, grid_size=2.54, elements=["junctions"])
|
||||||
|
assert result["snapped"] == 0
|
||||||
|
assert result["already_on_grid"] >= 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestSnapLabels:
|
||||||
|
def test_off_grid_label_snapped_preserves_angle(self):
|
||||||
|
path = _make_temp_schematic(_label_sexp("NET_A", 2.51, 5.03, angle=90))
|
||||||
|
result = snap_to_grid(path, grid_size=2.54, elements=["labels"])
|
||||||
|
assert result["snapped"] >= 1
|
||||||
|
|
||||||
|
data = sexpdata.loads(path.read_text(encoding="utf-8"))
|
||||||
|
lbl = next(
|
||||||
|
item for item in data if isinstance(item, list) and item and item[0] == Symbol("label")
|
||||||
|
)
|
||||||
|
at = next(sub for sub in lbl[1:] if isinstance(sub, list) and sub[0] == Symbol("at"))
|
||||||
|
assert _is_on_grid(float(at[1]), 2.54), f"x={at[1]} not on grid"
|
||||||
|
assert _is_on_grid(float(at[2]), 2.54), f"y={at[2]} not on grid"
|
||||||
|
# angle must be preserved
|
||||||
|
assert float(at[3]) == pytest.approx(90.0)
|
||||||
|
|
||||||
|
def test_on_grid_label_unchanged(self):
|
||||||
|
path = _make_temp_schematic(_label_sexp("NET_B", 2.54, 5.08))
|
||||||
|
result = snap_to_grid(path, grid_size=2.54, elements=["labels"])
|
||||||
|
assert result["snapped"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestSnapDefaults:
|
||||||
|
def test_default_elements_snaps_wires_and_junctions_and_labels(self):
|
||||||
|
extra = "\n".join(
|
||||||
|
[
|
||||||
|
_wire_sexp(2.51, 5.03, 7.56, 5.03),
|
||||||
|
_junction_sexp(2.51, 2.51),
|
||||||
|
_label_sexp("VCC", 2.51, 2.51),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
path = _make_temp_schematic(extra)
|
||||||
|
result = snap_to_grid(path) # defaults: grid=2.54, elements=None
|
||||||
|
assert result["snapped"] >= 3
|
||||||
|
assert result["grid_size"] == pytest.approx(2.54)
|
||||||
|
|
||||||
|
def test_idempotent(self):
|
||||||
|
path = _make_temp_schematic(_wire_sexp(2.51, 5.03, 7.56, 5.03))
|
||||||
|
snap_to_grid(path, grid_size=2.54)
|
||||||
|
content_after_first = path.read_text(encoding="utf-8")
|
||||||
|
snap_to_grid(path, grid_size=2.54)
|
||||||
|
content_after_second = path.read_text(encoding="utf-8")
|
||||||
|
assert content_after_first == content_after_second
|
||||||
|
|
||||||
|
def test_custom_grid(self):
|
||||||
|
# 1.27 mm grid — wire at 1.25 should snap to 1.27
|
||||||
|
path = _make_temp_schematic(_wire_sexp(1.25, 1.25, 2.51, 2.51))
|
||||||
|
result = snap_to_grid(path, grid_size=1.27)
|
||||||
|
assert result["snapped"] >= 1
|
||||||
|
data = sexpdata.loads(path.read_text(encoding="utf-8"))
|
||||||
|
wire = next(
|
||||||
|
item for item in data if isinstance(item, list) and item and item[0] == Symbol("wire")
|
||||||
|
)
|
||||||
|
pts = next(sub for sub in wire[1:] if isinstance(sub, list) and sub[0] == Symbol("pts"))
|
||||||
|
xy_pairs = [sub for sub in pts[1:] if isinstance(sub, list) and sub[0] == Symbol("xy")]
|
||||||
|
for pt in xy_pairs:
|
||||||
|
assert _is_on_grid(float(pt[1]), 1.27), f"x={pt[1]} not on 1.27 grid"
|
||||||
|
assert _is_on_grid(float(pt[2]), 1.27), f"y={pt[2]} not on 1.27 grid"
|
||||||
Reference in New Issue
Block a user