diff --git a/src/Mod/BIM/Arch.py b/src/Mod/BIM/Arch.py index bc9b98358d..169a0c292b 100644 --- a/src/Mod/BIM/Arch.py +++ b/src/Mod/BIM/Arch.py @@ -52,6 +52,7 @@ __author__ = "Yorik van Havre" __url__ = "https://www.freecad.org" import FreeCAD +from typing import Optional if FreeCAD.GuiUp: import FreeCADGui @@ -895,44 +896,100 @@ def makeProject(sites=None, name=None): return project - -def makeRebar(baseobj=None, sketch=None, diameter=None, amount=1, offset=None, name=None): +def makeRebar( + baseobj: Optional[FreeCAD.DocumentObject] = None, + sketch: Optional[FreeCAD.DocumentObject] = None, + diameter: Optional[float] = None, + amount: int = 1, + offset: Optional[float] = None, + name: Optional[str] = None +) -> Optional[FreeCAD.DocumentObject]: """ - Creates a reinforcement bar object. + Creates a reinforcement bar (rebar) object. + + The rebar's geometry is typically defined by a `sketch` object (e.g., a Sketcher::SketchObject + or a Draft.Wire). This sketch represents the path of a single bar. The `amount` and `spacing` + (calculated by the object) properties then determine how many such bars are created and + distributed. + + The `baseobj` usually acts as the structural host for the rebar. The rebar's distribution (e.g., + spacing, direction) can be calculated relative to this host object's dimensions if a `Host` is + assigned and the rebar logic uses it. Parameters ---------- - baseobj : Part::FeaturePython, optional - The structural object to host the rebar. Defaults to None. - sketch : Part::FeaturePython, optional - The sketch defining the rebar profile. Defaults to None. + baseobj : FreeCAD.DocumentObject, optional + The structural object to host the rebar (e.g., an ArchStructure._Structure created with + `Arch.makeStructure()`). If provided with `sketch`, it's set as `rebar.Host`. If provided + *without* a `sketch`, `rebar.Shape` is set from `baseobj.Shape`, and `rebar.Host` remains + None. Defaults to None. + sketch : FreeCAD.DocumentObject, optional + An object (e.g., "Sketcher::SketchObject") whose shape defines the rebar's path. Assigned to + `rebar.Base`. If the sketch is attached to `baseobj` before calling this function (e.g. for + positioning purposes), this function may clear that specific attachment to avoid conflicts, + as the rebar itself will be hosted. Defaults to None. diameter : float, optional - The diameter of the rebar. Defaults to None. + The diameter of the rebar. If None, uses Arch preferences ("RebarDiameter"). Defaults to + None. amount : int, optional - The number of rebars. Defaults to 1. + The number of rebar instances. Defaults to 1. offset : float, optional - The offset distance for the rebar. Defaults to None. + Concrete cover distance, sets `rebar.OffsetStart` and `rebar.OffsetEnd`. If None, uses Arch + preferences ("RebarOffset"). Defaults to None. name : str, optional - The name to assign to the created rebar. Defaults to None. + The user-visible name (Label) for the rebar. If None, defaults to "Rebar". Defaults to None. Returns ------- - Part::FeaturePython - The created rebar object. + FreeCAD.DocumentObject or None + The created rebar object, or None if creation fails. + + Examples + -------- + >>> import FreeCAD, Arch, Part, Sketcher + >>> doc = FreeCAD.newDocument() + >>> # Create a host structure (e.g., a concrete beam) + >>> beam = Arch.makeStructure(length=2000, width=200, height=300) + >>> doc.recompute() # Ensure beam's shape is ready + >>> + >>> # Create a sketch for the rebar path + >>> rebar_sketch = doc.addObject('Sketcher::SketchObject') + >>> # For positioning, attach the sketch to a face of the beam *before* makeRebar + >>> # Programmatically select a face (e.g., the first one) + >>> # For stable scripts, select faces by more reliable means + >>> rebar_sketch.AttachmentSupport = (beam, ['Face1']) # Faces are 1-indexed + >>> rebar_sketch.MapMode = "FlatFace" + >>> # Define sketch geometry relative to the attached face's plane + >>> rebar_sketch.addGeometry(Part.LineSegment(FreeCAD.Vector(25, 25, 0), + ... FreeCAD.Vector(1975, 25, 0)), False) + >>> doc.recompute() # Recompute sketch after geometry and attachment + >>> + >>> # Create the rebar object, linking it to the beam and using the sketch + >>> rebar_obj = Arch.makeRebar(baseobj=beam, sketch=rebar_sketch, diameter=12, + ... amount=4, offset=25) + >>> doc.recompute() # Trigger rebar's geometry calculation """ rebar = _initializeArchObject( "Part::FeaturePython", baseClassName="_Rebar", internalName="Rebar", - defaultLabel=name if name else translate("Arch", "Project"), + defaultLabel=name if name else translate("Arch", "Rebar"), moduleName="ArchRebar", viewProviderName="_ViewProviderRebar", ) # Initialize all relevant properties if baseobj and sketch: + # Case 1: both the structural element (base object) and a sketch defining the shape and path + # of a single rebar strand are provided. This is the most common scenario. if hasattr(sketch, "AttachmentSupport"): if sketch.AttachmentSupport: + # If the sketch is already attached to the base object, remove that attachment. + # Support two AttachmentSupport (PropertyLinkList) formats: + # 1. Tuple: (baseobj, subelement) + # 2. Direct object: baseobj + # TODO: why is the list format not checked for here? + # ~ 3. List: [baseobj, subelement] ~ if isinstance(sketch.AttachmentSupport, tuple): if sketch.AttachmentSupport[0] == baseobj: sketch.AttachmentSupport = None @@ -943,12 +1000,15 @@ def makeRebar(baseobj=None, sketch=None, diameter=None, amount=1, offset=None, n sketch.ViewObject.hide() rebar.Host = baseobj elif not baseobj and sketch: - # A obj could be based on a wire without the existence of a Structure + # Case 2: standalone rebar strand defined by a sketch, not attached to any structural + # element. rebar.Base = sketch if FreeCAD.GuiUp: sketch.ViewObject.hide() rebar.Host = None elif baseobj and not sketch: + # Case 3: rebar strand defined by the shape of a structural element (base object). The + # base object becomes the rebar. rebar.Shape = baseobj.Shape rebar.Diameter = diameter if diameter else params.get_param_arch("RebarDiameter") rebar.Amount = amount diff --git a/src/Mod/BIM/ArchRebar.py b/src/Mod/BIM/ArchRebar.py index 46526f3fb5..8e6d22ac84 100644 --- a/src/Mod/BIM/ArchRebar.py +++ b/src/Mod/BIM/ArchRebar.py @@ -49,6 +49,11 @@ if FreeCAD.GuiUp: from PySide.QtCore import QT_TRANSLATE_NOOP import FreeCADGui from draftutils.translate import translate + # TODO: check if this import is still needed, and if so, whether + # it can be moved made conditional on the GUI being loaded + # for Rebar addon compatibility + from bimcommands import BimRebar + _CommandRebar = BimRebar.Arch_Rebar else: # \cond def translate(ctxt,txt): @@ -57,10 +62,6 @@ else: return txt # \endcond -# for Rebar addon compatibility -from bimcommands import BimRebar -_CommandRebar = BimRebar.Arch_Rebar - class _Rebar(ArchComponent.Component): diff --git a/src/Mod/BIM/bimtests/TestArchRebar.py b/src/Mod/BIM/bimtests/TestArchRebar.py index 5cfdd06ddf..d981207cc5 100644 --- a/src/Mod/BIM/bimtests/TestArchRebar.py +++ b/src/Mod/BIM/bimtests/TestArchRebar.py @@ -22,18 +22,152 @@ # * * # *************************************************************************** +import FreeCAD import Arch +import Part from bimtests import TestArchBase class TestArchRebar(TestArchBase.TestArchBase): - # TODO: remove NOT_ prefix once it is understood why Arch.makeRebar fails - # Check https://wiki.freecad.org/Arch_Rebar#Scripting - def NOT_test_makeRebar(self): - """Test the makeRebar function.""" - operation = "Testing makeRebar function" - self.printTestMessage(operation) + def _create_sketch(self, add_line=True, length=500.0): + """Helper function to create a basic sketch.""" + sketch = self.document.addObject("Sketcher::SketchObject") + if add_line: + sketch.addGeometry(Part.LineSegment(FreeCAD.Vector(0,0,0), + FreeCAD.Vector(length,0,0)), False) + self.document.recompute() + return sketch + + def _create_host_structure(self, length=1000.0, width=200.0, height=300.0): + """Helper function to create a basic host structure.""" + host = Arch.makeStructure(length=length, width=width, height=height) + self.document.recompute() + return host + + def test_makeRebar_default_name(self): + """Test makeRebar creates rebar with default 'Rebar' Label. + + See https://github.com/FreeCAD/FreeCAD/issues/21670 + """ + sketch = self._create_sketch() + rebar = Arch.makeRebar(sketch=sketch, diameter=6) + self.document.recompute() - rebar = Arch.makeRebar(diameter=16, amount=5, name="TestRebar") self.assertIsNotNone(rebar, "makeRebar failed to create a rebar object.") - self.assertEqual(rebar.Label, "TestRebar", "Rebar label is incorrect.") \ No newline at end of file + self.assertEqual(rebar.Label, "Rebar", "Rebar default label should be 'Rebar'.") + self.assertTrue(rebar.Name.startswith("Rebar"), + "Rebar internal name should start with 'Rebar'.") + + def test_makeRebar_custom_name(self): + """Test makeRebar with a custom name sets Label and Mark correctly.""" + sketch = self._create_sketch() + custom_name = "MyCustomRebar" + rebar = Arch.makeRebar(sketch=sketch, diameter=16, amount=5, name=custom_name) + self.document.recompute() + + self.assertIsNotNone(rebar, "makeRebar failed to create a rebar object.") + self.assertEqual(rebar.Label, custom_name, "Rebar label is incorrect.") + self.assertEqual(rebar.Mark, custom_name, + "Rebar mark should match label by default.") + + def test_makeRebar_with_sketch_and_host(self): + """Test makeRebar with both sketch and host object links them correctly.""" + sketch_len = 800.0 + host_len = 1000.0 + sketch = self._create_sketch(length=sketch_len) + host = self._create_host_structure(length=host_len) + + rebar = Arch.makeRebar(baseobj=host, sketch=sketch, diameter=10, amount=3, offset=20) + self.document.recompute() + + self.assertIsNotNone(rebar, "makeRebar failed.") + self.assertEqual(rebar.Host, host, "Rebar.Host is not set correctly.") + self.assertEqual(rebar.Base, sketch, "Rebar.Base (sketch) is not set correctly.") + self.assertAlmostEqual(rebar.Diameter.Value, 10.0, delta=1e-9) + self.assertEqual(rebar.Amount, 3) + self.assertAlmostEqual(rebar.OffsetStart.Value, 20.0, delta=1e-9) + + self.assertTrue(hasattr(rebar, "Shape"), "Rebar should have a Shape attribute.") + self.assertIsNotNone(rebar.Shape, "Rebar.Shape should not be None after recompute.") + self.assertTrue(rebar.Shape.isValid(), "Rebar.Shape should be valid.") + self.assertGreater(rebar.Shape.Length, 0, "Rebar shape seems to have no length.") + self.assertGreater(rebar.TotalLength.Value, 0, + "Rebar total length should be greater than 0.") + + def test_makeRebar_with_sketch_only(self): + """Test makeRebar with only sketch results in no host and valid geometry.""" + sketch = self._create_sketch() + rebar = Arch.makeRebar(sketch=sketch, diameter=8) + self.document.recompute() + + self.assertIsNotNone(rebar, "makeRebar failed.") + self.assertIsNone(rebar.Host, "Rebar.Host should be None.") + self.assertEqual(rebar.Base, sketch, "Rebar.Base (sketch) is not set correctly.") + self.assertAlmostEqual(rebar.Diameter.Value, 8.0, delta=1e-9) + self.assertIsNotNone(rebar.Shape) + self.assertTrue(rebar.Shape.isValid()) + self.assertGreater(rebar.Shape.Length, 0) + + def test_makeRebar_with_baseobj_only_shape_conversion(self): + """Test makeRebar with baseobj only copies shape.""" + host_shape_provider = self._create_host_structure() + rebar = Arch.makeRebar(baseobj=host_shape_provider) + self.document.recompute() + + self.assertIsNotNone(rebar, "makeRebar failed.") + self.assertIsNone(rebar.Host, "Rebar.Host should be None in this mode.") + self.assertIsNone(rebar.Base, "Rebar.Base should be None in this mode.") + self.assertIsNotNone(rebar.Shape, "Rebar.Shape should be set from baseobj.") + self.assertTrue(rebar.Shape.isValid()) + self.assertGreater(rebar.Shape.Volume, 0, + "Converted rebar shape should have volume.") + + def test_makeRebar_properties_set_correctly(self): + """Test makeRebar sets dimensional and calculated properties correctly.""" + sketch = self._create_sketch(length=900) + host = self._create_host_structure(length=1200) + + rebar = Arch.makeRebar( + baseobj=host, + sketch=sketch, + diameter=12.5, + amount=7, + offset=30.0 + ) + # Default label "Rebar" will be used, and Mark will also be "Rebar" + self.document.recompute() + + self.assertEqual(rebar.Mark, "Rebar") + self.assertAlmostEqual(rebar.Diameter.Value, 12.5, delta=1e-9) + self.assertEqual(rebar.Amount, 7) + self.assertAlmostEqual(rebar.OffsetStart.Value, 30.0, delta=1e-9) + self.assertAlmostEqual(rebar.OffsetEnd.Value, 30.0, delta=1e-9) + self.assertEqual(rebar.Host, host) + self.assertEqual(rebar.Base, sketch) + + self.assertTrue(hasattr(rebar, "Length"), "Rebar should have Length property.") + self.assertTrue(hasattr(rebar, "TotalLength"), "Rebar should have TotalLength property.") + self.assertAlmostEqual(rebar.Length.Value, sketch.Shape.Length, delta=1e-9, + msg="Single rebar length should match sketch length.") + expected_total_length = rebar.Length.Value * rebar.Amount + self.assertAlmostEqual(rebar.TotalLength.Value, expected_total_length, delta=1e-9, + msg="TotalLength calculation is incorrect.") + + def test_makeRebar_no_sketch_no_baseobj(self): + """Test makeRebar with no sketch/baseobj creates object with no meaningful shape.""" + rebar = Arch.makeRebar(diameter=10, amount=1) + self.document.recompute() + + self.assertIsNotNone(rebar, + "makeRebar should create an object even with minimal inputs.") + self.assertIsNone(rebar.Base, "Rebar.Base should be None.") + self.assertIsNone(rebar.Host, "Rebar.Host should be None.") + self.assertAlmostEqual(rebar.Diameter.Value, 10.0, delta=1e-9) + self.assertEqual(rebar.Amount, 1) + + # A Part::FeaturePython object always has a .Shape attribute. + # For a rebar with no Base, its execute() returns early, so no geometry is made. + # The Shape should be the default null/empty shape. + self.assertIsNotNone(rebar.Shape, "Rebar object should always have a Shape attribute.") + self.assertTrue(rebar.Shape.isNull(), + "Rebar.Shape should be a null shape if no Base is provided.") \ No newline at end of file