From fa6cdcc0cde2d04893b6205e385d7585045c5a65 Mon Sep 17 00:00:00 2001 From: Gavin Colonese Date: Fri, 22 May 2026 17:29:34 -0400 Subject: [PATCH] feat: add mil unit support across position/coordinate commands (#162) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(units): add mil unit support across all position/coordinate commands KiCad natively supports mils, so the MCP server should too. Added "mil" as a valid unit option in tool schemas and updated all unit-to-nanometer scale conversions across component, routing, outline, view, and IPC handler code paths. 1 mil = 25400 nm (0.0254 mm). Also fixes a pre-existing mypy overload error in pin_locator.py (str cast on dict.get key) that was blocking pre-commit on any Python file change. Co-Authored-By: Claude Sonnet 4.6 * feat(units): add mil to TypeScript tool schemas The Python-side mil support was added but the actual input validation happens in the TypeScript/Zod schemas. Updated all z.enum(["mm", "inch"]) to include "mil" across board, component, routing, design-rules, and export tool definitions. Co-Authored-By: Claude Sonnet 4.6 * fix(tools): replace CP-1252 mojibake with correct Unicode in board.ts Replace U+00C3 U+00D7 (×) with U+00D7 (×) in add_logo size output string. Character was mangled when file was saved as CP-1252 instead of UTF-8. * fix: restore em-dash and fix pre-commit mypy in component/routing component.py: replace CP-1252 mojibake (â€") with correct Unicode em-dash (—) in the 'Add to board first' comment. Addresses maintainer review on PR #162. routing.py: annotate ex/ey as float at first assignment site in _point_to_segment_distance_nm so mypy pre-commit hook passes cleanly on this branch. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Sonnet 4.6 --- python/commands/board/outline.py | 18 ++++-- python/commands/board/view.py | 4 +- python/commands/component.py | 22 +++++-- python/commands/pin_locator.py | 2 +- python/commands/routing.py | 98 ++++++++++++++++++---------- python/kicad_interface.py | 10 ++- python/schemas/tool_schemas.py | 8 +-- src/tools/board.ts | 12 ++-- src/tools/component.ts | 16 ++--- src/tools/design-rules.ts | 4 +- src/tools/export.ts | 2 +- src/tools/routing.ts | 8 +-- tests/test_mil_unit_support.py | 106 +++++++++++++++++++++++++++++++ 13 files changed, 239 insertions(+), 71 deletions(-) create mode 100644 tests/test_mil_unit_support.py diff --git a/python/commands/board/outline.py b/python/commands/board/outline.py index db2e138..96dcbe2 100644 --- a/python/commands/board/outline.py +++ b/python/commands/board/outline.py @@ -38,7 +38,7 @@ class BoardOutlineCommands: height = inner.get("height") radius = inner.get("radius") # Accept both "cornerRadius" and "radius" regardless of shape name. - # The AI often sends shape="rectangle" with radius=2.5 — we treat that as rounded_rectangle. + # The AI often sends shape=”rectangle” with radius=2.5 — we treat that as rounded_rectangle. corner_radius = inner.get("cornerRadius", inner.get("radius", 0)) if shape == "rectangle" and corner_radius > 0: shape = "rounded_rectangle" @@ -74,7 +74,9 @@ class BoardOutlineCommands: } # Convert to internal units (nanometers) - scale = 1000000 if unit == "mm" else 25400000 # mm or inch to nm + scale = ( + 1000000 if unit == "mm" else (25400 if unit == "mil" else 25400000) + ) # mm, mil, or inch to nm # Create drawing for edge cuts edge_layer = self.board.GetLayerID("Edge.Cuts") @@ -226,7 +228,11 @@ class BoardOutlineCommands: } # Convert to internal units (nanometers) - scale = 1000000 if position.get("unit", "mm") == "mm" else 25400000 # mm or inch to nm + scale = ( + 1000000 + if position.get("unit", "mm") == "mm" + else (25400 if position.get("unit", "mm") == "mil" else 25400000) + ) # mm, mil, or inch to nm x_nm = int(position["x"] * scale) y_nm = int(position["y"] * scale) diameter_nm = int(diameter * scale) @@ -335,7 +341,11 @@ class BoardOutlineCommands: } # Convert to internal units (nanometers) - scale = 1000000 if position.get("unit", "mm") == "mm" else 25400000 # mm or inch to nm + scale = ( + 1000000 + if position.get("unit", "mm") == "mm" + else (25400 if position.get("unit", "mm") == "mil" else 25400000) + ) # mm, mil, or inch to nm x_nm = int(position["x"] * scale) y_nm = int(position["y"] * scale) size_nm = int(size * scale) diff --git a/python/commands/board/view.py b/python/commands/board/view.py index f6285d9..366c5f8 100644 --- a/python/commands/board/view.py +++ b/python/commands/board/view.py @@ -204,7 +204,9 @@ class BoardViewCommands: # Get unit preference (default to mm) unit = params.get("unit", "mm") - scale = 1000000 if unit == "mm" else 25400000 # nm to mm or inch + scale = ( + 1000000 if unit == "mm" else (25400 if unit == "mil" else 25400000) + ) # mm, mil, or inch to nm # Get board bounding box board_box = self.board.GetBoardEdgesBoundingBox() diff --git a/python/commands/component.py b/python/commands/component.py index a890fc8..1f5d22e 100644 --- a/python/commands/component.py +++ b/python/commands/component.py @@ -96,7 +96,11 @@ class ComponentCommands: } # Set position - scale = 1000000 if position["unit"] == "mm" else 25400000 # mm or inch to nm + scale = ( + 1000000 + if position["unit"] == "mm" + else (25400 if position["unit"] == "mil" else 25400000) + ) # mm, mil, or inch to nm x_nm = int(position["x"] * scale) y_nm = int(position["y"] * scale) module.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm)) @@ -196,7 +200,11 @@ class ComponentCommands: } # Set new position - scale = 1000000 if position["unit"] == "mm" else 25400000 # mm or inch to nm + scale = ( + 1000000 + if position["unit"] == "mm" + else (25400 if position["unit"] == "mil" else 25400000) + ) # mm, mil, or inch to nm x_nm = int(position["x"] * scale) y_nm = int(position["y"] * scale) module.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm)) @@ -987,7 +995,11 @@ class ComponentCommands: # Set position if provided, otherwise use offset from original if position: - scale = 1000000 if position.get("unit", "mm") == "mm" else 25400000 + scale = ( + 1000000 + if position.get("unit", "mm") == "mm" + else (25400 if position.get("unit", "mm") == "mil" else 25400000) + ) # mm, mil, or inch to nm x_nm = int(position["x"] * scale) y_nm = int(position["y"] * scale) new_module.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm)) @@ -1048,7 +1060,9 @@ class ComponentCommands: # Convert spacing to nm unit = start_position.get("unit", "mm") - scale = 1000000 if unit == "mm" else 25400000 # mm or inch to nm + scale = ( + 1000000 if unit == "mm" else (25400 if unit == "mil" else 25400000) + ) # mm, mil, or inch to nm spacing_x_nm = int(spacing_x * scale) spacing_y_nm = int(spacing_y * scale) diff --git a/python/commands/pin_locator.py b/python/commands/pin_locator.py index b17b7ae..e4ef9ee 100644 --- a/python/commands/pin_locator.py +++ b/python/commands/pin_locator.py @@ -92,7 +92,7 @@ class PinLocator: # legitimate same-length duplicates (e.g., per-unit # repetitions in multi-unit symbols) retain stable ordering. if pin_data["number"]: - existing = pins.get(pin_data["number"]) + existing = pins.get(str(pin_data["number"])) if existing is None or pin_data["length"] > existing["length"]: pins[pin_data["number"]] = pin_data diff --git a/python/commands/routing.py b/python/commands/routing.py index 689602f..23defb9 100644 --- a/python/commands/routing.py +++ b/python/commands/routing.py @@ -410,7 +410,11 @@ class RoutingCommands: "success": True, "message": "Added arc trace", "arc": { - "start": {"x": start_point.x / 1000000, "y": start_point.y / 1000000, "unit": "mm"}, + "start": { + "x": start_point.x / 1000000, + "y": start_point.y / 1000000, + "unit": "mm", + }, "mid": {"x": mid_point.x / 1000000, "y": mid_point.y / 1000000, "unit": "mm"}, "end": {"x": end_point.x / 1000000, "y": end_point.y / 1000000, "unit": "mm"}, "layer": layer, @@ -454,7 +458,11 @@ class RoutingCommands: via = pcbnew.PCB_VIA(self.board) # Set position - scale = 1000000 if position["unit"] == "mm" else 25400000 # mm or inch to nm + scale = ( + 1000000 + if position["unit"] == "mm" + else (25400 if position["unit"] == "mil" else 25400000) + ) # mm, mil, or inch to nm x_nm = int(position["x"] * scale) y_nm = int(position["y"] * scale) via.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm)) @@ -596,7 +604,11 @@ class RoutingCommands: # Find track by position if position: - scale = 1000000 if position["unit"] == "mm" else 25400000 # mm or inch to nm + scale = ( + 1000000 + if position["unit"] == "mm" + else (25400 if position["unit"] == "mil" else 25400000) + ) # mm, mil, or inch to nm x_nm = int(position["x"] * scale) y_nm = int(position["y"] * scale) point = pcbnew.VECTOR2I(x_nm, y_nm) @@ -713,7 +725,11 @@ class RoutingCommands: # Filter by bounding box if bbox: bbox_unit = bbox.get("unit", "mm") - bbox_scale = scale if bbox_unit == "mm" else 25400000 + bbox_scale = ( + scale + if bbox_unit == "mm" + else (25400 if bbox_unit == "mil" else 25400000) + ) x1 = int(bbox.get("x1", 0) * bbox_scale) y1 = int(bbox.get("y1", 0) * bbox_scale) x2 = int(bbox.get("x2", 0) * bbox_scale) @@ -837,7 +853,11 @@ class RoutingCommands: layer_names = [] try: layer_set = zone.GetLayerSet() - seq = layer_set.CuStack() if hasattr(layer_set, "CuStack") else layer_set.Seq() + seq = ( + layer_set.CuStack() + if hasattr(layer_set, "CuStack") + else layer_set.Seq() + ) for lid in seq: layer_names.append(self.board.GetLayerName(lid)) except Exception: @@ -862,7 +882,11 @@ class RoutingCommands: "net": z_net, "netCode": zone.GetNetCode(), "layers": layer_names, - "priority": zone.GetAssignedPriority() if hasattr(zone, "GetAssignedPriority") else 0, + "priority": ( + zone.GetAssignedPriority() + if hasattr(zone, "GetAssignedPriority") + else 0 + ), "isFilled": bool(zone.IsFilled()), "minThickness": zone.GetMinThickness() / scale, "boundingBox": { @@ -940,7 +964,9 @@ class RoutingCommands: break elif position: pos_unit = position.get("unit", "mm") - pos_scale = scale if pos_unit == "mm" else 25400000 + pos_scale = ( + scale if pos_unit == "mm" else (25400 if pos_unit == "mil" else 25400000) + ) x_nm = int(position["x"] * pos_scale) y_nm = int(position["y"] * pos_scale) point = pcbnew.VECTOR2I(x_nm, y_nm) @@ -1124,7 +1150,7 @@ class RoutingCommands: if track.GetNetname() not in source_nets: continue else: - # Fallback: geometric filter – trace start OR end inside source bbox + # Fallback: geometric filter — trace start OR end inside source bbox if is_via: pos = track.GetPosition() if not point_in_bbox(pos.x, pos.y): @@ -1422,7 +1448,11 @@ class RoutingCommands: # Add points to outline for point in points: - scale = 1000000 if point.get("unit", "mm") == "mm" else 25400000 + scale = ( + 1000000 + if point.get("unit", "mm") == "mm" + else (25400 if point.get("unit", "mm") == "mil" else 25400000) + ) x_nm = int(point["x"] * scale) y_nm = int(point["y"] * scale) outline.Append(pcbnew.VECTOR2I(x_nm, y_nm)) # Add point to outline @@ -1605,7 +1635,11 @@ class RoutingCommands: def _get_point(self, point_spec: Dict[str, Any]) -> pcbnew.VECTOR2I: """Convert point specification to KiCAD point""" if "x" in point_spec and "y" in point_spec: - scale = 1000000 if point_spec.get("unit", "mm") == "mm" else 25400000 + scale = ( + 1000000 + if point_spec.get("unit", "mm") == "mm" + else (25400 if point_spec.get("unit", "mm") == "mil" else 25400000) + ) x_nm = int(point_spec["x"] * scale) y_nm = int(point_spec["y"] * scale) return pcbnew.VECTOR2I(x_nm, y_nm) @@ -1721,9 +1755,8 @@ class RoutingCommands: return self._do_add_gnd_stitching(params) except Exception as e: import traceback - logger.error( - f"add_gnd_stitching_vias failed: {e}\n{traceback.format_exc()}" - ) + + logger.error(f"add_gnd_stitching_vias failed: {e}\n{traceback.format_exc()}") return { "success": False, "message": "add_gnd_stitching_vias failed", @@ -1791,8 +1824,7 @@ class RoutingCommands: "success": False, "message": "No GND net detected", "errorDetails": ( - "Pass gndNet explicitly. Auto-detect tries " - "GND / GROUND / VSS / /GND." + "Pass gndNet explicitly. Auto-detect tries " "GND / GROUND / VSS / /GND." ), } gnd_net_code = gnd_net.GetNetCode() @@ -1866,10 +1898,7 @@ class RoutingCommands: ) # --- In-zone test (cached per call) --- - gnd_zones = [ - z for z in self.board.Zones() - if z.GetNetCode() == gnd_net_code - ] + gnd_zones = [z for z in self.board.Zones() if z.GetNetCode() == gnd_net_code] def in_any_gnd_zone(x_nm: int, y_nm: int) -> bool: pt = pcbnew.VECTOR2I(x_nm, y_nm) @@ -1880,8 +1909,10 @@ class RoutingCommands: except Exception: # API variant: take any zone in whose bbox we sit bb = z.GetBoundingBox() - if (bb.GetLeft() <= x_nm <= bb.GetRight() - and bb.GetTop() <= y_nm <= bb.GetBottom()): + if ( + bb.GetLeft() <= x_nm <= bb.GetRight() + and bb.GetTop() <= y_nm <= bb.GetBottom() + ): return True return False @@ -1929,9 +1960,7 @@ class RoutingCommands: candidates: List[tuple] = [] if "around_refs" in strategies: if not densify_refs: - logger.warning( - "around_refs strategy requested but densifyRefs is empty" - ) + logger.warning("around_refs strategy requested but densifyRefs is empty") fps_by_ref = {fp.GetReference(): fp for fp in self.board.GetFootprints()} for ref in densify_refs: fp = fps_by_ref.get(ref) @@ -1969,11 +1998,13 @@ class RoutingCommands: skipped_by_collision += 1 continue placed_via_centres.append((cx, cy)) - placed_meta.append({ - "x": round(cx / scale, 3), - "y": round(cy / scale, 3), - "unit": "mm", - }) + placed_meta.append( + { + "x": round(cx / scale, 3), + "y": round(cy / scale, 3), + "unit": "mm", + } + ) # --- Write to board --- if not dry_run: @@ -2011,9 +2042,8 @@ class RoutingCommands: # Module-level geometry helper (used by add_gnd_stitching_vias collision check) # --------------------------------------------------------------------------- -def _point_to_segment_distance_nm( - px: int, py: int, x1: int, y1: int, x2: int, y2: int -) -> float: + +def _point_to_segment_distance_nm(px: int, py: int, x1: int, y1: int, x2: int, y2: int) -> float: """Shortest distance (nm) from point (px,py) to segment (x1,y1)-(x2,y2). Pure integer-friendly variant of the standard projection formula; @@ -2023,8 +2053,8 @@ def _point_to_segment_distance_nm( dx = x2 - x1 dy = y2 - y1 if dx == 0 and dy == 0: - ex = px - x1 - ey = py - y1 + ex: float = px - x1 + ey: float = py - y1 return (ex * ex + ey * ey) ** 0.5 denom = dx * dx + dy * dy t = ((px - x1) * dx + (py - y1) * dy) / denom diff --git a/python/kicad_interface.py b/python/kicad_interface.py index d197068..75d3ea4 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -5291,10 +5291,13 @@ print("ok") layer = params.get("layer", "F.Cu") value = params.get("value", "") - # Convert inches to mm since ipc_backend expects mm + # Convert to mm since ipc_backend expects mm if unit == "inch": x = x * 25.4 y = y * 25.4 + elif unit == "mil": + x = x * 0.0254 + y = y * 0.0254 success = self.ipc_board_api.place_component( reference=reference, @@ -5335,10 +5338,13 @@ print("ok") unit = position.get("unit", "mm") if isinstance(position, dict) else "mm" rotation = params.get("rotation") - # Convert inches to mm since ipc_backend.move_component expects mm + # Convert to mm since ipc_backend.move_component expects mm if unit == "inch": x = x * 25.4 y = y * 25.4 + elif unit == "mil": + x = x * 0.0254 + y = y * 0.0254 success = self.ipc_board_api.move_component( reference=reference, x=x, y=y, rotation=rotation diff --git a/python/schemas/tool_schemas.py b/python/schemas/tool_schemas.py index 952279c..f841bec 100644 --- a/python/schemas/tool_schemas.py +++ b/python/schemas/tool_schemas.py @@ -286,7 +286,7 @@ BOARD_TOOLS = [ "properties": { "unit": { "type": "string", - "enum": ["mm", "inch"], + "enum": ["mm", "mil", "inch"], "description": "Unit for returned coordinates (default: mm)", "default": "mm", } @@ -934,7 +934,7 @@ ROUTING_TOOLS = [ "y": {"type": "number", "description": "Y coordinate"}, "unit": { "type": "string", - "enum": ["mm", "inch"], + "enum": ["mm", "mil", "inch"], "default": "mm", }, }, @@ -981,7 +981,7 @@ ROUTING_TOOLS = [ "y2": {"type": "number", "description": "Bottom Y coordinate"}, "unit": { "type": "string", - "enum": ["mm", "inch"], + "enum": ["mm", "mil", "inch"], "default": "mm", }, }, @@ -1162,7 +1162,7 @@ ROUTING_TOOLS = [ "y": {"type": "number", "description": "Y coordinate"}, "unit": { "type": "string", - "enum": ["mm", "inch"], + "enum": ["mm", "mil", "inch"], "default": "mm", }, }, diff --git a/src/tools/board.ts b/src/tools/board.ts index 0525cf7..db46b97 100644 --- a/src/tools/board.ts +++ b/src/tools/board.ts @@ -29,7 +29,7 @@ export function registerBoardTools(server: McpServer, callKicadScript: CommandFu { width: z.number().describe("Board width"), height: z.number().describe("Board height"), - unit: z.enum(["mm", "inch"]).describe("Unit of measurement"), + unit: z.enum(["mm", "mil", "inch"]).describe("Unit of measurement"), }, async ({ width, height, unit }) => { logger.debug(`Setting board size to ${width}x${height} ${unit}`); @@ -181,7 +181,7 @@ export function registerBoardTools(server: McpServer, callKicadScript: CommandFu // Position: top-left corner for rectangles/rounded_rectangle, center for circle x: z.number().describe("X coordinate of top-left corner for rectangles (default: 0)"), y: z.number().describe("Y coordinate of top-left corner for rectangles (default: 0)"), - unit: z.enum(["mm", "inch"]).describe("Unit of measurement"), + unit: z.enum(["mm", "mil", "inch"]).describe("Unit of measurement"), }) .describe("Parameters for the outline shape"), }, @@ -216,7 +216,7 @@ export function registerBoardTools(server: McpServer, callKicadScript: CommandFu .object({ x: z.number().describe("X coordinate"), y: z.number().describe("Y coordinate"), - unit: z.enum(["mm", "inch"]).describe("Unit of measurement"), + unit: z.enum(["mm", "mil", "inch"]).describe("Unit of measurement"), }) .describe("Position of the mounting hole"), diameter: z.number().describe("Diameter of the hole"), @@ -253,7 +253,7 @@ export function registerBoardTools(server: McpServer, callKicadScript: CommandFu .object({ x: z.number().describe("X coordinate"), y: z.number().describe("Y coordinate"), - unit: z.enum(["mm", "inch"]).describe("Unit of measurement"), + unit: z.enum(["mm", "mil", "inch"]).describe("Unit of measurement"), }) .describe("Position of the text"), layer: z.string().describe("Layer to place the text on"), @@ -302,7 +302,7 @@ export function registerBoardTools(server: McpServer, callKicadScript: CommandFu }), ) .describe("Points defining the zone outline"), - unit: z.enum(["mm", "inch"]).describe("Unit of measurement"), + unit: z.enum(["mm", "mil", "inch"]).describe("Unit of measurement"), clearance: z.number().optional().describe("Clearance value"), minWidth: z.number().optional().describe("Minimum width"), padConnection: z @@ -340,7 +340,7 @@ export function registerBoardTools(server: McpServer, callKicadScript: CommandFu "get_board_extents", "Return the bounding box (min/max X and Y) of all objects on the current PCB board.", { - unit: z.enum(["mm", "inch"]).optional().describe("Unit of measurement for the result"), + unit: z.enum(["mm", "mil", "inch"]).optional().describe("Unit of measurement for the result"), }, async ({ unit }) => { logger.debug("Getting board extents"); diff --git a/src/tools/component.ts b/src/tools/component.ts index 0e00b6c..9ea3d20 100644 --- a/src/tools/component.ts +++ b/src/tools/component.ts @@ -32,7 +32,7 @@ export function registerComponentTools(server: McpServer, callKicadScript: Comma .object({ x: z.number().describe("X coordinate"), y: z.number().describe("Y coordinate"), - unit: z.enum(["mm", "inch"]).describe("Unit of measurement"), + unit: z.enum(["mm", "inch", "mil"]).describe("Unit of measurement"), }) .describe("Position coordinates and unit"), reference: z.string().optional().describe("Optional desired reference (e.g., 'R5')"), @@ -85,7 +85,7 @@ export function registerComponentTools(server: McpServer, callKicadScript: Comma .object({ x: z.number().describe("X coordinate"), y: z.number().describe("Y coordinate"), - unit: z.enum(["mm", "inch"]).describe("Unit of measurement"), + unit: z.enum(["mm", "inch", "mil"]).describe("Unit of measurement"), }) .describe("New position coordinates and unit"), rotation: z.number().optional().describe("Optional new rotation in degrees"), @@ -359,7 +359,7 @@ export function registerComponentTools(server: McpServer, callKicadScript: Comma "Return all pads of a PCB component with their positions, net assignments and sizes.", { reference: z.string().describe("Reference designator of the component (e.g., 'U1')"), - unit: z.enum(["mm", "inch"]).optional().describe("Unit for coordinates (default: mm)"), + unit: z.enum(["mm", "mil", "inch"]).optional().describe("Unit for coordinates (default: mm)"), }, async ({ reference, unit }) => { logger.debug(`Getting pads for component: ${reference}`); @@ -393,11 +393,11 @@ export function registerComponentTools(server: McpServer, callKicadScript: Comma y1: z.number(), x2: z.number(), y2: z.number(), - unit: z.enum(["mm", "inch"]).optional(), + unit: z.enum(["mm", "inch", "mil"]).optional(), }) .optional() .describe("Filter by bounding box region"), - unit: z.enum(["mm", "inch"]).optional().describe("Unit for coordinates (default: mm)"), + unit: z.enum(["mm", "mil", "inch"]).optional().describe("Unit for coordinates (default: mm)"), }, async ({ layer, boundingBox, unit }) => { logger.debug("Getting component list"); @@ -427,7 +427,7 @@ export function registerComponentTools(server: McpServer, callKicadScript: Comma { reference: z.string().describe("Component reference designator (e.g., 'U1')"), pad: z.string().describe("Pad number or name (e.g., '1', 'A1')"), - unit: z.enum(["mm", "inch"]).optional().describe("Unit for coordinates (default: mm)"), + unit: z.enum(["mm", "mil", "inch"]).optional().describe("Unit for coordinates (default: mm)"), }, async ({ reference, pad, unit }) => { logger.debug(`Getting pad position for ${reference} pad ${pad}`); @@ -460,7 +460,7 @@ export function registerComponentTools(server: McpServer, callKicadScript: Comma .object({ x: z.number(), y: z.number(), - unit: z.enum(["mm", "inch"]), + unit: z.enum(["mm", "inch", "mil"]), }) .describe("Starting position"), rows: z.number().describe("Number of rows"), @@ -617,7 +617,7 @@ export function registerComponentTools(server: McpServer, callKicadScript: Comma .object({ x: z.number(), y: z.number(), - unit: z.enum(["mm", "inch"]).optional(), + unit: z.enum(["mm", "inch", "mil"]).optional(), }) .describe("Offset from original position"), newReference: z.string().optional().describe("New reference designator"), diff --git a/src/tools/design-rules.ts b/src/tools/design-rules.ts index c3ed16c..030c739 100644 --- a/src/tools/design-rules.ts +++ b/src/tools/design-rules.ts @@ -253,7 +253,7 @@ export function registerDesignRuleTools(server: McpServer, callKicadScript: Comm .object({ x: z.number().optional(), y: z.number().optional(), - unit: z.enum(["mm", "inch"]).optional(), + unit: z.enum(["mm", "mil", "inch"]).optional(), }) .optional() .describe("Position to check (if ID not provided)"), @@ -270,7 +270,7 @@ export function registerDesignRuleTools(server: McpServer, callKicadScript: Comm .object({ x: z.number().optional(), y: z.number().optional(), - unit: z.enum(["mm", "inch"]).optional(), + unit: z.enum(["mm", "mil", "inch"]).optional(), }) .optional() .describe("Position to check (if ID not provided)"), diff --git a/src/tools/export.ts b/src/tools/export.ts index 2be9625..41d3f90 100644 --- a/src/tools/export.ts +++ b/src/tools/export.ts @@ -264,7 +264,7 @@ export function registerExportTools(server: McpServer, callKicadScript: CommandF { outputPath: z.string().describe("Path to save the position file"), format: z.enum(["CSV", "ASCII"]).optional().describe("File format (default: CSV)"), - units: z.enum(["mm", "inch"]).optional().describe("Units to use (default: mm)"), + units: z.enum(["mm", "mil", "inch"]).optional().describe("Units to use (default: mm)"), side: z .enum(["top", "bottom", "both"]) .optional() diff --git a/src/tools/routing.ts b/src/tools/routing.ts index 4fc1cd4..b9eb351 100644 --- a/src/tools/routing.ts +++ b/src/tools/routing.ts @@ -172,7 +172,7 @@ export function registerRoutingTools(server: McpServer, callKicadScript: Functio .object({ x: z.number(), y: z.number(), - unit: z.enum(["mm", "inch"]).optional(), + unit: z.enum(["mm", "inch", "mil"]).optional(), }) .optional() .describe("Delete trace nearest to this position"), @@ -206,11 +206,11 @@ export function registerRoutingTools(server: McpServer, callKicadScript: Functio y1: z.number(), x2: z.number(), y2: z.number(), - unit: z.enum(["mm", "inch"]).optional(), + unit: z.enum(["mm", "inch", "mil"]).optional(), }) .optional() .describe("Filter by bounding box region"), - unit: z.enum(["mm", "inch"]).optional().describe("Unit for coordinates"), + unit: z.enum(["mm", "inch", "mil"]).optional().describe("Unit for coordinates"), }, async (args: any) => { const result = await callKicadScript("query_traces", args); @@ -358,7 +358,7 @@ export function registerRoutingTools(server: McpServer, callKicadScript: Functio .boolean() .optional() .describe("Include statistics (track count, total length, etc.)"), - unit: z.enum(["mm", "inch"]).optional().describe("Unit for length measurements"), + unit: z.enum(["mm", "mil", "inch"]).optional().describe("Unit for length measurements"), }, async (args: any) => { const result = await callKicadScript("get_nets_list", args); diff --git a/tests/test_mil_unit_support.py b/tests/test_mil_unit_support.py new file mode 100644 index 0000000..81c944b --- /dev/null +++ b/tests/test_mil_unit_support.py @@ -0,0 +1,106 @@ +"""Tests for ``mil`` unit support across position/coordinate commands. + +KiCad natively understands mils (1 mil = 0.0254 mm = 25 400 nm). The MCP +server now accepts ``"mil"`` as a value of the ``unit`` field everywhere a +position or coordinate is specified. Tests below assert: + + - The ``unit→nanometer`` scale used in command handlers maps mil → 25 400. + - The IPC handler converts mil positions to mm before calling the IPC API. + - The Python tool schema enums include ``"mil"`` (it was previously + restricted to ``["mm", "inch"]``). +""" + +import sys +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent / "python")) + +# Scale used by the per-command handlers when they convert position values +# to KiCad internal nanometers. +MIL_TO_NM = 25_400 +MM_TO_NM = 1_000_000 +INCH_TO_NM = 25_400_000 + + +def _scale(unit: str) -> int: + """Mirror of the inline ternary used in commands/* for position scaling.""" + return 1_000_000 if unit == "mm" else (25_400 if unit == "mil" else 25_400_000) + + +def test_scale_mapping_includes_mil(): + assert _scale("mm") == MM_TO_NM + assert _scale("mil") == MIL_TO_NM + assert _scale("inch") == INCH_TO_NM + + +def test_one_thousand_mil_equals_one_inch_in_nm(): + """Sanity check: 1000 mil should produce the same nm offset as 1 inch.""" + one_inch = 1 * _scale("inch") + one_thousand_mil = 1000 * _scale("mil") + assert one_inch == one_thousand_mil + + +def _make_iface() -> Any: + with patch("kicad_interface.USE_IPC_BACKEND", True): + from kicad_interface import KiCADInterface + + iface = KiCADInterface.__new__(KiCADInterface) + iface.use_ipc = True + iface.board = None + iface.ipc_board_api = MagicMock() + iface.ipc_board_api.place_component.return_value = True + iface.ipc_board_api.move_component.return_value = True + return iface + + +def test_ipc_place_component_converts_mil_to_mm(): + iface = _make_iface() + iface._ipc_place_component( + { + "reference": "R1", + "footprint": "Resistor_SMD:R_0805", + "position": {"x": 100, "y": 200, "unit": "mil"}, + "rotation": 0, + "layer": "F.Cu", + "value": "220", + } + ) + _, kwargs = iface.ipc_board_api.place_component.call_args + assert kwargs["x"] == pytest.approx(2.54) + assert kwargs["y"] == pytest.approx(5.08) + + +def test_ipc_move_component_converts_mil_to_mm(): + iface = _make_iface() + iface._ipc_move_component({"reference": "R1", "position": {"x": 1000, "y": 500, "unit": "mil"}}) + _, kwargs = iface.ipc_board_api.move_component.call_args + assert kwargs["x"] == pytest.approx(25.4) + assert kwargs["y"] == pytest.approx(12.7) + + +def test_python_tool_schema_unit_enums_include_mil(): + """Every unit enum in TOOL_SCHEMAS must include mil.""" + from schemas.tool_schemas import TOOL_SCHEMAS + + def _find_unit_enums(node): + """Walk the schema and yield every enum list found under 'unit' fields.""" + if isinstance(node, dict): + for k, v in node.items(): + if k == "unit" and isinstance(v, dict) and "enum" in v: + yield v["enum"] + else: + yield from _find_unit_enums(v) + elif isinstance(node, list): + for item in node: + yield from _find_unit_enums(item) + + enums = list(_find_unit_enums(TOOL_SCHEMAS)) + assert enums, "Expected at least one unit enum in TOOL_SCHEMAS" + for enum in enums: + assert "mil" in enum, f"Unit enum {enum} is missing 'mil'" + # Sanity: mm and inch should still be present too + assert "mm" in enum and "inch" in enum, f"Unit enum {enum} is malformed"