Add query_zones tool for auditing copper pours (#174)
query_traces silently omits PCB_ZONE_T objects, so layer-usage audits miss power planes and GND pours entirely. query_zones complements it by iterating board.Zones() and returning each zone's net, layers, priority, fill state, min thickness, bounding box, and filled area, with the same net/layer/boundingBox filter surface as query_traces.
This commit is contained in:
@@ -35,6 +35,13 @@ All notable changes to the KiCAD MCP Server project are documented here.
|
|||||||
|
|
||||||
### New MCP Tools
|
### New MCP Tools
|
||||||
|
|
||||||
|
- `query_zones` — Query copper zones (filled pours) on the board with optional
|
||||||
|
filters by net, layer, or bounding box. Returns one entry per zone with its
|
||||||
|
net, layers, priority, fill state, min thickness, bounding box, and filled
|
||||||
|
area. Complements `query_traces`, which only reports tracks/vias and silently
|
||||||
|
omits power-plane and GND pours — making layer-usage audits incomplete on any
|
||||||
|
board that uses copper zones.
|
||||||
|
|
||||||
- `set_schematic_component_property` — Add or update a single custom property
|
- `set_schematic_component_property` — Add or update a single custom property
|
||||||
(BOM / sourcing field) on a placed schematic symbol. Convenience wrapper
|
(BOM / sourcing field) on a placed schematic symbol. Convenience wrapper
|
||||||
around `edit_schematic_component` for the common case of attaching one MPN /
|
around `edit_schematic_component` for the common case of attaching one MPN /
|
||||||
|
|||||||
@@ -791,6 +791,113 @@ class RoutingCommands:
|
|||||||
"errorDetails": str(e),
|
"errorDetails": str(e),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def query_zones(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Query copper zones (filled pours) by net, layer, or bounding box.
|
||||||
|
|
||||||
|
Returns one entry per zone with its net, layers, priority, fill state,
|
||||||
|
and bounding box. Useful for auditing power planes / GND pours that
|
||||||
|
``query_traces`` does not report (zones are PCB_ZONE_T, not tracks).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not self.board:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "No board is loaded",
|
||||||
|
"errorDetails": "Load or create a board first",
|
||||||
|
}
|
||||||
|
|
||||||
|
net_name = params.get("net")
|
||||||
|
layer = params.get("layer")
|
||||||
|
bbox = params.get("boundingBox")
|
||||||
|
|
||||||
|
scale = 1000000 # nm -> mm
|
||||||
|
target_layer_id = None
|
||||||
|
if layer:
|
||||||
|
target_layer_id = self.board.GetLayerID(layer)
|
||||||
|
|
||||||
|
bbox_box = None
|
||||||
|
if bbox:
|
||||||
|
bbox_unit = bbox.get("unit", "mm")
|
||||||
|
bbox_scale = scale if bbox_unit == "mm" else 25400000
|
||||||
|
bbox_box = (
|
||||||
|
int(bbox.get("x1", 0) * bbox_scale),
|
||||||
|
int(bbox.get("y1", 0) * bbox_scale),
|
||||||
|
int(bbox.get("x2", 0) * bbox_scale),
|
||||||
|
int(bbox.get("y2", 0) * bbox_scale),
|
||||||
|
)
|
||||||
|
|
||||||
|
zones_out = []
|
||||||
|
for zone in list(self.board.Zones()):
|
||||||
|
try:
|
||||||
|
z_net = zone.GetNetname()
|
||||||
|
if net_name and z_net != net_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# A zone can span multiple copper layers; collect them.
|
||||||
|
layer_names = []
|
||||||
|
try:
|
||||||
|
layer_set = zone.GetLayerSet()
|
||||||
|
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:
|
||||||
|
layer_names = [self.board.GetLayerName(zone.GetLayer())]
|
||||||
|
|
||||||
|
if target_layer_id is not None:
|
||||||
|
if target_layer_id not in [self.board.GetLayerID(n) for n in layer_names]:
|
||||||
|
continue
|
||||||
|
|
||||||
|
bb = zone.GetBoundingBox()
|
||||||
|
bb_x1, bb_y1 = bb.GetLeft(), bb.GetTop()
|
||||||
|
bb_x2, bb_y2 = bb.GetRight(), bb.GetBottom()
|
||||||
|
|
||||||
|
if bbox_box is not None:
|
||||||
|
x1, y1, x2, y2 = bbox_box
|
||||||
|
# Reject if no overlap with filter bbox.
|
||||||
|
if bb_x2 < x1 or bb_x1 > x2 or bb_y2 < y1 or bb_y1 > y2:
|
||||||
|
continue
|
||||||
|
|
||||||
|
entry = {
|
||||||
|
"uuid": zone.m_Uuid.AsString(),
|
||||||
|
"net": z_net,
|
||||||
|
"netCode": zone.GetNetCode(),
|
||||||
|
"layers": layer_names,
|
||||||
|
"priority": zone.GetAssignedPriority() if hasattr(zone, "GetAssignedPriority") else 0,
|
||||||
|
"isFilled": bool(zone.IsFilled()),
|
||||||
|
"minThickness": zone.GetMinThickness() / scale,
|
||||||
|
"boundingBox": {
|
||||||
|
"x1": bb_x1 / scale,
|
||||||
|
"y1": bb_y1 / scale,
|
||||||
|
"x2": bb_x2 / scale,
|
||||||
|
"y2": bb_y2 / scale,
|
||||||
|
"unit": "mm",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
# Area is only available when zone is filled.
|
||||||
|
try:
|
||||||
|
entry["filledArea"] = zone.GetFilledArea() / (scale * scale)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
zones_out.append(entry)
|
||||||
|
except Exception as zone_err:
|
||||||
|
logger.warning(f"Skipping invalid zone object: {zone_err}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"zoneCount": len(zones_out),
|
||||||
|
"zones": zones_out,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error querying zones: {str(e)}")
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "Failed to query zones",
|
||||||
|
"errorDetails": str(e),
|
||||||
|
}
|
||||||
|
|
||||||
def modify_trace(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
def modify_trace(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Modify properties of an existing trace
|
"""Modify properties of an existing trace
|
||||||
|
|
||||||
|
|||||||
@@ -368,6 +368,7 @@ class KiCADInterface:
|
|||||||
"add_via": self.routing_commands.add_via,
|
"add_via": self.routing_commands.add_via,
|
||||||
"delete_trace": self.routing_commands.delete_trace,
|
"delete_trace": self.routing_commands.delete_trace,
|
||||||
"query_traces": self.routing_commands.query_traces,
|
"query_traces": self.routing_commands.query_traces,
|
||||||
|
"query_zones": self.routing_commands.query_zones,
|
||||||
"modify_trace": self.routing_commands.modify_trace,
|
"modify_trace": self.routing_commands.modify_trace,
|
||||||
"copy_routing_pattern": self.routing_commands.copy_routing_pattern,
|
"copy_routing_pattern": self.routing_commands.copy_routing_pattern,
|
||||||
"get_nets_list": self.routing_commands.get_nets_list,
|
"get_nets_list": self.routing_commands.get_nets_list,
|
||||||
|
|||||||
@@ -912,6 +912,39 @@ ROUTING_TOOLS = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "query_zones",
|
||||||
|
"title": "Query Zones",
|
||||||
|
"description": "Queries copper zones (filled pours) on the board with optional filters by net, layer, or bounding box. Returns one entry per zone with net, layers, priority, fill state, and bounding box. Useful for auditing power planes and GND pours, which 'query_traces' does not include.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"net": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Filter by net name (e.g., 'GND', '+3V3')",
|
||||||
|
},
|
||||||
|
"layer": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Filter by layer name (e.g., 'In1.Cu', 'B.Cu'). Matches zones that include this layer in their layer set.",
|
||||||
|
},
|
||||||
|
"boundingBox": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Filter to zones whose bounding box overlaps this region",
|
||||||
|
"properties": {
|
||||||
|
"x1": {"type": "number", "description": "Left X coordinate"},
|
||||||
|
"y1": {"type": "number", "description": "Top Y coordinate"},
|
||||||
|
"x2": {"type": "number", "description": "Right X coordinate"},
|
||||||
|
"y2": {"type": "number", "description": "Bottom Y coordinate"},
|
||||||
|
"unit": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["mm", "inch"],
|
||||||
|
"default": "mm",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "modify_trace",
|
"name": "modify_trace",
|
||||||
"title": "Modify Trace",
|
"title": "Modify Trace",
|
||||||
|
|||||||
@@ -225,6 +225,40 @@ export function registerRoutingTools(server: McpServer, callKicadScript: Functio
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Query zones tool
|
||||||
|
server.tool(
|
||||||
|
"query_zones",
|
||||||
|
"Query copper zones (filled pours) on the board with optional filters by net, layer, or bounding box. Returns zone net, layers, priority, fill state, and bounding box. Useful for auditing power planes and GND pours that query_traces does not include.",
|
||||||
|
{
|
||||||
|
net: z.string().optional().describe("Filter by net name"),
|
||||||
|
layer: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Filter by layer name (matches zones that include this layer)"),
|
||||||
|
boundingBox: z
|
||||||
|
.object({
|
||||||
|
x1: z.number(),
|
||||||
|
y1: z.number(),
|
||||||
|
x2: z.number(),
|
||||||
|
y2: z.number(),
|
||||||
|
unit: z.enum(["mm", "inch"]).optional(),
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.describe("Filter to zones whose bounding box overlaps this region"),
|
||||||
|
},
|
||||||
|
async (args: any) => {
|
||||||
|
const result = await callKicadScript("query_zones", args);
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify(result, null, 2),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Get nets list tool
|
// Get nets list tool
|
||||||
server.tool(
|
server.tool(
|
||||||
"get_nets_list",
|
"get_nets_list",
|
||||||
|
|||||||
Reference in New Issue
Block a user