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:
Furgo
2025-10-05 11:47:56 +02:00
committed by GitHub
parent 960434f7e5
commit 8e202d6aaf
6 changed files with 445 additions and 47 deletions

View File

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

View File

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

View File

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

View File

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

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

Binary file not shown.