| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| """ |
| Directional (unidirectional) facing toolpath generator. |
| |
| This module implements the unidirectional clearing pattern that cuts in the same |
| direction for every pass, providing consistent surface finish. |
| |
| Feed moves (cutting) are aligned with the angle_degrees argument direction. |
| |
| At the end of each cutting pass, the cutter retracts to safe height and moves laterally to |
| the start position of the next pass. |
| |
| This strategy always maintains either climb or conventional milling direction. |
| """ |
|
|
| import FreeCAD |
| import Path |
| from . import facing_common |
|
|
| 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 directional( |
| polygon, |
| tool_diameter, |
| stepover_percent, |
| pass_extension=None, |
| retract_height=None, |
| milling_direction="climb", |
| reverse=False, |
| angle_degrees=None, |
| ): |
|
|
| import math |
| import Path |
| import FreeCAD |
| from . import facing_common |
|
|
| if pass_extension is None: |
| pass_extension = tool_diameter * 0.5 |
|
|
| theta = float(angle_degrees) if angle_degrees is not None else 0.0 |
| primary_vec, step_vec = facing_common.unit_vectors_from_angle(theta) |
| primary_vec = FreeCAD.Vector(primary_vec).normalize() |
| step_vec = FreeCAD.Vector(step_vec).normalize() |
|
|
| origin = polygon.BoundBox.Center |
| z = polygon.BoundBox.ZMin |
|
|
| min_s, max_s = facing_common.project_bounds(polygon, primary_vec, origin) |
| min_t, max_t = facing_common.project_bounds(polygon, step_vec, origin) |
|
|
| if not all(math.isfinite(x) for x in [min_s, max_s, min_t, max_t]): |
| Path.Log.error("Directional: non-finite projection bounds; aborting") |
| return [] |
|
|
| step_positions = facing_common.generate_t_values( |
| polygon, step_vec, tool_diameter, stepover_percent, origin |
| ) |
|
|
| tool_radius = tool_diameter / 2.0 |
| stepover_distance = tool_diameter * (stepover_percent / 100.0) |
|
|
| if stepover_percent >= 99.9 and step_positions: |
| min_covered = min(step_positions) - tool_radius |
| max_covered = max(step_positions) + tool_radius |
|
|
| added = False |
| if max_covered < max_t - 1e-4: |
| step_positions.append(step_positions[-1] + stepover_distance) |
| added = True |
| if min_covered > min_t + 1e-4: |
| step_positions.insert(0, step_positions[0] - stepover_distance) |
| added = True |
| if added: |
| Path.Log.info("Directional: Added extra pass(es) for full coverage at high stepover") |
|
|
| |
| if reverse: |
| center = (min_t + max_t) / 2.0 |
| step_positions = [2 * center - t for t in step_positions] |
|
|
| Path.Log.debug(f"Directional (fixed): {len(step_positions)} passes") |
|
|
| |
| total_extension = ( |
| pass_extension |
| + tool_radius |
| + facing_common.calculate_engagement_offset(tool_diameter, stepover_percent) |
| ) |
|
|
| start_s = min_s - total_extension |
| end_s = max_s + total_extension |
|
|
| commands = [] |
| kept_segments = 0 |
|
|
| for t in step_positions: |
| |
| if milling_direction == "climb": |
| if reverse: |
| p_start, p_end = start_s, end_s |
| else: |
| p_start, p_end = end_s, start_s |
| else: |
| if reverse: |
| p_start, p_end = end_s, start_s |
| else: |
| p_start, p_end = start_s, end_s |
|
|
| start_point = origin + primary_vec * p_start + step_vec * t |
| end_point = origin + primary_vec * p_end + step_vec * t |
| start_point.z = z |
| end_point.z = z |
|
|
| if commands: |
| if retract_height is not None: |
| commands.append(Path.Command("G0", {"Z": retract_height})) |
| commands.append(Path.Command("G0", {"X": start_point.x, "Y": start_point.y})) |
| commands.append(Path.Command("G0", {"Z": z})) |
| else: |
| commands.append( |
| Path.Command("G0", {"X": start_point.x, "Y": start_point.y, "Z": z}) |
| ) |
| else: |
| commands.append(Path.Command("G0", {"X": start_point.x, "Y": start_point.y, "Z": z})) |
|
|
| commands.append(Path.Command("G1", {"X": end_point.x, "Y": end_point.y, "Z": z})) |
| kept_segments += 1 |
|
|
| Path.Log.debug(f"Directional: generated {kept_segments} segments") |
| |
| if kept_segments == 0: |
| t_candidates = [] |
| |
| t_candidates.append(0.5 * (min_t + max_t)) |
| t_candidates.append(min_t) |
| t_candidates.append(max_t) |
| for t in t_candidates: |
| intervals = facing_common.slice_wire_segments(polygon, primary_vec, step_vec, t, origin) |
| if not intervals: |
| continue |
| s0, s1 = intervals[0] |
| start_s = max(s0 - pass_extension, min_s - s_margin) |
| end_s = min(s1 + pass_extension, max_s + s_margin) |
| if end_s <= start_s: |
| continue |
| if milling_direction == "climb": |
| p_start, p_end = start_s, end_s |
| else: |
| p_start, p_end = end_s, start_s |
| if reverse: |
| p_start, p_end = p_end, p_start |
| sp = ( |
| FreeCAD.Vector(origin) |
| .add(FreeCAD.Vector(primary_vec).multiply(p_start)) |
| .add(FreeCAD.Vector(step_vec).multiply(t)) |
| ) |
| ep = ( |
| FreeCAD.Vector(origin) |
| .add(FreeCAD.Vector(primary_vec).multiply(p_end)) |
| .add(FreeCAD.Vector(step_vec).multiply(t)) |
| ) |
| sp.z = z |
| ep.z = z |
| |
| if retract_height is not None: |
| commands.append(Path.Command("G0", {"Z": retract_height})) |
| commands.append(Path.Command("G0", {"X": sp.x, "Y": sp.y})) |
| commands.append(Path.Command("G0", {"Z": z})) |
| else: |
| commands.append(Path.Command("G1", {"X": sp.x, "Y": sp.y, "Z": z})) |
| commands.append(Path.Command("G1", {"X": ep.x, "Y": ep.y, "Z": z})) |
| break |
| return commands |
|
|