From 3fe3cf549a9cfc1d890de160d796bd1767894325 Mon Sep 17 00:00:00 2001 From: Joachim Halder Date: Wed, 15 Jul 2026 22:08:53 +0200 Subject: [PATCH 1/3] Add FreeCAD CI test POC --- .github/workflows/tests.yml | 44 +++++++++++++++ InitGui.py | 3 + ffDesign_Tests.py | 109 ++++++++++++++++++++++++++++++++++++ 3 files changed, 156 insertions(+) create mode 100644 .github/workflows/tests.yml create mode 100644 ffDesign_Tests.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..9f1ae59 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,44 @@ +name: FreeCAD tests + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + freecad: ["1.0.*", "1.1.*"] + + defaults: + run: + shell: bash -el {0} + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Install FreeCAD ${{ matrix.freecad }} + uses: mamba-org/setup-micromamba@v3 + with: + environment-name: freecad-ci + create-args: freecad=${{ matrix.freecad }} + + - name: Install virtual display + run: sudo apt-get update && sudo apt-get install --yes xvfb + + - name: Install addon + run: | + mkdir -p "$HOME/.local/share/FreeCAD/Mod" + ln -s "$GITHUB_WORKSPACE" "$HOME/.local/share/FreeCAD/Mod/FusedFilamentDesign" + + - name: Run FreeCAD tests + run: | + set -o pipefail + xvfb-run -a FreeCAD -t ffDesign_Tests 2>&1 | tee freecad-tests.log + ! grep -q "Traceback (most recent call last)" freecad-tests.log diff --git a/InitGui.py b/InitGui.py index 06b5ce4..b34948b 100644 --- a/InitGui.py +++ b/InitGui.py @@ -59,3 +59,6 @@ class ffDesignWB: register_pd_toolbar() + +if "ffDesign_Tests" not in FreeCAD.__unit_test__: + FreeCAD.__unit_test__.append("ffDesign_Tests") diff --git a/ffDesign_Tests.py b/ffDesign_Tests.py new file mode 100644 index 0000000..de9d175 --- /dev/null +++ b/ffDesign_Tests.py @@ -0,0 +1,109 @@ +import unittest + +import FreeCAD as App +import Part + +import ffDesign_AutoFillet as AutoFillet +import ffDesign_RoofBridge as RoofBridge +import ffDesign_Utils as Utils +import ffDesign_ZipTieChannels as ZipTieChannels + + +class DocumentTestCase(unittest.TestCase): + document_name = "FusedFilamentDesignTests" + + def setUp(self): + if self.document_name in App.listDocuments(): + App.closeDocument(self.document_name) + self.document = App.newDocument(self.document_name) + + def tearDown(self): + if self.document_name in App.listDocuments(): + App.closeDocument(self.document_name) + + def make_body(self): + return self.document.addObject("PartDesign::Body", "Body") + + +class GeometryTests(DocumentTestCase): + def test_get_sketch_locations_ignores_construction_geometry(self): + body = self.make_body() + sketch = body.newObject("Sketcher::SketchObject", "Locations") + sketch.addGeometry(Part.Point(App.Vector(1, 2, 0)), False) + sketch.addGeometry(Part.Circle(App.Vector(3, 4, 0), App.Vector(0, 0, 1), 1), False) + construction_index = sketch.addGeometry(Part.Point(App.Vector(5, 6, 0)), True) + + locations = Utils.get_sketch_locations( + sketch, + Utils.MASK_PROFILE_POINTS | Utils.MASK_PROFILE_CIRCLES, + ) + + self.assertTrue(sketch.getConstruction(construction_index)) + self.assertEqual(len(locations), 2) + self.assertEqual(locations[0].x_expr, "Locations.Geometry[0].X * 1mm") + self.assertEqual(locations[1].vector_expr, "Locations.Geometry[1].Center") + + def test_auto_fillet_creates_a_valid_feature(self): + body = self.make_body() + source = body.newObject("PartDesign::Feature", "Box") + source.Shape = Part.makeBox(10, 10, 10) + self.document.recompute() + + AutoFillet.make_auto_fillets(body, axis="Z", radius="1 mm") + self.document.recompute() + + result = body.Tip + self.assertEqual(result.TypeId, "PartDesign::Fillet") + self.assertFalse(result.Shape.isNull()) + self.assertTrue(result.Shape.isValid()) + self.assertGreater(result.Shape.Volume, 0) + self.assertLess(result.Shape.Volume, source.Shape.Volume) + + def test_zip_tie_template_has_the_expected_profile(self): + body = self.make_body() + original = body.newObject("Sketcher::SketchObject", "Locations") + + template = ZipTieChannels.make_zip_tie_channel_template( + body, + original, + thickness="1.5 mm", + bridge_dia="2.5 mm", + ) + self.document.recompute() + + radii = sorted(geometry.Radius for geometry in template.Geometry if hasattr(geometry, "Radius")) + self.assertEqual(len(template.Geometry), 4) + self.assertEqual(len(template.Constraints), 12) + self.assertEqual(len(radii), 2) + self.assertAlmostEqual(radii[0], 1.25) + self.assertAlmostEqual(radii[1], 2.75) + self.assertFalse(template.Shape.isNull()) + self.assertTrue(template.Shape.isValid()) + + def test_roof_bridge_profile_recomputes(self): + body = self.make_body() + sketch = body.newObject("Sketcher::SketchObject", "RoofBridge") + location = Utils.LocationExprSet( + vector_expr="vector(0 mm, 0 mm, 0 mm)", + x_expr="0 mm", + y_expr="0 mm", + ) + + RoofBridge.make_parametric_roof_bridge( + sketch, + hole_loc=location, + diameter_expr="6 mm", + angle_expr="45 deg", + rotation_expr="90 deg", + clearance_expr="0.2 mm", + ) + self.document.recompute() + + self.assertEqual(len(sketch.Geometry), 5) + self.assertEqual(len(sketch.Constraints), 13) + self.assertFalse(sketch.Shape.isNull()) + self.assertTrue(sketch.Shape.isValid()) + + +if __name__ == "__main__": + unittest.main() From cca61825f8ee2dad6ffe08d642a85eded4fc3ce2 Mon Sep 17 00:00:00 2001 From: Joachim Halder Date: Wed, 15 Jul 2026 22:10:49 +0200 Subject: [PATCH 2/3] Fix FreeCAD CI environment activation --- .github/workflows/tests.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9f1ae59..1085717 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -21,7 +21,7 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Install FreeCAD ${{ matrix.freecad }} uses: mamba-org/setup-micromamba@v3 @@ -38,7 +38,9 @@ jobs: ln -s "$GITHUB_WORKSPACE" "$HOME/.local/share/FreeCAD/Mod/FusedFilamentDesign" - name: Run FreeCAD tests + shell: micromamba-shell {0} run: | set -o pipefail - xvfb-run -a FreeCAD -t ffDesign_Tests 2>&1 | tee freecad-tests.log + freecad_executable="$(command -v FreeCAD || command -v freecad)" + xvfb-run -a "$freecad_executable" -t ffDesign_Tests 2>&1 | tee freecad-tests.log ! grep -q "Traceback (most recent call last)" freecad-tests.log From 27815e76f9bc2ee079c44acade07e35806f4dbfe Mon Sep 17 00:00:00 2001 From: Joachim Halder Date: Thu, 16 Jul 2026 08:18:20 +0200 Subject: [PATCH 3/3] Expand FreeCAD feature regression coverage --- ffDesign_RibThreads.py | 14 ++- ffDesign_Tests.py | 245 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 258 insertions(+), 1 deletion(-) diff --git a/ffDesign_RibThreads.py b/ffDesign_RibThreads.py index f34b293..41e0ec2 100644 --- a/ffDesign_RibThreads.py +++ b/ffDesign_RibThreads.py @@ -1,5 +1,6 @@ import math import dataclasses +import re from PySide import QtCore, QtGui @@ -275,7 +276,17 @@ def rib_template_name(hole, global_template: bool): def find_rib_template(body, hole, global_template: bool): name = rib_template_name(hole, global_template) if global_template: - return body.Document.getObject(name) + template = body.Document.getObject(name) + if template is None: + sanitized_name = re.sub(r"[^A-Za-z0-9_]", "_", name) + template = body.Document.getObject(sanitized_name) + if template is not None: + return template + + # New templates preserve the unsanitized thread name in their Label, + # which also provides a fallback if FreeCAD's sanitization changes. + templates = body.Document.getObjectsByLabel(name) + return templates[0] if templates else None else: return body.getObject(name) @@ -292,6 +303,7 @@ def get_or_create_rib_template(body, hole, global_template: bool, rib_param: Rib name = rib_template_name(hole, global_template) if global_template: template = body.Document.addObject("Sketcher::SketchObject", name) + template.Label = name else: template = body.newObject("Sketcher::SketchObject", name) template.Label = f"{hole.Label}_RibThread_Template" diff --git a/ffDesign_Tests.py b/ffDesign_Tests.py index de9d175..91bb410 100644 --- a/ffDesign_Tests.py +++ b/ffDesign_Tests.py @@ -1,10 +1,15 @@ import unittest import FreeCAD as App +import FreeCADGui as Gui import Part import ffDesign_AutoFillet as AutoFillet +import ffDesign_CounterboreBridges as CounterboreBridges +import ffDesign_HoleWizard as HoleWizard +import ffDesign_RibThreads as RibThreads import ffDesign_RoofBridge as RoofBridge +import ffDesign_Teardrop as Teardrop import ffDesign_Utils as Utils import ffDesign_ZipTieChannels as ZipTieChannels @@ -24,6 +29,71 @@ def tearDown(self): def make_body(self): return self.document.addObject("PartDesign::Body", "Body") + def make_solid_body(self): + body = self.make_body() + base = body.newObject("PartDesign::Feature", "Base") + base.Shape = Part.makeBox(20, 20, 10, App.Vector(-10, -10, 0)) + self.document.recompute() + self.assert_valid_shape(base) + return body, base + + def make_supported_sketch(self, body, base, name, geometry): + sketch = body.newObject("Sketcher::SketchObject", name) + sketch.AttachmentSupport = [(base, "Face6")] + sketch.MapMode = "FlatFace" + sketch.addGeometry(geometry, False) + self.document.recompute() + return sketch + + def make_hole( + self, + *, + counterbore=False, + locations=((0, 0),), + diameter="4 mm", + thread_size=None, + ): + body, base = self.make_solid_body() + circles = [ + Part.Circle(App.Vector(x, y, 0), App.Vector(0, 0, 1), 2) + for x, y in locations + ] + profile = self.make_supported_sketch(body, base, "HoleProfile", circles) + + hole = body.newObject("PartDesign::Hole", "Hole") + hole.Profile = profile + hole.Diameter = diameter + hole.Depth = "8 mm" + if thread_size is not None: + hole.ThreadType = "ISOMetricProfile" + matching_sizes = [ + size + for size in hole.getEnumerationsOfProperty("ThreadSize") + if size == thread_size or size.startswith(thread_size + "x") + ] + self.assertTrue(matching_sizes, thread_size) + hole.ThreadSize = matching_sizes[0] + hole.Threaded = True + if counterbore: + hole.HoleCutType = "Counterbore" + hole.HoleCutDiameter = "8 mm" + hole.HoleCutDepth = "2 mm" + self.document.recompute() + + self.assert_valid_shape(hole) + return body, hole + + def assert_valid_shape(self, feature): + self.assertFalse(feature.Shape.isNull(), feature.Name) + self.assertTrue(feature.Shape.isValid(), feature.Name) + self.assertGreater(feature.Shape.Volume, 0, feature.Name) + + def assert_valid_features(self, *features): + self.document.recompute() + for feature in features: + self.assertIsNotNone(feature) + self.assert_valid_shape(feature) + class GeometryTests(DocumentTestCase): def test_get_sketch_locations_ignores_construction_geometry(self): @@ -80,6 +150,37 @@ def test_zip_tie_template_has_the_expected_profile(self): self.assertFalse(template.Shape.isNull()) self.assertTrue(template.Shape.isValid()) + def test_zip_tie_channels_create_one_valid_pocket_per_point(self): + body, base = self.make_solid_body() + sketch = self.make_supported_sketch( + body, + base, + "Locations", + [ + Part.Point(App.Vector(-4, 0, 0)), + Part.Point(App.Vector(4, 0, 0)), + ], + ) + + ZipTieChannels.make_zip_tie_channels_from_sketch( + body, + sketch, + width="3.5 mm", + thickness="1.5 mm", + bridge_dia="2.5 mm", + ) + + settings = body.getObject("Locations_ZipTieChannel_Settings") + first = body.getObject("Locations_ZipTieChannel001") + second = body.getObject("Locations_ZipTieChannel002") + self.assertIsNotNone(settings) + self.assertAlmostEqual(settings.ChannelWidth.Value, 3.5) + self.assert_valid_features(first, second) + self.assertLess(second.Shape.Volume, base.Shape.Volume) + + settings.ChannelWidth = "4 mm" + self.assert_valid_features(first, second) + def test_roof_bridge_profile_recomputes(self): body = self.make_body() sketch = body.newObject("Sketcher::SketchObject", "RoofBridge") @@ -104,6 +205,150 @@ def test_roof_bridge_profile_recomputes(self): self.assertFalse(sketch.Shape.isNull()) self.assertTrue(sketch.Shape.isValid()) + def test_counterbore_bridges_create_valid_part_design_features(self): + body, hole = self.make_hole(counterbore=True) + + CounterboreBridges.make_upside_down_counterbores(body, hole) + + bridges_y = body.getObject("Hole_BridgesY") + bridges_x = body.getObject("Hole_BridgesX") + self.assertAlmostEqual(hole.LayerHeight.Value, 0.2) + self.assert_valid_features(bridges_y, bridges_x) + self.assertLess(bridges_x.Shape.Volume, hole.Shape.Volume) + + hole.LayerHeight = "0.3 mm" + self.assert_valid_features(bridges_y, bridges_x) + + def test_teardrops_cover_double_sided_and_counterbore_paths(self): + body, hole = self.make_hole(counterbore=True) + + Teardrop.make_teardrops( + body, + hole, + angle="120 deg", + rotation="90 deg", + do_counterbore=True, + double_sided=True, + ) + + through_hole = body.getObject("Hole_Teardrops") + counterbore = body.getObject("Hole_TeardropsCb") + self.assertAlmostEqual(hole.TeardropAngle.Value, 120) + self.assertAlmostEqual(hole.TeardropRotation.Value, 90) + self.assert_valid_features(through_hole, counterbore) + self.assertLess(counterbore.Shape.Volume, hole.Shape.Volume) + + hole.TeardropAngle = "100 deg" + self.assert_valid_features(through_hole, counterbore) + + def test_roof_bridges_cover_double_sided_and_counterbore_paths(self): + body, hole = self.make_hole(counterbore=True) + + RoofBridge.make_roof_bridges( + body, + hole, + angle="45 deg", + rotation="90 deg", + do_counterbore=True, + do_doublesided=True, + bridge_clearance="0.2 mm", + ) + + features = [ + body.getObject("Hole_RoofBridge"), + body.getObject("Hole_RoofBridge2"), + body.getObject("Hole_RoofBridgeCb"), + body.getObject("Hole_RoofBridgeCb2"), + ] + self.assertAlmostEqual(hole.RoofBridgeOverhangAngle.Value, 45) + self.assertAlmostEqual(hole.RoofBridgeClearance.Value, 0.2) + self.assert_valid_features(*features) + self.assertLess(features[-1].Shape.Volume, hole.Shape.Volume) + + hole.RoofBridgeRotation = "80 deg" + self.assert_valid_features(*features) + + def test_rib_thread_template_and_local_generator_recompute(self): + body, hole = self.make_hole(diameter="3.3 mm", thread_size="M4") + rib_parameters = RibThreads.RIB_PARAMETERS["ISOMetricProfile"]["M4"] + + RibThreads.make_rib_threads( + body, + hole, + global_template=False, + rib_param=rib_parameters, + ) + + template = body.getObject("Hole_RibThread_Template") + ribs = body.getObject("Hole_ThreadRibs") + entrance = body.getObject("Hole_ThreadEntrance") + self.assertEqual(len(template.Geometry), 12) + self.assertAlmostEqual(template.OuterDiameter.Value, 4.4) + self.assert_valid_features(ribs, entrance) + self.assertLess(entrance.Shape.Volume, hole.Shape.Volume) + + settings = body.getObject("Hole_RibThread_Settings") + settings.Rotation = "15 deg" + self.assert_valid_features(ribs, entrance) + + def test_rib_threads_support_multiple_holes_and_global_template(self): + body, hole = self.make_hole( + locations=((-4, 0), (4, 0)), + diameter="3.3 mm", + thread_size="M4", + ) + rib_parameters = RibThreads.RIB_PARAMETERS["ISOMetricProfile"]["M4"] + + RibThreads.make_rib_threads( + body, + hole, + global_template=True, + rib_param=rib_parameters, + ) + + template = RibThreads.find_rib_template(body, hole, global_template=True) + merged_profile = body.getObject("Hole_RibThreads") + ribs = body.getObject("Hole_ThreadRibs") + entrance = body.getObject("Hole_ThreadEntrance") + self.assertIsNotNone( + template, + [(obj.Name, obj.Label) for obj in self.document.Objects if "RibThread" in obj.Name], + ) + self.assertEqual(merged_profile.TypeId, "PartDesign::SubShapeBinder") + self.assert_valid_features(ribs, entrance) + self.assertIs( + RibThreads.get_or_create_rib_template( + body, + hole, + global_template=True, + rib_param=rib_parameters, + ), + template, + ) + + def test_all_workbench_commands_and_task_panels_are_registered(self): + expected_commands = { + "ffDesign_About", + "ffDesign_AutoFillet", + "ffDesign_CounterboreBridges", + "ffDesign_HoleWizard", + "ffDesign_RibThreads", + "ffDesign_RoofBridge", + "ffDesign_Teardrop", + "ffDesign_ZipTieChannels", + } + + self.assertTrue(expected_commands.issubset(set(Gui.listCommands()))) + for panel in ( + "ffDesign_AutoFillet.ui", + "ffDesign_HoleWizard.ui", + "ffDesign_RibThreads.ui", + "ffDesign_RoofBridge.ui", + "ffDesign_Teardrop.ui", + "ffDesign_ZipTieChannels.ui", + ): + self.assertTrue(Utils.Resources.get_panel(panel).endswith(panel)) + if __name__ == "__main__": unittest.main()