Add S-expression parser for custom library pins and global labels

kicad-sch-api has two parsing gaps: get_symbol_definition() returns
None for non-standard library prefixes (e.g. Espressif:ESP32-P4),
and there is no sch.global_labels attribute for (global_label ...)
nodes. This adds a focused parser that reads directly from the raw
.kicad_sch file as a fallback, integrated into the connectivity
engine, pin extraction, and label counting tools.
This commit is contained in:
Ryan Malloy 2026-03-04 17:18:01 -07:00
parent b7e4fc6859
commit e610bf3871
5 changed files with 609 additions and 7 deletions

View File

@ -16,6 +16,11 @@ from typing import Any
from mckicad.config import INLINE_RESULT_THRESHOLD from mckicad.config import INLINE_RESULT_THRESHOLD
from mckicad.server import mcp from mckicad.server import mcp
from mckicad.utils.file_utils import write_detail_file from mckicad.utils.file_utils import write_detail_file
from mckicad.utils.sexp_parser import (
parse_global_labels,
parse_lib_symbol_pins,
transform_pin_to_schematic,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -705,8 +710,18 @@ def get_schematic_info(schematic_path: str) -> dict[str, Any]:
if "text_elements" not in stats or not isinstance(stats.get("text_elements"), dict): if "text_elements" not in stats or not isinstance(stats.get("text_elements"), dict):
stats["text_elements"] = {} stats["text_elements"] = {}
stats["text_elements"]["global_label"] = len(hier_labels) stats["text_elements"]["global_label"] = len(hier_labels)
# Fallback: parse (global_label ...) nodes from raw file when
# kicad-sch-api reports 0 — it has no sch.global_labels attribute.
if isinstance(stats.get("text_elements"), dict):
if stats["text_elements"].get("global_label", 0) == 0:
raw_global = parse_global_labels(schematic_path)
if raw_global:
stats["text_elements"]["global_label"] = len(raw_global)
stats["text_elements"]["total_text_elements"] = ( stats["text_elements"]["total_text_elements"] = (
stats["text_elements"].get("label", 0) + len(hier_labels) stats["text_elements"].get("label", 0)
+ stats["text_elements"].get("global_label", 0)
) )
# Run validation # Run validation
@ -871,6 +886,31 @@ def get_component_detail(schematic_path: str, reference: str) -> dict[str, Any]:
if not raw_pins: if not raw_pins:
raw_pins = list(getattr(comp, "pins", [])) raw_pins = list(getattr(comp, "pins", []))
# Last resort: parse pins from the raw (lib_symbols ...) section.
# Handles custom library symbols where get_symbol_definition() fails.
if not raw_pins:
lib_id = getattr(comp, "lib_id", None)
if lib_id:
sexp_pins = parse_lib_symbol_pins(schematic_path, str(lib_id))
if sexp_pins:
comp_pos = getattr(comp, "position", None)
comp_rot = float(getattr(comp, "rotation", 0) or 0)
comp_mirror = getattr(comp, "mirror", None)
mirror_x = comp_mirror in ("x", True) if comp_mirror else False
cx = float(comp_pos.x) if comp_pos is not None and hasattr(comp_pos, "x") else 0.0
cy = float(comp_pos.y) if comp_pos is not None and hasattr(comp_pos, "y") else 0.0
for sp in sexp_pins:
sx, sy = transform_pin_to_schematic(
sp["x"], sp["y"], cx, cy, comp_rot, mirror_x
)
pins.append({
"number": sp["number"],
"name": sp["name"],
"type": sp["type"],
"position": {"x": sx, "y": sy},
})
for p in (raw_pins or []): for p in (raw_pins or []):
if isinstance(p, dict): if isinstance(p, dict):
pins.append(p) pins.append(p)

View File

@ -18,6 +18,11 @@ from mckicad.config import INLINE_RESULT_THRESHOLD, TIMEOUT_CONSTANTS
from mckicad.server import mcp from mckicad.server import mcp
from mckicad.utils.file_utils import write_detail_file from mckicad.utils.file_utils import write_detail_file
from mckicad.utils.kicad_cli import find_kicad_cli from mckicad.utils.kicad_cli import find_kicad_cli
from mckicad.utils.sexp_parser import (
parse_global_labels,
parse_lib_symbol_pins,
transform_pin_to_schematic,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -104,7 +109,9 @@ def _default_output_path(schematic_path: str, filename: str) -> str:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _build_connectivity(sch: Any) -> tuple[dict[str, list[dict[str, str]]], list[dict[str, str]]]: def _build_connectivity(
sch: Any, schematic_path: str | None = None
) -> tuple[dict[str, list[dict[str, str]]], list[dict[str, str]]]:
"""Build a net connectivity graph by walking wires, pin positions, and labels. """Build a net connectivity graph by walking wires, pin positions, and labels.
kicad-sch-api does not auto-compute nets on loaded schematics. This kicad-sch-api does not auto-compute nets on loaded schematics. This
@ -112,10 +119,14 @@ def _build_connectivity(sch: Any) -> tuple[dict[str, list[dict[str, str]]], list
1. Collecting all wire start/end coordinates 1. Collecting all wire start/end coordinates
2. Mapping component pin positions to coordinates 2. Mapping component pin positions to coordinates
3. Mapping label positions to coordinates 3. Mapping label positions to coordinates (local, hierarchical, global)
4. Using union-find to group touching points into nets 4. Using union-find to group touching points into nets
5. Merging groups that share the same label text 5. Merging groups that share the same label text
When *schematic_path* is provided, ``(global_label ...)`` entries are
also extracted directly from the raw file via :mod:`sexp_parser`, since
kicad-sch-api does not expose them.
Returns: Returns:
(net_graph, unconnected_pins) where net_graph maps net names to (net_graph, unconnected_pins) where net_graph maps net names to
lists of {reference, pin} dicts, and unconnected_pins lists lists of {reference, pin} dicts, and unconnected_pins lists
@ -197,7 +208,7 @@ def _build_connectivity(sch: Any) -> tuple[dict[str, list[dict[str, str]]], list
except Exception: except Exception:
pass pass
# Hierarchical / global labels # Hierarchical / global labels (kicad-sch-api attribute)
try: try:
for label in getattr(sch, "hierarchical_labels", []): for label in getattr(sch, "hierarchical_labels", []):
text = getattr(label, "text", None) text = getattr(label, "text", None)
@ -209,6 +220,15 @@ def _build_connectivity(sch: Any) -> tuple[dict[str, list[dict[str, str]]], list
except Exception: except Exception:
pass pass
# Global labels from raw file — kicad-sch-api has no sch.global_labels
# attribute, so (global_label ...) nodes are invisible to the API.
# Parse them directly from the S-expression when the file path is known.
if schematic_path:
for gl in parse_global_labels(schematic_path):
coord = _rc(gl["x"], gl["y"])
_find(coord)
label_at[coord] = gl["text"]
# Power symbols — their value acts as a net name (e.g. GND, VCC) # Power symbols — their value acts as a net name (e.g. GND, VCC)
for comp in getattr(sch, "components", []): for comp in getattr(sch, "components", []):
ref = getattr(comp, "reference", None) ref = getattr(comp, "reference", None)
@ -487,7 +507,7 @@ def analyze_connectivity(schematic_path: str) -> dict[str, Any]:
try: try:
sch = _ksa_load(schematic_path) sch = _ksa_load(schematic_path)
net_graph, unconnected = _build_connectivity(sch) net_graph, unconnected = _build_connectivity(sch, schematic_path)
total_connections = sum(len(pins) for pins in net_graph.values()) total_connections = sum(len(pins) for pins in net_graph.values())
detail_path = write_detail_file(schematic_path, "connectivity.json", { detail_path = write_detail_file(schematic_path, "connectivity.json", {
@ -550,7 +570,7 @@ def check_pin_connection(
try: try:
sch = _ksa_load(schematic_path) sch = _ksa_load(schematic_path)
net_graph, _unconnected = _build_connectivity(sch) net_graph, _unconnected = _build_connectivity(sch, schematic_path)
# Find which net this pin belongs to # Find which net this pin belongs to
net_name = None net_name = None
@ -632,7 +652,7 @@ def verify_pins_connected(
try: try:
sch = _ksa_load(schematic_path) sch = _ksa_load(schematic_path)
net_graph, _unconnected = _build_connectivity(sch) net_graph, _unconnected = _build_connectivity(sch, schematic_path)
# Find the net for each pin # Find the net for each pin
net_for_pin1 = None net_for_pin1 = None
@ -759,6 +779,34 @@ def get_component_pins(
if not raw_pins: if not raw_pins:
raw_pins = getattr(comp, "pins", []) raw_pins = getattr(comp, "pins", [])
# Last resort: parse pins directly from the raw (lib_symbols ...)
# section of the .kicad_sch file. This handles custom library
# symbols (e.g. Espressif:ESP32-P4) where get_symbol_definition()
# returns None because kicad-sch-api can't resolve the library prefix.
if not raw_pins:
lib_id = getattr(comp, "lib_id", None)
if lib_id:
sexp_pins = parse_lib_symbol_pins(schematic_path, str(lib_id))
if sexp_pins:
# Get component position and rotation for coordinate transform
comp_pos = getattr(comp, "position", None)
comp_rot = float(getattr(comp, "rotation", 0) or 0)
comp_mirror = getattr(comp, "mirror", None)
mirror_x = comp_mirror in ("x", True) if comp_mirror else False
cx = float(comp_pos.x) if comp_pos is not None and hasattr(comp_pos, "x") else 0.0
cy = float(comp_pos.y) if comp_pos is not None and hasattr(comp_pos, "y") else 0.0
for sp in sexp_pins:
sx, sy = transform_pin_to_schematic(
sp["x"], sp["y"], cx, cy, comp_rot, mirror_x
)
pins_data.append({
"number": sp["number"],
"name": sp["name"],
"type": sp["type"],
"position": {"x": sx, "y": sy},
})
for p in raw_pins: for p in raw_pins:
pin_entry: dict[str, Any] = {} pin_entry: dict[str, Any] = {}

View File

@ -0,0 +1,219 @@
"""Minimal S-expression parser for extracting data kicad-sch-api doesn't parse.
kicad-sch-api v0.5.5 has two known parsing gaps:
1. ``sheets.data['lib_symbols']`` is always ``{}`` even though the raw
``.kicad_sch`` file embeds full symbol definitions (including pin data)
in its ``(lib_symbols ...)`` section.
2. ``(global_label ...)`` nodes are not exposed through any collection
attribute ``sch.labels`` only contains local labels, and there is
no ``sch.global_labels`` attribute.
This module provides focused parsers for these two cases, reading directly
from the raw ``.kicad_sch`` file.
"""
import math
import re
from typing import Any
# ---------------------------------------------------------------------------
# Global labels
# ---------------------------------------------------------------------------
# Match: (global_label "TEXT" ... (at X Y [R]) ...)
_GLOBAL_LABEL_RE = re.compile(
r'\(global_label\s+"([^"]+)"' # capture label text
r'.*?' # skip shape, nested parens etc. (non-greedy, DOTALL)
r'\(at\s+([\d.e+-]+)\s+([\d.e+-]+)', # capture x, y
re.DOTALL,
)
def parse_global_labels(filepath: str) -> list[dict[str, Any]]:
"""Extract ``(global_label ...)`` entries from a .kicad_sch file.
Returns a list of ``{'text': str, 'x': float, 'y': float}`` dicts.
"""
try:
with open(filepath, encoding="utf-8") as f:
content = f.read()
except Exception:
return []
labels: list[dict[str, Any]] = []
for match in _GLOBAL_LABEL_RE.finditer(content):
labels.append({
"text": match.group(1),
"x": float(match.group(2)),
"y": float(match.group(3)),
})
return labels
# ---------------------------------------------------------------------------
# lib_symbols pin extraction
# ---------------------------------------------------------------------------
# Match pins inside a symbol section:
# (pin TYPE SHAPE (at X Y [R]) (length L) (name "NAME" ...) (number "NUM" ...))
_PIN_RE = re.compile(
r'\(pin\s+'
r'(\w+)\s+' # pin type (passive, input, output, ...)
r'(\w+)\s*' # pin shape (line, inverted, ...)
r'\(at\s+([\d.e+-]+)\s+([\d.e+-]+)(?:\s+([\d.e+-]+))?\)' # (at X Y [R])
r'\s*\(length\s+([\d.e+-]+)\)' # (length L)
r'.*?' # skip to name
r'\(name\s+"([^"]*)"' # (name "NAME" ...)
r'.*?' # skip to number
r'\(number\s+"([^"]*)"', # (number "NUM" ...)
re.DOTALL,
)
def parse_lib_symbol_pins(filepath: str, lib_id: str) -> list[dict[str, Any]]:
"""Extract pin definitions for a symbol from the ``(lib_symbols ...)`` section.
Pins are returned in **local symbol coordinates** (not transformed to
schematic space). Use :func:`transform_pin_to_schematic` to convert
them if the component's position and rotation are known.
Args:
filepath: Path to a .kicad_sch file.
lib_id: Full library identifier (e.g. ``Espressif:ESP32-P4``).
Returns:
List of pin dicts with ``number``, ``name``, ``type``, ``shape``,
``x``, ``y``, ``rotation``, and ``length`` fields.
"""
try:
with open(filepath, encoding="utf-8") as f:
content = f.read()
except Exception:
return []
# 1. Find the (lib_symbols ...) section
lib_section = _extract_section(content, "lib_symbols")
if not lib_section:
return []
# 2. Find the top-level (symbol "LIB_ID" ...) within lib_symbols
symbol_section = _extract_named_section(lib_section, "symbol", lib_id)
if not symbol_section:
return []
# 3. Extract all (pin ...) entries
pins: list[dict[str, Any]] = []
for match in _PIN_RE.finditer(symbol_section):
pins.append({
"number": match.group(8),
"name": match.group(7),
"type": match.group(1),
"shape": match.group(2),
"x": float(match.group(3)),
"y": float(match.group(4)),
"rotation": float(match.group(5) or 0),
"length": float(match.group(6)),
})
return pins
# ---------------------------------------------------------------------------
# Coordinate transformation
# ---------------------------------------------------------------------------
def transform_pin_to_schematic(
pin_x: float,
pin_y: float,
comp_x: float,
comp_y: float,
comp_rotation_deg: float = 0,
mirror_x: bool = False,
) -> tuple[float, float]:
"""Transform a pin from local symbol coordinates to schematic coordinates.
Applies the component's rotation and position offset.
Args:
pin_x: Pin X in local symbol coordinates.
pin_y: Pin Y in local symbol coordinates.
comp_x: Component X position in schematic coordinates.
comp_y: Component Y position in schematic coordinates.
comp_rotation_deg: Component rotation in degrees (0, 90, 180, 270).
mirror_x: Whether the component is mirrored along the X axis.
Returns:
(schematic_x, schematic_y) tuple.
"""
px, py = pin_x, pin_y
# Apply mirror first (before rotation)
if mirror_x:
px = -px
# Apply rotation (standard 2D rotation matrix)
rad = math.radians(comp_rotation_deg)
cos_r = math.cos(rad)
sin_r = math.sin(rad)
rx = px * cos_r - py * sin_r
ry = px * sin_r + py * cos_r
# Apply position offset
return (round(comp_x + rx, 3), round(comp_y + ry, 3))
# ---------------------------------------------------------------------------
# Section extraction helpers
# ---------------------------------------------------------------------------
def _extract_section(content: str, keyword: str) -> str | None:
"""Extract the text of a top-level ``(keyword ...)`` section using bracket counting."""
marker = f"({keyword}"
start = content.find(marker)
if start == -1:
return None
depth = 0
for i in range(start, len(content)):
if content[i] == "(":
depth += 1
elif content[i] == ")":
depth -= 1
if depth == 0:
return content[start : i + 1]
return None
def _extract_named_section(content: str, keyword: str, name: str) -> str | None:
"""Extract ``(keyword "name" ...)`` section, matching the exact name.
Avoids matching sub-units like ``"Device:R_0_1"`` when looking for
``"Device:R"``.
"""
marker = f'({keyword} "{name}"'
pos = 0
while True:
start = content.find(marker, pos)
if start == -1:
return None
# Verify exact name match (next char must be whitespace or close paren)
after = start + len(marker)
if after < len(content) and content[after] not in (" ", "\n", "\r", "\t", ")"):
pos = after
continue
# Extract using bracket counting
depth = 0
for i in range(start, len(content)):
if content[i] == "(":
depth += 1
elif content[i] == ")":
depth -= 1
if depth == 0:
return content[start : i + 1]
return None

View File

@ -74,6 +74,7 @@ class TestAddPowerSymbolToPin:
sch.components.add(lib_id="Device:C", reference="C1", value="100nF", position=(100, 100)) sch.components.add(lib_id="Device:C", reference="C1", value="100nF", position=(100, 100))
pin_pos = sch.get_component_pin_position("C1", "1") pin_pos = sch.get_component_pin_position("C1", "1")
assert pin_pos is not None
result = add_power_symbol_to_pin( result = add_power_symbol_to_pin(
sch=sch, sch=sch,
pin_position=(pin_pos.x, pin_pos.y), pin_position=(pin_pos.x, pin_pos.y),
@ -93,6 +94,8 @@ class TestAddPowerSymbolToPin:
pin1 = sch.get_component_pin_position("R1", "1") pin1 = sch.get_component_pin_position("R1", "1")
pin2 = sch.get_component_pin_position("R1", "2") pin2 = sch.get_component_pin_position("R1", "2")
assert pin1 is not None
assert pin2 is not None
r1 = add_power_symbol_to_pin(sch, (pin1.x, pin1.y), "+3V3") r1 = add_power_symbol_to_pin(sch, (pin1.x, pin1.y), "+3V3")
r2 = add_power_symbol_to_pin(sch, (pin2.x, pin2.y), "GND") r2 = add_power_symbol_to_pin(sch, (pin2.x, pin2.y), "GND")

292
tests/test_sexp_parser.py Normal file
View File

@ -0,0 +1,292 @@
"""Tests for the S-expression parser utilities.
These tests do NOT require kicad-sch-api they test raw file parsing.
"""
import os
import tempfile
import pytest
from mckicad.utils.sexp_parser import (
parse_global_labels,
parse_lib_symbol_pins,
transform_pin_to_schematic,
)
# Minimal .kicad_sch content with global labels and lib_symbols
SAMPLE_SCHEMATIC = """\
(kicad_sch
(version 20231120)
(generator "eeschema")
(uuid "abc123")
(paper "A4")
(lib_symbols
(symbol "Device:R"
(pin_numbers hide)
(pin_names
(offset 0)
)
(exclude_from_sim no)
(in_bom yes)
(on_board yes)
(property "Reference" "R"
(at 2.032 0 90)
(effects
(font
(size 1.27 1.27)
)
)
)
(symbol "Device:R_0_1"
(polyline
(pts
(xy -1.016 -2.54)
(xy -1.016 2.54)
)
)
)
(symbol "Device:R_1_1"
(pin passive line
(at 0 3.81 270)
(length 2.54)
(name "~"
(effects
(font
(size 1.27 1.27)
)
)
)
(number "1"
(effects
(font
(size 1.27 1.27)
)
)
)
)
(pin passive line
(at 0 -3.81 90)
(length 2.54)
(name "~"
(effects
(font
(size 1.27 1.27)
)
)
)
(number "2"
(effects
(font
(size 1.27 1.27)
)
)
)
)
)
)
(symbol "Espressif:ESP32-P4"
(pin_names
(offset 1.016)
)
(exclude_from_sim no)
(in_bom yes)
(on_board yes)
(symbol "Espressif:ESP32-P4_0_1"
(pin input line
(at -25.4 22.86 0)
(length 2.54)
(name "GPIO0"
(effects
(font
(size 1.27 1.27)
)
)
)
(number "1"
(effects
(font
(size 1.27 1.27)
)
)
)
)
(pin power_in line
(at 0 30.48 270)
(length 2.54)
(name "VDD"
(effects
(font
(size 1.27 1.27)
)
)
)
(number "2"
(effects
(font
(size 1.27 1.27)
)
)
)
)
(pin output line
(at 25.4 22.86 180)
(length 2.54)
(name "TX"
(effects
(font
(size 1.27 1.27)
)
)
)
(number "3"
(effects
(font
(size 1.27 1.27)
)
)
)
)
)
)
)
(global_label "ESP_3V3"
(shape input)
(at 127 95.25 180)
(uuid "def456")
(effects
(font
(size 1.27 1.27)
)
(justify right)
)
)
(global_label "GND"
(shape input)
(at 200.5 150.75 0)
(uuid "ghi789")
)
(global_label "SPI_CLK"
(shape output)
(at 300 200 90)
(uuid "jkl012")
)
(label "LOCAL_NET"
(at 100 100 0)
(uuid "mno345")
)
)
"""
@pytest.fixture
def sample_schematic_file():
"""Write the sample schematic to a temp file and return its path."""
with tempfile.NamedTemporaryFile(
mode="w", suffix=".kicad_sch", delete=False, encoding="utf-8"
) as f:
f.write(SAMPLE_SCHEMATIC)
path = f.name
yield path
os.unlink(path)
class TestParseGlobalLabels:
def test_finds_all_global_labels(self, sample_schematic_file):
labels = parse_global_labels(sample_schematic_file)
assert len(labels) == 3
def test_extracts_text_and_position(self, sample_schematic_file):
labels = parse_global_labels(sample_schematic_file)
texts = {lbl["text"] for lbl in labels}
assert texts == {"ESP_3V3", "GND", "SPI_CLK"}
esp = next(lbl for lbl in labels if lbl["text"] == "ESP_3V3")
assert esp["x"] == pytest.approx(127.0)
assert esp["y"] == pytest.approx(95.25)
gnd = next(lbl for lbl in labels if lbl["text"] == "GND")
assert gnd["x"] == pytest.approx(200.5)
assert gnd["y"] == pytest.approx(150.75)
def test_does_not_include_local_labels(self, sample_schematic_file):
labels = parse_global_labels(sample_schematic_file)
texts = {lbl["text"] for lbl in labels}
assert "LOCAL_NET" not in texts
def test_nonexistent_file_returns_empty(self):
labels = parse_global_labels("/nonexistent/path.kicad_sch")
assert labels == []
class TestParseLibSymbolPins:
def test_finds_resistor_pins(self, sample_schematic_file):
pins = parse_lib_symbol_pins(sample_schematic_file, "Device:R")
assert len(pins) == 2
nums = {p["number"] for p in pins}
assert nums == {"1", "2"}
pin1 = next(p for p in pins if p["number"] == "1")
assert pin1["name"] == "~"
assert pin1["type"] == "passive"
assert pin1["x"] == pytest.approx(0.0)
assert pin1["y"] == pytest.approx(3.81)
def test_finds_custom_ic_pins(self, sample_schematic_file):
pins = parse_lib_symbol_pins(sample_schematic_file, "Espressif:ESP32-P4")
assert len(pins) == 3
names = {p["name"] for p in pins}
assert names == {"GPIO0", "VDD", "TX"}
gpio = next(p for p in pins if p["name"] == "GPIO0")
assert gpio["number"] == "1"
assert gpio["type"] == "input"
assert gpio["x"] == pytest.approx(-25.4)
assert gpio["y"] == pytest.approx(22.86)
assert gpio["rotation"] == pytest.approx(0.0)
vdd = next(p for p in pins if p["name"] == "VDD")
assert vdd["type"] == "power_in"
def test_does_not_match_subunit_prefix(self, sample_schematic_file):
# "Espressif:ESP32-P4_0_1" is a sub-unit, not the top-level symbol
pins = parse_lib_symbol_pins(sample_schematic_file, "Espressif:ESP32-P4_0")
assert len(pins) == 0
def test_nonexistent_lib_id_returns_empty(self, sample_schematic_file):
pins = parse_lib_symbol_pins(sample_schematic_file, "NoSuchLib:Missing")
assert pins == []
def test_nonexistent_file_returns_empty(self):
pins = parse_lib_symbol_pins("/nonexistent/path.kicad_sch", "Device:R")
assert pins == []
class TestTransformPinToSchematic:
def test_zero_rotation(self):
sx, sy = transform_pin_to_schematic(0, 3.81, 100, 100, 0)
assert sx == pytest.approx(100.0)
assert sy == pytest.approx(103.81)
def test_90_degree_rotation(self):
sx, sy = transform_pin_to_schematic(0, 3.81, 100, 100, 90)
assert sx == pytest.approx(100 - 3.81, abs=0.01)
assert sy == pytest.approx(100.0, abs=0.01)
def test_180_degree_rotation(self):
sx, sy = transform_pin_to_schematic(0, 3.81, 100, 100, 180)
assert sx == pytest.approx(100.0, abs=0.01)
assert sy == pytest.approx(100 - 3.81, abs=0.01)
def test_270_degree_rotation(self):
sx, sy = transform_pin_to_schematic(0, 3.81, 100, 100, 270)
assert sx == pytest.approx(100 + 3.81, abs=0.01)
assert sy == pytest.approx(100.0, abs=0.01)
def test_mirror_x(self):
sx, sy = transform_pin_to_schematic(5, 0, 100, 100, 0, mirror_x=True)
assert sx == pytest.approx(95.0)
assert sy == pytest.approx(100.0)