Skip to content

Instantly share code, notes, and snippets.

@LeoDJ
Last active February 17, 2026 22:11
Show Gist options
  • Select an option

  • Save LeoDJ/ab902d6e3fc2aaf112093180ae89e8e0 to your computer and use it in GitHub Desktop.

Select an option

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
#!/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