diff --git a/src/Mod/BIM/Arch.py b/src/Mod/BIM/Arch.py index 7d3837f3e1..3e89a9b11b 100644 --- a/src/Mod/BIM/Arch.py +++ b/src/Mod/BIM/Arch.py @@ -2214,7 +2214,15 @@ def debaseWall(wall): This operation preserves the wall's exact size and global position. It is only supported for walls based on a single, straight line. - Returns True on success, False otherwise. + Parameters + ---------- + wall : FreeCAD.DocumentObject + The Arch Wall object to debase. + + Returns + ------- + bool + True on success, False otherwise. """ import FreeCAD @@ -2224,55 +2232,44 @@ def debaseWall(wall): doc = wall.Document doc.openTransaction(f"Debase Wall: {wall.Label}") + try: - # Calculate the final state before making any changes - # A wall's final position and orientation are derived from its Base object and its own - # properties like Width and Align. To make the wall independent, this final state must be - # captured. A simple transfer of the Base object's Placement property is unreliable for two - # main reasons: - # - Ambiguity: A Draft.Line's direction is defined by its vertex coordinates, so a line at - # 45° can have a 0° rotation in its Placement. - # - Coordinate Systems: A universal method is needed to handle both Draft objects (defined - # in global coordinates) and Sketch objects (defined in a local coordinate system). - # - # The solution is to use the wall.Proxy.basewires internal attribute. It is non-persistent - # and populated on the fly by calling getExtrusionData(). It contains the baseline edge - # already transformed into the document's global coordinate system. From the vertex - # positions of this globally-aware edge, the new wall's final Placement is calculated. - extrusion_data = wall.Proxy.getExtrusionData(wall) - if not extrusion_data or not hasattr(wall.Proxy, "basewires") or not wall.Proxy.basewires: - raise Exception("Could not retrieve extrusion data to calculate global placement.") + # --- Calculation of the final placement --- + base_obj = wall.Base + base_edge = base_obj.Shape.Edges[0] - # In addition to the baseline edge, getExtrusionData() also provides the extrusion vector, - # which is used to determine the wall's vertical orientation. - extrusion_vector = extrusion_data[1] - baseline_edge = wall.Proxy.basewires[0][0] + # Step 1: Get global coordinates of the baseline's endpoints. + # For Draft objects, Vertex coordinates are already in the global system. For Sketches, + # they are local, but ArchWall's internal logic transforms them. The most reliable + # way to get the final global baseline is to use the vertices of the base object's + # final shape, which are always in global coordinates for these object types. + p1_global = base_edge.Vertexes[0].Point + p2_global = base_edge.Vertexes[1].Point - # Now determine the wall's rotation inferred from its local axes: - # - The local X axis is along the baseline edge (length). - # - The local Z axis is along the extrusion vector (height). - # - The local Y axis is the cross product of X and Z (width, perpendicular to both the - # - above). - # Once the local axes are known, a FreeCAD.Rotation matrix can be constructed. - z_axis = extrusion_vector.normalize() - x_axis = (baseline_edge.lastVertex().Point - baseline_edge.firstVertex().Point).normalize() - y_axis = z_axis.cross(x_axis).normalize() + # Step 2: Determine the extrusion normal vector. + normal = wall.Normal + if normal.Length == 0: + normal = base_obj.Placement.Rotation.multVec(FreeCAD.Vector(0, 0, 1)) + if normal.Length == 0: + normal = FreeCAD.Vector(0, 0, 1) + + # Step 3: Calculate the final orientation from the geometric axes. + # - The local Z-axis is the extrusion direction (height). + # - The local X-axis is along the baseline (length). + # - The local Y-axis is perpendicular to both, pointing "Right" to remain + # consistent with the wall's internal creation logic (X x Z = Y). + z_axis = normal.normalize() + x_axis = (p2_global - p1_global).normalize() + y_axis = x_axis.cross(z_axis).normalize() final_rotation = FreeCAD.Rotation(x_axis, y_axis, z_axis) - # This will be the debased wall's local coordinate system origin (0, 0, 0). - # The wall's Align property (Left, Center, Right) determines how the wall's - # Width offsets the final position from the centerline. - centerline_position = baseline_edge.CenterOfMass - align_offset_distance = 0 - if wall.Align == "Left": - align_offset_distance = wall.Width.Value / 2.0 - elif wall.Align == "Right": - align_offset_distance = -wall.Width.Value / 2.0 - - # Convert the offset distance into a vector in the width direction (local Y axis). - align_offset_vector = y_axis * align_offset_distance - final_position = centerline_position - align_offset_vector + # Step 4: Calculate the final position (the wall's volumetric center). + # The new placement's Base must be the global coordinate of the final wall's center. + centerline_position = (p1_global + p2_global) * 0.5 + # The new placement's Base is the center of the baseline. The alignment is handled by the + # geometry generation itself, not by shifting the placement. + final_position = centerline_position final_placement = FreeCAD.Placement(final_position, final_rotation) # Store properties before unlinking diff --git a/src/Mod/BIM/ArchWall.py b/src/Mod/BIM/ArchWall.py index b5b3f108a0..042d2f011f 100644 --- a/src/Mod/BIM/ArchWall.py +++ b/src/Mod/BIM/ArchWall.py @@ -560,45 +560,41 @@ class _Wall(ArchComponent.Component): extdata = self.getExtrusionData(obj) if extdata: - bplates = extdata[0] + base_faces = extdata[0] extv = extdata[2].Rotation.multVec(extdata[1]) - if isinstance(bplates, list): - shps = [] - # Test : if base is Sketch, then fuse all solid; otherwise, makeCompound - sketchBaseToFuse = obj.Base.getLinkedObject().isDerivedFrom( - "Sketcher::SketchObject" - ) - # but turn this off if we have layers, otherwise layers get merged - if ( + + # Normalize geometry: getExtrusionData can return a single face or a list of faces. + # Normalize it to always be a list to simplify the logic below. + if not isinstance(base_faces, list): + base_faces = [base_faces] + + # Determine the fusion strategy: solids should only be fused if the base is a Sketch and + # it is not a multi-layer wall. + should_fuse_solids = False + if obj.Base and obj.Base.isDerivedFrom("Sketcher::SketchObject"): + is_multi_layer = ( hasattr(obj, "Material") and obj.Material and hasattr(obj.Material, "Materials") and obj.Material.Materials - ): - sketchBaseToFuse = False - for b in bplates: - b.Placement = extdata[2].multiply(b.Placement) - b = b.extrude(extv) + ) + if not is_multi_layer: + should_fuse_solids = True - # See getExtrusionData() - not fusing baseplates there - fuse solids here - # Remarks - If solids are fused, but exportIFC.py use underlying baseplates w/o fuse, the result in ifc look slightly different from in FC. + # Generate solids + solids = [] + for face in base_faces: + face.Placement = extdata[2].multiply(face.Placement) + solids.append(face.extrude(extv)) - if sketchBaseToFuse: - if shps: - shps = shps.fuse(b) # shps.fuse(b) - else: - shps = b - else: - shps.append(b) - # TODO - To let user to select whether to fuse (slower) or to do a compound (faster) only ? - - if sketchBaseToFuse: - base = shps - else: - base = Part.makeCompound(shps) + # Apply the fusion strategy + if should_fuse_solids: + fused_shape = None + for solid in solids: + fused_shape = fused_shape.fuse(solid) if fused_shape else solid + base = fused_shape else: - bplates.Placement = extdata[2].multiply(bplates.Placement) - base = bplates.extrude(extv) + base = Part.makeCompound(solids) if obj.Base: if hasattr(obj.Base, "Shape"): if obj.Base.Shape.isNull(): @@ -1526,32 +1522,10 @@ class _Wall(ArchComponent.Component): if baseface: base, placement = self.rebase(baseface) - # Build Wall if there is no obj.Base or even obj.Base is not valid + # Build Wall from scratch if there is no obj.Base or even obj.Base is not valid else: - if layers: - totalwidth = sum([abs(l) for l in layers]) - offset = 0 - base = [] - for l in layers: - if l > 0: - l2 = length / 2 or 0.5 - w1 = -totalwidth / 2 + offset - w2 = w1 + l - v1 = Vector(-l2, w1, 0) - v2 = Vector(l2, w1, 0) - v3 = Vector(l2, w2, 0) - v4 = Vector(-l2, w2, 0) - base.append(Part.Face(Part.makePolygon([v1, v2, v3, v4, v1]))) - offset += abs(l) - else: - l2 = length / 2 or 0.5 - w2 = width / 2 or 0.5 - v1 = Vector(-l2, -w2, 0) - v2 = Vector(l2, -w2, 0) - v3 = Vector(l2, w2, 0) - v4 = Vector(-l2, w2, 0) - base = Part.Face(Part.makePolygon([v1, v2, v3, v4, v1])) - placement = FreeCAD.Placement() + base, placement = self.build_base_from_scratch(obj) + if base and placement: normal.normalize() extrusion = normal.multiply(height) @@ -1560,6 +1534,40 @@ class _Wall(ArchComponent.Component): return (base, extrusion, placement) return None + def calc_endpoints(self, obj): + """Returns the global start and end points of a baseless wall's centerline.""" + # The wall's shape is centered, so its endpoints in local coordinates + # are at (-Length/2, 0, 0) and (+Length/2, 0, 0). + p1_local = FreeCAD.Vector(-obj.Length.Value / 2, 0, 0) + p2_local = FreeCAD.Vector(obj.Length.Value / 2, 0, 0) + + # Transform these local points into global coordinates using the wall's placement. + p1_global = obj.Placement.multVec(p1_local) + p2_global = obj.Placement.multVec(p2_local) + + return [p1_global, p2_global] + + def set_from_endpoints(self, obj, pts): + """Sets the Length and Placement of a baseless wall from two global points.""" + if len(pts) < 2: + return + + p1 = pts[0] + p2 = pts[1] + + # Recalculate the wall's properties based on the new endpoints + new_length = p1.distanceToPoint(p2) + new_midpoint = (p1 + p2) * 0.5 + new_direction = (p2 - p1).normalize() + + # Calculate the rotation required to align the local X-axis with the new direction + new_rotation = FreeCAD.Rotation(FreeCAD.Vector(1, 0, 0), new_direction) + + # Apply the new properties to the wall object + obj.Length = new_length + obj.Placement.Base = new_midpoint + obj.Placement.Rotation = new_rotation + def handleComponentRemoval(self, obj, subobject): """ Overrides the default component removal to implement smart debasing @@ -1609,6 +1617,144 @@ class _Wall(ArchComponent.Component): # from the parent Component class. super(_Wall, self).handleComponentRemoval(obj, subobject) + def get_width(self, obj, widths=True): + """Returns a width and a list of widths for this wall. + If widths is False, only the main width is returned""" + import ArchSketchObject + + # Set 'default' width - for filling in any item in the list == 0 or None + if obj.Width.Value: + width = obj.Width.Value + else: + width = 200 # 'Default' width value + if not widths: + return width + + lwidths = [] + if ( + hasattr(obj, "ArchSketchData") + and obj.ArchSketchData + and Draft.getType(obj.Base) == "ArchSketch" + ): + if hasattr(obj.Base, "Proxy"): + if hasattr(obj.Base.Proxy, "getWidths"): + lwidths = obj.Base.Proxy.getWidths( + obj.Base, propSetUuid=self.ArchSkPropSetPickedUuid + ) + if not lwidths: + if obj.OverrideWidth: + if obj.Base and obj.Base.isDerivedFrom("Sketcher::SketchObject"): + lwidths = ArchSketchObject.sortSketchWidth( + obj.Base, obj.OverrideWidth, obj.ArchSketchEdges + ) + else: + lwidths = obj.OverrideWidth + elif obj.Width: + lwidths = [obj.Width.Value] + else: + return None + return width, lwidths + + def get_layers(self, obj): + """Returns a list of layers""" + layers = [] + width = self.get_width(obj, widths=False) + if hasattr(obj, "Material"): + if obj.Material: + if hasattr(obj.Material, "Materials"): + thicknesses = [abs(t) for t in obj.Material.Thicknesses] + restwidth = width - sum(thicknesses) + varwidth = 0 + if restwidth > 0: + varwidth = [t for t in thicknesses if t == 0] + if varwidth: + varwidth = restwidth / len(varwidth) + for t in obj.Material.Thicknesses: + if t: + layers.append(t) + elif varwidth: + layers.append(varwidth) + return layers + + def build_base_from_scratch(self, obj): + """Generate the 2D profile for extruding a baseless Arch Wall. + + This function creates the rectangular face or faces that form the wall's cross-section, + which is then used by the caller for extrusion. It handles both single- and multi-layer wall + configurations. + + Parameters + ---------- + obj : FreeCAD.DocumentObject + The wall object being built. Its Length, Align, and layer + properties are used. + + Returns + ------- + tuple of (list of Part.Face, FreeCAD.Placement) + A tuple containing two elements: + 1. A list of one or more Part.Face objects representing the cross-section. + 2. An identity Placement, as the geometry is in local coordinates. + + Notes + ----- + The geometry follows the convention where `Align='Left'` offsets the wall in the negative-Y + direction (the geometric right). + """ + import Part + + def _create_face_from_coords(half_length, y_min, y_max): + """Creates a rectangular Part.Face centered on the X-axis, defined by Y coordinates.""" + bottom_left = Vector(-half_length, y_min, 0) + bottom_right = Vector(half_length, y_min, 0) + top_right = Vector(half_length, y_max, 0) + top_left = Vector(-half_length, y_max, 0) + return Part.Face( + Part.makePolygon([bottom_left, bottom_right, top_right, top_left, bottom_left]) + ) + + layers = self.get_layers(obj) + width = self.get_width(obj, widths=False) + align = obj.Align + + # Use a small default for zero dimensions to ensure a valid shape can be created. + safe_length = obj.Length.Value or 0.5 + + if not layers: + safe_width = width or 0.5 + layers = [safe_width] # Treat a single-layer wall as a multi-layer wall with one layer. + + # --- Calculate and Create Geometry --- + base_faces = [] + + # The total width is needed to calculate the starting offset for alignment. + totalwidth = sum([abs(layer) for layer in layers]) + + # The offset acts as a cursor, tracking the current position along the Y-axis. + offset = 0 + if align == "Center": + offset = -totalwidth / 2 + elif align == "Left": + # Per convention, 'Left' is on the geometric right (-Y direction). + offset = -totalwidth + + # Loop through all layers and create a face for each. + for layer in layers: + # A negative layer value is not drawn, so its geometry is skipped. + if layer > 0: + half_length = safe_length / 2 + layer_y_min = offset + layer_y_max = offset + layer + face = _create_face_from_coords(half_length, layer_y_min, layer_y_max) + base_faces.append(face) + + # The offset is always increased by the absolute thickness of the layer. + offset += abs(layer) + + placement = FreeCAD.Placement() + + return base_faces, placement + class _ViewProviderWall(ArchComponent.ViewProviderComponent): """The view provider for the wall object. diff --git a/src/Mod/BIM/CMakeLists.txt b/src/Mod/BIM/CMakeLists.txt index 0c5834a706..90e2a21824 100644 --- a/src/Mod/BIM/CMakeLists.txt +++ b/src/Mod/BIM/CMakeLists.txt @@ -229,6 +229,7 @@ SET(bimtests_SRCS bimtests/TestArchRoof.py bimtests/TestArchSpace.py bimtests/TestArchWall.py + bimtests/TestArchWallGui.py bimtests/TestArchMaterial.py bimtests/TestArchPanel.py bimtests/TestArchWindow.py diff --git a/src/Mod/BIM/Resources/ui/preferences-archdefaults.ui b/src/Mod/BIM/Resources/ui/preferences-archdefaults.ui index 3a9c276d59..fa0e915609 100644 --- a/src/Mod/BIM/Resources/ui/preferences-archdefaults.ui +++ b/src/Mod/BIM/Resources/ui/preferences-archdefaults.ui @@ -315,22 +315,38 @@ - - - - Use sketches for walls - - - true - - - WallSketches - - - Mod/Arch - - + + + + Wall baseline + + + + + + + WallBaseline + + + Mod/BIM + + + + No baseline + + + + Draft line + + + + + Sketch + + + + diff --git a/src/Mod/BIM/TestArchGui.py b/src/Mod/BIM/TestArchGui.py index f878c12c2d..e8fb68ecb2 100644 --- a/src/Mod/BIM/TestArchGui.py +++ b/src/Mod/BIM/TestArchGui.py @@ -28,4 +28,5 @@ from bimtests.TestArchImportersGui import TestArchImportersGui from bimtests.TestArchBuildingPartGui import TestArchBuildingPartGui from bimtests.TestArchSiteGui import TestArchSiteGui from bimtests.TestArchReportGui import TestArchReportGui +from bimtests.TestArchWallGui import TestArchWallGui from bimtests.TestWebGLExportGui import TestWebGLExportGui diff --git a/src/Mod/BIM/bimcommands/BimWall.py b/src/Mod/BIM/bimcommands/BimWall.py index d639b97969..4847dcc3a0 100644 --- a/src/Mod/BIM/bimcommands/BimWall.py +++ b/src/Mod/BIM/bimcommands/BimWall.py @@ -26,11 +26,16 @@ import FreeCAD import FreeCADGui +from enum import Enum QT_TRANSLATE_NOOP = FreeCAD.Qt.QT_TRANSLATE_NOOP translate = FreeCAD.Qt.translate -PARAMS = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Mod/BIM") + +class WallBaselineMode(Enum): + NONE = 0 + DRAFT_LINE = 1 + SKETCH = 2 class Arch_Wall: @@ -86,7 +91,14 @@ class Arch_Wall: self.Width = params.get_param_arch("WallWidth") self.Height = params.get_param_arch("WallHeight") self.Offset = params.get_param_arch("WallOffset") + # Baseline creation mode (NONE / DRAFT_LINE / SKETCH) + mode_index = params.get_param("WallBaseline", path="Mod/BIM") self.JOIN_WALLS_SKETCHES = params.get_param_arch("joinWallSketches") + + try: + self.baseline_mode = WallBaselineMode(mode_index) + except Exception: + self.baseline_mode = WallBaselineMode.NONE self.AUTOJOIN = params.get_param_arch("autoJoinWalls") sel = FreeCADGui.Selection.getSelectionEx() self.existing = [] @@ -176,97 +188,225 @@ class Arch_Wall: ) elif len(self.points) == 2: - FreeCAD.activeDraftCommand = None - FreeCADGui.Snapper.off() - self.tracker.off() + self.create_wall() - self.doc.openTransaction(translate("Arch", "Create Wall")) + def _create_baseless_wall(self, p0, p1): + """Creates a baseless wall and ensures all steps are macro-recordable.""" + import __main__ - # Some code in gui_utils.autogroup requires a wall shape to determine - # the target group. We therefore need to create a wall first. - self.addDefault() - wall = self.doc.Objects[-1] - wallGrp = wall.getParentGroup() + line_vector = p1.sub(p0) + length = line_vector.Length + midpoint = (p0 + p1) * 0.5 + direction = line_vector.normalize() + rotation = FreeCAD.Rotation(FreeCAD.Vector(1, 0, 0), direction) - if ( - (self.JOIN_WALLS_SKETCHES or self.AUTOJOIN) - and self.existing - and self.existing[-1].getParentGroup() == wallGrp - ): - oldWall = self.existing[-1] - if self.JOIN_WALLS_SKETCHES and ArchWall.areSameWallTypes([wall, oldWall]): - FreeCADGui.doCommand( - "Arch.joinWalls([wall, doc." - + oldWall.Name - + "], " - + "delete=True, deletebase=True)" - ) - elif self.AUTOJOIN: - if wallGrp is not None: - FreeCADGui.doCommand("wall.getParentGroup().removeObject(wall)") - FreeCADGui.doCommand("Arch.addComponents(wall, doc." + oldWall.Name + ")") + # This placement is local to the working plane. + local_placement = FreeCAD.Placement(midpoint, rotation) + # Transform the local placement into the global coordinate system. + final_placement = self.wp.get_placement().multiply(local_placement) - self.doc.commitTransaction() - self.doc.recompute() - # gui_utils.end_all_events() # Causes a crash on Linux. - self.tracker.finalize() - if FreeCADGui.draftToolBar.continueMode: - self.Activated() + wall_var = "new_baseless_wall" - def addDefault(self): - """Create a wall using a line segment, with all parameters as the default. - - Used solely by _CommandWall.getPoint() when the interactive mode has - selected two points. - """ - from draftutils import params - - sta = self.wp.get_local_coords(self.points[0]) - end = self.wp.get_local_coords(self.points[1]) - - FreeCADGui.doCommand("import Part") - FreeCADGui.addModule("Draft") - FreeCADGui.addModule("Arch") - FreeCADGui.addModule("WorkingPlane") - FreeCADGui.doCommand("doc = FreeCAD.ActiveDocument") - FreeCADGui.doCommand("wp = WorkingPlane.get_working_plane()") - FreeCADGui.doCommand( - "trace = Part.LineSegment(FreeCAD." + str(sta) + ", FreeCAD." + str(end) + ")" + # Construct command strings using the final, correct global placement. + placement_str = ( + f"FreeCAD.Placement(FreeCAD.Vector({final_placement.Base.x}, {final_placement.Base.y}, {final_placement.Base.z}), " + f"FreeCAD.Rotation({final_placement.Rotation.Q[0]}, {final_placement.Rotation.Q[1]}, {final_placement.Rotation.Q[2]}, {final_placement.Rotation.Q[3]}))" ) - if params.get_param_arch("WallSketches"): - # Use ArchSketch if SketchArch add-on is present - try: - import ArchSketchObject - FreeCADGui.doCommand("import ArchSketchObject") - FreeCADGui.doCommand("base = ArchSketchObject.makeArchSketch()") - except: - FreeCADGui.doCommand('base = doc.addObject("Sketcher::SketchObject", "WallTrace")') - FreeCADGui.doCommand("base.Placement = wp.get_placement()") - FreeCADGui.doCommand("base.addGeometry(trace)") - else: + make_wall_cmd = ( + f"{wall_var} = Arch.makeWall(length={length}, width={self.Width}, " + f"height={self.Height}, align='{self.Align}')" + ) + + # Execute creation and property-setting commands + FreeCADGui.doCommand("import Arch") + FreeCADGui.doCommand(make_wall_cmd) + FreeCADGui.doCommand(f"{wall_var}.Placement = {placement_str}") + if self.MultiMat: + FreeCADGui.doCommand( + f"{wall_var}.Material = FreeCAD.ActiveDocument.{self.MultiMat.Name}" + ) + + # Get a reference to the newly created object + newly_created_wall = getattr(__main__, wall_var) + + # Now, issue the autogroup command using the object's actual name + FreeCADGui.doCommand("import Draft") + FreeCADGui.doCommand(f"Draft.autogroup({wall_var})") + + return newly_created_wall + + def _create_baseline_object(self, p0, p1): + """Creates a baseline object (Draft line or Sketch) and returns its name.""" + import __main__ + + placement = self.wp.get_placement() + placement_str = ( + f"FreeCAD.Placement(FreeCAD.Vector({placement.Base.x}, {placement.Base.y}, {placement.Base.z}), " + f"FreeCAD.Rotation({placement.Rotation.Q[0]}, {placement.Rotation.Q[1]}, {placement.Rotation.Q[2]}, {placement.Rotation.Q[3]}))" + ) + + trace_cmd = ( + f"trace = Part.LineSegment(FreeCAD.Vector({p0.x}, {p0.y}, {p0.z}), " + f"FreeCAD.Vector({p1.x}, {p1.y}, {p1.z}))" + ) + + FreeCADGui.doCommand("import FreeCAD") + FreeCADGui.doCommand("import Part") + FreeCADGui.addModule("WorkingPlane") + FreeCADGui.doCommand(trace_cmd) + + if self.baseline_mode == WallBaselineMode.DRAFT_LINE: + FreeCADGui.doCommand("import Draft") + + # Execute creation command. FreeCAD will ensure a unique name. FreeCADGui.doCommand("base = Draft.make_line(trace)") + + # Execute placement command. + FreeCADGui.doCommand(f"base.Placement = {placement_str}") + # The created line should not stay selected as this causes an issue in continue mode. # Two walls would then be created based on the same line. FreeCADGui.Selection.clearSelection() - FreeCADGui.doCommand("base.Placement = wp.get_placement()") - FreeCADGui.doCommand("doc.recompute()") - FreeCADGui.doCommand( - "wall = Arch.makeWall(base, width=" - + str(self.Width) - + ", height=" - + str(self.Height) - + ', align="' - + str(self.Align) - + '")' + FreeCADGui.doCommand("FreeCAD.ActiveDocument.recompute()") + + elif self.baseline_mode == WallBaselineMode.SKETCH: + import ArchSketchObject + + if not hasattr(ArchSketchObject, "makeArchSketch"): + # Regular path without SketchArch add-on installed. Execute creation command with a + # suggested name. FreeCAD will ensure uniqueness. + FreeCADGui.doCommand( + "base = FreeCAD.ActiveDocument.addObject('Sketcher::SketchObject', 'WallTrace')" + ) + else: + # Use ArchSketch if SketchArch add-on is present + FreeCADGui.doCommand("import ArchSketchObject") + FreeCADGui.doCommand("base = ArchSketchObject.makeArchSketch()") + + user_label = translate("BimWall", "Wall Trace") + # Apply placement and geometry using the correctly identified object name. + FreeCADGui.doCommand(f"base.Placement = {placement_str}") + # Set the user-facing, translated label. + FreeCADGui.doCommand(f"base.Label = {repr(user_label)}") + FreeCADGui.doCommand(f"base.addGeometry(trace)") + + FreeCADGui.doCommand("FreeCAD.ActiveDocument.recompute()") + + # Get a reference to the newly created object + baseline_obj = getattr(__main__, "base") + + return baseline_obj + + def _create_wall_from_baseline(self, base_obj): + """Creates a wall from a baseline object, ensuring all steps are macro-recordable.""" + import __main__ + + if base_obj is None: + return None + + # Use a unique variable name for the wall in the command string + wall_var = "new_wall_from_base" + + # Construct command strings + make_wall_cmd = ( + f"{wall_var} = Arch.makeWall(FreeCAD.ActiveDocument.{base_obj.Name}, " + f"width={self.Width}, height={self.Height}, align='{self.Align}')" ) - FreeCADGui.doCommand("wall.Normal = wp.axis") + set_normal_cmd = f"{wall_var}.Normal = FreeCAD.{self.wp.axis}" + + # Execute creation and property-setting commands + FreeCADGui.doCommand("import Arch") + FreeCADGui.doCommand(make_wall_cmd) + FreeCADGui.doCommand(set_normal_cmd) if self.MultiMat: - FreeCADGui.doCommand("wall.Material = doc." + self.MultiMat.Name) - FreeCADGui.doCommand( - "doc.recompute()" - ) # required as some autogroup code requires the wall shape - FreeCADGui.doCommand("Draft.autogroup(wall)") + FreeCADGui.doCommand( + f"{wall_var}.Material = FreeCAD.ActiveDocument.{self.MultiMat.Name}" + ) + + # Get a reference to the newly-created object + wall_obj = getattr(__main__, wall_var) + + # Issue the autogroup command using the object's actual name + FreeCADGui.doCommand("import Draft") + FreeCADGui.doCommand(f"Draft.autogroup({wall_var})") + + return wall_obj + + def _handle_wall_joining(self, wall_obj): + """Helper to handle wall joining/autogrouping logic after a new wall is created.""" + import ArchWall + from draftutils import params + + JOIN_WALLS_SKETCHES = params.get_param_arch("joinWallSketches") + AUTOJOIN = params.get_param_arch("autoJoinWalls") + + if wall_obj and self.existing: + oldWall = self.existing[-1] + wallGrp = wall_obj.getParentGroup() + oldWallGrp = oldWall.getParentGroup() + + if wallGrp == oldWallGrp: + joined = False + # Attempt destructive merge first if conditions allow + if ( + JOIN_WALLS_SKETCHES + and wall_obj.Base + and ArchWall.areSameWallTypes([wall_obj, oldWall]) + ): + FreeCADGui.doCommand( + f"Arch.joinWalls([FreeCAD.ActiveDocument.{wall_obj.Name}, FreeCAD.ActiveDocument.{oldWall.Name}], delete=True, deletebase=True)" + ) + joined = True + + # If no destructive merge, attempt non-destructive autojoin + if not joined and AUTOJOIN: + if wallGrp: + # Remove the new wall from its default autogroup if one was assigned, + # before adding it to the existing wall's additions. + FreeCADGui.doCommand( + f"FreeCAD.ActiveDocument.{wallGrp.Name}.removeObject(FreeCAD.ActiveDocument.{wall_obj.Name})" + ) + FreeCADGui.doCommand( + f"Arch.addComponents(FreeCAD.ActiveDocument.{wall_obj.Name}, FreeCAD.ActiveDocument.{oldWall.Name})" + ) + + def create_wall(self): + """Orchestrate wall creation according to the baseline mode.""" + from draftutils import params + + p0 = self.wp.get_local_coords(self.points[0]) + p1 = self.wp.get_local_coords(self.points[1]) + + self.tracker.off() + FreeCAD.activeDraftCommand = None + FreeCADGui.Snapper.off() + + self.doc.openTransaction(translate("Arch", "Create Wall")) + + # Ensure baseline_mode is initialized (some tests call create_wall() + # directly without going through Activated()). + if not hasattr(self, "baseline_mode"): + self.baseline_mode = WallBaselineMode(params.get_param("WallBaseline", path="Mod/BIM")) + + # Create the wall object (either baseless or from a baseline) + wall_obj = None + if self.baseline_mode == WallBaselineMode.NONE: + wall_obj = self._create_baseless_wall(p0, p1) + else: + baseline_obj = self._create_baseline_object(p0, p1) + if baseline_obj: + wall_obj = self._create_wall_from_baseline(baseline_obj) + + # Delegate all joining logic to the helper function + self._handle_wall_joining(wall_obj) + + # Finalization + self.doc.commitTransaction() + self.doc.recompute() + self.tracker.finalize() + if FreeCADGui.draftToolBar.continueMode: + self.Activated() def update(self, point, info): # info parameter is not used but needed for compatibility with the snapper @@ -374,15 +514,21 @@ class Arch_Wall: grid.addWidget(labelOffset, 5, 0, 1, 1) grid.addWidget(inputOffset, 5, 1, 1, 1) - # Wall "use sketches" checkbox - labelUseSketches = QtGui.QLabel(translate("Arch", "Use sketches")) - checkboxUseSketches = QtGui.QCheckBox() - checkboxUseSketches.setObjectName("UseSketches") - checkboxUseSketches.setLayoutDirection(QtCore.Qt.RightToLeft) - labelUseSketches.setBuddy(checkboxUseSketches) - checkboxUseSketches.setChecked(params.get_param_arch("WallSketches")) - grid.addWidget(labelUseSketches, 6, 0, 1, 1) - grid.addWidget(checkboxUseSketches, 6, 1, 1, 1) + # Wall baseline dropdown + labelBaseline = QtGui.QLabel(translate("Arch", "Baseline")) + comboBaseline = QtGui.QComboBox() + comboBaseline.setObjectName("Baseline") + labelBaseline.setBuddy(comboBaseline) + comboBaseline.addItems( + [ + translate("Arch", "No baseline"), + translate("Arch", "Draft line"), + translate("Arch", "Sketch"), + ] + ) + comboBaseline.setCurrentIndex(params.get_param("WallBaseline", path="Mod/BIM")) + grid.addWidget(labelBaseline, 6, 0, 1, 1) + grid.addWidget(comboBaseline, 6, 1, 1, 1) # Enable/disable inputOffset based on inputAlignment def updateOffsetState(index): @@ -401,10 +547,7 @@ class Arch_Wall: inputHeight.valueChanged.connect(self.setHeight) comboAlignment.currentIndexChanged.connect(self.setAlign) inputOffset.valueChanged.connect(self.setOffset) - if hasattr(checkboxUseSketches, "checkStateChanged"): # Qt version >= 6.7.0 - checkboxUseSketches.checkStateChanged.connect(self.setUseSketch) - else: # Qt version < 6.7.0 - checkboxUseSketches.stateChanged.connect(self.setUseSketch) + comboBaseline.currentIndexChanged.connect(self.setBaseline) comboWallPresets.currentIndexChanged.connect(self.setMat) # Define the workflow of the input fields: @@ -476,12 +619,12 @@ class Arch_Wall: self.Offset = d params.set_param_arch("WallOffset", d) - def setUseSketch(self, i): - """Simple callback to set if walls should based on sketches.""" - + def setBaseline(self, i): + """Simple callback to set the wall baseline creation mode.""" from draftutils import params - params.set_param_arch("WallSketches", bool(getattr(i, "value", i))) + self.baseline_mode = WallBaselineMode(i) + params.set_param("WallBaseline", i, path="Mod/BIM") def createFromGUI(self): """Callback to create wall by using the _CommandWall.taskbox()""" diff --git a/src/Mod/BIM/bimtests/TestArchBaseGui.py b/src/Mod/BIM/bimtests/TestArchBaseGui.py index cb1adc7b03..17fa47d1ae 100644 --- a/src/Mod/BIM/bimtests/TestArchBaseGui.py +++ b/src/Mod/BIM/bimtests/TestArchBaseGui.py @@ -56,6 +56,20 @@ class TestArchBaseGui(TestArchBase): """ super().setUp() + def tearDown(self): + """ + Ensure GUI events are processed and dialogs closed before the document is destroyed. + This prevents race conditions where pending GUI tasks try to access a closed document. + """ + # Process any pending Qt events (like todo.delay calls) while the doc is still open + self.pump_gui_events() + + # Close any open task panels + if FreeCAD.GuiUp: + FreeCADGui.Control.closeDialog() + + super().tearDown() + def pump_gui_events(self, timeout_ms=200): """Run the Qt event loop briefly so queued GUI callbacks execute. diff --git a/src/Mod/BIM/bimtests/TestArchWall.py b/src/Mod/BIM/bimtests/TestArchWall.py index c3e745e3e7..48c3140a84 100644 --- a/src/Mod/BIM/bimtests/TestArchWall.py +++ b/src/Mod/BIM/bimtests/TestArchWall.py @@ -261,3 +261,111 @@ class TestArchWall(TestArchBase.TestArchBase): delta=1e-6, msg="Wall should remain parametric and its volume should change with height.", ) + + def test_makeWall_baseless_alignment(self): + """ + Tests that Arch.makeWall correctly creates a baseless wall with the + specified alignment. + """ + self.printTestMessage("Checking baseless wall alignment from makeWall...") + + # Define the test cases: (Alignment Mode, Expected final Y-center) + test_cases = [ + ("Center", 0.0), + ("Left", -100.0), + ("Right", 100.0), + ] + + for align_mode, expected_y_center in test_cases: + with self.subTest(alignment=align_mode): + # 1. Arrange & Act: Create a baseless wall using the API call. + wall = Arch.makeWall(length=2000, width=200, height=1500, align=align_mode) + self.document.recompute() + + # 2. Assert Geometry: Verify the shape is valid. + self.assertFalse( + wall.Shape.isNull(), msg=f"[{align_mode}] Shape should not be null." + ) + expected_volume = 2000 * 200 * 1500 + self.assertAlmostEqual( + wall.Shape.Volume, + expected_volume, + delta=1e-6, + msg=f"[{align_mode}] Wall volume is incorrect.", + ) + + # 3. Assert Placement and Alignment. + # The wall's Placement should be at the origin. + self.assertTrue( + wall.Placement.Base.isEqual(App.Vector(0, 0, 0), 1e-6), + msg=f"[{align_mode}] Default placement Base should be at the origin.", + ) + self.assertAlmostEqual( + wall.Placement.Rotation.Angle, + 0.0, + delta=1e-6, + msg=f"[{align_mode}] Default placement Rotation should be zero.", + ) + + # The shape's center should be offset according to the alignment. + shape_center = wall.Shape.BoundBox.Center + expected_center = App.Vector(0, expected_y_center, 750) + + self.assertTrue( + shape_center.isEqual(expected_center, 1e-5), + msg=f"For '{align_mode}' align, wall center {shape_center} does not match expected {expected_center}", + ) + + def test_baseless_wall_stretch_api(self): + """ + Tests the proxy methods for graphically editing baseless walls: + calc_endpoints() and set_from_endpoints(). + """ + self.printTestMessage("Checking baseless wall stretch API...") + + # 1. Arrange: Create a baseless wall and then set its placement. + initial_placement = App.Placement( + App.Vector(1000, 1000, 0), App.Rotation(App.Vector(0, 0, 1), 45) + ) + # Create wall first, then set its placement. + wall = Arch.makeWall(length=2000) + wall.Placement = initial_placement + self.document.recompute() + + # 2. Test calc_endpoints() + endpoints = wall.Proxy.calc_endpoints(wall) + self.assertEqual(len(endpoints), 2, "calc_endpoints should return two points.") + + # Verify the calculated endpoints against manual calculation + half_len_vec_x = App.Vector(1000, 0, 0) + rotated_half_vec = initial_placement.Rotation.multVec(half_len_vec_x) + expected_p1 = initial_placement.Base - rotated_half_vec + expected_p2 = initial_placement.Base + rotated_half_vec + + self.assertTrue(endpoints[0].isEqual(expected_p1, 1e-6), "Start point is incorrect.") + self.assertTrue(endpoints[1].isEqual(expected_p2, 1e-6), "End point is incorrect.") + + # 3. Test set_from_endpoints() + new_p1 = App.Vector(0, 0, 0) + new_p2 = App.Vector(4000, 0, 0) + wall.Proxy.set_from_endpoints(wall, [new_p1, new_p2]) + self.document.recompute() + + # Assert that the wall's properties have been updated correctly + self.assertAlmostEqual( + wall.Length.Value, 4000.0, delta=1e-6, msg="Length was not updated correctly." + ) + + expected_center = App.Vector(2000, 0, 0) + self.assertTrue( + wall.Placement.Base.isEqual(expected_center, 1e-6), + "Placement.Base (center) was not updated correctly.", + ) + + # Check rotation (should now be zero as the new points are on the X-axis) + self.assertAlmostEqual( + wall.Placement.Rotation.Angle, + 0.0, + delta=1e-6, + msg="Placement.Rotation was not updated correctly.", + ) diff --git a/src/Mod/BIM/bimtests/TestArchWallGui.py b/src/Mod/BIM/bimtests/TestArchWallGui.py new file mode 100644 index 0000000000..d39012baf0 --- /dev/null +++ b/src/Mod/BIM/bimtests/TestArchWallGui.py @@ -0,0 +1,663 @@ +# 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 * +# * . * +# * * +# *************************************************************************** + +"""GUI tests for the ArchWall module.""" + +import FreeCAD +import FreeCADGui +import Draft +import Arch +import Part +import WorkingPlane +from bimtests import TestArchBaseGui +from bimcommands.BimWall import Arch_Wall +from unittest.mock import patch + + +class MockTracker: + """A dummy tracker to absorb GUI calls during logic tests.""" + + def off(self): + pass + + def on(self): + pass + + def finalize(self): + pass + + def update(self, points): + pass + + def setorigin(self, arg): + pass + + +class TestArchWallGui(TestArchBaseGui.TestArchBaseGui): + + def setUp(self): + """Set up the test environment by activating the BIM workbench and setting preferences.""" + super().setUp() + self.params = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Mod/BIM") + self.original_wall_base = self.params.GetInt("WallBaseline", 1) # Default to 1 (line) + + def tearDown(self): + """Restore original preferences after the test.""" + self.params.SetInt("WallBaseline", self.original_wall_base) + super().tearDown() + + def test_create_baseless_wall_interactive_mode(self): + """ + Tests the interactive creation of a baseless wall by simulating the + Arch_Wall command's internal logic. + """ + from draftguitools import gui_trackers # Import the tracker module + + self.printTestMessage("Testing interactive creation of a baseless wall...") + + # 1. Arrange: Set preference to "No baseline" mode + self.params.SetInt("WallBaseline", 0) + + # 2. Arrange: Simulate the state of the command after two clicks + cmd = Arch_Wall() + cmd.doc = self.document + cmd.wp = WorkingPlane.get_working_plane() + cmd.points = [FreeCAD.Vector(1000, 1000, 0), FreeCAD.Vector(3000, 1000, 0)] + cmd.Align = "Center" + cmd.Width = 200.0 + cmd.Height = 2500.0 + cmd.MultiMat = None + cmd.existing = [] + cmd.tracker = gui_trackers.boxTracker() + + initial_object_count = len(self.document.Objects) + + # 3. Act: Call the internal method that processes the points + cmd.create_wall() + + # 4. Assert + self.assertEqual( + len(self.document.Objects), + initial_object_count + 1, + "Exactly one new object should have been created.", + ) + + wall = self.document.Objects[-1] + self.assertEqual(Draft.get_type(wall), "Wall", "The created object is not a wall.") + + self.assertIsNone(wall.Base, "A baseless wall should have its Base property set to None.") + + self.assertAlmostEqual( + wall.Length.Value, 2000.0, delta=1e-6, msg="Wall length is incorrect." + ) + + # Verify the placement is correct + expected_center = FreeCAD.Vector(2000, 1000, 0) + self.assertTrue( + wall.Placement.Base.isEqual(expected_center, 1e-6), + f"Wall center {wall.Placement.Base} does not match expected {expected_center}", + ) + + # Verify the rotation is correct (aligned with global X-axis, so no rotation) + self.assertAlmostEqual( + wall.Placement.Rotation.Angle, + 0.0, + delta=1e-6, + msg="Wall rotation should be zero for a horizontal line.", + ) + + def test_create_draft_line_baseline_wall_interactive(self): + """Tests the interactive creation of a wall with a Draft.Line baseline.""" + from draftguitools import gui_trackers + + self.printTestMessage("Testing interactive creation of a Draft.Line based wall...") + + # 1. Arrange: Set preference to "Draft line" mode + self.params.SetInt("WallBaseline", 1) # Corresponds to WallBaselineMode.DRAFT_LINE + + cmd = Arch_Wall() + cmd.doc = self.document + cmd.wp = WorkingPlane.get_working_plane() + cmd.points = [FreeCAD.Vector(0, 0, 0), FreeCAD.Vector(2000, 0, 0)] + cmd.Align = "Center" + cmd.Width = 200.0 + cmd.Height = 2500.0 + cmd.MultiMat = None + cmd.existing = [] + cmd.tracker = gui_trackers.boxTracker() + + initial_object_count = len(self.document.Objects) + + # 2. Act + cmd.create_wall() + + # 3. Assert + self.assertEqual( + len(self.document.Objects), + initial_object_count + 2, + "Should have created a Wall and a Draft Line.", + ) + + # The wall is created after the base, so it's the last object + wall = self.document.Objects[-1] + base = self.document.Objects[-2] + + self.assertEqual(Draft.get_type(wall), "Wall") + self.assertEqual(Draft.get_type(base), "Wire") + self.assertEqual(wall.Base, base, "The wall's Base should be the newly created line.") + + def test_create_sketch_baseline_wall_interactive(self): + """Tests the interactive creation of a wall with a Sketch baseline.""" + from draftguitools import gui_trackers + + self.printTestMessage("Testing interactive creation of a Sketch based wall...") + + # 1. Arrange: Set preference to "Sketch" mode + self.params.SetInt("WallBaseline", 2) # Corresponds to WallBaselineMode.SKETCH + + cmd = Arch_Wall() + cmd.doc = self.document + cmd.wp = WorkingPlane.get_working_plane() + cmd.points = [FreeCAD.Vector(0, 0, 0), FreeCAD.Vector(2000, 0, 0)] + cmd.Align = "Center" + cmd.Width = 200.0 + cmd.Height = 2500.0 + cmd.MultiMat = None + cmd.existing = [] + cmd.tracker = gui_trackers.boxTracker() + + initial_object_count = len(self.document.Objects) + + # 2. Act + cmd.create_wall() + + # 3. Assert + self.assertEqual( + len(self.document.Objects), + initial_object_count + 2, + "Should have created a Wall and a Sketch.", + ) + + wall = self.document.Objects[-1] + base = self.document.Objects[-2] + + self.assertEqual(Draft.get_type(wall), "Wall") + self.assertEqual(base.TypeId, "Sketcher::SketchObject") + self.assertEqual(wall.Base, base, "The wall's Base should be the newly created sketch.") + + def test_stretch_rotated_baseless_wall(self): + """Tests that the Draft_Stretch tool correctly handles a rotated baseless wall.""" + self.printTestMessage("Testing stretch on a rotated baseless wall...") + + from draftguitools.gui_stretch import Stretch + + # 1. Arrange: Create a rotated baseless wall + wall = Arch.makeWall(length=2000, width=200, height=1500) + + rotation = FreeCAD.Rotation(FreeCAD.Vector(0, 0, 1), 45) + placement = FreeCAD.Placement(FreeCAD.Vector(1000, 1000, 0), rotation) + wall.Placement = placement + self.document.recompute() + + # Ensure the view is scaled to the object so selection logic works correctly + FreeCADGui.ActiveDocument.ActiveView.fitAll() + + # Get initial state for assertion later + initial_endpoints = wall.Proxy.calc_endpoints(wall) + p_start_initial = initial_endpoints[0] + p_end_initial = initial_endpoints[1] + + # 2. Act: Simulate the Stretch command + cmd = Stretch() + cmd.doc = self.document + FreeCADGui.Selection.addSelection(self.document.Name, wall.Name) + + # Activate the command. It will detect the existing selection and + # call proceed() internally after performing necessary setup. + cmd.Activated() + + # Simulate user clicks: + # Define a selection rectangle that encloses only the end point + cmd.addPoint(FreeCAD.Vector(p_end_initial.x - 1, p_end_initial.y - 1, 0)) + cmd.addPoint(FreeCAD.Vector(p_end_initial.x + 1, p_end_initial.y + 1, 0)) + + # Manually inject the selection state to bypass the view-dependent tracker, + # which acts inconsistently in a headless test environment. + # [False, True] selects the end point while keeping the start point anchored. + cmd.ops = [[wall, [False, True]]] + + # Define the displacement vector + displacement_vector = FreeCAD.Vector(500, -500, 0) + cmd.addPoint(FreeCAD.Vector(0, 0, 0)) # Start of displacement + cmd.addPoint(displacement_vector) # End of displacement + + # Allow the GUI command's macro to be processed + self.pump_gui_events() + + # 3. Assert: Verify the new position of the endpoints + final_endpoints = wall.Proxy.calc_endpoints(wall) + p_start_final = final_endpoints[0] + p_end_final = final_endpoints[1] + + # Calculate the error vector for diagnosis + diff = p_start_final.sub(p_start_initial) + + error_message = ( + f"\nThe unselected start point moved!\n" + f"Initial: {p_start_initial}\n" + f"Final: {p_start_final}\n" + f"Diff Vec: {diff}\n" + f"Error Mag: {diff.Length:.12f}" + ) + + # The start point should not have moved + self.assertTrue(p_start_final.isEqual(p_start_initial, 1e-6), error_message) + + # The end point should have moved by the global displacement vector + expected_end_point = p_end_initial.add(displacement_vector) + self.assertTrue( + p_end_final.isEqual(expected_end_point, 1e-6), + f"Stretched endpoint {p_end_final} does not match expected {expected_end_point}", + ) + + def test_create_baseless_wall_on_rotated_working_plane(self): + """Tests that a baseless wall respects the current working plane.""" + import Part + + self.printTestMessage("Testing baseless wall creation on a rotated working plane...") + + # Arrange: Create a non-standard working plane (rotated and elevated) + wp = WorkingPlane.plane() + placement = FreeCAD.Placement( + FreeCAD.Vector(0, 0, 1000), FreeCAD.Rotation(FreeCAD.Vector(0, 0, 1), 45) + ) + + # Apply the placement to the working plane, ensuring translation is included + wp.setFromPlacement(placement, rebase=True) + + # Define points in the local coordinate system of the working plane + p1_local = FreeCAD.Vector(0, 0, 0) + p2_local = FreeCAD.Vector(2000, 0, 0) + + # Convert local points to the global coordinates the command will receive + p1_global = wp.getGlobalCoords(p1_local) + p2_global = wp.getGlobalCoords(p2_local) + + self.params.SetInt("WallBaseline", 0) + + cmd = Arch_Wall() + cmd.doc = self.document + cmd.wp = wp + cmd.points = [p1_global, p2_global] + cmd.Align = "Center" + cmd.Width = 200.0 + cmd.Height = 1500.0 + + # Use a mock tracker to isolate logic tests from the 3D view environment + cmd.tracker = MockTracker() + cmd.existing = [] + + # Act + cmd.create_wall() + + # Assert + wall = self.document.ActiveObject + self.assertEqual(Draft.get_type(wall), "Wall") + + # Calculate the expected global placement + midpoint_local = (p1_local + p2_local) * 0.5 + direction_local = (p2_local - p1_local).normalize() + rotation_local = FreeCAD.Rotation(FreeCAD.Vector(1, 0, 0), direction_local) + placement_local = FreeCAD.Placement(midpoint_local, rotation_local) + + # The wall's final placement must be the local placement transformed by the WP + expected_placement = wp.get_placement().multiply(placement_local) + + # Compare Position (Vector) + self.assertTrue( + wall.Placement.Base.isEqual(expected_placement.Base, Part.Precision.confusion()), + f"Wall position {wall.Placement.Base} does not match expected {expected_placement.Base}", + ) + + # Compare Orientation (Rotation) + self.assertTrue( + wall.Placement.Rotation.isSame(expected_placement.Rotation, Part.Precision.confusion()), + f"Wall rotation {wall.Placement.Rotation.Q} does not match expected {expected_placement.Rotation.Q}", + ) + + def test_create_multiple_sketch_based_walls(self): + """Tests that creating multiple sketch-based walls uses separate sketches.""" + self.printTestMessage("Testing creation of multiple sketch-based walls...") + + self.params.SetInt("WallBaseline", 2) + + cmd = Arch_Wall() + cmd.doc = self.document + cmd.wp = WorkingPlane.get_working_plane() + cmd.Align = "Left" + cmd.Width = 200.0 + cmd.Height = 1500.0 + cmd.tracker = MockTracker() + cmd.existing = [] + + initial_object_count = len(self.document.Objects) + + # Act: Create the first wall + cmd.points = [FreeCAD.Vector(0, 0, 0), FreeCAD.Vector(1000, 0, 0)] + cmd.create_wall() + base1 = self.document.getObject("Wall").Base + + # Act again: Create the second wall + cmd.points = [FreeCAD.Vector(0, 1000, 0), FreeCAD.Vector(1000, 1000, 0)] + cmd.create_wall() + + # Retrieve the last object to ensure we get the newest wall + wall2 = self.document.ActiveObject + base2 = wall2.Base + + # Assert + self.assertEqual( + len(self.document.Objects), + initial_object_count + 4, + "Should have created two Walls and two Sketches.", + ) + self.assertIsNotNone(base1, "First wall should have a base sketch.") + self.assertIsNotNone(base2, "Second wall should have a base sketch.") + + self.assertNotEqual( + base1, base2, "Each sketch-based wall should have its own unique sketch object." + ) + + def _get_mock_side_effect(self, **kwargs): + """ + Creates a side_effect function for mocking params.get_param. + This reads the actual system parameter dictionary (draftutils.params.PARAM_DICT) + to populate defaults, ensuring all parameters expected by Draft/BIM tools are present. + It then overrides specific values as needed for the test. + """ + + from draftutils import params + + def side_effect(name, path=None, ret_default=False, silent=False): + # Start with a comprehensive dictionary built from the real parameter definitions + defaults = {} + # Flatten PARAM_DICT: iterate over all groups ('Mod/Draft', 'View', etc.) + for group_name, group_params in params.PARAM_DICT.items(): + for param_name, param_data in group_params.items(): + # param_data is (type, value) + defaults[param_name] = param_data[1] + + # Add or Override with test-specific values and missing parameters + # Some parameters might be dynamic or not yet in PARAM_DICT in the environment + overrides = { + # Arch Wall specific overrides for tests + "joinWallSketches": False, + "autoJoinWalls": False, + "WallBaseline": 0, + } + defaults.update(overrides) + + # Apply any kwargs passed specifically to this side_effect call (from tests) + if name in kwargs: + return kwargs[name] + + val = defaults.get(name) + + return val + + return side_effect + + def _simulate_interactive_wall_creation(self, p1, p2, existing_wall, wall_width=200.0): + """ + Simulates the core logic of the Arch_Wall command's interactive mode. + """ + try: + cmd = Arch_Wall() + + # This calls the real Activated() method, but the mock intercepts the + # calls to params.get_param, allowing us to control the outcome. + FreeCADGui.Selection.clearSelection() + cmd.Activated() + + # Override interactive parts of the command instance + cmd.doc = self.document + cmd.wp = WorkingPlane.get_working_plane() + cmd.points = [p1, p2] + cmd.Width = wall_width + cmd.existing = [existing_wall] if existing_wall else [] + cmd.tracker = MockTracker() + + # This is the core action being tested + cmd.create_wall() + return self.document.Objects[-1] # Return the newly created wall + finally: + # Clean up the global command state to ensure test isolation + # We put this here to ensure cleanup even if the wall creation fails + if FreeCAD.activeDraftCommand is cmd: + FreeCAD.activeDraftCommand = None + + # Section 1: Baseless wall joining + + @patch("draftutils.params.get_param") + def test_baseless_wall_autojoins_as_addition(self, mock_get_param): + """Verify baseless wall becomes an 'Addition' when AUTOJOIN is on.""" + mock_get_param.side_effect = self._get_mock_side_effect(autoJoinWalls=True, WallBaseline=0) + self.printTestMessage("Testing baseless wall with AUTOJOIN=True...") + + wall1 = Arch.makeWall(length=1000) + self.document.recompute() + initial_object_count = len(self.document.Objects) + + wall2 = self._simulate_interactive_wall_creation( + FreeCAD.Vector(1000, 0, 0), FreeCAD.Vector(1000, 1000, 0), wall1 + ) + + self.assertEqual(len(self.document.Objects), initial_object_count + 1) + self.assertIn(wall2, wall1.Additions, "New baseless wall should be in wall1's Additions.") + + @patch("draftutils.params.get_param") + def test_baseless_wall_does_not_join_when_autojoin_is_off(self, mock_get_param): + """Verify no relationship is created for baseless wall when AUTOJOIN is off.""" + mock_get_param.side_effect = self._get_mock_side_effect(autoJoinWalls=False, WallBaseline=0) + self.printTestMessage("Testing baseless wall with AUTOJOIN=False...") + + wall1 = Arch.makeWall(length=1000) + self.document.recompute() + + self._simulate_interactive_wall_creation( + FreeCAD.Vector(1000, 0, 0), FreeCAD.Vector(1000, 1000, 0), wall1 + ) + + self.assertEqual(len(wall1.Additions), 0, "No join action should have occurred.") + + # Section 2: Draft-Line-based wall joining + + @patch("draftutils.params.get_param") + def test_line_based_wall_merges_with_joinWallSketches(self, mock_get_param): + """Verify line-based wall performs a destructive merge.""" + mock_get_param.side_effect = self._get_mock_side_effect( + joinWallSketches=True, WallBaseline=1 + ) + self.printTestMessage("Testing line-based wall with JOIN_SKETCHES=True...") + + line1 = Draft.makeLine(FreeCAD.Vector(0, 0, 0), FreeCAD.Vector(1000, 0, 0)) + wall1 = Arch.makeWall(line1) + self.document.recompute() + base1_initial_edges = len(wall1.Base.Shape.Edges) + + self._simulate_interactive_wall_creation( + FreeCAD.Vector(1000, 0, 0), FreeCAD.Vector(1000, 1000, 0), wall1 + ) + + self.assertEqual( + len(self.document.Objects), + 3, # In theory objects should be 2, but wall merging does not delete the original baseline + "The new wall and its line should have been deleted.", + ) + self.assertEqual( + wall1.Base.TypeId, + "Sketcher::SketchObject", + "The base of wall1 should have been converted to a Sketch.", + ) + self.assertGreater( + len(wall1.Base.Shape.Edges), + base1_initial_edges, + "The base sketch should have more edges after the merge.", + ) + + @patch("draftutils.params.get_param") + def test_line_based_wall_uses_autojoin_when_joinWallSketches_is_off(self, mock_get_param): + """Verify line-based wall uses AUTOJOIN when sketch joining is off.""" + mock_get_param.side_effect = self._get_mock_side_effect( + joinWallSketches=False, autoJoinWalls=True, WallBaseline=1 + ) + self.printTestMessage("Testing line-based wall with JOIN_SKETCHES=False, AUTOJOIN=True...") + + line1 = Draft.makeLine(FreeCAD.Vector(0, 0, 0), FreeCAD.Vector(1000, 0, 0)) + wall1 = Arch.makeWall(line1) + self.document.recompute() + initial_object_count = len(self.document.Objects) + + wall2 = self._simulate_interactive_wall_creation( + FreeCAD.Vector(1000, 0, 0), FreeCAD.Vector(1000, 1000, 0), wall1 + ) + + self.assertEqual( + len(self.document.Objects), + initial_object_count + 2, + "A new wall and its baseline should have been created.", + ) + self.assertIn(wall2, wall1.Additions, "The new wall should be an Addition to the first.") + + @patch("draftutils.params.get_param") + def test_line_based_wall_falls_back_to_autojoin_on_incompatible_walls(self, mock_get_param): + """Verify fallback to AUTOJOIN for incompatible line-based walls.""" + mock_get_param.side_effect = self._get_mock_side_effect( + joinWallSketches=True, autoJoinWalls=True, WallBaseline=1 + ) + self.printTestMessage("Testing line-based wall fallback to AUTOJOIN...") + + line1 = Draft.makeLine(FreeCAD.Vector(0, 0, 0), FreeCAD.Vector(1000, 0, 0)) + wall1 = Arch.makeWall(line1, width=200) # Incompatible width + self.document.recompute() + + wall2 = self._simulate_interactive_wall_creation( + FreeCAD.Vector(1000, 0, 0), FreeCAD.Vector(1000, 1000, 0), wall1, wall_width=300 + ) + + self.assertIn(wall2, wall1.Additions, "Fallback failed; wall should be an Addition.") + + # Section 3: Sketch-based wall joining + + @patch("draftutils.params.get_param") + def test_sketch_based_wall_merges_with_joinWallSketches(self, mock_get_param): + """Verify sketch-based wall performs a destructive merge.""" + mock_get_param.side_effect = self._get_mock_side_effect( + joinWallSketches=True, WallBaseline=2 + ) + self.printTestMessage("Testing sketch-based wall with JOIN_SKETCHES=True...") + + sketch1 = self.document.addObject("Sketcher::SketchObject", "Sketch1") + sketch1.addGeometry(Part.LineSegment(FreeCAD.Vector(0, 0, 0), FreeCAD.Vector(1000, 0, 0))) + wall1 = Arch.makeWall(sketch1) + self.document.recompute() + base1_initial_edges = len(wall1.Base.Shape.Edges) + + self._simulate_interactive_wall_creation( + FreeCAD.Vector(1000, 0, 0), FreeCAD.Vector(1000, 1000, 0), wall1 + ) + + self.assertEqual( + len(self.document.Objects), + 2, + "The new wall and its sketch should have been deleted.", + ) + self.assertGreater( + len(wall1.Base.Shape.Edges), + base1_initial_edges, + "The base sketch should have more edges after the merge.", + ) + + @patch("draftutils.params.get_param") + def test_sketch_based_wall_uses_autojoin_when_joinWallSketches_is_off(self, mock_get_param): + """Verify sketch-based wall uses AUTOJOIN when sketch joining is off.""" + mock_get_param.side_effect = self._get_mock_side_effect( + joinWallSketches=False, autoJoinWalls=True, WallBaseline=2 + ) + self.printTestMessage( + "Testing sketch-based wall with JOIN_SKETCHES=False, AUTOJOIN=True..." + ) + + sketch1 = self.document.addObject("Sketcher::SketchObject", "Sketch1") + sketch1.addGeometry(Part.LineSegment(FreeCAD.Vector(0, 0, 0), FreeCAD.Vector(1000, 0, 0))) + wall1 = Arch.makeWall(sketch1) + self.document.recompute() + + wall2 = self._simulate_interactive_wall_creation( + FreeCAD.Vector(1000, 0, 0), FreeCAD.Vector(1000, 1000, 0), wall1 + ) + + self.assertIn(wall2, wall1.Additions, "The new wall should be an Addition to the first.") + + @patch("draftutils.params.get_param") + def test_sketch_based_wall_falls_back_to_autojoin_on_incompatible_walls(self, mock_get_param): + """Verify fallback to AUTOJOIN for incompatible sketch-based walls.""" + mock_get_param.side_effect = self._get_mock_side_effect( + joinWallSketches=True, autoJoinWalls=True, WallBaseline=2 + ) + self.printTestMessage("Testing sketch-based wall fallback to AUTOJOIN...") + + sketch1 = self.document.addObject("Sketcher::SketchObject", "Sketch1") + sketch1.addGeometry(Part.LineSegment(FreeCAD.Vector(0, 0, 0), FreeCAD.Vector(1000, 0, 0))) + wall1 = Arch.makeWall(sketch1, width=200) # Incompatible width + self.document.recompute() + + wall2 = self._simulate_interactive_wall_creation( + FreeCAD.Vector(1000, 0, 0), FreeCAD.Vector(1000, 1000, 0), wall1, wall_width=300 + ) + + self.assertIn(wall2, wall1.Additions, "Fallback failed; wall should be an Addition.") + + @patch("draftutils.params.get_param") + def test_no_join_action_when_prefs_are_off(self, mock_get_param): + """Verify no join action occurs when both preferences are off.""" + mock_get_param.side_effect = self._get_mock_side_effect( + joinWallSketches=False, autoJoinWalls=False, WallBaseline=1 + ) + self.printTestMessage("Testing no join action when preferences are off...") + + # Test with a based wall + line1 = Draft.makeLine(FreeCAD.Vector(0, 0, 0), FreeCAD.Vector(1000, 0, 0)) + wall1 = Arch.makeWall(line1) + self.document.recompute() + initial_object_count = len(self.document.Objects) + + self._simulate_interactive_wall_creation( + FreeCAD.Vector(1000, 0, 0), FreeCAD.Vector(1000, 1000, 0), wall1 + ) + + self.assertEqual(len(self.document.Objects), initial_object_count + 2) + self.assertEqual( + len(wall1.Additions), 0, "No join action should have occurred for based wall." + ) diff --git a/src/Mod/Draft/draftguitools/gui_stretch.py b/src/Mod/Draft/draftguitools/gui_stretch.py index 30eb22f950..bbdbab8173 100644 --- a/src/Mod/Draft/draftguitools/gui_stretch.py +++ b/src/Mod/Draft/draftguitools/gui_stretch.py @@ -109,6 +109,8 @@ class Stretch(gui_base_original.Modifier): self.sel.append( [obj.Base.Base, obj.Placement.multiply(obj.Base.Placement)] ) + elif utils.getType(obj) == "Wall" and not obj.Base: # baseless walls + self.sel.append([obj, App.Placement()]) elif utils.getType(obj) in ["Offset2D", "Array"]: base = None if hasattr(obj, "Source") and obj.Source: @@ -222,6 +224,18 @@ class Stretch(gui_base_original.Modifier): nodes.append(p) if iso: self.ops.append([o, np]) + elif tp == "Wall": + np = [] + iso = False + # For baseless walls, get endpoints from our new API method + for p in o.Proxy.calc_endpoints(o): + isi = self.rectracker.isInside(p) + np.append(isi) + if isi: + iso = True + nodes.append(p) + if iso: + self.ops.append([o, np]) else: p = o.Placement.Base p = vispla.multVec(p) @@ -477,6 +491,28 @@ class Stretch(gui_base_original.Modifier): commitops.append("w = " + _cmd) commitops.append(_format) commitops.append(_hide) + elif tp == "Wall": + npts = [] + # Reconstruct the new endpoints after applying displacement + for i, pt in enumerate(ops[0].Proxy.calc_endpoints(ops[0])): + if ops[1][i]: + npts.append(pt.add(self.displacement)) + else: + npts.append(pt) + # Construct the points list string + points_str = ( + "[" + + ", ".join([f"FreeCAD.Vector({p.x}, {p.y}, {p.z})" for p in npts]) + + "]" + ) + + commitops.append("import FreeCAD") + commitops.append( + f"wall_obj = FreeCAD.ActiveDocument.getObject('{ops[0].Name}')" + ) + commitops.append( + f"wall_obj.Proxy.set_from_endpoints(wall_obj, {points_str})" + ) else: _pl = _doc + ops[0].Name _pl += ".Placement.Base=FreeCAD." diff --git a/src/Mod/Draft/draftutils/params.py b/src/Mod/Draft/draftutils/params.py index 7720c264f9..128f517e92 100644 --- a/src/Mod/Draft/draftutils/params.py +++ b/src/Mod/Draft/draftutils/params.py @@ -625,6 +625,7 @@ def _get_param_dictionary(): # Note: incomplete! param_dict["Mod/BIM"] = { "BIMSketchPlacementOnly": ("bool", False), + "WallBaseline": ("int", 0), } # For the Mod/Mesh parameters we do not check the preferences: