# -*- coding: utf-8 -*- # SPDX-License-Identifier: LGPL-2.1-or-later # *************************************************************************** # * * # * Copyright (c) 2025 sliptonic sliptonic@freecad.org * # * * # * This file is part of FreeCAD. * # * * # * FreeCAD is free software: you can redistribute it and/or modify it * # * under the terms of the GNU Lesser General Public License as * # * published by the Free Software Foundation, either version 2.1 of the * # * License, or (at your option) any later version. * # * * # * FreeCAD is distributed in the hope that it will be useful, but * # * WITHOUT ANY WARRANTY; without even the implied warranty of * # * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * # * Lesser General Public License for more details. * # * * # * You should have received a copy of the GNU Lesser General Public * # * License along with FreeCAD. If not, see * # * . * # * * # *************************************************************************** import Part import Path from FreeCAD import Vector from typing import List, Optional if False: Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) Path.Log.trackModule(Path.Log.thisModule()) else: Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) def check_collision( start_position: Vector, target_position: Vector, solids: Optional[List[Part.Shape]] = None, tolerance: float = 0.001, ) -> bool: """ Check if a direct move from start to target would collide with solids. Returns True if collision detected, False if path is clear. """ if start_position == target_position: return False # Build collision model collision_model = None if solids: solids = [s for s in solids if s] if len(solids) == 1: collision_model = solids[0] elif len(solids) > 1: collision_model = Part.makeCompound(solids) if not collision_model: return False # Create direct path wire wire = Part.Wire([Part.makeLine(start_position, target_position)]) distance = wire.distToShape(collision_model)[0] return distance < tolerance def get_linking_moves( start_position: Vector, target_position: Vector, local_clearance: float, global_clearance: float, tool_shape: Part.Shape, # required placeholder solids: Optional[List[Part.Shape]] = None, retract_height_offset: Optional[float] = None, skip_if_no_collision: bool = False, ) -> list: """ Generate linking moves from start to target position. If skip_if_no_collision is True and the direct path at the current height is collision-free, returns empty list (useful for canned drill cycles that handle their own retraction). """ if start_position == target_position: return [] # For canned cycles: if we're already at a safe height and can move directly, skip linking if skip_if_no_collision: if not check_collision(start_position, target_position, solids): return [] if local_clearance > global_clearance: raise ValueError("Local clearance must not exceed global clearance") if retract_height_offset is not None and retract_height_offset < 0: raise ValueError("Retract offset must be positive") # Collision model collision_model = None if solids: solids = [s for s in solids if s] if len(solids) == 1: collision_model = solids[0] elif len(solids) > 1: collision_model = Part.makeCompound(solids) # Determine candidate heights if retract_height_offset is not None: if retract_height_offset > 0: retract_height = max(start_position.z, target_position.z) + retract_height_offset candidate_heights = {retract_height, local_clearance, global_clearance} else: # explicitly 0 retract_height = max(start_position.z, target_position.z) candidate_heights = {retract_height, local_clearance, global_clearance} else: candidate_heights = {local_clearance, global_clearance} heights = sorted(candidate_heights) # Try each height for height in heights: wire = make_linking_wire(start_position, target_position, height) if is_wire_collision_free(wire, collision_model): cmds = Path.fromShape(wire).Commands # Ensure all commands have complete XYZ coordinates # Path.fromShape() may omit coordinates that don't change current_pos = start_position complete_cmds = [] for i, cmd in enumerate(cmds): params = dict(cmd.Parameters) # Fill in missing coordinates from current position x = params.get("X", current_pos.x) y = params.get("Y", current_pos.y) # For the last command (plunge to target), use target.z if Z is missing if "Z" not in params and i == len(cmds) - 1: z = target_position.z else: z = params.get("Z", current_pos.z) complete_cmds.append(Path.Command("G0", {"X": x, "Y": y, "Z": z})) current_pos = Vector(x, y, z) return complete_cmds raise RuntimeError("No collision-free path found between start and target positions") def make_linking_wire(start: Vector, target: Vector, z: float) -> Part.Wire: p1 = Vector(start.x, start.y, z) p2 = Vector(target.x, target.y, z) edges = [] # Only add retract edge if there's actual movement if not start.isEqual(p1, 1e-6): edges.append(Part.makeLine(start, p1)) # Only add traverse edge if there's actual movement if not p1.isEqual(p2, 1e-6): edges.append(Part.makeLine(p1, p2)) # Only add plunge edge if there's actual movement if not p2.isEqual(target, 1e-6): edges.append(Part.makeLine(p2, target)) return Part.Wire(edges) if edges else Part.Wire([Part.makeLine(start, target)]) def is_wire_collision_free( wire: Part.Wire, solid: Optional[Part.Shape], tolerance: float = 0.001 ) -> bool: if not solid: return True distance = wire.distToShape(solid)[0] return distance >= tolerance