FreeCAD / src /Mod /CAM /Path /Base /Generator /zigzag_facing.py
AbdulElahGwaith's picture
Upload folder using huggingface_hub
985c397 verified
# -*- 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 *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
"""
Zigzag facing toolpath generator.
This module implements the zigzag clearing pattern that cuts back and forth
across the polygon in alternating directions, creating a continuous zigzag pattern.
"""
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 _create_link(
prev_seg, next_seg, link_mode, link_radius, stepover_distance, tool_radius, primary_vec, z
):
"""
Create linking moves between two segments.
Args:
prev_seg: Previous segment dict with 'end', 'side', 't' keys
next_seg: Next segment dict with 'start', 'side', 't' keys
link_mode: "arc" or "straight"
link_radius: Radius for arc links (None = auto)
stepover_distance: Distance between passes
tool_radius: Tool radius
primary_vec: Primary direction vector
z: Z height
Returns:
List of Path.Command objects for the link
"""
import math
P = prev_seg["end"]
Q = next_seg["start"]
# Safety checks
if not (
math.isfinite(P.x) and math.isfinite(P.y) and math.isfinite(Q.x) and math.isfinite(Q.y)
):
return [Path.Command("G0", {"X": Q.x, "Y": Q.y})]
# Check if we should use arc mode
if link_mode != "arc":
return [Path.Command("G0", {"X": Q.x, "Y": Q.y})]
# Calculate chord vector and distance
dx = Q.x - P.x
dy = Q.y - P.y
chord_length = math.sqrt(dx * dx + dy * dy)
# Minimum chord length check
if chord_length < 1e-6:
return [Path.Command("G0", {"X": Q.x, "Y": Q.y})]
# Natural semicircle radius for 180° arc
r0 = chord_length / 2.0
# Use specified radius or default to natural radius
if link_radius is not None and link_radius > r0:
r = link_radius
else:
r = r0
# Minimum radius check
if r < 1e-6:
return [Path.Command("G0", {"X": Q.x, "Y": Q.y})]
# Calculate arc center
# Midpoint of chord
mx = 0.5 * (P.x + Q.x)
my = 0.5 * (P.y + Q.y)
# Normal to chord (rotate chord by 90°)
# Two options: rotate left (-dy, dx) or rotate right (dy, -dx)
# For zigzag, we need the arc to bulge in the primary direction away from center
# Use cross product to determine which way to rotate the chord
# The arc should bulge in the direction perpendicular to the chord
# and in the same primary direction as the segment side
# For vertical chords (dy != 0, dx ≈ 0), normal is horizontal
# For horizontal chords (dx != 0, dy ≈ 0), normal is vertical
# Calculate both possible normals (90° rotations of chord)
n1x = -dy / chord_length # Rotate left
n1y = dx / chord_length
n2x = dy / chord_length # Rotate right
n2y = -dx / chord_length
# Choose normal based on which side we're on
# The arc should bulge in the primary direction indicated by prev_seg['side']
outward_x = primary_vec.x * prev_seg["side"]
outward_y = primary_vec.y * prev_seg["side"]
dot1 = n1x * outward_x + n1y * outward_y
dot2 = n2x * outward_x + n2y * outward_y
if dot1 > dot2:
nx, ny = n1x, n1y
Path.Log.debug(f" Chose n1: ({nx:.3f}, {ny:.3f}), dot1={dot1:.3f} > dot2={dot2:.3f}")
else:
nx, ny = n2x, n2y
Path.Log.debug(f" Chose n2: ({nx:.3f}, {ny:.3f}), dot2={dot2:.3f} > dot1={dot1:.3f}")
Path.Log.debug(
f" Chord: dx={dx:.3f}, dy={dy:.3f}, side={prev_seg['side']}, outward=({outward_x:.3f},{outward_y:.3f})"
)
# Calculate offset distance for the arc center from the chord midpoint
# Geometry: For an arc with radius r connecting two points separated by chord length 2*r0,
# the center must be perpendicular to the chord at distance offset from the midpoint,
# where: r^2 = r0^2 + offset^2 (Pythagorean theorem)
# Therefore: offset = sqrt(r^2 - r0^2)
#
# For r = r0 (minimum possible radius): offset = 0 (semicircle, 180° arc)
# For r > r0: offset > 0 (less than semicircle)
# For nice smooth arcs, we could use r = 2*r0, giving offset = sqrt(3)*r0
if r >= r0:
offset = math.sqrt(r * r - r0 * r0)
else:
# Radius too small to connect endpoints - shouldn't happen but handle gracefully
offset = 0.0
# Arc center
cx = mx + nx * offset
cy = my + ny * offset
# Verify center is finite
if not (math.isfinite(cx) and math.isfinite(cy)):
return [Path.Command("G0", {"X": Q.x, "Y": Q.y})]
# Determine arc direction (G2=CW, G3=CCW)
# For semicircles where center is on the chord, cross product is unreliable
# Instead, use the normal direction and chord direction to determine arc sense
# The arc goes from P to Q, bulging in direction (nx, ny)
# Cross product of chord direction with normal gives us the arc direction
# chord × normal = (dx, dy, 0) × (nx, ny, 0) = (0, 0, dx*ny - dy*nx)
z_cross = dx * ny - dy * nx
Path.Log.debug(f" z_cross = {dx:.3f}*{ny:.3f} - {dy:.3f}*{nx:.3f} = {z_cross:.3f}")
# Invert the logic - positive cross product means clockwise for our convention
if z_cross < 0:
arc_cmd = "G3" # Counter-clockwise
else:
arc_cmd = "G2" # Clockwise
# Calculate IJ (relative to start point P)
I = cx - P.x
J = cy - P.y
# Verify IJ are finite
if not (math.isfinite(I) and math.isfinite(J)):
return [Path.Command("G0", {"X": Q.x, "Y": Q.y})]
Path.Log.debug(
f"Arc link: P=({P.x:.3f},{P.y:.3f}) Q=({Q.x:.3f},{Q.y:.3f}) "
f"C=({cx:.3f},{cy:.3f}) r={r:.3f} {arc_cmd} I={I:.3f} J={J:.3f}"
)
# K=0 for XY plane arcs - use string format to ensure I, J, K are preserved
cmd_string = f"{arc_cmd} I{I:.6f} J{J:.6f} K0.0 X{Q.x:.6f} Y{Q.y:.6f} Z{z:.6f}"
return [Path.Command(cmd_string)]
def zigzag(
polygon,
tool_diameter,
stepover_percent,
pass_extension=None,
retract_height=None,
milling_direction="climb",
reverse=False,
angle_degrees=None,
link_mode="arc",
link_radius=None,
):
if pass_extension is None:
pass_extension = tool_diameter * 0.5
import math
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 (
math.isfinite(min_s)
and math.isfinite(max_s)
and math.isfinite(min_t)
and math.isfinite(max_t)
):
Path.Log.error("Zigzag: non-finite projection bounds; aborting")
return []
# === Use exactly the same step position generation as bidirectional and directional ===
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)
# Guarantee full coverage at high stepover – identical to bidirectional/directional
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("Zigzag: Added extra pass(es) for full coverage at ≥100% stepover")
# Reverse only reverses traversal order (same positions set as reverse=False, identical coverage)
if reverse:
step_positions = step_positions[::-1]
Path.Log.debug(
f"Zigzag: {len(step_positions)} passes generated (now identical to bidirectional)"
)
# Determine if first pass should cut negative primary direction to maintain climb/conventional preference
base_negative = (
milling_direction == "climb"
) ^ reverse # True → negative primary for first pass
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
s_mid = (min_s + max_s) / 2.0
segments = []
for idx, t in enumerate(step_positions):
current_negative = base_negative if (idx % 2 == 0) else not base_negative
if current_negative:
p_start = end_s
p_end = start_s
else:
p_start = start_s
p_end = 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
side = 1 if p_end > s_mid - 1e-6 else -1 # slightly more tolerant comparison
segments.append(
{
"t": t,
"side": side,
"start": start_point,
"end": end_point,
"s_start": p_start,
"s_end": p_end,
}
)
commands = []
for i, seg in enumerate(segments):
if i == 0:
commands.append(Path.Command("G0", {"X": seg["start"].x, "Y": seg["start"].y, "Z": z}))
else:
prev_seg = segments[i - 1]
link_commands = _create_link(
prev_seg,
seg,
link_mode,
link_radius,
stepover_distance,
tool_radius,
primary_vec,
z,
)
commands.extend(link_commands)
commands.append(Path.Command("G1", {"X": seg["end"].x, "Y": seg["end"].y, "Z": z}))
return commands