Skip to content

Commit 316b92b

Browse files
committed
Replace basic puzzle split with Keebforge Chaikin stepped seam algorithm
1 parent 89bf2bb commit 316b92b

1 file changed

Lines changed: 72 additions & 65 deletions

File tree

scripts/build_plate.py

Lines changed: 72 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -584,89 +584,95 @@ def apply_poker_cutins(poly, plate_w, plate_h):
584584
return poly
585585

586586

587-
def apply_puzzle_split(outline_poly, all_cutouts, screws, thickness=1.5, gap=10.0, tolerance=0.1):
587+
def apply_puzzle_split(outline_poly, all_cutouts, screws, keys, U1, gap=10.0, tolerance=0.15):
588588
"""
589-
Split the plate geometry into two halves using a zigzag joint.
590-
Assigns cutouts by centroid to guarantee they are never intersected or broken.
591-
Includes a 'tolerance' gap for 3D printing fit.
589+
Split the plate geometry into two halves using a stepped staircase seam
590+
(Chaikin smoothed) that weaves between key columns, like the DeltaSplit75.
591+
Assigns cutouts by centroid to guarantee they are never intersected.
592+
Includes a 'tolerance' gap for fit.
592593
"""
593-
minx, miny, maxx, maxy = outline_poly.bounds
594-
center_x = (minx + maxx) / 2.0
594+
GRID = U1
595+
SEAM_BIAS = 1.0
596+
BIG_SEAM = 9999.0
595597

596-
# Wider corridor search
597-
tooth_h = 4.0
598-
search_range = 80.0
599-
step_size = 0.5
598+
# 1. Find the natural gap (the widest horizontal gap between key centers)
599+
xs = sorted([k['cx_u'] * GRID for k in keys])
600+
if len(xs) < 2:
601+
seam_gap = outline_poly.centroid.x
602+
else:
603+
center_x = outline_poly.centroid.x
604+
gaps = [(xs[i + 1] - xs[i], (xs[i] + xs[i + 1]) / 2) for i in range(len(xs) - 1)]
605+
# Sort by: 1. Largest gap (rounded, descending), 2. Closest to center (ascending)
606+
gaps.sort(key=lambda g: (-round(g[0], 2), abs(g[1] - center_x)))
607+
seam_gap = gaps[0][1]
600608

601-
samples = []
602-
for i in range(int(-search_range / step_size), int(search_range / step_size)):
603-
x = center_x + i * step_size
604-
test_box = box(x - (tooth_h/2 + 1.0), miny - 5, x + (tooth_h/2 + 1.0), maxy + 5)
605-
hits = 0
606-
for p in all_cutouts:
607-
if test_box.intersects(p): hits += 1
608-
samples.append((x, hits))
609+
log.info("apply_puzzle_split: found seam gap at x=%.2f", seam_gap)
610+
611+
# 2. Extract key geometries for seam bands calculation
612+
k_tups = [(k['cx_u'] * GRID, k['cy_u'] * GRID, k['w'], k['h']) for k in keys]
609613

610-
zero_ranges = []
611-
current_range = []
612-
for x, hits in samples:
613-
if hits == 0:
614-
current_range.append(x)
614+
rys = sorted({round(k[1], 0) for k in k_tups})
615+
rows = []
616+
for y in rys:
617+
if not rows or y - rows[-1][-1] > 9:
618+
rows.append([y])
615619
else:
616-
if current_range:
617-
zero_ranges.append(current_range)
618-
current_range = []
619-
if current_range: zero_ranges.append(current_range)
620-
621-
if zero_ranges:
622-
best_range = max(zero_ranges, key=len)
623-
mid_x = sum(best_range) / len(best_range)
624-
log.info("found clear corridor at x=%.2f (width %.1fmm)", mid_x, len(best_range) * step_size)
625-
else:
626-
min_hits = min(s[1] for s in samples)
627-
best_samples = [s for s in samples if s[1] == min_hits]
628-
mid_x = min(best_samples, key=lambda s: abs(s[0] - center_x))[0]
629-
log.warning("no clear corridor found; splitting with %d hits at x=%.2f", min_hits, mid_x)
630-
631-
tooth_w = 15.0
632-
num_teeth = int((maxy - miny) / tooth_w)
633-
if num_teeth < 2: num_teeth = 2
634-
step = (maxy - miny) / num_teeth
635-
636-
pts = [(mid_x, miny - 10.0)]
637-
for i in range(num_teeth):
638-
y = miny + i * step
639-
pts.append((mid_x - tooth_h/2, y + step*0.2))
640-
pts.append((mid_x + tooth_h/2, y + step*0.4))
641-
pts.append((mid_x + tooth_h/2, y + step*0.6))
642-
pts.append((mid_x - tooth_h/2, y + step*0.8))
643-
pts.append((mid_x, maxy + 10.0))
620+
rows[-1].append(y)
621+
mids = sorted(sum(r) / len(r) for r in rows)
622+
bounds = [-BIG_SEAM] + [(mids[i] + mids[i + 1]) / 2 for i in range(len(mids) - 1)] + [BIG_SEAM]
623+
bands = []
624+
for i, ym in enumerate(mids):
625+
bk = [k for k in k_tups if abs(k[1] - ym) < 9]
626+
Lk = [k[0] + k[2] * GRID / 2 for k in bk if k[0] < seam_gap]
627+
Rk = [k[0] - k[2] * GRID / 2 for k in bk if k[0] >= seam_gap]
628+
sx = (max(Lk) + min((min(Rk) - max(Lk)) / 2, SEAM_BIAS)) if (Lk and Rk) else seam_gap
629+
sx = max(seam_gap - GRID, min(seam_gap + GRID, sx))
630+
bands.append((bounds[i], bounds[i + 1], sx))
631+
632+
# 3. Build the shared staircase centerline
633+
def chaikin(pts, iters):
634+
for _ in range(int(iters)):
635+
out = [pts[0]]
636+
for a, b in zip(pts, pts[1:]):
637+
out.append((0.75 * a[0] + 0.25 * b[0], 0.75 * a[1] + 0.25 * b[1]))
638+
out.append((0.25 * a[0] + 0.75 * b[0], 0.25 * a[1] + 0.75 * b[1]))
639+
out.append(pts[-1])
640+
pts = out
641+
return pts
642+
643+
# Clamp bounds to plate bounds
644+
minx, miny, maxx, maxy = outline_poly.bounds
645+
ylo, yhi = miny - 30, maxy + 30
646+
stair = []
647+
for y0, y1, x in bands:
648+
stair.append((x, max(ylo, min(yhi, y0))))
649+
stair.append((x, max(ylo, min(yhi, y1))))
650+
651+
stair = chaikin(stair, 5) # 5 iterations of smoothing
652+
653+
joint_line = LineString(stair)
654+
655+
# 5. Build left/right bounding polygons
656+
raw_left = [(minx - 100, ylo)] + stair + [(minx - 100, yhi)]
657+
poly_left_base = Polygon(raw_left)
644658

645-
# Base split polygons
646-
poly_left_base = Polygon(pts + [(mid_x - 5000, maxy + 10), (mid_x - 5000, miny - 10)])
647-
poly_right_base = Polygon(pts + [(mid_x + 5000, maxy + 10), (mid_x + 5000, miny - 10)])
659+
raw_right = [(maxx + 100, ylo)] + stair + [(maxx + 100, yhi)]
660+
poly_right_base = Polygon(raw_right)
648661

649-
# Apply tolerance to the right side by buffering the cutter
650-
# This creates a small gap between the teeth
651662
if tolerance > 0:
652-
# We shrink the left part and right part slightly at the joint?
653-
# Simpler: offset the zigzag line by tolerance/2 for each side.
654-
joint_line = LineString(pts)
655-
left_cutter = poly_left_base.difference(joint_line.buffer(tolerance/2, join_style=2))
656-
right_cutter = poly_right_base.difference(joint_line.buffer(tolerance/2, join_style=2))
663+
left_cutter = poly_left_base.difference(joint_line.buffer(tolerance, join_style=1))
664+
right_cutter = poly_right_base.difference(joint_line.buffer(tolerance, join_style=1))
657665
else:
658666
left_cutter = poly_left_base
659667
right_cutter = poly_right_base
660-
668+
661669
def process_half(cutter, raw_base_cutter, x_shift=0.0):
662670
new_outline = outline_poly.intersection(cutter)
663671
if x_shift != 0:
664672
new_outline = affinity.translate(new_outline, xoff=x_shift)
665673

666674
new_cutouts = []
667675
for p in all_cutouts:
668-
# We use the RAW base cutter (no tolerance) for assignment to ensure
669-
# we don't lose holes that fall into the tolerance gap
670676
if raw_base_cutter.contains(p.centroid):
671677
res = p
672678
if x_shift != 0: res = affinity.translate(res, xoff=x_shift)
@@ -686,6 +692,7 @@ def process_half(cutter, raw_base_cutter, x_shift=0.0):
686692
return (left_out, left_cuts, left_screws), (right_out, right_cuts, right_screws)
687693

688694

695+
689696
def generate_plate(kle_path=None, out_path=None, pcb_path=None,
690697
switch_type=1, stab_type=0, kerf=0.0, pad=0.0,
691698
screw_diameter=2.4, pcb_dx=0.0, pcb_dy=0.0,
@@ -913,7 +920,7 @@ def poly_to_segs(p):
913920

914921
if puzzle_split:
915922
log.info("applying puzzle split to geometry")
916-
(l_out, l_cuts, l_screws), (r_out, r_cuts, r_screws) = apply_puzzle_split(outline_poly, all_cutouts, screws, gap=10.0)
923+
(l_out, l_cuts, l_screws), (r_out, r_cuts, r_screws) = apply_puzzle_split(outline_poly, all_cutouts, screws, keys, U1, gap=10.0)
917924

918925
# Re-combine for emission
919926
outline_poly = MultiPolygon([l_out, r_out]) if isinstance(l_out, Polygon) and isinstance(r_out, Polygon) else unary_union([l_out, r_out])

0 commit comments

Comments
 (0)