BIM: add interactive sun position and ray visualization to Arch Site (#22516)

* BIM: add properties for static sun sphere representation

* BIM: create Coin3D nodes for the sun sphere

* BIM: implement logic for static sun representation

* BIM: add additional info properties to sun ray

* BIM: use Draft.Line for sun ray, improve visual appearance

* BIM: color code the sun path arc

* BIM: add hour marker points to the sun path arc

* BIM: make sun path diagram smoother

* BIM: add conditional sun hour marker labels

* BIM: set constraints to time values

* BIM: remove redundant import
This commit is contained in:
Furgo
2025-08-25 11:37:30 +02:00
committed by GitHub
parent 8d880f2860
commit 4d8a277926

View File

@@ -198,12 +198,18 @@ def makeSolarDiagram(longitude,latitude,scale=1,complete=False,tz=None):
h = "SUMMER"
hourpos.append((h,ep))
if i < 7:
sunpaths.append(Part.makePolygon(pts))
if len(pts) > 1:
b_spline = Part.BSplineCurve()
b_spline.buildFromPoles(pts)
sunpaths.append(b_spline.toShape())
for h in hpts:
if complete:
h.append(h[0])
hourpaths.append(Part.makePolygon(h))
if len(h) > 1:
b_spline = Part.BSplineCurve()
b_spline.buildFromPoles(h)
hourpaths.append(b_spline.toShape())
# cut underground lines
sz = 2.1*scale
@@ -577,6 +583,9 @@ class _Site(ArchIFC.IfcProduct):
obj.addProperty("App::PropertyInteger","TimeZone","Site",QT_TRANSLATE_NOOP("App::Property","The time zone where this site is located"), locked=True)
if not "EPWFile" in pl:
obj.addProperty("App::PropertyFileIncluded","EPWFile","Site",QT_TRANSLATE_NOOP("App::Property","An optional EPW File for the location of this site. Refer to the Site documentation to know how to obtain one"), locked=True)
if not "SunRay" in pl:
obj.addProperty("App::PropertyLink", "SunRay", "Sun", QT_TRANSLATE_NOOP("App::Property", "The generated sun ray object"), locked=True)
obj.setEditorMode("SunRay", ["ReadOnly", "Hidden"])
def onDocumentRestored(self,obj):
"""Method run when the document is restored. Re-adds the properties."""
@@ -810,6 +819,22 @@ class _ViewProviderSite:
vobj.addProperty("App::PropertyVector", "CompassPosition", "Compass", QT_TRANSLATE_NOOP("App::Property", "The position of the Compass relative to the Site placement"), locked=True)
if not "UpdateDeclination" in pl:
vobj.addProperty("App::PropertyBool", "UpdateDeclination", "Compass", QT_TRANSLATE_NOOP("App::Property", "Update the Declination value based on the compass rotation"), locked=True)
if not "ShowSunPosition" in pl:
vobj.addProperty("App::PropertyBool", "ShowSunPosition", "Sun", QT_TRANSLATE_NOOP("App::Property", "Show the sun position for a specific date and time"), locked=True)
if not "SunDateMonth" in pl:
vobj.addProperty("App::PropertyIntegerConstraint", "SunDateMonth", "Sun", QT_TRANSLATE_NOOP("App::Property", "The month of the year to show the sun position"), locked=True)
vobj.SunDateMonth = (6, 1, 12, 1) # Default to June
if not "SunDateDay" in pl:
vobj.addProperty("App::PropertyIntegerConstraint", "SunDateDay", "Sun", QT_TRANSLATE_NOOP("App::Property", "The day of the month to show the sun position"), locked=True)
# 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 not "SunTimeHour" in pl:
vobj.addProperty("App::PropertyFloatConstraint", "SunTimeHour", "Sun", QT_TRANSLATE_NOOP("App::Property", "The hour of the day to show the sun position"), locked=True)
# Use 23.99 to avoid issues with hour 24
vobj.SunTimeHour = (12.0, 0.0, 23.5, 0.5) # Default to noon
if not "ShowHourLabels" in pl:
vobj.addProperty("App::PropertyBool", "ShowHourLabels", "Sun", QT_TRANSLATE_NOOP("App::Property", "Show text labels for key hours on the sun path"), locked=True)
vobj.ShowHourLabels = True # Show hour labels by default
def getIcon(self):
"""Return the path to the appropriate icon.
@@ -936,6 +961,67 @@ class _ViewProviderSite:
self.rotateCompass(vobj)
vobj.Annotation.addChild(self.compass.rootNode)
self.sunSwitch = coin.SoSwitch() # Toggle the sun sphere on and off
self.sunSwitch.whichChild = -1 # -1 means hidden
self.sunSep = coin.SoSeparator() # A separator to group all sun elements
self.sunTransform = coin.SoTransform() # Position the sphere
self.sunMaterial = coin.SoMaterial()
self.sunMaterial.diffuseColor.setValue(1, 1, 0) # Yellow color
self.sunSphere = coin.SoSphere()
# Assemble the scene graph for the sphere
self.sunSep.addChild(self.sunTransform)
self.sunSep.addChild(self.sunMaterial)
self.sunSep.addChild(self.sunSphere)
self.sunSwitch.addChild(self.sunSep)
# Add the entire sun assembly to the object's annotation node
vobj.Annotation.addChild(self.sunSwitch)
def setup_path_segment(color_tuple):
separator = coin.SoSeparator()
material = coin.SoMaterial()
material.diffuseColor.setValue(color_tuple)
node = coin.SoSeparator() # This will hold the geometry
separator.addChild(material)
separator.addChild(node)
self.sunSwitch.addChild(separator)
return node
# Create nodes for different segments of the sun path representing
# morning, midday, and afternoon with distinct colors.
self.sunPathMorningNode = setup_path_segment((0.2, 0.8, 1.0)) # Sky Blue
self.sunPathMiddayNode = setup_path_segment((1.0, 0.75, 0.0)) # Golden Yellow / Amber
self.sunPathAfternoonNode = setup_path_segment((1.0, 0.35, 0.0)) # Orange-Red
# Create nodes for the hour marker points.
self.hourMarkerSep = coin.SoSeparator()
self.hourMarkerMaterial = coin.SoMaterial()
self.hourMarkerMaterial.diffuseColor.setValue(0.8, 0.8, 0.8) # Grey
self.hourMarkerDrawStyle = coin.SoDrawStyle()
self.hourMarkerDrawStyle.pointSize.setValue(5) # Set a visible point size (e.g., 5 pixels)
self.hourMarkerDrawStyle.style.setValue(coin.SoDrawStyle.POINTS)
self.hourMarkerCoords = coin.SoCoordinate3()
self.hourMarkerSet = coin.SoPointSet()
self.hourMarkerSep.addChild(self.hourMarkerMaterial)
self.hourMarkerSep.addChild(self.hourMarkerDrawStyle)
self.hourMarkerSep.addChild(self.hourMarkerCoords)
self.hourMarkerSep.addChild(self.hourMarkerSet)
self.sunSwitch.addChild(self.hourMarkerSep)
# Create nodes for the hour labels.
self.hourLabelSep = coin.SoSeparator()
self.hourLabelMaterial = coin.SoMaterial()
self.hourLabelMaterial.diffuseColor.setValue(0.8, 0.8, 0.8) # Same grey as markers
self.hourLabelFont = coin.SoFont()
self.hourLabelSep.addChild(self.hourLabelMaterial)
self.hourLabelSep.addChild(self.hourLabelFont)
self.sunSwitch.addChild(self.hourLabelSep)
def updateData(self,obj,prop):
"""Method called when the host object has a property changed.
@@ -1013,6 +1099,16 @@ class _ViewProviderSite:
switch.whichChild = idx
def onChanged(self,vobj,prop):
from pivy import coin
if prop == 'Visibility':
if vobj.Visibility:
# When the site becomes visible, check if the sun elements should also be shown.
if vobj.ShowSunPosition:
self.sunSwitch.whichChild = coin.SO_SWITCH_ALL
else:
# When the site is hidden, always hide the sun elements.
self.sunSwitch.whichChild = coin.SO_SWITCH_NONE
# onChanged is called multiple times when a document is opened.
# Some display mode nodes can be missing during initial calls.
@@ -1048,6 +1144,10 @@ class _ViewProviderSite:
del self.diagramnode
else:
self.diagramswitch.whichChild = -1
elif prop in [
"ShowSunPosition", "SunDateMonth", "SunDateDay", "SunTimeHour",
"SolarDiagramScale", "SolarDiagramPosition", "ShowHourLabels"]:
self.updateSunPosition(vobj)
elif prop == "WindRose":
if hasattr(self,"windrosenode"):
del self.windrosenode
@@ -1183,3 +1283,179 @@ class _ViewProviderSite:
def loads(self,state):
return None
def updateSunPosition(self, vobj):
"""Calculates sun position and updates the sphere, path arc, and ray object."""
import math
import Part
import datetime
from pivy import coin
obj = vobj.Object
# Handle the visibility toggle for all elements
self.sunPathMorningNode.removeAllChildren()
self.sunPathMiddayNode.removeAllChildren()
self.sunPathAfternoonNode.removeAllChildren()
self.hourLabelSep.removeAllChildren()
self.hourMarkerCoords.point.deleteValues(0)
if not vobj.ShowSunPosition:
self.sunSwitch.whichChild = -1 # Hide the Pivy sphere and path
if obj.SunRay and hasattr(obj.SunRay, "ViewObject"):
obj.SunRay.ViewObject.Visibility = False
return
self.sunSwitch.whichChild = coin.SO_SWITCH_ALL # Show sphere and path
dt_object_for_label = None
try:
from ladybug import location, sunpath
loc = location.Location(latitude=obj.Latitude, longitude=obj.Longitude, time_zone=obj.TimeZone)
sp = sunpath.Sunpath.from_location(loc)
is_ladybug = True
except ImportError:
try:
import pysolar.solar as solar
is_ladybug = False
except ImportError:
FreeCAD.Console.PrintError("Ladybug or Pysolar module not found. Cannot calculate sun position.\n")
return
morning_points, midday_points, afternoon_points = [], [], []
self.hourMarkerCoords.point.deleteValues(0) # Clear previous marker coordinates
marker_coords = []
for hour_float in [h / 2.0 for h in range(48)]: # Loop from 0.0 to 23.5
if is_ladybug:
sun = sp.calculate_sun(month=vobj.SunDateMonth, day=vobj.SunDateDay, hour=hour_float)
alt = sun.altitude
az = sun.azimuth
else:
tz = datetime.timezone(datetime.timedelta(hours=obj.TimeZone))
dt = datetime.datetime(2023, vobj.SunDateMonth, vobj.SunDateDay, int(hour_float), int((hour_float % 1)*60), tzinfo=tz)
alt = solar.get_altitude(obj.Latitude, obj.Longitude, dt)
az = solar.get_azimuth(obj.Latitude, obj.Longitude, dt)
if alt > 0:
alt_rad = math.radians(alt)
az_rad = math.radians(90 - az)
xy_proj = math.cos(alt_rad) * vobj.SolarDiagramScale
x = math.cos(az_rad) * xy_proj
y = math.sin(az_rad) * xy_proj
z = math.sin(alt_rad) * vobj.SolarDiagramScale
point = FreeCAD.Vector(x, y, z)
if hour_float < 10:
morning_points.append(point)
elif hour_float <= 14:
midday_points.append(point)
else:
afternoon_points.append(point)
# Check if the current time is a full hour
if hour_float % 1 == 0:
marker_coords.append(FreeCAD.Vector(x, y, z))
if hasattr(vobj, "ShowHourLabels") and vobj.ShowHourLabels:
if vobj.ShowHourLabels and (hour_float in [9.0, 12.0, 15.0]):
# Create a text node for the label
text_node = coin.SoText2()
text_node.string = f"{int(hour_float)}h"
# Create a transform to position the text slightly offset from the marker
text_transform = coin.SoTransform()
offset_vec = FreeCAD.Vector(x, y, z).normalize() * (vobj.SolarDiagramScale * 0.03)
text_pos = FreeCAD.Vector(x, y, z).add(offset_vec)
text_transform.translation.setValue(text_pos.x, text_pos.y, text_pos.z)
# Add a separator for this specific label
label_sep = coin.SoSeparator()
label_sep.addChild(text_transform)
label_sep.addChild(text_node)
self.hourLabelSep.addChild(label_sep)
if marker_coords:
self.hourMarkerCoords.point.setValues(marker_coords)
if len(morning_points) > 1:
path_b_spline = Part.BSplineCurve()
path_b_spline.buildFromPoles(morning_points)
self.sunPathMorningNode.addChild(toNode(path_b_spline.toShape()))
if len(midday_points) > 1:
# To connect midday to morning, we need the last point from the morning list
if morning_points:
midday_points.insert(0, morning_points[-1])
path_b_spline = Part.BSplineCurve()
path_b_spline.buildFromPoles(midday_points)
self.sunPathMiddayNode.addChild(toNode(path_b_spline.toShape()))
if len(afternoon_points) > 1:
# To connect afternoon to midday, we need the last point from the midday list
if midday_points:
afternoon_points.insert(0, midday_points[-1])
path_b_spline = Part.BSplineCurve()
path_b_spline.buildFromPoles(afternoon_points)
self.sunPathAfternoonNode.addChild(toNode(path_b_spline.toShape()))
self.hourLabelFont.size = vobj.SolarDiagramScale * 0.015
# Sun sphere and sun ray logic
if is_ladybug:
sun = sp.calculate_sun(month=vobj.SunDateMonth, day=vobj.SunDateDay, hour=vobj.SunTimeHour)
altitude_deg, azimuth_deg = sun.altitude, sun.azimuth
dt_object_for_label = datetime.datetime(2023, vobj.SunDateMonth, vobj.SunDateDay, int(vobj.SunTimeHour), int((vobj.SunTimeHour % 1)*60))
else:
tz = datetime.timezone(datetime.timedelta(hours=obj.TimeZone))
dt_object_for_label = datetime.datetime(2023, vobj.SunDateMonth, vobj.SunDateDay, int(vobj.SunTimeHour), int((vobj.SunTimeHour % 1)*60), tzinfo=tz)
altitude_deg = solar.get_altitude(obj.Latitude, obj.Longitude, dt_object_for_label)
azimuth_deg = solar.get_azimuth(obj.Latitude, obj.Longitude, dt_object_for_label)
altitude_rad = math.radians(altitude_deg)
azimuth_rad = math.radians(90 - azimuth_deg)
xy_proj = math.cos(altitude_rad) * vobj.SolarDiagramScale
x = math.cos(azimuth_rad) * xy_proj
y = math.sin(azimuth_rad) * xy_proj
z = math.sin(altitude_rad) * vobj.SolarDiagramScale
sun_pos_3d = vobj.SolarDiagramPosition.add(FreeCAD.Vector(x, y, z)) # Final absolute position
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):
ray_object = Draft.make_line(sun_pos_3d, vobj.SolarDiagramPosition)
vo = ray_object.ViewObject
vo.LineColor = (1.0, 1.0, 0.0)
vo.DrawStyle = "Dashed"
vo.ArrowType = "Arrow"
vo.LineWidth = 2
vo.EndArrow = True
vo.ArrowSize = vobj.SolarDiagramScale * 0.015
if hasattr(obj, "addObject"):
obj.addObject(ray_object)
obj.SunRay = ray_object
ray_object.recompute()
# Add and update custom data properties
if not hasattr(ray_object, "Altitude"):
ray_object.addProperty("App::PropertyAngle", "Altitude", "Sun Data", QT_TRANSLATE_NOOP("App::Property", "The altitude of the sun above the horizon"), locked=True)
ray_object.setEditorMode("Altitude", ["ReadOnly", "Hidden"])
ray_object.addProperty("App::PropertyAngle", "Azimuth", "Sun Data", QT_TRANSLATE_NOOP("App::Property", "The compass direction of the sun (0° is North)"), locked=True)
ray_object.setEditorMode("Azimuth", ["ReadOnly", "Hidden"])
ray_object.addProperty("App::PropertyString", "Time", "Sun Data", QT_TRANSLATE_NOOP("App::Property", "The date and time for this sun position"), locked=True)
ray_object.setEditorMode("Time", ["ReadOnly", "Hidden"])
ray_object.Altitude = math.radians(altitude_deg)
ray_object.Azimuth = math.radians(azimuth_deg)
time_string = dt_object_for_label.strftime("%B %d, %H:%M")
ray_object.Time = time_string
ray_object.Label = f"Sun Ray ({time_string})"