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:
Furgo
2026-01-13 09:30:37 +01:00
committed by GitHub
parent 145947e6d7
commit e2ddf62467
11 changed files with 1337 additions and 211 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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">

View File

@@ -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

View File

@@ -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()"""

View File

@@ -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.

View File

@@ -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.",
)

View 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."
)

View File

@@ -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."

View File

@@ -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: