Fixes #29184
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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user