The boundary calculation was failing on curved wires.
The fix descretizes them to a polygon before passing the
boundary on to the path generators.

This also implements fundamental tests that the boundary encompasses the stock

Removed some commented code and fixed up headers.
This commit is contained in:
sliptonic
2026-04-08 14:32:12 -05:00
parent 918e335ef3
commit 12a2a76a61
3 changed files with 190 additions and 124 deletions
+120 -21
View File
@@ -1,27 +1,24 @@
# -*- 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/>. *
# * *
# ***************************************************************************
# SPDX-FileCopyrightText: 2025 sliptonic sliptonic@freecad.org
# SPDX-FileNotice: Part of the FreeCAD project.
################################################################################
# #
# 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 #
# #
################################################################################
import FreeCAD
import math
import Path.Base.Generator.spiral_facing as spiral_facing
@@ -1174,6 +1171,108 @@ class TestPathFacingGenerator(PathTestBase):
"Spiral should move inward - later moves should be closer to center",
)
# ------------------------------------------------------------------ #
# Boundary wire tests — verify facing_common functions handle #
# non-rectangular (e.g. circular) wires from cylindrical stock. #
# ------------------------------------------------------------------ #
def test_project_bounds_rectangular_wire(self):
"""project_bounds returns correct extents for a rectangular wire."""
origin = self.square_wire.BoundBox.Center
vec_x = FreeCAD.Vector(1, 0, 0)
vec_y = FreeCAD.Vector(0, 1, 0)
min_x, max_x = facing_common.project_bounds(self.square_wire, vec_x, origin)
min_y, max_y = facing_common.project_bounds(self.square_wire, vec_y, origin)
self.assertAlmostEqual(min_x, -5.0, places=2)
self.assertAlmostEqual(max_x, 5.0, places=2)
self.assertAlmostEqual(min_y, -5.0, places=2)
self.assertAlmostEqual(max_y, 5.0, places=2)
def test_project_bounds_circular_wire(self):
"""project_bounds must return the full circular extent, not just seam vertices."""
# circle_wire is centred at (5,5) with radius 5
origin = self.circle_wire.BoundBox.Center
vec_x = FreeCAD.Vector(1, 0, 0)
vec_y = FreeCAD.Vector(0, 1, 0)
min_x, max_x = facing_common.project_bounds(self.circle_wire, vec_x, origin)
min_y, max_y = facing_common.project_bounds(self.circle_wire, vec_y, origin)
# True extents are ±5 from centre
self.assertAlmostEqual(min_x, -5.0, places=1)
self.assertAlmostEqual(max_x, 5.0, places=1)
self.assertAlmostEqual(min_y, -5.0, places=1)
self.assertAlmostEqual(max_y, 5.0, places=1)
def test_generate_t_values_circular_wire(self):
"""generate_t_values must produce multiple step positions spanning the circular wire."""
origin = self.circle_wire.BoundBox.Center
step_vec = FreeCAD.Vector(0, 1, 0)
values = facing_common.generate_t_values(self.circle_wire, step_vec, 2.0, 50, origin)
# For a 10 mm diameter circle with 2 mm tool at 50% stepover (1 mm steps)
# we expect roughly 10 passes; definitely more than 2
self.assertGreater(
len(values), 2, f"Expected multiple t-values for circular wire, got {len(values)}"
)
# Values should span most of the ±5 range from centre
self.assertLess(min(values), -3.0)
self.assertGreater(max(values), 3.0)
def test_zigzag_circular_wire(self):
"""Zigzag generator must produce a valid toolpath for a circular boundary wire."""
commands = zigzag_facing.zigzag(
polygon=self.circle_wire,
tool_diameter=2.0,
stepover_percent=50,
angle_degrees=0.0,
)
self.assertIsInstance(commands, list)
self.assertGreater(len(commands), 0, "Zigzag should produce commands for circular wire")
# Should have cutting moves
cutting = [c for c in commands if c.Name == "G1"]
self.assertGreater(len(cutting), 2, "Should have multiple cutting passes")
def test_bidirectional_circular_wire(self):
"""Bidirectional generator must produce a valid toolpath for a circular boundary wire."""
commands = bidirectional_facing.bidirectional(
polygon=self.circle_wire,
tool_diameter=2.0,
stepover_percent=50,
angle_degrees=0.0,
)
self.assertIsInstance(commands, list)
self.assertGreater(
len(commands), 0, "Bidirectional should produce commands for circular wire"
)
cutting = [c for c in commands if c.Name == "G1"]
self.assertGreater(len(cutting), 2, "Should have multiple cutting passes")
def test_directional_circular_wire(self):
"""Directional generator must produce a valid toolpath for a circular boundary wire."""
commands = directional_facing.directional(
polygon=self.circle_wire,
tool_diameter=2.0,
stepover_percent=50,
angle_degrees=0.0,
)
self.assertIsInstance(commands, list)
self.assertGreater(
len(commands), 0, "Directional should produce commands for circular wire"
)
cutting = [c for c in commands if c.Name == "G1"]
self.assertGreater(len(cutting), 2, "Should have multiple cutting passes")
def _create_mock_tool_controller(self, spindle_dir):
"""Create a mock tool controller for testing."""
@@ -1,27 +1,24 @@
# -*- 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/>. *
# * *
# ***************************************************************************
# SPDX-FileCopyrightText: 2025 sliptonic sliptonic@freecad.org
# SPDX-FileNotice: Part of the FreeCAD project.
################################################################################
# #
# 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 #
# #
################################################################################
"""
Common helper functions for facing toolpath generators.
@@ -40,6 +37,36 @@ else:
Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule())
def _wire_has_curves(wire):
"""Check if a wire contains any non-linear (curved) edges."""
for edge in wire.Edges:
if len(edge.Vertexes) < 2:
return True
chord = edge.Vertexes[0].Point.distanceToPoint(edge.Vertexes[1].Point)
if edge.Length - chord > 1e-6:
return True
return False
def ensure_polygon(wire, num_segments=64):
"""Convert a wire with curved edges into a polygonal approximation.
If the wire already consists entirely of line segments, it is returned
unchanged. Otherwise the wire is discretized into *num_segments*
straight segments so that downstream functions which iterate vertices
and treat edges as line segments produce correct results.
"""
if not _wire_has_curves(wire):
return wire
Path.Log.debug("Discretizing curved wire into polygon")
points = wire.discretize(Number=num_segments + 1)
# Ensure the polygon is closed
if points[0].distanceToPoint(points[-1]) > 1e-6:
points.append(FreeCAD.Vector(points[0]))
return Part.makePolygon(points)
def extract_polygon_geometry(polygon):
"""Extract edges and corners from a rectangular polygon."""
edges = []
@@ -368,6 +395,7 @@ def unit_vectors_from_angle(angle_degrees):
def project_bounds(wire, vec, origin):
"""Project all vertices of wire onto vec relative to origin and return (min_t, max_t)."""
wire = ensure_polygon(wire)
ts = []
for v in wire.Vertexes:
ts.append(vec.dot(v.Point.sub(origin)))
@@ -413,6 +441,8 @@ def slice_wire_segments(wire, primary_vec, step_vec, t, origin):
"""
import math
wire = ensure_polygon(wire)
# For diagnostics
bb = wire.BoundBox
diag = math.hypot(bb.XLength, bb.YLength)
+19 -82
View File
@@ -1,26 +1,24 @@
# -*- 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/>. *
# * *
# ***************************************************************************
# SPDX-FileCopyrightText: 2025 sliptonic sliptonic@freecad.org
# SPDX-FileNotice: Part of the FreeCAD project.
################################################################################
# #
# 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 #
# #
################################################################################
__title__ = "CAM Mill Facing Operation"
@@ -654,67 +652,6 @@ class ObjectMillFacing(PathOp.ObjectOp):
# Prefer Z-only to avoid non-numeric XY issues
self.commandlist.append(Path.Command("G0", {"Z": targetZ}))
# # Sanitize commands: ensure full XYZ continuity and remove zero-length/invalid/absurd moves
# sanitized = []
# curX = curY = curZ = None
# # Compute XY bounds from original wire
# try:
# bb = boundary_wire.BoundBox
# import math
# diag = math.hypot(bb.XLength, bb.YLength)
# xy_limit = max(1.0, diag * 10.0)
# except Exception:
# xy_limit = 1e6
# for idx, cmd in enumerate(self.commandlist):
# params = dict(cmd.Parameters)
# # Carry forward
# if curX is not None:
# params.setdefault("X", curX)
# params.setdefault("Y", curY)
# params.setdefault("Z", curZ)
# # Extract
# X = params.get("X")
# Y = params.get("Y")
# Z = params.get("Z")
# # Skip NaN/inf
# try:
# _ = float(X) + float(Y) + float(Z)
# except Exception:
# Path.Log.warning(
# f"Dropping cmd {idx} non-finite coords: {cmd.Name} {cmd.Parameters}"
# )
# continue
# # Debug: large finite XY - log but keep for analysis (do not drop)
# if abs(X) > xy_limit or abs(Y) > xy_limit:
# Path.Log.warning(f"Large XY detected (limit {xy_limit}): {cmd.Name} {params}")
# # Skip zero-length
# if (
# curX is not None
# and abs(X - curX) <= 1e-12
# and abs(Y - curY) <= 1e-12
# and abs(Z - curZ) <= 1e-12
# ):
# continue
# # Preserve I, J, K parameters for arc commands (G2/G3)
# if cmd.Name in ["G2", "G3"]:
# arc_params = {"X": X, "Y": Y, "Z": Z}
# if "I" in params:
# arc_params["I"] = params["I"]
# if "J" in params:
# arc_params["J"] = params["J"]
# if "K" in params:
# arc_params["K"] = params["K"]
# if "R" in params:
# arc_params["R"] = params["R"]
# sanitized.append(Path.Command(cmd.Name, arc_params))
# else:
# sanitized.append(Path.Command(cmd.Name, {"X": X, "Y": Y, "Z": Z}))
# curX, curY, curZ = X, Y, Z
# self.commandlist = sanitized
# Apply feedrates to the entire commandlist, with debug on failure
try:
FeedRate.setFeedRate(self.commandlist, obj.ToolController)