File size: 11,977 Bytes
985c397
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
# -*- 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