Last active
February 17, 2026 22:11
-
-
Save LeoDJ/ab902d6e3fc2aaf112093180ae89e8e0 to your computer and use it in GitHub Desktop.
KiCad 9 script to duplicate and arrange footprints along a drawing line / path
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| KiCad 9 PCBnew Script: Duplicate Footprint Along a Drawing Path | |
| Author: LeoDJ | |
| Disclaimer: Dirtily hacked together and partly vibe coded. | |
| I've tried to use the attached prompt, but the result was shit, I had to fix many logic mistakes / API hallucinations. Probably would've been faster to actually write it completely myself. | |
| Usage: | |
| - Save this script as footprint_path_placer.py besides the .kicad_pro file | |
| - In the PCB Editor, move a footprint you want to arrange on top of a drawing line and select the footprint | |
| - Run this script from Tools -> External Plugins -> Run Script -> | |
| - exec(open('footprint_path_placer.py').read()); place(spacing_mm=10.00); | |
| - (Adjust spacing_mm parameter as needed) | |
| - It will duplicate the selected footprint along the path underneath while incrementing its reference | |
| - Now you only need to run "Update PCB from Schematic" with the "Re-link footprints to schematic symbols based on their reference designators" option checked | |
| - If you need to run this script again: | |
| - Delete the placed components manually | |
| - Simply run again with just `place(10.00)`, you don't need to load it again | |
| Alternate usage: | |
| - Save script in Tools -> Plugins -> Open Plugins Directory | |
| - Tools -> Plugins -> Refresh Plugins | |
| - Launch Script from toolbar | |
| - Improvements: You can now undo what the script did | |
| Optional improvements for a potential future version™: | |
| - Group the output together | |
| - Also allow for groups of footprints to be duplicated | |
| Prompt: | |
| Write a KiCad 9 pcbnew Script. | |
| It should get an already selected footprint. | |
| This footprint is located on top of a drawing segment. | |
| This drawing segment is part of a larger loop of connected drawing segments. | |
| It should get all points of the full loop in a clockwise order, starting from the segment where the footprint is located. | |
| Additionally it should snap/move the footprint exactly onto the segment and snap its rotation to the segment too. (but by applying a rotation of up to +-45°, thus preserving the rough initial rotation) | |
| Also it should then duplicate the footprint along the path given by the drawing segments in a spacing given by the user (e.g. at the top of the script) while incrementing the reference. | |
| It should be a simple script, no plugin. | |
| """ | |
| import pcbnew | |
| import wx | |
| import math | |
| # USER CONFIGURABLE PARAMETERS | |
| SPACING_MM = 10.0 # Distance between duplicated footprints in millimeters | |
| SNAP_ANGLE_THRESHOLD = 45.0 # Maximum rotation adjustment in degrees | |
| def nm_to_mm(nm): | |
| """Convert nanometers to millimeters""" | |
| return nm / 1e6 | |
| def mm_to_nm(mm): | |
| """Convert millimeters to nanometers""" | |
| return int(mm * 1e6) | |
| def get_distance(p1, p2): | |
| """Calculate distance between two points in mm""" | |
| dx = nm_to_mm(p1.x - p2.x) | |
| dy = nm_to_mm(p1.y - p2.y) | |
| return math.sqrt(dx*dx + dy*dy) | |
| def get_angle(p1, p2): | |
| """Get angle from p1 to p2 in degrees""" | |
| dx = p2.x - p1.x | |
| dy = p2.y - p1.y | |
| return math.degrees(math.atan2(dx, dy)) | |
| def normalize_angle(angle): | |
| """Normalize angle to -180 to 180 range""" | |
| while angle > 180: | |
| angle -= 360 | |
| while angle < -180: | |
| angle += 360 | |
| return angle | |
| def point_to_segment_distance(point, seg_start, seg_end): | |
| """Calculate perpendicular distance from point to line segment""" | |
| px, py = nm_to_mm(point.x), nm_to_mm(point.y) | |
| x1, y1 = nm_to_mm(seg_start.x), nm_to_mm(seg_start.y) | |
| x2, y2 = nm_to_mm(seg_end.x), nm_to_mm(seg_end.y) | |
| # Vector from start to end | |
| dx = x2 - x1 | |
| dy = y2 - y1 | |
| if dx == 0 and dy == 0: | |
| return math.sqrt((px - x1)**2 + (py - y1)**2) | |
| # Parameter t of closest point on segment | |
| t = max(0, min(1, ((px - x1) * dx + (py - y1) * dy) / (dx * dx + dy * dy))) | |
| # Closest point on segment | |
| closest_x = x1 + t * dx | |
| closest_y = y1 + t * dy | |
| return math.sqrt((px - closest_x)**2 + (py - closest_y)**2) | |
| def find_connected_segment(segments, current_seg, end_point, used_segments): | |
| """Find the next connected segment at the given endpoint""" | |
| tolerance = mm_to_nm(0.01) # 10 microns tolerance | |
| for seg in segments: | |
| if id(seg) in used_segments or seg == current_seg: | |
| continue | |
| start = seg.GetStart() | |
| end = seg.GetEnd() | |
| # Check if this segment connects to our endpoint | |
| if (abs(start.x - end_point.x) < tolerance and abs(start.y - end_point.y) < tolerance): | |
| return seg, end | |
| elif (abs(end.x - end_point.x) < tolerance and abs(end.y - end_point.y) < tolerance): | |
| return seg, start | |
| return None, None | |
| def trace_loop(segments, start_segment, start_point): | |
| """Trace the complete loop of segments starting from start_segment""" | |
| points = [start_point] | |
| used_segments = set([id(start_segment)]) | |
| current_seg = start_segment | |
| current_point = start_segment.GetEnd() if start_point == start_segment.GetStart() else start_segment.GetStart() | |
| points.append(current_point) | |
| # Trace forward until we complete the loop | |
| while True: | |
| next_seg, next_point = find_connected_segment(segments, current_seg, current_point, used_segments) | |
| if next_seg is None: | |
| break | |
| points.append(next_point) | |
| if next_point == points[0]: | |
| # Loop completed | |
| break | |
| used_segments.add(id(next_seg)) | |
| current_seg = next_seg | |
| current_point = next_point | |
| if len(used_segments) > 1000: # Safety limit | |
| print("Warning: Loop trace exceeded safety limit") | |
| break | |
| return points | |
| def is_clockwise(points): | |
| """Determine if points are in clockwise order using shoelace formula""" | |
| if len(points) < 3: | |
| return True | |
| area = 0 | |
| for i in range(len(points)): | |
| j = (i + 1) % len(points) | |
| area += (points[j].x - points[i].x) * (points[j].y + points[i].y) | |
| return area > 0 | |
| def snap_footprint_to_segment(footprint, seg_start, seg_end): | |
| """Snap footprint position and rotation to the segment""" | |
| # Calculate midpoint of segment | |
| mid_x = (seg_start.x + seg_end.x) // 2 | |
| mid_y = (seg_start.y + seg_end.y) // 2 | |
| # Move footprint to midpoint | |
| # footprint.SetPosition(pcbnew.VECTOR2I(mid_x, mid_y)) | |
| footprint.SetPosition(seg_start) | |
| # Calculate segment angle | |
| segment_angle = get_angle(seg_start, seg_end) | |
| current_angle = footprint.GetOrientation().AsDegrees() | |
| # Find the closest orthogonal angle (0, 90, 180, 270 relative to segment) | |
| possible_angles = [segment_angle, segment_angle + 90, segment_angle + 180, segment_angle + 270] | |
| # Choose angle with minimum rotation from current orientation | |
| min_diff = float('inf') | |
| best_angle = segment_angle | |
| for angle in possible_angles: | |
| diff = abs(normalize_angle(angle - current_angle)) | |
| if diff < min_diff: | |
| min_diff = diff | |
| best_angle = angle | |
| footprint.SetOrientationDegrees(best_angle) | |
| def interpolate_path_points(points, spacing_mm): | |
| """Generate interpolated points along the path with adjusted spacing to fit evenly""" | |
| # Calculate total path length | |
| total_length = 0.0 | |
| for i in range(len(points) - 1): | |
| total_length += get_distance(points[i], points[i + 1]) | |
| # Calculate how many footprints fit with the desired spacing | |
| num_components = max(1, round(total_length / spacing_mm)) | |
| # Adjust spacing so components are evenly distributed | |
| adjusted_spacing = total_length / num_components if num_components > 0 else spacing_mm | |
| print(f"Total path length: {total_length:.2f} mm") | |
| print(f"Number of components: {num_components}") | |
| print(f"Adjusted spacing: {adjusted_spacing:.2f} mm (requested: {spacing_mm:.2f} mm)") | |
| spacing_mm = adjusted_spacing | |
| result_points = [] | |
| result_angles = [] | |
| accumulated_distance = 0.0 | |
| for i in range(len(points) - 1): | |
| p1 = points[i] | |
| p2 = points[i + 1] | |
| segment_length = get_distance(p1, p2) | |
| segment_angle = get_angle(p1, p2) | |
| # Add points along this segment (subtract some tolerance, otherwise last footprint might not be placed) | |
| while (accumulated_distance - 0.01) < segment_length: | |
| # Interpolate position | |
| t = accumulated_distance / segment_length if segment_length > 0 else 0 | |
| x = int(p1.x + t * (p2.x - p1.x)) | |
| y = int(p1.y + t * (p2.y - p1.y)) | |
| result_points.append(pcbnew.VECTOR2I(x, y)) | |
| result_angles.append(segment_angle) | |
| accumulated_distance += spacing_mm | |
| accumulated_distance -= segment_length | |
| # Remove last (duplicate point), if path is loop | |
| if points[0] == points[-1]: | |
| result_points = result_points[:-1] | |
| result_angles = result_angles[:-1] | |
| return result_points, result_angles | |
| def increment_reference(ref): | |
| """Increment the reference designator (e.g., R1 -> R2)""" | |
| import re | |
| match = re.match(r'([A-Za-z]+)(\d+)', ref) | |
| if match: | |
| prefix = match.group(1) | |
| number = int(match.group(2)) | |
| return f"{prefix}{number + 1}" | |
| return ref + "_1" | |
| def place(spacing_mm = SPACING_MM): | |
| board = pcbnew.GetBoard() | |
| # Get selected footprint | |
| selected_footprints = [item for item in board.GetFootprints() if item.IsSelected()] | |
| if len(selected_footprints) == 0: | |
| print("Please select a footprint first!") | |
| return | |
| if len(selected_footprints) > 1: | |
| print("Please select only one footprint!") | |
| return | |
| footprint = selected_footprints[0] | |
| fp_pos = footprint.GetPosition() | |
| # Get all drawing segments on Edge.Cuts or other drawing layers | |
| drawings = board.GetDrawings() | |
| segments = [d for d in drawings if d.GetClass() == 'PCB_SHAPE' and d.GetShape() == pcbnew.SHAPE_T_SEGMENT] | |
| if len(segments) == 0: | |
| print("No drawing segments found on the board!") | |
| return | |
| # Find segment closest to footprint | |
| closest_seg = None | |
| min_distance = float('inf') | |
| for seg in segments: | |
| dist = point_to_segment_distance(fp_pos, seg.GetStart(), seg.GetEnd()) | |
| if dist < min_distance: | |
| min_distance = dist | |
| closest_seg = seg | |
| if closest_seg is None: | |
| print("Could not find a drawing segment near the footprint!") | |
| return | |
| print(f"Found closest segment at distance: {min_distance:.3f} mm") | |
| # Determine which endpoint is closer to start from | |
| dist_to_start = get_distance(fp_pos, closest_seg.GetStart()) | |
| dist_to_end = get_distance(fp_pos, closest_seg.GetEnd()) | |
| start_point = closest_seg.GetStart() if dist_to_start < dist_to_end else closest_seg.GetEnd() | |
| # Trace the loop | |
| points = trace_loop(segments, closest_seg, start_point) | |
| if len(points) < 3: | |
| print("Could not trace a complete loop of segments!") | |
| return | |
| print(f"Traced loop with {len(points)} points") | |
| # Ensure clockwise order | |
| if not is_clockwise(points): | |
| points.reverse() | |
| print("Reversed points to clockwise order") | |
| # Snap original footprint to segment | |
| seg_start = start_point | |
| seg_end = points[1] if len(points) > 1 else closest_seg.GetEnd() | |
| snap_footprint_to_segment(footprint, seg_start, seg_end) | |
| # Generate interpolated positions along path | |
| positions, angles = interpolate_path_points(points, spacing_mm) | |
| print(f"Generated {len(positions)} positions along path") | |
| # Duplicate footprint at each position (skip first as original is already there) | |
| footprint_rotation = footprint.GetOrientationDegrees() - get_angle(seg_start, seg_end) | |
| current_ref = footprint.GetReference() | |
| for i in range(1, len(positions)): | |
| # Create duplicate | |
| new_fp = pcbnew.Cast_to_FOOTPRINT(footprint.Duplicate()) | |
| new_fp.SetPosition(positions[i]) | |
| new_fp.SetOrientationDegrees(angles[i] + footprint_rotation) | |
| # Increment reference | |
| current_ref = increment_reference(current_ref) | |
| new_fp.SetReference(current_ref) | |
| # Add to board | |
| board.Add(new_fp) | |
| pcbnew.Refresh() | |
| print(f"Successfully duplicated footprint {len(positions) - 1} times along the path") | |
| # # Run the script | |
| # if __name__ == '__main__': | |
| # place() | |
| class FootprintPathPlacerPlugin(pcbnew.ActionPlugin): | |
| """ | |
| KiCad Action Plugin for duplicating footprints along a drawing path | |
| """ | |
| def defaults(self): | |
| """ | |
| Method to define the plugin metadata | |
| """ | |
| self.name = "Footprint Path Placer" | |
| self.category = "Modify PCB" | |
| self.description = "Duplicate a selected footprint along a drawing path with specified spacing" | |
| self.show_toolbar_button = True | |
| self.icon_file_name = "" # Optional: add path to icon file | |
| def Run(self): | |
| """ | |
| Method called when the plugin is executed | |
| """ | |
| # Prompt user for spacing | |
| dlg = wx.TextEntryDialog( | |
| None, | |
| "Enter spacing between footprints (mm):", | |
| "Footprint Path Placer", | |
| str(SPACING_MM) | |
| ) | |
| if dlg.ShowModal() == wx.ID_OK: | |
| try: | |
| spacing_mm = float(dlg.GetValue()) | |
| if spacing_mm <= 0: | |
| wx.MessageBox("Spacing must be greater than 0!", "Error", wx.OK | wx.ICON_ERROR) | |
| dlg.Destroy() | |
| return | |
| # Execute the placement | |
| success = place(spacing_mm) | |
| if success: | |
| # Commit the changes for undo | |
| pcbnew.Refresh() | |
| wx.MessageBox( | |
| f"Footprints placed successfully!\n\n" | |
| f"Don't forget to run 'Update PCB from Schematic' with\n" | |
| f"'Re-link footprints based on reference designators' checked.", | |
| "Success", | |
| wx.OK | wx.ICON_INFORMATION | |
| ) | |
| except ValueError: | |
| wx.MessageBox("Invalid spacing value! Please enter a number.", "Error", wx.OK | wx.ICON_ERROR) | |
| dlg.Destroy() | |
| # Register the plugin | |
| FootprintPathPlacerPlugin().register() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment