Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
"""Coordinate / transform core for the KiCad writer (SPEC §3.5).
This is *the module that makes wires hit pins*: it computes the exact world
(sheet) coordinate of a symbol pin given a placed instance, so a wire endpoint
emitted at that coordinate is electrically coincident with the pin.
Hard rules (SPEC §1.1 / §1.2 / §2.1):
* **All geometry is integer nanometres.** Inputs are converted to integer nm and
every transform is closed over the integers (rotation by 0/90/180/270 and
mirror only permute/negate coordinates, so no rounding ever creeps in). We only
stringify to millimetres at serialize time via :func:`nm_to_mm_str`.
* **Canonical frame is +Y down** (KiCad sheet convention). KiCad *library* symbol
geometry is authored **+Y up**, so :func:`pin_world` applies the library Y-flip
before the instance transform.
* **Transform order is rotate-then-mirror** (SPEC §2.1), applied about the symbol
origin, then translated by the instance placement. Rotation is the enum
``{0,90,180,270}``; mirror is ``{none,x,y}`` where ``x`` mirrors across the X
axis (negates Y) and ``y`` mirrors across the Y axis (negates X).
The rotation matrices implement **eeschema's** instance transform, established
empirically against ``kicad-cli sch export netlist`` (KiCad 10.0.4) over the
full rot × mirror matrix (see ``tests/test_kicad_parity.py``). A file angle of
``+90`` rotates the symbol 90° *counter-clockwise on screen*, which in the
+Y-down algebra is:
0 -> ( x, y)
90 -> ( y, -x)
180 -> (-x, -y)
270 -> (-y, x)
followed by the mirror. (The pre-parity code rotated the other way — ``90 ->
(-y, x)`` — which put every rot-90/270 pin on the point-mirrored position;
eeschema then saw the wire miss the pin. The KiCad reader delegates here, so
reader and writer cannot diverge again.)
"""
from __future__ import annotations
from .. import units
from ..model import Component, Pin, SymbolDef
# Re-exported from units so writer code has a single import surface (SPEC §3.5).
from ..units import mil_to_nm, nm_to_mm_str
__all__ = [
"pin_world",
"label_angle_away",
"transform_point",
"transform_angle",
"transform_child",
"grid_snap_nm",
"mil_to_nm",
"nm_to_mm_str",
"DEFAULT_GRID_NM",
]
# Default schematic grid: 50 mil (SPEC §1.1 / §2.1), in integer nm.
DEFAULT_GRID_NM: int = 50 * units.NM_PER_MIL
# ---------------------------------------------------------------------------
# Core point transform (integer nm, rotate-then-mirror, +Y down)
# ---------------------------------------------------------------------------
def transform_point(
pt: tuple[int, int],
rot: int = 0,
mirror: str = "none",
origin: tuple[int, int] = (0, 0),
) -> tuple[int, int]:
"""Rotate-then-mirror a point about (0,0), then translate by ``origin``.
All coordinates are integer nanometres in the canonical **+Y-down** frame.
``rot`` is one of ``{0,90,180,270}`` (any other multiple of 90 is normalised;
non-multiples raise ``ValueError``). ``mirror`` is ``{none,x,y}``. Rotation
is eeschema's: +90 turns counter-clockwise on screen (see module docstring).
"""
x = int(pt[0])
y = int(pt[1])
r = int(rot) % 360
if r == 0:
pass
elif r == 90:
x, y = y, -x
elif r == 180:
x, y = -x, -y
elif r == 270:
x, y = -y, x
else:
raise ValueError(f"rotation must be a multiple of 90, got {rot!r}")
if mirror == "x": # mirror across the X axis -> negate Y
y = -y
elif mirror == "y": # mirror across the Y axis -> negate X
x = -x
elif mirror not in ("none", "", None):
raise ValueError(f"mirror must be one of none|x|y, got {mirror!r}")
return (x + int(origin[0]), y + int(origin[1]))
def transform_angle(angle: float, rot: int = 0, mirror: str = "none") -> float:
"""Transform a child text/property angle (degrees) under rotate-then-mirror.
A rotation adds ``rot`` to a direction angle; ``mirror x`` (negate Y) reflects
the angle (``a -> -a``); ``mirror y`` (negate X) reflects it about the Y axis
(``a -> 180 - a``). Result is normalised to ``[0, 360)``.
"""
a = float(angle) + (int(rot) % 360)
if mirror == "x":
a = -a
elif mirror == "y":
a = 180.0 - a
elif mirror not in ("none", "", None):
raise ValueError(f"mirror must be one of none|x|y, got {mirror!r}")
return a % 360.0
# Lib-frame (+Y up) unit vectors for the four pin orientations.
_PIN_DIR: dict[int, tuple[int, int]] = {0: (1, 0), 90: (0, 1), 180: (-1, 0), 270: (0, -1)}
def label_angle_away(pin_orientation: int, rot: int = 0, mirror: str = "none") -> int:
"""KiCad label angle whose text extends AWAY from the symbol body.
A library pin's ``(at x y a)`` angle points from its electrical tip *toward*
the body, so the free side of the pin is the opposite direction. This
transforms that away-vector through the library Y-flip and the instance
rotate-then-mirror, then maps the world vector to the KiCad label-angle
convention (0 = text extends +X, 90 = up-screen, 180 = -X, 270 = down-screen).
A label at the pin tip with this angle reads outward instead of running
over the symbol it names.
"""
dx, dy = _PIN_DIR.get(int(pin_orientation) % 360, (1, 0))
flipped = (-dx, dy) # away = -dir, then +Y-up -> +Y-down
wx, wy = transform_point(flipped, rot, mirror)
if wx > 0:
return 0
if wx < 0:
return 180
return 90 if wy < 0 else 270
def transform_child(
local_nm: tuple[int, int],
local_angle: float,
rot: int = 0,
mirror: str = "none",
origin: tuple[int, int] = (0, 0),
) -> tuple[tuple[int, int], float]:
"""World ``((x_nm, y_nm), angle_deg)`` for a symbol-local child (property).
``local_nm`` is the child's anchor in **symbol-local, +Y-up** nm (the KiCad
library convention, identical to pin offsets). This applies the library
Y-flip, then the instance rotate-then-mirror + translate (position via
:func:`transform_point`, angle via :func:`transform_angle`).
"""
flipped = (int(local_nm[0]), -int(local_nm[1])) # +Y up -> +Y down
world = transform_point(flipped, rot, mirror, origin)
# The Y-flip negates a direction angle just like mirror across X.
angle = transform_angle(-float(local_angle), rot, mirror)
return world, angle
# ---------------------------------------------------------------------------
# Pin world coordinate — the function that makes wires hit pins
# ---------------------------------------------------------------------------
def pin_world(sym: SymbolDef, inst: Component, pin: Pin) -> tuple[int, int]:
"""World coordinate (integer nm, +Y down) of ``pin``'s electrical endpoint.
``sym`` is the resolved library symbol, ``inst`` the placed component, and
``pin`` one of the symbol's pins (symbol-local, +Y-up mils). The library
Y-flip is applied first, then the instance rotate-then-mirror about the
placement, then translation by the instance position.
A wire endpoint emitted at this coordinate is exactly coincident with the
pin, which is what the connectivity verifier requires.
"""
# Symbol-local pin offset, +Y up -> +Y down, in integer nm.
local = (mil_to_nm(pin.x_mil), -mil_to_nm(pin.y_mil))
origin = (mil_to_nm(inst.x_mil), mil_to_nm(inst.y_mil))
return transform_point(local, inst.rotation, inst.mirror, origin)
def world_box_from_extent(
ext_mil: tuple[float, float, float, float],
rotation: int, mirror: str, origin_nm: tuple[int, int],
) -> tuple[int, int, int, int]:
"""World (nm, +Y down) AABB of a lib-frame (mil, +Y up) body extent.
``ext_mil`` is ``kicad_lib.body_extent_mil``'s ``(x0, y0, x1, y1)``. The
four corners get the library Y-flip, then the instance rotate-then-mirror
about ``origin_nm`` — the same chain as :func:`pin_world`, so a body box
computed here can never disagree with the pins' world coordinates. Shared
by the writer's property autoplacement, the layout lint and ``akcli bbox``
(one transform, no parity drift).
"""
x0, y0, x1, y1 = ext_mil
pts = [
transform_point((mil_to_nm(cx), -mil_to_nm(cy)), rotation, mirror, origin_nm)
for (cx, cy) in ((x0, y0), (x1, y0), (x0, y1), (x1, y1))
]
return (min(p[0] for p in pts), min(p[1] for p in pts),
max(p[0] for p in pts), max(p[1] for p in pts))
def grid_snap_nm(
pt: tuple[int, int], grid: int = DEFAULT_GRID_NM
) -> tuple[int, int]:
"""Snap an integer-nm point to the nearest ``grid`` multiple (default 50 mil).
``grid <= 0`` is a no-op (returns the point as ints).
"""
if grid <= 0:
return (int(pt[0]), int(pt[1]))
g = int(grid)
return (
int(round(int(pt[0]) / g)) * g,
int(round(int(pt[1]) / g)) * g,
)
def point_world_mil(
lx_mil: float,
ly_mil: float,
px_mil: float,
py_mil: float,
rot_deg: int,
mirror: str,
) -> tuple[float, float]:
"""World coords (mil, +Y down) of a symbol-local point offset.
The mil-level wrapper over :func:`transform_point` (library +Y-up flip,
then rotate-then-mirror), formerly ``readers.kicad._pin_world`` — hoisted
here so checks/arrange stop reaching into the reader for a pure
transform. Math is exact in integer nanometres; a non-right angle snaps
to the nearest 90 degrees (eeschema only stores right angles).
"""
local = (units.mil_to_nm(lx_mil), -units.mil_to_nm(ly_mil))
origin = (units.mil_to_nm(px_mil), units.mil_to_nm(py_mil))
rot = (round(rot_deg / 90) * 90) % 360
wx, wy = transform_point(local, rot, mirror, origin)
return units.nm_to_mil(wx), units.nm_to_mil(wy)
2126c3284This file is pinned to the selected release. Download the original to open it in your design tool.