Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
"""Read/write ``.kicad_dru`` custom design-rule files.
KiCad's per-layer/conditional DRC constraints live in a project-scoped
``.kicad_dru`` text file (an S-expression rules DSL), sibling to the
``.kicad_pcb``/``.kicad_pro`` with the same base filename — e.g.
``myproject.kicad_dru`` next to ``myproject.kicad_pcb``. This is not exposed
through any pcbnew SWIG API: there is no ``DRC_RULE``/rules-parsing class in
the bindings, so the write path here is pure text manipulation, not a live
board call. ``kicad-cli pcb drc`` auto-discovers the sibling file by
convention (there is no ``--rules``/``--dru`` flag), so writing this file is
the entire integration surface.
Verified against real KiCad 10 (``kicad-cli pcb drc``) with a deliberately
strict rule: all four constraint types below (``track_width``, ``clearance``,
``via_diameter``, ``hole_size``) are recognized and enforced, and violations
cite the rule by name (``rule 'mcp_layer_constraint_F.Cu' ...``).
These helpers do a surgical text edit (find-and-replace-by-name, or append)
rather than a full parse/reserialize, so a hand-edited file's other rules,
comments, and formatting survive untouched — matching how this codebase
already edits ``.kicad_sch`` files (see ``schematic_text_utils.py``) rather
than round-tripping them through a full S-expression reserializer.
"""
import os
import tempfile
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
# Rule names generated by this module, so a later call for the same layer
# replaces its own rule instead of duplicating it.
_RULE_NAME_PREFIX = "mcp_layer_constraint_"
def rule_name_for_layer(layer: str) -> str:
return f"{_RULE_NAME_PREFIX}{layer}"
def resolve_dru_path(board_path: Optional[str]) -> Optional[str]:
"""Derive the sibling ``.kicad_dru`` path from a ``.kicad_pcb`` path."""
if not board_path or not board_path.endswith(".kicad_pcb"):
return None
return str(Path(board_path).with_suffix(".kicad_dru"))
def _find_matching_paren(s: str, start: int) -> int:
"""Return index of the ')' matching the '(' at position *start*, or -1."""
depth = 0
for i in range(start, len(s)):
if s[i] == "(":
depth += 1
elif s[i] == ")":
depth -= 1
if depth == 0:
return i
return -1
def _find_rule_block(content: str, rule_name: str) -> Tuple[int, int]:
"""Find a ``(rule "rule_name" ...)`` block.
Returns ``(start, end)`` character offsets (``end`` is exclusive, one
past the closing paren), or ``(-1, -1)`` if the rule isn't present.
"""
marker = f'(rule "{rule_name}"'
pos = content.find(marker)
if pos == -1:
return -1, -1
end = _find_matching_paren(content, pos)
if end == -1:
return -1, -1
return pos, end + 1
def build_layer_constraint_rule(
layer: str,
min_track_width: Optional[float] = None,
min_clearance: Optional[float] = None,
min_via_diameter: Optional[float] = None,
min_via_drill: Optional[float] = None,
) -> str:
"""Build a ``(rule ...)`` block enforcing per-layer minimums.
``(layer "<layer>")`` alone scopes every constraint to that layer — no
``condition`` clause is needed since we're not filtering by net/diff-pair,
just setting a floor for the whole layer.
"""
constraints: List[str] = []
if min_track_width is not None:
constraints.append(f" (constraint track_width (min {min_track_width}mm))")
if min_clearance is not None:
constraints.append(f" (constraint clearance (min {min_clearance}mm))")
if min_via_diameter is not None:
constraints.append(f" (constraint via_diameter (min {min_via_diameter}mm))")
if min_via_drill is not None:
constraints.append(f" (constraint hole_size (min {min_via_drill}mm))")
lines = [
f'(rule "{rule_name_for_layer(layer)}"',
f' (layer "{layer}")',
*constraints,
")",
]
return "\n".join(lines)
def apply_layer_constraint_rule(dru_text: str, rule_name: str, rule_text: str) -> str:
"""Insert or replace a named rule block in ``dru_text``. Pure string transform."""
start, end = _find_rule_block(dru_text, rule_name)
if start != -1:
return dru_text[:start] + rule_text + dru_text[end:]
text = dru_text.rstrip("\n")
if not text:
text = "(version 1)"
return text + "\n\n" + rule_text + "\n"
def persist_layer_constraint_rule(
dru_path: Optional[str], rule_name: str, rule_text: str
) -> Dict[str, Any]:
"""Read/modify/write ``dru_path`` (creating it if absent). Atomic write.
Returns ``{"persisted": bool, "druFile"?: str, "warning"?: str}``. Never
raises: a persistence failure is reported, not fatal.
"""
if not dru_path:
return {
"persisted": False,
"warning": "could not determine .kicad_dru path (board has no .kicad_pcb file name)",
}
try:
if os.path.exists(dru_path):
with open(dru_path, "r", encoding="utf-8") as f:
existing = f.read()
else:
existing = "(version 1)\n"
new_text = apply_layer_constraint_rule(existing, rule_name, rule_text)
directory = os.path.dirname(dru_path) or "."
fd, tmp_path = tempfile.mkstemp(dir=directory, prefix=".dru-", suffix=".kicad_dru")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(new_text)
os.replace(tmp_path, dru_path)
except Exception:
try:
os.remove(tmp_path)
except OSError:
pass
raise
return {"persisted": True, "druFile": dru_path}
except Exception as exc: # report, never fail the command on a persistence error
return {
"persisted": False,
"warning": f"could not persist layer constraint to {dru_path}: {exc}",
}
670d2a9c4This file is pinned to the selected release. Download the original to open it in your design tool.