| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | """ |
| | 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 |
| |
|