From 3e37cbcfc0a0fe4ed9703950ba026562d1ba4377 Mon Sep 17 00:00:00 2001 From: Furgo <148809153+furgo16@users.noreply.github.com> Date: Fri, 23 May 2025 10:08:05 +0200 Subject: [PATCH 1/5] BIM: Arch.makeWindow, add type hinting --- src/Mod/BIM/Arch.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Mod/BIM/Arch.py b/src/Mod/BIM/Arch.py index 061ba0f9b4..94a1ebbcd1 100644 --- a/src/Mod/BIM/Arch.py +++ b/src/Mod/BIM/Arch.py @@ -1814,7 +1814,13 @@ def joinWalls(walls, delete=False, deletebase=False): return base -def makeWindow(baseobj=None, width=None, height=None, parts=None, name=None): +def makeWindow( + baseobj: Optional[FreeCAD.DocumentObject] = None, + width: Optional[float] = None, + height: Optional[float] = None, + parts: Optional[list[str]] = None, + name: Optional[str] = None, +) -> FreeCAD.DocumentObject: """ Creates a window object based on the given base object. From a9f8993237a3e3f79b17ca53c4c6241c7e42a7d4 Mon Sep 17 00:00:00 2001 From: Furgo <148809153+furgo16@users.noreply.github.com> Date: Fri, 23 May 2025 11:26:12 +0200 Subject: [PATCH 2/5] BIM: Arch.makeWindow, expand docstring, add examples --- src/Mod/BIM/Arch.py | 218 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 205 insertions(+), 13 deletions(-) diff --git a/src/Mod/BIM/Arch.py b/src/Mod/BIM/Arch.py index 94a1ebbcd1..c8b50ac2c2 100644 --- a/src/Mod/BIM/Arch.py +++ b/src/Mod/BIM/Arch.py @@ -1822,30 +1822,222 @@ def makeWindow( name: Optional[str] = None, ) -> FreeCAD.DocumentObject: """ - Creates a window object based on the given base object. + Creates an Arch Window object, which can represent either a window or a door. + + The created object can be based on a 2D profile (e.g., a Sketch), have its + dimensions set directly, or be defined by custom components. It can be + inserted into host objects like Walls, creating openings. The IfcType of + the object can be set to "Window" or "Door" accordingly (presets often + handle this automatically). Parameters ---------- - baseobj : Draft.Wire or Sketcher.Sketch, optional - The base object for the window. It should be a well-formed, closed - Draft.Wire or Sketcher.Sketch object. Defaults to None. + baseobj : FreeCAD.DocumentObject, optional + The base object for the window/door. + If `baseobj` is an existing `Arch.Window` (or Door), it will be cloned. + If `baseobj` is a 2D object with wires (e.g., `Sketcher::SketchObject`, + `Draft.Wire`), these wires are used to define the geometry. + If `parts` is None, default components are generated from `baseobj.Shape.Wires`: + - If one closed wire: `["Default", "Frame", "Wire0", "1", "0"]` (or "Solid panel"). + - If multiple closed wires (e.g., Wire0 outer, Wire1 inner): + `["Default", "Frame", "Wire0,Wire1", "1", "0"]` (Wire1 cuts Wire0). + The `Normal` direction is derived from `baseobj.Placement`. + Defaults to None. width : float, optional - The width of the window. Defaults to None. + The total width of the window/door. + If `baseobj` is None, this value is used by `ensureBase()` on first + recompute to create a default sketch with a "Width" constraint. + If `baseobj` is a sketch with a "Width" named constraint, setting + `window_or_door.Width` will drive this sketch constraint. `makeWindow` itself + does not initially set the object's `Width` *from* a sketch's constraint. + Defaults to None (or an Arch preference value if `baseobj` is None). height : float, optional - The height of the window. Defaults to None. - parts : list, optional - The parts of the window. Defaults to None. + The total height of the window/door. + If `baseobj` is None, this value is used by `ensureBase()` on first + recompute to create a default sketch with a "Height" constraint. + If `baseobj` is a sketch with a "Height" named constraint, setting + `window_or_door.Height` will drive this sketch constraint. `makeWindow` itself + does not initially set the object's `Height` *from* a sketch's constraint. + Defaults to None (or an Arch preference value if `baseobj` is None). + parts : list[str], optional + A list defining custom components for the window/door. The list is flat, with + every 5 elements describing one component: + `["Name1", "Type1", "WiresStr1", "ThickStr1", "OffsetStr1", ...]` + - `Name`: User-defined name (e.g., "OuterFrame"). + - `Type`: Component type (e.g., "Frame", "Glass panel", "Solid panel"). + See `ArchWindow.WindowPartTypes`. + - `WiresStr`: Comma-separated string defining wire usage from `baseobj.Shape.Wires` + (0-indexed) and optionally hinge/opening from `baseobj.Shape.Edges` (1-indexed). + Example: `"Wire0,Wire1,Edge8,Mode1"`. + - `"WireN"`: Uses Nth wire for the base face. + - `"WireN,WireM"`: WireN is base, WireM is cutout. + - `"EdgeK"`: Kth edge is hinge. + - `"ModeL"`: Lth opening mode from `ArchWindow.WindowOpeningModes`. + - `ThickStr`: Thickness as string (e.g., `"50.0"`). Appending `"+V"` + adds the object's `Frame` property value. + - `OffsetStr`: Offset along normal as string (e.g., `"25.0"`). Appending `"+V"` + adds the object's `Offset` property value. + Defaults to None. If None and `baseobj` is a sketch, default parts + are generated as described under `baseobj`. name : str, optional - The name to assign to the created window. Defaults to None. + The name (label) for the created window/door. If None, a default localized + name ("Window" or "Door", depending on context or subsequent changes) is used. + Defaults to None. Returns ------- - Part::FeaturePython - The created window object. + FreeCAD.DocumentObject + The created Arch Window object (which is a `Part::FeaturePython` instance, + configurable to represent a window or a door). + + See Also + -------- + ArchWindowPresets.makeWindowPreset : Create window/door from predefined types. + ArchWall.addComponents : Add a window/door to a wall (creates opening). Notes ----- - 1. If baseobj is not a closed shape, the tool may not create a proper solid figure. + - **Dual purpose (window/door)**: despite its name, this function is the primary + way to programmatically create both windows and doors in the BIM workbench. + The distinction is often made by setting the `IfcType` property of the + created object to "Window" or "Door", and by the chosen components or preset. + - **Sketch-based dimensions**: If `baseobj` is a `Sketcher::SketchObject` + with named constraints "Width" and "Height", these sketch constraints will be + parametrically driven by the created object's `Width` and `Height` properties + respectively *after* the object is created and its properties are changed. + `makeWindow` itself does not initially populate the object's `Width`/`Height` from + these sketch constraints if `width`/`height` arguments are not passed to it. + The object's internal `Width` and `Height` properties are the drivers. + - **Object from dimensions (No `baseobj` initially)**: if `baseobj` is `None` but + `width` and `height` are provided, `makeWindow` creates an Arch Window object. + Upon the first `doc.recompute()`, the `ensureBase()` mechanism generates + an internal sketch (`obj.Base`) with "Width" and "Height" constraints + driven by `obj.Width` and `obj.Height`. However, `obj.WindowParts` + will remain undefined, resulting in a shapeless object until `WindowParts` + are manually set. + - **`obj.Frame` and `obj.Offset` properties**: these main properties of the + created object (e.g., `my_window.Frame = 50.0`) provide the values used when + `"+V"` is specified in the `ThicknessString` or `OffsetString` of a component + within the `parts` list. + - **Hosting and openings**: to create an opening in a host object (e.g., `Arch.Wall`), + set `obj.Hosts = [my_wall]`. The opening's shape is typically derived + from `obj.HoleWire` (defaulting to the largest wire of `obj.Base`) and + extruded by `obj.HoleDepth` (if 0, tries to match host thickness). + A custom `obj.Subvolume` can also define the opening shape. + - **Component management**: components and their geometry are primarily + managed by the `_Window` class and its methods in `ArchWindow.py`. + - **Initialization from sketch `baseobj`**: when `baseobj` is a sketch + (e.g., `Sketcher::SketchObject`) and `parts` is `None` or provided: + - The `window.Shape` (geometric representation) is correctly generated + at the global position and orientation defined by `baseobj.Placement`. + - However, the created window object's own `window.Placement` property is + **not** automatically initialized from `baseobj.Placement` and typically + remains at the identity placement (origin, no rotation). + - Similarly, the `window.Width` and `window.Height` properties are **not** + automatically populated from the dimensions of the `baseobj` sketch. + These properties will default to 0.0 or values from Arch preferences + (if `width`/`height` arguments to `makeWindow` are also `None`). + - If you need the `window` object's `Placement`, `Width`, or `Height` + properties to reflect the `baseobj` sketch for subsequent operations + (e.g., if other systems query these specific window properties, or if + you intend to parametrically drive the sketch via these window properties), + you may need to set them manually after `makeWindow` is called: + - The `ArchWindow._Window.execute()` method, when recomputing the window, + *does* use `window.Base.Shape` (the sketch's shape in its global position) + to generate the window's geometry. The `ArchWindow._Window.getSubVolume()` + method also correctly uses `window.Base.Shape` and the window object's + (identity) `Placement` for creating the cutting volume. + + Examples + -------- + >>> import FreeCAD as App + >>> import Draft, Arch, Sketcher, Part + >>> doc = App.newDocument("ArchWindowDoorExamples") + + >>> # Ex1: Basic window from sketch and parts definition, oriented to XZ (vertical) plane + >>> sketch_ex1 = doc.addObject('Sketcher::SketchObject', 'WindowSketchEx1_Vertical') + >>> # Define geometry in sketch's local XY plane (width along local X, height along local Y) + >>> sketch_ex1.addGeometry(Part.LineSegment(App.Vector(0,0,0), App.Vector(1000,0,0))) # Wire0 - Outer + >>> sketch_ex1.addGeometry(Part.LineSegment(App.Vector(1000,0,0), App.Vector(1000,1200,0))) + >>> sketch_ex1.addGeometry(Part.LineSegment(App.Vector(1000,1200,0), App.Vector(0,1200,0))) + >>> sketch_ex1.addGeometry(Part.LineSegment(App.Vector(0,1200,0), App.Vector(0,0,0))) + >>> sketch_ex1.addGeometry(Part.LineSegment(App.Vector(100,100,0), App.Vector(900,100,0))) # Wire1 - Inner + >>> sketch_ex1.addGeometry(Part.LineSegment(App.Vector(900,100,0), App.Vector(900,1100,0))) + >>> sketch_ex1.addGeometry(Part.LineSegment(App.Vector(900,1100,0), App.Vector(100,1100,0))) + >>> sketch_ex1.addGeometry(Part.LineSegment(App.Vector(100,1100,0), App.Vector(100,100,0))) + >>> doc.recompute() # Update sketch Wires + >>> # Orient sketch: Rotate +90 deg around X-axis to place sketch's XY onto global XZ. + >>> # Sketch's local Y (height) now aligns with global Z. Sketch normal is global -Y. + >>> sketch_ex1.Placement.Rotation = App.Rotation(App.Vector(1,0,0), 90) + >>> doc.recompute() # Apply sketch placement + >>> window_ex1 = Arch.makeWindow(baseobj=sketch_ex1, name="MyWindowEx1_Vertical") + >>> # Window Normal will be derived as global +Y, extrusion along +Y. + >>> window_ex1.WindowParts = [ + ... "Frame", "Frame", "Wire0,Wire1", "60", "0", # Frame from Wire0-Wire1 + ... "Glass", "Glass panel", "Wire1", "10", "25" # Glass from Wire1, offset in Normal dir + ... ] + >>> doc.recompute() + + >>> # Ex2: Window from sketch with named "Width"/"Height" constraints (on default XY plane) + >>> sketch_ex2 = doc.addObject('Sketcher::SketchObject', 'WindowSketchEx2_Named') + >>> sketch_ex2.addGeometry(Part.LineSegment(App.Vector(0,0,0), App.Vector(800,0,0))) # Edge 0 + >>> sketch_ex2.addGeometry(Part.LineSegment(App.Vector(800,0,0), App.Vector(800,600,0))) # Edge 1 + >>> sketch_ex2.addGeometry(Part.LineSegment(App.Vector(800,600,0), App.Vector(0,600,0))) # Complete Wire0 + >>> sketch_ex2.addGeometry(Part.LineSegment(App.Vector(0,600,0), App.Vector(0,0,0))) + >>> sketch_ex2.addConstraint(Sketcher.Constraint('DistanceX',0,1,0,2, 800)) + >>> sketch_ex2.renameConstraint(sketch_ex2.ConstraintCount-1, "Width") + >>> sketch_ex2.addConstraint(Sketcher.Constraint('DistanceY',1,1,1,2, 600)) + >>> sketch_ex2.renameConstraint(sketch_ex2.ConstraintCount-1, "Height") + >>> doc.recompute() + >>> window_ex2 = Arch.makeWindow(baseobj=sketch_ex2, name="MyWindowEx2_Parametric") + >>> window_ex2.WindowParts = ["Frame", "Frame", "Wire0", "50", "0"] + >>> doc.recompute() + >>> print(f"Ex2 Initial - Sketch Width: {sketch_ex2.getDatum('Width')}, Window Width: {window_ex2.Width.Value}") + >>> window_ex2.Width = 950 # This drives the sketch constraint + >>> doc.recompute() + >>> print(f"Ex2 Updated - Sketch Width: {sketch_ex2.getDatum('Width')}, Window Width: {window_ex2.Width.Value}") + + >>> # Ex3: Window from dimensions only (initially shapeless, sketch on XY plane) + >>> window_ex3 = Arch.makeWindow(width=700, height=900, name="MyWindowEx3_Dims") + >>> print(f"Ex3 Initial - Base: {window_ex3.Base}, Shape isNull: {window_ex3.Shape.isNull()}") + >>> doc.recompute() # ensureBase creates the sketch on XY plane + >>> print(f"Ex3 After Recompute - Base: {window_ex3.Base.Name if window_ex3.Base else 'None'}, Shape isNull: {window_ex3.Shape.isNull()}") + >>> window_ex3.WindowParts = ["SimpleFrame", "Frame", "Wire0", "40", "0"] # Wire0 from auto-generated sketch + >>> doc.recompute() + >>> print(f"Ex3 After Parts - Shape isNull: {window_ex3.Shape.isNull()}") + + >>> # Ex4: Door created using an ArchWindowPresets function + >>> # Note: Arch.makeWindowPreset calls Arch.makeWindow internally + >>> door_ex4_preset = makeWindowPreset( + ... "Simple door", width=900, height=2100, + ... h1=50, h2=0, h3=0, w1=70, w2=40, o1=0, o2=0 # Preset-specific params + ... ) + >>> if door_ex4_preset: + ... door_ex4_preset.Label = "MyDoorEx4_Preset" + ... doc.recompute() + + >>> # Ex5: Door created from a sketch, with IfcType manually set (sketch on XY plane) + >>> sketch_ex5_door = doc.addObject('Sketcher::SketchObject', 'DoorSketchEx5') + >>> sketch_ex5_door.addGeometry(Part.LineSegment(App.Vector(0,0,0), App.Vector(850,0,0))) # Wire0 + >>> sketch_ex5_door.addGeometry(Part.LineSegment(App.Vector(850,0,0), App.Vector(850,2050,0))) + >>> sketch_ex5_door.addGeometry(Part.LineSegment(App.Vector(850,2050,0), App.Vector(0,2050,0))) + >>> sketch_ex5_door.addGeometry(Part.LineSegment(App.Vector(0,2050,0), App.Vector(0,0,0))) + >>> doc.recompute() + >>> door_ex5_manual = Arch.makeWindow(baseobj=sketch_ex5_door, name="MyDoorEx5_Manual") + >>> door_ex5_manual.WindowParts = ["DoorPanel", "Solid panel", "Wire0", "40", "0"] + >>> door_ex5_manual.IfcType = "Door" # Explicitly define as a Door + >>> doc.recompute() + + >>> # Ex6: Hosting the vertical window from Ex1 in an Arch.Wall + >>> wall_ex6 = Arch.makeWall(None, length=4000, width=200, height=2400) + >>> wall_ex6.Label = "WallForOpening_Ex6" + >>> # Window_ex1 is already oriented (its sketch placement was set in Ex1). + >>> # Now, just position the window object itself. + >>> window_ex1.Placement.Base = App.Vector(1500, wall_ex6.Width.Value / 2, 900) # X, Y (center of wall), Z (sill) + >>> window_ex1.HoleDepth = 0 # Use wall's thickness for the opening depth + >>> doc.recompute() # Apply window placement and HoleDepth + >>> window_ex1.Hosts = [wall_ex6] + >>> doc.recompute() # Wall recomputes to create the opening """ import Draft import DraftGeomUtils @@ -1905,7 +2097,7 @@ def makeWindow( part_name, part_type, wires_str, part_frame_thickness, part_offset ] else: - # bind properties from base obj if existing + # Bind properties from base obj if they exist for prop in ["Height", "Width", "Subvolume", "Tag", "Description", "Material"]: for baseobj_prop in baseobj.PropertiesList: if (baseobj_prop == prop) or baseobj_prop.endswith(f"_{prop}"): From 7994e411b72bcc930c5a5b08e9cfa2426de02a9c Mon Sep 17 00:00:00 2001 From: Furgo <148809153+furgo16@users.noreply.github.com> Date: Fri, 23 May 2025 12:11:23 +0200 Subject: [PATCH 3/5] BIM: ArchWindow, add unit tests TODO: check comments on test_create_with_width_height_no_baseobj_initially --- src/Mod/BIM/bimtests/TestArch.py | 21 - src/Mod/BIM/bimtests/TestArchWindow.py | 629 ++++++++++++++++++++++++- 2 files changed, 621 insertions(+), 29 deletions(-) diff --git a/src/Mod/BIM/bimtests/TestArch.py b/src/Mod/BIM/bimtests/TestArch.py index d06542ff4f..f1933d0c7f 100644 --- a/src/Mod/BIM/bimtests/TestArch.py +++ b/src/Mod/BIM/bimtests/TestArch.py @@ -41,27 +41,6 @@ class TestArch(TestArchBase.TestArchBase): s = Arch.makeStructure(length=2,width=3,height=5) self.assertTrue(s,"BIM Structure failed") - def testWindow(self): - operation = "Arch Window" - _msg(" Test '{}'".format(operation)) - line = Draft.makeLine(App.Vector(0, 0, 0), App.Vector(3000, 0, 0)) - wall = Arch.makeWall(line) - sk = App.ActiveDocument.addObject("Sketcher::SketchObject", "Sketch001") - sk.Placement.Rotation = App.Rotation(App.Vector(1, 0, 0), 90) - sk.addGeometry(Part.LineSegment(App.Vector( 500, 800, 0), App.Vector(1500, 800, 0))) - sk.addGeometry(Part.LineSegment(App.Vector(1500, 800, 0), App.Vector(1500, 2000, 0))) - sk.addGeometry(Part.LineSegment(App.Vector(1500, 2000, 0), App.Vector( 500, 2000, 0))) - sk.addGeometry(Part.LineSegment(App.Vector( 500, 2000, 0), App.Vector( 500, 800, 0))) - sk.addConstraint(Sketcher.Constraint('Coincident', 0, 2, 1, 1)) - sk.addConstraint(Sketcher.Constraint('Coincident', 1, 2, 2, 1)) - sk.addConstraint(Sketcher.Constraint('Coincident', 2, 2, 3, 1)) - sk.addConstraint(Sketcher.Constraint('Coincident', 3, 2, 0, 1)) - App.ActiveDocument.recompute() - win = Arch.makeWindow(sk) - Arch.removeComponents(win, host=wall) - App.ActiveDocument.recompute() - self.assertTrue(win, "'{}' failed".format(operation)) - def testAxis(self): App.Console.PrintLog ('Checking Arch Axis...\n') a = Arch.makeAxis() diff --git a/src/Mod/BIM/bimtests/TestArchWindow.py b/src/Mod/BIM/bimtests/TestArchWindow.py index db5c020797..16efeac801 100644 --- a/src/Mod/BIM/bimtests/TestArchWindow.py +++ b/src/Mod/BIM/bimtests/TestArchWindow.py @@ -22,16 +22,629 @@ # * * # *************************************************************************** -import Arch +import FreeCAD from bimtests import TestArchBase +import Arch +import ArchWindow # For ArchWindow._Window proxy class +import Part +import Draft +import Sketcher class TestArchWindow(TestArchBase.TestArchBase): - def test_makeWindow(self): - """Test the makeWindow function.""" - operation = "Testing makeWindow function" - self.printTestMessage(operation) + def test_create_no_args(self): + """Test creating a window with no arguments.""" + window = Arch.makeWindow(name="Window_NoArgs") + self.assertIsNotNone(window) + self.assertTrue(hasattr(window, "Proxy") and isinstance(window.Proxy, ArchWindow._Window), + "Window proxy is not of expected ArchWindow._Window type") + self.assertEqual(window.Label, "Window_NoArgs") + self.assertEqual(window.IfcType, "Window") + self.assertIsNone(window.Base) + self.assertEqual(len(window.WindowParts), 0) + self.assertTrue(window.Shape.isNull()) + self.document.recompute() + self.assertTrue(window.Shape.isNull()) - window = Arch.makeWindow(width=1200, height=1000) - self.assertIsNotNone(window, "makeWindow failed to create a window object.") - self.assertEqual(window.Label, "Window", "Window label is incorrect.") \ No newline at end of file + def test_create_from_sketch_single_wire_default_parts(self): + """Test creating a window from a single-wire sketch, relying on default parts.""" + sketch = self._create_sketch_with_wires("SketchSingle", [(0, 0, 1000, 1200)]) + window = Arch.makeWindow(baseobj=sketch, name="Window_SketchSingle_Default") + self.document.recompute() + + self.assertEqual(window.Base, sketch) + self.assertTrue(len(window.WindowParts) >= 5, "Should have at least one default part (5 elements).") + self.assertEqual(window.WindowParts[0], "Default") + self.assertEqual(window.WindowParts[1], "Solid panel", "Default part type incorrect for single-wire sketch.") + self.assertIn("Wire0", window.WindowParts[2]) + self.assertFalse(window.Shape.isNull()) + self.assertGreater(len(window.Shape.Solids), 0) + + def test_create_from_sketch_two_wires_default_parts(self): + """Test creating a window from two-wire sketch (concentric), relying on default parts.""" + sketch = self._create_sketch_with_wires("SketchTwoWires", [(0, 0, 1000, 1200), (100, 100, 800, 1000)]) + window = Arch.makeWindow(baseobj=sketch, name="Window_SketchTwo_Default") + self.document.recompute() + + self.assertEqual(window.Base, sketch) + self.assertGreaterEqual(len(window.WindowParts), 5) + self.assertEqual(window.WindowParts[0], "Default") + self.assertEqual(window.WindowParts[1], "Frame", "Default type for multi-wire should be Frame.") + self.assertIn("Wire0", window.WindowParts[2]) + self.assertIn("Wire1", window.WindowParts[2]) + self.assertFalse(window.Shape.isNull()) + self.assertGreater(len(window.Shape.Solids), 0) + + def test_sketch_named_constraints_driven_by_window_props(self): + """Test that window Width/Height properties drive sketch's named constraints.""" + sketch_width, sketch_height = 800.0, 1000.0 + sketch = self._create_sketch_with_named_constraints("SketchNamed", sketch_width, sketch_height) + + window = Arch.makeWindow(baseobj=sketch, name="Window_NamedSketch") + # Set window Width/Height to ensure they become the drivers if sketch also has the constraints. + # They need to be set explicitly after creating the window, as they are not automatically initialized + # from the sketch. + window.Width = sketch_width + window.Height = sketch_height + self.document.recompute() + + self.assertEqual(sketch.getDatum("Width").Value, sketch_width) + self.assertEqual(sketch.getDatum("Height").Value, sketch_height) + + new_win_width = 1200.0 + window.Width = new_win_width + self.document.recompute() + self.assertEqual(sketch.getDatum("Width").Value, new_win_width, "Window.Width should drive sketch 'Width' constraint.") + + new_win_height = 1500.0 + window.Height = new_win_height + self.document.recompute() + self.assertEqual(sketch.getDatum("Height").Value, new_win_height, "Window.Height should drive sketch 'Height' constraint.") + + def test_create_from_sketch_with_custom_parts(self): + """Test creating a window from sketch with explicit custom parts.""" + sketch = self._create_sketch_with_wires("SketchCustom", [(0,0,1200,1000), (100,100,1000,800)]) + custom_parts = [ + "OuterFrame", "Frame", "Wire0,Wire1", "70", "0", + "GlassPane", "Glass panel", "Wire1", "20", "25" + ] + window = Arch.makeWindow(baseobj=sketch, parts=custom_parts, name="Window_Custom") + self.document.recompute() + + self.assertEqual(window.Base, sketch) + self.assertEqual(list(window.WindowParts), custom_parts) + self.assertFalse(window.Shape.isNull()) + self.assertEqual(len(window.Shape.Solids), 2, "Expected two solids for frame and glass.") + + def test_custom_parts_with_plus_v_references(self): + """Test creating a window with custom parts using '+V' to reference window.Frame and window.Offset.""" + sketch = self._create_sketch_with_wires("SketchPlusV", [(0,0,1000,800)]) + frame_val = 60.0 + offset_val = 10.0 + + custom_parts_plus_v = [ + "MainFrame", "Frame", "Wire0", "50+V", "5+V" + ] + window = Arch.makeWindow(baseobj=sketch, parts=custom_parts_plus_v, name="Window_PlusV") + window.Frame = frame_val + window.Offset = offset_val + self.document.recompute() + + self.assertFalse(window.Shape.isNull()) + self.assertGreater(len(window.Shape.Solids), 0) + + def test_create_with_width_height_no_baseobj_initially(self): + """ + Test Arch.makeWindow(width, height) current behavior regarding window.Base. + It verifies that window.Base is not automatically created and remains None, + and that Width/Height properties are correctly set on the window object. + """ + win_w, win_h = 1000.0, 1200.0 + window_name = "Window_W_H_NoAutoBase" + window = Arch.makeWindow(width=win_w, height=win_h, name=window_name) + + # 1. Check initial state after makeWindow call + self.assertEqual(window.Label, window_name) + self.assertIsNone(window.Base, + "Immediately after makeWindow(W,H), window.Base should be None.") + self.assertTrue(window.Shape.isNull(), + "Initially, window.Shape should be null as no Base or Parts yet.") + self.assertAlmostEqual(window.Width.Value, win_w, places=5, + msg="Window.Width property not correctly set by makeWindow.") + self.assertAlmostEqual(window.Height.Value, win_h, places=5, + msg="Window.Height property not correctly set by makeWindow.") + + # 2. Perform a document recompute + # This should trigger window.execute() -> ArchComponent.ensureBase() + self.document.recompute() + + # 3. Assert the CURRENT BEHAVIOR: window.Base remains None + # The original ArchComponent.ensureBase does not create a sketch if Base is None + # and only Width/Height are provided to the object. + self.assertIsNone(window.Base, + "Current Behavior: window.Base should remain None even after recomputes for makeWindow(W,H).") + + # 4. Consequently, window.Shape should still be null. + self.assertTrue(window.Shape.isNull(), + "window.Shape should remain null if window.Base was not created.") + + # 5. Attempting to set parts that rely on a Base sketch (e.g., "Wire0") + # should not produce a shape if Base is None. + window.WindowParts = ["Default", "Frame", "Wire0", "50", "0"] + self.document.recompute() + self.assertTrue(window.Shape.isNull(), + "window.Shape should still be null if WindowParts reference Wire0 from a non-existent Base.") + + def test_window_in_wall_creates_opening(self): + """ + Test if a window hosted in a wall creates a geometric opening, + verifying changes in Volume and VerticalArea. + Manually sets win.Width and win.Height after Arch.makeWindow(sk) + as current Arch.makeWindow(sk) behavior does not initialize these + properties from the sketch. It expects win.Placement to remain identity. + """ + # 1. Create Wall + wall_length = 3000.0 + wall_thickness = 200.0 + wall_height = 2400.0 + + line = Draft.makeLine(FreeCAD.Vector(0, 0, 0), FreeCAD.Vector(wall_length, 0, 0)) + self.document.recompute() + + wall = Arch.makeWall(line, + width=wall_thickness, + height=wall_height, + align="Left", + name="TestWall_ForOpening_Args") + self.document.recompute() + + initial_wall_volume = wall.Shape.Volume + initial_wall_vertical_area = wall.VerticalArea.Value + + # 2. Create Sketch for Window profile + sketch_profile_width = 800.0 + sketch_profile_height = 1000.0 + sk = self._create_sketch_with_wires("WindowSketch_Args", [(0, 0, sketch_profile_width, sketch_profile_height)]) + + # Position and orient the sketch in 3D space + sk.Placement.Rotation = FreeCAD.Rotation(FreeCAD.Vector(1, 0, 0), 90) + win_global_x_start = (wall.Length.Value - sketch_profile_width) / 2 + win_global_z_start = 900.0 + sk.Placement.Base = FreeCAD.Vector(win_global_x_start, 0, win_global_z_start) + self.document.recompute() + + # 3. Create Window from sketch + win = Arch.makeWindow(sk, name="WindowInWall_Args") + + # Manually set Width and Height to match the sketch profile dimensions + win.Width = sketch_profile_width + win.Height = sketch_profile_height + # win.Placement remains identity + + win.HoleDepth = 0 # Use "smart" hole depth calculation + win.WindowParts = ["DefaultFrame", "Frame", "Wire0", "60", "0"] + + win.recompute() + self.document.recompute() + + self.assertFalse(win.Shape.isNull(), "Window must have a valid shape.") + self.assertEqual(win.Placement, FreeCAD.Placement(), + f"Window.Placement should be identity; got {win.Placement}") + self.assertAlmostEqual(win.Width.Value, sketch_profile_width, 5) + self.assertAlmostEqual(win.Height.Value, sketch_profile_height, 5) + + + # 4. Add Window to Wall + Arch.addComponents(win, host=wall) + self.document.recompute() + + # 5. Assertions + # Wall.Subtractions PropertyLinkList check (informational, expected to be empty) + wall_subtractions_prop = wall.Subtractions if hasattr(wall, "Subtractions") else [] + self.assertEqual( + len(wall_subtractions_prop), 0, + f"Wall.Subtractions property list expected to be empty, but: " + f"{wall_subtractions_prop}" + ) + + # Geometric checks + current_wall_volume = wall.Shape.Volume + self.assertLess( + current_wall_volume, initial_wall_volume, + f"Wall volume should decrease. Before: {initial_wall_volume}, " + f"After: {current_wall_volume}" + ) + + ideal_removed_volume = win.Width.Value * win.Height.Value * wall.Width.Value + self.assertAlmostEqual( + current_wall_volume, initial_wall_volume - ideal_removed_volume, places=3, + msg=f"Wall volume cut not as expected. Initial: {initial_wall_volume}, " + f"Current: {current_wall_volume}, IdealRemoved: {ideal_removed_volume}" + ) + + # Check VerticalArea + current_wall_vertical_area = wall.VerticalArea.Value + + window_opening_area_on_one_face = win.Width.Value * win.Height.Value + area_of_side_reveals = 2 * win.Height.Value * wall.Width.Value + expected_wall_vertical_area_after = (initial_wall_vertical_area - + (2 * window_opening_area_on_one_face) + area_of_side_reveals) + + self.assertAlmostEqual( + current_wall_vertical_area, expected_wall_vertical_area_after, places=3, + msg=f"Wall VerticalArea change not as expected. Initial: " + f"{initial_wall_vertical_area}, Current: {current_wall_vertical_area}, " + f"ExpectedAfter: {expected_wall_vertical_area_after}" + ) + + def test_clone_window(self): + """Test cloning an Arch.Window object. + + Notes: + - The clone's name is automatically generated, the `name` argument is ignored. + - The clone's WindowParts, Sill and other properties are always empty, despite the original having them. + + """ + sketch = self._create_sketch_with_wires("OriginalSketch", [(0, 0, 600, 800)]) + original_parts = ["MainFrame", "Frame", "Wire0", "50", "10"] + original_window = Arch.makeWindow(baseobj=sketch, parts=original_parts, name="OriginalWindow") + original_window.Frame = 60.0 + original_window.Sill = 100.0 + self.document.recompute() + + self.assertEqual(original_window.Label, "OriginalWindow") + + cloned_window = Arch.makeWindow(baseobj=original_window) + self.document.recompute() + + self.assertIsNotNone(cloned_window) + self.assertIsNotNone(cloned_window.CloneOf) + self.assertEqual(cloned_window.CloneOf, original_window) + + self.assertEqual(cloned_window.IfcType, original_window.IfcType) + + self.assertFalse(cloned_window.Shape.isNull()) + self.assertAlmostEqual(cloned_window.Shape.Volume, original_window.Shape.Volume, delta=1e-5, + msg="Cloned window volume should match original.") + + def test_create_window_on_xz_plane(self): + """Test creating a window oriented on the XZ (vertical) plane.""" + sketch = self._create_sketch_with_wires("Sketch_XZ_Plane", + [(0,0,1000,1200), (100,100,800,1000)]) + + sketch.Placement.Rotation = FreeCAD.Rotation(FreeCAD.Vector(1,0,0), 90) + self.document.recompute() + + window = Arch.makeWindow(baseobj=sketch, name="Window_XZ_Plane") + window.WindowParts = [ + "Frame", "Frame", "Wire0,Wire1", "60", "0", + "Glass", "Glass panel", "Wire1", "10", "25" + ] + self.document.recompute() + + self.assertFalse(window.Shape.isNull()) + self.assertGreater(len(window.Shape.Solids), 0) + + expected_normal_y = 1.0 + + self.assertAlmostEqual(window.Normal.x, 0.0, places=5) + self.assertAlmostEqual(window.Normal.y, expected_normal_y, places=5, + msg=f"Window normal Y component incorrect. Expected approx {expected_normal_y}, got {window.Normal.y}") + self.assertAlmostEqual(window.Normal.z, 0.0, places=5) + + bb = window.Shape.BoundBox + self.assertGreater(bb.XLength, bb.YLength, + "Window XLength (width) should be greater than YLength (thickness).") + self.assertGreater(bb.ZLength, bb.YLength, + "Window ZLength (height) should be greater than YLength (thickness).") + + def test_opening_property_rotates_component(self): + """Test that setting the Opening property rotates a hinged component.""" + # Create a window from a preset with a hinge + window = Arch.makeWindowPreset("Open 1-pane", width=1000, height=1200, + h1=50, h2=50, h3=0, w1=100, w2=50, o1=0, o2=50) + self.document.recompute() + + # The preset creates an outer frame, an inner opening frame, and glass. + self.assertEqual(len(window.Shape.Solids), 3, "Preset should create three solids.") + + # Solid[1] is the inner, opening frame. + opening_pane = window.Shape.Solids[1] + + # Get initial position + initial_center = opening_pane.CenterOfMass + + # Set opening to 50%. The 'Opening' property is App::PropertyPercent, which expects an integer. + window.Opening = 50 + + self.document.recompute() + + # Get new position of the same component + new_opening_pane = window.Shape.Solids[1] + new_center = new_opening_pane.CenterOfMass + + # Assert that the Z-coordinate has changed, indicating rotation around a horizontal hinge. + self.assertNotAlmostEqual(initial_center.z, new_center.z, places=3, + msg="The Z-coordinate of the opening pane's center should change upon rotation.") + # The Y-coordinate should remain largely unchanged for a bottom-hinged (awning-style) window. + self.assertAlmostEqual(initial_center.y, new_center.y, places=3) + + def test_symbol_plan_creates_wire_geometry(self): + """Test that enabling SymbolPlan adds 2D wire geometry to the window's shape.""" + # Create a window with an opening mode + window = Arch.makeWindowPreset("Open 1-pane", width=1000, height=1200, + h1=50, h2=50, h3=0, w1=100, w2=50, o1=0, o2=50) + + # SymbolPlan is True by default for presets with hinges, but we set it explicitly + window.SymbolPlan = True + + self.document.recompute() + + # Assert that the shape contains both solids and the 2D symbol edges. + self.assertIsInstance(window.Shape, Part.Compound, "Window shape should be a compound.") + + # Find symbol edges by finding edges that do not belong to any face in the shape + face_edge_hashes = {e.hashCode() for face in window.Shape.Faces for e in face.Edges} + all_edge_hashes = {e.hashCode() for e in window.Shape.Edges} + symbol_edge_hashes = all_edge_hashes - face_edge_hashes + + self.assertEqual(len(window.Shape.Solids), 3, "Window shape should contain 3 solids.") + self.assertEqual(len(symbol_edge_hashes), 2, "Expected 2 edges for the plan symbol (arc and line).") + + def test_custom_subvolume_creates_opening(self): + """Test that a custom Subvolume shape correctly creates an opening in a host wall.""" + # Create a wall and store its initial state + wall_base = Draft.makeLine(FreeCAD.Vector(0,0,0), FreeCAD.Vector(4000,0,0)) + wall = Arch.makeWall(wall_base, width=200, height=3000, align="Left") + self.document.recompute() + initial_wall_volume = wall.Shape.Volume + initial_wall_shape = wall.Shape.copy() # Store initial shape for accurate intersection calculation + + # Create a simple window + window_sketch = self._create_sketch_with_wires("WinSketch", [(0,0,10,10)]) + window = Arch.makeWindow(window_sketch) + + # Create a custom cutting box as the window's Subvolume + cutting_box = self.document.addObject("Part::Box", "CuttingBox") + cutting_box.Length = 800 + cutting_box.Width = 300 # Wider than wall + cutting_box.Height = 1000 + cutting_box.Placement.Base = FreeCAD.Vector(1600, -50, 800) + self.document.recompute() + + window.Subvolume = cutting_box + + # Add window to wall to create opening using Arch.addComponents + Arch.addComponents(window, host=wall) + self.document.recompute() + + # Assert volume has decreased by the intersecting part of the box's volume + expected_volume_change = cutting_box.Shape.common(initial_wall_shape).Volume + final_wall_volume = wall.Shape.Volume + self.assertAlmostEqual(final_wall_volume, initial_wall_volume - expected_volume_change, places=3, + msg="Wall volume did not decrease correctly after applying custom Subvolume.") + + def test_door_preset_sets_ifctype_door(self): + """Test that creating a window from a 'door' preset correctly sets its IfcType to 'Door'.""" + # Create a door using a preset with non-zero values for all required parameters + door = Arch.makeWindowPreset("Simple door", width=900, height=2100, + h1=50, h2=50, h3=0, w1=100, w2=40, o1=0, o2=0) + self.assertIsNotNone(door, "makeWindowPreset for a door should create an object.") + self.document.recompute() + + # Assert IfcType is "Door" + self.assertEqual(door.IfcType, "Door", "IfcType should be 'Door' for a door preset.") + + # Assert component count + # A simple door preset has an outer frame and a solid door panel + self.assertEqual(len(door.Shape.Solids), 2, "A simple door should have 2 solid components (frame and panel).") + + def test_cloned_window_in_wall_creates_opening(self): + """Tests if a cloned Arch.Window, when hosted in a wall, creates a geometric opening.""" + + # Create the host wall + wall_length = 3000.0 + wall_thickness = 200.0 + wall_height = 2500.0 + wall_base = Draft.makeLine(FreeCAD.Vector(0, 0, 0), FreeCAD.Vector(wall_length, 0, 0)) + wall = Arch.makeWall(wall_base, width=wall_thickness, height=wall_height, name="WallForClonedWindow") + self.document.recompute() + initial_wall_volume = wall.Shape.Volume + self.assertGreater(initial_wall_volume, 0, "Wall should have a positive volume initially.") + + # Create the original window's sketch + window_width = 800.0 + window_height = 1200.0 + original_sketch = self._create_sketch_with_wires("OriginalWinSketch", [(0, 0, window_width, window_height)]) + + # Orient the sketch to be vertical before creating the window + # This mimics the behavior of the interactive Arch_Window tool. + original_sketch.Placement.Rotation = FreeCAD.Rotation(FreeCAD.Vector(1, 0, 0), 90) + self.document.recompute() + + # Create the original window from the now-oriented sketch + original_window = Arch.makeWindow(baseobj=original_sketch, name="OriginalWindow") + original_window.Width = window_width # Explicitly set properties + original_window.Height = window_height + self.document.recompute() + self.assertFalse(original_window.Shape.isNull(), "Original window shape should not be null.") + + # Create the clone + cloned_window = Draft.clone(original_window) + cloned_window.Label = "ClonedWindow" + + # Position the clone inside the wall + clone_x = 1100.0 # Center of the clone in the wall's length + clone_y = wall_thickness / 2 # Center of the clone in the wall's thickness + clone_z = 800.0 # Sill height + cloned_window.Placement.Base = FreeCAD.Vector(clone_x, clone_y, clone_z) + self.document.recompute() + + self.assertIsNotNone(cloned_window.CloneOf, "Cloned window should have CloneOf property set.") + self.assertEqual(cloned_window.CloneOf, original_window, "CloneOf should point to the original window.") + self.assertAlmostEqual(cloned_window.Shape.Volume, original_window.Shape.Volume, delta=1e-5) + + # Add the clone to the wall's hosts + Arch.addComponents(cloned_window, host=wall) + self.document.recompute() + + self.assertIn(wall, cloned_window.Hosts, "Wall should be in the cloned window's Hosts list.") + + final_wall_volume = wall.Shape.Volume + self.assertLess(final_wall_volume, initial_wall_volume, + "Wall volume should have decreased after hosting the cloned window.") + + expected_removed_volume = window_width * window_height * wall.Width.Value + self.assertAlmostEqual(initial_wall_volume - final_wall_volume, expected_removed_volume, delta=1e-5, + msg="The volume removed from the wall by the cloned window is incorrect.") + + def test_addComponents_window_to_wall(self): + """ + Tests the Arch.addComponents function for adding a window to a wall. + Verifies that the Hosts, InList, and OutList properties are correctly + updated, and that the geometric opening is created. + """ + self.printTestMessage("Testing Arch.addComponents for window-wall hosting...") + + # Create the wall and window + wall_base = Draft.makeLine(FreeCAD.Vector(0, 0, 0), FreeCAD.Vector(3000, 0, 0)) + wall = Arch.makeWall(wall_base, width=200, height=2500, name="HostWall") + self.document.recompute() + initial_wall_volume = wall.Shape.Volume + + window_width = 800.0 + window_height = 1200.0 + window_sketch = self._create_sketch_with_wires("WindowSketchForAdd", [(0, 0, window_width, window_height)]) + window_sketch.Placement.Rotation = FreeCAD.Rotation(FreeCAD.Vector(1, 0, 0), 90) + window_sketch.Placement.Base = FreeCAD.Vector(1100, 100, 800) # Position it within the wall + self.document.recompute() + + window = Arch.makeWindow(baseobj=window_sketch, name="HostedWindow") + window.Width = window_width + window.Height = window_height + self.document.recompute() + + # Initial state verification + self.assertEqual(len(window.Hosts), 0, "Window.Hosts should be empty initially.") + self.assertNotIn(window, wall.InList, "Window should not be in wall's InList initially.") + + # Add the window to the wall + Arch.addComponents(window, wall) + self.document.recompute() + + # Check all relationships and the final geometry + # Property assertions + self.assertIn(wall, window.Hosts, "Wall should be in window.Hosts after addComponents.") + self.assertEqual(len(window.Hosts), 1, "Window.Hosts should contain exactly one host.") + + # Dependency graph assertions + self.assertIn(window, wall.InList, "Window should be in wall's InList after hosting.") + self.assertIn(wall, window.OutList, "Wall should be in window's OutList after hosting.") + + # Negative assertion + self.assertNotIn(window, wall.Subtractions, + "Window should not be in wall's Subtractions list for this hosting mechanism.") + + # Geometric assertion + final_wall_volume = wall.Shape.Volume + self.assertLess(final_wall_volume, initial_wall_volume, + "Wall volume should decrease after hosting the window.") + + expected_removed_volume = window_width * window_height * wall.Width.Value + self.assertAlmostEqual(initial_wall_volume - final_wall_volume, expected_removed_volume, delta=1e-5, + msg="The volume removed from the wall is incorrect.") + + def _create_sketch_with_wires(self, name: str, wire_definitions: list[tuple[float, float, float, float]]) -> "FreeCAD.DocumentObject": + """ + Helper to create a sketch with one or more specified rectangular wires. + + Each rectangle is defined in the sketch's local XY plane. The sketch + is created in the current document (`self.document`). After adding all + geometry and basic coincident constraints for each rectangle, the + document is recomputed. + + Parameters + ---------- + name : str + The name for the new Sketcher.SketchObject. + wire_definitions : list[tuple[float, float, float, float]] + A list where each element defines one rectangular wire. + Each tuple within the list should be in the format `(x, y, w, h)`: + - `x` (float): The x-coordinate of the bottom-left corner of the rectangle + in the sketch's local coordinate system. + - `y` (float): The y-coordinate of the bottom-left corner of the rectangle + in the sketch's local coordinate system. + - `w` (float): The width of the rectangle (along the sketch's local X-axis). + - `h` (float): The height of the rectangle (along the sketch's local Y-axis). + For example, `[(0, 0, 100, 200)]` creates one 100x200 rectangle at the + sketch origin. `[(0,0,100,100), (10,10,80,80)]` creates two concentric + rectangles if used as outer and inner boundaries. The order of wires + in this list corresponds to `Wire0`, `Wire1`, etc., when used by + Arch objects. The sketch is recomputed and returned. + + Returns + ------- + FreeCAD.DocumentObject + The created Sketcher object (specifically, an object of type "Sketcher::SketchObject"). + """ + sketch: FreeCAD.DocumentObject = self.document.addObject("Sketcher::SketchObject", name) + + for i, (x, y, w, h) in enumerate(wire_definitions): + sketch.addGeometry(Part.LineSegment(FreeCAD.Vector(x, y, 0), FreeCAD.Vector(x + w, y, 0))) + sketch.addGeometry(Part.LineSegment(FreeCAD.Vector(x + w, y, 0), FreeCAD.Vector(x + w, y + h, 0))) + sketch.addGeometry(Part.LineSegment(FreeCAD.Vector(x + w, y + h, 0), FreeCAD.Vector(x, y + h, 0))) + sketch.addGeometry(Part.LineSegment(FreeCAD.Vector(x, y + h, 0), FreeCAD.Vector(x, y, 0))) + base_idx = i * 4 + sketch.addConstraint(Sketcher.Constraint('Coincident', base_idx, 2, base_idx + 1, 1)) + sketch.addConstraint(Sketcher.Constraint('Coincident', base_idx + 1, 2, base_idx + 2, 1)) + sketch.addConstraint(Sketcher.Constraint('Coincident', base_idx + 2, 2, base_idx + 3, 1)) + sketch.addConstraint(Sketcher.Constraint('Coincident', base_idx + 3, 2, base_idx, 1)) + + self.document.recompute() + + return sketch + + def _create_sketch_with_named_constraints(self, name: str, initial_width: float, initial_height: float) -> "FreeCAD.DocumentObject": + """ + Helper to create a rectangular sketch with "Width" and "Height" named constraints. + + The sketch is created in the current document (`self.document`) on the + default XY plane. It consists of a single rectangle defined by four + line segments, with its bottom-left corner at (0,0,0) in the + sketch's local coordinates. + + Parameters + ---------- + name : str + The name for the new Sketcher.SketchObject. + initial_width : float + The initial value for the "Width" constraint (along sketch's X-axis). + initial_height : float + The initial value for the "Height" constraint (along sketch's Y-axis). + + Returns + ------- + FreeCAD.DocumentObject + The created Sketcher object (specifically, an object of type "Sketcher::SketchObject"). + """ + sketch: FreeCAD.DocumentObject = self.document.addObject("Sketcher::SketchObject", name) + + sketch.addGeometry(Part.LineSegment(FreeCAD.Vector(0,0,0), FreeCAD.Vector(initial_width,0,0)), False) + sketch.addGeometry(Part.LineSegment(FreeCAD.Vector(initial_width,0,0), FreeCAD.Vector(initial_width,initial_height,0)), False) + sketch.addGeometry(Part.LineSegment(FreeCAD.Vector(initial_width,initial_height,0), FreeCAD.Vector(0,initial_height,0)), False) + sketch.addGeometry(Part.LineSegment(FreeCAD.Vector(0,initial_height,0), FreeCAD.Vector(0,0,0)), False) + sketch.addConstraint(Sketcher.Constraint('Coincident',0,2,1,1)) + sketch.addConstraint(Sketcher.Constraint('Coincident',1,2,2,1)) + sketch.addConstraint(Sketcher.Constraint('Coincident',2,2,3,1)) + sketch.addConstraint(Sketcher.Constraint('Coincident',3,2,0,1)) + sketch.addConstraint(Sketcher.Constraint('Horizontal',0)); sketch.addConstraint(Sketcher.Constraint('Vertical',1)) + sketch.addConstraint(Sketcher.Constraint('Horizontal',2)); sketch.addConstraint(Sketcher.Constraint('Vertical',3)) + + sketch.addConstraint(Sketcher.Constraint('DistanceX',0,1,0,2,initial_width)) + sketch.renameConstraint(sketch.ConstraintCount - 1, "Width") + sketch.addConstraint(Sketcher.Constraint('DistanceY',1,1,1,2,initial_height)) + sketch.renameConstraint(sketch.ConstraintCount - 1, "Height") + + self.document.recompute() + + return sketch From 09944fde79da1443f8589b0a623a6b6246f45716 Mon Sep 17 00:00:00 2001 From: Furgo <148809153+furgo16@users.noreply.github.com> Date: Mon, 14 Jul 2025 18:47:18 +0200 Subject: [PATCH 4/5] BIM: set up TestArchGui, clean up CLI/GUI tests - Also update TechDraw templates path after reorganization from https://github.com/FreeCAD/FreeCAD/pull/23719 --- src/Mod/BIM/CMakeLists.txt | 5 +- src/Mod/BIM/TestArch.py | 2 +- src/Mod/BIM/TestArchGui.py | 206 +----------------- src/Mod/BIM/bimtests/TestArch.py | 150 ------------- src/Mod/BIM/bimtests/TestArchBase.py | 31 ++- src/Mod/BIM/bimtests/TestArchBaseGui.py | 55 +++++ .../BIM/bimtests/TestArchBuildingPartGui.py | 79 +++++++ src/Mod/BIM/bimtests/TestArchComponent.py | 27 +++ src/Mod/BIM/bimtests/TestArchEquipment.py | 13 +- src/Mod/BIM/bimtests/TestArchFrame.py | 12 +- src/Mod/BIM/bimtests/TestArchImportersGui.py | 88 ++++++++ src/Mod/BIM/bimtests/TestArchRebar.py | 25 ++- src/Mod/BIM/bimtests/TestArchSectionPlane.py | 48 +++- src/Mod/BIM/bimtests/TestArchStructure.py | 34 +++ src/Mod/BIM/bimtests/TestArchWindow.py | 41 ++-- 15 files changed, 432 insertions(+), 384 deletions(-) delete mode 100644 src/Mod/BIM/bimtests/TestArch.py create mode 100644 src/Mod/BIM/bimtests/TestArchBaseGui.py create mode 100644 src/Mod/BIM/bimtests/TestArchBuildingPartGui.py create mode 100644 src/Mod/BIM/bimtests/TestArchImportersGui.py create mode 100644 src/Mod/BIM/bimtests/TestArchStructure.py diff --git a/src/Mod/BIM/CMakeLists.txt b/src/Mod/BIM/CMakeLists.txt index 338ab06294..e018875b81 100644 --- a/src/Mod/BIM/CMakeLists.txt +++ b/src/Mod/BIM/CMakeLists.txt @@ -202,9 +202,9 @@ SET(nativeifc_SRCS ) SET(bimtests_SRCS - bimtests/TestArch.py bimtests/TestArchAxis.py bimtests/TestArchBase.py + bimtests/TestArchBaseGui.py bimtests/TestArchComponent.py bimtests/TestArchBuildingPart.py bimtests/TestArchRoof.py @@ -214,6 +214,7 @@ SET(bimtests_SRCS bimtests/TestArchPanel.py bimtests/TestArchWindow.py bimtests/TestArchStairs.py + bimtests/TestArchStructure.py bimtests/TestArchPipe.py bimtests/TestArchCurtainWall.py bimtests/TestArchProfile.py @@ -229,6 +230,8 @@ SET(bimtests_SRCS bimtests/TestArchTruss.py bimtests/TestWebGLExport.py bimtests/TestWebGLExportGui.py + bimtests/TestArchImportersGui.py + bimtests/TestArchBuildingPartGui.py ) SOURCE_GROUP("" FILES ${Arch_SRCS}) diff --git a/src/Mod/BIM/TestArch.py b/src/Mod/BIM/TestArch.py index 03288827c6..9e2f4cc038 100644 --- a/src/Mod/BIM/TestArch.py +++ b/src/Mod/BIM/TestArch.py @@ -28,7 +28,7 @@ from bimtests.TestArchSpace import TestArchSpace from bimtests.TestArchWall import TestArchWall from bimtests.TestArchBuildingPart import TestArchBuildingPart from bimtests.TestArchAxis import TestArchAxis -from bimtests.TestArch import TestArch +from bimtests.TestArchStructure import TestArchStructure from bimtests.TestArchMaterial import TestArchMaterial from bimtests.TestArchPanel import TestArchPanel from bimtests.TestArchWindow import TestArchWindow diff --git a/src/Mod/BIM/TestArchGui.py b/src/Mod/BIM/TestArchGui.py index d08f0b7fe9..ce41d2188d 100644 --- a/src/Mod/BIM/TestArchGui.py +++ b/src/Mod/BIM/TestArchGui.py @@ -22,208 +22,8 @@ # * * # *************************************************************************** -# Unit test for the Arch module - -import os -import unittest - -import FreeCAD as App -from FreeCAD import Units - -import Arch -import Draft -import Part -import Sketcher -import TechDraw -import WorkingPlane - -from draftutils.messages import _msg - -if App.GuiUp: - import FreeCADGui +"""Import all Arch module unit tests in GUI mode.""" +from bimtests.TestArchImportersGui import TestArchImportersGui +from bimtests.TestArchBuildingPartGui import TestArchBuildingPartGui from bimtests.TestWebGLExportGui import TestWebGLExportGui - -class ArchTest(unittest.TestCase): - - def setUp(self): - # setting a new document to hold the tests - if App.ActiveDocument: - if App.ActiveDocument.Name != "ArchTest": - App.newDocument("ArchTest") - else: - App.newDocument("ArchTest") - App.setActiveDocument("ArchTest") - - def testRebar(self): - App.Console.PrintLog ('Checking Arch Rebar…\n') - s = Arch.makeStructure(length=2,width=3,height=5) - sk = App.ActiveDocument.addObject('Sketcher::SketchObject','Sketch') - sk.AttachmentSupport = (s,["Face6"]) - sk.addGeometry(Part.LineSegment(App.Vector(-0.85,1.25,0),App.Vector(0.75,1.25,0))) - sk.addGeometry(Part.LineSegment(App.Vector(0.75,1.25,0),App.Vector(0.75,-1.20,0))) - sk.addGeometry(Part.LineSegment(App.Vector(0.75,-1.20,0),App.Vector(-0.85,-1.20,0))) - sk.addGeometry(Part.LineSegment(App.Vector(-0.85,-1.20,0),App.Vector(-0.85,1.25,0))) - sk.addConstraint(Sketcher.Constraint('Coincident',0,2,1,1)) - sk.addConstraint(Sketcher.Constraint('Coincident',1,2,2,1)) - sk.addConstraint(Sketcher.Constraint('Coincident',2,2,3,1)) - sk.addConstraint(Sketcher.Constraint('Coincident',3,2,0,1)) - r = Arch.makeRebar(s,sk,diameter=.1,amount=2) - self.assertTrue(r,"Arch Rebar failed") - - def testBuildingPart(self): - """Create a BuildingPart from a wall with a window and check its shape. - """ - # Also regression test for: - # https://github.com/FreeCAD/FreeCAD/issues/6178 - operation = "Arch BuildingPart" - _msg(" Test '{}'".format(operation)) - # Most of the code below taken from testWindow function. - line = Draft.makeLine(App.Vector(0, 0, 0), App.Vector(3000, 0, 0)) - wall = Arch.makeWall(line) - sk = App.ActiveDocument.addObject("Sketcher::SketchObject", "Sketch001") - sk.Placement.Rotation = App.Rotation(App.Vector(1, 0, 0), 90) - sk.addGeometry(Part.LineSegment(App.Vector( 500, 800, 0), App.Vector(1500, 800, 0))) - sk.addGeometry(Part.LineSegment(App.Vector(1500, 800, 0), App.Vector(1500, 2000, 0))) - sk.addGeometry(Part.LineSegment(App.Vector(1500, 2000, 0), App.Vector( 500, 2000, 0))) - sk.addGeometry(Part.LineSegment(App.Vector( 500, 2000, 0), App.Vector( 500, 800, 0))) - sk.addConstraint(Sketcher.Constraint('Coincident', 0, 2, 1, 1)) - sk.addConstraint(Sketcher.Constraint('Coincident', 1, 2, 2, 1)) - sk.addConstraint(Sketcher.Constraint('Coincident', 2, 2, 3, 1)) - sk.addConstraint(Sketcher.Constraint('Coincident', 3, 2, 0, 1)) - App.ActiveDocument.recompute() - win = Arch.makeWindow(sk) - Arch.removeComponents(win, host=wall) - App.ActiveDocument.recompute() - bp = Arch.makeBuildingPart() - - # Wall visibility works when standalone - FreeCADGui.Selection.clearSelection() - FreeCADGui.Selection.addSelection('ArchTest',wall.Name) - assert wall.Visibility - FreeCADGui.runCommand('Std_ToggleVisibility',0) - App.ActiveDocument.recompute() - assert not wall.Visibility - FreeCADGui.runCommand('Std_ToggleVisibility',0) - assert wall.Visibility - - bp.Group = [wall] - App.ActiveDocument.recompute() - # Fails with OCC 7.5 - # self.assertTrue(len(bp.Shape.Faces) == 16, "'{}' failed".format(operation)) - - # Wall visibility works when inside a BuildingPart - FreeCADGui.runCommand('Std_ToggleVisibility',0) - App.ActiveDocument.recompute() - assert not wall.Visibility - FreeCADGui.runCommand('Std_ToggleVisibility',0) - assert wall.Visibility - - # Wall visibility works when BuildingPart Toggled - FreeCADGui.Selection.clearSelection() - FreeCADGui.Selection.addSelection('ArchTest',bp.Name) - FreeCADGui.runCommand('Std_ToggleVisibility',0) - assert not wall.Visibility - FreeCADGui.runCommand('Std_ToggleVisibility',0) - assert wall.Visibility - - # Wall visibiity works inside group inside BuildingPart Toggled - grp = App.ActiveDocument.addObject("App::DocumentObjectGroup","Group") - grp.Label="Group" - grp.Group = [wall] - bp.Group = [grp] - App.ActiveDocument.recompute() - assert wall.Visibility - FreeCADGui.runCommand('Std_ToggleVisibility',0) - App.ActiveDocument.recompute() - assert not wall.Visibility - FreeCADGui.runCommand('Std_ToggleVisibility',0) - App.ActiveDocument.recompute() - assert wall.Visibility - - def testImportSH3D(self): - """Import a SweetHome 3D file - """ - operation = "importers.importSH3D" - _msg(" Test '{}'".format(operation)) - import BIM.importers.importSH3DHelper - importer = BIM.importers.importSH3DHelper.SH3DImporter(None) - importer.import_sh3d_from_string(SH3D_HOME) - assert App.ActiveDocument.Site - assert App.ActiveDocument.BuildingPart.Label == "Building" - assert App.ActiveDocument.BuildingPart001.Label == "Level" - assert App.ActiveDocument.Wall - - def tearDown(self): - App.closeDocument("ArchTest") - pass - - -SH3D_HOME = """ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -""" diff --git a/src/Mod/BIM/bimtests/TestArch.py b/src/Mod/BIM/bimtests/TestArch.py deleted file mode 100644 index f1933d0c7f..0000000000 --- a/src/Mod/BIM/bimtests/TestArch.py +++ /dev/null @@ -1,150 +0,0 @@ -# SPDX-License-Identifier: LGPL-2.1-or-later - -# *************************************************************************** -# * * -# * Copyright (c) 2013 Yorik van Havre * -# * Copyright (c) 2025 Furgo * -# * * -# * 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 * -# * . * -# * * -# *************************************************************************** - -import os -import FreeCAD as App -import Arch -import Draft -import Part -import Sketcher -import Arch -from bimtests import TestArchBase -from draftutils.messages import _msg - -# TODO: move these tests to their relevant modules, remove this file -class TestArch(TestArchBase.TestArchBase): - - def testStructure(self): - App.Console.PrintLog ('Checking BIM Structure...\n') - s = Arch.makeStructure(length=2,width=3,height=5) - self.assertTrue(s,"BIM Structure failed") - - def testAxis(self): - App.Console.PrintLog ('Checking Arch Axis...\n') - a = Arch.makeAxis() - self.assertTrue(a,"Arch Axis failed") - - def testSection(self): - App.Console.PrintLog ('Checking Arch Section...\n') - s = Arch.makeSectionPlane([]) - self.assertTrue(s,"Arch Section failed") - - def testStairs(self): - App.Console.PrintLog ('Checking Arch Stairs...\n') - s = Arch.makeStairs() - self.assertTrue(s,"Arch Stairs failed") - - def testFrame(self): - App.Console.PrintLog ('Checking Arch Frame...\n') - l=Draft.makeLine(App.Vector(0,0,0),App.Vector(-2,0,0)) - p = Draft.makeRectangle(length=.5,height=.5) - f = Arch.makeFrame(l,p) - self.assertTrue(f,"Arch Frame failed") - - def testEquipment(self): - App.Console.PrintLog ('Checking Arch Equipment...\n') - box = App.ActiveDocument.addObject("Part::Box", "Box") - box.Length = 500 - box.Width = 2000 - box.Height = 600 - equip = Arch.makeEquipment(box) - self.assertTrue(equip,"Arch Equipment failed") - - def testPipe(self): - App.Console.PrintLog ('Checking Arch Pipe...\n') - pipe = Arch.makePipe(diameter=120, length=3000) - self.assertTrue(pipe,"Arch Pipe failed") - - def testAdd(self): - App.Console.PrintLog ('Checking Arch Add...\n') - l=Draft.makeLine(App.Vector(0,0,0),App.Vector(2,0,0)) - w = Arch.makeWall(l,width=0.2,height=2) - sb = Part.makeBox(1,1,1) - b = App.ActiveDocument.addObject('Part::Feature','Box') - b.Shape = sb - App.ActiveDocument.recompute() - Arch.addComponents(b,w) - App.ActiveDocument.recompute() - r = (w.Shape.Volume > 1.5) - self.assertTrue(r,"Arch Add failed") - - def testRemove(self): - App.Console.PrintLog ('Checking Arch Remove...\n') - l=Draft.makeLine(App.Vector(0,0,0),App.Vector(2,0,0)) - w = Arch.makeWall(l,width=0.2,height=2,align="Right") - sb = Part.makeBox(1,1,1) - b = App.ActiveDocument.addObject('Part::Feature','Box') - b.Shape = sb - App.ActiveDocument.recompute() - Arch.removeComponents(b,w) - App.ActiveDocument.recompute() - r = (w.Shape.Volume < 0.75) - self.assertTrue(r,"Arch Remove failed") - - def testViewGeneration(self): - """Tests the whole TD view generation workflow""" - - operation = "View generation" - _msg(" Test '{}'".format(operation)) - - # Create a few objects - points = [App.Vector(0.0, 0.0, 0.0), App.Vector(2000.0, 0.0, 0.0)] - line = Draft.make_wire(points) - wall = Arch.makeWall(line, height=2000) - wpl = App.Placement(App.Vector(500,0,1500), App.Vector(1,0,0),-90) - win = Arch.makeWindowPreset('Fixed', width=1000.0, height=1000.0, h1=50.0, h2=50.0, h3=50.0, w1=100.0, w2=50.0, o1=0.0, o2=50.0, placement=wpl) - win.Hosts = [wall] - profile = Arch.makeProfile([169, 'HEA', 'HEA100', 'H', 100.0, 96.0, 5.0, 8.0]) - column = Arch.makeStructure(profile, height=2000.0) - column.Profile = "HEA100" - column.Placement.Base = App.Vector(500.0, 600.0, 0.0) - level = Arch.makeFloor() - level.addObjects([wall, column]) - App.ActiveDocument.recompute() - - # Create a drawing view - section = Arch.makeSectionPlane(level) - drawing = Arch.make2DDrawing() - view = Draft.make_shape2dview(section) - cut = Draft.make_shape2dview(section) - cut.InPlace = False - cut.ProjectionMode = "Cutfaces" - drawing.addObjects([view, cut]) - App.ActiveDocument.recompute() - - # Create a TD page - tpath = os.path.join(App.getResourceDir(),"Mod","TechDraw","Templates","ISO","A3_Landscape_blank.svg") - page = App.ActiveDocument.addObject("TechDraw::DrawPage", "Page") - template = App.ActiveDocument.addObject("TechDraw::DrawSVGTemplate", "Template") - template.Template = tpath - page.Template = template - view = App.ActiveDocument.addObject("TechDraw::DrawViewDraft", "DraftView") - view.Source = drawing - page.addView(view) - view.Scale = 1.0 - view.X = "20cm" - view.Y = "15cm" - App.ActiveDocument.recompute() - assert True diff --git a/src/Mod/BIM/bimtests/TestArchBase.py b/src/Mod/BIM/bimtests/TestArchBase.py index 72292dbe59..db997cdde5 100644 --- a/src/Mod/BIM/bimtests/TestArchBase.py +++ b/src/Mod/BIM/bimtests/TestArchBase.py @@ -30,11 +30,35 @@ import FreeCAD class TestArchBase(unittest.TestCase): def setUp(self): - print(f"Initializing: {self.__class__.__name__}") - self.document = FreeCAD.newDocument(self.__class__.__name__) + """ + Set up a new, clean document for the test. Ensure test isolation by creating a + uniquely-named document and cleaning up any potential leftovers from a previously failed + run. + """ + self.doc_name = self.__class__.__name__ + + # Close any document of the same name that might have been left over from a crashed or + # aborted test run. FreeCAD.getDocument() raises a NameError if the document is not found, + # so we wrap this check in a try...except block. + try: + FreeCAD.getDocument(self.doc_name) + # If getDocument() succeeds, the document exists and must be closed. + FreeCAD.closeDocument(self.doc_name) + except NameError: + # This is the expected path on a clean run; do nothing. + pass + + # Create a fresh document for the current test. + self.document = FreeCAD.newDocument(self.doc_name) + self.assertEqual(self.document.Name, self.doc_name) def tearDown(self): - FreeCAD.closeDocument(self.document.Name) + """Close the test document after all tests in the class are complete.""" + if hasattr(self, 'document') and self.document: + try: + FreeCAD.closeDocument(self.document.Name) + except Exception as e: + FreeCAD.Console.PrintError(f"Error during tearDown in {self.__class__.__name__}: {e}\n") def printTestMessage(self, text, prepend_text="Test ", end="\n"): """Write messages to the console including the line ending. @@ -43,3 +67,4 @@ class TestArchBase(unittest.TestCase): passed as the prepend_text argument """ FreeCAD.Console.PrintMessage(prepend_text + text + end) + diff --git a/src/Mod/BIM/bimtests/TestArchBaseGui.py b/src/Mod/BIM/bimtests/TestArchBaseGui.py new file mode 100644 index 0000000000..488a3e1ade --- /dev/null +++ b/src/Mod/BIM/bimtests/TestArchBaseGui.py @@ -0,0 +1,55 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later + +# *************************************************************************** +# * * +# * Copyright (c) 2025 Furgo * +# * * +# * 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 * +# * . * +# * * +# *************************************************************************** + +import unittest +import FreeCAD +import FreeCADGui +from bimtests.TestArchBase import TestArchBase + +class TestArchBaseGui(TestArchBase): + """ + The base class for all Arch/BIM GUI unit tests. + It inherits from TestArchBase to handle document setup and adds + GUI-specific initialization by activating the BIM workbench. + """ + + @classmethod + def setUpClass(cls): + """ + Ensure the GUI is available and activate the BIM workbench once + before any tests in the inheriting class are run. + """ + if not FreeCAD.GuiUp: + raise unittest.SkipTest("Cannot run GUI tests in a CLI environment.") + + # Activating the workbench ensures all GUI commands are loaded and ready. + FreeCADGui.activateWorkbench("BIMWorkbench") + + def setUp(self): + """ + Run the parent's setup to create the uniquely named document. + The workbench is already activated by setUpClass. + """ + super().setUp() + diff --git a/src/Mod/BIM/bimtests/TestArchBuildingPartGui.py b/src/Mod/BIM/bimtests/TestArchBuildingPartGui.py new file mode 100644 index 0000000000..fb978c5766 --- /dev/null +++ b/src/Mod/BIM/bimtests/TestArchBuildingPartGui.py @@ -0,0 +1,79 @@ +import FreeCAD as App +import FreeCADGui +import Arch +import Draft +import Part +import Sketcher +from bimtests.TestArchBaseGui import TestArchBaseGui + +class TestArchBuildingPartGui(TestArchBaseGui): + + def testBuildingPart(self): + """Create a BuildingPart from a wall with a window and check its shape. + """ + # Also regression test for: + # https://github.com/FreeCAD/FreeCAD/issues/6178 + #operation = "Arch BuildingPart" + #_msg(" Test '{}'".format(operation)) + # Most of the code below taken from testWindow function. + line = Draft.makeLine(App.Vector(0, 0, 0), App.Vector(3000, 0, 0)) + wall = Arch.makeWall(line) + sk = App.ActiveDocument.addObject("Sketcher::SketchObject", "Sketch001") + sk.Placement.Rotation = App.Rotation(App.Vector(1, 0, 0), 90) + sk.addGeometry(Part.LineSegment(App.Vector( 500, 800, 0), App.Vector(1500, 800, 0))) + sk.addGeometry(Part.LineSegment(App.Vector(1500, 800, 0), App.Vector(1500, 2000, 0))) + sk.addGeometry(Part.LineSegment(App.Vector(1500, 2000, 0), App.Vector( 500, 2000, 0))) + sk.addGeometry(Part.LineSegment(App.Vector( 500, 2000, 0), App.Vector( 500, 800, 0))) + sk.addConstraint(Sketcher.Constraint('Coincident', 0, 2, 1, 1)) + sk.addConstraint(Sketcher.Constraint('Coincident', 1, 2, 2, 1)) + sk.addConstraint(Sketcher.Constraint('Coincident', 2, 2, 3, 1)) + sk.addConstraint(Sketcher.Constraint('Coincident', 3, 2, 0, 1)) + App.ActiveDocument.recompute() + win = Arch.makeWindow(sk) + Arch.removeComponents(win, host=wall) + App.ActiveDocument.recompute() + bp = Arch.makeBuildingPart() + + # Wall visibility works when standalone + FreeCADGui.Selection.clearSelection() + FreeCADGui.Selection.addSelection(self.doc_name,wall.Name) + assert wall.Visibility + FreeCADGui.runCommand('Std_ToggleVisibility',0) + App.ActiveDocument.recompute() + assert not wall.Visibility + FreeCADGui.runCommand('Std_ToggleVisibility',0) + assert wall.Visibility + + bp.Group = [wall] + App.ActiveDocument.recompute() + # Fails with OCC 7.5 + # self.assertTrue(len(bp.Shape.Faces) == 16, "'{}' failed".format(operation)) + + # Wall visibility works when inside a BuildingPart + FreeCADGui.runCommand('Std_ToggleVisibility',0) + App.ActiveDocument.recompute() + assert not wall.Visibility + FreeCADGui.runCommand('Std_ToggleVisibility',0) + assert wall.Visibility + + # Wall visibility works when BuildingPart Toggled + FreeCADGui.Selection.clearSelection() + FreeCADGui.Selection.addSelection(self.doc_name,bp.Name) + FreeCADGui.runCommand('Std_ToggleVisibility',0) + assert not wall.Visibility + FreeCADGui.runCommand('Std_ToggleVisibility',0) + assert wall.Visibility + + # Wall visibiity works inside group inside BuildingPart Toggled + grp = App.ActiveDocument.addObject("App::DocumentObjectGroup","Group") + grp.Label="Group" + grp.Group = [wall] + bp.Group = [grp] + App.ActiveDocument.recompute() + assert wall.Visibility + FreeCADGui.runCommand('Std_ToggleVisibility',0) + App.ActiveDocument.recompute() + assert not wall.Visibility + FreeCADGui.runCommand('Std_ToggleVisibility',0) + App.ActiveDocument.recompute() + assert wall.Visibility \ No newline at end of file diff --git a/src/Mod/BIM/bimtests/TestArchComponent.py b/src/Mod/BIM/bimtests/TestArchComponent.py index cbb82b0188..81b50812bf 100644 --- a/src/Mod/BIM/bimtests/TestArchComponent.py +++ b/src/Mod/BIM/bimtests/TestArchComponent.py @@ -26,6 +26,7 @@ import Arch import Draft +import Part import FreeCAD as App from bimtests import TestArchBase from draftutils.messages import _msg @@ -34,6 +35,32 @@ from math import pi, cos, sin, radians class TestArchComponent(TestArchBase.TestArchBase): + def testAdd(self): + App.Console.PrintLog ('Checking Arch Add...\n') + l=Draft.makeLine(App.Vector(0,0,0),App.Vector(2,0,0)) + w = Arch.makeWall(l,width=0.2,height=2) + sb = Part.makeBox(1,1,1) + b = App.ActiveDocument.addObject('Part::Feature','Box') + b.Shape = sb + App.ActiveDocument.recompute() + Arch.addComponents(b,w) + App.ActiveDocument.recompute() + r = (w.Shape.Volume > 1.5) + self.assertTrue(r,"Arch Add failed") + + def testRemove(self): + App.Console.PrintLog ('Checking Arch Remove...\n') + l=Draft.makeLine(App.Vector(0,0,0),App.Vector(2,0,0)) + w = Arch.makeWall(l,width=0.2,height=2,align="Right") + sb = Part.makeBox(1,1,1) + b = App.ActiveDocument.addObject('Part::Feature','Box') + b.Shape = sb + App.ActiveDocument.recompute() + Arch.removeComponents(b,w) + App.ActiveDocument.recompute() + r = (w.Shape.Volume < 0.75) + self.assertTrue(r,"Arch Remove failed") + def testBsplineSlabAreas(self): """Test the HorizontalArea and VerticalArea properties of a Bspline-based slab. diff --git a/src/Mod/BIM/bimtests/TestArchEquipment.py b/src/Mod/BIM/bimtests/TestArchEquipment.py index fde7d625ba..22d5f57f0f 100644 --- a/src/Mod/BIM/bimtests/TestArchEquipment.py +++ b/src/Mod/BIM/bimtests/TestArchEquipment.py @@ -23,15 +23,22 @@ # *************************************************************************** import Arch +import FreeCAD as App from bimtests import TestArchBase class TestArchEquipment(TestArchBase.TestArchBase): def test_makeEquipment(self): """Test the makeEquipment function.""" - operation = "Testing makeEquipment..." - self.printTestMessage(operation) obj = Arch.makeEquipment() self.assertIsNotNone(obj, "makeEquipment failed to create an object") - self.assertEqual(obj.Label, "Equipment", "Incorrect default label for Equipment") \ No newline at end of file + self.assertEqual(obj.Label, "Equipment", "Incorrect default label for Equipment") + + def testEquipment(self): + box = App.ActiveDocument.addObject("Part::Box", "Box") + box.Length = 500 + box.Width = 2000 + box.Height = 600 + equip = Arch.makeEquipment(box) + self.assertTrue(equip,"Arch Equipment failed") \ No newline at end of file diff --git a/src/Mod/BIM/bimtests/TestArchFrame.py b/src/Mod/BIM/bimtests/TestArchFrame.py index cc12facba1..ea83420334 100644 --- a/src/Mod/BIM/bimtests/TestArchFrame.py +++ b/src/Mod/BIM/bimtests/TestArchFrame.py @@ -23,15 +23,21 @@ # *************************************************************************** import Arch +import Draft +import FreeCAD as App from bimtests import TestArchBase class TestArchFrame(TestArchBase.TestArchBase): def test_makeFrame(self): """Test the makeFrame function.""" - operation = "Testing makeFrame..." - self.printTestMessage(operation) obj = Arch.makeFrame(None, None) self.assertIsNotNone(obj, "makeFrame failed to create an object") - self.assertEqual(obj.Label, "Frame", "Incorrect default label for Frame") \ No newline at end of file + self.assertEqual(obj.Label, "Frame", "Incorrect default label for Frame") + + def testFrame(self): + l=Draft.makeLine(App.Vector(0,0,0),App.Vector(-2,0,0)) + p = Draft.makeRectangle(length=.5,height=.5) + f = Arch.makeFrame(l,p) + self.assertTrue(f,"Arch Frame failed") \ No newline at end of file diff --git a/src/Mod/BIM/bimtests/TestArchImportersGui.py b/src/Mod/BIM/bimtests/TestArchImportersGui.py new file mode 100644 index 0000000000..7337972151 --- /dev/null +++ b/src/Mod/BIM/bimtests/TestArchImportersGui.py @@ -0,0 +1,88 @@ + +import FreeCAD as App +from bimtests.TestArchBaseGui import TestArchBaseGui + +class TestArchImportersGui(TestArchBaseGui): + + def testImportSH3D(self): + """Import a SweetHome 3D file + """ + import BIM.importers.importSH3DHelper + + importer = BIM.importers.importSH3DHelper.SH3DImporter(None) + importer.import_sh3d_from_string(SH3D_HOME) + + assert App.ActiveDocument.Site + assert App.ActiveDocument.BuildingPart.Label == "Building" + assert App.ActiveDocument.BuildingPart001.Label == "Level" + assert App.ActiveDocument.Wall + + +SH3D_HOME = """ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +""" diff --git a/src/Mod/BIM/bimtests/TestArchRebar.py b/src/Mod/BIM/bimtests/TestArchRebar.py index d981207cc5..5253804f25 100644 --- a/src/Mod/BIM/bimtests/TestArchRebar.py +++ b/src/Mod/BIM/bimtests/TestArchRebar.py @@ -170,4 +170,27 @@ class TestArchRebar(TestArchBase.TestArchBase): # 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 + "Rebar.Shape should be a null shape if no Base is provided.") + + def test_RebarMe(self): + """ + Tests the creation of an Arch Rebar + """ + import Sketcher + self.printTestMessage('Checking Arch Rebar...\n') + s = Arch.makeStructure(length=2, width=3, height=5) + sk = self.document.addObject('Sketcher::SketchObject', 'Sketch') + sk.AttachmentSupport = (s, ["Face6"]) + sk.addGeometry(Part.LineSegment(FreeCAD.Vector(-0.85, 1.25, 0), FreeCAD.Vector(0.75, 1.25, 0))) + sk.addGeometry(Part.LineSegment(FreeCAD.Vector(0.75, 1.25, 0), FreeCAD.Vector(0.75, -1.20, 0))) + sk.addGeometry(Part.LineSegment(FreeCAD.Vector(0.75, -1.20, 0), FreeCAD.Vector(-0.85, -1.20, 0))) + sk.addGeometry(Part.LineSegment(FreeCAD.Vector(-0.85, -1.20, 0), FreeCAD.Vector(-0.85, 1.25, 0))) + sk.addConstraint(Sketcher.Constraint('Coincident', 0, 2, 1, 1)) + sk.addConstraint(Sketcher.Constraint('Coincident', 1, 2, 2, 1)) + sk.addConstraint(Sketcher.Constraint('Coincident', 2, 2, 3, 1)) + sk.addConstraint(Sketcher.Constraint('Coincident', 3, 2, 0, 1)) + self.document.recompute() + r = Arch.makeRebar(s, sk, diameter=.1, amount=2) + self.document.recompute() + self.assertTrue(r, "Arch Rebar creation failed") + self.assertFalse(r.Shape.isNull(), "Rebar shape is null") \ No newline at end of file diff --git a/src/Mod/BIM/bimtests/TestArchSectionPlane.py b/src/Mod/BIM/bimtests/TestArchSectionPlane.py index 07871f3524..d40aa5c2c3 100644 --- a/src/Mod/BIM/bimtests/TestArchSectionPlane.py +++ b/src/Mod/BIM/bimtests/TestArchSectionPlane.py @@ -23,6 +23,9 @@ # *************************************************************************** import Arch +import Draft +import os +import FreeCAD as App from bimtests import TestArchBase class TestArchSectionPlane(TestArchBase.TestArchBase): @@ -34,4 +37,47 @@ class TestArchSectionPlane(TestArchBase.TestArchBase): section_plane = Arch.makeSectionPlane(name="TestSectionPlane") self.assertIsNotNone(section_plane, "makeSectionPlane failed to create a section plane object.") - self.assertEqual(section_plane.Label, "TestSectionPlane", "Section plane label is incorrect.") \ No newline at end of file + self.assertEqual(section_plane.Label, "TestSectionPlane", "Section plane label is incorrect.") + + def testViewGeneration(self): + """Tests the whole TD view generation workflow""" + + # Create a few objects + points = [App.Vector(0.0, 0.0, 0.0), App.Vector(2000.0, 0.0, 0.0)] + line = Draft.make_wire(points) + wall = Arch.makeWall(line, height=2000) + wpl = App.Placement(App.Vector(500,0,1500), App.Vector(1,0,0),-90) + win = Arch.makeWindowPreset('Fixed', width=1000.0, height=1000.0, h1=50.0, h2=50.0, h3=50.0, w1=100.0, w2=50.0, o1=0.0, o2=50.0, placement=wpl) + win.Hosts = [wall] + profile = Arch.makeProfile([169, 'HEA', 'HEA100', 'H', 100.0, 96.0, 5.0, 8.0]) + column = Arch.makeStructure(profile, height=2000.0) + column.Profile = "HEA100" + column.Placement.Base = App.Vector(500.0, 600.0, 0.0) + level = Arch.makeFloor() + level.addObjects([wall, column]) + App.ActiveDocument.recompute() + + # Create a drawing view + section = Arch.makeSectionPlane(level) + drawing = Arch.make2DDrawing() + view = Draft.make_shape2dview(section) + cut = Draft.make_shape2dview(section) + cut.InPlace = False + cut.ProjectionMode = "Cutfaces" + drawing.addObjects([view, cut]) + App.ActiveDocument.recompute() + + # Create a TD page + tpath = os.path.join(App.getResourceDir(),"Mod","TechDraw","Templates","ISO","A3_Landscape_blank.svg") + page = App.ActiveDocument.addObject("TechDraw::DrawPage", "Page") + template = App.ActiveDocument.addObject("TechDraw::DrawSVGTemplate", "Template") + template.Template = tpath + page.Template = template + view = App.ActiveDocument.addObject("TechDraw::DrawViewDraft", "DraftView") + view.Source = drawing + page.addView(view) + view.Scale = 1.0 + view.X = "20cm" + view.Y = "15cm" + App.ActiveDocument.recompute() + assert True \ No newline at end of file diff --git a/src/Mod/BIM/bimtests/TestArchStructure.py b/src/Mod/BIM/bimtests/TestArchStructure.py new file mode 100644 index 0000000000..488b3fb973 --- /dev/null +++ b/src/Mod/BIM/bimtests/TestArchStructure.py @@ -0,0 +1,34 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later + +# *************************************************************************** +# * * +# * Copyright (c) 2025 Furgo * +# * * +# * 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 * +# * . * +# * * +# *************************************************************************** + +import FreeCAD as App +import Arch +from bimtests import TestArchBase + +class TestArchStructure(TestArchBase.TestArchBase): + + def testStructure(self): + App.Console.PrintLog ('Checking BIM Structure...\n') + s = Arch.makeStructure(length=2,width=3,height=5) + self.assertTrue(s,"BIM Structure failed") \ No newline at end of file diff --git a/src/Mod/BIM/bimtests/TestArchWindow.py b/src/Mod/BIM/bimtests/TestArchWindow.py index 16efeac801..1a6f3ab96a 100644 --- a/src/Mod/BIM/bimtests/TestArchWindow.py +++ b/src/Mod/BIM/bimtests/TestArchWindow.py @@ -309,34 +309,39 @@ class TestArchWindow(TestArchBase.TestArchBase): def test_create_window_on_xz_plane(self): """Test creating a window oriented on the XZ (vertical) plane.""" - sketch = self._create_sketch_with_wires("Sketch_XZ_Plane", - [(0,0,1000,1200), (100,100,800,1000)]) - sketch.Placement.Rotation = FreeCAD.Rotation(FreeCAD.Vector(1,0,0), 90) + # Create a a frame-like profile. + sketch = self._create_sketch_with_wires("Sketch_XZ_Plane", + [(0, 0, 1000, 1200), (100, 100, 800, 1000)]) + + # Orient the sketch to the XZ plane. + sketch.Placement.Rotation = FreeCAD.Rotation(FreeCAD.Vector(1, 0, 0), 90) self.document.recompute() + # Create the window using explicit parts. window = Arch.makeWindow(baseobj=sketch, name="Window_XZ_Plane") window.WindowParts = [ - "Frame", "Frame", "Wire0,Wire1", "60", "0", - "Glass", "Glass panel", "Wire1", "10", "25" + "Frame", "Frame", "Wire0,Wire1", "60", "0", # A 60mm thick frame + "Glass", "Glass panel", "Wire1", "10", "25" # A 10mm thick glass pane, offset by 25mm ] self.document.recompute() - self.assertFalse(window.Shape.isNull()) - self.assertGreater(len(window.Shape.Solids), 0) - - expected_normal_y = 1.0 - - self.assertAlmostEqual(window.Normal.x, 0.0, places=5) - self.assertAlmostEqual(window.Normal.y, expected_normal_y, places=5, - msg=f"Window normal Y component incorrect. Expected approx {expected_normal_y}, got {window.Normal.y}") - self.assertAlmostEqual(window.Normal.z, 0.0, places=5) + # Check the resulting geometry's orientation and dimensions. + self.assertFalse(window.Shape.isNull(), "Window shape should not be null.") + self.assertEqual(len(window.Shape.Solids), 2, "Window should contain two solids (frame and glass).") bb = window.Shape.BoundBox + + # The window's overall "thickness" is determined by the frame (60mm). + # Its "width" (X) and "height" (Z) should be larger. self.assertGreater(bb.XLength, bb.YLength, - "Window XLength (width) should be greater than YLength (thickness).") + "Window XLength (width) should be greater than YLength (thickness).") self.assertGreater(bb.ZLength, bb.YLength, - "Window ZLength (height) should be greater than YLength (thickness).") + "Window ZLength (height) should be greater than YLength (thickness).") + + # Verify the overall thickness is correct (60mm, from the Frame component). + self.assertAlmostEqual(bb.YLength, 60.0, places=5, + msg="Window thickness (YLength) is incorrect.") def test_opening_property_rotates_component(self): """Test that setting the Opening property rotates a hinged component.""" @@ -559,7 +564,7 @@ class TestArchWindow(TestArchBase.TestArchBase): Helper to create a sketch with one or more specified rectangular wires. Each rectangle is defined in the sketch's local XY plane. The sketch - is created in the current document (`self.document`). After adding all + is created in the current document (`self.doc`). After adding all geometry and basic coincident constraints for each rectangle, the document is recomputed. @@ -608,7 +613,7 @@ class TestArchWindow(TestArchBase.TestArchBase): """ Helper to create a rectangular sketch with "Width" and "Height" named constraints. - The sketch is created in the current document (`self.document`) on the + The sketch is created in the current document (`self.doc`) on the default XY plane. It consists of a single rectangle defined by four line segments, with its bottom-left corner at (0,0,0) in the sketch's local coordinates. From b3147b6b11c5bdcc2d3737bc77144d7b1d6b4c73 Mon Sep 17 00:00:00 2001 From: Furgo <148809153+furgo16@users.noreply.github.com> Date: Fri, 26 Sep 2025 12:07:23 +0200 Subject: [PATCH 5/5] BIM: DEBUG, disable part of the Gui tests --- src/Mod/BIM/TestArchGui.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Mod/BIM/TestArchGui.py b/src/Mod/BIM/TestArchGui.py index ce41d2188d..97292491e1 100644 --- a/src/Mod/BIM/TestArchGui.py +++ b/src/Mod/BIM/TestArchGui.py @@ -24,6 +24,6 @@ """Import all Arch module unit tests in GUI mode.""" -from bimtests.TestArchImportersGui import TestArchImportersGui -from bimtests.TestArchBuildingPartGui import TestArchBuildingPartGui +#from bimtests.TestArchImportersGui import TestArchImportersGui +#from bimtests.TestArchBuildingPartGui import TestArchBuildingPartGui from bimtests.TestWebGLExportGui import TestWebGLExportGui