From de11d3cd26fa448cf88917842a7619ec3b8cfed3 Mon Sep 17 00:00:00 2001 From: Rahix Date: Fri, 28 Aug 2026 23:11:36 +0200 Subject: [PATCH 1/2] tests: Add testcase for a partial profile PartDesign_Hole --- ffDesign_Tests.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/ffDesign_Tests.py b/ffDesign_Tests.py index e384c0d..0022cbd 100644 --- a/ffDesign_Tests.py +++ b/ffDesign_Tests.py @@ -122,6 +122,24 @@ def test_construction_and_non_defining(self): self.assert_expected_body() +class PartialHoleProfiles(ffDesignTestCase_Fc11): + test_document = "PartialHoleProfile.FCStd" + + @unittest.expectedFailure + def test_plausibility(self): + self.prepare_regression_test() + self.assert_expected_body() + + def test_partial_profile(self): + """ + Test that a PartDesign_Hole using a partial sketch as its profile is + correctly handled by the counterbore bridges tool. + """ + self.prepare_regression_test() + ffDesign_CounterboreBridges.CounterboreBridgesCommand().Activated() + self.assert_expected_body() + + class RibThreads(ffDesignTestCase): test_document = "TapHoles.FCStd" From 9081db38be4f918cc87f7463b5040cb66893bfb9 Mon Sep 17 00:00:00 2001 From: Rahix Date: Sat, 29 Aug 2026 12:55:09 +0200 Subject: [PATCH 2/2] Add support for holes with partial sketch profiles In modern FreeCAD, you can select only a subset of edges from a sketch as the profile for a feature like a PartDesign_Hole. So far, we have errored when trying to generate ffDesign features ontop of such holes. Add support for these kind of holes by recovering the sketch geometry of the selected edges. Unfortunately, this has do be done in a somewhat convoluted way: We make a list of the "mapped names" of each edge as a lookup table for filtering. Then, when walking the sketch to look for holes, we fetch the "geometry id" of each element. This id, with a prefix (`g` for internal, `e` for external), will appear in the relevant mapped name so we can filter against the earlier list. This is not particularly efficient, but it sidesteps having to parse the deeper structure of the mapped names, which seems hard to rely on [1]. [1]: https://github.com/FreeCAD/FreeCAD/blob/f7fa73cb0f96494679830c3870af6e9c2de89232/src/Mod/Sketcher/App/SketchObject.cpp#L2032-L2086 --- ffDesign_CounterboreBridges.py | 4 +-- ffDesign_RibThreads.py | 4 +-- ffDesign_RoofBridge.py | 4 +-- ffDesign_Teardrop.py | 4 +-- ffDesign_Utils.py | 53 +++++++++++++++++++++++++++++----- 5 files changed, 54 insertions(+), 15 deletions(-) diff --git a/ffDesign_CounterboreBridges.py b/ffDesign_CounterboreBridges.py index f016252..9cb12ad 100644 --- a/ffDesign_CounterboreBridges.py +++ b/ffDesign_CounterboreBridges.py @@ -106,12 +106,12 @@ def make_upside_down_counterbores(body, hole): if not Utils.hole_has_counterbore_sure(hole): Utils.warning_confirm_proceed("Selected Hole does not seem to have a known counterbore type.") - profile_sketch = Utils.get_hole_profile_sketch(hole) + profile_sketch, profile_partial = Utils.get_hole_profile_sketch(hole) sketch_bridges_y = Utils.make_derived_sketch(body, profile_sketch, "_BridgesY") sketch_bridges_x = Utils.make_derived_sketch(body, profile_sketch, "_BridgesX") - for hole_loc in Utils.get_sketch_locations(profile_sketch, Utils.get_hole_profile_type(hole)): + for hole_loc in Utils.get_sketch_locations(profile_sketch, Utils.get_hole_profile_type(hole), profile_partial): # Create parametric y bridges cutout for this circle make_parametric_y_cutout( sketch_bridges_y, diff --git a/ffDesign_RibThreads.py b/ffDesign_RibThreads.py index cd2cadf..4d08c0e 100644 --- a/ffDesign_RibThreads.py +++ b/ffDesign_RibThreads.py @@ -308,7 +308,7 @@ def make_rib_threads(body, hole, global_template: bool, rib_param: RibParameters Utils.assert_body(body) Utils.assert_hole(hole) - profile_sketch = Utils.get_hole_profile_sketch(hole) + profile_sketch, profile_partial = Utils.get_hole_profile_sketch(hole) template = get_or_create_rib_template(body, hole, global_template, rib_param) @@ -327,7 +327,7 @@ def make_rib_threads(body, hole, global_template: bool, rib_param: RibParameters sketch_entrance = Utils.make_derived_sketch(body, profile_sketch, "_ThreadEntrance") - circles = Utils.get_sketch_locations(profile_sketch, Utils.get_hole_profile_type(hole)) + circles = Utils.get_sketch_locations(profile_sketch, Utils.get_hole_profile_type(hole), profile_partial) if len(circles) == 1 and not global_template: # In the special case of the Hole only having one circle and we have # generated a local template, we can just move this local template in diff --git a/ffDesign_RoofBridge.py b/ffDesign_RoofBridge.py index 1b8f328..476080b 100644 --- a/ffDesign_RoofBridge.py +++ b/ffDesign_RoofBridge.py @@ -99,13 +99,13 @@ def make_roof_bridges( hole.addProperty("App::PropertyLength", "RoofBridgeClearance", group="FusedFilamentDesign") hole.RoofBridgeClearance = bridge_clearance - profile_sketch = Utils.get_hole_profile_sketch(hole) + profile_sketch, profile_partial = Utils.get_hole_profile_sketch(hole) roofbridge_sketch = Utils.make_derived_sketch(body, profile_sketch, "_RoofBridge") roofbridge_sketch_other_side = None if do_doublesided: roofbridge_sketch_other_side = Utils.make_derived_sketch(body, profile_sketch, "_RoofBridge2") - hole_locations = Utils.get_sketch_locations(profile_sketch, Utils.get_hole_profile_type(hole)) + hole_locations = Utils.get_sketch_locations(profile_sketch, Utils.get_hole_profile_type(hole), profile_partial) for hole_loc in hole_locations: make_parametric_roof_bridge( roofbridge_sketch, diff --git a/ffDesign_Teardrop.py b/ffDesign_Teardrop.py index 7076059..9de0e5f 100644 --- a/ffDesign_Teardrop.py +++ b/ffDesign_Teardrop.py @@ -109,10 +109,10 @@ def make_teardrops( hole.addProperty("App::PropertyAngle", "TeardropRotation", group="FusedFilamentDesign") hole.TeardropRotation = rotation - profile_sketch = Utils.get_hole_profile_sketch(hole) + profile_sketch, profile_partial = Utils.get_hole_profile_sketch(hole) teardrop_sketch = Utils.make_derived_sketch(body, profile_sketch, "_Teardrops") - hole_locations = Utils.get_sketch_locations(profile_sketch, Utils.get_hole_profile_type(hole)) + hole_locations = Utils.get_sketch_locations(profile_sketch, Utils.get_hole_profile_type(hole), profile_partial) for hole_loc in hole_locations: make_parametric_teardrop( teardrop_sketch, diff --git a/ffDesign_Utils.py b/ffDesign_Utils.py index 703f003..6a26366 100644 --- a/ffDesign_Utils.py +++ b/ffDesign_Utils.py @@ -225,12 +225,14 @@ def get_hole_profile_sketch(hole): if profile_sketch.TypeId != "Sketcher::SketchObject": raise ffDesignError("Hole profile must be a Sketch!") - if hole.Profile[1] != [""]: - raise ffDesignError( - "Only some parts of the sketch are selected as profile for the PartDesign_Hole. This is not supported yet!" - ) - - return profile_sketch + if hole.Profile[1] in ([""], []): + return (profile_sketch, None) + else: + # Only some edges of the Sketch are selected. We prepare identifying + # information for them so we can later filter for only these + # geometries: + profile_partial = [profile_sketch.Shape.getElementMappedName(edge_name) for edge_name in hole.Profile[1]] + return (profile_sketch, profile_partial) def make_derived_sketch(body, original, suffix: str): @@ -277,6 +279,35 @@ def sketch_external_geo_is_defining(sketch, index): return None +def profile_partial_contains_geo(sketch, profile_partial, geo_id) -> bool: + if profile_partial is None: + return True + + geometry_id = sketch.getGeometryId(geo_id) + ident = f"g{geometry_id}" + + for pp in profile_partial: + if ident in pp: + return True + + return False + + +def profile_partial_contains_external_geo(sketch, profile_partial, ext_geo_id) -> bool: + if profile_partial is None: + return True + + ext_geo = sketch.ExternalGeo[ext_geo_id] + geometry_id = Sketcher.ExternalGeometryFacade(ext_geo).Id + ident = f"e{geometry_id}" + + for pp in profile_partial: + if ident in pp: + return True + + return False + + @dataclasses.dataclass class LocationExprSet: vector_expr: str @@ -284,7 +315,7 @@ class LocationExprSet: y_expr: str -def get_sketch_locations(sketch, profile_type): +def get_sketch_locations(sketch, profile_type, profile_partial=None): assert_sketch(sketch) def try_make_loc_expr_set(index, obj, kind): @@ -325,6 +356,10 @@ def try_make_loc_expr_set(index, obj, kind): if sketch.getConstruction(i): continue + # If we are filtering for partial selection, check if this geo is included + if not profile_partial_contains_geo(sketch, profile_partial, i): + continue + loc = try_make_loc_expr_set(i, obj, "Geometry") if loc is not None: locations.append(loc) @@ -334,6 +369,10 @@ def try_make_loc_expr_set(index, obj, kind): if not sketch_external_geo_is_defining(sketch, i): continue + # If we are filtering for partial selection, check if this external geo is included + if not profile_partial_contains_external_geo(sketch, profile_partial, i): + continue + loc = try_make_loc_expr_set(i, obj, "ExternalGeo") if loc is not None: locations.append(loc)