BIM: implement baseless walls creation (#24595)
* BIM: Implement smart base removal for Walls Previously, removing the Base object from an Arch Wall would cause the wall to reset its position to the document origin and could lead to unintended geometric changes for complex walls. This commit introduces a "smart debasing" mechanism integrated into the Component Task Panel's "Remove" button: - For walls based on a single straight line, the operation now preserves the wall's global position and parametric `Length`, making it an independent object. - For walls with complex bases (multi-segment, curved), a warning dialog is now presented to the user, explaining the consequences (shape alteration and position reset) before allowing the operation to proceed. This is supported by new API functions `Arch.is_debasable()` and `Arch.debaseWall()`, which contain the core logic for the feature. Fixes: https://github.com/FreeCAD/FreeCAD/issues/24453 * BIM: Move wall debasing logic into ArchWall proxy The logic for handling the removal of a wall's base object was previously implemented directly within the generic `ComponentTaskPanel` in `ArchComponent.py`. This created a tight coupling, forcing the generic component UI to have specific knowledge about the `ArchWall` type. This commit refactors the implementation to follow a more object-oriented and polymorphic design: 1. A new overridable method, `handleComponentRemoval(subobject)`, has been added to the base `ArchComponent` proxy class. Its default implementation maintains the standard removal behavior. 2. The `_Wall` proxy class in `ArchWall.py` now overrides this method. All wall-specific debasing logic, including the eligibility check and the user-facing warning dialog, now resides entirely within this override. 3. The `ComponentTaskPanel.removeElement` method has been simplified. It is now a generic dispatcher that calls `handleComponentRemoval` on the proxy of the object being edited, with no specific knowledge of object types. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * BIM: Add interactive creation of baseless walls Introduce a new workflow for creating Arch Walls without a dependency on a baseline object (e.g., a Draft Line). - The `Arch_Wall` command is enhanced with a "No baseline" mode, controlled by a new "Walls baseline" preference, allowing users to create placement-driven walls directly in the 3D view. - The existing `debaseWall` function has been refactored for correctness and consistency with the new baseless wall geometry. Co-authored-by: Yorik van Havre <yorik@uncreated.net> * BIM: Refactor structure of the Arch Wall command Refactor the `Arch_Wall` GUI command (`BimWall.py`) for improved readability, maintainability, and architectural clarity. - A `WallBaselineMode` Enum is introduced to replace the original integer values, making the code self-documenting. - The monolithic `create_wall` method is broken down into smaller, single-responsibility helper functions for each creation mode. - The `addDefault` method has been removed, with its logic integrated into the new structure. * BIM: Add Draft Stretch support for baseless walls This commit makes the new baseless Arch Walls graphically editable using the `Draft_Stretch` tool. - An API for stretching (`calc_endpoints` and `set_from_endpoints`) has been added to the `ArchWall` proxy. - The `Draft_Stretch` tool is now aware of baseless walls and calls this new proxy API to perform the stretch operation, enabling users to stretch them. Co-authored-by: Yorik van Havre <yorik@uncreated.net> * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * BIM: address CodeQL warnings * BIM: Fix wall alignment for GUI creation of baseless walls Fixes an issue whereby creating a baseless wall from the GUI would ignore the selected `Align` property, always resulting in a center-aligned wall. - The underlying geometry generation for baseless walls now correctly honors the `Align` property passed by the GUI and API. - To ensure predictable behavior, the implementation uses the same geometric convention as walls built from a base object, making the `Align` property work uniformly for all wall types. - This also corrects the behavior of the `Arch.makeWall` function for baseless walls. - Update unit tests to test wall alignment. * BIM: Refactor wall geometry generation for improved clarity and maintainability Improves the internal logic for wall geometry creation, addressing CodeQL warnings and enhancing overall maintainability without changing external behavior. - The `build_base_from_scratch` method is refactored to unify the separate logic paths for single- and multi-layer walls, reducing code duplication. - A local helper function is introduced to create face geometry, for better modularity and readability. - In the `_Wall.execute` method, the control flow that relied on implicit type checking is replaced with an explicit strategy pattern for fusing solids, making the logic more robust. - Variable names are made more descriptive. - A NumPy-style docstring is added to better document the function. * Draft: fix stretching of rotated baseless walls * BIM: add unit test for stretching baseless walls * BIM: add regression tests for working-plane-relative coordinates and reuse of base sketches * BIM: Fix baseless wall creation to respect the working plane Corrects an issue where baseless walls were created using global coordinates instead of being relative to the active Draft working plane. The calculated local placement of the wall is now correctly transformed into the global coordinate system by multiplying it with the working plane's placement. * BIM: Ensure unique baselines for subsequent wall creation Fixes a bug where creating multiple walls with baselines would incorrectly reuse the same underlying Sketch or Draft Line object. The object retrieval logic after the `doCommand` call now correctly uses `ActiveObject` to get a reliable reference to the new object instead of relying on a hardcoded name. * BIM: Make the wall's base object label translatable * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * BIM: use singular for consistency with other labels Co-authored-by: Roy-043 <70520633+Roy-043@users.noreply.github.com> * Fix typo * BIM: address reviewer's comments about improving object reference passing between Python and FreeCAD contexts, and functions * BIM: remove defensive programming: the callback is only executed as a result of a user's GUI action * BIM: use the params API to define WallBaseline parameter * BIM: add Arch Wall tests for joining wall logic * BIM: add joining logic * BIM: re-add ArchSketch support * BIM: re-add multimaterial support on wall creation * BIM: address CodeQL warning, remove module duplication * BIM: fix check for SketchArch module when creating sketch-based walls --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Yorik van Havre <yorik@uncreated.net> Co-authored-by: Roy-043 <70520633+Roy-043@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -315,22 +315,38 @@
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="0" colspan="3">
|
||||
<widget class="Gui::PrefCheckBox" name="checkBox_WallSketches">
|
||||
<property name="text">
|
||||
<string>Use sketches for walls</string>
|
||||
</property>
|
||||
<property name="checked">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="prefEntry" stdset="0">
|
||||
<cstring>WallSketches</cstring>
|
||||
</property>
|
||||
<property name="prefPath" stdset="0">
|
||||
<cstring>Mod/Arch</cstring>
|
||||
</property>
|
||||
</widget>
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label_WallBaseline">
|
||||
<property name="text">
|
||||
<string>Wall baseline</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="Gui::PrefComboBox" name="comboBox_WallBaseline">
|
||||
<property name="prefEntry" stdset="0">
|
||||
<cstring>WallBaseline</cstring>
|
||||
</property>
|
||||
<property name="prefPath" stdset="0">
|
||||
<cstring>Mod/BIM</cstring>
|
||||
</property>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>No baseline</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>Draft line</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>Sketch</string>
|
||||
</property>
|
||||
</item>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="0">
|
||||
<widget class="QLabel" name="label_StairsLength">
|
||||
<property name="text">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()"""
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.",
|
||||
)
|
||||
|
||||
663
src/Mod/BIM/bimtests/TestArchWallGui.py
Normal file
663
src/Mod/BIM/bimtests/TestArchWallGui.py
Normal file
@@ -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 *
|
||||
# * <https://www.gnu.org/licenses/>. *
|
||||
# * *
|
||||
# ***************************************************************************
|
||||
|
||||
"""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."
|
||||
)
|
||||
@@ -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."
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user