BIM: Fix ArchSite View Provider lifecycle, property constraints and defaults (#23844)
* BIM: Fix Site view provider initialization and constraints This commit fixes bugs in the `_ViewProviderSite` lifecycle, ensuring that sun path properties are correctly initialized with valid defaults and that their constraints are reliably restored when a document is opened. Previously, two main problems existed: 1. New Objects: When a new `Site` object was created, its sun path properties (e.g., `SunDateMonth`) would default to 0, which is an invalid value. The property editor in the GUI would also lack the appropriate min/max constraints. 2. Restored Objects: When a document containing a `Site` was opened, the property constraints were not reapplied. This happened because the view provider's initialization logic was not being reliably triggered due to race conditions during the document deserialization process. These issues are addressed now by a deferred initialization sequence: - For New Objects: In `_ViewProviderSite.__init__`, constraint and default value setup is deferred using `QTimer.singleShot(0)`. The `restoreConstraints` method also now sets sensible defaults (e.g., month 6, day 21) when it detects a new object. - For Restored Objects: The data object's `_Site.onDocumentRestored` hook is now used as a trigger to start the view provider's initialization. This is a necessary workaround, as the `ViewProvider` lacks its own restoration hook. This method now ensures the view provider's properties are present and then schedules `restoreConstraints` to run via `QTimer`. * BIM: add ArchSite GUI tests and fixtures - Additionally clean up and document the CMakeLists.txt file for better maintainability and readability * BIM: Enable GUI tests on CI - The line to enable the BIM workbench has been commented out, as previously it occasioned a timeout error on CI - The BIM GUI tests have been uncommented out to enable them
This commit is contained in:
@@ -606,13 +606,69 @@ class _Site(ArchIFC.IfcProduct):
|
||||
obj.setEditorMode("SunRay", ["ReadOnly", "Hidden"])
|
||||
|
||||
def onDocumentRestored(self,obj):
|
||||
"""Method run when the document is restored. Re-adds the properties."""
|
||||
"""FreeCAD hook run after the object is restored from a file.
|
||||
|
||||
This method serves a dual purpose due to FreeCAD framework limitations:
|
||||
|
||||
1. **Standard Role:** Restore properties on the data object (`_Site` proxy) itself by
|
||||
calling `self.setProperties(obj)`. This ensures backward compatibility by adding any new
|
||||
properties defined in the current FreeCAD version to an object loaded from an older
|
||||
file.
|
||||
|
||||
2. **Workaround Role:** Trigger the initialization for the associated view provider object
|
||||
(`_ViewProviderSite`). This is necessary because: a) The view provider has no equivalent
|
||||
`onDocumentRestored` callback. b) This hook provides a reliable point in the document
|
||||
lifecycle where the `ViewObject` is guaranteed to be attached to the data object
|
||||
(`obj`), which is a prerequisite for any GUI-related setup.
|
||||
|
||||
The core issue is that the App::FeaturePython object has a convenient onDocumentRestored
|
||||
hook, but its Gui::ViewProviderPython counterpart does not. This forces us to use the data
|
||||
object's hook as a trigger for initializing the view object, which is architecturally
|
||||
unclean but necessary.
|
||||
"""
|
||||
|
||||
# 1. Restore properties on the data object.
|
||||
self.setProperties(obj)
|
||||
# The UI is built before constraints are applied during a file load.
|
||||
# We must defer the constraint restoration until after the entire
|
||||
# loading process is complete.
|
||||
|
||||
|
||||
# 2. Trigger the restoration sequence for the associated view provider.
|
||||
# This block only runs in GUI mode.
|
||||
if FreeCAD.GuiUp and hasattr(obj, "ViewObject"):
|
||||
# Manually ensure the view provider's properties are up-to-date.
|
||||
#
|
||||
# When loading a document, FreeCAD's C++ document loading mechanism restores Python
|
||||
# proxy objects by allocating an empty instance and then calling its `loads()` method.
|
||||
# The `__init__()` constructor is intentionally bypassed in this restoration path.
|
||||
#
|
||||
# As a result, any setup logic in the view provider's `__init__` (like the call to
|
||||
# `self.setProperties()`) is never executed during a file load.
|
||||
#
|
||||
# The solution is to call `setProperties()` again here to ensure the view object is
|
||||
# correctly initialized, especially for backward compatibility when a newer version of
|
||||
# FreeCAD adds new properties.
|
||||
try:
|
||||
proxy = getattr(obj.ViewObject, "Proxy", None)
|
||||
if proxy is not None and hasattr(proxy, "setProperties"):
|
||||
proxy.setProperties(obj.ViewObject)
|
||||
except Exception as e:
|
||||
# Do not break document restore if view-side initialization fails.
|
||||
FreeCAD.Console.PrintError(f"ArchSite: proxy.setProperties failed: {e}\n")
|
||||
|
||||
# The Site's view provider has property constraints defined (e.g., min/max values for
|
||||
# dates). This requires a special handling sequence during document restoration due to
|
||||
# the way FreeCAD's GUI and data layers interact.
|
||||
#
|
||||
# 1. Constraints are not saved in the .FCStd file, so they must be programmatically
|
||||
# reapplied every time a document is loaded.
|
||||
# 2. The Property Editor GUI builds its widgets (like spin boxes) as soon as it is
|
||||
# notified that a property has been added. If constraints are applied in the same
|
||||
# function call, a race condition occurs: the GUI may build its widget *before* the
|
||||
# constraints are set, resulting in an unconstrained input field.
|
||||
#
|
||||
# To solve this, we defer the constraint restoration. We use QTimer.singleShot(0, ...)
|
||||
# to push the `restoreConstraints` call to the end of the Qt event queue. This
|
||||
# guarantees that the Property Editor has fully processed the property-add signals in
|
||||
# one event loop cycle *before* we apply the constraints in a subsequent cycle.
|
||||
from PySide import QtCore
|
||||
QtCore.QTimer.singleShot(0, lambda: obj.ViewObject.Proxy.restoreConstraints(obj.ViewObject))
|
||||
|
||||
@@ -807,6 +863,9 @@ class _ViewProviderSite:
|
||||
vobj.Proxy = self
|
||||
vobj.addExtension("Gui::ViewProviderGroupExtensionPython")
|
||||
self.setProperties(vobj)
|
||||
# Defer the constraint and default value setup until after the GUI is fully initialized.
|
||||
from PySide import QtCore
|
||||
QtCore.QTimer.singleShot(0, lambda: self.restoreConstraints(vobj))
|
||||
|
||||
def setProperties(self,vobj):
|
||||
"""Give the site view provider its site view provider specific properties.
|
||||
@@ -856,23 +915,26 @@ class _ViewProviderSite:
|
||||
vobj.ShowHourLabels = True # Show hour labels by default
|
||||
|
||||
def restoreConstraints(self, vobj):
|
||||
"""Re-apply non-persistent property constraints after a file load."""
|
||||
"""Re-apply non-persistent property constraints after a file load.
|
||||
|
||||
It also handles new objects, where their value is 0.
|
||||
"""
|
||||
pl = vobj.PropertiesList
|
||||
if "SunDateMonth" in pl:
|
||||
saved_month = vobj.SunDateMonth
|
||||
saved_month = vobj.SunDateMonth if vobj.SunDateMonth != 0 else 6
|
||||
vobj.SunDateMonth = (saved_month, 1, 12, 1)
|
||||
else:
|
||||
vobj.SunDateMonth = (6, 1, 12, 1) # Default to June
|
||||
|
||||
if "SunDateDay" in pl:
|
||||
saved_day = vobj.SunDateDay
|
||||
saved_day = vobj.SunDateDay if vobj.SunDateDay != 0 else 21
|
||||
vobj.SunDateDay = (saved_day, 1, 31, 1)
|
||||
else:
|
||||
# 31 is a safe maximum; the datetime object will handle invalid dates like Feb 31.
|
||||
vobj.SunDateDay = (21, 1, 31, 1) # Default to the 21st (solstice)
|
||||
|
||||
if "SunTimeHour" in pl:
|
||||
saved_hour = vobj.SunTimeHour
|
||||
saved_hour = vobj.SunTimeHour if abs(vobj.SunTimeHour) > 1e-9 else 12.0
|
||||
vobj.SunTimeHour = (saved_hour, 0.0, 23.5, 0.5)
|
||||
else:
|
||||
# Use 23.5 to avoid issues with hour 24
|
||||
@@ -1146,7 +1208,7 @@ class _ViewProviderSite:
|
||||
if prop == 'Visibility':
|
||||
if vobj.Visibility:
|
||||
# When the site becomes visible, check if the sun elements should also be shown.
|
||||
if vobj.ShowSunPosition:
|
||||
if hasattr(vobj, 'ShowSunPosition') and vobj.ShowSunPosition:
|
||||
self.sunSwitch.whichChild = coin.SO_SWITCH_ALL
|
||||
else:
|
||||
# When the site is hidden, always hide the sun elements.
|
||||
@@ -1329,9 +1391,100 @@ class _ViewProviderSite:
|
||||
return None
|
||||
|
||||
def loads(self,state):
|
||||
"""Restore hook for view provider instances created by the loader.
|
||||
|
||||
During document deserialization the Python instance may be
|
||||
allocated without calling __init__, so runtime initialization
|
||||
(adding view properties) must be performed here. We defer the
|
||||
actual reinitialization to the event loop to ensure the
|
||||
ViewObject binding and the Property Editor are available.
|
||||
"""
|
||||
# Try to obtain the `ViewObject` immediately; if not ready, the
|
||||
# helper method `_wait_for_viewobject` will schedule retries
|
||||
# via the event loop. This ensures view-specific properties and
|
||||
# constraints are applied once the view object exists.
|
||||
self._wait_for_viewobject(self._on_viewobject_ready)
|
||||
|
||||
return None
|
||||
|
||||
def _migrate_legacy_solar_diagram_scale(self, vobj):
|
||||
"""Migration for legacy SolarDiagramScale values.
|
||||
|
||||
Historically older FreeCAD files (1.0.0 and prior) sometimes stored
|
||||
an impractically small SolarDiagramScale (for example `1.0`) which
|
||||
results in invisible or confusing solar diagrams in the UI. This
|
||||
helper intentionally performs a small, best-effort normalization:
|
||||
|
||||
- If the `SolarDiagramScale` property exists and its saved value is
|
||||
numeric and <= 1.0 (or effectively zero) it will be replaced with
|
||||
the modern default of 20000.0 (20 m radius).
|
||||
- The operation is non-destructive and best-effort: failures are
|
||||
quietly ignored so loading does not fail.
|
||||
|
||||
Keep this helper small and idempotent so it can be called safely
|
||||
during view-provider restore.
|
||||
"""
|
||||
# Keep the migration compact and non-fatal
|
||||
if not FreeCAD.GuiUp:
|
||||
return
|
||||
try:
|
||||
if "SolarDiagramScale" in vobj.PropertiesList:
|
||||
scale_value = getattr(vobj, "SolarDiagramScale", None)
|
||||
if scale_value is None:
|
||||
return
|
||||
try:
|
||||
scale_value = float(scale_value)
|
||||
except (ValueError, TypeError):
|
||||
return
|
||||
# Treat 0 or 1 as legacy values and replace them with the modern default
|
||||
if scale_value <= 1.0 or abs(scale_value) < 1e-9:
|
||||
vobj.SolarDiagramScale = 20000.0
|
||||
FreeCAD.Console.PrintMessage(
|
||||
"ArchSite: migrated SolarDiagramScale property value to 20 m.\n"
|
||||
)
|
||||
except Exception as e:
|
||||
# Non-fatal: never let migration break document restore.
|
||||
FreeCAD.Console.PrintError(f"ArchSite: Failed during legacy migration: {e}\n")
|
||||
|
||||
def _wait_for_viewobject(self, callback):
|
||||
"""Polls until the ViewObject is available, then executes the callback."""
|
||||
from PySide import QtCore
|
||||
|
||||
# Try common ways to obtain the ViewObject
|
||||
vobj = getattr(self, "__vobject__", None)
|
||||
if vobj is None:
|
||||
appobj = getattr(self, "Object", None)
|
||||
if appobj is not None:
|
||||
vobj = getattr(appobj, "ViewObject", None)
|
||||
|
||||
if vobj is None:
|
||||
# ViewObject binding not ready yet: schedule a retry
|
||||
QtCore.QTimer.singleShot(50, lambda: self._wait_for_viewobject(callback))
|
||||
return
|
||||
|
||||
# ViewObject is ready, execute the callback
|
||||
callback(vobj)
|
||||
|
||||
def _on_viewobject_ready(self, vobj):
|
||||
"""Callback executed once the ViewObject is guaranteed to be available."""
|
||||
from PySide import QtCore
|
||||
|
||||
# Ensure properties exist (idempotent)
|
||||
try:
|
||||
self.setProperties(vobj)
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintError(f"ArchSite: Failed to set properties during restore: {e}\n")
|
||||
|
||||
# Perform any small migrations
|
||||
try:
|
||||
self._migrate_legacy_solar_diagram_scale(vobj)
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintError(f"ArchSite: Failed during legacy migration: {e}\n")
|
||||
|
||||
# Give the UI one more event cycle to pick up the new properties,
|
||||
# then restore constraints and defaults.
|
||||
QtCore.QTimer.singleShot(0, lambda: self.restoreConstraints(vobj))
|
||||
|
||||
def updateSunPosition(self, vobj):
|
||||
"""Calculates sun position and updates the sphere, path arc, and ray object."""
|
||||
import math
|
||||
@@ -1341,6 +1494,25 @@ class _ViewProviderSite:
|
||||
|
||||
obj = vobj.Object
|
||||
|
||||
# During document restore the view provider may be allocated without full runtime
|
||||
# initialization (attach()/node creation). If the scenegraph nodes we need are not yet
|
||||
# present, schedule a retry in the next event loop iteration and return. This avoids
|
||||
# AttributeError and is harmless because updateSunPosition will be called again when the
|
||||
# object becomes consistent, or the scheduled retry will run after attach() finishes.
|
||||
required_attrs = [
|
||||
'sunPathMorningNode', 'sunPathMiddayNode', 'sunPathAfternoonNode',
|
||||
'hourMarkerCoords', 'hourLabelSep', 'sunTransform', 'sunSphere'
|
||||
]
|
||||
for a in required_attrs:
|
||||
if not hasattr(self, a):
|
||||
try:
|
||||
from PySide import QtCore
|
||||
QtCore.QTimer.singleShot(0, lambda: self.updateSunPosition(vobj))
|
||||
except Exception:
|
||||
# If Qt is unavailable or scheduling fails, just return silently.
|
||||
pass
|
||||
return
|
||||
|
||||
# Handle the visibility toggle for all elements
|
||||
self.sunPathMorningNode.removeAllChildren()
|
||||
self.sunPathMiddayNode.removeAllChildren()
|
||||
@@ -1350,7 +1522,8 @@ class _ViewProviderSite:
|
||||
|
||||
if not vobj.ShowSunPosition:
|
||||
self.sunSwitch.whichChild = -1 # Hide the Pivy sphere and path
|
||||
if obj.SunRay and hasattr(obj.SunRay, "ViewObject"):
|
||||
ray_object = getattr(obj, "SunRay", None)
|
||||
if ray_object and hasattr(ray_object, "ViewObject"):
|
||||
obj.SunRay.ViewObject.Visibility = False
|
||||
return
|
||||
|
||||
@@ -1472,13 +1645,17 @@ class _ViewProviderSite:
|
||||
self.sunTransform.translation.setValue(sun_pos_3d.x, sun_pos_3d.y, sun_pos_3d.z)
|
||||
self.sunSphere.radius = vobj.SolarDiagramScale * 0.02
|
||||
|
||||
try:
|
||||
ray_object = obj.SunRay
|
||||
if not ray_object: raise AttributeError
|
||||
ray_object.Start = sun_pos_3d
|
||||
ray_object.End = vobj.SolarDiagramPosition
|
||||
ray_object.ViewObject.Visibility = True
|
||||
except (AttributeError, ReferenceError):
|
||||
# Safely obtain existing SunRay if present, and update it; otherwise create one
|
||||
ray_object = getattr(obj, "SunRay", None)
|
||||
if ray_object and hasattr(ray_object, "ViewObject"):
|
||||
try:
|
||||
ray_object.Start = sun_pos_3d
|
||||
ray_object.End = vobj.SolarDiagramPosition
|
||||
ray_object.ViewObject.Visibility = True
|
||||
except Exception:
|
||||
# If updating fails, fall back to creating a new ray
|
||||
ray_object = None
|
||||
if not ray_object:
|
||||
ray_object = Draft.make_line(sun_pos_3d, vobj.SolarDiagramPosition)
|
||||
vo = ray_object.ViewObject
|
||||
vo.LineColor = (1.0, 1.0, 0.0)
|
||||
@@ -1489,7 +1666,14 @@ class _ViewProviderSite:
|
||||
|
||||
if hasattr(obj, "addObject"):
|
||||
obj.addObject(ray_object)
|
||||
obj.SunRay = ray_object
|
||||
# Store new ray on the Site object
|
||||
try:
|
||||
obj.SunRay = ray_object
|
||||
except Exception as e:
|
||||
# Ignore failures to set property on legacy objects, but log them
|
||||
FreeCAD.Console.PrintWarning(
|
||||
f"ArchSite: could not assign SunRay on object {obj.Label}: {e}\n"
|
||||
)
|
||||
|
||||
ray_object.recompute()
|
||||
|
||||
@@ -1507,4 +1691,3 @@ class _ViewProviderSite:
|
||||
time_string = dt_object_for_label.strftime("%B %d, %H:%M")
|
||||
ray_object.Time = time_string
|
||||
ray_object.Label = f"Sun Ray ({time_string})"
|
||||
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
# ==============================================================================
|
||||
# File Management
|
||||
# Define the source files and resources of the BIM workbench
|
||||
# ==============================================================================
|
||||
|
||||
# If we are building the GUI mode, we need to process the Qt resource file
|
||||
# Note that if any of the files listed in the .qrc file changes, the
|
||||
# resource file will NOT be regenerated automatically. To work around this,
|
||||
# use `touch` on the .qrc file to force regeneration.
|
||||
IF (BUILD_GUI)
|
||||
PYSIDE_WRAP_RC(Arch_QRC_SRCS Resources/Arch.qrc)
|
||||
ENDIF (BUILD_GUI)
|
||||
@@ -227,6 +236,7 @@ SET(bimtests_SRCS
|
||||
bimtests/TestArchFrame.py
|
||||
bimtests/TestArchReference.py
|
||||
bimtests/TestArchSchedule.py
|
||||
bimtests/TestArchSiteGui.py
|
||||
bimtests/TestArchTruss.py
|
||||
bimtests/TestWebGLExport.py
|
||||
bimtests/TestWebGLExportGui.py
|
||||
@@ -234,7 +244,9 @@ SET(bimtests_SRCS
|
||||
bimtests/TestArchBuildingPartGui.py
|
||||
)
|
||||
|
||||
SOURCE_GROUP("" FILES ${Arch_SRCS})
|
||||
set(bimtests_FIXTURES
|
||||
bimtests/fixtures/FC_site_simple-102.FCStd
|
||||
)
|
||||
|
||||
SET(BIMGuiIcon_SVG
|
||||
Resources/icons/BIMWorkbench.svg
|
||||
@@ -244,6 +256,38 @@ SET(ImportersSample_Files
|
||||
importers/samples/Sample.sh3d
|
||||
)
|
||||
|
||||
# TODO:
|
||||
# - ImportersSample_Files should probably be merged into bimtests_FIXTURES
|
||||
# - BIM_templates should probably be merged into Arch_presets
|
||||
|
||||
# ==============================================================================
|
||||
# Developer workflow
|
||||
# This populates the build tree using the file lists from the Install Manifest
|
||||
# above, enabling the workbench to be run directly from the build tree.
|
||||
#
|
||||
# For a faster development cycle when working on Python or resource
|
||||
# files, symlinking can be enabled. This allows developers to edit source files
|
||||
# and see the changes immediately without needing to rebuild the project.
|
||||
#
|
||||
# To enable this, the build directory needs to be reconfigured from the command
|
||||
# line with:
|
||||
# cmake -DINSTALL_PREFER_SYMLINKS=ON <path_to_build_dir>
|
||||
#
|
||||
# Note for Windows users: "Developer Mode" must be enabled in the
|
||||
# Windows settings for unprivileged symlink creation to work.
|
||||
# ==============================================================================
|
||||
|
||||
# The `BIM` custom target aggregates BIM module sources for IDEs and the build
|
||||
# system.
|
||||
#
|
||||
# - Listing files here makes IDEs show the workbench and its files under a
|
||||
# single node.
|
||||
# - The ALL keyword makes this target part of the default build (so the build
|
||||
# tree is populated automatically).
|
||||
# - Importantly, since they share the same output files, it makes the
|
||||
# `fc_copy_sources` and `fc_target_copy_resource` output a dependency of the
|
||||
# BIM target. Thus building the BIM target triggers populating the build tree
|
||||
# with the workbench sources and resources, if needed.
|
||||
ADD_CUSTOM_TARGET(BIM ALL
|
||||
SOURCES ${Arch_SRCS}
|
||||
${Arch_QRC_SRCS}
|
||||
@@ -251,43 +295,33 @@ ADD_CUSTOM_TARGET(BIM ALL
|
||||
${Arch_presets}
|
||||
${ArchGuiIcon_SVG}
|
||||
${importers_SRCS}
|
||||
${ImportersSample_Files}
|
||||
${bimcommands_SRCS}
|
||||
${bimtests_SRCS}
|
||||
${bimtests_FIXTURES}
|
||||
${nativeifc_SRCS}
|
||||
${BIMGuiIcon_SVG}
|
||||
${BIM_templates}
|
||||
)
|
||||
|
||||
ADD_CUSTOM_TARGET(ImporterPythonTestData ALL
|
||||
SOURCES ${ImportersSample_Files}
|
||||
)
|
||||
|
||||
# Populate the build tree with the BIM workbench sources and test data
|
||||
fc_copy_sources(BIM "${CMAKE_BINARY_DIR}/Mod/BIM" ${Arch_SRCS})
|
||||
fc_copy_sources(BIM "${CMAKE_BINARY_DIR}/Mod/BIM" ${Dice3DS_SRCS})
|
||||
fc_copy_sources(BIM "${CMAKE_BINARY_DIR}/Mod/BIM" ${importers_SRCS})
|
||||
fc_copy_sources(BIM "${CMAKE_BINARY_DIR}/Mod/BIM" ${bimcommands_SRCS})
|
||||
fc_copy_sources(BIM "${CMAKE_BINARY_DIR}/Mod/BIM" ${bimtests_SRCS})
|
||||
fc_copy_sources(BIM "${CMAKE_BINARY_DIR}/Mod/BIM" ${bimtests_FIXTURES})
|
||||
fc_copy_sources(BIM "${CMAKE_BINARY_DIR}/Mod/BIM" ${ImportersSample_Files})
|
||||
fc_copy_sources(BIM "${CMAKE_BINARY_DIR}/Mod/BIM" ${nativeifc_SRCS})
|
||||
|
||||
# Populate the build tree with the BIM workbench resources.
|
||||
fc_copy_sources(BIM "${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_DATADIR}/Mod/BIM" ${BIMGuiIcon_SVG})
|
||||
fc_copy_sources(BIM "${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_DATADIR}/Mod/BIM" ${Arch_presets})
|
||||
fc_copy_sources(BIM "${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_DATADIR}/Mod/BIM" ${BIM_templates})
|
||||
|
||||
fc_target_copy_resource(BIM
|
||||
${CMAKE_SOURCE_DIR}/src/Mod/BIM
|
||||
${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_DATADIR}/Mod/BIM
|
||||
${Arch_presets}
|
||||
)
|
||||
|
||||
fc_target_copy_resource(BIM
|
||||
${CMAKE_SOURCE_DIR}/src/Mod/BIM
|
||||
${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_DATADIR}/Mod/BIM
|
||||
${BIM_templates}
|
||||
)
|
||||
|
||||
fc_target_copy_resource(ImporterPythonTestData
|
||||
${CMAKE_SOURCE_DIR}/src/Mod/BIM
|
||||
${CMAKE_BINARY_DIR}/Mod/BIM
|
||||
${ImportersSample_Files})
|
||||
|
||||
|
||||
# For generated resources, we cannot rely on `fc_copy_sources` in case
|
||||
# INSTALL_PREFER_SYMLINKS=ON has been specified, since we're generating a new
|
||||
# file not present in the source tree and thus cannot create a symlink to it.
|
||||
IF (BUILD_GUI)
|
||||
fc_target_copy_resource(BIM
|
||||
${CMAKE_CURRENT_BINARY_DIR}
|
||||
@@ -295,6 +329,12 @@ IF (BUILD_GUI)
|
||||
Arch_rc.py)
|
||||
ENDIF (BUILD_GUI)
|
||||
|
||||
# ==============================================================================
|
||||
# Install Manifest
|
||||
# Defines what gets installed into the final package, running the --install
|
||||
# target.
|
||||
# ==============================================================================
|
||||
|
||||
INSTALL(
|
||||
FILES
|
||||
${Arch_SRCS}
|
||||
@@ -326,6 +366,12 @@ INSTALL(
|
||||
DESTINATION Mod/BIM/bimtests
|
||||
)
|
||||
|
||||
INSTALL(
|
||||
FILES
|
||||
${bimtests_FIXTURES}
|
||||
DESTINATION Mod/BIM/bimtests/fixtures
|
||||
)
|
||||
|
||||
INSTALL(
|
||||
FILES
|
||||
${nativeifc_SRCS}
|
||||
@@ -336,7 +382,7 @@ INSTALL(
|
||||
DIRECTORY
|
||||
Presets
|
||||
DESTINATION
|
||||
${CMAKE_INSTALL_DATADIR}/Mod/BIM
|
||||
"${CMAKE_INSTALL_DATADIR}/Mod/BIM"
|
||||
)
|
||||
|
||||
INSTALL(
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
|
||||
"""Import all Arch module unit tests in GUI mode."""
|
||||
|
||||
#from bimtests.TestArchImportersGui import TestArchImportersGui
|
||||
#from bimtests.TestArchBuildingPartGui import TestArchBuildingPartGui
|
||||
from bimtests.TestArchImportersGui import TestArchImportersGui
|
||||
from bimtests.TestArchBuildingPartGui import TestArchBuildingPartGui
|
||||
from bimtests.TestArchSiteGui import TestArchSiteGui
|
||||
from bimtests.TestWebGLExportGui import TestWebGLExportGui
|
||||
|
||||
@@ -42,9 +42,11 @@ class TestArchBaseGui(TestArchBase):
|
||||
"""
|
||||
if not FreeCAD.GuiUp:
|
||||
raise unittest.SkipTest("Cannot run GUI tests in a CLI environment.")
|
||||
|
||||
|
||||
# Activating the workbench ensures all GUI commands are loaded and ready.
|
||||
FreeCADGui.activateWorkbench("BIMWorkbench")
|
||||
# TODO: commenting out this line for now as it causes a timeout without further logging in
|
||||
# CI
|
||||
#FreeCADGui.activateWorkbench("BIMWorkbench")
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
@@ -53,3 +55,19 @@ class TestArchBaseGui(TestArchBase):
|
||||
"""
|
||||
super().setUp()
|
||||
|
||||
def pump_gui_events(self, timeout_ms=200):
|
||||
"""Run the Qt event loop briefly so queued GUI callbacks execute.
|
||||
|
||||
This helper starts a QEventLoop and quits it after `timeout_ms` milliseconds using
|
||||
QTimer.singleShot. Any exception (e.g. missing Qt in the environment) is silently ignored so
|
||||
tests can still run in pure-CLI environments where the GUI isn't available.
|
||||
"""
|
||||
try:
|
||||
from PySide import QtCore
|
||||
loop = QtCore.QEventLoop()
|
||||
QtCore.QTimer.singleShot(int(timeout_ms), loop.quit)
|
||||
loop.exec_()
|
||||
except Exception:
|
||||
# Best-effort: if Qt isn't present or event pumping fails, continue.
|
||||
pass
|
||||
|
||||
|
||||
150
src/Mod/BIM/bimtests/TestArchSiteGui.py
Normal file
150
src/Mod/BIM/bimtests/TestArchSiteGui.py
Normal file
@@ -0,0 +1,150 @@
|
||||
# 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/>. *
|
||||
# * *
|
||||
# ***************************************************************************
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
import FreeCAD
|
||||
import Arch
|
||||
from bimtests import TestArchBaseGui
|
||||
|
||||
|
||||
class TestArchSiteGui(TestArchBaseGui.TestArchBaseGui):
|
||||
|
||||
def test_new_site_creation(self):
|
||||
"""Test: creating a new Site adds the view properties and sets defaults."""
|
||||
site = Arch.makeSite()
|
||||
self.assertIsNotNone(site, "makeSite() returned None")
|
||||
FreeCAD.ActiveDocument.recompute()
|
||||
# Wait briefly so the document loader can attach the ViewObject and let the view provider's
|
||||
# queued restore/migration callbacks (setProperties, migration, restoreConstraints) run on
|
||||
# the GUI event loop before we inspect properties.
|
||||
self.pump_gui_events()
|
||||
|
||||
# ViewObject properties should exist
|
||||
vobj = site.ViewObject
|
||||
props = vobj.PropertiesList
|
||||
expected = [
|
||||
'ShowSunPosition', 'SunDateMonth', 'SunDateDay', 'SunTimeHour',
|
||||
'SolarDiagramScale', 'SolarDiagramPosition', 'ShowHourLabels'
|
||||
]
|
||||
for p in expected:
|
||||
self.assertIn(p, props, f"Property '{p}' missing from ViewObject")
|
||||
|
||||
# Check defaults where applicable
|
||||
if hasattr(vobj, 'SunDateMonth'):
|
||||
self.assertEqual(vobj.SunDateMonth, 6)
|
||||
if hasattr(vobj, 'SunDateDay'):
|
||||
self.assertEqual(vobj.SunDateDay, 21)
|
||||
if hasattr(vobj, 'SunTimeHour'):
|
||||
self.assertAlmostEqual(vobj.SunTimeHour, 12.0)
|
||||
if hasattr(vobj, 'SolarDiagramScale'):
|
||||
self.assertAlmostEqual(vobj.SolarDiagramScale, 20000.0)
|
||||
|
||||
def test_new_site_save_and_reopen(self):
|
||||
"""Test: save document and reopen; view properties must be present and constrained."""
|
||||
self.printTestMessage('Save and reopen new Site...')
|
||||
site = Arch.makeSite()
|
||||
FreeCAD.ActiveDocument.recompute()
|
||||
|
||||
# Save to a temporary file
|
||||
tf = tempfile.NamedTemporaryFile(delete=False, suffix='.FCStd')
|
||||
tf.close()
|
||||
path = tf.name
|
||||
try:
|
||||
FreeCAD.ActiveDocument.saveAs(path)
|
||||
|
||||
# Open the saved document (this returns a new Document instance)
|
||||
reopened = FreeCAD.openDocument(path)
|
||||
try:
|
||||
# Find the Site object in the reopened document by checking proxy type
|
||||
found = None
|
||||
for o in reopened.Objects:
|
||||
if hasattr(o, 'Proxy') and getattr(o.Proxy, 'Type', None) == 'Site':
|
||||
found = o
|
||||
break
|
||||
self.assertIsNotNone(found, 'Site object not found after reopen')
|
||||
|
||||
# Ensure async GUI setup completes
|
||||
self.pump_gui_events()
|
||||
|
||||
vobj = found.ViewObject
|
||||
|
||||
# 1. Verify that constraints have been reapplied by testing clamping behavior.
|
||||
# Setting an out-of-bounds value should not raise an exception but
|
||||
# should coerce the value to the nearest limit.
|
||||
vobj.SunDateMonth = 13
|
||||
self.assertEqual(vobj.SunDateMonth, 12, "Property should be clamped to its max value")
|
||||
|
||||
vobj.SunDateMonth = 0
|
||||
self.assertEqual(vobj.SunDateMonth, 1, "Property should be clamped to its min value")
|
||||
|
||||
finally:
|
||||
# Close reopened document to keep test isolation
|
||||
FreeCAD.closeDocument(reopened.Name)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def test_legacy_site_migration(self):
|
||||
"""Legacy migration test (<=1.0.0 -> newer)
|
||||
|
||||
Verifies that loading a legacy (<=1.0.0) FreeCAD file triggers the view provider's restore
|
||||
path and performs a conservative migration of legacy values.
|
||||
|
||||
When a Site previously saved with `SolarDiagramScale == 1.0` is loaded, the view provider's
|
||||
`loads()` restore path will detect that this is a legacy value and replace it with the
|
||||
modern default of 20 m radius (https://github.com/FreeCAD/FreeCAD/pull/22496). The migration
|
||||
is conservative: only values less-or-equal to `1.0` (or effectively zero) are considered
|
||||
legacy and will be changed.
|
||||
|
||||
The test waits briefly for the loader's deferred binding and normalization callbacks to run
|
||||
before asserting the migrated value.
|
||||
"""
|
||||
self.printTestMessage('Save and reopen legacy Site...')
|
||||
fixtures_dir = os.path.join(os.path.dirname(__file__), 'fixtures')
|
||||
fname = os.path.join(fixtures_dir, 'FC_site_simple-102.FCStd')
|
||||
if not os.path.exists(fname):
|
||||
raise unittest.SkipTest('Legacy migration fixture not found; skipping test.')
|
||||
|
||||
# If fixture exists, open and validate migration
|
||||
d = FreeCAD.openDocument(fname)
|
||||
try:
|
||||
# Wait briefly so the document loader can attach the ViewObject and let the view
|
||||
# provider's queued restore/migration callbacks (setProperties, migration,
|
||||
# restoreConstraints) run on the GUI event loop before we inspect properties.
|
||||
self.pump_gui_events()
|
||||
site = None
|
||||
for o in d.Objects:
|
||||
if hasattr(o, 'Proxy') and getattr(o.Proxy, 'Type', None) == 'Site':
|
||||
site = o
|
||||
break
|
||||
assert site is not None, 'No Site found in fixture document'
|
||||
vobj = site.ViewObject
|
||||
# Example assertion: SolarDiagramScale should be normalized to 20000
|
||||
self.assertAlmostEqual(getattr(vobj, 'SolarDiagramScale', 1.0), 20000.0)
|
||||
finally:
|
||||
FreeCAD.closeDocument(d.Name)
|
||||
BIN
src/Mod/BIM/bimtests/fixtures/FC_site_simple-102.FCStd
Normal file
BIN
src/Mod/BIM/bimtests/fixtures/FC_site_simple-102.FCStd
Normal file
Binary file not shown.
Reference in New Issue
Block a user