diff --git a/src/Mod/BIM/ArchSite.py b/src/Mod/BIM/ArchSite.py index 1953c50806..ae16bdb6a0 100644 --- a/src/Mod/BIM/ArchSite.py +++ b/src/Mod/BIM/ArchSite.py @@ -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})" - diff --git a/src/Mod/BIM/CMakeLists.txt b/src/Mod/BIM/CMakeLists.txt index e018875b81..325e2546c5 100644 --- a/src/Mod/BIM/CMakeLists.txt +++ b/src/Mod/BIM/CMakeLists.txt @@ -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 +# +# 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( diff --git a/src/Mod/BIM/TestArchGui.py b/src/Mod/BIM/TestArchGui.py index 97292491e1..e4de71f012 100644 --- a/src/Mod/BIM/TestArchGui.py +++ b/src/Mod/BIM/TestArchGui.py @@ -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 diff --git a/src/Mod/BIM/bimtests/TestArchBaseGui.py b/src/Mod/BIM/bimtests/TestArchBaseGui.py index 488a3e1ade..066ec301d7 100644 --- a/src/Mod/BIM/bimtests/TestArchBaseGui.py +++ b/src/Mod/BIM/bimtests/TestArchBaseGui.py @@ -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 + diff --git a/src/Mod/BIM/bimtests/TestArchSiteGui.py b/src/Mod/BIM/bimtests/TestArchSiteGui.py new file mode 100644 index 0000000000..efc175b47e --- /dev/null +++ b/src/Mod/BIM/bimtests/TestArchSiteGui.py @@ -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 * +# * . * +# * * +# *************************************************************************** + +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) diff --git a/src/Mod/BIM/bimtests/fixtures/FC_site_simple-102.FCStd b/src/Mod/BIM/bimtests/fixtures/FC_site_simple-102.FCStd new file mode 100644 index 0000000000..d9cb8701e1 Binary files /dev/null and b/src/Mod/BIM/bimtests/fixtures/FC_site_simple-102.FCStd differ