""" ============================================================================= MACRO: CNC Slot Skeletonizer (Centerlines Only) ============================================================================= DESCRIPTION: This macro is specifically designed for CAM/CNC routing where the router bit diameter matches the slot width. It reads a DXF-style slot sketch, calculates the mathematical centerlines, and produces a sketch containing ONLY the toolpaths (centerlines). WHAT IT DOES: 1. Safely duplicates the selected sketch. 2. Mathematically calculates true Arcs from faceted DXF micro-segments. 3. Calculates intersections for Straight, L-shape, and T-shape junctions. 4. Merges collinear centerline segments. 5. ADVANCED CAM OPTIMIZATION: Groups connected lines into continuous chains so the tool doesn't lift unnecessarily. Starts the job at the origin (0,0) and takes the shortest rapid paths between cut groups. 6. WIPES THE SKETCH COMPLETELY CLEAN of all original geometry. 7. Draws ONLY the pure skeleton centerlines. ============================================================================= """ import FreeCAD as App import FreeCADGui as Gui import Part import math try: from PySide6.QtWidgets import QMessageBox except ImportError: try: from PySide2.QtWidgets import QMessageBox except ImportError: from PySide.QtGui import QMessageBox # ================================================================= # CONFIGURATION MAX_SEGMENT_LENGTH = 0.5 # ================================================================= def get_circumcenter(p1, p2, p3): temp = p2.x**2 + p2.y**2 bc = (p1.x**2 + p1.y**2 - temp) / 2.0 cd = (temp - p3.x**2 - p3.y**2) / 2.0 det = (p1.x - p2.x) * (p2.y - p3.y) - (p2.x - p3.x) * (p1.y - p2.y) if abs(det) < 1e-6: return None, None cx = (bc * (p2.y - p3.y) - cd * (p1.y - p2.y)) / det cy = ((p1.x - p2.x) * cd - (p2.x - p3.x) * bc) / det center = App.Vector(cx, cy, 0) radius = (p1 - center).Length return center, radius def prepare_working_sketch(): doc = App.ActiveDocument if not doc: return None target = None edit_view = Gui.ActiveDocument.getInEdit() if edit_view and edit_view.Object.isDerivedFrom("Sketcher::SketchObject"): target = edit_view.Object Gui.ActiveDocument.resetEdit() if not target: sel = Gui.Selection.getSelection() if sel and sel[0].isDerivedFrom("Sketcher::SketchObject"): target = sel[0] if not target: return None new_sketch = doc.copyObject(target, False) new_sketch.Label = target.Label + "_Toolpath" if hasattr(target, "ViewObject") and target.ViewObject: target.ViewObject.Visibility = False if hasattr(new_sketch, "ViewObject") and new_sketch.ViewObject: new_sketch.ViewObject.Visibility = True doc.recompute() return new_sketch def merge_collinear_lines(lines): merged = True while merged: merged = False for i in range(len(lines)): for j in range(i + 1, len(lines)): l1 = lines[i] l2 = lines[j] shared_pt, other1, other2 = None, None, None tol = 1e-3 if (l1[0] - l2[0]).Length < tol: shared_pt, other1, other2 = l1[0], l1[1], l2[1] elif (l1[0] - l2[1]).Length < tol: shared_pt, other1, other2 = l1[0], l1[1], l2[0] elif (l1[1] - l2[0]).Length < tol: shared_pt, other1, other2 = l1[1], l1[0], l2[1] elif (l1[1] - l2[1]).Length < tol: shared_pt, other1, other2 = l1[1], l1[0], l2[0] if shared_pt: v1 = (other1 - shared_pt) v2 = (other2 - shared_pt) if v1.Length > tol and v2.Length > tol: v1.normalize() v2.normalize() if abs(v1.dot(v2) + 1.0) < 1e-4: lines.pop(j) lines.pop(i) lines.append((other1, other2)) merged = True break if merged: break return lines def optimize_toolpath_order(lines): if not lines: return [] # Phase 1: Build continuous chains (Polylines) unvisited = lines.copy() chains = [] tol = 1e-3 while unvisited: current_chain = [unvisited.pop(0)] growing = True while growing: growing = False chain_start = current_chain[0][0] chain_end = current_chain[-1][1] for i, line in enumerate(unvisited): l_start, l_end = line[0], line[1] if (l_start - chain_end).Length < tol: current_chain.append(unvisited.pop(i)) growing = True; break elif (l_end - chain_end).Length < tol: current_chain.append((l_end, l_start)) # Flip unvisited.pop(i) growing = True; break elif (l_end - chain_start).Length < tol: current_chain.insert(0, unvisited.pop(i)) growing = True; break elif (l_start - chain_start).Length < tol: current_chain.insert(0, (l_end, l_start)) # Flip unvisited.pop(i) growing = True; break chains.append(current_chain) # Phase 2: Traveling Salesperson between chains unvisited_chains = chains.copy() optimized_lines = [] # Start with the chain closest to global Origin (0,0,0) start_idx = 0 best_origin_dist = float('inf') for i, chain in enumerate(unvisited_chains): d1 = chain[0][0].Length d2 = chain[-1][1].Length if min(d1, d2) < best_origin_dist: best_origin_dist = min(d1, d2) start_idx = i current_chain = unvisited_chains.pop(start_idx) # Ensure it starts at the point closer to origin if current_chain[-1][1].Length < current_chain[0][0].Length: current_chain.reverse() current_chain = [(l[1], l[0]) for l in current_chain] optimized_lines.extend(current_chain) current_pos = current_chain[-1][1] # Position after cutting first chain while unvisited_chains: best_dist = float('inf') best_idx = -1 reverse_chain = False for i, chain in enumerate(unvisited_chains): c_start = chain[0][0] c_end = chain[-1][1] dist_to_start = (c_start - current_pos).Length dist_to_end = (c_end - current_pos).Length if dist_to_start < best_dist: best_dist = dist_to_start best_idx = i reverse_chain = False if dist_to_end < best_dist: best_dist = dist_to_end best_idx = i reverse_chain = True next_chain = unvisited_chains.pop(best_idx) if reverse_chain: next_chain.reverse() next_chain = [(l[1], l[0]) for l in next_chain] optimized_lines.extend(next_chain) current_pos = next_chain[-1][1] return optimized_lines def show_message(title, message): msg = QMessageBox() msg.setIcon(QMessageBox.Information) msg.setWindowTitle(title) msg.setText(message) try: msg.exec() except AttributeError: msg.exec_() def process_sketch(): sketch = prepare_working_sketch() if not sketch: App.Console.PrintError("Could not find a sketch. Select one in the tree view and run.\n") return geo = sketch.Geometry # --- STEP 1: IDENTIFY MICRO-SEGMENTS TO INFER ARC DATA --- graph = {} pt_dict = {} def get_pt_key(pt): return (round(pt.x, 3), round(pt.y, 3)) for i, g in enumerate(geo): if isinstance(g, Part.LineSegment) and not sketch.GeometryFacadeList[i].Construction: if (g.EndPoint - g.StartPoint).Length < MAX_SEGMENT_LENGTH: k1 = get_pt_key(g.StartPoint) k2 = get_pt_key(g.EndPoint) if k1 not in graph: graph[k1] = [] if k2 not in graph: graph[k2] = [] graph[k1].append((k2, i)) graph[k2].append((k1, i)) pt_dict[k1] = g.StartPoint pt_dict[k2] = g.EndPoint paths = [] visited_edges = set() for node, edges in graph.items(): if len(edges) == 1: first_edge = edges[0][1] if first_edge in visited_edges: continue path_nodes = [node] curr = node; prev = None while True: neighbors = graph[curr] next_node = None; next_idx = None for n, idx in neighbors: if n != prev: next_node = n; next_idx = idx break if next_node is None: break path_nodes.append(next_node) visited_edges.add(next_idx) if len(graph[next_node]) > 2: break prev = curr; curr = next_node if len(path_nodes) >= 3: paths.append(path_nodes) # --- STEP 2: CALCULATE TRUE ARC CENTERS & DIRECTIONS --- arc_data = [] for path_nodes in paths: p1 = pt_dict[path_nodes[0]] p2 = pt_dict[path_nodes[len(path_nodes)//2]] p3 = pt_dict[path_nodes[-1]] center, radius = get_circumcenter(p1, p2, p3) if center: v1 = p1 - center v3 = p3 - center if v1.Length > 1e-4 and v3.Length > 1e-4: dot = max(-1.0, min(1.0, v1.dot(v3) / (v1.Length * v3.Length))) angle = math.acos(dot) if angle > 2.0: chord = p3 - p1 perp = App.Vector(-chord.y, chord.x, 0) if perp.Length > 1e-6: perp.normalize() if perp.dot(center - p2) < 0: perp = -perp arc_data.append((center, perp)) # Existing native Arcs for i, g in enumerate(geo): if isinstance(g, Part.ArcOfCircle) and not sketch.GeometryFacadeList[i].Construction: p1 = g.StartPoint p3 = g.EndPoint center = g.Center v1 = p1 - center v3 = p3 - center if v1.Length > 1e-4 and v3.Length > 1e-4: dot = max(-1.0, min(1.0, v1.dot(v3) / (v1.Length * v3.Length))) angle = math.acos(dot) if angle > 2.0: chord = p3 - p1 perp = App.Vector(-chord.y, chord.x, 0) if perp.Length > 1e-6: perp.normalize() mid_u = (g.FirstParameter + g.LastParameter) / 2.0 p2 = g.value(mid_u) if perp.dot(center - p2) < 0: perp = -perp arc_data.append((center, perp)) # --- STEP 3: WIPE THE SKETCH CLEAN --- for i in range(sketch.ConstraintCount - 1, -1, -1): sketch.delConstraint(i) for i in range(sketch.GeometryCount - 1, -1, -1): sketch.delGeometry(i) # --- STEP 4: CALCULATE TOOLPATHS --- raw_lines = [] for i, (c1, dir1) in enumerate(arc_data): min_t = float('inf') best_stop = None for j, (c2, dir2) in enumerate(arc_data): if i == j: continue cross = dir1.x * dir2.y - dir1.y * dir2.x if abs(cross) < 1e-4: vec = c2 - c1 dist_cross = vec.x * dir1.y - vec.y * dir1.x if abs(dist_cross) < 1e-2: t1 = vec.dot(dir1) t2 = (-vec).dot(dir2) if t1 > 1e-2 and t2 > 1e-2: if t1 < min_t: min_t = t1 best_stop = c2 else: dx = c2.x - c1.x dy = c2.y - c1.y t1 = (dx * dir2.y - dy * dir2.x) / cross t2 = (dx * dir1.y - dy * dir1.x) / cross if t1 > 1e-2 and t2 > -1e-2: if t1 < min_t: min_t = t1 best_stop = c1 + dir1 * t1 if best_stop is not None: if (c1 - best_stop).Length > 1e-3: pts = sorted([(round(c1.x, 3), round(c1.y, 3)), (round(best_stop.x, 3), round(best_stop.y, 3))]) pA = App.Vector(pts[0][0], pts[0][1], 0) pB = App.Vector(pts[1][0], pts[1][1], 0) if not any((l[0]-pA).Length < 1e-3 and (l[1]-pB).Length < 1e-3 for l in raw_lines): raw_lines.append((pA, pB)) # --- STEP 5: MERGE AND OPTIMIZE PATHS FOR CNC --- merged_lines = merge_collinear_lines(raw_lines) optimized_lines = optimize_toolpath_order(merged_lines) # Draw the final optimized lines sequentially for pA, pB in optimized_lines: sketch.addGeometry(Part.LineSegment(pA, pB), False) App.ActiveDocument.recompute() App.Console.PrintMessage(f"Skeletonizer successful: Created '{sketch.Label}'.\n") show_message("CAM Toolpath Complete", f"Successfully generated continuous, optimized CNC toolpaths in:\n\n{sketch.Label}") # Run it process_sketch()