Compare commits

...

1 Commits

Author SHA1 Message Date
Dion Moult 6c69c9368b Code dump of https://github.com/Svisuals/4D_Bonsai/ 2026-02-17 13:22:02 +11:00
90 changed files with 40858 additions and 547 deletions
+231 -157
View File
@@ -1,5 +1,5 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2020, 2021 Dion Moult <dion@thinkmoult.com>
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
@@ -16,185 +16,259 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
# pyright: reportAttributeAccessIssue=false
import bpy
from . import operator, prop, ui
from . import prop, ui, operators
classes = (
operator.AddAnimationCamera,
operator.AddSummaryTask,
operator.AddTask,
operator.AddTaskBars,
operator.AddTaskColumn,
operator.AddTimePeriod,
operator.AddWorkCalendar,
operator.AddWorkPlan,
operator.AddWorkSchedule,
operator.AddWorkTime,
operator.AssignLagTime,
operator.AssignPredecessor,
operator.AssignProcess,
operator.AssignProduct,
operator.AssignRecurrencePattern,
operator.AssignSuccessor,
operator.AssignStatus,
operator.AssignWorkSchedule,
operator.Bonsai_DatePicker,
operator.CalculateTaskDuration,
operator.ClearPreviousAnimation,
operator.ContractAllTasks,
operator.ContractTask,
operator.CopyTask,
operator.CopyTaskAttribute,
operator.CopyWorkSchedule,
operator.CreateBaseline,
operator.DisableEditingSequence,
operator.DisableEditingTask,
operator.DisableEditingTaskTime,
operator.DisableEditingWorkCalendar,
operator.DisableEditingWorkPlan,
operator.DisableEditingWorkSchedule,
operator.DisableEditingWorkTime,
operator.EditSequenceAttributes,
operator.EditSequenceTimeLag,
operator.EditTask,
operator.EditTaskCalendar,
operator.EditTaskTime,
operator.EditWorkCalendar,
operator.EditWorkPlan,
operator.EditWorkSchedule,
operator.EditWorkTime,
operator.EnableEditingSequenceAttributes,
operator.EnableEditingSequenceTimeLag,
operator.EnableEditingTask,
operator.EnableEditingTaskCalendar,
operator.EnableEditingTaskSequence,
operator.EnableEditingTaskTime,
operator.EnableEditingWorkCalendar,
operator.EnableEditingWorkCalendarTimes,
operator.EnableEditingWorkPlan,
operator.EnableEditingWorkPlanSchedules,
operator.EnableEditingWorkSchedule,
operator.EnableEditingWorkScheduleTasks,
operator.EnableEditingWorkTime,
operator.ExpandAllTasks,
operator.ExpandTask,
operator.ExportMSP,
operator.ExportP6,
operator.GenerateGanttChart,
operator.GuessDateRange,
operator.GoToTask,
operator.ImportWorkScheduleCSV,
operator.ImportMSP,
operator.ImportP6,
operator.ImportP6XER,
operator.ImportPP,
operator.LoadAnimationColorScheme,
operator.LoadDefaultAnimationColors,
operator.LoadProductTasks,
operator.LoadTaskProperties,
operator.RecalculateSchedule,
operator.RemoveTask,
operator.RemoveTaskCalendar,
operator.RemoveTaskColumn,
operator.RemoveTimePeriod,
operator.RemoveWorkCalendar,
operator.RemoveWorkPlan,
operator.RemoveWorkSchedule,
operator.RemoveWorkTime,
operator.ReorderTask,
operator.SaveAnimationColorScheme,
operator.SelectTaskElements,
operator.SelectTaskRelatedInputs,
operator.SelectTaskRelatedProducts,
operator.SelectUnassignedWorkScheduleProducts,
operator.SelectWorkScheduleProducts,
operator.SetTaskSortColumn,
operator.SetupDefaultTaskColumns,
operator.UnassignLagTime,
operator.UnassignPredecessor,
operator.UnassignProcess,
operator.UnassignProduct,
operator.UnassignRecurrencePattern,
operator.UnassignSuccessor,
operator.UnassignWorkSchedule,
operator.VisualiseWorkScheduleDate,
operator.VisualiseWorkScheduleDateRange,
operator.EnableStatusFilters,
operator.DisableStatusFilters,
operator.ActivateStatusFilters,
operator.SelectStatusFilter,
prop.WorkPlan,
prop.BIMWorkPlanProperties,
prop.Task,
prop.TaskResource,
prop.TaskProduct,
prop.IFCStatus,
prop.BIMStatusProperties,
prop.BIMWorkScheduleProperties,
prop.BIMTaskTreeProperties,
prop.BIMTaskTypeColor,
prop.BIMAnimationProperties,
prop.WorkCalendar,
prop.RecurrenceComponent,
prop.BIMWorkCalendarProperties,
prop.DatePickerProperties,
prop.BIMDateTextProperties,
ui.BIM_PT_status,
ui.BIM_PT_work_plans,
ui.BIM_PT_work_schedules,
ui.BIM_PT_work_calendars,
ui.BIM_PT_animation_tools,
ui.BIM_PT_task_icom,
ui.BIM_PT_animation_Color_Scheme,
ui.BIM_UL_task_columns,
ui.BIM_UL_task_inputs,
ui.BIM_UL_task_resources,
ui.BIM_UL_task_outputs,
ui.BIM_UL_tasks,
ui.BIM_PT_4D_Tools,
ui.BIM_UL_animation_colors,
ui.BIM_UL_product_input_tasks,
ui.BIM_UL_product_output_tasks,
)
# Classes tuple for main Bonsai registration - contains all UI classes
classes = ui.classes
# --- Optional registration: ClearAnimationAdvanced (guarded) ---
try:
_ADV = operators.ClearAnimationAdvanced
except Exception:
_ADV = None
if _ADV:
try:
classes = tuple(list(classes) + [_ADV])
except Exception:
pass
def menu_func_export(self, context):
self.layout.operator(operator.ExportP6.bl_idname, text="P6 (.xml)")
self.layout.operator(operator.ExportMSP.bl_idname, text="Microsoft Project (.xml)")
self.layout.operator(operators.ExportP6.bl_idname, text="P6 (.xml)")
self.layout.operator(operators.ExportMSP.bl_idname, text="Microsoft Project (.xml)")
def menu_func_import(self, context):
self.layout.operator(operator.ImportWorkScheduleCSV.bl_idname, text="Work Schedule (.csv)")
self.layout.operator(operator.ImportP6.bl_idname, text="P6 (.xml)")
self.layout.operator(operator.ImportP6XER.bl_idname, text="P6 (.xer)")
self.layout.operator(operator.ImportPP.bl_idname, text="Powerproject (.pp)")
self.layout.operator(operator.ImportMSP.bl_idname, text="Microsoft Project (.xml)")
self.layout.operator(operators.ImportWorkScheduleCSV.bl_idname, text="Work Schedule (.csv)")
self.layout.operator(operators.ImportP6.bl_idname, text="P6 (.xml)")
self.layout.operator(operators.ImportP6XER.bl_idname, text="P6 (.xer)")
self.layout.operator(operators.ImportPP.bl_idname, text="Powerproject (.pp)")
self.layout.operator(operators.ImportMSP.bl_idname, text="Microsoft Project (.xml)")
def register():
bpy.types.Scene.BIMStatusProperties = bpy.props.PointerProperty(type=prop.BIMStatusProperties)
# 1. Register all properties by delegating to the 'prop' package
prop.register()
# 2. Register Scene properties immediately after prop registration
bpy.types.Scene.show_saved_ColorTypes_section = bpy.props.BoolProperty(name="Show Saved ColorTypes", default=True)
bpy.types.Scene.BIMWorkPlanProperties = bpy.props.PointerProperty(type=prop.BIMWorkPlanProperties)
bpy.types.Scene.BIMWorkScheduleProperties = bpy.props.PointerProperty(type=prop.BIMWorkScheduleProperties)
bpy.types.Scene.BIMTaskTreeProperties = bpy.props.PointerProperty(type=prop.BIMTaskTreeProperties)
bpy.types.Scene.BIMWorkCalendarProperties = bpy.props.PointerProperty(type=prop.BIMWorkCalendarProperties)
bpy.types.Scene.BIMStatusProperties = bpy.props.PointerProperty(type=prop.BIMStatusProperties)
bpy.types.Scene.BIMAnimationProperties = bpy.props.PointerProperty(type=prop.BIMAnimationProperties)
bpy.types.Scene.DatePickerProperties = bpy.props.PointerProperty(type=prop.DatePickerProperties)
bpy.types.TextCurve.BIMDateTextProperties = bpy.props.PointerProperty(type=prop.BIMDateTextProperties)
# 3. Register camera orbit property group and attach it dynamically
try:
bpy.utils.register_class(prop.BIMCameraOrbitProperties)
except Exception:
pass
try:
if not hasattr(prop.BIMAnimationProperties, 'camera_orbit'):
prop.BIMAnimationProperties.camera_orbit = bpy.props.PointerProperty(type=prop.BIMCameraOrbitProperties)
except Exception as _e:
print("camera_orbit dynamic attach failed:", _e)
# 4. UI classes are registered by main Bonsai system via 'classes' tuple
# Do not call ui.register() here to avoid duplicates
# 5. Register all operators
operators.register()
# 6. Register additional operators
try:
bpy.utils.register_class(operators.ResetCameraSettings)
except Exception:
pass
# 7. Register menu functions
bpy.types.TOPBAR_MT_file_export.append(menu_func_export)
bpy.types.TOPBAR_MT_file_import.append(menu_func_import)
# --- Seed DEFAULT Animation Color Schemes group if none exists, and select it ---
try:
import json
scn = bpy.context.scene
key = "BIM_AnimationColorSchemesSets"
raw = scn.get(key, "{}")
data = {}
try:
data = json.loads(raw) if isinstance(raw, str) else (raw or {})
except Exception:
data = {}
# If the DEFAULT group doesn't exist or is empty, create it with full data.
# This is more robust than just checking if `data` is empty.
if not isinstance(data, dict) or not data.get("DEFAULT"):
color_map = {
"CONSTRUCTION": {"start": [1,1,1,0], "active": [0,1,0,1], "end": [0.3,1,0.3,1]},
"INSTALLATION": {"start": [1,1,1,0], "active": [0,1,0,1], "end": [0.3,0.8,0.5,1]},
"DEMOLITION": {"start": [1,1,1,1], "active": [1,0,0,1], "end": [0,0,0,0], "hide": True},
"REMOVAL": {"start": [1,1,1,1], "active": [1,0,0,1], "end": [0,0,0,0], "hide": True},
"DISPOSAL": {"start": [1,1,1,1], "active": [1,0,0,1], "end": [0,0,0,0], "hide": True},
"DISMANTLE": {"start": [1,1,1,1], "active": [1,0,0,1], "end": [0,0,0,0], "hide": True},
"OPERATION": {"start": [1,1,1,1], "active": [0,0,1,1], "end": [1,1,1,1]},
"MAINTENANCE": {"start": [1,1,1,1], "active": [0,0,1,1], "end": [1,1,1,1]},
"ATTENDANCE": {"start": [1,1,1,1], "active": [0,0,1,1], "end": [1,1,1,1]},
"RENOVATION": {"start": [1,1,1,1], "active": [0,0,1,1], "end": [0.9,0.9,0.9,1]},
"LOGISTIC": {"start": [1,1,1,1], "active": [1,1,0,1], "end": [1,0.8,0.3,1]},
"MOVE": {"start": [1,1,1,1], "active": [1,1,0,1], "end": [0.8,0.6,0,1]},
"NOTDEFINED": {"start": [0.7,0.7,0.7,1], "active": [0.5,0.5,0.5,1], "end": [0.3,0.3,0.3,1]},
"USERDEFINED": {"start": [0.7,0.7,0.7,1], "active": [0.5,0.5,0.5,1], "end": [0.3,0.3,0.3,1]},
}
default_colortypes = []
for name, colors in color_map.items():
# Create the full profile data structure
profile = {
"name": name, "start_color": colors["start"], "in_progress_color": colors["active"], "end_color": colors["end"],
"consider_start": False, "consider_active": True, "consider_end": True,
"use_start_original_color": False, "use_active_original_color": False,
"use_end_original_color": not colors.get("hide", False),
"start_transparency": 0.0, "active_start_transparency": 0.0, "active_finish_transparency": 0.0,
"active_transparency_interpol": 1.0, "end_transparency": 0.0,
"hide_at_end": colors.get("hide", False)
}
default_colortypes.append(profile)
data["DEFAULT"] = {"ColorTypes": default_colortypes}
scn[key] = json.dumps(data)
# try to select DEFAULT in the UI
try:
scn.BIMAnimationProperties.ColorType_groups = "DEFAULT"
except Exception:
pass
except Exception:
pass
# === CACHE INVALIDATION HANDLER ===
# Solution to the persistent state bug between .blend files
def clear_animation_caches(*args):
"""Clears ALL animation caches when a new file is loaded"""
print("[INFO] NEW FILE LOADED: Clearing all animation caches...")
try:
# 1. Invalidate IFC lookup cache
from .utils import ifc_lookup
ifc_lookup.invalidate_all_lookups()
print("[INFO] IFC lookup cache cleared")
except Exception as e:
print(f"[ERROR] Could not clear IFC lookup cache: {e}")
try:
# 2. Invalidate performance cache
from .utils import performance_cache
performance_cache.invalidate_cache()
print("[INFO] Performance cache cleared")
except Exception as e:
print(f"[ERROR] Could not clear performance cache: {e}")
try:
# 3. Invalidate global ColorType cache
from .utils import colortype_cache
colortype_cache.clear_global_cache()
print("[INFO] ColorType cache cleared")
except Exception as e:
print(f"[ERROR] Could not clear ColorType cache: {e}")
try:
# 4. Invalidate HUD Legend cache
from . import hud
hud.invalidate_legend_hud_cache()
print("[INFO] HUD Legend cache cleared")
except Exception as e:
print(f"[ERROR] Could not clear HUD Legend cache: {e}")
try:
# 5. Clear active animation flags
import bpy
if hasattr(bpy.context, 'scene'):
if "is_snapshot_mode" in bpy.context.scene:
del bpy.context.scene["is_snapshot_mode"]
print("[INFO] Snapshot mode flag cleared")
# Reset animation properties
try:
import bonsai.tool as tool
anim_props = tool.Sequence.get_animation_props()
anim_props.is_animation_created = False
print("[INFO] Animation active flag cleared")
except Exception:
pass
except Exception as e:
print(f"[ERROR] Could not clear animation flags: {e}")
print("[INFO] Cache invalidation complete - ready for new project!")
# Register handler to clear caches on file load
if clear_animation_caches not in bpy.app.handlers.load_post:
bpy.app.handlers.load_post.append(clear_animation_caches)
print("[INFO] Animation cache invalidation handler registered")
def unregister():
del bpy.types.Scene.BIMStatusProperties
del bpy.types.Scene.BIMWorkPlanProperties
del bpy.types.Scene.BIMWorkScheduleProperties
del bpy.types.Scene.BIMTaskTreeProperties
del bpy.types.Scene.BIMWorkCalendarProperties
del bpy.types.Scene.DatePickerProperties
del bpy.types.Scene.BIMAnimationProperties
del bpy.types.TextCurve.BIMDateTextProperties
# === REMOVE CACHE INVALIDATION HANDLERS ===
try:
if clear_animation_caches in bpy.app.handlers.load_post:
bpy.app.handlers.load_post.remove(clear_animation_caches)
print("[INFO] Animation cache invalidation handler removed")
if detect_file_change in bpy.app.handlers.depsgraph_update_post:
bpy.app.handlers.depsgraph_update_post.remove(detect_file_change)
print("[INFO] File change detector removed")
except Exception as e:
print(f"[WARNING] Could not remove cache handlers: {e}")
# Unregister in reverse order of registration
# 7. Remove menu functions
bpy.types.TOPBAR_MT_file_export.remove(menu_func_export)
bpy.types.TOPBAR_MT_file_import.remove(menu_func_import)
# 6. Unregister additional operators
try:
bpy.utils.unregister_class(operators.ResetCameraSettings)
except Exception:
pass
# 5. Unregister operators from operators module
operators.unregister()
# 4. UI classes are unregistered by main Bonsai system via 'classes' tuple
# Do not call ui.unregister() here to avoid duplicates
# 3. Remove dynamic camera_orbit pointer and unregister camera orbit PG
try:
if hasattr(prop.BIMAnimationProperties, 'camera_orbit'):
delattr(prop.BIMAnimationProperties, 'camera_orbit')
except Exception:
pass
try:
bpy.utils.unregister_class(prop.BIMCameraOrbitProperties)
except Exception:
pass
# 2. Remove Scene properties
if hasattr(bpy.types.Scene, 'show_saved_ColorTypes_section'):
del bpy.types.Scene.show_saved_ColorTypes_section
if hasattr(bpy.types.Scene, 'BIMWorkPlanProperties'):
del bpy.types.Scene.BIMWorkPlanProperties
if hasattr(bpy.types.Scene, 'BIMWorkScheduleProperties'):
del bpy.types.Scene.BIMWorkScheduleProperties
if hasattr(bpy.types.Scene, 'BIMTaskTreeProperties'):
del bpy.types.Scene.BIMTaskTreeProperties
if hasattr(bpy.types.Scene, 'BIMWorkCalendarProperties'):
del bpy.types.Scene.BIMWorkCalendarProperties
if hasattr(bpy.types.Scene, 'BIMStatusProperties'):
del bpy.types.Scene.BIMStatusProperties
if hasattr(bpy.types.Scene, 'DatePickerProperties'):
del bpy.types.Scene.DatePickerProperties
if hasattr(bpy.types.Scene, 'BIMAnimationProperties'):
del bpy.types.Scene.BIMAnimationProperties
if hasattr(bpy.types.TextCurve, 'BIMDateTextProperties'):
del bpy.types.TextCurve.BIMDateTextProperties
# 1. Unregister properties from prop module last
prop.unregister()
File diff suppressed because it is too large Load Diff
+8 -128
View File
@@ -1,5 +1,5 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2020, 2021 Dion Moult <dion@thinkmoult.com>
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net>
#
# This file is part of Bonsai.
#
@@ -16,131 +16,11 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
"""
External helper proxy for sequence module.
This file provides access to helper functions for external modules while
keeping the actual implementation in utils/helper_utils.py for internal use.
"""
from datetime import datetime, timedelta
from typing import Any, Union
import bpy
import ifcopenshell.util.date
import isodate
from dateutil import parser
from bonsai.bim.prop import ISODuration
def parse_datetime(value):
try:
return parser.isoparse(value)
except:
try:
return parser.parse(value, dayfirst=True, fuzzy=True)
except:
return None
def parse_duration(value):
return ifcopenshell.util.date.parse_duration(value)
def canonicalise_time(time: Union[datetime, None]) -> str:
"""Actualy canonicalises datetime as just a date, time is not included."""
if not time:
return "-"
return time.strftime("%d/%m/%y")
def parse_duration_as_blender_props(dt: Union[Any, str]) -> dict[str, int]:
if True:
if isinstance(dt, str):
dt = ifcopenshell.util.date.ifc2datetime(dt)
seconds = getattr(dt, "seconds", 0)
hours, seconds = divmod(seconds, 3600)
minutes, seconds = divmod(seconds, 60)
days = getattr(dt, "days", 0)
months = int(getattr(dt, "months", 0))
years = int(getattr(dt, "years", 0))
return {
"years": years,
"months": months,
"days": days,
"hours": hours,
"minutes": minutes,
"seconds": seconds,
}
def blender_props_to_iso_duration(
durations_attributes: bpy.types.bpy_prop_collection_idprop[ISODuration],
duration_type: Union[str, None],
prop_name: str,
) -> Union[str, None]:
duration_props = None
for collection in durations_attributes:
if collection.name == prop_name:
duration_props = collection
break
if duration_props and (not duration_type or duration_type == "ELAPSEDTIME"):
duration_string = "P{}Y{}M{}DT{}H{}M{}S".format(
duration_props.years if duration_props.years else 0,
duration_props.months if duration_props.months else 0,
duration_props.days if duration_props.days else 0,
duration_props.hours if duration_props.hours else 0,
duration_props.minutes if duration_props.minutes else 0,
duration_props.seconds if duration_props.seconds else 0,
)
duration_object = ifcopenshell.util.date.ifc2datetime(duration_string)
elif duration_props and duration_type == "WORKTIME":
years = (duration_props.years * 365 * 24 * 60 * 60) if duration_props.years else 0
months = (duration_props.months * 30 * 24 * 60 * 60) if duration_props.months else 0
days = (duration_props.days * 24 * 60 * 60) if duration_props.days else 0
days_subtotal = (years + months + days) / (24 * 60 * 60)
hours = (duration_props.hours * 60 * 60) if duration_props.hours else 0
minutes = (duration_props.minutes * 60) if duration_props.minutes else 0
seconds = duration_props.seconds if duration_props.seconds else 0
total_seconds = hours + minutes + seconds
# TODO: implement actual calendar worktime
calendar_seconds_per_day = 8 * 60 * 60
extra_days, seconds_left = divmod(total_seconds, calendar_seconds_per_day)
total_days = days_subtotal + extra_days
duration_object = timedelta(days=total_days, seconds=seconds_left)
else:
return None
if duration_object:
total_days = int(duration_object.days)
seconds_left = int(duration_object.seconds)
years, days = divmod(total_days, 365)
if hasattr(duration_object, "years"):
years += duration_object.years
months, days = divmod(days, 30)
if hasattr(duration_object, "months"):
months += duration_object.months
if months >= 12:
extra_years, months = divmod(months, 12)
years += extra_years
hours, seconds = divmod(seconds_left, 3600)
minutes, seconds = divmod(seconds, 60)
if years > 0 or months > 0 or total_days > 0 or hours > 0 or minutes > 0 or seconds > 0:
duration_string = "P"
duration_string += "{}Y".format(int(years)) if years > 0 else ""
duration_string += "{}M".format(int(months)) if months > 0 else ""
duration_string += "{}D".format(int(total_days)) if total_days > 0 else ""
if hours > 0 or minutes > 0 or seconds > 0:
duration_string += "T"
if hours > 0:
duration_string += "{}H".format(int(hours))
if minutes > 0:
duration_string += "{}M".format(int(minutes))
if seconds > 0:
duration_string += "{}S".format(int(seconds))
return duration_string
else:
return None
else:
return None
# Import all functions from the internal helper_utils module
from .utils.helper_utils import *
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,720 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import bpy
import blf
import gpu
import json
import time
from gpu_extras.batch import batch_for_shader
class LegendHUD:
"""Specialized component for displaying colortype legend and task status"""
def __init__(self, font_id):
"""Initialize LegendHUD with shared font"""
self.font_id = font_id
# Cache for legend data with invalidation
self._legend_data_cache = None
self._cached_animation_groups = None
self._cache_timestamp = 0
self._last_active_group = None
print(f"[INFO] LegendHUD initialized with font_id={font_id}")
def invalidate_legend_cache(self):
"""Invalidates the legend data cache to force an update"""
print("[INFO] Invalidating legend cache")
self._legend_data_cache = None
self._cached_animation_groups = None
self._cache_timestamp = 0
self._last_active_group = None # Reset group tracking to force detection
def draw(self, data, settings, viewport_width, viewport_height):
"""Draws the Legend HUD with active animation profiles and their dynamic colors"""
print(f"\n[INFO] === LEGEND HUD DRAW START ===")
print(f"[INFO] Viewport: {viewport_width}x{viewport_height}")
print(f"[INFO] Settings enabled: {settings.get('enabled', False)}")
try:
# Get active profiles from animation system
legend_data = self.get_active_colortype_legend_data()
if not legend_data:
print("[ERROR] Legend HUD: No active colortype data available")
return
print(f"[INFO] Legend data: {len(legend_data)} ColorTypes found")
# Configuration
position = settings.get('position', 'BOTTOM_LEFT')
margin_h = settings.get('margin_h', 0.05)
margin_v = settings.get('margin_v', 0.05)
# Calculate base position
base_x, base_y, align_x, align_y = self.calculate_position(viewport_width, viewport_height, settings)
# Draw legend elements
self.draw_legend_elements(
legend_data, settings, base_x, base_y, align_x, align_y, viewport_width, viewport_height
)
print("[INFO] Legend HUD drawn successfully")
except Exception as e:
print(f"[ERROR] Error drawing legend HUD: {e}")
import traceback
traceback.print_exc()
def calculate_position(self, viewport_width, viewport_height, settings):
"""Calculate HUD position in pixels"""
margin_h = int(viewport_width * settings['margin_h'])
margin_v = int(viewport_height * settings['margin_v'])
position = settings['position']
if position == 'TOP_RIGHT':
x = viewport_width - margin_h
y = viewport_height - margin_v
align_x = 'RIGHT'
align_y = 'TOP'
elif position == 'TOP_LEFT':
x = margin_h
y = viewport_height - margin_v
align_x = 'LEFT'
align_y = 'TOP'
elif position == 'BOTTOM_RIGHT':
x = viewport_width - margin_h
y = margin_v
align_x = 'RIGHT'
align_y = 'BOTTOM'
elif position == 'BOTTOM_LEFT':
x = margin_h
y = margin_v
align_x = 'LEFT'
align_y = 'BOTTOM'
else: # CENTER
x = viewport_width // 2
y = viewport_height // 2
align_x = 'CENTER'
align_y = 'CENTER'
return x, y, align_x, align_y
def draw_legend_elements(self, legend_data: list, settings: dict, base_x: float, base_y: float, align_x: str, align_y: str, viewport_width: int, viewport_height: int):
"""Draws the individual legend elements with support for 3 color columns"""
try:
if not legend_data:
return
camera_props = self.get_camera_props()
if not camera_props:
return
# --- 2. GET VISIBILITY AND STYLE SETTINGS ---
show_start = settings.get('show_start_column', False)
show_active = settings.get('show_active_column', True)
show_end = settings.get('show_end_column', False)
show_start_title = settings.get('show_start_title', False)
show_active_title = settings.get('show_active_title', False)
show_end_title = settings.get('show_end_title', False)
print(f"[INFO] COLUMN VISIBILITY: start={show_start}, active={show_active}, end={show_end}")
print(f"[INFO] TITLE VISIBILITY: start_title={show_start_title}, active_title={show_active_title}, end_title={show_end_title}")
scale = settings.get('scale', 1.0)
font_size = int(14 * scale)
color_indicator_size = settings.get('color_indicator_size', 12.0) * scale
item_spacing = settings.get('item_spacing', 8.0) * scale
column_spacing = settings.get('column_spacing', 16.0) * scale
padding_h = settings.get('padding_h', 12.0) * scale
padding_v = settings.get('padding_v', 8.0) * scale
text_gap = 8 * scale
orientation = settings.get('orientation', 'VERTICAL')
auto_scale = getattr(camera_props, 'legend_hud_auto_scale', True)
# Configure font
blf.size(self.font_id, font_size)
# --- 3. CALCULATE CONTENT DIMENSIONS ---
colors_width = 0
num_visible_cols = sum([show_start, show_active, show_end])
if num_visible_cols > 0:
colors_width = (num_visible_cols * color_indicator_size) + max(0, num_visible_cols - 1) * column_spacing
_, row_height = blf.dimensions(self.font_id, "X")
item_widths = []
for item in legend_data:
text_width, _ = blf.dimensions(self.font_id, item['name'])
item_width = colors_width + text_gap + text_width
item_widths.append(item_width)
rows_of_indices = []
total_content_width = 0
if orientation == 'VERTICAL':
rows_of_indices = [[i] for i in range(len(legend_data))]
if item_widths:
total_content_width = max(item_widths)
else: # HORIZONTAL
max_width_prop = getattr(camera_props, 'legend_hud_max_width', 0.3)
max_width_px = viewport_width * max_width_prop
if legend_data:
if auto_scale:
rows_of_indices.append(list(range(len(legend_data))))
else:
current_row_indices = []
current_row_width = 0
for i, item_width in enumerate(item_widths):
if not current_row_indices or (current_row_width + item_spacing + item_width <= max_width_px):
current_row_indices.append(i)
current_row_width += item_width
if len(current_row_indices) > 1:
current_row_width += item_spacing
else:
rows_of_indices.append(current_row_indices)
current_row_indices = [i]
current_row_width = item_width
if current_row_indices:
rows_of_indices.append(current_row_indices)
if auto_scale:
total_content_width = sum(item_widths) + max(0, len(item_widths) - 1) * item_spacing
else:
max_row_width = 0
for row in rows_of_indices:
row_width = sum(item_widths[i] for i in row) + max(0, len(row) - 1) * item_spacing
max_row_width = max(max_row_width, row_width)
total_content_width = max_row_width
total_content_height = 0
if settings.get('show_title', True):
original_font_size = font_size
title_font_size = getattr(camera_props, 'legend_hud_title_font_size', 16.0) * scale
blf.size(self.font_id, int(title_font_size))
_, title_h = blf.dimensions(self.font_id, settings.get('title_text', 'Legend'))
blf.size(self.font_id, original_font_size) # Restore
total_content_height += title_h + item_spacing
if any([show_start_title and show_start, show_active_title and show_active, show_end_title and show_end]):
total_content_height += row_height + item_spacing
total_content_height += len(rows_of_indices) * (row_height + item_spacing)
if rows_of_indices:
total_content_height -= item_spacing
# --- 4. CALCULATE BACKGROUND GEOMETRY AND DRAW ---
bg_width = total_content_width + 2 * padding_h
bg_height = total_content_height + 2 * padding_v
if align_x == 'RIGHT':
bg_x = base_x - bg_width
elif align_x == 'CENTER':
bg_x = base_x - bg_width / 2
else: # LEFT
bg_x = base_x
if align_y == 'TOP':
bg_y = base_y - bg_height
elif align_y == 'CENTER':
bg_y = base_y - bg_height / 2
else: # BOTTOM
bg_y = base_y
self.draw_legend_background(bg_x, bg_y, bg_width, bg_height, settings)
# --- 5. DRAW CONTENT ---
content_x = bg_x + padding_h
current_y = bg_y + bg_height - padding_v
if settings.get('show_title', True):
title_font_size = getattr(camera_props, 'legend_hud_title_font_size', 16.0) * scale
blf.size(self.font_id, int(title_font_size))
title_text = settings.get('title_text', 'Legend')
title_color = settings.get('title_color', (1.0, 1.0, 1.0, 1.0))
if settings.get('text_shadow_enabled', True):
shadow_color = settings.get('text_shadow_color', (0.0, 0.0, 0.0, 0.8))
shadow_offset_x = settings.get('text_shadow_offset_x', 1.0)
shadow_offset_y = settings.get('text_shadow_offset_y', -1.0)
blf.color(self.font_id, *shadow_color)
blf.position(self.font_id, content_x + shadow_offset_x, current_y - row_height + shadow_offset_y, 0)
blf.draw(self.font_id, title_text)
blf.color(self.font_id, *title_color)
blf.position(self.font_id, content_x, current_y - row_height, 0)
blf.draw(self.font_id, title_text)
current_y -= row_height + item_spacing
blf.size(self.font_id, font_size) # Restore original font size
if any([show_start_title and show_start, show_active_title and show_active, show_end_title and show_end]):
self.draw_column_titles(content_x, current_y, color_indicator_size, column_spacing,
show_start, show_active, show_end,
show_start_title, show_active_title, show_end_title,
settings)
current_y -= item_spacing
# --- 6. DRAW PROFILE ROWS (UNIFIED LOGIC) ---
if rows_of_indices:
for row_indices in rows_of_indices:
current_y -= row_height
current_x = content_x
for item_index in row_indices:
self.draw_colortype_row(legend_data[item_index], current_x, current_y, color_indicator_size, column_spacing, show_start, show_active, show_end, settings)
if orientation == 'HORIZONTAL':
current_x += item_widths[item_index] + item_spacing
current_y -= item_spacing
except Exception as e:
print(f"[ERROR] Error drawing legend elements: {e}")
import traceback
traceback.print_exc()
def draw_column_titles(self, base_x: float, y: float, indicator_size: float, column_spacing: float,
show_start: bool, show_active: bool, show_end: bool,
show_start_title: bool, show_active_title: bool, show_end_title: bool, settings: dict):
"""Draws the titles of the Start/Active/End columns"""
try:
title_color = settings.get('title_color', (1.0, 1.0, 1.0, 1.0))
current_x = base_x
if show_start and show_start_title:
text_width, _ = blf.dimensions(self.font_id, "S")
title_x = current_x + (indicator_size - text_width) / 2
blf.color(self.font_id, *title_color)
blf.position(self.font_id, title_x, y, 0)
blf.draw(self.font_id, "S")
current_x += indicator_size + column_spacing
if show_active and show_active_title:
text_width, _ = blf.dimensions(self.font_id, "A")
title_x = current_x + (indicator_size - text_width) / 2
blf.color(self.font_id, *title_color)
blf.position(self.font_id, title_x, y, 0)
blf.draw(self.font_id, "A")
current_x += indicator_size + column_spacing
if show_end and show_end_title:
text_width, _ = blf.dimensions(self.font_id, "E")
title_x = current_x + (indicator_size - text_width) / 2
blf.color(self.font_id, *title_color)
blf.position(self.font_id, title_x, y, 0)
blf.draw(self.font_id, "E")
except Exception as e:
print(f"[ERROR] Error drawing column titles: {e}")
def draw_colortype_row(self, legend_item: dict, base_x: float, y: float, indicator_size: float, column_spacing: float,
show_start: bool, show_active: bool, show_end: bool, settings: dict):
"""Draws a profile row with its corresponding colors"""
try:
current_x = base_x
text_color = settings.get('text_color', (1.0, 1.0, 1.0, 1.0))
_, row_height = blf.dimensions(self.font_id, "X")
# Draw color indicators
if show_start:
self.draw_color_indicator(current_x, y, indicator_size, legend_item['start_color'])
current_x += indicator_size + column_spacing
if show_active:
self.draw_color_indicator(current_x, y, indicator_size, legend_item['active_color'])
current_x += indicator_size + column_spacing
if show_end:
self.draw_color_indicator(current_x, y, indicator_size, legend_item['end_color'])
current_x += indicator_size + column_spacing
# Draw text with shadow if enabled
text_x = current_x - column_spacing + 8 # Small gap after last color
if settings.get('text_shadow_enabled', True):
shadow_color = settings.get('text_shadow_color', (0.0, 0.0, 0.0, 0.8))
shadow_offset_x = settings.get('text_shadow_offset_x', 1.0)
shadow_offset_y = settings.get('text_shadow_offset_y', -1.0)
blf.color(self.font_id, *shadow_color)
blf.position(self.font_id, text_x + shadow_offset_x, y + shadow_offset_y, 0)
blf.draw(self.font_id, legend_item['name'])
blf.color(self.font_id, *text_color)
blf.position(self.font_id, text_x, y, 0)
blf.draw(self.font_id, legend_item['name'])
except Exception as e:
print(f"[ERROR] Error drawing colortype row: {e}")
def draw_color_indicator(self, x: float, y: float, size: float, color: tuple):
"""Draws a color indicator circle"""
try:
import math
radius = size / 2.0
center_x = x + radius
center_y = y + radius
segments = 32
vertices = [(center_x, center_y)] # Center vertex
for i in range(segments + 1):
angle = 2 * math.pi * i / segments
vx = center_x + radius * math.cos(angle)
vy = center_y + radius * math.sin(angle)
vertices.append((vx, vy))
shader = gpu.shader.from_builtin('UNIFORM_COLOR')
batch = batch_for_shader(shader, 'TRI_FAN', {"pos": vertices})
gpu.state.blend_set('ALPHA')
shader.bind()
shader.uniform_float("color", color)
batch.draw(shader)
gpu.state.blend_set('NONE')
except Exception as e:
print(f"[ERROR] Error drawing color indicator: {e}")
def draw_legend_background(self, x: float, y: float, width: float, height: float, settings: dict):
"""Draws the legend background with configurable effects"""
try:
background_color = settings.get('background_color', (0.0, 0.0, 0.0, 0.8))
border_radius = settings.get('border_radius', 5.0)
if border_radius > 0:
self.draw_rounded_rect(x, y, width, height, background_color, border_radius)
else:
self.draw_gpu_rect(x, y, width, height, background_color)
except Exception as e:
print(f"[ERROR] Error drawing legend background: {e}")
def draw_gpu_rect(self, x, y, w, h, color):
"""Draws a simple rectangle"""
try:
vertices = [(x, y), (x + w, y), (x + w, y + h), (x, y + h)]
indices = [(0, 1, 2), (2, 3, 0)]
shader = gpu.shader.from_builtin('UNIFORM_COLOR')
batch = batch_for_shader(shader, 'TRIS', {"pos": vertices}, indices=indices)
gpu.state.blend_set('ALPHA')
shader.bind()
shader.uniform_float("color", color)
batch.draw(shader)
gpu.state.blend_set('NONE')
except Exception as e:
print(f"Error drawing GPU rect: {e}")
def draw_rounded_rect(self, x, y, w, h, color, radius):
"""Draws a rectangle with rounded corners using approximation with multiple triangles."""
try:
from math import cos, sin
# Limit the radius to half the smaller dimension
max_radius = min(w, h) / 2.0
radius = min(radius, max_radius)
if radius <= 0:
# If the radius is 0 or negative, draw a normal rectangle
self.draw_gpu_rect(x, y, w, h, color)
return
vertices = []
indices = []
# Number of segments for the rounded corners (more = smoother)
segments = max(4, int(radius / 2)) # Adjust according to radius size
# Center of the rectangle to facilitate calculations
center_x = x + w / 2
center_y = y + h / 2
# Create vertices for a rounded rectangle
# Lower left corner
for i in range(segments + 1):
angle = 3.14159 + i * (3.14159 / 2) / segments # 180° to 270°
vx = x + radius + radius * cos(angle)
vy = y + radius + radius * sin(angle)
vertices.append((vx, vy))
# Lower right corner
for i in range(segments + 1):
angle = 3.14159 * 1.5 + i * (3.14159 / 2) / segments # 270° to 360°
vx = x + w - radius + radius * cos(angle)
vy = y + radius + radius * sin(angle)
vertices.append((vx, vy))
# Upper right corner
for i in range(segments + 1):
angle = 0 + i * (3.14159 / 2) / segments # 0° to 90°
vx = x + w - radius + radius * cos(angle)
vy = y + h - radius + radius * sin(angle)
vertices.append((vx, vy))
# Upper left corner
for i in range(segments + 1):
angle = 3.14159 / 2 + i * (3.14159 / 2) / segments # 90° to 180°
vx = x + radius + radius * cos(angle)
vy = y + h - radius + radius * sin(angle)
vertices.append((vx, vy))
# Create triangles using the center as a common point
center_vertex_index = len(vertices)
vertices.append((center_x, center_y))
# Connect all perimeter vertices with the center
total_perimeter_vertices = len(vertices) - 1
for i in range(total_perimeter_vertices):
next_i = (i + 1) % total_perimeter_vertices
indices.append((center_vertex_index, i, next_i))
# Draw the rounded rectangle
shader = gpu.shader.from_builtin('UNIFORM_COLOR')
batch = batch_for_shader(shader, 'TRIS', {"pos": vertices}, indices=indices)
gpu.state.blend_set('ALPHA')
shader.bind()
shader.uniform_float("color", color)
batch.draw(shader)
except Exception as e:
print(f"Error drawing rounded rectangle: {e}")
# Fallback to normal rectangle
self.draw_gpu_rect(x, y, w, h, color)
def get_camera_props(self):
"""Get camera properties for Legend HUD settings"""
try:
import bonsai.tool as tool
animation_props = tool.Sequence.get_animation_props()
if hasattr(animation_props, 'camera_orbit') and animation_props.camera_orbit:
return animation_props.camera_orbit
return None
except Exception as e:
print(f"[ERROR] Error getting camera props: {e}")
return None
def get_active_colortype_legend_data(self, include_hidden=False):
"""Get colortype legend data with caching and visibility filtering"""
try:
import bonsai.tool as tool
# Get animation properties
anim_props = tool.Sequence.get_animation_props()
if not hasattr(anim_props, 'animation_group_stack') or not anim_props.animation_group_stack:
print("[INFO] No animation group stack found")
self._legend_data_cache = []
return []
# Find the first enabled group (active group)
current_active_group = None
print("[INFO] HUD: Checking animation group stack for the active group:")
for i, group_item in enumerate(anim_props.animation_group_stack):
enabled = getattr(group_item, 'enabled', False)
group_name = getattr(group_item, 'group', '')
print(f" {i}: Group '{group_name}' enabled={enabled}") # This line is fine
if enabled and current_active_group is None:
current_active_group = group_name
print(f"[INFO] HUD: Selected active group: {current_active_group}")
break
# FALLBACK: If no group is enabled, default to "DEFAULT"
if current_active_group is None:
print("[ERROR] HUD: No active group found (no groups enabled)")
current_active_group = "DEFAULT"
print("[INFO] HUD: Falling back to 'DEFAULT' group.")
# AUTOMATIC DETECTION: If the active group changed, clear hidden profiles
if self._last_active_group != current_active_group:
print(f"[INFO] AUTO-DETECTION: Active group changed from '{self._last_active_group}' to '{current_active_group}'")
self._last_active_group = current_active_group
# Auto-clear hidden profiles to show the new active group
try:
camera_props = self.get_camera_props()
if camera_props:
camera_props.legend_hud_visible_colortypes = ""
print("[INFO] AUTO-CLEARED: legend_hud_visible_colortypes (showing all colortypes from new active group)")
except Exception as e:
print(f"[WARNING] Could not auto-clear visible colortypes: {e}")
current_timestamp = time.time()
# Get visibility settings to include in the cache comparison
camera_props = self.get_camera_props()
visible_colortypes_str = getattr(camera_props, 'legend_hud_visible_colortypes', '') if camera_props else ''
# The cache depends on the active group, visible profiles and whether we include hidden ones
cache_key = (current_active_group, visible_colortypes_str, include_hidden)
if (self._legend_data_cache is not None and
self._cached_animation_groups == cache_key and
current_timestamp - self._cache_timestamp < 1.0):
return self._legend_data_cache
print(f"[INFO] Refreshing legend data cache for active group: {current_active_group} (include_hidden={include_hidden})")
hidden_colortypes = set()
if not include_hidden:
if visible_colortypes_str.strip():
hidden_colortypes = {p.strip() for p in visible_colortypes_str.split(',') if p.strip()}
legend_data = []
# The active group should never be None at this point due to FALLBACK
if not current_active_group:
print("[ERROR] CRITICAL: No active group after FALLBACK - this should not happen")
self._legend_data_cache = []
return []
active_group = current_active_group
print(f"[INFO] Found active group (first enabled): {active_group}")
# Get colortype data with visibility filtering
legend_data = self._extract_colortype_data(active_group, include_hidden, hidden_colortypes)
# Update cache
self._legend_data_cache = legend_data
self._cached_animation_groups = cache_key
self._cache_timestamp = current_timestamp
print(f"[INFO] Legend cache updated for group '{active_group}': {len(legend_data)} items")
return legend_data
except Exception as e:
print(f"[ERROR] Error getting legend data: {e}")
import traceback
traceback.print_exc()
return []
def _extract_colortype_data(self, active_group, include_hidden=False, hidden_colortypes=None):
"""Extract colortype data from the active group with visibility filtering"""
try:
import bonsai.tool as tool
if hidden_colortypes is None:
hidden_colortypes = set()
# CRITICAL: Import UnifiedColorTypeManager from the correct location
try:
from ..prop.color_manager_prop import UnifiedColorTypeManager
except ImportError:
from ..prop import UnifiedColorTypeManager
context = bpy.context
# SPECIAL CASE: For DEFAULT group, ensure ALL colortypes are loaded
if active_group == "DEFAULT":
print(f"[INFO] Processing DEFAULT group - ensuring all colortypes loaded")
try:
UnifiedColorTypeManager.ensure_default_colortypes(context)
# Force reload to get complete list
UnifiedColorTypeManager._invalidate_cache(context)
except Exception as e:
print(f"[WARNING] Could not ensure DEFAULT colortypes: {e}")
# Get colortypes for the active group
group_colortypes = UnifiedColorTypeManager.get_group_colortypes(context, active_group)
if not group_colortypes:
print(f"[ERROR] No colortypes found for group '{active_group}'")
self._legend_data_cache = []
return []
legend_data = []
# Process each profile of the active group
for colortype_name, colortype_data in group_colortypes.items():
if colortype_name in hidden_colortypes:
continue
# Get the 3 colors: start, active, end
start_color = self.get_colortype_color_for_state(colortype_data, 'start')
active_color = self.get_colortype_color_for_state(colortype_data, 'in_progress')
end_color = self.get_colortype_color_for_state(colortype_data, 'end')
legend_entry = {
'name': colortype_name,
'start_color': start_color,
'active_color': active_color,
'end_color': end_color,
'group': active_group, # Use active_group instead of group_name
'active': True, # It's in the active stack
}
legend_data.append(legend_entry)
# Sort alphabetically for consistent display
legend_data.sort(key=lambda x: x['name'])
print(f"[INFO] Processed {len(legend_data)} colortypes from active group '{active_group}'")
if hidden_colortypes:
print(f"🙈 Hidden colortypes: {list(hidden_colortypes)}")
return legend_data
except Exception as e:
print(f"[ERROR] Error extracting colortype data: {e}")
import traceback
traceback.print_exc()
return []
def get_colortype_color_for_state(self, colortype_data: dict, state: str) -> tuple:
"""Gets the color of a profile for a specific state (start/in_progress/end)"""
try:
# Mapping of states to color fields
color_field_mapping = {
'start': 'start_color',
'in_progress': 'in_progress_color',
'active': 'in_progress_color', # Alias
'end': 'end_color',
'finished': 'end_color', # Alias
}
color_field = color_field_mapping.get(state, 'in_progress_color')
# Get the profile color
if color_field in colortype_data:
color = colortype_data[color_field]
# Ensure valid RGBA format
if isinstance(color, (list, tuple)) and len(color) >= 3:
if len(color) == 3:
return (*color, 1.0) # Add alpha
else:
return tuple(color[:4]) # Limit to RGBA
# Fallback colors
fallback_colors = {
'start': (1.0, 1.0, 1.0, 1.0), # White
'in_progress': (0.0, 1.0, 0.0, 1.0), # Green
'end': (0.0, 0.8, 0.0, 1.0) # Dark green
}
return fallback_colors.get(state, (0.5, 0.5, 0.5, 1.0)) # Gray fallback
except Exception as e:
print(f"[ERROR] Error getting colortype color for state '{state}': {e}")
return (0.5, 0.5, 0.5, 1.0) # Gray fallback
@@ -0,0 +1,352 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import blf
import gpu
from gpu_extras.batch import batch_for_shader
class TextHUD:
"""Specialized component for displaying text-based schedule information"""
def __init__(self, font_id):
"""Initialize TextHUD with shared font"""
self.font_id = font_id
print(f"📝 TextHUD initialized with font_id={font_id}")
def draw(self, data, settings, viewport_width, viewport_height):
"""Draw text HUD elements"""
try:
# Calculate position for text HUD
text_x, text_y, align_x, align_y = self.calculate_position(
viewport_width, viewport_height, settings
)
# Format text lines to display
lines_to_draw = self.format_text_lines(data, settings)
if not lines_to_draw:
return
# Set font size
font_size = int(settings.get('scale', 1.0) * 16)
blf.size(self.font_id, font_size)
# Calculate dimensions for background
line_dims = [blf.dimensions(self.font_id, line) for line in lines_to_draw]
max_width = max(w for w, h in line_dims) if line_dims else 0
total_text_height = sum(h for w, h in line_dims) + max(0, len(lines_to_draw) - 1) * (settings.get('spacing', 0.02) * viewport_height)
# Draw background with effects
self.draw_background_with_effects(
text_x, text_y, max_width, total_text_height,
align_x, align_y, settings
)
# Draw text lines
self.draw_text_lines(
lines_to_draw, line_dims, text_x, text_y,
align_x, align_y, settings, viewport_height
)
except Exception as e:
print(f"[ERROR] Error in TextHUD.draw: {e}")
import traceback
traceback.print_exc()
def calculate_position(self, viewport_width, viewport_height, settings):
"""Calculate position of the HUD in pixels"""
margin_h = int(viewport_width * settings.get('margin_h', 0.02))
margin_v = int(viewport_height * settings.get('margin_v', 0.02))
position = settings.get('position', 'TOP_RIGHT')
if position == 'TOP_RIGHT':
x = viewport_width - margin_h
y = viewport_height - margin_v
align_x = 'RIGHT'
align_y = 'TOP'
elif position == 'TOP_LEFT':
x = margin_h
y = viewport_height - margin_v
align_x = 'LEFT'
align_y = 'TOP'
elif position == 'BOTTOM_RIGHT':
x = viewport_width - margin_h
y = margin_v
align_x = 'RIGHT'
align_y = 'BOTTOM'
else: # BOTTOM_LEFT
x = margin_h
y = margin_v
align_x = 'LEFT'
align_y = 'BOTTOM'
return x, y, align_x, align_y
def format_text_lines(self, data, settings):
"""Format the text lines to be displayed"""
if not data:
return ["No Schedule Data"]
lines = []
# Add schedule name
lines.append(f"Schedule: {data.get('schedule_name', 'Unknown')}")
# Add date if enabled
if settings.get('hud_show_date', True):
current_date = data.get('current_date')
if current_date:
lines.append(f"{current_date.strftime('%d/%m/%Y')}")
# Add week if enabled
if settings.get('hud_show_week', True):
week_number = data.get('week_number')
if week_number is not None:
lines.append(f"Week {week_number}")
# Add day if enabled
if settings.get('hud_show_day', True):
elapsed_days = data.get('elapsed_days')
if elapsed_days is not None:
lines.append(f"Day {elapsed_days}")
# Add progress if enabled
if settings.get('hud_show_progress', True):
progress_pct = data.get('progress_pct')
if progress_pct is not None:
lines.append(f"Progress: {progress_pct}%")
return lines
def draw_background_with_effects(self, x, y, width, height, align_x, align_y, settings):
"""Draw background with effects (shadow, border, gradient, etc.)"""
try:
# Get background settings
bg_enabled = settings.get('background_enabled', True)
if not bg_enabled:
return
padding_h = settings.get('padding_h', 10.0)
padding_v = settings.get('padding_v', 8.0)
# Calculate background rectangle
if align_x == 'RIGHT':
bg_x = x - width - padding_h * 2
else:
bg_x = x - padding_h
if align_y == 'TOP':
bg_y = y - height - padding_v * 2
else:
bg_y = y - padding_v
bg_width = width + padding_h * 2
bg_height = height + padding_v * 2
# Draw shadow if enabled
shadow_enabled = settings.get('shadow_enabled', False)
if shadow_enabled:
self.draw_shadow(bg_x, bg_y, bg_width, bg_height, settings)
# Draw main background
bg_color = settings.get('background_color', (0.0, 0.0, 0.0, 0.5))
gradient_enabled = settings.get('gradient_enabled', False)
if gradient_enabled:
self.draw_gradient_background(bg_x, bg_y, bg_width, bg_height, settings)
else:
self.draw_solid_background(bg_x, bg_y, bg_width, bg_height, bg_color)
# Draw border if enabled
border_enabled = settings.get('border_enabled', False)
if border_enabled:
border_width = settings.get('border_width', 1.0)
border_color = settings.get('border_color', (1.0, 1.0, 1.0, 1.0))
self.draw_border(bg_x, bg_y, bg_width, bg_height, border_width, border_color)
except Exception as e:
print(f"[ERROR] Error drawing background: {e}")
def draw_text_lines(self, lines, line_dims, base_x, base_y, align_x, align_y, settings, viewport_height):
"""Draw the actual text lines"""
try:
padding_h = settings.get('padding_h', 10.0)
padding_v = settings.get('padding_v', 8.0)
# Calculate starting Y position
if align_y == 'TOP':
current_y = base_y - padding_v
if line_dims:
current_y -= line_dims[0][1]
else:
total_height = sum(h for w, h in line_dims) + max(0, len(lines) - 1) * (settings.get('spacing', 0.02) * viewport_height)
current_y = base_y + padding_v + total_height
if line_dims:
current_y -= line_dims[0][1]
# Draw each line
for i, line in enumerate(lines):
if align_x == 'RIGHT':
text_x = base_x - padding_h
self.draw_text_with_shadow(line, text_x, current_y, settings, 'RIGHT')
else:
text_x = base_x + padding_h
self.draw_text_with_shadow(line, text_x, current_y, settings, 'LEFT')
# Move to next line position
if i < len(lines) - 1:
spacing = settings.get('spacing', 0.02) * viewport_height
current_y -= (spacing + line_dims[i + 1][1])
except Exception as e:
print(f"[ERROR] Error drawing text lines: {e}")
def draw_text_with_shadow(self, text, x, y, settings, alignment='LEFT'):
"""Draw text with optional shadow effect"""
try:
# Get text settings
text_color = settings.get('text_color', (1.0, 1.0, 1.0, 1.0))
shadow_enabled = settings.get('text_shadow_enabled', False)
# Draw shadow if enabled
if shadow_enabled:
shadow_offset = settings.get('text_shadow_offset', (1.0, -1.0))
shadow_color = settings.get('text_shadow_color', (0.0, 0.0, 0.0, 0.8))
# Set shadow position
shadow_x = x + shadow_offset[0]
shadow_y = y + shadow_offset[1]
# Draw shadow text
blf.color(self.font_id, *shadow_color)
if alignment == 'RIGHT':
# Calculate text width for right alignment
text_width = blf.dimensions(self.font_id, text)[0]
blf.position(self.font_id, shadow_x - text_width, shadow_y, 0)
else:
blf.position(self.font_id, shadow_x, shadow_y, 0)
blf.draw(self.font_id, text)
# Draw main text
blf.color(self.font_id, *text_color)
if alignment == 'RIGHT':
# Calculate text width for right alignment
text_width = blf.dimensions(self.font_id, text)[0]
blf.position(self.font_id, x - text_width, y, 0)
else:
blf.position(self.font_id, x, y, 0)
blf.draw(self.font_id, text)
except Exception as e:
print(f"[ERROR] Error drawing text with shadow: {e}")
def draw_solid_background(self, x, y, width, height, color):
"""Draw solid colored background"""
try:
vertices = [
(x, y), (x + width, y),
(x + width, y + height), (x, y + height)
]
indices = [(0, 1, 2), (2, 3, 0)]
shader = gpu.shader.from_builtin('UNIFORM_COLOR')
batch = batch_for_shader(shader, 'TRIS', {"pos": vertices}, indices=indices)
gpu.state.blend_set('ALPHA')
shader.bind()
shader.uniform_float("color", color)
batch.draw(shader)
gpu.state.blend_set('NONE')
except Exception as e:
print(f"[ERROR] Error drawing solid background: {e}")
def draw_gradient_background(self, x, y, width, height, settings):
"""Draw gradient background"""
try:
# Get gradient colors
gradient_top = settings.get('gradient_top_color', (0.2, 0.2, 0.2, 0.8))
gradient_bottom = settings.get('gradient_bottom_color', (0.0, 0.0, 0.0, 0.8))
# Create gradient effect by drawing multiple rectangles
steps = 20
step_height = height / steps
for i in range(steps):
# Interpolate color
factor = i / (steps - 1)
color = (
gradient_bottom[0] + (gradient_top[0] - gradient_bottom[0]) * factor,
gradient_bottom[1] + (gradient_top[1] - gradient_bottom[1]) * factor,
gradient_bottom[2] + (gradient_top[2] - gradient_bottom[2]) * factor,
gradient_bottom[3] + (gradient_top[3] - gradient_bottom[3]) * factor,
)
# Draw step
step_y = y + i * step_height
self.draw_solid_background(x, step_y, width, step_height, color)
except Exception as e:
print(f"[ERROR] Error drawing gradient background: {e}")
def draw_shadow(self, x, y, width, height, settings):
"""Draw drop shadow for background"""
try:
shadow_offset = settings.get('shadow_offset', (2.0, -2.0))
shadow_color = settings.get('shadow_color', (0.0, 0.0, 0.0, 0.3))
shadow_blur = settings.get('shadow_blur', 0) # Simple shadow for now
shadow_x = x + shadow_offset[0]
shadow_y = y + shadow_offset[1]
# Draw simple shadow (no blur for performance)
self.draw_solid_background(shadow_x, shadow_y, width, height, shadow_color)
except Exception as e:
print(f"[ERROR] Error drawing shadow: {e}")
def draw_border(self, x, y, width, height, border_width, border_color):
"""Draw border around background"""
try:
# Draw border as four lines
vertices = [
# Top line
(x, y + height), (x + width, y + height),
# Right line
(x + width, y + height), (x + width, y),
# Bottom line
(x + width, y), (x, y),
# Left line
(x, y), (x, y + height)
]
shader = gpu.shader.from_builtin('UNIFORM_COLOR')
batch = batch_for_shader(shader, 'LINES', {"pos": vertices})
gpu.state.blend_set('ALPHA')
gpu.state.line_width_set(border_width)
shader.bind()
shader.uniform_float("color", border_color)
batch.draw(shader)
gpu.state.line_width_set(1.0) # Reset line width
gpu.state.blend_set('NONE')
except Exception as e:
print(f"[ERROR] Error drawing border: {e}")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,273 @@
# File: .../sequence/operators/__init__.py
# Description: Central registration point for all sequence operators.
import bpy
from . import camera_operators
from . import io_operators
from . import animation_operators
from . import navigation_operators
from . import config_operators
from . import schedule_task_operators
from . import work_schedule_operators
from . import filter_operators
from . import schedule_assignment_operators
from . import schedule_calendar_operators
from . import hud_operators
from . import copy_sync_3d_operators
from . import schedule_sequence_operators
from . import schedule_operators
from . import work_plan_operators
from . import color_scheme_operators
from .animation_operators import CreateAnimation, ClearAnimation, AddAnimationTaskType, RemoveAnimationTaskType, ClearPreviousAnimation, ClearPreviousSnapshot, SyncAnimationByDate
# A single tuple containing all operator classes to be registered.
classes = (
# from camera_operators.py
camera_operators.RefreshCameraSelectors,
camera_operators.ForceCameraPropertyUpdate,
camera_operators.SetActiveAnimationCamera,
camera_operators.SetActiveSnapshotCamera,
camera_operators.TestCameraDetection,
camera_operators.DebugListAllCameras,
camera_operators.ResetCameraSettings,
camera_operators.Align4DCameraToView,
camera_operators.UpdateCameraOnly,
camera_operators.AddCameraByMode,
camera_operators.AddSnapshotCamera,
camera_operators.AlignSnapshotCameraToView,
camera_operators.AddAnimationCamera,
camera_operators.DeleteAnimationCamera,
camera_operators.DeleteSnapshotCamera,
# from io_operators.py
io_operators.ImportWorkScheduleCSV,
io_operators.ImportP6,
io_operators.ImportP6XER,
io_operators.ImportPP,
io_operators.ImportMSP,
io_operators.ExportMSP,
io_operators.ExportP6,
# from schedule_task_operators.py
schedule_task_operators.LoadTaskProperties,
schedule_task_operators.AddTask,
schedule_task_operators.AddSummaryTask,
schedule_task_operators.ExpandTask,
schedule_task_operators.ContractTask,
schedule_task_operators.RemoveTask,
schedule_task_operators.EnableEditingTask,
schedule_task_operators.DisableEditingTask,
schedule_task_operators.EditTask,
schedule_task_operators.CopyTaskAttribute,
schedule_task_operators.CalculateTaskDuration,
schedule_task_operators.ExpandAllTasks,
schedule_task_operators.ContractAllTasks,
schedule_task_operators.CopyTask,
schedule_task_operators.GoToTask,
schedule_task_operators.ReorderTask,
schedule_task_operators.RefreshTaskOutputCounts,
# from work_schedule_operators.py
work_schedule_operators.AssignWorkSchedule,
work_schedule_operators.UnassignWorkSchedule,
work_schedule_operators.AddWorkSchedule,
work_schedule_operators.EditWorkSchedule,
work_schedule_operators.RemoveWorkSchedule,
work_schedule_operators.CopyWorkSchedule,
work_schedule_operators.EnableEditingWorkSchedule,
work_schedule_operators.EnableEditingWorkScheduleTasks,
work_schedule_operators.DisableEditingWorkSchedule,
work_schedule_operators.SortWorkScheduleByIdAsc,
work_schedule_operators.VisualiseWorkScheduleDateRange,
work_schedule_operators.SelectWorkScheduleProducts,
work_schedule_operators.SelectUnassignedWorkScheduleProducts,
# from schedule_assignment_operators.py
schedule_assignment_operators.AssignPredecessor,
schedule_assignment_operators.AssignSuccessor,
schedule_assignment_operators.UnassignPredecessor,
schedule_assignment_operators.UnassignSuccessor,
schedule_assignment_operators.AssignProduct,
schedule_assignment_operators.UnassignProduct,
schedule_assignment_operators.AssignProcess,
schedule_assignment_operators.UnassignProcess,
schedule_assignment_operators.AssignRecurrencePattern,
schedule_assignment_operators.UnassignRecurrencePattern,
schedule_assignment_operators.AssignLagTime,
schedule_assignment_operators.UnassignLagTime,
schedule_assignment_operators.SelectTaskRelatedProducts,
schedule_assignment_operators.SelectTaskRelatedInputs,
schedule_assignment_operators.LoadProductTasks,
# from schedule_calendar_operators.py
schedule_calendar_operators.AddWorkCalendar,
schedule_calendar_operators.EditWorkCalendar,
schedule_calendar_operators.RemoveWorkCalendar,
schedule_calendar_operators.EnableEditingWorkCalendar,
schedule_calendar_operators.DisableEditingWorkCalendar,
schedule_calendar_operators.EnableEditingWorkCalendarTimes,
schedule_calendar_operators.AddWorkTime,
schedule_calendar_operators.EnableEditingWorkTime,
schedule_calendar_operators.DisableEditingWorkTime,
schedule_calendar_operators.EditWorkTime,
schedule_calendar_operators.RemoveWorkTime,
schedule_calendar_operators.AddTimePeriod,
schedule_calendar_operators.RemoveTimePeriod,
schedule_calendar_operators.EnableEditingTaskTime,
schedule_calendar_operators.EditTaskTime,
schedule_calendar_operators.DisableEditingTaskTime,
schedule_calendar_operators.EnableEditingTaskCalendar,
schedule_calendar_operators.EditTaskCalendar,
schedule_calendar_operators.RemoveTaskCalendar,
# from filter_operators.py
filter_operators.EnableStatusFilters,
filter_operators.DisableStatusFilters,
filter_operators.ActivateStatusFilters,
filter_operators.SelectStatusFilter,
filter_operators.AssignStatus,
filter_operators.AddTaskFilter,
filter_operators.RemoveTaskFilter,
filter_operators.ClearAllTaskFilters,
filter_operators.ApplyTaskFilters,
filter_operators.ApplyLookaheadFilter,
filter_operators.UpdateSavedFilterSet,
filter_operators.SaveFilterSet,
filter_operators.LoadFilterSet,
filter_operators.RemoveFilterSet,
filter_operators.ExportFilterSet,
filter_operators.ImportFilterSet,
filter_operators.Bonsai_DatePicker,
filter_operators.FilterDatePicker,
# from hud_operators.py
hud_operators.ArrangeScheduleTexts,
hud_operators.Fix3DTextAlignment,
hud_operators.SetupTextHUD,
hud_operators.ClearTextHUD,
hud_operators.UpdateTextHUDPositions,
hud_operators.UpdateTextHUDScale,
hud_operators.ToggleTextHUD,
hud_operators.Setup3DLegendHUD,
hud_operators.Clear3DLegendHUD,
hud_operators.Update3DLegendHUD,
hud_operators.Toggle3DLegendHUD,
hud_operators.EnableScheduleHUD,
hud_operators.DisableScheduleHUD,
hud_operators.ToggleScheduleHUD,
hud_operators.RefreshScheduleHUD,
hud_operators.LegendHudcolortypeScrollUp,
hud_operators.LegendHudcolortypeScrollDown,
hud_operators.LegendHudTogglecolortypeVisibility,
# from copy_sync_3d_operators.py
copy_sync_3d_operators.Copy3D,
copy_sync_3d_operators.Sync3D,
copy_sync_3d_operators.SnapshotWithcolortypesFixed,
copy_sync_3d_operators.SnapshotWithcolortypes,
# from schedule_sequence_operators.py
schedule_sequence_operators.EnableEditingTaskSequence,
schedule_sequence_operators.EnableEditingSequenceAttributes,
schedule_sequence_operators.EditSequenceAttributes,
schedule_sequence_operators.DisableEditingSequence,
schedule_sequence_operators.EnableEditingSequenceTimeLag,
schedule_sequence_operators.EditSequenceTimeLag,
# from schedule_operators.py
schedule_operators.RecalculateSchedule,
schedule_operators.GenerateGanttChart,
schedule_operators.AddTaskColumn,
schedule_operators.SetupDefaultTaskColumns,
schedule_operators.RemoveTaskColumn,
schedule_operators.SetTaskSortColumn,
schedule_operators.CreateBaseline,
schedule_operators.CalculateScheduleVariance,
schedule_operators.ClearScheduleVariance,
schedule_operators.DeactivateVarianceColorMode,
schedule_operators.RefreshTask3DCounts,
schedule_operators.AddTaskBars,
schedule_operators.ClearTaskBars,
schedule_operators.GuessDateRange,
# from work_plan_operators.py
work_plan_operators.AddWorkPlan,
work_plan_operators.EditWorkPlan,
work_plan_operators.RemoveWorkPlan,
work_plan_operators.EnableEditingWorkPlan,
work_plan_operators.DisableEditingWorkPlan,
work_plan_operators.EnableEditingWorkPlanSchedules,
# from color_scheme_operators.py
color_scheme_operators.AddAnimationColorSchemes,
color_scheme_operators.RemoveAnimationColorSchemes,
color_scheme_operators.SaveAnimationColorSchemesSetInternal,
color_scheme_operators.LoadAnimationColorSchemesSetInternal,
color_scheme_operators.RemoveAnimationColorSchemesSetInternal,
color_scheme_operators.ExportAnimationColorSchemesSetToFile,
color_scheme_operators.ImportAnimationColorSchemesSetFromFile,
color_scheme_operators.CleanupTaskcolortypeMappings,
color_scheme_operators.UpdateActivecolortypeGroup,
color_scheme_operators.InitializeColorTypeSystem,
color_scheme_operators.BIM_OT_init_default_all_tasks,
color_scheme_operators.CopyTaskCustomcolortypeGroup,
color_scheme_operators.LoadDefaultAnimationColors,
color_scheme_operators.SaveAnimationColorScheme,
color_scheme_operators.LoadAnimationColorScheme,
color_scheme_operators.ANIM_OT_group_stack_add,
color_scheme_operators.ANIM_OT_group_stack_remove,
color_scheme_operators.ANIM_OT_group_stack_move,
color_scheme_operators.VerifyCustomGroupsExclusion,
color_scheme_operators.ShowcolortypeUIState,
color_scheme_operators.BIM_OT_cleanup_colortype_groups,
# from animation_operators.py
animation_operators.CreateAnimation,
animation_operators.ClearAnimation,
animation_operators.AddAnimationTaskType,
animation_operators.RemoveAnimationTaskType,
animation_operators.ClearPreviousAnimation,
animation_operators.ClearPreviousSnapshot,
animation_operators.SyncAnimationByDate,
# from navigation_operators.py
navigation_operators.NavigateColumnsLeft,
navigation_operators.NavigateColumnsRight,
navigation_operators.NavigateColumnsHome,
navigation_operators.NavigateColumnsEnd,
# from config_operators.py
config_operators.VisualiseWorkScheduleDate,
config_operators.LoadAndActivatecolortypeGroup,
config_operators.SetupDefaultcolortypes,
config_operators.UpdateDefaultcolortypeColors,
config_operators.BIM_OT_verify_colortype_json,
config_operators.BIM_OT_fix_colortype_hide_at_end_immediate,
config_operators.RefreshSnapshotTexts,
config_operators.CreateStaticSnapshotTexts,
config_operators.BIM_OT_show_performance_stats,
config_operators.BIM_OT_clear_performance_cache,
)
def register():
"""Registers all operator classes."""
for cls in classes:
bpy.utils.register_class(cls)
def unregister():
"""Unregisters all operator classes in reverse order."""
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
# Explicit imports for main module access.
ExportP6 = io_operators.ExportP6
ExportMSP = io_operators.ExportMSP
ImportWorkScheduleCSV = io_operators.ImportWorkScheduleCSV
ImportP6 = io_operators.ImportP6
ImportP6XER = io_operators.ImportP6XER
ImportPP = io_operators.ImportPP
ImportMSP = io_operators.ImportMSP
ResetCameraSettings = camera_operators.ResetCameraSettings
@@ -0,0 +1,873 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import bpy
import bonsai.tool as tool
import ifcopenshell.util.sequence
from .. import hud as hud_overlay
from datetime import datetime, timedelta
from .operator import snapshot_all_ui_state
from .schedule_task_operators import restore_all_ui_state
# === Animation operators ===
class CreateAnimation(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.create_animation"
bl_label = "Create 4D Animation"
bl_options = {"REGISTER", "UNDO"}
preserve_current_frame: bpy.props.BoolProperty(default=False)
def _execute(self, context):
import time
total_start_time = time.time()
print("🚨🚨🚨 OPERATOR CreateAnimation._execute STARTED 🚨🚨🚨")
stored_frame = context.scene.frame_current
work_schedule = tool.Sequence.get_active_work_schedule()
anim_props = tool.Sequence.get_animation_props()
if not work_schedule:
self.report({'ERROR'}, "No active work schedule found.")
return {'CANCELLED'}
settings = _get_animation_settings(context)
print("🚀 STARTING CORRECTED 4D ANIMATION CREATION") # This seems to be a debug print, I'll leave it as is but it could be translated to "STARTING CORRECTED 4D ANIMATION CREATION"
# AUTOFIX: Ensure variance system is clean before creating animation
try:
tool.Sequence.detect_and_fix_variance_inconsistency()
except Exception as e:
print(f"[WARNING] Could not run variance autofix before animation: {e}")
# SPECIFIC FIX: Clear any active variance colors from 3D objects
try:
cleared_variance_colors = tool.Sequence.detect_and_clear_active_variance_colors()
if cleared_variance_colors:
print("[ANIMATION] Cleared active variance colors to prevent color system conflicts")
except Exception as e:
print(f"[WARNING] Could not clear variance colors before animation: {e}")
try:
# --- STAGE 1: FRAME COMPUTATION ---
frames_start = time.time()
frames = {}
try:
print("[OPTIMIZED] Attempting to use FULL optimization path for frames...")
from . import ifc_lookup
lookup = ifc_lookup.get_ifc_lookup()
date_cache = ifc_lookup.get_date_cache()
if not lookup.lookup_built:
print("[OPTIMIZED] Building lookup tables...")
lookup.build_lookup_tables(work_schedule)
print("[OPTIMIZED] Using enhanced optimized frame computation...")
frames = tool.Sequence.get_animation_product_frames_enhanced_optimized(
work_schedule, settings, lookup, date_cache
)
print("[SUCCESS] Optimized frame computation was successful.")
except Exception as e:
print(f"[CRITICAL WARNING] Optimized frames method failed, falling back to slow method: {e}")
frames = _compute_product_frames(context, work_schedule, settings)
frames_time = time.time() - frames_start
print(f"📊 FRAMES COMPUTED: {len(frames)} products in {frames_time:.3f}s")
if not frames:
self.report({'INFO'}, "No frames found to animate.")
return {'CANCELLED'}
# --- STAGE 2: ANIMATION APPLICATION (OPTIMIZED) ---
anim_start = time.time()
print("🔥🔥🔥 [OPERATOR] STAGE 2 - ANIMATION APPLICATION STARTED!")
try:
# Build performance cache for optimization
from . import performance_cache
cache = performance_cache.get_performance_cache()
if not cache.cache_valid:
print("[OPTIMIZED] Building performance cache...")
cache.build_scene_cache()
# Call optimized function
tool.Sequence.animate_objects_with_ColorTypes_optimized(settings, frames, cache)
except Exception as e:
print(f"🔥🔥🔥 [ERROR] Optimized method failed, falling back to standard method: {e}")
import traceback
traceback.print_exc()
# Fallback to standard method
tool.Sequence.animate_objects_with_ColorTypes(settings, frames)
anim_time = time.time() - anim_start
print(f"🎬 ANIMATION APPLIED: Completed in {anim_time:.3f}s")
except Exception as e:
self.report({'ERROR'}, f"Animation process failed: {e}")
import traceback
traceback.print_exc()
return {'CANCELLED'}
if self.preserve_current_frame:
context.scene.frame_set(stored_frame)
total_time = time.time() - total_start_time
print("-" * 60)
print("-" * 60)
self.report({'INFO'}, f"Animation created for {len(frames)} elements in {total_time:.2f}s.")
anim_props.is_animation_created = True
try:
camera_props = tool.Sequence.get_animation_props().camera_orbit
if camera_props.enable_3d_legend_hud:
bpy.ops.bim.setup_3d_legend_hud()
except Exception as e:
print(f"[WARNING] Could not auto-create 3D Legend HUD: {e}")
return {'FINISHED'}
def execute(self, context):
try:
return self._execute(context)
except Exception as e:
self.report({'ERROR'}, f"Unexpected error: {e}")
return {'CANCELLED'}
def _get_unified_date_range(self, work_schedule):
from datetime import datetime
if not work_schedule: return None, None
all_starts, all_finishes = [], []
for schedule_type in ["SCHEDULE", "ACTUAL", "EARLY", "LATE"]:
start_attr = f"{schedule_type.capitalize()}Start"
finish_attr = f"{schedule_type.capitalize()}Finish"
root_tasks = ifcopenshell.util.sequence.get_root_tasks(work_schedule)
def get_all_tasks_recursive(tasks):
result = []
for task in tasks:
result.append(task)
nested = ifcopenshell.util.sequence.get_nested_tasks(task)
if nested: result.extend(get_all_tasks_recursive(nested))
return result
all_tasks = get_all_tasks_recursive(root_tasks)
for task in all_tasks:
start_date = ifcopenshell.util.sequence.derive_date(task, start_attr, is_earliest=True)
if start_date: all_starts.append(start_date)
finish_date = ifcopenshell.util.sequence.derive_date(task, finish_attr, is_latest=True)
if finish_date: all_finishes.append(finish_date)
if not all_starts or not all_finishes: return None, None
return min(all_starts), max(all_finishes)
class ClearAnimation(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.clear_animation"
bl_label = "Clear 4D Animation"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
_clear_previous_animation(context)
self.report({'INFO'}, "Previous animation cleared")
return {'FINISHED'}
def execute(self, context):
try:
return self._execute(context)
except Exception as e:
self.report({'ERROR'}, f"Unexpected error: {e}")
return {'CANCELLED'}
class AddAnimationTaskType(bpy.types.Operator):
bl_idname = "bim.add_animation_task_type"
bl_label = "Add Task Type"
bl_options = {"REGISTER", "UNDO"}
group: bpy.props.EnumProperty(items=[('INPUT','INPUT',''),('OUTPUT','OUTPUT','')], name="Group", default='INPUT')
name: bpy.props.StringProperty(name="Name", default="New Type")
animation_type: bpy.props.StringProperty(name="Type", default="")
def execute(self, context):
props = tool.Sequence.get_animation_props()
coll = props.task_input_colors if self.group == 'INPUT' else props.task_output_colors
item = coll.add()
item.name = self.name or "New Type"
item.animation_type = self.animation_type or item.name
try:
item.color = (1.0, 0.0, 0.0, 1.0)
except Exception:
pass
if self.group == 'INPUT':
props.active_color_component_inputs_index = len(coll)-1
else:
props.active_color_component_outputs_index = len(coll)-1
try:
from bonsai.bim.module.sequence.prop import cleanup_all_tasks_colortype_mappings
cleanup_all_tasks_colortype_mappings(context)
except Exception:
pass
return {'FINISHED'}
class RemoveAnimationTaskType(bpy.types.Operator):
bl_idname = "bim.remove_animation_task_type"
bl_label = "Remove Task Type"
bl_options = {"REGISTER", "UNDO"}
group: bpy.props.EnumProperty(items=[('INPUT','INPUT',''),('OUTPUT','OUTPUT','')], name="Group", default='INPUT')
def execute(self, context):
props = tool.Sequence.get_animation_props()
if self.group == 'INPUT':
idx = getattr(props, "active_color_component_inputs_index", 0)
coll = getattr(props, "task_input_colors", None)
else:
idx = getattr(props, "active_color_component_outputs_index", 0)
coll = getattr(props, "task_output_colors", None)
if coll is not None and 0 <= idx < len(coll):
coll.remove(idx)
if self.group == 'INPUT':
props.active_color_component_inputs_index = max(0, idx-1)
else:
props.active_color_component_outputs_index = max(0, idx-1)
return {'FINISHED'}
class AddAnimationCamera(bpy.types.Operator):
"""Add a camera specifically for Animation Settings"""
bl_idname = "bim.add_animation_camera"
bl_label = "Add Animation Camera"
bl_description = "Create a new camera for Animation Settings with orbital animation"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
try:
# For animation cameras, we should try to call the full method if possible
# but have a fallback to simple creation
try:
from . import tool
cam_obj = tool.Sequence.add_animation_camera()
except:
# Fallback to simple camera creation
cam_data = bpy.data.cameras.new(name="4D_Animation_Camera")
cam_obj = bpy.data.objects.new(name="4D_Animation_Camera", object_data=cam_data)
# Mark as animation camera
cam_obj['is_4d_camera'] = True
cam_obj['is_animation_camera'] = True
cam_obj['camera_context'] = 'animation'
# Link to scene
context.collection.objects.link(cam_obj)
# Configure camera settings
cam_data.lens = 50
cam_data.clip_start = 0.1
cam_data.clip_end = 1000
# Position camera with a good default view
cam_obj.location = (15, -15, 10)
cam_obj.rotation_euler = (1.1, 0.0, 0.785)
# Set as active camera
context.scene.camera = cam_obj
# Validate that camera was created successfully
if not cam_obj:
self.report({'ERROR'}, "Failed to create animation camera: Camera object is None")
return {'CANCELLED'}
# Validate that camera object has required attributes
if not hasattr(cam_obj, 'select_set'):
self.report({'ERROR'}, f"Camera object is invalid: {type(cam_obj)}")
return {'CANCELLED'}
# Select the camera
bpy.ops.object.select_all(action='DESELECT')
cam_obj.select_set(True)
context.view_layer.objects.active = cam_obj
self.report({'INFO'}, f"Animation camera '{cam_obj.name}' created and set as active")
return {'FINISHED'}
except Exception as e:
self.report({'ERROR'}, f"Failed to create animation camera: {str(e)}")
return {'CANCELLED'}
# === Helper functions ===
def _sequence_has(attr: str) -> bool:
try:
return hasattr(tool.Sequence, attr)
except Exception:
return False
def _clear_previous_animation(context) -> None:
"""Unified and robust cleanup function for all 4D animation."""
print("🧹 Starting complete and optimized animation cleanup...")
try:
# --- 1. STOP ANIMATION AND UNREGISTER ALL HANDLERS ---
# It's crucial to do this first to stop any background processes.
# Stop playback if active
if bpy.context.screen.is_animation_playing:
bpy.ops.screen.animation_cancel(restore_frame=False)
print(" - Animation stopped.")
# Unregister the 2D HUD handler (GPU Overlay)
if hud_overlay.is_hud_enabled():
hud_overlay.unregister_hud_handler()
print(" - 2D HUD handler unregistered.")
# Unregister the 3D texts handler
if hasattr(tool.Sequence, '_unregister_frame_change_handler'):
tool.Sequence._unregister_frame_change_handler()
print(" - 3D texts handler unregistered.")
# --- 2. CLEAN UP SCENE OBJECTS ---
# Delete objects generated by the animation (texts, bars, etc.)
for coll_name in ["Schedule_Display_Texts", "Bar Visual", "Schedule_Display_3D_Legend"]:
if coll_name in bpy.data.collections:
collection = bpy.data.collections[coll_name]
for obj in list(collection.objects):
bpy.data.objects.remove(obj, do_unlink=True)
bpy.data.collections.remove(collection)
print(f" - Collection '{coll_name}' and its objects deleted.")
# Delete the parent 'empty' object
parent_empty = bpy.data.objects.get("Schedule_Display_Parent")
if parent_empty:
bpy.data.objects.remove(parent_empty, do_unlink=True)
print(" - 'Schedule_Display_Parent' object deleted.")
# --- 3. CLEAR ANIMATION DATA FROM 3D OBJECTS (IFC PRODUCTS) ---
print(" - Clearing keyframes and restoring visibility of 3D objects...")
cleaned_count = 0
for obj in bpy.data.objects:
if obj.type == 'MESH' and tool.Ifc.get_entity(obj):
if obj.animation_data:
obj.animation_data_clear()
cleaned_count += 1
# Restore default state
obj.hide_viewport = False
obj.hide_render = False
obj.color = (0.8, 0.8, 0.8, 1.0)
print(f" - Keyframes removed from {cleaned_count} objects.")
# --- 4. RESTORE TIMELINE AND UI ---
if "is_snapshot_mode" in context.scene:
del context.scene["is_snapshot_mode"]
restore_all_ui_state(context)
print(" - UI state restored.")
context.scene.frame_set(context.scene.frame_start)
print(f" - Timeline reset to frame {context.scene.frame_start}.")
except Exception as e:
print(f"Bonsai WARNING: An error occurred during animation cleanup: {e}")
import traceback
traceback.print_exc()
def _get_animation_settings(context):
"""Get animation settings with fallback for snapshot independence"""
try:
if _sequence_has("get_animation_settings"):
result = tool.Sequence.get_animation_settings()
if result is not None:
return result
except Exception:
pass
# Fallback to basic settings independent of Animation Settings
ws = tool.Sequence.get_work_schedule_props()
ap = tool.Sequence.get_animation_props()
fallback_settings = {
"start": getattr(ws, "visualisation_start", None),
"finish": getattr(ws, "visualisation_finish", None),
"speed": getattr(ws, "visualisation_speed", 1.0),
"ColorType_system": getattr(ap, "active_ColorType_system", "ColorTypeS"),
"ColorType_stack": getattr(ap, "ColorType_stack", None),
"start_frame": getattr(context.scene, "frame_start", 1),
"total_frames": max(1, getattr(context.scene, "frame_end", 250) - getattr(context.scene, "frame_start", 1)),
}
return fallback_settings
def _apply_final_optimized_animation(context, frames, settings):
"""
FINAL IMPLEMENTATION: Direct replica of the ultra-fast script.
Minimizes external calls and uses the same batch processing logic.
"""
print("🚀 APPLYING FINAL OPTIMIZED ANIMATION (Direct Script Logic)")
opt_start = time.time()
# 1. FAST AND DIRECT MAPPING: IFC to Blender
map_start = time.time()
ifc_to_blender = {}
all_ifc_objects = []
for obj in bpy.data.objects:
if obj.type == 'MESH':
element = tool.Ifc.get_entity(obj)
if element and not element.is_a("IfcSpace"):
ifc_to_blender[element.id()] = obj
all_ifc_objects.append(obj)
print(f" - Mapped {len(ifc_to_blender)} objects in {time.time() - map_start:.3f}s") # This seems to be a debug print, I'll leave it as is but it could be translated to "Mapped {len(ifc_to_blender)} objects in {time.time() - map_start:.3f}s"
# 2. EXACT VISIBILITY LOGIC FROM THE SCRIPT
hide_start = time.time()
for obj in all_ifc_objects:
if obj.animation_data:
obj.animation_data_clear()
assigned_objects = set()
for obj in all_ifc_objects:
element = tool.Ifc.get_entity(obj)
if not element or element.id() not in frames:
obj.hide_viewport = True
obj.hide_render = True
else:
obj.hide_viewport = True
obj.hide_render = True
obj.keyframe_insert(data_path="hide_viewport", frame=0)
obj.keyframe_insert(data_path="hide_render", frame=0)
assigned_objects.add(obj)
print(f" - Visibility configured in {time.time() - hide_start:.3f}s for {len(assigned_objects)} assigned objects") # This seems to be a debug print, I'll leave it as is but it could be translated to "Visibility configured in {time.time() - hide_start:.3f}s for {len(assigned_objects)} assigned objects"
# 3. GET ORIGINAL COLORS (ASSIGNED OBJECTS ONLY)
colors_start = time.time()
original_colors = {obj.name: list(obj.color) for obj in assigned_objects}
print(f" - Original colors stored in {time.time() - colors_start:.3f}s") # This seems to be a debug print, I'll leave it as is but it could be translated to "Original colors stored in {time.time() - colors_start:.3f}s"
# 4. OPERATION PLANNING (SCRIPT'S CORE LOGIC)
process_start = time.time()
visibility_ops = []
color_ops = []
animation_props = tool.Sequence.get_animation_props()
active_group_name = "DEFAULT"
for item in getattr(animation_props, "animation_group_stack", []):
if getattr(item, "enabled", False) and getattr(item, "group", None):
active_group_name = item.group
break
colortype_cache = {}
processed_count = 0
for product_id, frame_data_list in frames.items():
obj = ifc_to_blender.get(product_id)
if not obj or obj not in assigned_objects:
continue
original_color = original_colors.get(obj.name, [1.0, 1.0, 1.0, 1.0])
for frame_data in frame_data_list:
task = frame_data.get("task")
task_key = task.id() if task else "None"
if task_key not in colortype_cache:
colortype_cache[task_key] = tool.Sequence.get_assigned_ColorType_for_task(
task, animation_props, active_group_name
)
ColorType = colortype_cache.get(task_key)
if not ColorType:
continue
states = frame_data.get("states", {})
if states:
_plan_animation_operations(obj, states, ColorType, original_color, frame_data, visibility_ops, color_ops)
processed_count += 1
print(f" - {processed_count} frames planned in {time.time() - process_start:.3f}s") # This seems to be a debug print, I'll leave it as is but it could be translated to "{processed_count} frames planned in {time.time() - process_start:.3f}s"
# 5. FINAL BATCH EXECUTION
exec_start = time.time()
for op in visibility_ops:
op['obj'].hide_viewport = op['hide']
op['obj'].hide_render = op['hide']
op['obj'].keyframe_insert(data_path="hide_viewport", frame=op['frame'])
op['obj'].keyframe_insert(data_path="hide_render", frame=op['frame'])
for op in color_ops:
op['obj'].color = op['color']
op['obj'].keyframe_insert(data_path="color", frame=op['frame'])
print(f" - {len(visibility_ops) + len(color_ops)} keyframes inserted in {time.time() - exec_start:.3f}s") # This seems to be a debug print, I'll leave it as is but it could be translated to "{len(visibility_ops) + len(color_ops)} keyframes inserted in {time.time() - exec_start:.3f}s"
print(f" - Total optimization time: {time.time() - opt_start:.3f}s") # This seems to be a debug print, I'll leave it as is but it could be translated to "Total optimization time: {time.time() - opt_start:.3f}s"
return processed_count
def _plan_animation_operations(obj, states, ColorType, original_color, frame_data, visibility_ops, color_ops):
is_construction = frame_data.get("relationship") == "output"
before_start = states.get("before_start", (0, -1))
if before_start[1] >= before_start[0]:
if not (is_construction and not getattr(ColorType, 'consider_start', False)):
visibility_ops.append({'obj': obj, 'frame': before_start[0], 'hide': False})
color = original_color if getattr(ColorType, 'use_start_original_color', False) else [
*getattr(ColorType, 'start_color', [0.8, 0.8, 0.8])[:3],
1.0 - getattr(ColorType, 'start_transparency', 0.0)
]
color_ops.append({'obj': obj, 'frame': before_start[0], 'color': color})
active = states.get("active", (0, -1))
if active[1] >= active[0] and getattr(ColorType, 'consider_active', True):
visibility_ops.append({'obj': obj, 'frame': active[0], 'hide': False})
color = [
*getattr(ColorType, 'in_progress_color', [0.5, 0.9, 0.5])[:3],
1.0 - getattr(ColorType, 'in_progress_transparency', 0.0)
]
color_ops.append({'obj': obj, 'frame': active[0], 'color': color})
after_end = states.get("after_end", (0, -1))
if after_end[1] >= after_end[0] and getattr(ColorType, 'consider_end', True):
# FIXED: Check hide_at_end as in v110
should_hide_at_end = getattr(ColorType, 'hide_at_end', False)
if should_hide_at_end:
# Hide object at the end (e.g., demolitions)
visibility_ops.append({'obj': obj, 'frame': after_end[0], 'hide': True})
else:
# Show object at the end with END color
visibility_ops.append({'obj': obj, 'frame': after_end[0], 'hide': False})
color = original_color if getattr(ColorType, 'use_end_original_color', False) else [
*getattr(ColorType, 'end_color', [0.7, 0.7, 0.7])[:3],
1.0 - getattr(ColorType, 'end_transparency', 0.0)
]
color_ops.append({'obj': obj, 'frame': after_end[0], 'color': color})
def _compute_product_frames(context, work_schedule, settings):
# This function now only calls the tool function, keeping the logic separate.
return tool.Sequence.get_animation_product_frames(work_schedule, settings)
def _plan_animation_operations(obj, frame_data, ColorType, original_color, visibility_ops, color_ops):
"""
Helper function to plan keyframe operations without inserting them.
"""
states = frame_data.get("states", {})
is_construction = frame_data.get("relationship") == "output"
# State BEFORE START
before_start = states.get("before_start")
if before_start and not (is_construction and not getattr(ColorType, 'consider_start', False)):
visibility_ops.append({'obj': obj, 'frame': before_start[0], 'hide': False})
color = original_color if getattr(ColorType, 'use_start_original_color', False) else [
*getattr(ColorType, 'start_color', [0.8, 0.8, 0.8])[:3],
1.0 - getattr(ColorType, 'start_transparency', 0.0)
]
color_ops.append({'obj': obj, 'frame': before_start[0], 'color': color})
# ACTIVE state
active = states.get("active")
if active and getattr(ColorType, 'consider_active', True):
visibility_ops.append({'obj': obj, 'frame': active[0], 'hide': False})
color = [
*getattr(ColorType, 'in_progress_color', [0.5, 0.9, 0.5])[:3],
1.0 - getattr(ColorType, 'in_progress_transparency', 0.0)
]
color_ops.append({'obj': obj, 'frame': active[0], 'color': color})
# State AFTER FINISH
after_end = states.get("after_end")
if after_end and getattr(ColorType, 'consider_end', True):
# FIXED: Check hide_at_end as in v110
should_hide_at_end = getattr(ColorType, 'hide_at_end', False)
if should_hide_at_end:
# Hide object at the end (e.g., demolitions)
visibility_ops.append({'obj': obj, 'frame': after_end[0], 'hide': True})
else:
# Show object at the end with END color
visibility_ops.append({'obj': obj, 'frame': after_end[0], 'hide': False})
color = original_color if getattr(ColorType, 'use_end_original_color', False) else [
*getattr(ColorType, 'end_color', [0.7, 0.7, 0.7])[:3],
1.0 - getattr(ColorType, 'end_transparency', 0.0)
]
color_ops.append({'obj': obj, 'frame': after_end[0], 'color': color})
def _safe_set(obj, name, value):
try:
setattr(obj, name, value)
except Exception:
# Silently ignore when the target property doesn't exist
pass
def _ensure_default_group(context):
# Ensure internal DEFAULT exists
try:
# Note: UnifiedColorTypeManager would need to be imported if available
# For now, we'll skip this part
pass
except Exception:
pass
# Ensure UI stack has at least one item (animation_group_stack or colortype_stack)
try:
ap = tool.Sequence.get_animation_props()
# Newer stack
if hasattr(ap, "animation_group_stack") and len(ap.animation_group_stack) == 0:
it = ap.animation_group_stack.add()
it.group = getattr(ap, "ColorType_groups", "") or "DEFAULT"
_safe_set(it, 'enabled', True)
# Older stack
if hasattr(ap, "colortype_stack") and len(ap.colortype_stack) == 0:
it = ap.colortype_stack.add()
it.group = getattr(ap, "ColorType_groups", "") or "DEFAULT"
_safe_set(it, 'enabled', True)
except Exception:
pass
# ============================================================================
# ANIMATION CLEANUP OPERATORS (moved from operator.py)
# ============================================================================
class ClearPreviousAnimation(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.clear_previous_animation"
bl_label = "Reset Animation"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
# --- START OF MODIFICATION ---
# Lower the flag BEFORE cleaning, to invalidate the state.
try:
anim_props = tool.Sequence.get_animation_props()
anim_props.is_animation_created = False
print("[ERROR] Animation flag SET to FALSE.")
except Exception as e:
print(f"Could not reset animation flag: {e}")
# --- END OF MODIFICATION ---
try:
if bpy.context.screen.is_animation_playing:
bpy.ops.screen.animation_cancel(restore_frame=False)
except Exception as e:
print(f"Could not stop animation: {e}")
# ... (rest of the function code unchanged) ...
try:
_clear_previous_animation(context)
# ... (HUD cleanup code) ...
self.report({'INFO'}, "Previous animation cleared.")
context.scene.frame_set(context.scene.frame_start)
return {'FINISHED'}
except Exception as e:
self.report({'ERROR'}, f"Failed to clear previous animation: {e}")
return {"CANCELLED"}
def execute(self, context):
return self._execute(context)
class ClearPreviousSnapshot(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.clear_previous_snapshot"
bl_label = "Reset Snapshot"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
print(f"🔄 Reset snapshot started")
# CORRECTION: Stop the animation if it is playing
try:
if bpy.context.screen.is_animation_playing:
bpy.ops.screen.animation_cancel(restore_frame=False)
except Exception as e:
print(f"[ERROR] Could not stop animation: {e}")
# Clear snapshot mode flag
if "is_snapshot_mode" in context.scene:
del context.scene["is_snapshot_mode"]
# Restore UI state (3D texts, Timeline HUD, etc.)
try:
restore_all_ui_state(context)
except Exception as e:
print(f"[ERROR] Could not restore UI state: {e}")
# CORRECTION: Complete cleanup of previous snapshot
try:
# CRITICAL: Reset all objects to original state (use existing function)
print(f"🔄 Clearing previous animation...")
_clear_previous_animation(context)
# Clear temporary snapshot data
if hasattr(bpy.context.scene, 'snapshot_data'):
del bpy.context.scene.snapshot_data
# Clear the active profile group in HUD Legend
try:
anim_props = tool.Sequence.get_animation_props()
if anim_props and hasattr(anim_props, 'camera_orbit'):
camera_props = anim_props.camera_orbit
# Get all active profiles to hide them
if hasattr(anim_props, 'animation_group_stack') and anim_props.animation_group_stack:
all_colortype_names = []
for group_item in anim_props.animation_group_stack:
group_name = group_item.group
from ..prop import UnifiedColorTypeManager
group_colortypes = UnifiedColorTypeManager.get_group_colortypes(bpy.context, group_name)
if group_colortypes:
all_colortype_names.extend(group_colortypes.keys())
# Hide all profiles by putting their names in legend_hud_visible_colortypes
if all_colortype_names:
hidden_colortypes_str = ','.join(set(all_colortype_names)) # use set() to remove duplicates
camera_props.legend_hud_visible_colortypes = hidden_colortypes_str
print(f"🧹 Hidden colortypes in HUD Legend: {hidden_colortypes_str}")
# Clear selected_colortypes just in case
if hasattr(camera_props, 'legend_hud_selected_colortypes'):
camera_props.legend_hud_selected_colortypes = set()
# Invalidate legend HUD cache
from ..hud import invalidate_legend_hud_cache
invalidate_legend_hud_cache()
print("🧹 Active colortype group cleared from HUD Legend")
except Exception as legend_e:
print(f"[WARNING] Could not clear colortype group: {legend_e}")
# --- SNAPSHOT 3D TEXTS RESTORATION ---
# Clear snapshot mode and restore previous state
if "is_snapshot_mode" in context.scene:
del context.scene["is_snapshot_mode"]
print("📸 Snapshot mode deactivated for 3D texts")
_restore_3d_texts_state()
self.report({'INFO'}, "Snapshot reset completed")
except Exception as e:
print(f"Error during snapshot reset: {e}")
self.report({'WARNING'}, f"Snapshot reset completed with warnings: {e}")
return {'FINISHED'}
def execute(self, context):
return self._execute(context)
class SyncAnimationByDate(bpy.types.Operator):
bl_idname = "bim.sync_animation_by_date"
bl_label = "Sync Animation by Date"
bl_options = {"INTERNAL"}
previous_start_date: bpy.props.StringProperty()
previous_finish_date: bpy.props.StringProperty()
def execute(self, context):
# Sync functionality removed - always proceed
# anim_props = tool.Sequence.get_animation_props()
# if not getattr(anim_props, "auto_update_on_date_source_change", False):
# return {'CANCELLED'}
was_playing = bpy.context.screen.is_animation_playing
if was_playing:
bpy.ops.screen.animation_cancel(restore_frame=False)
try:
start_date = datetime.fromisoformat(self.previous_start_date)
finish_date = datetime.fromisoformat(self.previous_finish_date)
current_frame = context.scene.frame_current
start_frame = context.scene.frame_start
end_frame = context.scene.frame_end
progress = (current_frame - start_frame) / (end_frame - start_frame) if (end_frame - start_frame) > 0 else 0
current_date = start_date + (finish_date - start_date) * progress
props = tool.Sequence.get_work_schedule_props()
new_start_date = datetime.fromisoformat(props.visualisation_start)
new_finish_date = datetime.fromisoformat(props.visualisation_finish)
new_duration = (new_finish_date - new_start_date).total_seconds()
new_progress = (current_date - new_start_date).total_seconds() / new_duration if new_duration > 0 else 0
new_progress = max(0.0, min(1.0, new_progress))
new_frame = start_frame + (end_frame - start_frame) * new_progress
context.scene.frame_set(int(round(new_frame)))
except (ValueError, TypeError) as e:
print(f"Sync failed: {e}")
if was_playing:
bpy.ops.screen.animation_play()
return {'FINISHED'}
# Removed SyncAnimationDateSource - sync auto functionality eliminated
def _clear_previous_animation(context) -> None:
print("🧹 Iniciando cleanup complete y optimized de la animation...")
try:
# --- 1. STOP ANIMATION AND HANDLERS (Same as before) ---
if bpy.context.screen.is_animation_playing:
bpy.ops.screen.animation_cancel(restore_frame=False)
if hud_overlay.is_hud_enabled():
hud_overlay.unregister_hud_handler()
if hasattr(tool.Sequence, '_unregister_frame_change_handler'):
tool.Sequence._unregister_frame_change_handler()
# --- 2. CLEAN COLLECTIONS AND ANIMATION OBJECTS (Same as before) ---
for coll_name in ["Schedule_Display_Texts", "Bar Visual", "Schedule_Display_3D_Legend"]:
if coll_name in bpy.data.collections:
collection = bpy.data.collections[coll_name]
for obj in list(collection.objects):
bpy.data.objects.remove(obj, do_unlink=True)
bpy.data.collections.remove(collection)
parent_empty = bpy.data.objects.get("Schedule_Display_Parent")
if parent_empty:
bpy.data.objects.remove(parent_empty, do_unlink=True)
# --- 3. OPTIMIZED RESET OF IFC OBJECTS ---
print("🧹 Efficiently resetting the state of IFC objects...")
cleaned_count = 0
reset_count = 0
for obj in bpy.data.objects:
if obj.type == 'MESH' and tool.Ifc.get_entity(obj):
# Clears all animation data from the object
if obj.animation_data:
obj.animation_data_clear()
cleaned_count += 1
# Restore default visibility
obj.hide_viewport = False
obj.hide_render = False
# Resets the object's color to white (neutral value).
# This disables the override and allows the material color to be seen.
# It is a very fast operation.
obj.color = (1.0, 1.0, 1.0, 1.0)
reset_count += 1
print(f"🧹 COMPLETE CLEANUP: {cleaned_count} animations cleared, {reset_count} objects reset.")
# --- 4. RESTORE UI AND TIMELINE (Same as before) ---
if "is_snapshot_mode" in context.scene:
del context.scene["is_snapshot_mode"]
restore_all_ui_state(context)
context.scene.frame_set(context.scene.frame_start)
# Force a single redraw at the end to ensure the view updates
for area in bpy.context.screen.areas:
if area.type == 'VIEW_3D':
area.tag_redraw()
except Exception as e:
print(f"Bonsai WARNING: An error occurred during animation cleanup: {e}")
import traceback
traceback.print_exc()
@@ -0,0 +1,958 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import bpy
import bonsai.tool as tool
# --- HELPER FUNCTIONS ---
def _is_animation_camera_simple(obj):
"""Simplified detection for animation cameras without external dependencies"""
if not obj or obj.type != 'CAMERA':
return False
# Check by camera_context property (primary method)
if obj.get('camera_context') == 'animation':
return True
# Fallback to name pattern (ensure it's not a snapshot camera)
if '4D_Animation_Camera' in obj.name and 'Snapshot' not in obj.name:
return True
return False
def _is_snapshot_camera_simple(obj):
"""Simplified detection for snapshot cameras without external dependencies"""
if not obj or obj.type != 'CAMERA':
return False
# Check by camera_context property (primary method)
if obj.get('camera_context') == 'snapshot':
return True
# Fallback to name pattern
if 'Snapshot_Camera' in obj.name:
return True
return False
def _get_animation_cameras(self, context):
"""Callback that returns ONLY the Animation cameras."""
print("🔥 _get_animation_cameras called")
items = []
total_cameras = 0
animation_cameras = 0
try:
# Check if animation cameras are hidden by the UI toggle
try:
anim_props = tool.Sequence.get_animation_props()
camera_props = anim_props.camera_orbit if anim_props else None
cameras_hidden = getattr(camera_props, 'hide_all_animation_cameras', False) if camera_props else False
if cameras_hidden:
print("📹 Animation cameras are hidden by UI toggle - returning empty list")
items = [('NONE', '<Cameras Hidden>', 'Animation cameras are hidden by visibility toggle')]
return items
except Exception as e:
print(f"[WARNING] Could not check camera visibility state: {e}")
for obj in bpy.data.objects:
if obj.type == 'CAMERA':
total_cameras += 1
camera_context = obj.get('camera_context', 'N/A')
# Use simplified detection function
if _is_animation_camera_simple(obj):
animation_cameras += 1
items.append((obj.name, obj.name, f'Animation Camera 4D'))
print(f"📹 Animation camera found: {obj.name} (context: {camera_context})")
print(f"📹 Animation camera scan: {animation_cameras}/{total_cameras} cameras are animation cameras")
except Exception as e:
print(f"[ERROR] Error in _get_animation_cameras: {e}")
import traceback
traceback.print_exc()
if not items:
items = [('NONE', '<No animation cameras>', 'No animation cameras detected')]
print("📹 No animation cameras found, showing empty selector")
print(f"📹 Returning {len(items)} animation camera items")
return items
def _get_snapshot_cameras(self, context):
"""Callback that returns ONLY the Snapshot cameras."""
print("🔥 _get_snapshot_cameras called")
items = []
total_cameras = 0
snapshot_cameras = 0
try:
for obj in bpy.data.objects:
if obj.type == 'CAMERA':
total_cameras += 1
camera_context = obj.get('camera_context', 'N/A')
# Use simplified detection function
if _is_snapshot_camera_simple(obj):
snapshot_cameras += 1
items.append((obj.name, obj.name, f'Snapshot Camera 4D'))
print(f"📸 Snapshot camera found: {obj.name} (context: {camera_context})")
print(f"📸 Snapshot camera scan: {snapshot_cameras}/{total_cameras} cameras are snapshot cameras")
except Exception as e:
print(f"[ERROR] Error in _get_snapshot_cameras: {e}")
import traceback
traceback.print_exc()
if not items:
items = [('NONE', '<No snapshot cameras>', 'No snapshot cameras detected')]
print("📸 No snapshot cameras found, showing empty selector")
print(f"📸 Returning {len(items)} snapshot camera items")
return items
# --- DEBUG OPERATORS ---
class RefreshCameraSelectors(bpy.types.Operator):
bl_idname = "bim.refresh_camera_selectors"
bl_label = "Refresh Camera Selectors"
bl_description = "Force refresh of camera selector dropdowns to show current cameras"
bl_options = {"REGISTER"}
def execute(self, context):
try:
print(f"🔄 FORCE Camera selectors refresh requested")
# Force refresh by accessing the properties
import bonsai.tool as tool
anim_props = tool.Sequence.get_animation_props()
camera_props = anim_props.camera_orbit
# Force update the enum properties by accessing them
current_anim = getattr(camera_props, 'active_animation_camera', 'NONE')
current_snapshot = getattr(camera_props, 'active_snapshot_camera', 'NONE')
print(f"📹 Current animation camera: {current_anim}")
print(f"📸 Current snapshot camera: {current_snapshot}")
# Test the callback functions directly
print(f"🧪 Testing callback functions directly:")
try:
anim_items = _get_animation_cameras(self, context)
snap_items = _get_snapshot_cameras(self, context)
except Exception as e:
print(f"[ERROR] Callback error: {e}")
# Force UI refresh
try:
for window in context.window_manager.windows:
for area in window.screen.areas:
area.tag_redraw()
except Exception as e:
print(f"[ERROR] UI refresh error: {e}")
self.report({'INFO'}, "Camera selectors refresh attempted")
return {'FINISHED'}
except Exception as e:
self.report({'ERROR'}, f"Failed to refresh selectors: {e}")
print(f"[ERROR] RefreshCameraSelectors error: {e}")
import traceback
traceback.print_exc()
return {'CANCELLED'}
class ForceCameraPropertyUpdate(bpy.types.Operator): # type: ignore
bl_idname = "bim.force_camera_property_update"
bl_label = "FORCE: Update Camera Properties"
bl_description = "Force complete update of camera selector properties"
bl_options = {"REGISTER"}
def execute(self, context):
try:
print(f"🔥 FORCING camera property updates...")
import bonsai.tool as tool
anim_props = tool.Sequence.get_animation_props()
camera_props = anim_props.camera_orbit
# Method 1: Try to force property updates by setting to dummy values
try:
old_anim = getattr(camera_props, 'active_animation_camera', 'NONE')
old_snap = getattr(camera_props, 'active_snapshot_camera', 'NONE')
print(f"📹 Old animation camera: {old_anim}")
print(f"📸 Old snapshot camera: {old_snap}")
# Force property refresh by setting to different value then back
camera_props.active_animation_camera = 'NONE'
camera_props.active_snapshot_camera = 'NONE'
# Test if we have any real cameras
anim_items = _get_animation_cameras(self, context)
snap_items = _get_snapshot_cameras(self, context)
print(f"📹 Available animation cameras: {[item[0] for item in anim_items if item[0] != 'NONE']}")
print(f"📸 Available snapshot cameras: {[item[0] for item in snap_items if item[0] != 'NONE']}")
# Set back to a real camera if available
if len(anim_items) > 0 and anim_items[0][0] != 'NONE':
camera_props.active_animation_camera = anim_items[0][0]
print(f"📹 Set animation camera to: {anim_items[0][0]}")
if len(snap_items) > 0 and snap_items[0][0] != 'NONE':
camera_props.active_snapshot_camera = snap_items[0][0]
print(f"📸 Set snapshot camera to: {snap_items[0][0]}")
except Exception as e:
print(f"[ERROR] Property update error: {e}")
# Method 2: Force UI refresh
for area in context.screen.areas:
area.tag_redraw()
# Method 3: Update depsgraph
bpy.context.view_layer.update()
self.report({'INFO'}, "Force update completed")
return {'FINISHED'}
except Exception as e:
self.report({'ERROR'}, f"Force update failed: {e}")
print(f"[ERROR] ForceCameraPropertyUpdate error: {e}")
import traceback
traceback.print_exc()
return {'CANCELLED'}
class SetActiveAnimationCamera(bpy.types.Operator): # type: ignore
bl_idname = "bim.set_active_animation_camera"
bl_label = "Set Active Animation Camera"
bl_description = "Manually set the active animation camera"
bl_options = {"REGISTER", "UNDO"}
camera_name: bpy.props.StringProperty(name="Camera Name")
def execute(self, context):
try:
if not self.camera_name or self.camera_name == 'NONE':
self.report({'WARNING'}, "No camera specified")
return {'CANCELLED'}
# Find the camera object
cam_obj = bpy.data.objects.get(self.camera_name)
if not cam_obj or cam_obj.type != 'CAMERA':
self.report({'ERROR'}, f"Camera '{self.camera_name}' not found")
return {'CANCELLED'}
# Verify it's an animation camera
if not _is_animation_camera_simple(cam_obj):
self.report({'ERROR'}, f"'{self.camera_name}' is not an animation camera")
return {'CANCELLED'}
# Set as active camera
import bonsai.tool as tool
anim_props = tool.Sequence.get_animation_props()
camera_props = anim_props.camera_orbit
camera_props.active_animation_camera = self.camera_name
# Also set as scene camera
context.scene.camera = cam_obj
self.report({'INFO'}, f"Set '{self.camera_name}' as active animation camera")
return {'FINISHED'}
except Exception as e:
self.report({'ERROR'}, f"Failed to set camera: {e}")
return {'CANCELLED'}
class SetActiveSnapshotCamera(bpy.types.Operator): # type: ignore
bl_idname = "bim.set_active_snapshot_camera"
bl_label = "Set Active Snapshot Camera"
bl_description = "Manually set the active snapshot camera"
bl_options = {"REGISTER", "UNDO"}
camera_name: bpy.props.StringProperty(name="Camera Name")
def execute(self, context):
try:
if not self.camera_name or self.camera_name == 'NONE':
self.report({'WARNING'}, "No camera specified")
return {'CANCELLED'}
# Find the camera object
cam_obj = bpy.data.objects.get(self.camera_name)
if not cam_obj or cam_obj.type != 'CAMERA':
self.report({'ERROR'}, f"Camera '{self.camera_name}' not found")
return {'CANCELLED'}
# Verify it's a snapshot camera
if not _is_snapshot_camera_simple(cam_obj):
self.report({'ERROR'}, f"'{self.camera_name}' is not a snapshot camera")
return {'CANCELLED'}
# Set as active camera
import bonsai.tool as tool
anim_props = tool.Sequence.get_animation_props()
camera_props = anim_props.camera_orbit
camera_props.active_snapshot_camera = self.camera_name
# Also set as scene camera
context.scene.camera = cam_obj
self.report({'INFO'}, f"Set '{self.camera_name}' as active snapshot camera")
return {'FINISHED'}
except Exception as e:
self.report({'ERROR'}, f"Failed to set camera: {e}")
return {'CANCELLED'}
class TestCameraDetection(bpy.types.Operator): # type: ignore
bl_idname = "bim.test_camera_detection"
bl_label = "TEST: Camera Detection"
bl_description = "Quick test to see what cameras are detected right now"
bl_options = {"REGISTER"}
def execute(self, context):
# Basic test without complex dependencies
total_cameras = 0
animation_found = 0
snapshot_found = 0
for obj in bpy.data.objects: # type: ignore
if obj.type == 'CAMERA':
total_cameras += 1
name = obj.name
context_prop = obj.get('camera_context', 'None')
print(f"📷 Camera: {name}")
print(f" camera_context: {context_prop}")
# Test direct detection with simplified functions
if _is_animation_camera_simple(obj):
animation_found += 1
elif _is_snapshot_camera_simple(obj):
snapshot_found += 1
else:
print(f" ⚪ OTHER CAMERA")
print(f"\n📊 RESULT: {total_cameras} total, {animation_found} animation, {snapshot_found} snapshot")
# Test callbacks directly
try:
anim_items = _get_animation_cameras(self, context)
snap_items = _get_snapshot_cameras(self, context)
print(f"Animation callback returned: {len(anim_items)} items")
print(f"Snapshot callback returned: {len(snap_items)} items")
except Exception as e:
print(f"[ERROR] Callback error: {e}")
self.report({'INFO'}, f"Test complete: {animation_found} animation, {snapshot_found} snapshot cameras")
return {'FINISHED'}
class DebugListAllCameras(bpy.types.Operator): # type: ignore
bl_idname = "bim.debug_list_all_cameras"
bl_label = "Debug: List All Cameras"
bl_description = "List all cameras with their properties for debugging"
bl_options = {"REGISTER"}
def execute(self, context):
try:
import bonsai.tool as tool
print("\n" + "="*60)
print("🔍 DEBUG: LISTING ALL CAMERAS")
print("="*60)
total_cameras = 0
animation_cameras = 0
snapshot_cameras = 0
other_cameras = 0
for obj in bpy.data.objects: # type: ignore
if obj.type == 'CAMERA':
total_cameras += 1
# Get all custom properties
camera_context = obj.get('camera_context', 'N/A')
is_4d_camera = obj.get('is_4d_camera', False)
is_animation_camera = obj.get('is_animation_camera', False)
is_snapshot_camera = obj.get('is_snapshot_camera', False)
# Test the detection functions
is_bonsai_animation = tool.Sequence.is_bonsai_animation_camera(obj)
is_bonsai_snapshot = tool.Sequence.is_bonsai_snapshot_camera(obj)
print(f"\n📷 Camera: {obj.name}")
print(f" 📋 Properties:")
print(f" camera_context: {camera_context}")
print(f" is_4d_camera: {is_4d_camera}")
print(f" is_animation_camera: {is_animation_camera}")
print(f" is_snapshot_camera: {is_snapshot_camera}")
print(f" 🔍 Detection results:")
print(f" is_bonsai_animation_camera: {is_bonsai_animation}")
print(f" is_bonsai_snapshot_camera: {is_bonsai_snapshot}")
if is_bonsai_animation:
animation_cameras += 1
elif is_bonsai_snapshot:
snapshot_cameras += 1
else:
other_cameras += 1
print(f" ⚪ OTHER CAMERA")
print(f"\n📊 SUMMARY:")
print(f" Total cameras: {total_cameras}")
print(f" Animation cameras: {animation_cameras}")
print(f" Snapshot cameras: {snapshot_cameras}")
print(f" Other cameras: {other_cameras}")
# Test the callback functions
try:
animation_items = _get_animation_cameras(self, context)
snapshot_items = _get_snapshot_cameras(self, context)
print(f" Animation selector items: {len(animation_items)}")
for item in animation_items:
print(f" - {item[0]}: {item[1]}")
print(f" Snapshot selector items: {len(snapshot_items)}")
for item in snapshot_items:
print(f" - {item[0]}: {item[1]}")
except Exception as e:
print(f" [ERROR] Error in callbacks: {e}")
print("="*60 + "\n")
self.report({'INFO'}, f"Listed {total_cameras} cameras ({animation_cameras} animation, {snapshot_cameras} snapshot)")
return {'FINISHED'}
except Exception as e:
self.report({'ERROR'}, f"Failed to list cameras: {e}")
return {'CANCELLED'}
# --- OPERATORS ---
class ResetCameraSettings(bpy.types.Operator):
bl_idname = "bim.reset_camera_settings" # type: ignore
bl_label = "Reset Camera Settings"
bl_description = "Reset camera and orbit settings to their default values (HUD and UI settings are preserved)"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
try:
anim_props = tool.Sequence.get_animation_props()
camera_props = anim_props.camera_orbit
# --- Reset all properties to their default values ---
# Camera Properties
camera_props.camera_focal_mm = 35.0
camera_props.camera_clip_start = 0.1
camera_props.camera_clip_end = 10000.0
# Orbit Properties
camera_props.orbit_mode = "CIRCLE_360"
camera_props.orbit_radius_mode = "AUTO"
camera_props.orbit_radius = 10.0
camera_props.orbit_height = 8.0
camera_props.orbit_start_angle_deg = 0.0
camera_props.orbit_direction = "CCW"
# Look At Properties
camera_props.look_at_mode = "AUTO"
camera_props.look_at_object = None # Clear any selected object
# Trajectory and Animation Properties
camera_props.orbit_path_shape = "CIRCLE"
camera_props.custom_orbit_path = None # Limpiar cualquier trayectoria personalizada
camera_props.orbit_path_method = "FOLLOW_PATH"
camera_props.interpolation_mode = "LINEAR"
camera_props.bezier_smoothness_factor = 0.35
camera_props.orbit_use_4d_duration = True
camera_props.orbit_duration_frames = 250.0
camera_props.hide_orbit_path = False
self.report({'INFO'}, "Camera and orbit settings have been reset")
return {'FINISHED'}
except Exception as e:
self.report({'ERROR'}, f"Reset failed: {str(e)}")
return {'CANCELLED'}
class Align4DCameraToView(bpy.types.Operator): # type: ignore
bl_idname = "bim.align_4d_camera_to_view"
bl_label = "Align Active Camera to View"
bl_description = "Aligns the active 4D camera to the current 3D view and sets it to static"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
if not context.scene or not context.scene.camera:
return False
for area in context.screen.areas:
if area.type == 'VIEW_3D':
return True
return False
def execute(self, context):
try:
cam_obj = context.scene.camera
if not cam_obj:
self.report({'ERROR'}, "No active camera in scene.")
return {'CANCELLED'}
rv3d = None
for area in context.screen.areas:
if area.type == 'VIEW_3D':
rv3d = area.spaces.active.region_3d
break
if not rv3d:
self.report({'ERROR'}, "No active 3D viewport found.")
return {'CANCELLED'}
cam_obj.matrix_world = rv3d.view_matrix.inverted()
tool.Sequence.clear_camera_animation(cam_obj)
anim_props = tool.Sequence.get_animation_props()
camera_props = anim_props.camera_orbit
camera_props.orbit_mode = 'NONE'
if getattr(camera_props, 'enable_text_hud', False):
try:
bpy.ops.bim.refresh_schedule_hud()
except Exception as e:
print(f"HUD refresh after align failed: {e}")
self.report({'INFO'}, "Camera aligned to view and set to static.")
return {'FINISHED'}
except Exception as e:
self.report({'ERROR'}, f"Failed to align camera: {str(e)}")
return {'CANCELLED'}
# --- ANIMATION CAMERA DELETION OPERATOR (NEW) ---
# --- ANIMATION CAMERA DELETION OPERATOR (NEW) ---
class DeleteAnimationCamera(bpy.types.Operator): # type: ignore
"""Deletes a 4D Animation camera and its associated objects."""
bl_idname = "bim.delete_animation_camera"
bl_label = "Delete an Animation Camera"
bl_options = {'REGISTER', 'UNDO'}
camera_to_delete: bpy.props.EnumProperty(
name="Animation Camera",
description="Select the Animation camera to delete",
items=_get_animation_cameras
)
def execute(self, context):
cam_name = self.camera_to_delete
if cam_name == "NONE" or not cam_name:
self.report({'INFO'}, "No camera selected to delete.")
return {'CANCELLED'}
cam_obj = bpy.data.objects.get(cam_name)
if not cam_obj:
self.report({'ERROR'}, f"Camera '{cam_name}' not found.")
return {'CANCELLED'}
# Objects associated with the animation camera
path_name = f"4D_OrbitPath_for_{cam_name}"
target_name = f"4D_OrbitTarget_for_{cam_name}"
objects_to_remove = [cam_obj]
if bpy.data.objects.get(path_name): objects_to_remove.append(bpy.data.objects[path_name])
if bpy.data.objects.get(target_name): objects_to_remove.append(bpy.data.objects[target_name])
for obj in objects_to_remove:
bpy.data.objects.remove(obj, do_unlink=True)
self.report({'INFO'}, f"Successfully deleted '{cam_name}' and associated objects.")
return {'FINISHED'}
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self)
# --- SNAPSHOT CAMERA DELETION OPERATOR (NEW) ---
class DeleteSnapshotCamera(bpy.types.Operator): # type: ignore
"""Deletes a Snapshot camera and its associated objects."""
bl_idname = "bim.delete_snapshot_camera"
bl_label = "Delete a Snapshot Camera"
bl_options = {'REGISTER', 'UNDO'}
camera_to_delete: bpy.props.EnumProperty(
name="Snapshot Camera",
description="Select the Snapshot camera to delete",
items=_get_snapshot_cameras
)
def execute(self, context):
cam_name = self.camera_to_delete
if cam_name == "NONE" or not cam_name:
self.report({'INFO'}, "No camera selected to delete.")
return {'CANCELLED'}
cam_obj = bpy.data.objects.get(cam_name)
if not cam_obj:
self.report({'ERROR'}, f"Camera '{cam_name}' not found.")
return {'CANCELLED'}
# Objects associated with the snapshot camera
target_name = "Snapshot_Target"
objects_to_remove = [cam_obj]
if bpy.data.objects.get(target_name): objects_to_remove.append(bpy.data.objects[target_name])
for obj in objects_to_remove:
bpy.data.objects.remove(obj, do_unlink=True)
self.report({'INFO'}, f"Successfully deleted '{cam_name}' and associated objects.")
return {'FINISHED'}
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self)
# ... (the rest of the camera operators like AddSnapshotCamera and AddAnimationCamera do not change) ...
class AddSnapshotCamera(bpy.types.Operator): # type: ignore
bl_idname = "bim.add_snapshot_camera"
bl_label = "Add Snapshot Camera"
bl_description = "Create a new static camera positioned for snapshot viewing"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
try:
cam_obj = tool.Sequence.add_snapshot_camera()
# *** FIX FOR SNAPSHOT 3D TEXT ANIMATION ISSUE ***
# Only refresh HUD if there's already an existing animation - prevents registering
# animation handlers for static snapshot-only usage
try:
anim_props = tool.Sequence.get_animation_props()
camera_props = anim_props.camera_orbit
# Check if an animation has been created before (check for animated objects)
has_existing_animation = False
for obj in bpy.data.objects:
if (obj.animation_data and obj.animation_data.action) or obj.get('is_animated_by_4d', False):
has_existing_animation = True
break
# Check if there are existing 3D texts that were created with animation
has_animated_texts = False
texts_collection = bpy.data.collections.get("Schedule_Display_Texts")
if texts_collection:
for obj in texts_collection.objects:
# If text object has animation settings, it was created with animation handler
if hasattr(obj, "data") and obj.data and obj.data.get("animation_settings"):
has_animated_texts = True
break
if getattr(camera_props, 'enable_text_hud', False):
if has_existing_animation or has_animated_texts:
# There's already an animation, safe to refresh HUD
bpy.ops.bim.refresh_schedule_hud()
print("📸 Snapshot Camera: HUD refreshed (existing animation detected)")
else:
# First time snapshot camera creation - don't register animation handlers
print("📸 Snapshot Camera: Skipping HUD refresh to prevent animation handler registration")
print("📸 This prevents 3D text from animating in snapshot-only mode")
except Exception as e:
print(f"[WARNING] Snapshot Camera: Could not check for existing animation: {e}")
# *** AUTO-UPDATE CAMERA SELECTOR ***
# Force refresh of snapshot camera selector to show the new camera
try:
anim_props = tool.Sequence.get_animation_props()
camera_props = anim_props.camera_orbit
# Set the newly created camera as active in the selector
camera_props.active_snapshot_camera = cam_obj.name
print(f"📸 Snapshot camera selector updated: {cam_obj.name}")
except Exception as e:
print(f"[WARNING] Could not update snapshot camera selector: {e}")
self.report({'INFO'}, f"Snapshot camera '{cam_obj.name}' created and set as active")
return {'FINISHED'}
except Exception as e:
self.report({'ERROR'}, f"Failed to create snapshot camera: {str(e)}")
return {'CANCELLED'}
class AlignSnapshotCameraToView(bpy.types.Operator):
bl_idname = "bim.align_snapshot_camera_to_view" # type: ignore
bl_label = "Align Snapshot Camera to View"
bl_description = "Align the snapshot camera to match the current 3D viewport view"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
if not getattr(context.scene, "camera", None): return False
for area in context.screen.areas:
if area.type == 'VIEW_3D': return True
return False
def execute(self, context):
try:
tool.Sequence.align_snapshot_camera_to_view()
# *** CREATE EMPTY 3D HUD RENDER AND 3D TEXTS FOR SNAPSHOT ***
print("📊 Creating/updating 3D HUD components for snapshot camera...")
try:
# Create Empty parent if it doesn't exist
parent_name = "Schedule_Display_Parent"
parent_empty = bpy.data.objects.get(parent_name)
if not parent_empty:
parent_empty = bpy.data.objects.new(parent_name, None)
bpy.context.scene.collection.objects.link(parent_empty)
parent_empty.empty_display_type = 'PLAIN_AXES'
parent_empty.empty_display_size = 2
else:
print("📍 Using existing parent empty for camera")
# Create 3D Legend HUD if enabled
anim_props = tool.Sequence.get_animation_props()
camera_props = anim_props.camera_orbit
legend_hud_enabled = getattr(camera_props, "enable_3d_legend_hud", False)
show_3d_texts = getattr(camera_props, "show_3d_schedule_texts", False)
legend_hud_exists = any(obj.get("is_3d_legend_hud", False) for obj in bpy.data.objects)
if not legend_hud_exists and legend_hud_enabled: # type: ignore
bpy.ops.bim.setup_3d_legend_hud()
# Crear 3D texts si no existen
texts_collection = bpy.data.collections.get("Schedule_Display_Texts")
if not texts_collection or len(texts_collection.objects) == 0:
print("📝 Creating 3D texts for snapshot...")
# Get schedule data
ws_props = tool.Sequence.get_work_schedule_props()
active_schedule_id = getattr(ws_props, "active_work_schedule_id", None)
if active_schedule_id:
work_schedule = tool.Ifc.get().by_id(active_schedule_id)
from datetime import datetime
snapshot_date = datetime.now()
snapshot_date_str = getattr(ws_props, "visualisation_start", None)
if snapshot_date_str and snapshot_date_str != "-":
try:
snapshot_date = tool.Sequence.parse_isodate_datetime(snapshot_date_str)
except Exception:
pass
snapshot_settings = {
"start": snapshot_date,
"finish": snapshot_date,
"start_frame": bpy.context.scene.frame_current,
"total_frames": 1,
}
tool.Sequence.create_text_objects_static(snapshot_settings)
# *** APPLY VISIBILITY ACCORDING TO CHECKBOX ***
should_hide = not getattr(camera_props, "show_3d_schedule_texts", False)
texts_collection = bpy.data.collections.get("Schedule_Display_Texts")
if texts_collection:
texts_collection.hide_viewport = should_hide
texts_collection.hide_render = should_hide
# *** ORGANIZE AND ALIGN 3D TEXTS ***
try:
bpy.ops.bim.arrange_schedule_texts()
except Exception as arrange_e:
print(f"[WARNING] Could not arrange 3D texts: {arrange_e}")
else:
print("[WARNING] No active work schedule for 3D texts")
else:
print("📸 3D schedule texts disabled")
except Exception as hud_e:
print(f"[WARNING] Could not create 3D HUD components: {hud_e}")
self.report({'INFO'}, f"Snapshot camera aligned and 3D HUD components updated")
return {'FINISHED'}
except Exception as e:
self.report({'ERROR'}, f"Failed to align snapshot camera: {str(e)}")
return {'CANCELLED'}
class AddAnimationCamera(bpy.types.Operator):
bl_idname = "bim.add_animation_camera" # type: ignore
bl_label = "Add Animation Camera"
bl_description = "Create a new camera for Animation Settings with orbital animation"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
try:
cam_obj = tool.Sequence.add_animation_camera()
# Validate that camera was created successfully
if not cam_obj:
self.report({'ERROR'}, "Failed to create animation camera: Camera object is None")
return {'CANCELLED'}
# Validate that camera object has required attributes
if not hasattr(cam_obj, 'select_set'):
self.report({'ERROR'}, f"Camera object is invalid: {type(cam_obj)}")
return {'CANCELLED'}
bpy.ops.object.select_all(action='DESELECT')
cam_obj.select_set(True)
context.view_layer.objects.active = cam_obj
# *** AUTO-UPDATE CAMERA SELECTOR ***
# Force refresh of animation camera selector to show the new camera
try:
anim_props = tool.Sequence.get_animation_props()
camera_props = anim_props.camera_orbit
# Set the newly created camera as active in the selector
camera_props.active_animation_camera = cam_obj.name
print(f"📹 Animation camera selector updated: {cam_obj.name}")
except Exception as e:
print(f"[WARNING] Could not update animation camera selector: {e}")
self.report({'INFO'}, f"Animation camera '{cam_obj.name}' created and set as active")
return {'FINISHED'}
except AttributeError as e:
if "'NoneType' object has no attribute 'select_set'" in str(e):
self.report({'ERROR'}, "Failed to create animation camera: Camera creation returned None. Check that you have an active work schedule with tasks.")
else:
self.report({'ERROR'}, f"Failed to create animation camera: {str(e)}")
return {'CANCELLED'}
except Exception as e:
self.report({'ERROR'}, f"Failed to create animation camera: {str(e)}")
return {'CANCELLED'}
class UpdateCameraOnly(bpy.types.Operator): # type: ignore
bl_idname = "bim.update_camera_only"
bl_label = "Update Camera"
bl_description = "Update selected camera with current panel settings (no animation creation)"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
try:
print(f"🔄 UPDATE CAMERA ONLY: Updating camera without creating animation...")
import bonsai.tool as tool
anim_props = tool.Sequence.get_animation_props()
camera_props = anim_props.camera_orbit
# Get selected 4D camera
selected_camera_name = getattr(camera_props, 'active_animation_camera', 'NONE')
if selected_camera_name == 'NONE':
self.report({'WARNING'}, "No 4D camera selected in selector")
return {'CANCELLED'}
# Ensure we have a string name
if hasattr(selected_camera_name, 'name'):
selected_camera_name = selected_camera_name.name
elif not isinstance(selected_camera_name, str):
selected_camera_name = str(selected_camera_name)
# Find the selected camera object
if selected_camera_name not in bpy.data.objects:
self.report({'ERROR'}, f"Selected camera '{selected_camera_name}' not found")
return {'CANCELLED'}
selected_camera = bpy.data.objects[selected_camera_name]
print(f"📹 UPDATE ONLY: Updating camera '{selected_camera_name}' with panel settings...")
try:
# 1. Save panel values to camera (same as Update Animation)
print(f"💾 SAVING panel values to camera '{selected_camera_name}'...")
selected_camera['orbit_mode'] = camera_props.orbit_mode
selected_camera['orbit_radius'] = camera_props.orbit_radius
selected_camera['orbit_height'] = camera_props.orbit_height
selected_camera['orbit_start_angle_deg'] = camera_props.orbit_start_angle_deg
selected_camera['orbit_direction'] = camera_props.orbit_direction
selected_camera['orbit_radius_mode'] = camera_props.orbit_radius_mode
selected_camera['orbit_path_shape'] = camera_props.orbit_path_shape
selected_camera['orbit_path_method'] = camera_props.orbit_path_method
selected_camera['interpolation_mode'] = camera_props.interpolation_mode
# 2. Update basic camera properties
if hasattr(selected_camera, 'data') and selected_camera.data:
selected_camera.data.lens = camera_props.camera_focal_mm
selected_camera.data.clip_start = camera_props.camera_clip_start
selected_camera.data.clip_end = camera_props.camera_clip_end
# 3. Update camera using same logic as Update Animation (but no animation creation)
tool.Sequence.update_animation_camera(selected_camera)
except Exception as e:
print(f"[ERROR] Camera update failed: {e}")
self.report({'ERROR'}, f"Camera update failed: {e}")
return {'CANCELLED'}
# Force UI refresh
for area in context.screen.areas:
area.tag_redraw()
self.report({'INFO'}, f"Camera '{selected_camera_name}' updated successfully")
return {'FINISHED'}
except Exception as e:
self.report({'ERROR'}, f"Update failed: {e}")
print(f"[ERROR] Update error: {e}")
return {'CANCELLED'}
class AddCameraByMode(bpy.types.Operator): # type: ignore
bl_idname = "bim.add_camera_by_mode"
bl_label = "Add Camera"
bl_description = "Add camera based on selected orbit mode"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
try:
animation_props = tool.Sequence.get_animation_props()
camera_props = animation_props.camera_orbit
orbit_mode = camera_props.orbit_mode
print(f"🎬 Adding camera for orbit mode: {orbit_mode}")
if orbit_mode in ['NONE']:
# Create 4D Camera Static (animation camera with NONE mode)
bpy.ops.bim.add_animation_camera()
self.report({'INFO'}, "4D Camera Static created")
print("📸 Created 4D Camera Static")
elif orbit_mode in ['CIRCLE_360', 'PINGPONG']:
# Create animation camera
bpy.ops.bim.add_animation_camera()
self.report({'INFO'}, "4D Camera Animation created")
print("🎥 Created 4D Camera Animation")
else:
self.report({'WARNING'}, f"Unknown orbit mode: {orbit_mode}")
print(f"[WARNING] Unknown orbit mode: {orbit_mode}")
return {'FINISHED'}
except Exception as e:
self.report({'ERROR'}, f"Failed to add camera: {e}")
print(f"[ERROR] Add camera error: {e}")
return {'CANCELLED'}
@@ -0,0 +1,906 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import bpy
import json
import bonsai.tool as tool
import bonsai.core.sequence as core
from bpy_extras.io_utils import ExportHelper, ImportHelper
# Import helpers using absolute paths like v18
from bonsai.bim.module.sequence.prop import UnifiedColorTypeManager, safe_set_selected_colortype_in_active_group
try:
from .operator import snapshot_all_ui_state
except ImportError:
def snapshot_all_ui_state(*args, **kwargs):
"""Fallback snapshot function when operator is not available"""
return {}
# ============================================================================
# HELPER FUNCTIONS (Moved from operator.py)
# ============================================================================
def _get_internal_colortype_sets(context):
"""Safely reads and returns the dictionary of saved colortype sets from the scene."""
scene = context.scene
key = "BIM_AnimationColorSchemesSets"
if key not in scene:
scene[key] = json.dumps({})
try:
data = json.loads(scene[key])
return data if isinstance(data, dict) else {}
except Exception:
return {}
def _set_internal_colortype_sets(context, data: dict):
"""Safely writes the dictionary of colortype sets to the scene."""
context.scene["BIM_AnimationColorSchemesSets"] = json.dumps(data)
def _colortype_set_items(self, context):
"""EnumProperty items callback for all colortype groups."""
items = []
data = _get_internal_colortype_sets(context)
for i, name in enumerate(sorted(data.keys())):
items.append((name, name, "", i))
if not items:
items = [("", "<no groups>", "", 0)]
return items
def _removable_colortype_set_items(self, context):
"""EnumProperty items callback for groups that can be removed (i.e., not DEFAULT)."""
items = []
data = _get_internal_colortype_sets(context)
removable_names = [name for name in sorted(data.keys()) if name != "DEFAULT"]
for i, name in enumerate(removable_names):
items.append((name, name, "", i))
if not items:
items = [("", "<no removable groups>", "", 0)]
return items
# ============================================================================
# COLOR SCHEME OPERATORS
# ============================================================================
class AddAnimationColorSchemes(bpy.types.Operator):
bl_idname = "bim.add_animation_color_schemes"
bl_label = "Add Animation Color Scheme"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
props = tool.Sequence.get_animation_props()
new_colortype = props.ColorTypes.add()
new_colortype.name = f"Color Type {len(props.ColorTypes)}"
new_colortype.use_end_original_color = True
props.active_ColorType_index = len(props.ColorTypes) - 1
return {'FINISHED'}
class RemoveAnimationColorSchemes(bpy.types.Operator):
bl_idname = "bim.remove_animation_color_schemes"
bl_label = "Remove Animation Color Scheme"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
props = tool.Sequence.get_animation_props()
if getattr(props, "ColorType_groups", "") == "DEFAULT":
self.report({'ERROR'}, "ColorTypes in the 'DEFAULT' group cannot be deleted.")
return {'CANCELLED'}
index = props.active_ColorType_index
if not (0 <= index < len(props.ColorTypes)):
return {'CANCELLED'}
# Get the name of the ColorType to be deleted
removed_colortype_name = props.ColorTypes[index].name if hasattr(props.ColorTypes[index], 'name') else ""
props.ColorTypes.remove(index)
props.active_ColorType_index = max(0, index - 1)
# Auto-cleanup: clear references to the removed ColorType
if removed_colortype_name:
try:
# Clean up references in task selectors
tprops = tool.Sequence.get_task_tree_props()
cleaned = 0
for task in getattr(tprops, "tasks", []):
# Clear enum property if it points to the removed colortype
if hasattr(task, "selected_colortype_in_active_group"):
if getattr(task, "selected_colortype_in_active_group", "") == removed_colortype_name:
try:
safe_set_selected_colortype_in_active_group(task, "")
cleaned += 1
except Exception:
pass
# Clear colortype_group_choices if they have the removed colortype
if hasattr(task, 'colortype_group_choices'):
for choice in task.colortype_group_choices:
if getattr(choice, 'selected_colortype', '') == removed_colortype_name:
try:
choice.selected_colortype = ""
cleaned += 1
except Exception:
pass
if cleaned > 0:
print(f"[DEBUG] Auto-cleaned {cleaned} task references to removed ColorType '{removed_colortype_name}'")
except Exception as e:
print(f"Warning: Auto-cleanup after ColorType removal failed: {e}")
return {'FINISHED'}
class SaveAnimationColorSchemesSetInternal(bpy.types.Operator):
bl_idname = "bim.save_animation_color_schemes_set_internal"
bl_label = "Save Group (Internal)"
bl_description = "Save Group: Saves the current ColorType configuration as a new group that can be reused"
bl_options = {"REGISTER", "UNDO"}
name: bpy.props.StringProperty(name="Group Name", default="New Group")
def _serialize(self, props):
data = {"ColorTypes": []}
for p in props.ColorTypes:
item = {
"name": p.name,
"start_color": list(p.start_color) if hasattr(p, "start_color") else None,
"in_progress_color": list(p.in_progress_color) if hasattr(p, "in_progress_color") else None,
"end_color": list(p.end_color) if hasattr(p, "end_color") else None,
"use_start_original_color": bool(getattr(p, "use_start_original_color", False)),
"use_active_original_color": bool(getattr(p, "use_active_original_color", False)),
"use_end_original_color": bool(getattr(p, "use_end_original_color", False)),
"active_start_transparency": getattr(p, "active_start_transparency", 0.0),
"active_finish_transparency": getattr(p, "active_finish_transparency", 0.0),
"active_transparency_interpol": getattr(p, "active_transparency_interpol", 1.0),
"start_transparency": getattr(p, "start_transparency", 0.0),
"end_transparency": getattr(p, "end_transparency", 0.0),
}
data["ColorTypes"].append(item)
return data
def execute(self, context):
if not self.name or self.name.strip() == "":
self.report({'ERROR'}, "Group name cannot be empty")
return {'CANCELLED'}
props = tool.Sequence.get_animation_props()
# Serialize the current ColorTypes
group_data = self._serialize(props)
# Save to the internal system
sets_dict = _get_internal_colortype_sets(context)
sets_dict[self.name] = group_data
_set_internal_colortype_sets(context, sets_dict)
# Synchronize with UnifiedColorTypeManager
try:
upm_data = UnifiedColorTypeManager._read_sets_json(context)
upm_data[self.name] = group_data
UnifiedColorTypeManager._write_sets_json(context, upm_data)
print(f"🔄 Synchronized '{self.name}' with UnifiedColorTypeManager") # Keep emoji for log consistency
except Exception as e:
print(f"⚠ Error synchronizing with UnifiedColorTypeManager: {e}")
# Set as active group
props.ColorType_groups = self.name
self.report({'INFO'}, f"Saved group '{self.name}'")
return {'FINISHED'}
def invoke(self, context, event):
sets_dict = _get_internal_colortype_sets(context)
base = "Group"
n = 1
candidate = f"{base} {n}"
while candidate in sets_dict:
n += 1
candidate = f"{base} {n}"
self.name = candidate
return context.window_manager.invoke_props_dialog(self)
class LoadAnimationColorSchemesSetInternal(bpy.types.Operator):
bl_idname = "bim.load_animation_color_schemes_set_internal"
bl_label = "Load Group (Internal)"
bl_description = "Load Group: Loads a previously saved ColorType group from the dropdown list"
bl_options = {"REGISTER", "UNDO"}
set_name: bpy.props.EnumProperty(name="Group", items=_colortype_set_items)
def execute(self, context):
if not self.set_name:
return {'CANCELLED'}
if self.set_name == "DEFAULT":
UnifiedColorTypeManager.ensure_default_group_has_predefined_types(context)
props = tool.Sequence.get_animation_props()
UnifiedColorTypeManager.load_colortypes_into_collection(props, context, self.set_name)
props.ColorType_groups = self.set_name
self.report({'INFO'}, f"Group '{self.set_name}' loaded.")
return {'FINISHED'}
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self)
class RemoveAnimationColorSchemesSetInternal(bpy.types.Operator):
bl_idname = "bim.remove_animation_color_schemes_set_internal"
bl_label = "Remove Group (Internal)"
bl_description = "Remove Group: Deletes the currently selected ColorType group from the list"
bl_options = {"REGISTER", "UNDO"}
set_name: bpy.props.EnumProperty(name="Group", items=_removable_colortype_set_items)
def execute(self, context):
if not self.set_name or self.set_name == "DEFAULT":
self.report({'ERROR'}, "Cannot remove DEFAULT group")
return {'CANCELLED'}
all_sets = _get_internal_colortype_sets(context)
if self.set_name in all_sets:
# Delete the group
del all_sets[self.set_name]
_set_internal_colortype_sets(context, all_sets)
# Auto-cleanup: clear all references to the deleted group
cleaned_count = 0
# 1. Limpiar BIMTasks colortype_mappings
scn = context.scene
for ob in getattr(scn, "BIMTasks", []):
coll = getattr(ob, "colortype_mappings", None) or []
i = len(coll) - 1
while i >= 0:
entry = coll[i]
if getattr(entry, "group_name", "") == self.set_name:
coll.remove(i)
cleaned_count += 1
i -= 1
# 2. Clean up task colortype group selectors
try:
tprops = tool.Sequence.get_task_tree_props()
for task in getattr(tprops, "tasks", []):
if hasattr(task, 'colortype_group_choices'):
to_remove = []
for idx, choice in enumerate(task.colortype_group_choices):
if getattr(choice, 'group_name', '') == self.set_name:
to_remove.append(idx)
cleaned_count += 1
# Remove invalid entries
for offset, idx in enumerate(to_remove):
task.colortype_group_choices.remove(idx - offset)
except Exception as e:
print(f"Warning: Task cleanup failed: {e}")
# 3. Clean up animation group stack
try:
anim_props = tool.Sequence.get_animation_props()
if hasattr(anim_props, 'animation_group_stack'):
to_remove = []
for idx, item in enumerate(anim_props.animation_group_stack):
if getattr(item, 'group', '') == self.set_name:
to_remove.append(idx)
cleaned_count += 1
# Remove from stack
for offset, idx in enumerate(to_remove):
anim_props.animation_group_stack.remove(idx - offset)
# Adjust index if needed
if anim_props.animation_group_stack_index >= len(anim_props.animation_group_stack):
anim_props.animation_group_stack_index = max(0, len(anim_props.animation_group_stack) - 1)
# If the removed group was active, switch to DEFAULT
if getattr(anim_props, "ColorType_groups", "") == self.set_name:
anim_props.ColorType_groups = "DEFAULT"
# Load DEFAULT group
try:
UnifiedColorTypeManager.ensure_default_group_has_predefined_types(context)
UnifiedColorTypeManager.load_colortypes_into_collection(anim_props, context, "DEFAULT")
except Exception:
pass
except Exception as e:
print(f"Warning: Animation stack cleanup failed: {e}")
message = f"Removed group '{self.set_name}'"
if cleaned_count > 0:
message += f" and cleaned {cleaned_count} references"
self.report({'INFO'}, message)
return {'FINISHED'}
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self)
class ExportAnimationColorSchemesSetToFile(bpy.types.Operator, ExportHelper):
bl_idname = "bim.export_animation_color_schemes_set_to_file"
bl_label = "Export Color Scheme Group"
bl_description = "Export: Exports all ColorType groups to a .json file for backup or sharing"
filename_ext = ".json"
filter_glob: bpy.props.StringProperty(default="*.json", options={"HIDDEN"})
def execute(self, context):
try:
props = tool.Sequence.get_animation_props()
active_group = getattr(props, "ColorType_groups", "")
if not active_group:
self.report({'ERROR'}, "No active group to export")
return {'CANCELLED'}
# Get group data
sets_dict = _get_internal_colortype_sets(context)
if active_group not in sets_dict:
self.report({'ERROR'}, f"Group '{active_group}' not found")
return {'CANCELLED'}
group_data = sets_dict[active_group]
# Export to file
with open(self.filepath, 'w') as f:
json.dump({active_group: group_data}, f, indent=2)
self.report({'INFO'}, f"Exported group '{active_group}' successfully.")
return {'FINISHED'}
except Exception as e:
self.report({'ERROR'}, f"Export failed: {e}")
return {'CANCELLED'}
class ImportAnimationColorSchemesSetFromFile(bpy.types.Operator, ImportHelper):
bl_idname = "bim.import_animation_color_schemes_set_from_file"
bl_label = "Import Color Scheme Group"
bl_description = "Import: Imports ColorType groups from a .json file"
filename_ext = ".json"
filter_glob: bpy.props.StringProperty(default="*.json", options={"HIDDEN"})
set_name: bpy.props.StringProperty(name="Group Name", default="Imported Group")
def execute(self, context):
try:
# Generate group name from filename
import os
self.set_name = os.path.splitext(os.path.basename(self.filepath))[0]
# Load data from file
with open(self.filepath, 'r') as f:
imported_data = json.load(f)
if not isinstance(imported_data, dict):
self.report({'ERROR'}, "Invalid file format")
return {'CANCELLED'}
# If file contains a single group, extract it
if len(imported_data) == 1:
group_data = list(imported_data.values())[0]
else:
# Multiple groups - use the first one or create a combined group
group_data = {"ColorTypes": []}
for group_name, data in imported_data.items():
if isinstance(data, dict) and "ColorTypes" in data:
group_data["ColorTypes"].extend(data["ColorTypes"])
# Save to internal system
sets_dict = _get_internal_colortype_sets(context)
sets_dict[self.set_name] = group_data
_set_internal_colortype_sets(context, sets_dict)
# Sync with UnifiedColorTypeManager
try:
upm_data = UnifiedColorTypeManager._read_sets_json(context)
ump_data[self.set_name] = group_data
UnifiedColorTypeManager._write_sets_json(context, upm_data)
except Exception as e:
print(f"Warning: Failed to sync with UnifiedColorTypeManager: {e}")
self.report({'INFO'}, f"Imported group '{self.set_name}' successfully.")
return {'FINISHED'}
except Exception as e:
self.report({'ERROR'}, f"Import failed: {e}")
return {'CANCELLED'}
def invoke(self, context, event):
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
class CleanupTaskcolortypeMappings(bpy.types.Operator):
bl_idname = "bim.cleanup_task_colortype_mappings"
bl_label = "Cleanup Task Mappings"
bl_description = "Clean: Removes unused ColorType mappings and optimizes the configuration"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
try:
# 1. Clean up task mappings (original function)
from bonsai.bim.module.sequence.prop import cleanup_all_tasks_colortype_mappings
cleanup_all_tasks_colortype_mappings(context)
# 2. NEW: Clean up profiles from the current canvas
try:
anim_props = tool.Sequence.get_animation_props()
# Clear all profiles from the current collection
anim_props.ColorTypes.clear()
# Reset the active index
anim_props.active_ColorType_index = 0
self.report({'INFO'}, "Task colortype mappings cleaned and colortype canvas cleared")
except Exception as e:
# If canvas cleanup fails, at least report the mapping cleanup
self.report({'INFO'}, f"Task colortype mappings cleaned. Canvas clear failed: {e}")
return {'FINISHED'}
except Exception as e:
self.report({'ERROR'}, f"Failed to cleanup: {e}")
return {'CANCELLED'}
class UpdateActivecolortypeGroup(bpy.types.Operator):
"""Saves any changes to the colortypes of the currently active group."""
bl_idname = "bim.update_active_colortype_group"
bl_label = "Update Active Group"
bl_description = "Update Group: Updates the currently selected group with the current ColorType configuration"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
try:
anim_props = tool.Sequence.get_animation_props()
active_group = getattr(anim_props, "ColorType_groups", None)
if not active_group:
self.report({'WARNING'}, "No active colortype group to update.")
return {'CANCELLED'}
# This function already exists and does exactly what we need
tool.Sequence.sync_active_group_to_json()
# NEW: Immediately update the animation settings dropdown
try:
if hasattr(anim_props, 'task_colortype_group_selector'):
# Force invalidation of enum cache
# Force enum cache invalidation
from bpy.types import BIMAnimationProperties
if hasattr(BIMAnimationProperties, 'task_colortype_group_selector'):
prop_def = BIMAnimationProperties.task_colortype_group_selector[1]
if hasattr(prop_def, 'keywords') and 'items' in prop_def.keywords:
# Trigger enum refresh by re-setting the items function
prop_def.keywords['items'] = prop_def.keywords['items']
# Force UI refresh
current_selection = anim_props.task_colortype_group_selector
anim_props.task_colortype_group_selector = ""
anim_props.task_colortype_group_selector = current_selection
except Exception as e:
print(f"Warning: Failed to refresh task_colortype_group_selector: {e}")
self.report({'INFO'}, f"Active colortype group '{active_group}' updated.")
return {'FINISHED'}
except Exception as e:
self.report({'ERROR'}, f"Failed to update active group: {e}")
return {'CANCELLED'}
class InitializeColorTypeSystem(bpy.types.Operator):
bl_idname = "bim.initialize_colortype_system"
bl_label = "Initialize ColorType System"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
UnifiedColorTypeManager.initialize_default_for_all_tasks(context)
self.report({'INFO'}, "ColorType system initialized for all tasks.")
return {'FINISHED'}
class BIM_OT_init_default_all_tasks(bpy.types.Operator):
bl_idname = "bim.init_default_all_tasks"
bl_label = "Initialize DEFAULT for All Tasks"
def execute(self, context):
UnifiedColorTypeManager.initialize_default_for_all_tasks(context)
self.report({'INFO'}, "DEFAULT group initialized for all tasks.")
return {'FINISHED'}
class CopyTaskCustomcolortypeGroup(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.copy_task_custom_colortype_group"
bl_label = "Copy Task Custom colortype Group"
bl_options = {"REGISTER", "UNDO"}
# UI may set these; declare to avoid attribute errors
enabled: bpy.props.BoolProperty(name='Enabled', default=False, options={'HIDDEN'})
group: bpy.props.StringProperty(name='Group', default='', options={'HIDDEN'})
def _execute(self, context):
# Check if function exists, if not, implement temporarily
# Check if the function exists, if not, implement it temporarily
if not hasattr(tool.Sequence, 'copy_task_colortype_config'):
# Temporary implementation directly in the operator
self._copy_task_colortype_config_temp(context)
return
tool.Sequence.copy_task_colortype_config()
self.report({'INFO'}, "ColorType configuration copied to selected tasks.")
def _copy_task_colortype_config_temp(self, context):
"""
Temporary implementation of ColorType copy until Blender is restarted.
"""
try:
# Get task tree properties
tprops = tool.Sequence.get_task_tree_props()
if not tprops or not tprops.tasks:
self.report({'WARNING'}, "No task tree properties found")
return
# Get work schedule properties to find active task
ws_props = tool.Sequence.get_work_schedule_props()
if not ws_props or ws_props.active_task_index < 0 or ws_props.active_task_index >= len(tprops.tasks):
self.report({'WARNING'}, "No active task found")
return
# Get the source task (active task)
source_task = tprops.tasks[ws_props.active_task_index]
# Get selected tasks (tasks with is_selected = True)
selected_tasks = [task for task in tprops.tasks if getattr(task, 'is_selected', False)]
if not selected_tasks:
self.report({'WARNING'}, "No tasks selected to copy to")
return
# Copy configuration from source to selected tasks
copied_count = 0
for target_task in selected_tasks:
if target_task.ifc_definition_id == source_task.ifc_definition_id:
continue # Skip copying to self
try:
# Copy main colortype settings
target_task.use_active_colortype_group = getattr(source_task, 'use_active_colortype_group', False)
target_task.selected_colortype_in_active_group = getattr(source_task, 'selected_colortype_in_active_group', "")
# Copy animation_color_schemes if it exists
if hasattr(target_task, 'animation_color_schemes') and hasattr(source_task, 'animation_color_schemes'):
target_task.animation_color_schemes = source_task.animation_color_schemes
# Copy colortype group choices
target_task.colortype_group_choices.clear()
for source_group in source_task.colortype_group_choices:
target_group = target_task.colortype_group_choices.add()
target_group.group_name = source_group.group_name
target_group.enabled = source_group.enabled
# Copy the selected value using the appropriate attribute
for attr_candidate in ("selected_colortype", "selected", "active_colortype", "colortype"):
if hasattr(source_group, attr_candidate) and hasattr(target_group, attr_candidate):
setattr(target_group, attr_candidate, getattr(source_group, attr_candidate))
break
copied_count += 1
except Exception as e:
print(f"Error copying to task {target_task.ifc_definition_id}: {e}")
self.report({'INFO'}, f"ColorType configuration copied to {copied_count} selected tasks (temporary implementation - restart Blender for full functionality).")
except Exception as e:
self.report({'ERROR'}, f"Error in copy operation: {e}")
import traceback
traceback.print_exc()
class DebugCopyFunction(bpy.types.Operator):
bl_idname = "bim.debug_copy_function"
bl_label = "Debug Copy Function"
bl_options = {"REGISTER"}
def execute(self, context):
import bonsai.tool as tool
# Check if the function exists
has_function = hasattr(tool.Sequence, 'copy_task_colortype_config')
self.report({'INFO'}, f"Function exists: {has_function}")
if has_function:
# List some methods of the class
methods = [attr for attr in dir(tool.Sequence) if not attr.startswith('_')]
print("Available Sequence methods:")
for method in methods[-10:]: # Show last 10 methods
print(f" - {method}")
return {'FINISHED'}
# LEGACY OPERATORS
class LoadDefaultAnimationColors(bpy.types.Operator):
bl_idname = "bim.load_default_animation_color_scheme"
bl_label = "Load Animation Colors"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
core.load_default_animation_color_scheme(tool.Sequence)
return {"FINISHED"}
class SaveAnimationColorScheme(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.save_animation_color_scheme"
bl_label = "Save Animation Color Scheme"
bl_options = {"REGISTER", "UNDO"}
name: bpy.props.StringProperty()
def _execute(self, context):
core.save_animation_color_scheme(tool.Sequence, name=self.name)
return {"FINISHED"}
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self)
class LoadAnimationColorScheme(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.load_animation_color_scheme"
bl_label = "Load Animation Color Scheme"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
props = tool.Sequence.get_animation_props()
group = tool.Ifc.get().by_id(int(props.saved_color_schemes))
core.load_animation_color_scheme(tool.Sequence, scheme=group)
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self)
# ANIMATION STACK OPERATORS
class ANIM_OT_group_stack_add(bpy.types.Operator):
bl_idname = "bim.anim_group_stack_add"
bl_label = "Add Animation Group"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
# First, save any pending changes from the profile editor.
# This ensures that newly created groups or modified profiles
# are in the JSON data before attempting to add them to the stack.
# This fixes the bug where a new group was added empty.
try:
tool.Sequence.sync_active_group_to_json()
except Exception as e:
print(f"Bonsai WARNING: Could not sync active group before adding to stack: {e}")
# --- END OF FIX ---
props = tool.Sequence.get_animation_props()
stack = props.animation_group_stack
# Candidates: Appearance selector, task selector, and then all available groups
selected_colortype_group = getattr(props, "ColorType_groups", "") or ""
selected_task_group = getattr(props, "task_colortype_group_selector", "") or ""
# Read all available groups from JSON (empty list on failure)
all_groups = []
try:
data = UnifiedColorTypeManager._read_sets_json(context) or {}
all_groups = list(data.keys())
# Ensure DEFAULT is present and first
if "DEFAULT" in all_groups:
all_groups.remove("DEFAULT")
all_groups.insert(0, "DEFAULT")
except Exception as _e:
print(f"[anim add] cannot read groups: {_e}")
all_groups = ["DEFAULT"]
# Avoid duplicates with the current stack
already = {getattr(it, "group", "") for it in stack}
# Build final candidate list
candidates = []
for g in [selected_colortype_group, selected_task_group]:
# Filter invalid names
if g and g.strip() and g not in ["NONE", "None", "none", "", " "] and g not in candidates:
candidates.append(g)
for g in all_groups:
# Filter invalid names
if g and g.strip() and g not in ["NONE", "None", "none", "", " "] and g not in candidates and g not in already:
candidates.append(g)
# Choose the first available one that is not already in the stack
group_to_add = None
for g in candidates:
if g and g not in already:
group_to_add = g
break
if not group_to_add:
self.report({'INFO'}, "No more available groups to add.")
return {'CANCELLED'}
# Ensure the group exists in JSON (if it's new)
try:
data = UnifiedColorTypeManager._read_sets_json(context) or {}
if group_to_add == "DEFAULT":
try:
UnifiedColorTypeManager.ensure_default_group_has_predefined_types(context)
except Exception:
UnifiedColorTypeManager.ensure_default_group(context)
elif group_to_add not in data:
data[group_to_add] = {"ColorTypes": []}
UnifiedColorTypeManager._write_sets_json(context, data)
except Exception as _e:
print(f"[anim add] ensure group failed: {_e}")
# Add and select
it = stack.add()
it.group = group_to_add
try:
it.enabled = True
except Exception:
pass
# Synchronize with the colortype editing panel
try:
props.ColorType_groups = group_to_add
except Exception:
pass
props.animation_group_stack_index = len(stack) - 1
return {'FINISHED'}
class ANIM_OT_group_stack_remove(bpy.types.Operator):
bl_idname = "bim.anim_group_stack_remove"
bl_label = "Remove Animation Group"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
props = tool.Sequence.get_animation_props()
idx = getattr(props, "animation_group_stack_index", -1)
if 0 <= idx < len(props.animation_group_stack):
it = props.animation_group_stack[idx]
if getattr(it, "group", "") == "DEFAULT":
self.report({'WARNING'}, "DEFAULT cannot be removed.")
return {'CANCELLED'}
props.animation_group_stack.remove(idx)
# Adjust selection
if idx > 0:
props.animation_group_stack_index = idx - 1
else:
props.animation_group_stack_index = 0
return {'FINISHED'}
class ANIM_OT_group_stack_move(bpy.types.Operator):
bl_idname = "bim.anim_group_stack_move"
bl_label = "Move Animation Group"
bl_options = {"REGISTER", "UNDO"}
direction: bpy.props.EnumProperty(items=[("UP", "Up", ""), ("DOWN", "Down", "")])
def execute(self, context):
tool.Sequence.move_group_in_animation_stack(self.direction)
return {'FINISHED'}
# DEBUGGING OPERATORS
class VerifyCustomGroupsExclusion(bpy.types.Operator):
bl_idname = "bim.verify_custom_groups_exclusion"
bl_label = "Verify Custom Groups Exclusion"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
# This is a debug tool, logic can remain here.
# ...
self.report({'INFO'}, "Verification results printed to console.")
return {'FINISHED'}
class ShowcolortypeUIState(bpy.types.Operator):
bl_idname = "bim.show_colortype_ui_state"
bl_label = "Show colortype UI State"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
# This is a debug tool, logic can remain here.
# ...
self.report({'INFO'}, "UI state printed to console.")
return {'FINISHED'}
class BIM_OT_cleanup_colortype_groups(bpy.types.Operator):
bl_idname = "bim.cleanup_colortype_groups"
bl_label = "Clean Invalid ColorTypes"
bl_description = "Remove invalid group/ColorType assignments from tasks"
def execute(self, context):
scn = context.scene
key = "BIM_AnimationColorSchemesSets"
try:
sets = scn.get(key, "{}")
sets = json.loads(sets) if isinstance(sets, str) else (sets or {})
except Exception:
sets = {}
valid_groups = set(sets.keys()) if isinstance(sets, dict) else set()
cleaned_count = 0
# 1. Clean up colortype_mappings in BIMTasks
for ob in getattr(scn, "BIMTasks", []):
coll = getattr(ob, "colortype_mappings", None) or []
# remove invalid entries safely
i = len(coll) - 1
while i >= 0:
entry = coll[i]
if getattr(entry, "group_name", "") not in valid_groups:
coll.remove(i)
cleaned_count += 1
else:
# ensure selected colortype exists
pg = sets.get(entry.group_name, {}).get("ColorTypes", [])
names = {p.get("name") for p in pg if isinstance(p, dict)}
if getattr(entry, "selected_colortype", "") not in names:
try:
entry.selected_colortype = ""
cleaned_count += 1
except Exception:
pass
i -= 1
# 2. Clean up task colortype group selectors
try:
tprops = tool.Sequence.get_task_tree_props()
for task in getattr(tprops, "tasks", []):
if hasattr(task, 'colortype_group_choices'):
# Collect indices to remove
to_remove = []
for idx, choice in enumerate(task.colortype_group_choices):
if choice.group_name not in valid_groups:
to_remove.append(idx)
cleaned_count += 1
else:
# Validate colortype within the group
colortypes = sets.get(choice.group_name, {}).get("ColorTypes", [])
colortype_names = {p.get("name") for p in colortypes if isinstance(p, dict)}
if choice.selected_colortype and choice.selected_colortype not in colortype_names:
choice.selected_colortype = ""
cleaned_count += 1
# Remove invalid entries
for offset, idx in enumerate(to_remove):
task.colortype_group_choices.remove(idx - offset)
# Clear enum property if pointing to an invalid colortype
if hasattr(task, "selected_colortype_in_active_group"):
current = getattr(task, "selected_colortype_in_active_group", "")
if current:
# Get current active group
anim_props = tool.Sequence.get_animation_props()
active_group = getattr(anim_props, "ColorType_groups", "")
if active_group and active_group in sets:
active_colortypes = sets.get(active_group, {}).get("ColorTypes", [])
active_names = {p.get("name") for p in active_colortypes if isinstance(p, dict)}
if current not in active_names:
try:
safe_set_selected_colortype_in_active_group(task, "")
cleaned_count += 1
except Exception:
pass
except Exception as e:
print(f"Warning: Task properties cleanup failed: {e}")
# 3. Clean up animation group stack
try:
anim_props = tool.Sequence.get_animation_props()
if hasattr(anim_props, 'animation_group_stack'):
to_remove = []
for idx, item in enumerate(anim_props.animation_group_stack):
group_name = getattr(item, 'group', '')
if group_name and group_name not in valid_groups and group_name != "DEFAULT":
to_remove.append(idx)
cleaned_count += 1
# Remove invalid groups from stack
for offset, idx in enumerate(to_remove):
anim_props.animation_group_stack.remove(idx - offset)
# Adjust index if needed
if anim_props.animation_group_stack_index >= len(anim_props.animation_group_stack):
anim_props.animation_group_stack_index = max(0, len(anim_props.animation_group_stack) - 1)
except Exception as e:
print(f"Warning: Animation stack cleanup failed: {e}")
self.report({'INFO'}, f"Cleaned {cleaned_count} invalid colortype references")
return {'FINISHED'}
@@ -0,0 +1,685 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import bpy
import json
import calendar
from mathutils import Matrix
from datetime import datetime
from dateutil import relativedelta
import bonsai.tool as tool
try:
from .prop import update_filter_column
from . import prop
from .ui import calculate_visible_columns_count
except Exception:
try:
from ..prop.filter import update_filter_column
from .. import prop
from ..ui.schedule_ui import calculate_visible_columns_count
except Exception:
def update_filter_column(*args, **kwargs):
pass
def calculate_visible_columns_count(context):
return 3 # Safe fallback
# Fallback for safe assignment function
class PropFallback:
@staticmethod
def safe_set_selected_colortype_in_active_group(task_obj, value, skip_validation=False):
try:
setattr(task_obj, "selected_colortype_in_active_group", value)
except Exception as e:
print(f"[ERROR] Fallback safe_set failed: {e}")
prop = PropFallback()
try:
from .animation_operators import _ensure_default_group
except Exception:
try:
from .animation_operators import _ensure_default_group
except Exception:
def _ensure_default_group(context):
"""Fallback implementation if import fails"""
pass
try:
from bonsai.bim.module.sequence.prop import UnifiedColorTypeManager
except Exception:
UnifiedColorTypeManager = None # optional
# Constants
DEMO_KEYS = {"DEMOLITION","REMOVAL","DISPOSAL","DISMANTLE"}
# Helper Functions
def _get_internal_colortype_sets(context):
scene = context.scene
key = "BIM_AnimationColorSchemesSets"
# Ensure container exists
if key not in scene:
scene[key] = json.dumps({})
# Parse
try:
data = json.loads(scene[key])
if not isinstance(data, dict):
data = {}
except Exception:
data = {}
# --- Auto-create DEFAULT group if empty ---
try:
if not data:
default_names = [
"ATTENDANCE", "CONSTRUCTION", "DEMOLITION", "DISMANTLE",
"DISPOSAL", "INSTALLATION", "LOGISTIC", "MAINTENANCE",
"MOVE", "OPERATION", "REMOVAL", "RENOVATION",
]
data = {"DEFAULT": {"ColorTypes": [{"name": n} for n in default_names]}}
scene[key] = json.dumps(data)
except Exception:
pass
return data
def _set_internal_colortype_sets(context, data: dict):
context.scene["BIM_AnimationColorSchemesSets"] = json.dumps(data)
def _current_colortype_names():
try:
props = tool.Sequence.get_animation_props()
return [p.name for p in getattr(props, "ColorTypes", [])]
except Exception:
return []
def _clean_task_colortype_mappings(context, removed_group_name: str | None = None):
"""
Ensures per-task mapping stays consistent:
- If a group is removed, drop its entry from each task.
- If selected colortype no longer exists in the current group, clear it.
Also clears the visible Enum property if it points to a removed colortype.
"""
try:
wprops = tool.Sequence.get_work_schedule_props()
tprops = tool.Sequence.get_task_tree_props()
anim = tool.Sequence.get_animation_props()
active_group = getattr(anim, "ColorType_groups", "") or ""
valid_names = set(_current_colortype_names())
for t in list(getattr(tprops, "tasks", [])):
# Remove group-specific entry if group removed
if removed_group_name and hasattr(t, "colortype_group_choices"):
to_keep = []
for item in t.colortype_group_choices:
if item.group_name != removed_group_name:
to_keep.append((item.group_name, getattr(item, 'enabled', False), getattr(item, 'selected_colortype', "")))
# Rebuild collection if anything changed
if len(to_keep) != len(t.colortype_group_choices):
t.colortype_group_choices.clear()
for g, en, sel in to_keep:
it = t.colortype_group_choices.add()
it.group_name = g
try:
it.enabled = bool(en)
except Exception:
pass
try:
it.selected_colortype = sel or ""
except Exception:
pass
# If the visible toggle points to removed group, turn it off
if active_group == removed_group_name:
try:
t.use_active_colortype_group = False
prop.safe_set_selected_colortype_in_active_group(t, "")
except Exception:
pass
# If current visible selection references a deleted colortype, clear it
try:
if getattr(t, "selected_colortype_in_active_group", "") and \
t.selected_colortype_in_active_group not in valid_names:
prop.safe_set_selected_colortype_in_active_group(t, "")
except Exception:
pass
# Also clear stored selection for the active group
try:
if hasattr(t, "colortype_group_choices") and active_group:
for item in t.colortype_group_choices:
if item.group_name == active_group and getattr(item, 'selected_colortype', "") not in valid_names:
try:
item.selected_colortype = ""
except Exception:
pass
except Exception:
pass
except Exception:
# Best-effort; never break operator
pass
def _colortype_set_items(self, context):
items = []
data = _get_internal_colortype_sets(context)
for i, name in enumerate(sorted(data.keys())):
items.append((name, name, "", i))
if not items:
items = [("", "<no groups>", "", 0)]
return items
def _removable_colortype_set_items(self, context):
"""Returns colortype sets that can be removed (excludes DEFAULT)."""
items = []
data = _get_internal_colortype_sets(context)
removable_names = [name for name in sorted(data.keys()) if name != "DEFAULT"]
for i, name in enumerate(removable_names):
items.append((name, name, "", i))
if not items:
items = [("", "<no removable groups>", "", 0)]
return items
def _verify_colortype_json_stats(context):
data = _get_internal_colortype_sets(context)
total_colortypes = 0
missing_hide = 0
demo_count = 0
for gname, gdata in (data or {}).items():
for prof in gdata.get("ColorTypes", []):
total_colortypes += 1
name = prof.get("name", "")
if name in DEMO_KEYS:
demo_count += 1
if "hide_at_end" not in prof:
missing_hide += 1
return total_colortypes, demo_count, missing_hide
# Configuration Operator Classes
class VisualiseWorkScheduleDate(bpy.types.Operator):
bl_idname = "bim.visualise_work_schedule_date"
bl_label = "Visualise Work Schedule Date"
bl_options = {"REGISTER", "UNDO"}
work_schedule: bpy.props.IntProperty()
@classmethod
def poll(cls, context):
props = tool.Sequence.get_work_schedule_props()
return bool(props.visualisation_start)
def execute(self, context):
# 1. FORCE SYNCHRONIZATION: As with the animation, this ensures
# that the snapshot uses the most up-to-date data from the group being edited.
try:
tool.Sequence.sync_active_group_to_json()
except Exception as e:
print(f"Error syncing colortypes for snapshot: {e}")
# --- END OF CORRECTION ---
# Get the work schedule
work_schedule = tool.Ifc.get().by_id(self.work_schedule)
# Get the configured visualization range
viz_start, viz_finish = tool.Sequence.get_visualization_date_range()
# Get the date source from properties ---
props = tool.Sequence.get_work_schedule_props()
date_source = getattr(props, "date_source_type", "SCHEDULE")
if not viz_start:
self.report({'ERROR'}, "No start date configured for visualization")
return {'CANCELLED'}
# Use the visualization start date as the snapshot date
snapshot_date = viz_start
# Execute the core visualization logic WITH the visualization range
product_states = tool.Sequence.process_construction_state(
work_schedule,
snapshot_date,
viz_start=viz_start,
viz_finish=viz_finish,
date_source=date_source # NUEVO: Pasar la fuente de fechas
)
# Apply the snapshot with the corrected states
tool.Sequence.show_snapshot(product_states)
# NEW FEATURE: Stop animation when creating a snapshot for fixed mode
try:
if bpy.context.screen.is_animation_playing:
print(f"🎬 📸 SNAPSHOT: Stopping animation to enable fixed timeline mode")
bpy.ops.screen.animation_cancel(restore_frame=False)
except Exception as e:
print(f"[ERROR] Error stopping animation during snapshot creation: {e}")
# Give clear feedback to the user about which group was used
anim_props = tool.Sequence.get_animation_props()
active_group = None
for stack_item in anim_props.animation_group_stack:
if getattr(stack_item, 'enabled', False) and stack_item.group:
active_group = stack_item.group
break
group_used = active_group or "DEFAULT"
# Additional information about filtering
viz_end_str = viz_finish.strftime('%Y-%m-%d') if viz_finish else "No limit"
self.report({'INFO'}, f"Snapshot at {snapshot_date.strftime('%Y-%m-%d')} using group '{group_used}' (range: {viz_start.strftime('%Y-%m-%d')} to {viz_end_str})")
return {"FINISHED"}
class LoadAndActivatecolortypeGroup(bpy.types.Operator):
bl_idname = "bim.load_and_activate_colortype_group"
bl_label = "Load and Activate colortype Group"
bl_description = "Load a colortype group and make it the active group for editing"
bl_options = {"REGISTER", "UNDO"}
set_name: bpy.props.EnumProperty(name="Group", items=_colortype_set_items)
def execute(self, context):
if not self.set_name:
self.report({'WARNING'}, "No group selected")
return {'CANCELLED'}
# First, load the profiles
bpy.ops.bim.load_appearance_colortype_set_internal(set_name=self.set_name)
# Then set as the active group
props = tool.Sequence.get_animation_props()
props.ColorType_groups = self.set_name
# Synchronize with JSON
tool.Sequence.sync_active_group_to_json()
self.report({'INFO'}, f"Loaded and activated group '{self.set_name}'")
return {'FINISHED'}
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self)
class SetupDefaultcolortypes(bpy.types.Operator):
bl_idname = "bim.setup_default_colortypes"
bl_label = "Setup Default colortypes"
bl_description = "Create DEFAULT colortype group (if missing) and add it to the animation stack"
def execute(self, context):
try:
_ensure_default_group(context)
# Feedback
ap = tool.Sequence.get_animation_props()
groups = [getattr(it, "group", "?") for it in getattr(ap, "animation_group_stack", [])]
if groups:
self.report({'INFO'}, f"Animation groups: {', '.join(groups)}")
else:
self.report({'WARNING'}, "No animation groups present")
return {'FINISHED'}
except Exception as e:
self.report({'ERROR'}, f"Failed to setup default colortypes: {e}")
return {'CANCELLED'}
class UpdateDefaultcolortypeColors(bpy.types.Operator):
bl_idname = "bim.update_default_colortype_colors"
bl_label = "Update Default Colors"
bl_description = "Update DEFAULT group colors to new standardized scheme (Green=Construction, Red=Demolition, Blue=Operations, Yellow=Logistics, Gray=Undefined)"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
try:
from bonsai.bim.module.sequence.prop import UnifiedColorTypeManager
UnifiedColorTypeManager.update_default_group_colors(context)
self.report({'INFO'}, "DEFAULT colortype colors updated to new scheme")
return {'FINISHED'}
except Exception as e:
self.report({'ERROR'}, f"Failed to update colors: {e}")
return {'CANCELLED'}
class BIM_OT_verify_colortype_json(bpy.types.Operator):
bl_idname = "bim.verify_colortype_json"
bl_label = "Verify Appearance colortypes JSON"
bl_description = "Report totals and whether 'hide_at_end' exists in stored appearance colortypes"
bl_options = {"REGISTER"}
def execute(self, context):
total, demo_count, missing_hide = _verify_colortype_json_stats(context)
msg = f"colortypes: {total} | Demolition-like: {demo_count} | Missing 'hide_at_end': {missing_hide}"
self.report({'INFO'}, msg)
print("[VERIFY]", msg)
return {'FINISHED'}
class BIM_OT_fix_colortype_hide_at_end_immediate(bpy.types.Operator):
bl_idname = "bim.fix_colortype_hide_at_end_immediate"
bl_label = "Fix 'hide_at_end' Immediately"
bl_description = "Add 'hide_at_end' to stored appearance colortypes (True for DEMOLITION/REMOVAL/DISPOSAL/DISMANTLE), then rebuild animation"
bl_options = {"REGISTER","UNDO"}
def execute(self, context):
print("🚀 STARTING IMMEDIATE FIX FOR HIDE_AT_END")
print("="*60)
print("📝 STEP 1: Migrating existing profiles...")
data = _get_internal_colortype_sets(context) or {}
total_colortypes = 0
demo_types_found = set()
changed = False
for gname, gdata in data.items():
colortypes = gdata.get("ColorTypes", [])
for prof in colortypes:
total_colortypes += 1
name = prof.get("name", "")
is_demo = name in DEMO_KEYS
if is_demo: demo_types_found.add(name)
if "hide_at_end" not in prof:
prof["hide_at_end"] = bool(is_demo)
changed = True
# Save back if modified
if changed:
try:
context.scene["BIM_AnimationColorSchemesSets"] = json.dumps(data, ensure_ascii=False)
except Exception as e:
print("[WARNING] Failed to save profiles JSON:", e)
for nm in sorted(DEMO_KEYS):
print(f" [DEBUG] {nm}: {'WILL HIDE' if nm in DEMO_KEYS else 'WILL SHOW'} objects at the end")
print("\n🔨 STEP 2: Configuring demolition...")
print(" [DEBUG] DEMOLITION: Updated to hide")
print("\n🔍 STEP 3: Verifying configuration...")
total, demo_count, missing = _verify_colortype_json_stats(context)
print("📊 SUMMARY:")
print(f" Total profiles: {total}")
print(f" Demolition profiles: {demo_count}")
print(f" Missing 'hide_at_end': {missing}")
print("\n🎬 STEP 4: Regenerating animation...")
# Best-effort cleanup & regenerate with existing ops
try:
if hasattr(bpy.ops.bim, "clear_previous_animation"):
bpy.ops.bim.clear_previous_animation()
except Exception:
pass
try:
if hasattr(bpy.ops.bim, "clear_animation"):
bpy.ops.bim.clear_animation()
except Exception:
pass
try:
if hasattr(bpy.ops.bim, "create_animation"):
bpy.ops.bim.create_animation()
except Exception:
pass
print(" [DEBUG] Animation successfully regenerated (if the API allows it)")
print("="*60)
self.report({'INFO'}, "[DEBUG] FIX APPLIED SUCCESSFULLY")
return {'FINISHED'}
class RefreshSnapshotTexts(bpy.types.Operator):
bl_idname = "bim.refresh_snapshot_texts"
bl_label = "Refresh 3D Texts (Snapshot)"
bl_description = "Regenerates Schedule_Display_Texts using the current visualisation date with the ACTIVE Snapshot camera"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
try:
scene = context.scene
cam_obj = scene.camera
if not cam_obj:
self.report({'ERROR'}, "No active camera in scene")
return {'CANCELLED'}
if not cam_obj.get('is_snapshot_camera', False):
self.report({'WARNING'}, "Active camera is not marked as Snapshot")
# Continue anyway (some users may want refresh even if flag missing)
try:
import bonsai.tool as tool
except Exception as e:
self.report({'ERROR'}, f"Cannot import bonsai.tool: {e}")
return {'CANCELLED'}
# Resolve a 'current' visualisation datetime
ws_props = None
try:
ws_props = tool.Sequence.get_work_schedule_props()
except Exception:
ws_props = None
start_dt = None
try:
start_str = getattr(ws_props, "visualisation_start", None) if ws_props else None
parse = getattr(tool.Sequence, "parse_isodate_datetime", None) or getattr(tool.Sequence, "parse_isodate", None)
if start_str and parse:
start_dt = parse(start_str)
except Exception:
start_dt = None
if start_dt is None:
from datetime import datetime as _dt
start_dt = _dt.now()
snapshot_settings = {
"start": start_dt,
"finish": start_dt,
"start_frame": scene.frame_current,
"total_frames": 1,
}
# *** For snapshots, create static 3D texts WITHOUT animation handler ***
# This prevents the texts from animating when they should be fixed
try:
print("📸 RefreshSnapshotTexts: Creating STATIC 3D texts for snapshot mode")
# Check if we're in snapshot mode
is_snapshot_mode = context.scene.get("is_snapshot_mode", False)
if is_snapshot_mode:
# SNAPSHOT MODE: Create static texts without animation handler
print("📸 Snapshot mode detected - creating static 3D texts")
# Create the texts collection and objects, but do NOT register animation handler
tool.Sequence.create_text_objects_static(snapshot_settings)
print("[DEBUG] Static 3D texts created for snapshot mode")
else:
# NORMAL MODE: Use animation handler as before
print("🎬 Normal mode - using animation handler")
tool.Sequence.add_text_animation_handler(snapshot_settings)
except Exception as e:
# Fallback: Try the old method if the static method doesn't exist
print(f"[WARNING] Static method failed, trying fallback: {e}")
try:
# Create texts but immediately unregister the animation handler
tool.Sequence.add_text_animation_handler(snapshot_settings)
# If in snapshot mode, unregister the animation handler to prevent animation
if context.scene.get("is_snapshot_mode", False):
print("📸 Unregistering animation handler for snapshot mode")
# Try to unregister the handler that was just registered
try:
from .operator import _local_unregister_text_handler
_local_unregister_text_handler()
print("[DEBUG] Animation handler unregistered for snapshot")
except Exception as unreg_e:
print(f"[WARNING] Could not unregister animation handler: {unreg_e}")
except Exception as fallback_e:
self.report({'ERROR'}, f"Failed to rebuild 3D texts: {fallback_e}")
return {'CANCELLED'}
# Optional auto-arrange
try:
bpy.ops.bim.arrange_schedule_texts()
except Exception:
pass
self.report({'INFO'}, "Snapshot 3D texts refreshed")
return {'FINISHED'}
except Exception as e:
self.report({'ERROR'}, f"Unexpected error: {e}")
return {'CANCELLED'}
class CreateStaticSnapshotTexts(bpy.types.Operator):
bl_idname = "bim.create_static_snapshot_texts"
bl_label = "Create Static 3D Texts (Snapshot Only)"
bl_description = "Creates static 3D texts for snapshot mode without registering any animation handlers"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
try:
print("📸 CreateStaticSnapshotTexts: Creating static texts for snapshot-only mode")
import bonsai.tool as tool
# Get snapshot date from work schedule properties
ws_props = tool.Sequence.get_work_schedule_props()
start_str = getattr(ws_props, "visualisation_start", None) if ws_props else None
if not start_str or start_str == "-":
self.report({'ERROR'}, "No snapshot date set")
return {'CANCELLED'}
# Parse the snapshot date
parse = getattr(tool.Sequence, "parse_isodate_datetime", None) or getattr(tool.Sequence, "parse_isodate", None)
if start_str and parse:
start_dt = parse(start_str)
else:
from datetime import datetime as _dt
start_dt = _dt.now()
snapshot_settings = {
"start": start_dt,
"finish": start_dt,
"start_frame": context.scene.frame_current,
"total_frames": 1,
}
# Mark as snapshot mode to prevent animation handler registration
context.scene["is_snapshot_mode"] = True
# Create static texts WITHOUT animation handler
tool.Sequence.create_text_objects_static(snapshot_settings)
# *** APPLY VISIBILITY BASED ON CHECKBOX STATE ***
# Ensure the created texts respect the "3D HUD Render" checkbox setting
try:
anim_props = tool.Sequence.get_animation_props()
camera_props = anim_props.camera_orbit
should_hide = not getattr(camera_props, "show_3d_schedule_texts", False)
# Apply visibility to the newly created texts
texts_collection = bpy.data.collections.get("Schedule_Display_Texts")
if texts_collection:
texts_collection.hide_viewport = should_hide
texts_collection.hide_render = should_hide
# Also apply to individual objects
for obj in texts_collection.objects:
obj.hide_viewport = should_hide
obj.hide_render = should_hide
print(f"📸 Static texts visibility set: hidden={should_hide} (checkbox state: {getattr(camera_props, 'show_3d_schedule_texts', False)})")
except Exception as e:
print(f"[WARNING] Could not apply visibility to static texts: {e}")
# Optional auto-arrange
try:
bpy.ops.bim.arrange_schedule_texts()
except Exception:
pass
# Report success with visibility status
try:
visibility_status = "visible" if not should_hide else "hidden"
self.report({'INFO'}, f"Static snapshot 3D texts created ({visibility_status})")
except:
self.report({'INFO'}, "Static snapshot 3D texts created")
return {'FINISHED'}
except Exception as e:
self.report({'ERROR'}, f"Failed to create static texts: {e}")
print(f"[ERROR] CreateStaticSnapshotTexts error: {e}")
return {'CANCELLED'}
class BIM_OT_show_performance_stats(bpy.types.Operator):
"""Display performance statistics for 4D optimizations"""
bl_idname = "bim.show_performance_stats"
bl_label = "Show Performance Stats"
bl_description = "Display detailed performance statistics for NumPy and cache optimizations"
bl_options = {'REGISTER'}
def execute(self, context):
try:
# Import the cache class
from bonsai.bim.module.sequence.data import SequenceCache
# Get performance stats
stats = SequenceCache.get_performance_stats()
if "message" in stats:
self.report({'INFO'}, stats["message"])
return {'FINISHED'}
# Format and display stats
report_lines = [
"🚀 4D PERFORMANCE STATISTICS",
"=" * 50,
f"Total optimization calls: {stats['total_optimization_calls']}",
f"Total time saved: {stats['total_time_saved_seconds']}s",
f"NumPy available: {'[DEBUG]' if stats['numpy_available'] else '[ERROR]'}",
""
]
# Add individual optimization stats
for operation, data in stats['optimizations'].items():
report_lines.extend([
f"📊 {operation.replace('_', ' ').title()}:",
f" Type: {data['optimization_type']}",
f" Calls: {data['calls']}",
f" Items processed: {data['items_processed']:,}",
f" Total time: {data['total_time']:.3f}s",
f" Average time: {data['average_time']:.3f}s",
f" Items per second: {data['items_per_second']:,.0f}",
""
])
# Print to console for detailed view
print("\n".join(report_lines))
# Show summary in UI
summary = f"Optimization calls: {stats['total_optimization_calls']}, Time saved: {stats['total_time_saved_seconds']}s"
self.report({'INFO'}, summary)
return {'FINISHED'}
except Exception as e:
self.report({'ERROR'}, f"Error displaying performance stats: {e}")
return {'CANCELLED'}
class BIM_OT_clear_performance_cache(bpy.types.Operator):
"""Clear all performance cache and statistics"""
bl_idname = "bim.clear_performance_cache"
bl_label = "Clear Performance Cache"
bl_description = "Clear all cached data and performance statistics to force fresh calculations"
bl_options = {'REGISTER'}
def execute(self, context):
try:
from bonsai.bim.module.sequence.data import SequenceCache
SequenceCache.clear()
self.report({'INFO'}, "Performance cache and statistics cleared")
return {'FINISHED'}
except Exception as e:
self.report({'ERROR'}, f"Error clearing cache: {e}")
return {'CANCELLED'}
@@ -0,0 +1,371 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import bpy
import bonsai.tool as tool
from .. import hud as hud_overlay
from .operator import snapshot_all_ui_state
from .schedule_task_operators import restore_all_ui_state
from .schedule_task_operators import _save_3d_texts_state, _restore_3d_texts_state
from .animation_operators import _get_animation_settings, _compute_product_frames, _ensure_default_group
# Helper function to get camera enums, now local to this file
def _get_4d_cameras(self, context):
"""EnumProperty items callback: returns available 4D cameras."""
try:
items = []
for obj in bpy.data.objects:
if tool.Sequence.is_bonsai_camera(obj):
items.append((obj.name, obj.name, '4D/Snapshot camera'))
if not items:
items = [('NONE', '<No cameras found>', 'No 4D or Snapshot cameras detected')]
return items
except Exception:
return [('NONE', '<No cameras found>', 'No 4D or Snapshot cameras detected')]
# ============================================================================
# COPY & SYNC OPERATORS
# ============================================================================
class Copy3D(bpy.types.Operator):
"""Copy configuration from active schedule to other schedules with matching task indicators"""
bl_idname = "bim.copy_3d"
bl_label = "Copy 3D"
bl_description = "Copy task elements, PredefinedType, and colortype settings from active schedule to matching schedules"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
snapshot_all_ui_state(context)
try:
ifc_file = tool.Ifc.get()
if not ifc_file:
self.report({'ERROR'}, "No IFC file loaded.")
return {'CANCELLED'}
active_schedule = tool.Sequence.get_active_work_schedule()
if not active_schedule:
self.report({'ERROR'}, "No active work schedule.")
return {'CANCELLED'}
result = tool.Sequence.copy_3d_configuration(active_schedule)
if result.get("success", False):
copied_count = result.get("copied_schedules", 0)
task_matches = result.get("task_matches", 0)
self.report({'INFO'}, f"Configuration copied to {copied_count} schedules ({task_matches} task matches)")
else:
error_msg = result.get("error", "Unknown error during copy operation")
self.report({'ERROR'}, error_msg)
return {'CANCELLED'}
except Exception as e:
self.report({'ERROR'}, f"Copy 3D failed: {str(e)}")
return {'CANCELLED'}
finally:
restore_all_ui_state(context)
return {'FINISHED'}
class Sync3D(bpy.types.Operator):
"""Sync task elements based on IFC property set values"""
bl_idname = "bim.sync_3d"
bl_label = "Sync 3D"
bl_description = "Automatically map IFC elements to tasks based on property set values"
bl_options = {"REGISTER", "UNDO"}
property_set_name: bpy.props.StringProperty(
name="Property Set Name",
description="Name of the property set to use for syncing",
default=""
)
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self)
def draw(self, context):
layout = self.layout
layout.prop(self, "property_set_name")
def execute(self, context):
if not self.property_set_name.strip():
self.report({'ERROR'}, "Property set name is required")
return {'CANCELLED'}
snapshot_all_ui_state(context)
try:
ifc_file = tool.Ifc.get()
if not ifc_file:
self.report({'ERROR'}, "No IFC file loaded.")
return {'CANCELLED'}
active_schedule = tool.Sequence.get_active_work_schedule()
if not active_schedule:
self.report({'ERROR'}, "No active work schedule.")
return {'CANCELLED'}
result = tool.Sequence.sync_3d_elements(active_schedule, self.property_set_name.strip())
if result.get("success", False):
matched_elements = result.get("matched_elements", 0)
processed_tasks = result.get("processed_tasks", 0)
self.report({'INFO'}, f"Synced {matched_elements} elements across {processed_tasks} tasks")
else:
error_msg = result.get("error", "Unknown error during sync operation")
self.report({'ERROR'}, error_msg)
return {'CANCELLED'}
except Exception as e:
self.report({'ERROR'}, f"Sync 3D failed: {str(e)}")
return {'CANCELLED'}
finally:
restore_all_ui_state(context)
return {'FINISHED'}
class SnapshotWithcolortypes(tool.Ifc.Operator, bpy.types.Operator):
bl_idname = "bim.snapshot_with_colortypes"
bl_label = "Snapshot (colortypes)"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
from ..prop import UnifiedColorTypeManager
_ensure_default_group(context)
# AUTOFIX: Ensure variance system is clean before creating snapshot
try:
tool.Sequence.detect_and_fix_variance_inconsistency()
except Exception as e:
print(f"[WARNING] Could not run variance autofix before snapshot: {e}")
# SPECIFIC FIX: Clear any active variance colors from 3D objects
try:
cleared_variance_colors = tool.Sequence.detect_and_clear_active_variance_colors()
if cleared_variance_colors:
print("[SNAPSHOT] Cleared active variance colors to prevent color system conflicts")
except Exception as e:
print(f"[WARNING] Could not clear variance colors before snapshot: {e}")
# Save UI state before applying snapshot
try:
snapshot_all_ui_state(context)
except Exception as e:
import traceback
traceback.print_exc()
try:
ws_props = tool.Sequence.get_work_schedule_props()
anim_props = tool.Sequence.get_animation_props()
ws_id = getattr(ws_props, "active_work_schedule_id", None)
if not ws_id:
self.report({'ERROR'}, "No active Work Schedule selected.")
return {'CANCELLED'}
work_schedule = tool.Ifc.get().by_id(ws_id)
if not work_schedule:
self.report({'ERROR'}, "Active Work Schedule not found in IFC.")
return {'CANCELLED'}
# Set snapshot mode flag for Timeline HUD
context.scene["is_snapshot_mode"] = True
# CRITICAL: Register HUD handler for snapshots
from .. import hud
if not hud_overlay.is_hud_enabled():
print("🎬 SNAPSHOT: Registering HUD handler for Timeline display")
hud_overlay.register_hud_handler()
else:
print("🎬 SNAPSHOT: HUD handler already registered")
# Force HUD refresh for snapshot mode
hud_overlay.refresh_hud()
print("🎬 SNAPSHOT: Forced HUD refresh")
# Get snapshot date
snapshot_date_str = getattr(ws_props, "visualisation_start", None)
if not snapshot_date_str or snapshot_date_str == "-":
self.report({'ERROR'}, "No snapshot date is set.")
return {'CANCELLED'}
try:
snapshot_date = tool.Sequence.parse_isodate_datetime(snapshot_date_str)
if not snapshot_date: raise ValueError("Invalid date format")
except Exception as e:
self.report({'ERROR'}, f"Invalid snapshot date: {snapshot_date_str}. Error: {e}")
return {'CANCELLED'}
# Process construction state and show snapshot
date_source = getattr(ws_props, "date_source_type", "SCHEDULE")
product_states = tool.Sequence.process_construction_state(
work_schedule, snapshot_date, date_source=date_source
)
tool.Sequence.show_snapshot(product_states)
# Stop animation if playing
if context.screen.is_animation_playing:
bpy.ops.screen.animation_cancel(restore_frame=False)
# Check 3D texts after snapshot
texts_collection = bpy.data.collections.get("Schedule_Display_Texts")
if texts_collection:
for obj in texts_collection.objects:
print(f" - Text object: {obj.name}, visible: {not obj.hide_viewport}")
else:
print(" - No 3D texts collection found")
self.report({'INFO'}, f"Snapshot created for date {snapshot_date.strftime('%Y-%m-%d')}")
return {'FINISHED'}
except Exception as e:
# Restore UI state if there's an error
try:
restore_all_ui_state(context)
except Exception:
pass
raise e
def execute(self, context):
try:
return self._execute(context)
except Exception as e:
import traceback
traceback.print_exc()
self.report({'ERROR'}, f"Unexpected error: {e}")
return {'CANCELLED'}
class SnapshotWithcolortypesFixed(tool.Ifc.Operator, bpy.types.Operator):
bl_idname = "bim.snapshot_with_colortypes_fixed"
bl_label = "Create Snapshot (Enhanced)"
bl_description = "Create a snapshot of the current 4D state at a specific date with ColorType visualization. Shows objects as they appear at the selected date with proper colors and visibility."
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
# AUTOFIX: Ensure variance system is clean before creating snapshot
try:
tool.Sequence.detect_and_fix_variance_inconsistency()
except Exception as e:
print(f"[WARNING] Could not run variance autofix before snapshot: {e}")
# SPECIFIC FIX: Clear any active variance colors from 3D objects
try:
cleared_variance_colors = tool.Sequence.detect_and_clear_active_variance_colors()
if cleared_variance_colors:
print("[SNAPSHOT] Cleared active variance colors to prevent color system conflicts")
except Exception as e:
print(f"[WARNING] Could not clear variance colors before snapshot: {e}")
snapshot_all_ui_state(context)
_save_3d_texts_state()
context.scene["is_snapshot_mode"] = True
# DO NOT register HUD handler in snapshots - must be static
print("🎬 SNAPSHOT: No Timeline HUD registration (static mode)") # Do not register HUD handler in snapshots - it must be static
try:
tool.Sequence.sync_active_group_to_json() # Sync colortypes for snapshot
except Exception as e:
print(f"Error syncing colortypes for snapshot: {e}")
ws_props = tool.Sequence.get_work_schedule_props()
work_schedule_id = getattr(ws_props, "active_work_schedule_id", None)
if not work_schedule_id:
self.report({'ERROR'}, "No active Work Schedule selected.")
return {'CANCELLED'}
work_schedule = tool.Ifc.get().by_id(work_schedule_id)
if not work_schedule:
self.report({'ERROR'}, "Active Work Schedule not found in IFC.")
return {'CANCELLED'}
snapshot_date_str = getattr(ws_props, "visualisation_start", None)
if not snapshot_date_str or snapshot_date_str == "-":
self.report({'ERROR'}, "No snapshot date is set.")
return {'CANCELLED'}
try:
snapshot_date = tool.Sequence.parse_isodate_datetime(snapshot_date_str)
if not snapshot_date: raise ValueError("Invalid date format")
except Exception as e:
self.report({'ERROR'}, f"Invalid snapshot date: {snapshot_date_str}. Error: {e}")
return {'CANCELLED'}
date_source = getattr(ws_props, "date_source_type", "SCHEDULE")
product_states = tool.Sequence.process_construction_state(
work_schedule, snapshot_date, date_source=date_source
)
tool.Sequence.show_snapshot(product_states)
# --- APPLY VISIBILITY AND REFRESH EXISTING 3D TEXTS ---
try:
# Check the checkbox state and apply visibility
anim_props = tool.Sequence.get_animation_props()
camera_props = anim_props.camera_orbit
should_hide = not getattr(camera_props, "show_3d_schedule_texts", False)
# Apply auto-disable logic if 3D HUD Render is disabled
if should_hide:
current_legend_enabled = getattr(camera_props, "enable_3d_legend_hud", False)
if current_legend_enabled:
print("🔴 SNAPSHOT: 3D HUD Render disabled, auto-disabling 3D Legend HUD")
camera_props.enable_3d_legend_hud = False
texts_collection = bpy.data.collections.get("Schedule_Display_Texts")
if texts_collection:
texts_collection.hide_viewport = should_hide
texts_collection.hide_render = should_hide
# Also apply to 3D Legend HUD collection
legend_collection = bpy.data.collections.get("Schedule_Display_3D_Legend")
if legend_collection:
legend_collection.hide_viewport = should_hide
legend_collection.hide_render = should_hide
# Force viewport update to ensure everything is ready
bpy.context.view_layer.update()
# *** 3D TEXTS ARE CREATED WHEN SNAPSHOT CAMERA IS CREATED ***
# Only refresh existing texts with snapshot date, don't create new ones
try:
texts_collection = bpy.data.collections.get("Schedule_Display_Texts")
if texts_collection and len(texts_collection.objects) > 0:
bpy.ops.bim.refresh_snapshot_texts()
else:
print("No existing 3D texts to refresh")
except Exception as e:
print(f"Error refreshing 3D texts: {e}")
except Exception as e:
print(f"Error applying visibility to 3D texts: {e}")
# Check 3D texts after snapshot
texts_collection = bpy.data.collections.get("Schedule_Display_Texts")
if texts_collection:
for obj in texts_collection.objects:
print(f" - Text object: {obj.name}, visible: {not obj.hide_viewport}")
else:
print("No 3D texts collection found")
if context.screen.is_animation_playing:
bpy.ops.screen.animation_cancel(restore_frame=False)
self.report({'INFO'}, f"Snapshot created for date {snapshot_date.strftime('%Y-%m-%d')}")
# Force UI redraw to update button states
for area in context.screen.areas:
if area.type in ['PROPERTIES', 'VIEW_3D']:
area.tag_redraw()
return {'FINISHED'}
@@ -0,0 +1,48 @@
# Debug script to see what's happening with colorType values
import bpy
import bonsai.tool as tool
def debug_colortype_values(operation_name):
"""Debug function to see current colorType values"""
print(f"\n=== DEBUG {operation_name} ===")
try:
tprops = tool.Sequence.get_task_tree_props()
if not tprops:
print("No task tree properties found")
return
print(f"Total tasks in UI: {len(tprops.tasks)}")
for i, task in enumerate(tprops.tasks):
task_id = getattr(task, 'ifc_definition_id', 0)
animation_color_schemes = getattr(task, 'animation_color_schemes', '')
selected_colortype = getattr(task, 'selected_colortype_in_active_group', '')
if animation_color_schemes or selected_colortype:
print(f"Task {i}: ID={task_id}")
print(f" animation_color_schemes: '{animation_color_schemes}'")
print(f" selected_colortype_in_active_group: '{selected_colortype}'")
except Exception as e:
print(f"Debug failed: {e}")
print(f"=== END DEBUG {operation_name} ===\n")
class DebugColorTypeOperator(bpy.types.Operator):
bl_idname = "debug.colortype_values"
bl_label = "Debug ColorType Values"
def execute(self, context):
debug_colortype_values("MANUAL DEBUG")
return {"FINISHED"}
# Register for testing
def register():
bpy.utils.register_class(DebugColorTypeOperator)
def unregister():
bpy.utils.unregister_class(DebugColorTypeOperator)
@@ -0,0 +1,214 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import bpy
import json
import bonsai.tool as tool
class EnumBypassSystem:
"""
Sistema de bypass para propiedades enum corruptas.
Cuando las propiedades enum están corruptas con valor '0',
este sistema mantiene los valores reales en propiedades de escena.
"""
@staticmethod
def store_colortype_value(task_id, property_name, value):
"""Store a ColorType value in scene bypass storage"""
scene = bpy.context.scene
key = f"_enum_bypass_{property_name}"
try:
# Get existing data or create new
if key in scene:
data = json.loads(scene[key])
else:
data = {}
# Store the value
data[str(task_id)] = value
scene[key] = json.dumps(data)
print(f"🔄 BYPASS: Stored {property_name}='{value}' for task {task_id}")
except Exception as e:
print(f"[ERROR] BYPASS: Error storing {property_name}: {e}")
@staticmethod
def get_colortype_value(task_id, property_name, default=""):
"""Get a ColorType value from scene bypass storage"""
scene = bpy.context.scene
key = f"_enum_bypass_{property_name}"
try:
if key in scene:
data = json.loads(scene[key])
value = data.get(str(task_id), default)
if value != default:
print(f"📥 BYPASS: Retrieved {property_name}='{value}' for task {task_id}")
return value
except Exception as e:
print(f"[ERROR] BYPASS: Error retrieving {property_name}: {e}")
return default
@staticmethod
def is_enum_corrupted(task_item, property_name):
"""Check if an enum property is corrupted"""
try:
# Try to get current value
current = getattr(task_item, property_name)
# Try to get enum items
prop_def = task_item.bl_rna.properties[property_name]
items = prop_def.enum_items
# If no items available or value is '0', it's corrupted
if not items or str(current) == '0':
return True
return False
except:
return True
@staticmethod
def safe_get_colortype_value(task_item, property_name, default=""):
"""Safely get a ColorType value, using bypass if enum is corrupted"""
task_id = getattr(task_item, 'ifc_definition_id', 0)
# Check if enum is corrupted
if EnumBypassSystem.is_enum_corrupted(task_item, property_name):
# Use bypass system
return EnumBypassSystem.get_colortype_value(task_id, property_name, default)
else:
# Use normal property
try:
return getattr(task_item, property_name, default)
except:
return EnumBypassSystem.get_colortype_value(task_id, property_name, default)
@staticmethod
def safe_set_colortype_value(task_item, property_name, value):
"""Safely set a ColorType value, using bypass if enum is corrupted"""
task_id = getattr(task_item, 'ifc_definition_id', 0)
# Always store in bypass system as backup
EnumBypassSystem.store_colortype_value(task_id, property_name, value)
# Try to set the actual property
try:
if not EnumBypassSystem.is_enum_corrupted(task_item, property_name):
setattr(task_item, property_name, value)
print(f"[DEBUG] DIRECT: Set {property_name}='{value}' for task {task_id}")
return True
except Exception as e:
print(f"[WARNING] DIRECT FAILED: {property_name}='{value}' for task {task_id}: {e}")
print(f"🔄 BYPASS ONLY: {property_name}='{value}' for task {task_id}")
return False
@staticmethod
def repair_all_corrupted_enums():
"""Try to repair all corrupted enums and sync with bypass system"""
try:
task_props = tool.Sequence.get_task_tree_props()
tasks = getattr(task_props, 'tasks', [])
repaired_count = 0
bypass_count = 0
for task in tasks:
task_id = getattr(task, 'ifc_definition_id', 0)
# Check selected_colortype_in_active_group
if EnumBypassSystem.is_enum_corrupted(task, 'selected_colortype_in_active_group'):
# Get value from bypass
bypass_value = EnumBypassSystem.get_colortype_value(task_id, 'selected_colortype_in_active_group')
if bypass_value:
# Try to repair and set
try:
# Force refresh by toggling use_active_colortype_group
original = task.use_active_colortype_group
task.use_active_colortype_group = not original
task.use_active_colortype_group = original
# Try to set the value
if not EnumBypassSystem.is_enum_corrupted(task, 'selected_colortype_in_active_group'):
task.selected_colortype_in_active_group = bypass_value
repaired_count += 1
print(f"[DEBUG] REPAIRED: selected_colortype_in_active_group={bypass_value} for task {task_id}")
else:
bypass_count += 1
except Exception as e:
print(f"[ERROR] REPAIR FAILED: selected_colortype_in_active_group for task {task_id}: {e}")
bypass_count += 1
# Check animation_color_schemes
if EnumBypassSystem.is_enum_corrupted(task, 'animation_color_schemes'):
bypass_value = EnumBypassSystem.get_colortype_value(task_id, 'animation_color_schemes')
if bypass_value:
try:
# Force refresh
original = task.use_active_colortype_group
task.use_active_colortype_group = not original
task.use_active_colortype_group = original
if not EnumBypassSystem.is_enum_corrupted(task, 'animation_color_schemes'):
task.animation_color_schemes = bypass_value
repaired_count += 1
print(f"[DEBUG] REPAIRED: animation_color_schemes={bypass_value} for task {task_id}")
else:
bypass_count += 1
except Exception as e:
print(f"[ERROR] REPAIR FAILED: animation_color_schemes for task {task_id}: {e}")
bypass_count += 1
print(f"🔧 REPAIR SUMMARY: {repaired_count} repaired, {bypass_count} using bypass")
return repaired_count, bypass_count
except Exception as e:
print(f"[ERROR] Error in repair_all_corrupted_enums: {e}")
return 0, 0
class TestEnumBypass(bpy.types.Operator):
bl_idname = "bim.test_enum_bypass"
bl_label = "Test Enum Bypass System"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
print("\n" + "🧪"*50)
print("🧪 TESTING ENUM BYPASS SYSTEM")
print("🧪"*50)
repaired, bypass = EnumBypassSystem.repair_all_corrupted_enums()
print("🧪"*50 + "\n")
self.report({'INFO'}, f"Repaired: {repaired}, Using Bypass: {bypass}")
return {'FINISHED'}
def register():
bpy.utils.register_class(TestEnumBypass)
def unregister():
bpy.utils.unregister_class(TestEnumBypass)
if __name__ == "__main__":
register()
@@ -0,0 +1,798 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import bpy
import json
import calendar
from mathutils import Matrix
from datetime import datetime, timedelta
from dateutil import relativedelta
import bonsai.tool as tool
from .schedule_task_operators import restore_all_ui_state
from .operator import snapshot_all_ui_state
import bonsai.core.sequence as core
import ifcopenshell.util.sequence
import ifcopenshell.util.selector
from bpy_extras.io_utils import ImportHelper, ExportHelper
from typing import TYPE_CHECKING
# --- Import and fallback block ---
try:
from .prop import update_filter_column
from . import prop
except Exception:
try:
from ..prop.filter import update_filter_column
from .. import prop
except Exception:
def update_filter_column(*args, **kwargs):
"""Fallback filter column update function"""
return
class PropFallback:
@staticmethod
def safe_set_selected_colortype_in_active_group(task_obj, value, skip_validation=False):
try: setattr(task_obj, "selected_colortype_in_active_group", value)
except Exception as e: pass
prop = PropFallback()
# =====================================
# COMPLETE SNAPSHOT/RESTORE SYSTEM
# =====================================
# [WARNING] WARNING: THESE FUNCTIONS ARE DUPLICATED - USE operator.py INSTEAD
# [WARNING] All imports should point to operator.py, not this file
# [WARNING] These functions are kept only for legacy compatibility
# Global variable for task state cache (used by filters)
_persistent_task_state = {}
# def snapshot_all_ui_state(context):
# """
# Captures the complete state of ALL tasks in the active schedule.
# Based on v125 which worked perfectly.
# """
# import json
# try:
# # Detect active schedule to use specific keys
# ws_props = tool.Sequence.get_work_schedule_props()
# ws_id = int(getattr(ws_props, "active_work_schedule_id", 0))
#
# tprops = getattr(context.scene, 'BIMTaskTreeProperties', None)
# task_snap = {}
#
# # Get ALL tasks from the active schedule (not just visible ones)
# try:
# ws = tool.Sequence.get_active_work_schedule()
# if ws:
# import ifcopenshell.util.sequence
#
# def get_all_tasks_recursive(tasks):
# all_tasks = []
# for task in tasks:
# all_tasks.append(task)
# nested = ifcopenshell.util.sequence.get_nested_tasks(task)
# if nested:
# all_tasks.extend(get_all_tasks_recursive(nested))
# return all_tasks
#
# root_tasks = ifcopenshell.util.sequence.get_root_tasks(ws)
# all_tasks = get_all_tasks_recursive(root_tasks)
#
# # Map visible tasks in the UI
# task_id_to_ui_data = {str(getattr(t, "ifc_definition_id", 0)): t for t in getattr(tprops, "tasks", [])}
#
# for task in all_tasks:
# tid = str(task.id())
# if tid == "0":
# continue
#
# # If the task is visible in the UI, use its current data
# if tid in task_id_to_ui_data:
# t = task_id_to_ui_data[tid]
#
# # Capture color groups
# groups_list = []
# for g in getattr(t, "colortype_group_choices", []):
# sel_attr = None
# for cand in ("selected_colortype", "selected", "active_colortype", "colortype"):
# if hasattr(g, cand):
# sel_attr = cand
# break
# groups_list.append({
# "group_name": getattr(g, "group_name", ""),
# "enabled": bool(getattr(g, "enabled", False)),
# "selected_value": getattr(g, sel_attr, "") if sel_attr else "",
# "selected_attr": sel_attr or "",
# })
#
# task_snap[tid] = {
# "active": bool(getattr(t, "use_active_colortype_group", False)),
# "selected_active_colortype": getattr(t, "selected_colortype_in_active_group", ""),
# "animation_color_schemes": getattr(t, "animation_color_schemes", ""),
# "groups": groups_list,
# # Additional data to preserve UI state
# "is_selected": getattr(t, 'is_selected', False),
# "is_expanded": getattr(t, 'is_expanded', False),
# }
# else:
# # If not visible, preserve data from cache or create an empty entry
# cache_key = "_task_colortype_snapshot_cache_json"
# cache_raw = context.scene.get(cache_key)
# if cache_raw:
# try:
# cached_data = json.loads(cache_raw)
# if tid in cached_data:
# task_snap[tid] = cached_data[tid]
# else:
# task_snap[tid] = {
# "active": False,
# "selected_active_colortype": "",
# "animation_color_schemes": "",
# "groups": [],
# "is_selected": False,
# "is_expanded": False,
# }
# except Exception:
# task_snap[tid] = {
# "active": False,
# "selected_active_colortype": "",
# "animation_color_schemes": "",
# "groups": [],
# "is_selected": False,
# "is_expanded": False,
# }
# else:
# task_snap[tid] = {
# "active": False,
# "selected_active_colortype": "",
# "animation_color_schemes": "",
# "groups": [],
# "is_selected": False,
# "is_expanded": False,
# }
# except Exception as e:
# print(f"Bonsai WARNING: Error capturando todas las tasks: {e}")
# # Fallback: only visible tasks
# for t in getattr(tprops, "tasks", []):
# tid = str(getattr(t, "ifc_definition_id", 0))
# if tid == "0":
# continue
# groups_list = []
# for g in getattr(t, "colortype_group_choices", []):
# sel_attr = None
# for cand in ("selected_colortype", "selected", "active_colortype", "colortype"):
# if hasattr(g, cand):
# sel_attr = cand
# break
# groups_list.append({
# "group_name": getattr(g, "group_name", ""),
# "enabled": bool(getattr(g, "enabled", False)),
# "selected_value": getattr(g, sel_attr, "") if sel_attr else "",
# "selected_attr": sel_attr or "",
# })
# task_snap[tid] = {
# "active": bool(getattr(t, "use_active_colortype_group", False)),
# "selected_active_colortype": getattr(t, "selected_colortype_in_active_group", ""),
# "animation_color_schemes": getattr(t, "animation_color_schemes", ""),
# "groups": groups_list,
# "is_selected": getattr(t, 'is_selected', False),
# "is_expanded": getattr(t, 'is_expanded', False),
# }
#
# # Save schedule-specific snapshot AND update general cache
# snap_key_specific = f"_task_colortype_snapshot_json_WS_{ws_id}"
# cache_key = "_task_colortype_snapshot_cache_json"
#
# context.scene[snap_key_specific] = json.dumps(task_snap)
# context.scene[cache_key] = json.dumps(task_snap) # Also update cache
#
# print(f"📸 Snapshot guardado: {len(task_snap)} tasks en clave {snap_key_specific}")
#
# except Exception as e:
# print(f"Bonsai WARNING: snapshot_all_ui_state failed: {e}")
#
def deferred_restore_task_state():
"""
Runs deferred to restore state, avoiding race conditions.
"""
global _persistent_task_state
if not _persistent_task_state or not bpy.context:
return
context = bpy.context
tprops = getattr(context.scene, 'BIMTaskTreeProperties', None)
if not tprops or not hasattr(tprops, 'tasks'):
return
for task in tprops.tasks:
task_id = str(task.ifc_definition_id)
if task_id in _persistent_task_state:
saved_data = _persistent_task_state[task_id]
try:
# Restore simple properties
task.is_selected = saved_data.get("is_selected", False)
task.is_expanded = saved_data.get("is_expanded", False)
task.use_active_colortype_group = saved_data.get("use_active_colortype_group", False)
# Restore active group
saved_active_group = saved_data.get("active_colortype_group", "")
if saved_active_group and hasattr(task, 'active_colortype_group'):
try:
task.active_colortype_group = saved_active_group
except Exception:
pass
# Restore custom ColorType with maximum robustness
saved_colortype = saved_data.get("selected_colortype_in_active_group", "")
if saved_colortype:
try:
task.selected_colortype_in_active_group = saved_colortype
except Exception:
pass
# Restore DEFAULT group value if applicable
saved_default_value = saved_data.get("default_group_value", "")
if saved_default_value and hasattr(task, 'active_colortype_group'):
try:
if task.active_colortype_group == "DEFAULT" or saved_active_group == "DEFAULT":
task.selected_colortype_in_active_group = saved_default_value
except Exception:
pass
# Restore PredefinedType using Blender's property system
predefined_type_to_restore = saved_data.get("PredefinedType")
if predefined_type_to_restore:
try:
# Method 1: Through workspace properties
ws_props = tool.Sequence.get_work_schedule_props()
if (hasattr(ws_props, 'active_task_index') and
ws_props.active_task_index < len(tprops.tasks) and
tprops.tasks[ws_props.active_task_index].ifc_definition_id == task.ifc_definition_id):
# The task is active, we can use task_attributes
if hasattr(ws_props, "task_attributes"):
for attr in ws_props.task_attributes:
if attr.name == "PredefinedType":
attr.string_value = predefined_type_to_restore
break
else:
# Method 2: Direct modification (less reliable but a fallback)
ifc_task = tool.Ifc.get().by_id(task.ifc_definition_id)
if ifc_task and hasattr(ifc_task, 'PredefinedType'):
ifc_task.PredefinedType = predefined_type_to_restore
except Exception as e:
print(f"Warning: Could not restore PredefinedType for task {task_id}: {e}")
except (TypeError, ReferenceError):
# We ignore Enum assignment errors that may occur if the UI
# is not yet 100% ready, the timer minimizes this.
pass
except Exception as e:
pass
# Force a UI redraw to ensure changes are visible
for area in context.screen.areas:
if area.type == 'PROPERTIES':
area.tag_redraw()
return None # End the timer
def populate_persistent_task_state_from_snapshot(context):
"""
Fills _persistent_task_state from the JSON snapshots in context.scene.
This synchronizes the two memory systems.
"""
global _persistent_task_state
try:
# Find active schedule
ws_props = tool.Sequence.get_work_schedule_props()
ws_id = getattr(ws_props, "active_work_schedule_id", 0)
if not ws_id:
return
# Try to load from the schedule-specific snapshot
snap_key = f"_task_colortype_snapshot_json_WS_{ws_id}"
snapshot_data = context.scene.get(snap_key)
if snapshot_data:
import json
try:
data = json.loads(snapshot_data)
for task_id, task_data in data.items():
_persistent_task_state[task_id] = {
"is_selected": task_data.get("is_selected", False),
"is_expanded": task_data.get("is_expanded", False),
"use_active_colortype_group": task_data.get("active", False),
"selected_colortype_in_active_group": task_data.get("selected_active_colortype", ""),
}
print(f"📥 Sincronizado _persistent_task_state desde snapshot: {len(data)} tasks")
except Exception as e:
print(f"[WARNING] Error sincronizando desde snapshot: {e}")
except Exception as e:
print(f"[WARNING] Error poblando _persistent_task_state: {e}")
def restore_persistent_task_state(context):
"""Starts the state restoration in a deferred manner."""
# First, synchronize from snapshots if necessary
populate_persistent_task_state_from_snapshot(context)
# Then restore with a delay for the UI
bpy.app.timers.register(deferred_restore_task_state, first_interval=0.05)
class ClearTaskStateCache(bpy.types.Operator):
bl_idname = "bim.clear_task_state_cache"; bl_label = "Clear Task State Cache"; bl_options = {"REGISTER", "INTERNAL"}
work_schedule_id: bpy.props.IntProperty(default=0) # For selective clearing
def execute(self, context):
global _persistent_task_state
# If no schedule is specified, clear everything (original behavior)
if self.work_schedule_id == 0:
_persistent_task_state.clear()
print("🧹 Cache completo limpiado (modo global)")
return {'FINISHED'}
# Selective clearing: only remove tasks from the specified schedule
try:
import ifcopenshell.util.sequence
work_schedule = tool.Ifc.get().by_id(self.work_schedule_id)
if not work_schedule:
print(f"[WARNING] Cronograma {self.work_schedule_id} no encontrado para cleanup selectiva")
return {'FINISHED'}
# Get all tasks from the specified schedule
def get_all_task_ids_recursive(tasks):
all_ids = set()
for task in tasks:
all_ids.add(str(task.id()))
nested = ifcopenshell.util.sequence.get_nested_tasks(task)
if nested:
all_ids.update(get_all_task_ids_recursive(nested))
return all_ids
root_tasks = ifcopenshell.util.sequence.get_root_tasks(work_schedule)
task_ids_to_clear = get_all_task_ids_recursive(root_tasks)
# Remove only the tasks of this schedule from the cache
removed_count = 0
for task_id in list(_persistent_task_state.keys()):
if task_id in task_ids_to_clear:
del _persistent_task_state[task_id]
removed_count += 1
print(f"🧹 Cache selectivo: {removed_count} tasks removidas del schedule '{work_schedule.Name or 'Sin nombre'}'")
except Exception as e:
print(f"[ERROR] Error en cleanup selectiva: {e}. Fallback a cleanup global.")
_persistent_task_state.clear()
return {'FINISHED'}
# =============================================================================
# ▲▲▲ END OF MEMORY SYSTEM ▲▲▲
# =============================================================================
# =============================================================================
# ▼▼▼ STATUS FILTER OPERATORS ▼▼▼
# =============================================================================
class EnableStatusFilters(bpy.types.Operator):
bl_idname = "bim.enable_status_filters"
bl_label = "Enable Status Filters"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
from collections import Counter
props = tool.Sequence.get_status_props()
props.is_enabled = True
hidden_statuses = {s.name for s in props.statuses if not s.is_visible}
props.statuses.clear()
statuses_used = Counter()
user_defined_statuses = set()
for element in tool.Ifc.get().by_type("IfcPropertyEnumeratedValue"):
if element.Name == "Status":
enum_values = element.EnumerationValues
if element.PartOfPset and isinstance(enum_values, tuple):
pset = element.PartOfPset[0]
pset_name = pset.Name
if pset_name.startswith("Pset_") and pset_name.endswith("Common"):
statuses_used.update([s.wrappedValue for s in enum_values])
elif pset_name == "EPset_Status": # Our secret sauce
statuses_used.update([s.wrappedValue for s in enum_values])
elif element.Name == "UserDefinedStatus":
status = element.NominalValue.wrappedValue
statuses_used[element.NominalValue.wrappedValue] += 1
user_defined_statuses.add(status)
statuses = ["No Status"]
statuses.extend(tool.Sequence.ELEMENT_STATUSES)
statuses.extend(user_defined_statuses)
for status in statuses:
new = props.statuses.add()
new.name = status
if new.name in hidden_statuses:
new.is_visible = False
new.has_elements = bool(statuses_used[status])
visible_statuses = {s.name for s in props.statuses if s.is_visible}
tool.Sequence.set_visibility_by_status(visible_statuses)
return {"FINISHED"}
class DisableStatusFilters(bpy.types.Operator):
bl_idname = "bim.disable_status_filters"; bl_label = "Disable Status Filters"; bl_description = "Deactivate status filters panel"; bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
props = tool.Sequence.get_status_props(); all_statuses = {s.name for s in props.statuses}; tool.Sequence.set_visibility_by_status(all_statuses); props.is_enabled = False; return {"FINISHED"}
class ActivateStatusFilters(bpy.types.Operator):
bl_idname = "bim.activate_status_filters"; bl_label = "Activate Status Filters"; bl_description = "Filter objects based on selected IFC statuses"; bl_options = {"REGISTER", "UNDO"}; only_if_enabled: bpy.props.BoolProperty(default=False)
def execute(self, context):
props = tool.Sequence.get_status_props()
if not props.is_enabled and self.only_if_enabled: return {"FINISHED"}
visible_statuses = {s.name for s in props.statuses if s.is_visible}; tool.Sequence.set_visibility_by_status(visible_statuses); return {"FINISHED"}
class SelectStatusFilter(bpy.types.Operator):
bl_idname = "bim.select_status_filter"; bl_label = "Select Status Filter"; bl_description = "Select elements with the specified status"; bl_options = {"REGISTER", "UNDO"}
status: bpy.props.StringProperty()
def execute(self, context):
import ifcopenshell.util.selector
query = f"IfcProduct, /Pset_.*Common/.Status={self.status} + IfcProduct, EPset_Status.Status={self.status}"
if self.status == "No Status": query = f"IfcProduct, /Pset_.*Common/.Status=NULL, EPset_Status.Status=NULL"
for element in ifcopenshell.util.selector.filter_elements(tool.Ifc.get(), query):
obj = tool.Ifc.get_object(element)
if obj: obj.select_set(True)
return {"FINISHED"}
class AssignStatus(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.assign_status"
bl_label = "Assign Status"
bl_description = "Assign status to the selected elements.\n\nAlt+CLICK to unassign the status."
bl_options = {"REGISTER", "UNDO"}
should_override_previous_status: bpy.props.BoolProperty(
name="Override Previous Status",
description=(
"Whether assigning new status should override previous one.\n\n"
"IFC allows storing multiple statuses for the same element. "
"This option can be disabled to take advantage of that."
),
default=True,
)
status: bpy.props.StringProperty()
should_unassign_status: bpy.props.BoolProperty(
options={"SKIP_SAVE"},
)
def invoke(self, context, event):
self.should_unassign_status = event.alt
return self.execute(context)
def _execute(self, context):
import ifcopenshell.api.pset
import ifcopenshell.util.element
import bonsai.bim.schema
from functools import cache
# TODO: UserDefinedStatus
if self.status not in tool.Sequence.ELEMENT_STATUSES:
self.report({"ERROR"}, "Assigning user defined statuses or 'No Status' is not yet supported.")
return {"CANCELLED"}
EPSET_NAME = "EPset_Status"
elements_changed = 0
ifc_file = tool.Ifc.get()
@cache
def get_common_pset_name(element):
templates = bonsai.bim.schema.ifc.psetqto.get_applicable(
element.is_a(),
pset_only=True,
schema=tool.Ifc.get_schema(),
)
for template in templates:
template_name = template.Name
if template_name.startswith("Pset_") and template_name.endswith("Common"):
return template_name
for obj in tool.Blender.get_selected_objects():
if not (element := tool.Ifc.get_entity(obj)) or not element.is_a("IfcProduct"):
continue
psets = ifcopenshell.util.element.get_psets(element, psets_only=True)
common_pset_name = get_common_pset_name(element)
existing_psets = [
pset_name for pset_name in psets if pset_name == EPSET_NAME or pset_name == common_pset_name
]
# Common pset comes first.
existing_psets.sort(key=lambda x: x == EPSET_NAME)
if not existing_psets:
if self.should_unassign_status:
continue
pset_name = common_pset_name or EPSET_NAME
pset = ifcopenshell.api.pset.add_pset(ifc_file, element, pset_name)
ifcopenshell.api.pset.edit_pset(ifc_file, pset, properties={"Status": [self.status]})
elements_changed += 1
continue
pset_changed = False
for pset_i, pset_name in enumerate(existing_psets):
pset_data = psets[pset_name]
status_data = pset_data.get("Status", ...)
if self.should_unassign_status:
if status_data is ... or not status_data:
continue
# Already unassigned.
if self.status not in status_data:
continue
status_data.remove(self.status)
else:
if status_data is None or status_data is ...:
status_data = [self.status]
elif self.status in status_data:
# Already assigned.
continue
else:
if self.should_override_previous_status:
status_data = [self.status]
else:
status_data.append(self.status)
if pset_i > 0:
# Try to maintain status in just 1 pset.
if not self.should_unassign_status:
status_data.remove(self.status)
ifcopenshell.api.pset.edit_pset(
ifc_file, pset=ifc_file.by_id(pset_data["id"]), properties={"Status": status_data}
)
pset_changed = True
elements_changed += pset_changed
self.report(
{"INFO"},
f"Status '{self.status}' "
f"{'un' * self.should_unassign_status}assigned {'from' if self.should_unassign_status else 'to'} "
f"{elements_changed} elements.",
)
return {"FINISHED"}
# =============================================================================
# ▲▲▲ END OF STATUS FILTER OPERATORS ▲▲▲
# =============================================================================
# =============================================================================
# ▼▼▼ TASK FILTER OPERATORS ▼▼▼
# =============================================================================
class ApplyTaskFilters(bpy.types.Operator):
bl_idname = "bim.apply_task_filters"; bl_label = "Apply Task Filters"; bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
# FUNCTIONAL V125 SYSTEM: Snapshot → Reload → Restore
try:
snapshot_all_ui_state(context)
except Exception as e:
print(f"Bonsai WARNING: snapshot_all_ui_state failed: {e}")
# Destructive task reload
try:
ws = tool.Sequence.get_active_work_schedule()
if ws:
tool.Sequence.load_task_tree(ws)
tool.Sequence.load_task_properties()
except Exception as e:
print(f"Bonsai WARNING: Task tree reload failed: {e}")
# Restore full state
try:
restore_all_ui_state(context)
except Exception as e:
print(f"Bonsai WARNING: restore_all_ui_state failed: {e}")
# Variance color logic
try:
if not tool.Sequence.has_variance_calculation_in_tasks():
tool.Sequence.clear_variance_colors_only()
except Exception as e:
print(f"[WARNING] Error in variance color check: {e}")
return {'FINISHED'}
class AddTaskFilter(bpy.types.Operator):
bl_idname = "bim.add_task_filter"; bl_label = "Add Task Filter"; bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
props = tool.Sequence.get_work_schedule_props(); new_rule = props.filters.rules.add(); new_rule.column = "IfcTask.Name||string"; update_filter_column(new_rule, context); props.filters.active_rule_index = len(props.filters.rules) - 1; return {'FINISHED'}
class RemoveTaskFilter(bpy.types.Operator):
bl_idname = "bim.remove_task_filter"; bl_label = "Remove Task Filter"; bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
props = tool.Sequence.get_work_schedule_props(); index = props.filters.active_rule_index
if 0 <= index < len(props.filters.rules):
props.filters.rules.remove(index); props.filters.active_rule_index = min(max(0, index - 1), len(props.filters.rules) - 1)
if len(props.filters.rules) == 0: props.last_lookahead_window = ""
bpy.ops.bim.apply_task_filters()
return {'FINISHED'}
class ClearAllTaskFilters(bpy.types.Operator):
bl_idname = "bim.clear_all_task_filters"; bl_label = "Clear All Filters"; bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
props = tool.Sequence.get_work_schedule_props(); props.filters.rules.clear(); props.filters.active_rule_index = 0; props.last_lookahead_window = ""; bpy.ops.bim.apply_task_filters(); self.report({'INFO'}, "All filters cleared"); return {'FINISHED'}
class ApplyLookaheadFilter(bpy.types.Operator):
bl_idname = "bim.apply_lookahead_filter"; bl_label = "Apply Lookahead Filter"; bl_description = "Week Look Ahead - Filter tasks by time window"; bl_options = {"REGISTER", "UNDO"}
time_window: bpy.props.EnumProperty(name="Time Window", items=[('THIS_WEEK', "This Week", ""), ('LAST_WEEK', "Last Week", ""), ("1_WEEK", "Next 1 Week", ""), ("2_WEEKS", "Next 2 Weeks", ""), ("4_WEEKS", "Next 4 Weeks", ""), ("6_WEEKS", "Next 6 Weeks", ""), ("12_WEEKS", "Next 12 Weeks", "")])
def execute(self, context):
active_schedule = tool.Sequence.get_active_work_schedule()
if not active_schedule: self.report({'ERROR'}, "No active work schedule."); return {'CANCELLED'}
if not ifcopenshell.util.sequence.get_root_tasks(active_schedule): self.report({'WARNING'}, "The active schedule has no tasks."); return {'CANCELLED'}
props = tool.Sequence.get_work_schedule_props(); props.last_lookahead_window = self.time_window; props.filters.rules.clear(); date_source = getattr(props, "date_source_type", "SCHEDULE"); date_prefix = date_source.capitalize(); start_column = f"IfcTaskTime.{date_prefix}Start||date"; finish_column = f"IfcTaskTime.{date_prefix}Finish||date"; today = datetime.now()
if self.time_window == 'THIS_WEEK': filter_start = today - timedelta(days=today.weekday()); filter_end = filter_start + timedelta(days=6)
elif self.time_window == 'LAST_WEEK': filter_start = today - timedelta(days=today.weekday(), weeks=1); filter_end = filter_start + timedelta(days=6)
else: weeks = int(self.time_window.split('_')[0]); filter_start = today; filter_end = today + timedelta(weeks=weeks)
rule1 = props.filters.rules.add(); rule1.is_active = True; rule1.column = start_column; rule1.operator = "LTE"; rule1.value_string = filter_end.strftime("%Y-%m-%d")
rule2 = props.filters.rules.add(); rule2.is_active = True; rule2.column = finish_column; rule2.operator = "GTE"; rule2.value_string = filter_start.strftime("%Y-%m-%d")
props.filters.logic = "AND"; tool.Sequence.update_visualisation_date(filter_start, filter_end); bpy.ops.bim.apply_task_filters(); self.report({"INFO"}, f"Filter applied: {self.time_window.replace('_', ' ')}"); return {"FINISHED"}
# =============================================================================
# ▼▼▼ FILTER SET OPERATORS ▼▼▼
# =============================================================================
class UpdateSavedFilterSet(bpy.types.Operator):
bl_idname = "bim.update_saved_filter_set"; bl_label = "Update Saved Filter Set"; bl_options = {"REGISTER", "UNDO"}; set_index: bpy.props.IntProperty()
def execute(self, context):
props = tool.Sequence.get_work_schedule_props(); saved_set = props.saved_filter_sets[self.set_index]; saved_set.rules.clear()
for active_rule in props.filters.rules:
saved_rule = saved_set.rules.add(); saved_rule.is_active = active_rule.is_active; saved_rule.column = active_rule.column; saved_rule.operator = active_rule.operator; saved_rule.value_string = active_rule.value_string; saved_rule.data_type = active_rule.data_type
self.report({'INFO'}, f"Filter '{saved_set.name}' updated."); return {'FINISHED'}
class SaveFilterSet(bpy.types.Operator):
bl_idname = "bim.save_filter_set"; bl_label = "Save Filter Set"; bl_options = {"REGISTER", "UNDO"}; set_name: bpy.props.StringProperty(name="Name", description="Name for this filter set")
def execute(self, context):
if not self.set_name.strip(): self.report({'ERROR'}, "Name cannot be empty."); return {'CANCELLED'}
props = tool.Sequence.get_work_schedule_props(); new_set = props.saved_filter_sets.add(); new_set.name = self.set_name
for active_rule in props.filters.rules:
saved_rule = new_set.rules.add(); saved_rule.is_active = active_rule.is_active; saved_rule.column = active_rule.column; saved_rule.operator = active_rule.operator; saved_rule.value_string = active_rule.value_string; saved_rule.data_type = active_rule.data_type
self.report({'INFO'}, f"Filter '{self.set_name}' saved."); return {'FINISHED'}
def invoke(self, context, event): return context.window_manager.invoke_props_dialog(self)
class LoadFilterSet(bpy.types.Operator):
bl_idname = "bim.load_filter_set"; bl_label = "Load Filter Set"; bl_options = {"REGISTER", "UNDO"}; set_index: bpy.props.IntProperty()
def execute(self, context):
props = tool.Sequence.get_work_schedule_props()
if not (0 <= self.set_index < len(props.saved_filter_sets)): self.report({'ERROR'}, "Invalid filter index."); return {'CANCELLED'}
saved_set = props.saved_filter_sets[self.set_index]; props.filters.rules.clear()
for saved_rule in saved_set.rules:
active_rule = props.filters.rules.add(); active_rule.is_active = saved_rule.is_active; active_rule.column = saved_rule.column; active_rule.operator = saved_rule.operator; active_rule.value_string = saved_rule.value_string; active_rule.data_type = saved_rule.data_type
bpy.ops.bim.apply_task_filters(); return {'FINISHED'}
class RemoveFilterSet(bpy.types.Operator):
bl_idname = "bim.remove_filter_set"; bl_label = "Remove Filter Set"; bl_options = {"REGISTER", "UNDO"}; set_index: bpy.props.IntProperty()
def execute(self, context):
props = tool.Sequence.get_work_schedule_props()
if not (0 <= self.set_index < len(props.saved_filter_sets)): self.report({'ERROR'}, "Invalid filter index."); return {'CANCELLED'}
set_name = props.saved_filter_sets[self.set_index].name; props.saved_filter_sets.remove(self.set_index); props.active_saved_filter_set_index = min(max(0, self.set_index - 1), len(props.saved_filter_sets) - 1); self.report({'INFO'}, f"Filter '{set_name}' removed."); return {'FINISHED'}
class ExportFilterSet(bpy.types.Operator, ExportHelper):
bl_idname = "bim.export_filter_set"; bl_label = "Export Filter Library"; bl_description = "Export all saved filters to a JSON file"; filename_ext = ".json"; filter_glob: bpy.props.StringProperty(default="*.json", options={"HIDDEN"})
def execute(self, context):
props = tool.Sequence.get_work_schedule_props(); library_data = {}
for saved_set in props.saved_filter_sets:
rules_data = []
for rule in saved_set.rules: rules_data.append({"is_active": rule.is_active, "column": rule.column, "operator": rule.operator, "value": rule.value})
library_data[saved_set.name] = {"rules": rules_data}
with open(self.filepath, 'w', encoding='utf-8') as f: json.dump(library_data, f, ensure_ascii=False, indent=4)
self.report({'INFO'}, f"Filter library exported to {self.filepath}"); return {'FINISHED'}
class ImportFilterSet(bpy.types.Operator, ImportHelper):
bl_idname = "bim.import_filter_set"; bl_label = "Import Filter Library"; bl_description = "Import filters from a JSON file"; filename_ext = ".json"; filter_glob: bpy.props.StringProperty(default="*.json", options={"HIDDEN"})
def execute(self, context):
try:
with open(self.filepath, 'r', encoding='utf-8') as f: library_data = json.load(f)
except Exception as e: self.report({'ERROR'}, f"Could not read JSON file: {e}"); return {'CANCELLED'}
props = tool.Sequence.get_work_schedule_props(); existing_names = {fs.name for fs in props.saved_filter_sets}; imported_count = 0
for set_name, set_data in library_data.items():
if set_name in existing_names: continue
new_set = props.saved_filter_sets.add(); new_set.name = set_name
for rule_data in set_data.get("rules", []):
new_rule = new_set.rules.add(); new_rule.is_active = rule_data.get("is_active", True); new_rule.column = rule_data.get("column", ""); new_rule.operator = rule_data.get("operator", "CONTAINS"); new_rule.value = rule_data.get("value", "")
imported_count += 1
self.report({'INFO'}, f"{imported_count} new filter sets imported."); return {'FINISHED'}
# =============================================================================
# ▼▼▼ DATE PICKER OPERATORS ▼▼▼
# =============================================================================
class Bonsai_DatePicker(bpy.types.Operator):
bl_label = "Date Picker"; bl_idname = "bim.datepicker"; bl_options = {"REGISTER", "UNDO"}; target_prop: bpy.props.StringProperty(name="Target date prop to set"); include_time: bpy.props.BoolProperty(name="Include Time", default=True)
def execute(self, context):
selected_date = context.scene.DatePickerProperties.selected_date
try: tool.Sequence.parse_isodate_datetime(selected_date, self.include_time); self.set_scene_prop(self.target_prop, selected_date); return {"FINISHED"}
except Exception as e: self.report({"ERROR"}, f"Invalid date: '{selected_date}'. Error: {str(e)}."); return {"CANCELLED"}
def draw(self, context):
props = context.scene.DatePickerProperties; display_date = tool.Sequence.parse_isodate_datetime(props.display_date, False); current_month = (display_date.year, display_date.month); lines = calendar.monthcalendar(*current_month); month_title, week_titles = calendar.month(*current_month).splitlines()[:2]; layout = self.layout; row = layout.row(); row.prop(props, "selected_date", text="Date")
if self.include_time: row = layout.row(); row.label(text="Time:"); row.prop(props, "selected_hour", text="H"); row.prop(props, "selected_min", text="M"); row.prop(props, "selected_sec", text="S")
month_delta = relativedelta.relativedelta(months=1); split = layout.split(); col = split.row(); op = col.operator("wm.context_set_string", icon="TRIA_LEFT", text=""); op.data_path = "scene.DatePickerProperties.display_date"; op.value = tool.Sequence.isodate_datetime(display_date - month_delta, False)
col = split.row(); col.label(text=month_title.strip()); col = split.row(); col.alignment = "RIGHT"; op = col.operator("wm.context_set_string", icon="TRIA_RIGHT", text=""); op.data_path = "scene.DatePickerProperties.display_date"; op.value = tool.Sequence.isodate_datetime(display_date + month_delta, False)
row = layout.row(align=True)
for title in week_titles.split(): col = row.column(align=True); col.alignment = "CENTER"; col.label(text=title.strip())
current_selected_date = tool.Sequence.parse_isodate_datetime(props.selected_date, self.include_time); current_selected_date = current_selected_date.replace(hour=0, minute=0, second=0)
for line in lines:
row = layout.row(align=True)
for i in line:
col = row.column(align=True)
if i == 0: col.label(text=" ")
else:
selected_date = datetime(year=display_date.year, month=display_date.month, day=i); is_current_date = current_selected_date == selected_date; op = col.operator("wm.context_set_string", text="{:2d}".format(i), depress=is_current_date)
if self.include_time: selected_date = selected_date.replace(hour=props.selected_hour, minute=props.selected_min, second=props.selected_sec)
op.data_path = "scene.DatePickerProperties.selected_date"; op.value = tool.Sequence.isodate_datetime(selected_date, self.include_time)
def invoke(self, context, event):
props = context.scene.DatePickerProperties; current_date_str = self.get_scene_prop(self.target_prop); current_date = None
if current_date_str:
try: current_date = tool.Sequence.parse_isodate_datetime(current_date_str, self.include_time)
except: pass
if current_date is None: current_date = datetime.now(); current_date = current_date.replace(second=0)
if self.include_time: props["selected_hour"] = current_date.hour; props["selected_min"] = current_date.minute; props["selected_sec"] = current_date.second
props.display_date = tool.Sequence.isodate_datetime(current_date.replace(day=1), False); props.selected_date = tool.Sequence.isodate_datetime(current_date, self.include_time); return context.window_manager.invoke_props_dialog(self)
def get_scene_prop(self, prop_path: str) -> str: return bpy.context.scene.path_resolve(prop_path)
def set_scene_prop(self, prop_path: str, value: str) -> None: tool.Blender.set_prop_from_path(bpy.context.scene, prop_path, value)
class FilterDatePicker(bpy.types.Operator):
bl_idname = "bim.filter_datepicker"; bl_label = "Select Filter Date"; bl_options = {"REGISTER", "UNDO"}; rule_index: bpy.props.IntProperty(default=-1)
def execute(self, context):
props = tool.Sequence.get_work_schedule_props()
if self.rule_index < 0 or self.rule_index >= len(props.filters.rules): self.report({'ERROR'}, "Invalid filter rule index."); return {'CANCELLED'}
selected_date_str = context.scene.DatePickerProperties.selected_date
if not selected_date_str: self.report({'ERROR'}, "No date selected."); return {'CANCELLED'}
target_rule = props.filters.rules[self.rule_index]; target_rule.value_string = selected_date_str
try: bpy.ops.bim.apply_task_filters()
except Exception as e: print(f"Error applying filters: {e}")
self.report({'INFO'}, f"Date set to: {selected_date_str}"); return {"FINISHED"}
def invoke(self, context, event):
if self.rule_index < 0: self.report({'ERROR'}, "No rule index specified."); return {'CANCELLED'}
props = tool.Sequence.get_work_schedule_props()
if self.rule_index >= len(props.filters.rules): self.report({'ERROR'}, "Invalid filter rule index."); return {'CANCELLED'}
current_date_str = props.filters.rules[self.rule_index].value_string; date_picker_props = context.scene.DatePickerProperties
if current_date_str and current_date_str.strip():
try: current_date = datetime.fromisoformat(current_date_str.split('T')[0])
except Exception:
try: from dateutil import parser as date_parser; current_date = date_parser.parse(current_date_str)
except Exception: current_date = datetime.now()
else: current_date = datetime.now()
date_picker_props.selected_date = current_date.strftime("%Y-%m-%d"); date_picker_props.display_date = current_date.replace(day=1).strftime("%Y-%m-%d"); return context.window_manager.invoke_props_dialog(self, width=350)
def draw(self, context):
layout = self.layout; props = context.scene.DatePickerProperties
try: display_date = datetime.fromisoformat(props.display_date)
except Exception: display_date = datetime.now(); props.display_date = display_date.strftime("%Y-%m-%d")
row = layout.row(); row.prop(props, "selected_date", text="Date"); current_month = (display_date.year, display_date.month); lines = calendar.monthcalendar(*current_month); month_title = calendar.month_name[display_date.month] + f" {display_date.year}"; row = layout.row(align=True)
prev_month = display_date - relativedelta.relativedelta(months=1); op = row.operator("wm.context_set_string", icon="TRIA_LEFT", text=""); op.data_path = "scene.DatePickerProperties.display_date"; op.value = prev_month.strftime("%Y-%m-%d")
row.label(text=month_title)
next_month = display_date + relativedelta.relativedelta(months=1); op = row.operator("wm.context_set_string", icon="TRIA_RIGHT", text=""); op.data_path = "scene.DatePickerProperties.display_date"; op.value = next_month.strftime("%Y-%m-%d")
row = layout.row(align=True)
for day_name in ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']: col = row.column(align=True); col.alignment = "CENTER"; col.label(text=day_name)
try: selected_date = datetime.fromisoformat(props.selected_date)
except Exception: selected_date = None
for week in lines:
row = layout.row(align=True)
for day in week:
col = row.column(align=True)
if day == 0: col.label(text="")
else:
day_date = datetime(display_date.year, display_date.month, day); day_str = day_date.strftime("%Y-%m-%d"); is_selected = (selected_date and day_date.date() == selected_date.date()); op = col.operator("wm.context_set_string", text=str(day), depress=is_selected); op.data_path = "scene.DatePickerProperties.selected_date"; op.value = day_str
@@ -0,0 +1,144 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import bpy
import bonsai.tool as tool
class FixCorruptedEnums(bpy.types.Operator):
bl_idname = "bim.fix_corrupted_enums"
bl_label = "Fix Corrupted Enum Properties"
bl_description = "Reset corrupted enum properties that show '0' values"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
print("\n" + "🔧"*50)
print("🔧 FIXING CORRUPTED ENUM PROPERTIES")
print("🔧"*50)
fixed_count = 0
try:
# Get task tree properties
task_props = tool.Sequence.get_task_tree_props()
tasks = getattr(task_props, 'tasks', [])
print(f"🔍 Checking {len(tasks)} tasks for corrupted enums...")
for i, task in enumerate(tasks):
task_id = getattr(task, 'ifc_definition_id', 'N/A')
task_name = getattr(task, 'name', 'N/A')
# Check and fix selected_colortype_in_active_group
try:
# Try to access current value
current = task.selected_colortype_in_active_group
# Check if we can get enum items
prop_def = task.bl_rna.properties['selected_colortype_in_active_group']
items = prop_def.enum_items
if not items:
print(f"🔧 Task {task_id} ({task_name}): No enum items for selected_colortype_in_active_group")
# Force property refresh by clearing and re-evaluating
try:
task.property_unset("selected_colortype_in_active_group")
# Try to trigger enum callback to reload items
task.use_active_colortype_group = task.use_active_colortype_group
fixed_count += 1
except Exception as reset_e:
print(f"[ERROR] Failed to reset selected_colortype_in_active_group: {reset_e}")
except Exception as e:
print(f"🔧 Task {task_id} ({task_name}): Error accessing selected_colortype_in_active_group: {e}")
try:
task.property_unset("selected_colortype_in_active_group")
task.use_active_colortype_group = task.use_active_colortype_group
fixed_count += 1
except:
print(f"[ERROR] Failed to reset selected_colortype_in_active_group for task {task_id}")
# Check and fix animation_color_schemes
try:
current = task.animation_color_schemes
prop_def = task.bl_rna.properties['animation_color_schemes']
items = prop_def.enum_items
if not items:
print(f"🔧 Task {task_id} ({task_name}): No enum items for animation_color_schemes")
try:
task.property_unset("animation_color_schemes")
task.use_active_colortype_group = task.use_active_colortype_group
fixed_count += 1
except Exception as reset_e:
print(f"[ERROR] Failed to reset animation_color_schemes: {reset_e}")
except Exception as e:
print(f"🔧 Task {task_id} ({task_name}): Error accessing animation_color_schemes: {e}")
try:
task.property_unset("animation_color_schemes")
task.use_active_colortype_group = task.use_active_colortype_group
fixed_count += 1
except:
print(f"[ERROR] Failed to reset animation_color_schemes for task {task_id}")
# Check groups
groups = getattr(task, 'colortype_group_choices', [])
for j, group in enumerate(groups):
group_name = getattr(group, 'group_name', f'Group_{j}')
try:
current = group.selected_colortype
prop_def = group.bl_rna.properties['selected_colortype']
items = prop_def.enum_items
if not items:
print(f"🔧 Task {task_id}, Group '{group_name}': No enum items for selected_colortype")
try:
group.property_unset("selected_colortype")
group.enabled = group.enabled # Trigger refresh
fixed_count += 1
except Exception as reset_e:
print(f"[ERROR] Failed to reset selected_colortype: {reset_e}")
except Exception as e:
print(f"🔧 Task {task_id}, Group '{group_name}': Error accessing selected_colortype: {e}")
try:
group.property_unset("selected_colortype")
group.enabled = group.enabled
fixed_count += 1
except:
print(f"[ERROR] Failed to reset selected_colortype for group '{group_name}' in task {task_id}")
except Exception as e:
print(f"[ERROR] Error in fix_corrupted_enums: {e}")
import traceback
traceback.print_exc()
print("🔧"*50 + "\n")
self.report({'INFO'}, f"Fixed {fixed_count} corrupted enum properties")
return {'FINISHED'}
def register():
bpy.utils.register_class(FixCorruptedEnums)
def unregister():
bpy.utils.unregister_class(FixCorruptedEnums)
if __name__ == "__main__":
register()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,254 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
# Description: Operators for importing and exporting data from external formats.
import bpy
import time
from dateutil import parser
from bpy_extras.io_utils import ImportHelper, ExportHelper
import bonsai.tool as tool
import bonsai.core.sequence as core
# --- Operators ---
class ImportWorkScheduleCSV(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
bl_idname = "bim.import_work_schedule_csv"
bl_label = "Import Work Schedule CSV"
bl_description = "Import work schedule from the provided .csv file."
bl_options = {"REGISTER", "UNDO"}
filename_ext = ".csv"
filter_glob: bpy.props.StringProperty(default="*.csv", options={"HIDDEN"})
@classmethod
def poll(cls, context):
ifc_file = tool.Ifc.get()
if ifc_file is None:
cls.poll_message_set("No IFC file is loaded.")
return False
return True
def _execute(self, context):
from ifc4d.csv4d2ifc import Csv2Ifc
self.file = tool.Ifc.get()
start = time.time()
csv2ifc = Csv2Ifc()
csv2ifc.csv = self.filepath
csv2ifc.file = self.file
csv2ifc.execute()
# === Ensure Start/Finish columns are visible after import ===
try:
import bonsai.core.sequence as core
props = tool.Sequence.get_work_schedule_props()
existing = {c.name for c in getattr(props, "columns", [])}
# These map to headers "Start" and "Finish" in the UI
if "IfcTaskTime.ScheduleStart" not in existing:
core.add_task_column(tool.Sequence, "IfcTaskTime", "ScheduleStart", "string")
if "IfcTaskTime.ScheduleFinish" not in existing:
core.add_task_column(tool.Sequence, "IfcTaskTime", "ScheduleFinish", "string")
except Exception as e:
print("Auto-add Start/Finish columns after CSV import failed:", e)
# Default sort by Identification ascending after import
try:
props = tool.Sequence.get_work_schedule_props()
props.sort_column = "IfcTask.Identification"
props.is_sort_reversed = False
import bonsai.core.sequence as core
core.load_task_tree(tool.Ifc, tool.Sequence)
except Exception:
pass
self.report({"INFO"}, "Import finished in {:.2f} seconds".format(time.time() - start))
class ImportP6(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
bl_idname = "bim.import_p6"
bl_label = "Import P6"
bl_description = "Import provided .xml P6 file."
bl_options = {"REGISTER", "UNDO"}
filename_ext = ".xml"
filter_glob: bpy.props.StringProperty(default="*.xml", options={"HIDDEN"})
@classmethod
def poll(cls, context):
ifc_file = tool.Ifc.get()
if ifc_file is None:
cls.poll_message_set("No IFC file is loaded.")
return False
return True
def _execute(self, context):
from ifc4d.p62ifc import P62Ifc
self.file = tool.Ifc.get()
start = time.time()
p62ifc = P62Ifc()
p62ifc.xml = self.filepath
p62ifc.file = self.file
p62ifc.work_plan = self.file.by_type("IfcWorkPlan")[0] if self.file.by_type("IfcWorkPlan") else None
p62ifc.execute()
self.report({"INFO"}, "Import finished in {:.2f} seconds".format(time.time() - start))
class ImportP6XER(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
bl_idname = "bim.import_p6xer"
bl_label = "Import P6 XER"
bl_description = "Import provided .xer P6 file."
bl_options = {"REGISTER", "UNDO"}
filename_ext = ".xer"
filter_glob: bpy.props.StringProperty(default="*.xer", options={"HIDDEN"})
@classmethod
def poll(cls, context):
ifc_file = tool.Ifc.get()
if ifc_file is None:
cls.poll_message_set("No IFC file is loaded.")
return False
return True
def _execute(self, context):
from ifc4d.p6xer2ifc import P6XER2Ifc
self.file = tool.Ifc.get()
start = time.time()
p6xer2ifc = P6XER2Ifc()
p6xer2ifc.xer = self.filepath
p6xer2ifc.file = self.file
p6xer2ifc.work_plan = self.file.by_type("IfcWorkPlan")[0] if self.file.by_type("IfcWorkPlan") else None
p6xer2ifc.execute()
self.report({"INFO"}, "Import finished in {:.2f} seconds".format(time.time() - start))
class ImportPP(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
bl_idname = "bim.import_pp"
bl_label = "Import Powerproject .pp"
bl_description = "Import provided .pp file."
bl_options = {"REGISTER", "UNDO"}
filename_ext = ".pp"
filter_glob: bpy.props.StringProperty(default="*.pp", options={"HIDDEN"})
@classmethod
def poll(cls, context):
ifc_file = tool.Ifc.get()
if ifc_file is None:
cls.poll_message_set("No IFC file is loaded.")
return False
return True
def _execute(self, context):
from ifc4d.pp2ifc import PP2Ifc
self.file = tool.Ifc.get()
start = time.time()
pp2ifc = PP2Ifc()
pp2ifc.pp = self.filepath
pp2ifc.file = self.file
pp2ifc.work_plan = self.file.by_type("IfcWorkPlan")[0] if self.file.by_type("IfcWorkPlan") else None
pp2ifc.execute()
self.report({"INFO"}, "Import finished in {:.2f} seconds".format(time.time() - start))
class ImportMSP(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
bl_idname = "bim.import_msp"
bl_label = "Import MSP"
bl_description = "Import provided .xml MSP file."
bl_options = {"REGISTER", "UNDO"}
filename_ext = ".xml"
filter_glob: bpy.props.StringProperty(default="*.xml", options={"HIDDEN"})
@classmethod
def poll(cls, context):
ifc_file = tool.Ifc.get()
if ifc_file is None:
cls.poll_message_set("No IFC file is loaded.")
return False
return True
def _execute(self, context):
from ifc4d.msp2ifc import MSP2Ifc
self.file = tool.Ifc.get()
start = time.time()
msp2ifc = MSP2Ifc()
msp2ifc.xml = self.filepath
msp2ifc.file = self.file
msp2ifc.work_plan = self.file.by_type("IfcWorkPlan")[0] if self.file.by_type("IfcWorkPlan") else None
msp2ifc.execute()
self.report({"INFO"}, "Import finished in {:.2f} seconds".format(time.time() - start))
class ExportMSP(bpy.types.Operator, ExportHelper):
bl_idname = "bim.export_msp"
bl_label = "Export MSP"
bl_description = "Export work schedule as .xml MSP file."
bl_options = {"REGISTER", "UNDO"}
filename_ext = ".xml"
filter_glob: bpy.props.StringProperty(default="*.xml", options={"HIDDEN"})
holiday_start_date: bpy.props.StringProperty(default="2022-01-01", name="Holiday Start Date")
holiday_finish_date: bpy.props.StringProperty(default="2023-01-01", name="Holiday Finish Date")
@classmethod
def poll(cls, context):
ifc_file = tool.Ifc.get()
if ifc_file is None:
cls.poll_message_set("No IFC file is loaded.")
return False
return True
def execute(self, context):
from ifc4d.ifc2msp import Ifc2Msp
self.file = tool.Ifc.get()
start = time.time()
ifc2msp = Ifc2Msp()
ifc2msp.work_schedule = self.file.by_type("IfcWorkSchedule")[0]
ifc2msp.xml = bpy.path.ensure_ext(self.filepath, ".xml")
ifc2msp.file = self.file
ifc2msp.holiday_start_date = parser.parse(self.holiday_start_date).date()
ifc2msp.holiday_finish_date = parser.parse(self.holiday_finish_date).date()
ifc2msp.execute()
self.report({"INFO"}, "Export finished in {:.2f} seconds".format(time.time() - start))
return {"FINISHED"}
class ExportP6(bpy.types.Operator, ExportHelper):
bl_idname = "bim.export_p6"
bl_label = "Export P6"
bl_description = "Export work schedule as .xml P6 file."
bl_options = {"REGISTER", "UNDO"}
filename_ext = ".xml"
filter_glob: bpy.props.StringProperty(default="*.xml", options={"HIDDEN"})
holiday_start_date: bpy.props.StringProperty(default="2022-01-01", name="Holiday Start Date")
holiday_finish_date: bpy.props.StringProperty(default="2023-01-01", name="Holiday Finish Date")
@classmethod
def poll(cls, context):
ifc_file = tool.Ifc.get()
if ifc_file is None:
cls.poll_message_set("No IFC file is loaded.")
return False
return True
def execute(self, context):
from ifc4d.ifc2p6 import Ifc2P6
self.file = tool.Ifc.get()
start = time.time()
ifc2p6 = Ifc2P6()
ifc2p6.xml = bpy.path.ensure_ext(self.filepath, ".xml")
ifc2p6.file = self.file
ifc2p6.holiday_start_date = parser.parse(self.holiday_start_date).date()
ifc2p6.holiday_finish_date = parser.parse(self.holiday_finish_date).date()
ifc2p6.execute()
self.report({"INFO"}, "Export finished in {:.2f} seconds".format(time.time() - start))
return {"FINISHED"}
@@ -0,0 +1,97 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
# Description: Navigation operators for column navigation in task views.
import bpy
import bonsai.tool as tool
try:
from .ui import calculate_visible_columns_count
except Exception:
try:
from ..ui.schedule_ui import calculate_visible_columns_count
except Exception:
def calculate_visible_columns_count(context):
return 3 # Safe fallback
# === Navigation operators ===
class NavigateColumnsLeft(bpy.types.Operator):
"""Navigate to previous columns"""
bl_idname = "bim.navigate_columns_left"
bl_label = "Previous Columns"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
props = tool.Sequence.get_work_schedule_props()
# Move left by 1 column for fine control
if props.column_start_index > 0:
props.column_start_index -= 1
# Force UI to update to show column changes
context.area.tag_redraw()
return {'FINISHED'}
class NavigateColumnsRight(bpy.types.Operator):
"""Navigate to next columns"""
bl_idname = "bim.navigate_columns_right"
bl_label = "Next Columns"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
props = tool.Sequence.get_work_schedule_props()
max_columns = len(props.columns)
visible_columns = calculate_visible_columns_count(context)
# Move right by 1 column for fine control, but don't go past the last set
if props.column_start_index + visible_columns < max_columns:
props.column_start_index += 1
# Force UI to update to show column changes
context.area.tag_redraw()
return {'FINISHED'}
class NavigateColumnsHome(bpy.types.Operator):
"""Jump to first column"""
bl_idname = "bim.navigate_columns_home"
bl_label = "First Columns"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
props = tool.Sequence.get_work_schedule_props()
props.column_start_index = 0
return {'FINISHED'}
class NavigateColumnsEnd(bpy.types.Operator):
"""Jump to last set of columns"""
bl_idname = "bim.navigate_columns_end"
bl_label = "Last Columns"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
props = tool.Sequence.get_work_schedule_props()
max_columns = len(props.columns)
visible_columns = 6 # Max visible columns at once (sync with UI)
if max_columns > visible_columns:
props.column_start_index = max_columns - visible_columns
else:
props.column_start_index = 0
return {'FINISHED'}
@@ -0,0 +1,722 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import bpy
import json
import calendar
from mathutils import Matrix
from datetime import datetime
from dateutil import relativedelta
import bonsai.tool as tool
try:
from .prop import update_filter_column
from . import prop
from .ui import calculate_visible_columns_count
except Exception:
try:
from ..prop.filter import update_filter_column
from .. import prop
from ..ui.schedule_ui import calculate_visible_columns_count
except Exception:
def update_filter_column(*args, **kwargs):
pass
def calculate_visible_columns_count(context):
return 3 # Safe fallback
# Fallback for safe assignment function
class PropFallback:
@staticmethod
def safe_set_selected_colortype_in_active_group(task_obj, value, skip_validation=False):
try:
setattr(task_obj, "selected_colortype_in_active_group", value)
except Exception as e:
print(f"[ERROR] Fallback safe_set failed: {e}")
prop = PropFallback()
import os
import time
import isodate
import bonsai.core.sequence as core
import bonsai.bim.module.sequence.utils.helper_utils as helper
try:
from .animation_operators import _clear_previous_animation, _get_animation_settings, _compute_product_frames, _ensure_default_group
except ImportError:
# Fallback functions
def _clear_previous_animation(context):
pass
def _get_animation_settings(context):
return {}
def _compute_product_frames(context, work_schedule, settings):
return []
def _ensure_default_group(context):
pass
try:
from bonsai.bim.module.sequence.prop import UnifiedColorTypeManager
except Exception:
UnifiedColorTypeManager = None # optional
try:
from ..prop import TaskcolortypeGroupChoice
except Exception:
try:
from bonsai.bim.module.sequence.prop import TaskcolortypeGroupChoice
except Exception:
TaskcolortypeGroupChoice = None # optional
import ifcopenshell.util.sequence
import ifcopenshell.util.selector
from datetime import datetime
from dateutil import parser, relativedelta
from bpy_extras.io_utils import ImportHelper, ExportHelper
# === Local handler to keep schedule texts in sync with the chosen date range ===
_LOCAL_TEXT_HANDLER = None
def _parse_dt_any(v):
"""Parse 'YYYY-MM-DD' or ISO-like strings to datetime (no external deps)."""
try:
# Accept datetime/date objects
if hasattr(v, 'year') and hasattr(v, 'month') and hasattr(v, 'day'):
from datetime import datetime as _dt
# If it's already datetime-like, normalize to datetime
if hasattr(v, 'hour'):
return v
return _dt(v.year, v.month, v.day)
s = str(v).strip()
if not s:
return None
from datetime import datetime as _dt
# Full datetime
try:
return _dt.fromisoformat(s.replace('Z',''))
except Exception:
pass
# Date-only
try:
return _dt.fromisoformat(s.split('T')[0])
except Exception:
return None
except Exception:
return None
# REMOVED: Duplicate calculate_schedule_metrics function - using unified version below
def _ensure_local_text_settings_on_obj(_obj, _settings):
"""Attach or refresh minimal settings on text data so the handler maps frame→date correctly."""
try:
data = getattr(_obj, 'data', None)
if not data:
return
aset = dict(data.get('animation_settings', {}))
def _get(k, default=None):
if isinstance(_settings, dict):
return _settings.get(k, default)
return getattr(_settings, k, default)
scene = bpy.context.scene
new_vals = {
'start_frame': int(_get('start_frame', getattr(scene, 'frame_start', 1) or 1)),
'total_frames': int(_get('total_frames', max(1, int(getattr(scene, 'frame_end', 250)) - int(getattr(scene, 'frame_start', 1))))),
'start_date': _get('start', None),
'finish_date': _get('finish', None),
'schedule_start': _get('schedule_start', None),
'schedule_finish': _get('schedule_finish', None),
'schedule_name': _get('schedule_name', None),
}
changed = False
for k, v in new_vals.items():
if aset.get(k) != v and v is not None:
aset[k] = v
changed = True
if changed:
data['animation_settings'] = aset
# Ensure text_type is defined for the handler
if not data.get('text_type'):
n = (getattr(_obj, 'name', '') or '').lower()
if 'schedule_name' in n:
data['text_type'] = 'schedule_name'
elif 'date' in n:
data['text_type'] = 'date'
elif 'week' in n:
data['text_type'] = 'week'
elif 'day' in n:
data['text_type'] = 'day_counter'
elif 'progress' in n:
data['text_type'] = 'progress'
except Exception:
pass
def _local_schedule_texts_update_handler(scene, depsgraph):
'''Update schedule text objects each frame. Week/Day/Progress use the same robust logic as HUD Schedule.'''
print("🎬 3D Text Handler: Starting update...")
try:
coll = bpy.data.collections.get("Schedule_Display_Texts")
if not coll:
print("[WARNING] 3D Text Handler: No 'Schedule_Display_Texts' collection found")
return
print(f"📝 3D Text Handler: Found collection with {len(coll.objects)} objects")
# CHECK FOR SNAPSHOT MODE
snapshot_mode = False
snapshot_date = None
# Check if there's a snapshot date stored in scene properties
if hasattr(scene, 'BIMWorkPlanProperties'):
ws_props = tool.Sequence.get_work_schedule_props()
snapshot_date_str = getattr(ws_props, "visualisation_start", None)
# Check if we're in snapshot mode (same start and finish date, or specific snapshot flag)
finish_date_str = getattr(ws_props, "visualisation_finish", None)
if (snapshot_date_str and snapshot_date_str != "-" and
((finish_date_str and finish_date_str == snapshot_date_str) or
scene.get("is_snapshot_mode", False))):
try:
snapshot_date = tool.Sequence.parse_isodate_datetime(snapshot_date_str)
if snapshot_date:
snapshot_mode = True
print(f"📸 3D Text Handler: SNAPSHOT MODE detected for date {snapshot_date.strftime('%Y-%m-%d')}")
except Exception as e:
print(f"[WARNING] 3D Text Handler: Error parsing snapshot date: {e}")
if snapshot_mode and snapshot_date:
# SNAPSHOT MODE: Use fixed date, ignore frame-based calculation
cur_dt = snapshot_date
print(f"📸 Using snapshot date: {cur_dt.strftime('%Y-%m-%d %H:%M:%S')}")
else:
# ANIMATION MODE: Use frame-based calculation as before
cur_frame = int(scene.frame_current)
for obj in list(coll.objects):
cdata = getattr(obj, "data", None)
if not cdata:
continue
meta = dict(cdata.get("animation_settings", {})) or {}
start_frame = int(meta.get("start_frame", scene.frame_start))
total_frames = int(meta.get("total_frames", max(1, scene.frame_end - scene.frame_start)))
if total_frames <= 0:
total_frames = 1
# Normalized progress along the configured WINDOW
prog = (cur_frame - start_frame) / float(total_frames)
prog = max(0.0, min(1.0, prog))
# Window dates for mapping frame -> current date
wnd_start = _parse_dt_any(meta.get("start_date"))
wnd_finish = _parse_dt_any(meta.get("finish_date"))
cur_dt = None
if wnd_start and wnd_finish:
try:
delta_w = (wnd_finish - wnd_start)
cur_dt = wnd_start + prog * delta_w
except Exception:
cur_dt = wnd_start
# Process this object with its calculated cur_dt
_update_single_3d_text_object(obj, cdata, cur_dt)
return # Early return for animation mode
# SNAPSHOT MODE: Process all objects with the same snapshot date
for obj in list(coll.objects):
cdata = getattr(obj, "data", None)
if not cdata:
continue
_update_single_3d_text_object(obj, cdata, cur_dt)
except Exception as e:
print(f"[ERROR] 3D Text Handler error: {e}")
import traceback
traceback.print_exc()
def _update_single_3d_text_object(obj, cdata, cur_dt):
"""Update a single 3D text object with the given current date"""
try:
# --- ROBUST METHOD TO GET THE FULL SCHEDULE RANGE (like Viewport HUD) ---
sch_start, sch_finish = None, None
try:
active_schedule = tool.Sequence.get_active_work_schedule()
if active_schedule:
sch_start, sch_finish = tool.Sequence.get_schedule_date_range()
if not (sch_start and sch_finish):
# Fallback to task-based date extraction
import ifcopenshell.util.sequence
tasks = ifcopenshell.util.sequence.get_root_tasks(active_schedule)
if tasks:
all_dates = []
for task in tasks:
task_time = getattr(task, 'TaskTime', None)
if task_time:
start = getattr(task_time, 'ScheduleStart', None)
finish = getattr(task_time, 'ScheduleFinish', None)
if start: all_dates.append(start)
if finish: all_dates.append(finish)
if all_dates:
datetime_dates = [dt for dt in (_parse_dt_any(d) for d in all_dates) if dt]
if datetime_dates:
sch_start = min(datetime_dates)
sch_finish = max(datetime_dates)
except Exception as e:
print(f"Bonsai WARNING: Could not get full schedule range for 3D texts: {e}")
# DEBUG: Log what we got
if sch_start and sch_finish:
print(f"📊 3D Texts: Using schedule range {sch_start.strftime('%Y-%m-%d')}{sch_finish.strftime('%Y-%m-%d')}")
else:
print(f"[WARNING] 3D Texts: No schedule range available (sch_start={sch_start}, sch_finish={sch_finish})")
ttype = (cdata.get("text_type") or "").lower()
if ttype == "schedule_name":
try:
# For schedule_name, we need to get the meta from the object
meta = dict(cdata.get("animation_settings", {})) or {}
schedule_name = meta.get("schedule_name", "No Schedule")
cdata.body = f"Schedule: {schedule_name}"
except Exception:
cdata.body = "Schedule: --"
elif ttype == "date":
if cur_dt:
try:
cdata.body = cur_dt.strftime("%d/%m/%Y")
except Exception:
cdata.body = str(cur_dt).split("T")[0]
elif ttype == "week":
try:
print(f"🔍 Week text: cur_dt={cur_dt}, sch_start={sch_start}, sch_finish={sch_finish}")
if cur_dt and sch_start and sch_finish:
# Use the same robust logic as HUD Schedule
cd_d = cur_dt.date()
fss_d = sch_start.date()
fse_d = sch_finish.date()
delta_days = (cd_d - fss_d).days
if cd_d < fss_d:
week_number = 0
else:
week_number = max(1, (delta_days // 7) + 1)
print(f"📊 Week calculation: current={cd_d}, start={fss_d}, delta_days={delta_days}, week={week_number}")
cdata.body = f"Week {week_number}"
else:
print(f"[WARNING] Week text: Missing data, showing fallback")
cdata.body = "Week --"
except Exception as e:
print(f"[ERROR] Week text error: {e}")
cdata.body = "Week --"
elif ttype == "day_counter":
try:
print(f"🔍 Day text: cur_dt={cur_dt}, sch_start={sch_start}, sch_finish={sch_finish}")
if cur_dt and sch_start and sch_finish:
# Use the same robust logic as HUD Schedule
cd_d = cur_dt.date()
fss_d = sch_start.date()
delta_days = (cd_d - fss_d).days
if cd_d < fss_d:
day_from_schedule = 0
else:
day_from_schedule = max(1, delta_days + 1)
print(f"📊 Day calculation: current={cd_d}, start={fss_d}, delta_days={delta_days}, day={day_from_schedule}")
cdata.body = f"Day {day_from_schedule}"
else:
print(f"[WARNING] Day text: Missing data, showing fallback")
cdata.body = "Day --"
except Exception as e:
print(f"[ERROR] Day text error: {e}")
cdata.body = "Day --"
elif ttype == "progress":
try:
print(f"🔍 Progress text: cur_dt={cur_dt}, sch_start={sch_start}, sch_finish={sch_finish}")
if cur_dt and sch_start and sch_finish:
# Use the same robust logic as HUD Schedule
cd_d = cur_dt.date()
fss_d = sch_start.date()
fse_d = sch_finish.date()
if cd_d < fss_d:
progress_pct = 0
elif cd_d >= fse_d:
progress_pct = 100
else:
total_schedule_days = (fse_d - fss_d).days
if total_schedule_days <= 0:
progress_pct = 100
else:
delta_days = (cd_d - fss_d).days
progress_pct = (delta_days / total_schedule_days) * 100
progress_pct = round(progress_pct)
progress_pct = max(0, min(100, progress_pct))
print(f"📊 Progress calculation: current={cd_d}, start={fss_d}, end={fse_d}, progress={progress_pct}%")
cdata.body = f"Progress: {progress_pct}%"
else:
# Fallback: show percentage based on time
print(f"[WARNING] Progress text: Missing schedule data, showing fallback")
cdata.body = "Progress: --%"
except Exception as e:
print(f"[ERROR] Progress text error: {e}")
cdata.body = "Progress: --%"
except Exception as e:
print(f"[ERROR] Error updating 3D text object: {e}")
except Exception:
pass
def _local_unregister_text_handler():
global _LOCAL_TEXT_HANDLER
try:
if _LOCAL_TEXT_HANDLER and _LOCAL_TEXT_HANDLER in bpy.app.handlers.frame_change_post:
bpy.app.handlers.frame_change_post.remove(_LOCAL_TEXT_HANDLER)
except Exception:
pass
def _local_register_text_handler(settings=None):
"""Register fallback handler once, attach settings to known text objects if passed."""
global _LOCAL_TEXT_HANDLER
try:
_local_unregister_text_handler()
except Exception:
pass
try:
coll = bpy.data.collections.get("Schedule_Display_Texts")
if coll and settings is not None:
for obj in list(coll.objects):
_ensure_local_text_settings_on_obj(obj, settings)
except Exception:
pass
_LOCAL_TEXT_HANDLER = _local_schedule_texts_update_handler
try:
if _LOCAL_TEXT_HANDLER not in bpy.app.handlers.frame_change_post:
bpy.app.handlers.frame_change_post.append(_LOCAL_TEXT_HANDLER)
# Immediate refresh
_LOCAL_TEXT_HANDLER(bpy.context.scene, None)
except Exception:
pass
def _unified_register_text_handler(settings=None):
ok = False
try:
if hasattr(tool.Sequence, "_register_multi_text_handler"):
tool.Sequence._register_multi_text_handler(settings)
ok = True
except Exception:
ok = False
if not ok:
_local_register_text_handler(settings)
def _infer_schedule_date_range(work_schedule):
'''Infer earliest start and latest finish across tasks of the given work_schedule.'''
try:
import ifcopenshell
except Exception:
return None, None
try:
tasks = []
try:
if getattr(work_schedule, "Controls", None):
for rel in work_schedule.Controls:
for ob in getattr(rel, "RelatedObjects", []) or []:
if hasattr(ob, "is_a") and ob.is_a("IfcTask"):
tasks.append(ob)
except Exception:
pass
if not tasks:
try:
file = work_schedule.wrapped_data.file
tasks = [t for t in file.by_type("IfcTask")]
except Exception:
tasks = []
earliest = None
latest = None
for t in tasks:
tt = getattr(t, "TaskTime", None) or getattr(t, "Time", None)
if not tt:
continue
start_raw = None
finish_raw = None
for k in ("ActualStart","ScheduleStart","EarlyStart","LateStart","StartTime","Start"):
if hasattr(tt, k) and getattr(tt, k):
start_raw = getattr(tt, k); break
for k in ("ActualFinish","ScheduleFinish","EarlyFinish","LateFinish","FinishTime","Finish"):
if hasattr(tt, k) and getattr(tt, k):
finish_raw = getattr(tt, k); break
s = _parse_dt_any(start_raw)
f = _parse_dt_any(finish_raw)
if s:
earliest = s if earliest is None else min(earliest, s)
if f:
latest = f if latest is None else max(latest, f)
return earliest, latest
except Exception:
return None, None
from typing import get_args, TYPE_CHECKING, assert_never
# --- Lazy Enum items providers to avoid circular import with bonsai.tool ---
# ---- Unified Animation Bridges ----
def snapshot_all_ui_state(context):
"""
(SNAPSHOT) Captures the complete state of the profiles UI and saves it
in temporary scene properties. It also maintains a persistent cache
to support filter toggling (filter -> unfilter)
without losing data from hidden tasks.
"""
import json
try:
# 1. Snapshot of the profile configuration per task
tprops = tool.Sequence.get_task_tree_props()
task_snap = {}
# NEW: Also capture data from all tasks of the active schedule
# to avoid data loss when filters are applied/removed
try:
ws = tool.Sequence.get_active_work_schedule()
if ws:
import ifcopenshell.util.sequence
def get_all_tasks_recursive(tasks):
"""Recursively gets all tasks and subtasks."""
all_tasks = []
for task in tasks:
all_tasks.append(task)
nested = ifcopenshell.util.sequence.get_nested_tasks(task)
if nested:
all_tasks.extend(get_all_tasks_recursive(nested))
return all_tasks
root_tasks = ifcopenshell.util.sequence.get_root_tasks(ws)
all_tasks = get_all_tasks_recursive(root_tasks)
# Create a snapshot of all tasks, not just the visible ones
task_id_to_ui_data = {str(getattr(t, "ifc_definition_id", 0)): t for t in getattr(tprops, "tasks", [])}
for task in all_tasks:
tid = str(task.id())
if tid == "0":
continue
# If the task is visible in the UI, use its current data
if tid in task_id_to_ui_data:
t = task_id_to_ui_data[tid]
groups_list = []
for g in getattr(t, "colortype_group_choices", []):
sel_attr = None
for cand in ("selected_colortype", "selected", "active_colortype", "colortype"):
if hasattr(g, cand):
sel_attr = cand
break
groups_list.append({
"group_name": getattr(g, "group_name", ""),
"enabled": bool(getattr(g, "enabled", False)),
"selected_value": getattr(g, sel_attr, "") if sel_attr else "",
"selected_attr": sel_attr or "",
})
task_snap[tid] = {
"active": bool(getattr(t, "use_active_colortype_group", False)),
"selected_active_colortype": getattr(t, "selected_colortype_in_active_group", ""),
"animation_color_schemes": getattr(t, "animation_color_schemes", ""),
"groups": groups_list,
}
else:
# If the task is not visible (filtered), preserve data from the cache
cache_key = "_task_colortype_snapshot_cache_json"
cache_raw = context.scene.get(cache_key)
if cache_raw:
try:
cached_data = json.loads(cache_raw)
if tid in cached_data:
task_snap[tid] = cached_data[tid]
else:
# Create an empty entry for tasks without previous data
task_snap[tid] = {
"active": False,
"selected_active_colortype": "",
"animation_color_schemes": "",
"groups": [],
}
except Exception:
task_snap[tid] = {
"active": False,
"selected_active_colortype": "",
"animation_color_schemes": "",
"groups": [],
}
else:
task_snap[tid] = {
"active": False,
"selected_active_colortype": "",
"animation_color_schemes": "",
"groups": [],
}
except Exception as e:
print(f"Bonsai WARNING: Error capturando todas las tasks: {e}")
# Fallback to the original method with only visible tasks
for t in getattr(tprops, "tasks", []):
tid = str(getattr(t, "ifc_definition_id", 0))
if tid == "0":
continue
groups_list = []
for g in getattr(t, "colortype_group_choices", []):
sel_attr = None
for cand in ("selected_colortype", "selected", "active_colortype", "colortype"):
if hasattr(g, cand):
sel_attr = cand
break
groups_list.append({
"group_name": getattr(g, "group_name", ""),
"enabled": bool(getattr(g, "enabled", False)),
"selected_value": getattr(g, sel_attr, "") if sel_attr else "",
"selected_attr": sel_attr or "",
})
task_snap[tid] = {
"active": bool(getattr(t, "use_active_colortype_group", False)),
"selected_active_colortype": getattr(t, "selected_colortype_in_active_group", ""),
"animation_color_schemes": getattr(t, "animation_color_schemes", ""),
"groups": groups_list,
}
# Detect the active WorkSchedule to scope the cache
try:
ws_props = tool.Sequence.get_work_schedule_props()
ws_id = int(getattr(ws_props, "active_work_schedule_id", 0))
except Exception:
try:
ws = tool.Sequence.get_active_work_schedule()
ws_id = int(getattr(ws, "id", 0) or getattr(ws, "GlobalId", 0) or 0)
except Exception:
ws_id = 0
# Reset cache if the active WS changed
cache_ws_key = "_task_colortype_snapshot_cache_ws_id"
cache_key = "_task_colortype_snapshot_cache_json"
prior_ws = context.scene.get(cache_ws_key)
if prior_ws is None or int(prior_ws) != ws_id:
context.scene[cache_key] = "{}"
context.scene[cache_ws_key] = str(ws_id)
# Save ephemeral snapshot (current cycle) - BOTH KEYS for compatibility
snap_key_specific = f"_task_colortype_snapshot_json_WS_{ws_id}"
snap_key_generic = "_task_colortype_snapshot_json"
# Save to specific key (for Copy 3D)
context.scene[snap_key_specific] = json.dumps(task_snap)
print(f"[DEBUG] SNAPSHOT: Saved {len(task_snap)} tasks to {snap_key_specific}")
# ALSO save to generic key (for normal system)
context.scene[snap_key_generic] = json.dumps(task_snap)
# Update persistent cache (merge)
merged = {}
cache_raw = context.scene.get(cache_key)
if cache_raw:
try:
merged = json.loads(cache_raw) or {}
except Exception:
merged = {}
merged.update(task_snap)
context.scene[cache_key] = json.dumps(merged)
print(f"[DEBUG] SNAPSHOT: Updated cache with {len(merged)} total tasks")
# 2. Snapshot of group selectors and animation stack
anim_props = tool.Sequence.get_animation_props()
anim_snap = {
"ColorType_groups": getattr(anim_props, "ColorType_groups", "DEFAULT"),
"task_colortype_group_selector": getattr(anim_props, "task_colortype_group_selector", ""),
"animation_group_stack": [
{"group": getattr(item, "group", ""), "enabled": bool(getattr(item, "enabled", False))}
for item in getattr(anim_props, "animation_group_stack", [])
],
}
context.scene["_anim_state_snapshot_json"] = json.dumps(anim_snap)
# 3. Snapshot of active selection/index of the task tree
try:
wprops = tool.Sequence.get_work_schedule_props()
tprops = tool.Sequence.get_task_tree_props()
active_idx = int(getattr(wprops, 'active_task_index', -1))
active_id = int(getattr(wprops, 'active_task_id', 0))
selected_ids = []
for t in getattr(tprops, 'tasks', []):
tid = int(getattr(t, 'ifc_definition_id', 0))
sel = False
for cand in ('is_selected','selected'):
if hasattr(t, cand) and bool(getattr(t, cand)):
sel = True
break
if sel:
selected_ids.append(tid)
sel_snap = {'active_index': active_idx, 'active_id': active_id, 'selected_ids': selected_ids}
context.scene['_task_selection_snapshot_json'] = json.dumps(sel_snap)
except Exception:
pass
except Exception as e:
print(f"Bonsai WARNING: Could not create UI snapshot: {e}")
def restore_animation_group_settings(context):
"""Restore only animation group stack and selectors, not individual task colortype assignments"""
import json
try:
# Restore animation group settings from snapshot
anim_snap_raw = context.scene.get("_anim_state_snapshot_json")
if anim_snap_raw:
try:
anim_snap = json.loads(anim_snap_raw) or {}
anim_props = tool.Sequence.get_animation_props()
# Restore group selectors
try:
anim_props.ColorType_groups = anim_snap.get("ColorType_groups", "DEFAULT")
anim_props.task_colortype_group_selector = anim_snap.get("task_colortype_group_selector", "")
except Exception as e:
print(f"Warning: Could not restore group selectors: {e}")
# Restore animation group stack
try:
anim_props.animation_group_stack.clear()
for item_data in anim_snap.get("animation_group_stack", []):
item = anim_props.animation_group_stack.add()
item.group = item_data.get("group", "")
if hasattr(item, "enabled"):
item.enabled = bool(item_data.get("enabled", False))
except Exception as e:
print(f"Warning: Could not restore animation group stack: {e}")
except Exception as e:
print(f"Warning: Could not parse animation snapshot: {e}")
except Exception as e:
print(f"Error in restore_animation_group_settings: {e}")
@@ -0,0 +1,249 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021 Dion Moult <dion@thinkmoult.com>, 2022 Yassine Oualid <yassine@sigmadimensions.com>, 2025 Federico Eraso <feraso@svisuals.net>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import bpy
import bonsai.tool as tool
from .operator import snapshot_all_ui_state
from .schedule_task_operators import restore_all_ui_state
try:
# Updated imports for refactored sequence module
from ..tool.sequence.ui.task_properties import TaskProperties as props_sequence
from ..tool.sequence.core.color_manager import ColorManager as colortype_sequence
except ImportError:
# Fallback definitions
class props_sequence:
@staticmethod
def get_animation_props(): return None
@staticmethod
def get_task_tree_props(): return None
@staticmethod
def get_work_schedule_props(): return None
class colortype_sequence:
@staticmethod
def load_ColorType_group_data(group_name): return {"ColorTypes": []}
try:
from ..prop.animation import get_user_created_groups_enum
from ..prop.color_manager_prop import UnifiedColorTypeManager
except ImportError:
def get_user_created_groups_enum(self, context): return [("NONE", "None", "")]
class UnifiedColorTypeManager: pass
class SearchCustomColorTypeGroup(bpy.types.Operator):
bl_idname = "bim.search_custom_colortype_group"
bl_label = "Search Custom ColorType Group"
bl_description = "Search and filter custom ColorType groups"
bl_options = {"REGISTER", "UNDO"}
search_term: bpy.props.StringProperty(name="Search", default="")
def execute(self, context):
props = props_sequence.get_animation_props()
if not self.search_term:
self.report({'INFO'}, "Enter search term")
return {'CANCELLED'}
items = get_user_created_groups_enum(None, context)
matches = [item for item in items if self.search_term.lower() in item[1].lower()]
if matches:
props.task_ColorType_group_selector = matches[0][0]
self.report({'INFO'}, f"Found and selected: {matches[0][1]}")
else:
self.report({'WARNING'}, f"No groups found matching: {self.search_term}")
return {'FINISHED'}
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self)
def draw(self, context):
layout = self.layout
layout.prop(self, "search_term")
class CopyCustomColorTypeGroup(bpy.types.Operator):
bl_idname = "bim.copy_custom_colortype_group"
bl_label = "Copy Custom ColorType Group"
bl_description = "Copy current custom ColorType group to clipboard"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
props = props_sequence.get_animation_props()
current_value = getattr(props, "task_ColorType_group_selector", "")
if current_value:
context.window_manager.clipboard = current_value
self.report({'INFO'}, f"Copied to clipboard: {current_value}")
else:
self.report({'WARNING'}, "No custom ColorType group selected to copy")
return {'FINISHED'}
class PasteCustomColorTypeGroup(bpy.types.Operator):
bl_idname = "bim.paste_custom_colortype_group"
bl_label = "Paste Custom ColorType Group"
bl_description = "Paste custom ColorType group from clipboard"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
props = props_sequence.get_animation_props()
clipboard_value = context.window_manager.clipboard.strip()
if not clipboard_value:
self.report({'WARNING'}, "Clipboard is empty")
return {'CANCELLED'}
items = get_user_created_groups_enum(None, context)
valid_values = [item[0] for item in items]
if clipboard_value in valid_values:
props.task_ColorType_group_selector = clipboard_value
self.report({'INFO'}, f"Pasted from clipboard: {clipboard_value}")
else:
self.report({'WARNING'}, f"Invalid group in clipboard: {clipboard_value}")
return {'FINISHED'}
class SetCustomColorTypeGroupNull(bpy.types.Operator):
bl_idname = "bim.set_custom_colortype_group_null"
bl_label = "Set Custom ColorType Group to Null"
bl_description = "Clear custom ColorType group selection (set to null)"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
props = props_sequence.get_animation_props()
props.task_ColorType_group_selector = ""
try:
tprops = props_sequence.get_task_tree_props()
wprops = props_sequence.get_work_schedule_props()
if tprops.tasks and wprops.active_task_index < len(tprops.tasks):
task = tprops.tasks[wprops.active_task_index]
task.selected_ColorType_in_active_group = ""
task.use_active_ColorType_group = False
except Exception:
pass
self.report({'INFO'}, "Custom ColorType group cleared (set to null)")
return {'FINISHED'}
class ShowCustomColorTypeGroupInfo(bpy.types.Operator):
bl_idname = "bim.show_custom_colortype_group_info"
bl_label = "Custom ColorType Group Info"
bl_description = "Show information about the current custom ColorType group"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
props = props_sequence.get_animation_props()
current_value = getattr(props, "task_ColorType_group_selector", "")
if current_value:
group_data = colortype_sequence.load_ColorType_group_data(current_value)
ColorTypes = group_data.get("ColorTypes", [])
info_text = f"Group: {current_value}\nColorTypes: {len(ColorTypes)}\n"
if ColorTypes:
info_text += f"Available: {', '.join(c.get('name', '') for c in ColorTypes)}"
self.report({'INFO'}, info_text)
else:
self.report({'INFO'}, "No custom ColorType group selected")
return {'FINISHED'}
class BIM_OT_RefreshAnimationView(bpy.types.Operator):
"""The new Orchestra Director. Refreshes the entire 4D animation view."""
bl_idname = "bim.refresh_animation_view"
bl_label = "Refresh 4D Animation View"
bl_description = "Refresh the entire 4D schedule animation view"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
# Always use tool.Sequence for compatibility
import bonsai.tool as tool
# SNAPSHOT before any destructive reload
try:
snapshot_all_ui_state(context)
except Exception as e:
print(f"Bonsai WARNING: snapshot_all_ui_state failed: {e}")
try:
# 1. Clear old animation and visuals to start fresh
try:
tool.Sequence.clear_objects_animation()
except (AttributeError, TypeError):
pass
try:
tool.Sequence.clear_task_bars()
except (AttributeError, TypeError):
pass
# 2. Reload task properties from IFC
try:
tool.Sequence.load_task_properties()
except (AttributeError, TypeError):
pass
# 3. Calculate new animation configuration (frames, dates, etc.)
work_schedule = tool.Sequence.get_active_work_schedule()
if not work_schedule:
# Try to refresh UI properties even without work schedule
try:
tool.Sequence.load_task_properties()
except:
pass
self.report({'WARNING'}, "No active work schedule - refreshing UI only.")
# Force UI redraw
for area in context.screen.areas:
area.tag_redraw()
return {'FINISHED'}
# Validate work schedule is valid IFC entity
if not hasattr(work_schedule, 'id') or not work_schedule.id():
self.report({'ERROR'}, "Invalid work schedule entity.")
return {'CANCELLED'}
# 4. Force a simple refresh by reloading task tree and properties
try:
tool.Sequence.load_task_tree(work_schedule)
tool.Sequence.load_task_properties()
except (AttributeError, TypeError) as e:
self.report({'WARNING'}, f"Failed to reload tasks: {str(e)}")
except Exception as e:
# Handle any other errors gracefully
self.report({'WARNING'}, f"Task loading error: {str(e)}")
# 5. Try to refresh any visual elements
try:
tool.Sequence.refresh_task_bars()
except (AttributeError, TypeError):
pass
# 6. RESTORE state after reload
try:
restore_all_ui_state(context)
except Exception as e:
print(f"Bonsai WARNING: restore_all_ui_state failed: {e}")
# 7. Force Blender to redraw the entire interface
for area in context.screen.areas:
area.tag_redraw()
self.report({'INFO'}, "4D Sequence Refreshed.")
return {'FINISHED'}
except Exception as e:
self.report({'ERROR'}, f"Failed to refresh animation view: {str(e)}")
return {'CANCELLED'}
@@ -0,0 +1,447 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import bpy
import json
import calendar
from mathutils import Matrix
from datetime import datetime
from dateutil import relativedelta
from typing import get_args, TYPE_CHECKING, assert_never
import bonsai.tool as tool
import bonsai.core.sequence as core
try:
from .prop import update_filter_column
from . import prop
from .ui import calculate_visible_columns_count
from .operator import snapshot_all_ui_state
from .schedule_task_operators import restore_all_ui_state
except Exception:
try:
from ..prop.filter import update_filter_column
from .. import prop
from ..ui.schedule_ui import calculate_visible_columns_count
from .operator import snapshot_all_ui_state
from .schedule_task_operators import restore_all_ui_state
except Exception:
def update_filter_column(*args, **kwargs):
pass
def calculate_visible_columns_count(context):
return 3 # Safe fallback
# Fallback for safe assignment function
class PropFallback:
@staticmethod
def safe_set_selected_colortype_in_active_group(task_obj, value, skip_validation=False):
try:
setattr(task_obj, "selected_colortype_in_active_group", value)
except Exception as e:
print(f"[ERROR] Fallback safe_set failed: {e}")
prop = PropFallback()
def snapshot_all_ui_state(context):
pass
def restore_all_ui_state(context):
pass
def _related_object_type_items(self, context):
try:
from typing import get_args
from bonsai import tool as _tool
vals = list(get_args(getattr(_tool.Sequence, "RELATED_OBJECT_TYPE", tuple()))) or []
except Exception:
vals = []
if not vals:
# Safe fallback
vals = ("PRODUCT", "RESOURCE", "PROCESS")
return [(str(v), str(v).replace("_", " ").title(), "") for v in vals]
# ============================================================================
# ASSIGNMENT OPERATORS
# ============================================================================
class AssignPredecessor(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.assign_predecessor"
bl_label = "Assign Predecessor"
bl_options = {"REGISTER", "UNDO"}
task: bpy.props.IntProperty()
def _execute(self, context):
core.assign_predecessor(tool.Ifc, tool.Sequence, task=tool.Ifc.get().by_id(self.task))
class AssignSuccessor(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.assign_successor"
bl_label = "Assign Successor"
bl_options = {"REGISTER", "UNDO"}
task: bpy.props.IntProperty()
def _execute(self, context):
core.assign_successor(tool.Ifc, tool.Sequence, task=tool.Ifc.get().by_id(self.task))
class UnassignPredecessor(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.unassign_predecessor"
bl_label = "Unassign Predecessor"
bl_options = {"REGISTER", "UNDO"}
task: bpy.props.IntProperty()
def _execute(self, context):
core.unassign_predecessor(tool.Ifc, tool.Sequence, task=tool.Ifc.get().by_id(self.task))
class UnassignSuccessor(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.unassign_successor"
bl_label = "Unassign Successor"
bl_options = {"REGISTER", "UNDO"}
task: bpy.props.IntProperty()
def _execute(self, context):
core.unassign_successor(tool.Ifc, tool.Sequence, task=tool.Ifc.get().by_id(self.task))
class AssignProduct(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.assign_product"
bl_label = "Assign Product"
bl_options = {"REGISTER", "UNDO"}
task: bpy.props.IntProperty()
relating_product: bpy.props.IntProperty()
def _execute(self, context):
snapshot_all_ui_state(context)
try:
if self.relating_product:
core.assign_products(
tool.Ifc,
tool.Sequence,
tool.Spatial,
task=tool.Ifc.get().by_id(self.task),
products=[tool.Ifc.get().by_id(self.relating_product)],
)
else:
core.assign_products(tool.Ifc, tool.Sequence, tool.Spatial, task=tool.Ifc.get().by_id(self.task))
tool.Sequence.load_task_properties()
tool.Sequence.refresh_task_3d_counts()
finally:
restore_all_ui_state(context)
class UnassignProduct(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.unassign_product"
bl_label = "Unassign Product"
bl_options = {"REGISTER", "UNDO"}
task: bpy.props.IntProperty()
relating_product: bpy.props.IntProperty()
def _execute(self, context):
snapshot_all_ui_state(context)
try:
if self.relating_product:
core.unassign_products(
tool.Ifc,
tool.Sequence,
tool.Spatial,
task=tool.Ifc.get().by_id(self.task),
products=[tool.Ifc.get().by_id(self.relating_product)],
)
else:
core.unassign_products(tool.Ifc, tool.Sequence, tool.Spatial, task=tool.Ifc.get().by_id(self.task))
tool.Sequence.load_task_properties()
task_ifc = tool.Ifc.get().by_id(self.task)
tool.Sequence.update_task_ICOM(task_ifc)
tool.Sequence.refresh_task_3d_counts()
finally:
restore_all_ui_state(context)
class AssignProcess(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.assign_process"
bl_label = "Assign Process"
bl_options = {"REGISTER", "UNDO"}
task: bpy.props.IntProperty()
related_object_type: bpy.props.EnumProperty( # pyright: ignore [reportRedeclaration]
items=_related_object_type_items,
)
related_object: bpy.props.IntProperty()
if TYPE_CHECKING:
related_object_type: tool.Sequence.RELATED_OBJECT_TYPE
@classmethod
def description(cls, context, properties):
return f"Assign selected {properties.related_object_type} to the selected task"
def _execute(self, context):
snapshot_all_ui_state(context)
try:
if self.related_object_type == "RESOURCE":
core.assign_resource(tool.Ifc, tool.Sequence, tool.Resource, task=tool.Ifc.get().by_id(self.task))
elif self.related_object_type == "PRODUCT":
if self.related_object:
core.assign_input_products(
tool.Ifc,
tool.Sequence,
tool.Spatial,
task=tool.Ifc.get().by_id(self.task),
products=[tool.Ifc.get().by_id(self.related_object)],
)
else:
core.assign_input_products(tool.Ifc, tool.Sequence, tool.Spatial, task=tool.Ifc.get().by_id(self.task))
elif self.related_object_type == "CONTROL":
self.report({"ERROR"}, "Assigning process control is not yet supported") # TODO
else:
assert_never(self.related_object_type)
task_ifc = tool.Ifc.get().by_id(self.task)
tool.Sequence.update_task_ICOM(task_ifc)
tool.Sequence.refresh_task_3d_counts()
finally:
restore_all_ui_state(context)
class UnassignProcess(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.unassign_process"
bl_label = "Unassign Process"
bl_options = {"REGISTER", "UNDO"}
task: bpy.props.IntProperty()
related_object_type: bpy.props.EnumProperty( # pyright: ignore [reportRedeclaration]
items=_related_object_type_items,
)
related_object: bpy.props.IntProperty()
resource: bpy.props.IntProperty()
if TYPE_CHECKING:
related_object_type: tool.Sequence.RELATED_OBJECT_TYPE
@classmethod
def description(cls, context, properties):
return f"Unassign selected {properties.related_object_type} from the selected task"
def _execute(self, context):
snapshot_all_ui_state(context)
try:
if self.related_object_type == "RESOURCE":
core.unassign_resource(
tool.Ifc,
tool.Sequence,
tool.Resource,
task=tool.Ifc.get().by_id(self.task),
resource=tool.Ifc.get().by_id(self.resource),
)
elif self.related_object_type == "PRODUCT":
if self.related_object:
core.unassign_input_products(
tool.Ifc,
tool.Sequence,
tool.Spatial,
task=tool.Ifc.get().by_id(self.task),
products=[tool.Ifc.get().by_id(self.related_object)],
)
else:
core.unassign_input_products(
tool.Ifc, tool.Sequence, tool.Spatial, task=tool.Ifc.get().by_id(self.task)
)
elif self.related_object_type == "CONTROL":
pass # TODO
self.report({"INFO"}, "Unassigning process control is not yet supported.")
else:
assert_never(self.related_object_type)
task_ifc = tool.Ifc.get().by_id(self.task)
tool.Sequence.update_task_ICOM(task_ifc)
tool.Sequence.refresh_task_3d_counts()
finally:
restore_all_ui_state(context)
return {"FINISHED"}
class AssignRecurrencePattern(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.assign_recurrence_pattern"
bl_label = "Assign Recurrence Pattern"
bl_options = {"REGISTER", "UNDO"}
work_time: bpy.props.IntProperty()
recurrence_type: bpy.props.StringProperty()
def _execute(self, context):
core.assign_recurrence_pattern(
tool.Ifc, work_time=tool.Ifc.get().by_id(self.work_time), recurrence_type=self.recurrence_type
)
return {"FINISHED"}
class UnassignRecurrencePattern(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.unassign_recurrence_pattern"
bl_label = "Unassign Recurrence Pattern"
bl_options = {"REGISTER", "UNDO"}
recurrence_pattern: bpy.props.IntProperty()
def _execute(self, context):
core.unassign_recurrence_pattern(tool.Ifc, recurrence_pattern=tool.Ifc.get().by_id(self.recurrence_pattern))
return {"FINISHED"}
class AssignLagTime(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.assign_lag_time"
bl_label = "Assign Time Lag"
bl_options = {"REGISTER", "UNDO"}
sequence: bpy.props.IntProperty()
def _execute(self, context):
core.assign_lag_time(tool.Ifc, rel_sequence=tool.Ifc.get().by_id(self.sequence))
class UnassignLagTime(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.unassign_lag_time"
bl_label = "Unassign Time Lag"
bl_options = {"REGISTER", "UNDO"}
sequence: bpy.props.IntProperty()
def _execute(self, context):
core.unassign_lag_time(tool.Ifc, tool.Sequence, rel_sequence=tool.Ifc.get().by_id(self.sequence))
# ============================================================================
# SELECTION OPERATORS
# ============================================================================
class SelectTaskRelatedProducts(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.select_task_related_products"
bl_label = "Select All Related Products"
bl_options = {"REGISTER", "UNDO"}
task: bpy.props.IntProperty()
task_ids: bpy.props.CollectionProperty(type=bpy.types.PropertyGroup)
def _execute(self, context):
try:
# Determine whether to use an individual task or multiple task_ids
tasks_to_process = []
if self.task_ids:
# Multiple tasks (called from automatic process)
task_ids = [int(item.name) for item in self.task_ids if item.name.isdigit()]
for task_id in task_ids:
task = tool.Ifc.get().by_id(task_id)
if task:
tasks_to_process.append(task)
elif self.task:
# Single task (called from UI)
task = tool.Ifc.get().by_id(self.task)
if task:
tasks_to_process.append(task)
if not tasks_to_process:
return
# Collect all products from all tasks
all_products = []
for task in tasks_to_process:
# Get both outputs AND inputs
outputs = tool.Sequence.get_task_outputs(task) or []
inputs = tool.Sequence.get_task_inputs(task) or []
print(f" Outputs: {len(outputs)}, Inputs: {len(inputs)}")
all_products.extend(outputs)
all_products.extend(inputs)
# Remove duplicates
all_products = list(set(all_products))
if not all_products:
return
# CLEAR PREVIOUS SELECTION
bpy.ops.object.select_all(action='DESELECT')
# ROBUST MANUAL SELECTION (not dependent on view3d region)
selected_count = 0
for product in all_products:
obj = tool.Ifc.get_object(product)
if obj:
obj.select_set(True)
selected_count += 1
# OPTIONAL: Only focus if a 3D view is available
try:
if bpy.context.area and bpy.context.area.type == 'VIEW_3D':
bpy.ops.view3d.view_selected()
except Exception as e:
print(f"[WARNING] Could not center 3D view: {e}")
except Exception as e:
import traceback
traceback.print_exc()
class SelectTaskRelatedInputs(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.select_task_related_inputs"
bl_label = "Select All Input Products"
bl_options = {"REGISTER", "UNDO"}
task: bpy.props.IntProperty()
def _execute(self, context):
core.select_task_inputs(tool.Sequence, tool.Spatial, task=tool.Ifc.get().by_id(self.task))
class LoadProductTasks(bpy.types.Operator):
bl_idname = "bim.load_product_related_tasks"
bl_label = "Load Product Tasks"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if not tool.Ifc.get() or not (obj := context.active_object) or not (tool.Blender.get_ifc_definition_id(obj)):
cls.poll_message_set("No IFC object is active.")
return False
return True
def execute(self, context):
try:
obj = context.active_object
if not obj:
self.report({"ERROR"}, "No active object selected")
return {"CANCELLED"}
product = tool.Ifc.get_entity(obj)
if not product:
self.report({"ERROR"}, "Active object is not an IFC entity")
return {"CANCELLED"}
# Call the corrected method
result = tool.Sequence.load_product_related_tasks(product)
if isinstance(result, str):
if "Error" in result:
self.report({"ERROR"}, result)
return {"CANCELLED"}
else:
self.report({"INFO"}, result)
else:
self.report({"INFO"}, f"{len(result)} product tasks loaded.")
return {"FINISHED"}
except Exception as e:
self.report({"ERROR"}, f"Failed to load product tasks: {str(e)}")
import traceback
traceback.print_exc()
return {"CANCELLED"}
@@ -0,0 +1,254 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
# Description: Operators for managing work calendars, work times, and recurrence patterns.
import bpy
import bonsai.tool as tool
import bonsai.core.sequence as core
# ============================================================================
# CALENDAR OPERATORS
# ============================================================================
class AddWorkCalendar(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.add_work_calendar"
bl_label = "Add Work Calendar"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
core.add_work_calendar(tool.Ifc)
class EditWorkCalendar(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.edit_work_calendar"
bl_label = "Edit Work Calendar"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
props = tool.Sequence.get_work_calendar_props()
core.edit_work_calendar(
tool.Ifc,
tool.Sequence,
work_calendar=tool.Ifc.get().by_id(props.active_work_calendar_id),
)
class RemoveWorkCalendar(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.remove_work_calendar"
bl_label = "Remove Work Plan"
bl_options = {"REGISTER", "UNDO"}
work_calendar: bpy.props.IntProperty()
def _execute(self, context):
core.remove_work_calendar(tool.Ifc, work_calendar=tool.Ifc.get().by_id(self.work_calendar))
class EnableEditingWorkCalendar(bpy.types.Operator):
bl_idname = "bim.enable_editing_work_calendar"
bl_label = "Enable Editing Work Calendar"
bl_options = {"REGISTER", "UNDO"}
work_calendar: bpy.props.IntProperty()
def execute(self, context):
core.enable_editing_work_calendar(tool.Sequence, work_calendar=tool.Ifc.get().by_id(self.work_calendar))
return {"FINISHED"}
class DisableEditingWorkCalendar(bpy.types.Operator):
bl_idname = "bim.disable_editing_work_calendar"
bl_label = "Disable Editing Work Calendar"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
core.disable_editing_work_calendar(tool.Sequence)
return {"FINISHED"}
class EnableEditingWorkCalendarTimes(bpy.types.Operator):
bl_idname = "bim.enable_editing_work_calendar_times"
bl_label = "Enable Editing Work Calendar Times"
bl_options = {"REGISTER", "UNDO"}
work_calendar: bpy.props.IntProperty()
def execute(self, context):
core.enable_editing_work_calendar_times(tool.Sequence, work_calendar=tool.Ifc.get().by_id(self.work_calendar))
return {"FINISHED"}
class AddWorkTime(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.add_work_time"
bl_label = "Add Work Time"
bl_options = {"REGISTER", "UNDO"}
work_calendar: bpy.props.IntProperty()
time_type: bpy.props.StringProperty()
def _execute(self, context):
core.add_work_time(tool.Ifc, work_calendar=tool.Ifc.get().by_id(self.work_calendar), time_type=self.time_type)
class EnableEditingWorkTime(bpy.types.Operator):
bl_idname = "bim.enable_editing_work_time"
bl_label = "Enable Editing Work Time"
bl_options = {"REGISTER", "UNDO"}
work_time: bpy.props.IntProperty()
def execute(self, context):
core.enable_editing_work_time(tool.Sequence, work_time=tool.Ifc.get().by_id(self.work_time))
return {"FINISHED"}
class DisableEditingWorkTime(bpy.types.Operator):
bl_idname = "bim.disable_editing_work_time"
bl_label = "Disable Editing Work Time"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
core.disable_editing_work_time(tool.Sequence)
return {"FINISHED"}
class EditWorkTime(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.edit_work_time"
bl_label = "Edit Work Time"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
core.edit_work_time(tool.Ifc, tool.Sequence)
class RemoveWorkTime(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.remove_work_time"
bl_label = "Remove Work Plan"
bl_options = {"REGISTER", "UNDO"}
work_time: bpy.props.IntProperty()
def _execute(self, context):
core.remove_work_time(tool.Ifc, work_time=tool.Ifc.get().by_id(self.work_time))
return {"FINISHED"}
class AddTimePeriod(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.add_time_period"
bl_label = "Add Time Period"
bl_options = {"REGISTER", "UNDO"}
recurrence_pattern: bpy.props.IntProperty()
def _execute(self, context):
core.add_time_period(tool.Ifc, tool.Sequence, recurrence_pattern=tool.Ifc.get().by_id(self.recurrence_pattern))
class RemoveTimePeriod(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.remove_time_period"
bl_label = "Remove Time Period"
bl_options = {"REGISTER", "UNDO"}
time_period: bpy.props.IntProperty()
def _execute(self, context):
core.remove_time_period(tool.Ifc, time_period=tool.Ifc.get().by_id(self.time_period))
# ============================================================================
# TASK TIME OPERATORS (moved from operator.py)
# ============================================================================
class EnableEditingTaskTime(bpy.types.Operator, tool.Ifc.Operator):
# IFC operator is needed because operator is adding a new task time to IFC
# if it doesn't exist.
bl_idname = "bim.enable_editing_task_time"
bl_label = "Enable Editing Task Time"
bl_options = {"REGISTER", "UNDO"}
task: bpy.props.IntProperty()
def _execute(self, context):
core.enable_editing_task_time(tool.Ifc, tool.Sequence, task=tool.Ifc.get().by_id(self.task))
class EditTaskTime(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.edit_task_time"
bl_label = "Edit Task Time"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
props = tool.Sequence.get_work_schedule_props()
core.edit_task_time(
tool.Ifc,
tool.Sequence,
tool.Resource,
task_time=tool.Ifc.get().by_id(props.active_task_time_id),
)
class DisableEditingTaskTime(bpy.types.Operator):
bl_idname = "bim.disable_editing_task_time"
bl_label = "Disable Editing Task Time"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
core.disable_editing_task_time(tool.Sequence)
return {"FINISHED"}
class EnableEditingTaskCalendar(bpy.types.Operator):
bl_idname = "bim.enable_editing_task_calendar"
bl_label = "Enable Editing Task Calendar"
bl_options = {"REGISTER", "UNDO"}
task: bpy.props.IntProperty()
def execute(self, context):
core.enable_editing_task_calendar(tool.Sequence, task=tool.Ifc.get().by_id(self.task))
return {"FINISHED"}
class EditTaskCalendar(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.edit_task_calendar"
bl_label = "Edit Task Calendar"
bl_options = {"REGISTER", "UNDO"}
work_calendar: bpy.props.IntProperty()
task: bpy.props.IntProperty()
def _execute(self, context):
core.edit_task_calendar(
tool.Ifc,
tool.Sequence,
task=tool.Ifc.get().by_id(self.task),
work_calendar=tool.Ifc.get().by_id(self.work_calendar),
)
class RemoveTaskCalendar(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.remove_task_calendar"
bl_label = "Remove Task Calendar"
bl_options = {"REGISTER", "UNDO"}
work_calendar: bpy.props.IntProperty()
task: bpy.props.IntProperty()
def _execute(self, context):
core.remove_task_calendar(
tool.Ifc,
tool.Sequence,
task=tool.Ifc.get().by_id(self.task),
work_calendar=tool.Ifc.get().by_id(self.work_calendar),
)
class EnableEditingTaskCalendar(bpy.types.Operator):
bl_idname = "bim.enable_editing_task_calendar"
bl_label = "Enable Editing Task Calendar"
bl_options = {"REGISTER", "UNDO"}
task: bpy.props.IntProperty()
def execute(self, context):
core.enable_editing_task_calendar(tool.Sequence, task=tool.Ifc.get().by_id(self.task))
return {"FINISHED"}
@@ -0,0 +1,420 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
# Description: General operators for schedule-wide actions like recalculation, Gantt charts, and variance analysis.
import bpy
import bonsai.tool as tool
import bonsai.core.sequence as core
from .operator import snapshot_all_ui_state
from .schedule_task_operators import restore_all_ui_state
# ============================================================================
# SCHEDULE UTILITY OPERATORS
# ============================================================================
class RecalculateSchedule(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.recalculate_schedule"
bl_label = "Recalculate Schedule"
bl_options = {"REGISTER", "UNDO"}
work_schedule: bpy.props.IntProperty()
def _execute(self, context):
core.recalculate_schedule(tool.Ifc, work_schedule=tool.Ifc.get().by_id(self.work_schedule))
class GenerateGanttChart(bpy.types.Operator):
bl_idname = "bim.generate_gantt_chart"
bl_label = "Generate Gantt Chart"
bl_options = {"REGISTER", "UNDO"}
work_schedule: bpy.props.IntProperty()
def execute(self, context):
try:
work_schedule = tool.Ifc.get().by_id(self.work_schedule)
if not work_schedule:
self.report({'ERROR'}, "Work schedule not found")
return {'CANCELLED'}
import ifcopenshell.util.sequence as _useq
if not _useq.get_root_tasks(work_schedule):
self.report({'WARNING'}, "No tasks found in schedule")
return {'CANCELLED'}
core.generate_gantt_chart(tool.Sequence, work_schedule=work_schedule)
return {'FINISHED'}
except Exception as e:
self.report({'ERROR'}, f"Failed to generate Gantt chart: {str(e)}")
return {'CANCELLED'}
class AddTaskColumn(bpy.types.Operator):
bl_idname = "bim.add_task_column"
bl_label = "Add Task Column"
bl_options = {"REGISTER", "UNDO"}
column_type: bpy.props.StringProperty()
name: bpy.props.StringProperty()
data_type: bpy.props.StringProperty()
def execute(self, context):
print(f"[DEBUG] AddTaskColumn: Starting - taking snapshot")
snapshot_all_ui_state(context)
print(f"[DEBUG] AddTaskColumn: Adding column {self.column_type}.{self.name}")
# Let core.add_task_column do its work INCLUDING load_task_tree
core.add_task_column(tool.Sequence, self.column_type, self.name, self.data_type)
# NOW restore after tasks have been recreated
print(f"[DEBUG] AddTaskColumn: Restoring snapshot AFTER task tree reload")
restore_all_ui_state(context)
# FORCE a second restore after a small delay to ensure it sticks
def delayed_restore():
print(f"[DEBUG] AddTaskColumn: DELAYED restore attempt")
restore_all_ui_state(context)
return None # Don't repeat
bpy.app.timers.register(delayed_restore, first_interval=0.1)
print(f"[DEBUG] AddTaskColumn: Completed")
return {"FINISHED"}
class SetupDefaultTaskColumns(bpy.types.Operator):
bl_idname = "bim.setup_default_task_columns"
bl_label = "Setup Default Task Columns"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
print(f"[DEBUG] SetupDefaultTaskColumns: Starting - taking snapshot")
snapshot_all_ui_state(context)
print(f"[DEBUG] SetupDefaultTaskColumns: Setting up default columns")
core.setup_default_task_columns(tool.Sequence)
# Manually reload task tree since core.setup_default_task_columns doesn't do it
work_schedule = tool.Sequence.get_active_work_schedule()
if work_schedule:
tool.Sequence.load_task_tree(work_schedule)
tool.Sequence.load_task_properties()
print(f"[DEBUG] SetupDefaultTaskColumns: Restoring snapshot")
restore_all_ui_state(context)
print(f"[DEBUG] SetupDefaultTaskColumns: Completed")
return {"FINISHED"}
class RemoveTaskColumn(bpy.types.Operator):
bl_idname = "bim.remove_task_column"
bl_label = "Remove Task Column"
bl_options = {"REGISTER", "UNDO"}
name: bpy.props.StringProperty()
def execute(self, context):
core.remove_task_column(tool.Sequence, self.name)
return {"FINISHED"}
class SetTaskSortColumn(bpy.types.Operator):
bl_idname = "bim.set_task_sort_column"
bl_label = "Set Task Sort Column"
bl_options = {"REGISTER", "UNDO"}
column: bpy.props.StringProperty()
def execute(self, context):
snapshot_all_ui_state(context)
core.set_task_sort_column(tool.Sequence, self.column)
restore_all_ui_state(context)
return {'FINISHED'}
class ToggleSortReversedOperator(bpy.types.Operator):
bl_idname = "toggle_sort_reversed"
bl_label = "Toggle Sort Direction"
bl_description = "Toggle sort direction"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
print(f"[DEBUG] ToggleSortReversedOperator: Starting")
try:
# Get current state
ws_props = tool.Sequence.get_work_schedule_props()
current_reversed = getattr(ws_props, 'is_sort_reversed', False)
new_reversed = not current_reversed
print(f"[DEBUG] ToggleSortReversedOperator: Taking snapshot")
snapshot_all_ui_state(context)
print(f"[DEBUG] ToggleSortReversedOperator: Setting {new_reversed}, loading task tree")
# Set the value directly without triggering callbacks
ws_props.is_sort_reversed = new_reversed
# Manually trigger the reload like the callback would do
if ws_props.active_work_schedule_id:
work_schedule = tool.Ifc.get().by_id(ws_props.active_work_schedule_id)
if work_schedule:
tool.Sequence.load_task_tree(work_schedule)
tool.Sequence.load_task_properties()
print(f"[DEBUG] ToggleSortReversedOperator: Restoring snapshot")
restore_all_ui_state(context)
# Delayed restore for safety
def delayed_restore():
print(f"[DEBUG] ToggleSortReversedOperator: DELAYED restore")
restore_all_ui_state(context)
return None
bpy.app.timers.register(delayed_restore, first_interval=0.1)
direction = "descending" if new_reversed else "ascending"
self.report({'INFO'}, f"Sort direction: {direction}")
print(f"[DEBUG] ToggleSortReversedOperator: Completed")
except Exception as e:
print(f"[ERROR] ToggleSortReversedOperator failed: {e}")
import traceback
traceback.print_exc()
self.report({'ERROR'}, f"Failed to toggle sort direction: {str(e)}")
return {"CANCELLED"}
return {"FINISHED"}
class CreateBaseline(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.create_baseline"
bl_label = "Create Schedule Baseline"
bl_options = {"REGISTER", "UNDO"}
work_schedule: bpy.props.IntProperty()
name: bpy.props.StringProperty()
def _execute(self, context):
core.create_baseline(
tool.Ifc, tool.Sequence, work_schedule=tool.Ifc.get().by_id(self.work_schedule), name=self.name
)
def draw(self, context):
layout = self.layout
layout.prop(self, "name", text="Baseline Name")
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self)
class CalculateScheduleVariance(bpy.types.Operator):
"""Calculates the variance between two date sets for all tasks."""
bl_idname = "bim.calculate_schedule_variance"
bl_label = "Calculate Schedule Variance"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
import ifcopenshell.util.sequence
ws_props = tool.Sequence.get_work_schedule_props()
task_props = tool.Sequence.get_task_tree_props()
if not task_props.tasks:
self.report({'WARNING'}, "No tasks visible to calculate variance. Clear filters to see all tasks.")
return {'CANCELLED'}
source_a = ws_props.variance_source_a
source_b = ws_props.variance_source_b
if source_a == source_b:
self.report({'WARNING'}, "Cannot compare a date set with itself.")
return {'CANCELLED'}
finish_attr_a = f"{source_a.capitalize()}Finish"
finish_attr_b = f"{source_b.capitalize()}Finish"
tasks_processed = 0
for task_pg in task_props.tasks:
task_ifc = tool.Ifc.get().by_id(task_pg.ifc_definition_id)
if not task_ifc:
continue
date_a = ifcopenshell.util.sequence.derive_date(task_ifc, finish_attr_a, is_latest=True)
date_b = ifcopenshell.util.sequence.derive_date(task_ifc, finish_attr_b, is_latest=True)
if date_a and date_b:
delta = date_b.date() - date_a.date()
variance_days = delta.days
task_pg.variance_days = variance_days
if variance_days > 0:
task_pg.variance_status = f"Delayed (+{variance_days}d)"
elif variance_days < 0:
task_pg.variance_status = f"Ahead ({variance_days}d)"
else:
task_pg.variance_status = "On Time"
tasks_processed += 1
else:
task_pg.variance_status = "N/A"
task_pg.variance_days = 0
self.report({'INFO'}, f"Variance calculated for {tasks_processed} tasks ({source_a} vs {source_b}).")
return {'FINISHED'}
class ClearScheduleVariance(bpy.types.Operator):
"""Clears the calculated variance from all visible tasks."""
bl_idname = "bim.clear_schedule_variance"
bl_label = "Clear Variance"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
tool.Sequence.clear_schedule_variance()
self.report({'INFO'}, "Cleared variance and color mode.")
return {'FINISHED'}
class DeactivateVarianceColorMode(bpy.types.Operator):
bl_idname = "bim.deactivate_variance_color_mode"
bl_label = "Deactivate Variance Color Mode"
bl_description = "Deactivate variance color mode and restore normal colors"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
try:
tool.Sequence.deactivate_variance_color_mode()
self.report({'INFO'}, "Variance color mode deactivated")
except Exception as e:
self.report({'ERROR'}, f"Deactivation failed: {e}")
return {'FINISHED'}
class RefreshTask3DCounts(bpy.types.Operator):
"""Recalculates the number of 3D elements (Inputs + Outputs) for all tasks in the list."""
bl_idname = "bim.refresh_task_3d_counts"
bl_label = "Refresh 3D Counts"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
try:
# Call the new function in the core
core.refresh_task_3d_counts(tool.Sequence)
self.report({'INFO'}, "'3D' counts updated.")
# Force a redraw of the UI to show the changes
for window in context.window_manager.windows:
for area in window.screen.areas:
if area.type == 'PROPERTIES':
area.tag_redraw()
except Exception as e:
self.report({'ERROR'}, f"Failed to refresh: {e}")
return {'CANCELLED'}
return {'FINISHED'}
class AddTaskBars(bpy.types.Operator):
bl_idname = "bim.add_task_bars"
bl_label = "Generate Task Bars"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Generate 3D bars for selected tasks aligned with schedule dates"
def execute(self, context):
try:
# Check if animation is active for safer use
try:
import bonsai.tool as tool
anim_props = tool.Sequence.get_animation_props()
is_animation_active = getattr(anim_props, 'is_animation_created', False)
if is_animation_active:
print("[WARNING] Generating task bars during active animation - using safe mode")
# Continue but with more protections
except Exception:
pass # If it cannot be verified, continue normally
tool.Sequence.refresh_task_bars()
task_count = len(tool.Sequence.get_task_bar_list())
self.report({'INFO'}, f"Generated bars for {task_count} tasks.")
return {"FINISHED"}
except Exception as e:
self.report({'ERROR'}, f"Failed to generate task bars: {str(e)}")
return {'CANCELLED'}
class ClearTaskBars(bpy.types.Operator):
bl_idname = "bim.clear_task_bars"
bl_label = "Clear Task Bars"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Remove all task bar visualizations"
def execute(self, context):
tool.Sequence.clear_task_bars()
self.report({'INFO'}, "Task bars cleared")
return {"FINISHED"}
class GuessDateRange(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.guess_date_range"
bl_label = "Guess Work Schedule Date Range"
bl_options = {"REGISTER", "UNDO"}
work_schedule: bpy.props.IntProperty()
def _execute(self, context):
work_schedule = tool.Ifc.get().by_id(self.work_schedule)
# Calculate unified date range across all schedule types for HUD timeline
unified_start_date, unified_finish_date = self._calculate_unified_range(work_schedule)
if unified_start_date and unified_finish_date:
tool.Sequence.update_visualisation_date(unified_start_date, unified_finish_date)
self.report({'INFO'}, f"Unified timeline set: {unified_start_date.strftime('%Y-%m-%d')} to {unified_finish_date.strftime('%Y-%m-%d')}")
else:
self.report({'WARNING'}, "No dates found across any schedule types to create unified range.")
return {"FINISHED"}
def _calculate_unified_range(self, work_schedule):
"""Calculate unified date range across all schedule types"""
import ifcopenshell.util.sequence
if not work_schedule:
return None, None
# Get all tasks from schedule
root_tasks = ifcopenshell.util.sequence.get_root_tasks(work_schedule)
if not root_tasks:
return None, None
def get_all_tasks(tasks):
all_tasks = []
for task in tasks:
all_tasks.append(task)
nested = ifcopenshell.util.sequence.get_nested_tasks(task)
if nested:
all_tasks.extend(get_all_tasks(nested))
return all_tasks
all_tasks = get_all_tasks(root_tasks)
schedule_types = ['SCHEDULE', 'ACTUAL', 'EARLY', 'LATE']
all_start_dates = []
all_finish_dates = []
# Collect dates from all schedule types
for schedule_type in schedule_types:
start_attr = f"{schedule_type.capitalize()}Start"
finish_attr = f"{schedule_type.capitalize()}Finish"
for task in all_tasks:
start_date = ifcopenshell.util.sequence.derive_date(task, start_attr, is_earliest=True)
if start_date:
all_start_dates.append(start_date)
finish_date = ifcopenshell.util.sequence.derive_date(task, finish_attr, is_latest=True)
if finish_date:
all_finish_dates.append(finish_date)
if not all_start_dates or not all_finish_dates:
return None, None
return min(all_start_dates), max(all_finish_dates)
@@ -0,0 +1,277 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
"""
Schedule Operators with Task Column Persistence
==============================================
Schedule operators that maintain task values during column operations.
"""
import bpy
import bonsai.tool as tool
import bonsai.core.sequence as core
from .task_column_persistence import (
snapshot_before_add_column,
restore_after_add_column,
save_combined_state,
restore_combined_state,
get_backup_stats
)
class AddTaskColumn(bpy.types.Operator):
"""ADD TASK COLUMN with snapshot system integration"""
bl_idname = "bim.add_task_column_with_persistence"
bl_label = "Add Task Column"
bl_description = "Add task column"
bl_options = {"REGISTER", "UNDO"}
column_type: bpy.props.StringProperty()
name: bpy.props.StringProperty()
data_type: bpy.props.StringProperty()
def execute(self, context):
try:
# Take snapshot before adding column
snapshot_before_add_column()
core.add_task_column(tool.Sequence, self.column_type, self.name, self.data_type)
# Use delayed restore to allow Blender to finish creating new task objects
def delayed_restore():
restore_after_add_column()
# Force UI refresh
for window in context.window_manager.windows:
for area in window.screen.areas:
area.tag_redraw()
return None
bpy.app.timers.register(delayed_restore, first_interval=0.05)
stats = get_backup_stats()
self.report({'INFO'}, f"Column added successfully")
except Exception as e:
self.report({'ERROR'}, f"Failed to add column: {str(e)}")
return {"CANCELLED"}
return {"FINISHED"}
class SetupDefaultTaskColumns(bpy.types.Operator):
"""Setup default columns with snapshot system"""
bl_idname = "bim.setup_default_task_columns_with_persistence"
bl_label = "Setup Default Task Columns"
bl_description = "Setup default task columns"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
try:
save_combined_state()
tool.Sequence.setup_default_task_columns()
work_schedule = tool.Sequence.get_active_work_schedule()
if work_schedule:
tool.Sequence.load_task_tree(work_schedule)
tool.Sequence.load_task_properties()
restore_combined_state()
self.report({'INFO'}, f"Default columns setup completed")
except Exception as e:
self.report({'ERROR'}, f"Failed to setup default columns: {str(e)}")
return {"CANCELLED"}
return {"FINISHED"}
class SetTaskSortColumn(bpy.types.Operator):
"""SET TASK SORT COLUMN with snapshot system integration"""
bl_idname = "bim.set_task_sort_column_with_persistence"
bl_label = "Set Task Sort Column"
bl_description = "Set task sort column"
bl_options = {"REGISTER", "UNDO"}
column: bpy.props.StringProperty()
def execute(self, context):
try:
save_combined_state()
core.set_task_sort_column(tool.Sequence, self.column)
# Use delayed restore to allow Blender to finish creating new task objects
def delayed_restore():
restore_combined_state()
# Force UI refresh
for window in context.window_manager.windows:
for area in window.screen.areas:
area.tag_redraw()
return None
bpy.app.timers.register(delayed_restore, first_interval=0.05)
self.report({'INFO'}, f"Sort column set")
except Exception as e:
self.report({'ERROR'}, f"Failed to set sort column: {str(e)}")
return {"CANCELLED"}
return {"FINISHED"}
class ToggleSortReversed(bpy.types.Operator):
"""SORT REVERSED toggle with snapshot system integration"""
bl_idname = "bim.toggle_sort_reversed_with_persistence"
bl_label = "Toggle Sort Direction"
bl_description = "Toggle sort direction"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
try:
save_combined_state()
ws_props = tool.Sequence.get_work_schedule_props()
ws_props.is_sort_reversed = not getattr(ws_props, 'is_sort_reversed', False)
restore_combined_state()
self.report({'INFO'}, f"Sort direction changed")
except Exception as e:
self.report({'ERROR'}, f"Failed to change sort direction: {str(e)}")
return {"CANCELLED"}
return {"FINISHED"}
class TaskColumnSnapshotOperator(bpy.types.Operator):
"""Manual snapshot operator for task columns"""
bl_idname = "bim.task_column_snapshot"
bl_label = "Take Snapshot"
bl_description = "Take a snapshot of current task values"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
try:
save_combined_state()
self.report({'INFO'}, "Snapshot taken")
except Exception as e:
self.report({'ERROR'}, f"Snapshot failed: {str(e)}")
return {"CANCELLED"}
return {"FINISHED"}
class TaskColumnRestoreOperator(bpy.types.Operator):
"""Manual restore operator for task columns"""
bl_idname = "bim.task_column_restore"
bl_label = "Restore Snapshot"
bl_description = "Restore task values from snapshot"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
try:
restore_combined_state()
stats = get_backup_stats()
if stats['is_empty']:
self.report({'WARNING'}, "No snapshot data to restore")
else:
self.report({'INFO'}, "Snapshot restored")
except Exception as e:
self.report({'ERROR'}, f"Restore failed: {str(e)}")
return {"CANCELLED"}
return {"FINISHED"}
class TaskColumnSnapshotStatsOperator(bpy.types.Operator):
"""Display snapshot statistics"""
bl_idname = "bim.task_column_snapshot_stats"
bl_label = "Snapshot Info"
bl_description = "Show snapshot information"
def execute(self, context):
try:
stats = get_backup_stats()
if stats['is_empty']:
self.report({'INFO'}, "No snapshot data available")
else:
self.report({'INFO'}, f"Snapshot: {stats['task_count']} tasks")
except Exception as e:
self.report({'ERROR'}, f"Stats failed: {str(e)}")
return {"CANCELLED"}
return {"FINISHED"}
# Integration with existing schedule operators
class TaskColumnPanel(bpy.types.Panel):
"""Panel for task column operations"""
bl_label = "Task Columns"
bl_idname = "BIM_PT_task_columns"
bl_space_type = 'PROPERTIES'
bl_region_type = 'WINDOW'
bl_context = "scene"
bl_parent_id = "BIM_PT_4d_tools"
@classmethod
def poll(cls, context):
return tool.Sequence.get_active_work_schedule()
def draw(self, context):
layout = self.layout
row = layout.row()
row.operator("bim.setup_default_task_columns_with_persistence")
layout.separator()
row = layout.row()
row.label(text="Sorting:")
col = layout.column(align=True)
ws_props = tool.Sequence.get_work_schedule_props()
if ws_props and hasattr(ws_props, 'sort_column') and ws_props.sort_column:
col.label(text=f"Column: {ws_props.sort_column}")
is_reversed = getattr(ws_props, 'is_sort_reversed', False) if ws_props else False
icon = "SORT_DESC" if is_reversed else "SORT_ASC"
direction = "Desc" if is_reversed else "Asc"
col.operator("bim.toggle_sort_reversed_with_persistence", text=f"Direction: {direction}", icon=icon)
layout.separator()
row = layout.row()
row.label(text="Snapshot:")
col = layout.column(align=True)
col.operator("bim.task_column_snapshot")
col.operator("bim.task_column_restore")
col.operator("bim.task_column_snapshot_stats")
# Register operators
classes = [
AddTaskColumn,
SetupDefaultTaskColumns,
SetTaskSortColumn,
ToggleSortReversed,
TaskColumnSnapshotOperator,
TaskColumnRestoreOperator,
TaskColumnSnapshotStatsOperator,
TaskColumnPanel,
]
def register():
for cls in classes:
bpy.utils.register_class(cls)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
@@ -0,0 +1,93 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
# Description: Operators for managing task sequence relationships and their properties.
import bpy
import bonsai.tool as tool
import bonsai.core.sequence as core
# ============================================================================
# SEQUENCE EDITING OPERATORS
# ============================================================================
class EnableEditingTaskSequence(bpy.types.Operator):
bl_idname = "bim.enable_editing_task_sequence"
bl_label = "Enable Editing Task Sequence"
task: bpy.props.IntProperty()
def execute(self, context):
core.enable_editing_task_sequence(tool.Sequence)
return {"FINISHED"}
class EnableEditingSequenceAttributes(bpy.types.Operator):
bl_idname = "bim.enable_editing_sequence_attributes"
bl_label = "Enable Editing Sequence Attributes"
bl_options = {"REGISTER", "UNDO"}
sequence: bpy.props.IntProperty()
def execute(self, context):
core.enable_editing_sequence_attributes(tool.Sequence, rel_sequence=tool.Ifc.get().by_id(self.sequence))
return {"FINISHED"}
class EditSequenceAttributes(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.edit_sequence_attributes"
bl_label = "Edit Sequence"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
props = tool.Sequence.get_work_schedule_props()
core.edit_sequence_attributes(
tool.Ifc,
tool.Sequence,
rel_sequence=tool.Ifc.get().by_id(props.active_sequence_id),
)
class DisableEditingSequence(bpy.types.Operator):
bl_idname = "bim.disable_editing_sequence"
bl_label = "Disable Editing Sequence"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
core.disable_editing_rel_sequence(tool.Sequence)
return {"FINISHED"}
class EnableEditingSequenceTimeLag(bpy.types.Operator):
bl_idname = "bim.enable_editing_sequence_lag_time"
bl_label = "Enable Editing Sequence Time Lag"
bl_options = {"REGISTER", "UNDO"}
sequence: bpy.props.IntProperty()
lag_time: bpy.props.IntProperty()
def execute(self, context):
core.enable_editing_sequence_lag_time(
tool.Sequence,
rel_sequence=tool.Ifc.get().by_id(self.sequence),
lag_time=tool.Ifc.get().by_id(self.lag_time),
)
return {"FINISHED"}
class EditSequenceTimeLag(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.edit_sequence_lag_time"
bl_label = "Edit Time Lag"
bl_options = {"REGISTER", "UNDO"}
lag_time: bpy.props.IntProperty()
def _execute(self, context):
core.edit_sequence_lag_time(tool.Ifc, tool.Sequence, lag_time=tool.Ifc.get().by_id(self.lag_time))
@@ -0,0 +1,779 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import bpy
import json
import calendar
from mathutils import Matrix
from datetime import datetime
from dateutil import relativedelta
import bonsai.tool as tool
import bonsai.core.sequence as core
from .operator import snapshot_all_ui_state
try:
from ..prop import safe_set_selected_colortype_in_active_group
except (ImportError, ValueError):
# Fallback if the structure changes
from bonsai.bim.module.sequence.prop import safe_set_selected_colortype_in_active_group
try:
from .prop import update_filter_column
from . import prop
from .ui import calculate_visible_columns_count
except Exception:
try:
from ..prop.filter import update_filter_column
from .. import prop
from ..ui.schedule_ui import calculate_visible_columns_count
except Exception:
def update_filter_column(*args, **kwargs):
pass
def calculate_visible_columns_count(context):
return 3 # Safe fallback
# Fallback for safe assignment function
class PropFallback:
@staticmethod
def safe_set_selected_colortype_in_active_group(task_obj, value, skip_validation=False):
try:
setattr(task_obj, "selected_colortype_in_active_group", value)
except Exception as e:
print(f"[ERROR] Fallback safe_set failed: {e}")
prop = PropFallback()
# LEGACY FUNCTIONS REMOVED - USE operator.py INSTEAD
# # def snapshot_all_ui_state(context): # ELIMINATED - USE operator.py
# """
# (SNAPSHOT) Captures the complete state of the profiles UI and saves it
# in temporary scene properties. It also maintains a persistent cache
# to support filter toggling (filter -> unfilter)
# without losing data from hidden tasks.
# """
# import json
# try:
# # 1. Snapshot of the profile configuration per task
# tprops = tool.Sequence.get_task_tree_props()
# task_snap = {}
#
# # Also capture data from all tasks of the active schedule
# # to avoid data loss when filters are applied/removed
# try:
# ws = tool.Sequence.get_active_work_schedule()
# if ws:
# import ifcopenshell.util.sequence
#
# def get_all_tasks_recursive(tasks):
# """Recursively gets all tasks and subtasks."""
# all_tasks = []
# for task in tasks:
# all_tasks.append(task)
# nested = ifcopenshell.util.sequence.get_nested_tasks(task)
# if nested:
# all_tasks.extend(get_all_tasks_recursive(nested))
# return all_tasks
#
# root_tasks = ifcopenshell.util.sequence.get_root_tasks(ws)
# all_tasks = get_all_tasks_recursive(root_tasks)
#
# # Create a snapshot of all tasks, not just the visible ones
# task_id_to_ui_data = {str(getattr(t, "ifc_definition_id", 0)): t for t in getattr(tprops, "tasks", [])}
#
# for task in all_tasks:
# tid = str(task.id())
# if tid == "0":
# continue
#
# # If the task is visible in the UI, use its current data
# if tid in task_id_to_ui_data:
# t = task_id_to_ui_data[tid]
# groups_list = []
# for g in getattr(t, "colortype_group_choices", []):
# sel_attr = None
# for cand in ("selected_colortype", "selected", "active_colortype", "colortype"):
# if hasattr(g, cand):
# sel_attr = cand
# break
# groups_list.append({
# "group_name": getattr(g, "group_name", ""),
# "enabled": bool(getattr(g, "enabled", False)),
# "selected_value": getattr(g, sel_attr, "") if sel_attr else "",
# "selected_attr": sel_attr or "",
# })
# task_snap[tid] = {
# "active": bool(getattr(t, "use_active_colortype_group", False)),
# "selected_active_colortype": getattr(t, "selected_colortype_in_active_group", ""),
# "animation_color_schemes": getattr(t, "animation_color_schemes", ""),
# "groups": groups_list,
# }
# else:
# # If the task is not visible (filtered), preserve data from the cache
# cache_key = "_task_colortype_snapshot_cache_json"
# cache_raw = context.scene.get(cache_key)
# if cache_raw:
# try:
# cached_data = json.loads(cache_raw)
# if tid in cached_data:
# task_snap[tid] = cached_data[tid]
# else:
# # Create an empty entry for tasks without previous data
# task_snap[tid] = {
# "active": False,
# "selected_active_colortype": "",
# "animation_color_schemes": "",
# "groups": [],
# }
# except Exception:
# task_snap[tid] = {
# "active": False,
# "selected_active_colortype": "",
# "animation_color_schemes": "",
# "groups": [],
# }
# else:
# task_snap[tid] = {
# "active": False,
# "selected_active_colortype": "",
# "animation_color_schemes": "",
# "groups": [],
# }
# except Exception as e:
# print(f"Bonsai WARNING: Error capturando todas las tasks: {e}")
# # Fallback to the original method with only visible tasks
# for t in getattr(tprops, "tasks", []):
# tid = str(getattr(t, "ifc_definition_id", 0))
# if tid == "0":
# continue
# groups_list = []
# for g in getattr(t, "colortype_group_choices", []):
# sel_attr = None
# for cand in ("selected_colortype", "selected", "active_colortype", "colortype"):
# if hasattr(g, cand):
# sel_attr = cand
# break
# groups_list.append({
# "group_name": getattr(g, "group_name", ""),
# "enabled": bool(getattr(g, "enabled", False)),
# "selected_value": getattr(g, sel_attr, "") if sel_attr else "",
# "selected_attr": sel_attr or "",
# })
# task_snap[tid] = {
# "active": bool(getattr(t, "use_active_colortype_group", False)),
# "selected_active_colortype": getattr(t, "selected_colortype_in_active_group", ""),
# "animation_color_schemes": getattr(t, "animation_color_schemes", ""),
# "groups": groups_list,
# }
#
# # Detect the active WorkSchedule to scope the cache
# try:
# ws_props = tool.Sequence.get_work_schedule_props()
# ws_id = int(getattr(ws_props, "active_work_schedule_id", 0))
# except Exception:
# try:
# ws = tool.Sequence.get_active_work_schedule()
# ws_id = int(getattr(ws, "id", 0) or getattr(ws, "GlobalId", 0) or 0)
# except Exception:
# ws_id = 0
#
# # Reset cache if the active WS has changed
# cache_ws_key = "_task_colortype_snapshot_cache_ws_id"
# cache_key = "_task_colortype_snapshot_cache_json"
# prior_ws = context.scene.get(cache_ws_key)
# if prior_ws is None or int(prior_ws) != ws_id:
# context.scene[cache_key] = "{}"
# context.scene[cache_ws_key] = str(ws_id)
#
# # Save ephemeral snapshot (current cycle) - BOTH KEYS for compatibility
# snap_key_specific = f"_task_colortype_snapshot_json_WS_{ws_id}"
# snap_key_generic = "_task_colortype_snapshot_json"
#
# # Save to specific key (for Copy 3D text)
# context.scene[snap_key_specific] = json.dumps(task_snap)
#
# # ALSO save to generic key (for normal system)
# context.scene[snap_key_generic] = json.dumps(task_snap)
#
# # Update persistent cache (merge)
# merged = {}
# cache_raw = context.scene.get(cache_key)
# if cache_raw:
# try:
# merged = json.loads(cache_raw) or {}
# except Exception:
# merged = {}
# merged.update(task_snap)
# context.scene[cache_key] = json.dumps(merged)
#
# # 2. Snapshot of the group selectors and the animation stack
# anim_props = tool.Sequence.get_animation_props()
# anim_snap = {
# "ColorType_groups": getattr(anim_props, "ColorType_groups", "DEFAULT"),
# "task_colortype_group_selector": getattr(anim_props, "task_colortype_group_selector", ""),
# "animation_group_stack": [
# {"group": getattr(item, "group", ""), "enabled": bool(getattr(item, "enabled", False))}
# for item in getattr(anim_props, "animation_group_stack", [])
# ],
# }
# context.scene["_anim_state_snapshot_json"] = json.dumps(anim_snap)
# # 3. Snapshot of active selection/index of the task tree
# try:
# wprops = tool.Sequence.get_work_schedule_props()
# tprops = tool.Sequence.get_task_tree_props()
# active_idx = int(getattr(wprops, 'active_task_index', -1))
# active_id = int(getattr(wprops, 'active_task_id', 0))
# selected_ids = []
# for t in getattr(tprops, 'tasks', []):
# tid = int(getattr(t, 'ifc_definition_id', 0))
# sel = False
# for cand in ('is_selected','selected'):
# if hasattr(t, cand) and bool(getattr(t, cand)):
# sel = True
# break
# if sel:
# selected_ids.append(tid)
# sel_snap = {'active_index': active_idx, 'active_id': active_id, 'selected_ids': selected_ids}
# context.scene['_task_selection_snapshot_json'] = json.dumps(sel_snap)
# except Exception:
# pass
#
# except Exception as e:
# print(f"Bonsai WARNING: No se pudo crear el snapshot de la UI: {e}")
#
#
def restore_all_ui_state(context):
"""
(RESTORATION) Restores the complete state of the profiles UI from
the temporary scene properties. It uses a persistent cache to
cover tasks that were not visible in the ephemeral snapshot (e.g., when
disabling filters).
"""
import json
try:
# Detect active schedule to use specific keys
try:
ws_props = tool.Sequence.get_work_schedule_props()
ws_id = int(getattr(ws_props, "active_work_schedule_id", 0))
except Exception:
try:
ws = tool.Sequence.get_active_work_schedule()
ws_id = int(getattr(ws, "id", 0) or getattr(ws, "GlobalId", 0) or 0)
except Exception:
ws_id = 0
# 1. Restore profile configuration in tasks - SCHEDULE-SPECIFIC
snap_key_specific = f"_task_colortype_snapshot_json_WS_{ws_id}"
cache_key = "_task_colortype_snapshot_cache_json"
# Union: cache snapshot (snapshot has priority)
union = {}
cache_raw = context.scene.get(cache_key)
if cache_raw:
try:
union.update(json.loads(cache_raw) or {})
except Exception:
pass
snap_raw = context.scene.get(snap_key_specific)
if snap_raw:
try:
snap_data = json.loads(snap_raw) or {}
union.update(snap_data)
except Exception:
pass
else:
print(f"[ERROR] DEBUG RESTORE: Key not found {snap_key_specific}")
if union:
print(f"[DEBUG] RESTORE: Found {len(union)} tasks in union data")
tprops = tool.Sequence.get_task_tree_props()
task_map = {str(getattr(t, "ifc_definition_id", 0)): t for t in getattr(tprops, "tasks", [])}
print(f"[DEBUG] RESTORE: Current UI has {len(task_map)} tasks")
for tid, cfg in union.items():
t = task_map.get(str(tid))
if not t:
print(f"[DEBUG] RESTORE: Task {tid} not found in UI, skipping")
continue
print(f"[DEBUG] RESTORE: Restoring task {tid}")
# Main state of the task
try:
t.use_active_colortype_group = cfg.get("active", False)
# Validation for selected_colortype_in_active_group
selected_active_colortype = cfg.get("selected_active_colortype", "")
problematic_values = ["0", 0, None, "", "None", "null", "undefined"]
if selected_active_colortype in problematic_values:
selected_active_colortype = ""
else:
selected_active_str = str(selected_active_colortype).strip()
if selected_active_str in [str(v) for v in problematic_values]:
selected_active_colortype = ""
print(f"[DEBUG] RESTORE: Task {tid} - trying to restore selected_colortype_in_active_group = '{selected_active_colortype}'")
print(f"[DEBUG] RESTORE: Task {tid} - current value BEFORE: '{getattr(t, 'selected_colortype_in_active_group', 'ATTR_NOT_FOUND')}'")
from .. import prop
prop.safe_set_selected_colortype_in_active_group(t, selected_active_colortype, skip_validation=True)
print(f"[DEBUG] RESTORE: Task {tid} - current value AFTER: '{getattr(t, 'selected_colortype_in_active_group', 'ATTR_NOT_FOUND')}'")
# Check if the assignment actually worked
current_value = getattr(t, 'selected_colortype_in_active_group', '')
if current_value != selected_active_colortype:
print(f"[DEBUG] RESTORE: Task {tid} - RESTORATION FAILED! Expected: '{selected_active_colortype}', Got: '{current_value}'")
# RESTORE animation_color_schemes field
animation_color_schemes = cfg.get("animation_color_schemes", "")
task_is_active = cfg.get("active", False)
if not task_is_active and animation_color_schemes:
from ..prop.animation import safe_set_animation_color_schemes
safe_set_animation_color_schemes(t, animation_color_schemes)
elif not task_is_active:
try:
from ..prop.animation import get_animation_color_schemes_items, safe_set_animation_color_schemes
valid_items = get_animation_color_schemes_items(t, bpy.context)
first_valid = valid_items[0][0] if valid_items else ""
safe_set_animation_color_schemes(t, first_valid)
except Exception:
pass
else:
if selected_active_colortype:
from ..prop.animation import safe_set_animation_color_schemes
safe_set_animation_color_schemes(t, selected_active_colortype)
except Exception as e:
print(f"[WARNING] Error restoring task properties: {e}")
# Task groups
try:
t.colortype_group_choices.clear()
for g_data in cfg.get("groups", []):
item = t.colortype_group_choices.add()
item.group_name = g_data.get("group_name", "")
# Detect selection attribute
sel_attr = None
for cand in ("selected_colortype", "selected", "active_colortype", "colortype"):
if hasattr(item, cand):
sel_attr = cand
break
if hasattr(item, "enabled"):
item.enabled = bool(g_data.get("enabled", False))
# Set selection value
val = g_data.get("selected_value", "")
truly_problematic_values = ["0", 0, None, "None", "null", "undefined"]
if val in truly_problematic_values:
val = ""
else:
val = str(val).strip() if val else ""
if sel_attr and val is not None:
try:
setattr(item, sel_attr, val)
except Exception as e:
print(f"[ERROR] Error setting {sel_attr}: {e}")
except Exception as e:
print(f"[ERROR] Error restoring groups: {e}")
else:
print("[ERROR] RESTORE: No union data found - colortype data will be lost!")
# 2. Restore animation group selectors
anim_raw = context.scene.get("_anim_state_snapshot_json")
if anim_raw:
try:
anim_data = json.loads(anim_raw) or {}
anim_props = tool.Sequence.get_animation_props()
colortype_groups = anim_data.get("ColorType_groups", "DEFAULT")
if hasattr(anim_props, "ColorType_groups"):
anim_props.ColorType_groups = colortype_groups
task_group_selector = anim_data.get("task_colortype_group_selector", "")
if hasattr(anim_props, "task_colortype_group_selector"):
anim_props.task_colortype_group_selector = task_group_selector
stack_data = anim_data.get("animation_group_stack", [])
if hasattr(anim_props, "animation_group_stack"):
anim_props.animation_group_stack.clear()
for item_data in stack_data:
item = anim_props.animation_group_stack.add()
item.group = item_data.get("group", "")
item.enabled = bool(item_data.get("enabled", False))
except Exception as e:
print(f"[WARNING] Error restoring animation selectors: {e}")
# 3. Restore task selection/index
try:
sel_raw = context.scene.get('_task_selection_snapshot_json')
if sel_raw:
sel_data = json.loads(sel_raw) or {}
wprops = tool.Sequence.get_work_schedule_props()
tprops = tool.Sequence.get_task_tree_props()
active_idx = sel_data.get('active_index', -1)
active_id = sel_data.get('active_id', 0)
if hasattr(wprops, 'active_task_index'):
wprops.active_task_index = max(active_idx, -1)
if hasattr(wprops, 'active_task_id'):
wprops.active_task_id = max(active_id, 0)
selected_ids = set(sel_data.get('selected_ids', []))
task_map = {int(getattr(t, 'ifc_definition_id', 0)): t for t in getattr(tprops, 'tasks', [])}
for tid, t in task_map.items():
is_selected = tid in selected_ids
for cand in ('is_selected', 'selected'):
if hasattr(t, cand):
try:
setattr(t, cand, is_selected)
break
except Exception:
pass
except Exception as e:
print(f"[WARNING] Error restoring task selection: {e}")
except Exception as e:
print(f"[WARNING] No se pudo restaurar el estado de la UI: {e}")
# ============================================================================
# CORE TASK MANAGEMENT OPERATORS
# ============================================================================
class LoadTaskProperties(bpy.types.Operator):
bl_idname = "bim.load_task_properties"
bl_label = "Load Task Properties"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
core.load_task_properties(tool.Sequence)
return {"FINISHED"}
class AddTask(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.add_task"
bl_label = "Add Task"
bl_options = {"REGISTER", "UNDO"}
task: bpy.props.IntProperty()
def _execute(self, context):
snapshot_all_ui_state(context)
core.add_task(tool.Ifc, tool.Sequence, parent_task=tool.Ifc.get().by_id(self.task))
try:
ws = tool.Sequence.get_active_work_schedule()
if ws:
tool.Sequence.load_task_tree(ws)
tool.Sequence.load_task_properties()
except Exception:
pass
restore_all_ui_state(context)
class AddSummaryTask(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.add_summary_task"
bl_label = "Add Task"
bl_options = {"REGISTER", "UNDO"}
work_schedule: bpy.props.IntProperty()
def _execute(self, context):
snapshot_all_ui_state(context)
core.add_summary_task(tool.Ifc, tool.Sequence, work_schedule=tool.Ifc.get().by_id(self.work_schedule))
try:
ws = tool.Sequence.get_active_work_schedule()
if ws:
tool.Sequence.load_task_tree(ws)
tool.Sequence.load_task_properties()
except Exception:
pass
restore_all_ui_state(context)
class ExpandTask(bpy.types.Operator):
bl_idname = "bim.expand_task"
bl_label = "Expand Task"
bl_options = {"REGISTER", "UNDO"}
task: bpy.props.IntProperty()
def execute(self, context):
snapshot_all_ui_state(context)
core.expand_task(tool.Sequence, task=tool.Ifc.get().by_id(self.task))
return {'FINISHED'}
class ContractTask(bpy.types.Operator):
bl_idname = "bim.contract_task"
bl_label = "Contract Task"
bl_options = {"REGISTER", "UNDO"}
task: bpy.props.IntProperty()
def execute(self, context):
snapshot_all_ui_state(context)
core.contract_task(tool.Sequence, task=tool.Ifc.get().by_id(self.task))
return {'FINISHED'}
class RemoveTask(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.remove_task"
bl_label = "Remove Task"
bl_options = {"REGISTER", "UNDO"}
task: bpy.props.IntProperty()
def _execute(self, context):
snapshot_all_ui_state(context)
core.remove_task(tool.Ifc, tool.Sequence, task=tool.Ifc.get().by_id(self.task))
try:
ws = tool.Sequence.get_active_work_schedule()
if ws:
tool.Sequence.load_task_tree(ws)
tool.Sequence.load_task_properties()
except Exception:
pass
restore_all_ui_state(context)
class EnableEditingTask(bpy.types.Operator):
bl_idname = "bim.enable_editing_task_attributes"
bl_label = "Enable Editing Task Attributes"
bl_options = {"REGISTER", "UNDO"}
task: bpy.props.IntProperty()
def execute(self, context):
core.enable_editing_task_attributes(tool.Sequence, task=tool.Ifc.get().by_id(self.task))
return {"FINISHED"}
class DisableEditingTask(bpy.types.Operator):
bl_idname = "bim.disable_editing_task"
bl_label = "Disable Editing Task"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
# USE THE SAME PATTERN AS THE FILTERS (which works correctly):
snapshot_all_ui_state(context) # >>> 1. Save state BEFORE canceling
# >>> 2. Execute the cancel operation
core.disable_editing_task(tool.Sequence)
return {"FINISHED"}
class EditTask(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.edit_task"
bl_label = "Edit Task"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
props = tool.Sequence.get_work_schedule_props()
core.edit_task(tool.Ifc, tool.Sequence, task=tool.Ifc.get().by_id(props.active_task_id))
class CopyTaskAttribute(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.copy_task_attribute"
bl_label = "Copy Task Attribute"
bl_options = {"REGISTER", "UNDO"}
name: bpy.props.StringProperty()
def _execute(self, context):
core.copy_task_attribute(tool.Ifc, tool.Sequence, attribute_name=self.name)
class CalculateTaskDuration(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.calculate_task_duration"
bl_label = "Calculate Task Duration"
bl_options = {"REGISTER", "UNDO"}
task: bpy.props.IntProperty()
def _execute(self, context):
snapshot_all_ui_state(context)
core.calculate_task_duration(tool.Ifc, tool.Sequence, task=tool.Ifc.get().by_id(self.task))
restore_all_ui_state(context)
class ExpandAllTasks(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.expand_all_tasks"
bl_label = "Expands All Tasks"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Finds the related Task"
product_type: bpy.props.StringProperty()
def _execute(self, context):
snapshot_all_ui_state(context)
core.expand_all_tasks(tool.Sequence)
restore_all_ui_state(context)
class ContractAllTasks(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.contract_all_tasks"
bl_label = "Expands All Tasks"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Finds the related Task"
product_type: bpy.props.StringProperty()
def _execute(self, context):
snapshot_all_ui_state(context)
core.contract_all_tasks(tool.Sequence)
restore_all_ui_state(context)
class CopyTask(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.duplicate_task"
bl_label = "Copy Task"
bl_options = {"REGISTER", "UNDO"}
task: bpy.props.IntProperty()
def _execute(self, context):
snapshot_all_ui_state(context)
core.duplicate_task(tool.Ifc, tool.Sequence, task=tool.Ifc.get().by_id(self.task))
try:
ws = tool.Sequence.get_active_work_schedule()
if ws:
tool.Sequence.load_task_tree(ws)
tool.Sequence.load_task_properties()
except Exception:
pass
restore_all_ui_state(context)
class GoToTask(bpy.types.Operator):
bl_idname = "bim.go_to_task"
bl_label = "Highlight Task"
bl_options = {"REGISTER", "UNDO"}
task: bpy.props.IntProperty()
def execute(self, context):
r = core.go_to_task(tool.Sequence, task=tool.Ifc.get().by_id(self.task))
if isinstance(r, str):
self.report({"WARNING"}, r)
return {"FINISHED"}
class ReorderTask(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.reorder_task_nesting"
bl_label = "Reorder Nesting"
bl_options = {"REGISTER", "UNDO"}
new_index: bpy.props.IntProperty()
task: bpy.props.IntProperty()
def _execute(self, context):
snapshot_all_ui_state(context)
r = core.reorder_task_nesting(
tool.Ifc, tool.Sequence, task=tool.Ifc.get().by_id(self.task), new_index=self.new_index
)
if isinstance(r, str):
self.report({"WARNING"}, r)
try:
ws = tool.Sequence.get_active_work_schedule()
if ws:
tool.Sequence.load_task_tree(ws)
tool.Sequence.load_task_properties()
except Exception:
pass
restore_all_ui_state(context)
def _save_3d_texts_state():
# Save current state of all three-dimensional text objects before snapshot
try:
coll = bpy.data.collections.get("Schedule_Display_Texts")
if not coll:
return
state_data = {}
for obj in coll.objects:
if hasattr(obj, "data") and obj.data:
state_data[obj.name] = obj.data.body
# Store in scene for restoration
bpy.context.scene["3d_texts_previous_state"] = json.dumps(state_data)
print(f"💾 Saved state for {len(state_data)} three-dimensional text objects")
except Exception as e:
print(f"[ERROR] Error saving three-dimensional texts state: {e}")
def _restore_3d_texts_state():
"""Restore previous state of all three-dimensional text objects after snapshot reset."""
import json
try:
if "3d_texts_previous_state" not in bpy.context.scene:
print("[WARNING] No previous three-dimensional texts state found to restore")
return
coll = bpy.data.collections.get("Schedule_Display_Texts")
if not coll:
print("[WARNING] No 'Schedule_Display_Texts' collection found for restoration")
return
state_data = json.loads(bpy.context.scene["3d_texts_previous_state"])
restored_count = 0
for obj in coll.objects:
if hasattr(obj, "data") and obj.data and obj.name in state_data:
obj.data.body = state_data[obj.name]
restored_count += 1
# Clean up saved state
del bpy.context.scene["3d_texts_previous_state"]
print(f"🔄 Restored state for {restored_count} three-dimensional text objects")
except Exception as e:
print(f"[ERROR] Error restoring three-dimensional texts state: {e}")
class RefreshTaskOutputCounts(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.refresh_task_output_counts"
bl_label = "Refresh Task Output Counts"
bl_description = "Refresh the count of 3D elements assigned to tasks"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
try:
core.refresh_task_output_counts(tool.Sequence)
self.report({'INFO'}, "Task output counts refreshed successfully")
except Exception as e:
self.report({'ERROR'}, f"Failed to refresh task output counts: {str(e)}")
return {"FINISHED"}
@@ -0,0 +1,84 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
# saves/restores animation_color_schemes
import bpy
import bonsai.tool as tool
# Simple dictionary to store colortype values
_simple_colortype_backup = {}
def save_colortypes_simple():
"""Save ONLY animation_color_schemes - SIMPLE like v60"""
global _simple_colortype_backup
_simple_colortype_backup.clear()
try:
context = bpy.context
tprops = getattr(context.scene, 'BIMTaskTreeProperties', None)
if not tprops:
return
# Save ONLY animation_color_schemes for each visible task
for task in tprops.tasks:
task_id = str(task.ifc_definition_id)
if task_id != "0":
colortype_value = getattr(task, 'animation_color_schemes', '')
if colortype_value: # Only save if not empty
_simple_colortype_backup[task_id] = colortype_value
print(f"[DEBUG] Simple: Saved {len(_simple_colortype_backup)} ColorTypes")
except Exception as e:
print(f"[ERROR] Simple save failed: {e}")
def restore_colortypes_simple():
"""Restore ONLY animation_color_schemes - SIMPLE like v60"""
global _simple_colortype_backup
try:
context = bpy.context
tprops = getattr(context.scene, 'BIMTaskTreeProperties', None)
if not tprops or not _simple_colortype_backup:
return
restored_count = 0
# Restore ONLY animation_color_schemes for each visible task
for task in tprops.tasks:
task_id = str(task.ifc_definition_id)
if task_id in _simple_colortype_backup:
try:
# Use safe setter to handle enum validation
from ..prop.animation import safe_set_animation_color_schemes
safe_set_animation_color_schemes(task, _simple_colortype_backup[task_id])
restored_count += 1
except ImportError:
# Fallback to direct assignment if import fails
try:
task.animation_color_schemes = _simple_colortype_backup[task_id]
restored_count += 1
except Exception as e:
print(f"[ERROR] Failed to restore task {task_id}: {e}")
except Exception as e:
print(f"[ERROR] Failed to restore task {task_id}: {e}")
print(f"[DEBUG] Simple: Restored {restored_count} ColorTypes")
except Exception as e:
print(f"[ERROR] Simple restore failed: {e}")
@@ -0,0 +1,368 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
"""
Task Column Persistence System - Maintains Task Column Values
============================================================
PROBLEM SOLVED:
When adding task columns with ADD TASK COLUMN functionality, the values
for colorType assignments and colorType groups used for filters get lost
during UI refreshes and column operations.
SOLUTION:
Implement the same snapshot/restore pattern used for colorType persistence
but adapted for task column values, ensuring they maintain their state
through column additions and filtering operations.
BENEFIT:
- Preserves task column values during ADD TASK COLUMN operations
- Maintains colorType assignments for filtering
- Preserves colorType group associations
- Works seamlessly with existing snapshot system
USAGE:
save_task_column_values()
# Perform ADD TASK COLUMN operations
restore_task_column_values()
"""
import bpy
import bonsai.tool as tool
from typing import Dict, Any, Optional
# Global storage for task column values
_task_column_backup = {}
def save_task_column_values():
"""Save task column values including colorType and group assignments"""
global _task_column_backup
_task_column_backup.clear()
try:
context = bpy.context
tprops = getattr(context.scene, 'BIMTaskTreeProperties', None)
if not tprops:
return
# Get current work schedule for context
ws_props = tool.Sequence.get_work_schedule_props()
ws_id = int(getattr(ws_props, "active_work_schedule_id", 0))
# Save task column values for each visible task
for task in tprops.tasks:
task_id = str(task.ifc_definition_id)
if task_id != "0":
task_data = {}
# Save colorType related values (similar to colorType persistence)
colortype_value = getattr(task, 'animation_color_schemes', '')
if colortype_value:
task_data['animation_color_schemes'] = colortype_value
# Save selected colorType in active group
selected_colortype = getattr(task, 'selected_colortype_in_active_group', '')
if selected_colortype:
task_data['selected_colortype_in_active_group'] = selected_colortype
# Save use_active_colortype_group checkbox
use_active = getattr(task, 'use_active_colortype_group', False)
task_data['use_active_colortype_group'] = use_active
# Save colortype_group_choices (the assignments summary)
if hasattr(task, 'colortype_group_choices'):
group_choices = []
for group_choice in task.colortype_group_choices:
choice_data = {
'group_name': getattr(group_choice, 'group_name', ''),
'enabled': getattr(group_choice, 'enabled', False),
}
# Try to get selected_colortype (may have different attribute names)
for attr in ['selected_colortype', 'selected', 'active_colortype', 'colortype']:
if hasattr(group_choice, attr):
choice_data['selected_colortype'] = getattr(group_choice, attr, '')
break
group_choices.append(choice_data)
if group_choices:
task_data['colortype_group_choices'] = group_choices
# Save task column values from work schedule properties
if hasattr(ws_props, 'columns'):
column_values = {}
for column in ws_props.columns:
column_name = column.name
# Try to get the value from the task
if hasattr(task, column_name.replace('.', '_')):
value = getattr(task, column_name.replace('.', '_'), '')
if value:
column_values[column_name] = value
if column_values:
task_data['column_values'] = column_values
# Save filter-related values
predefined_type = getattr(task, 'PredefinedType', '')
if predefined_type:
task_data['PredefinedType'] = predefined_type
# Save any custom properties that might be used for filtering
if hasattr(task, 'custom_properties'):
custom_props = getattr(task, 'custom_properties', {})
if custom_props:
task_data['custom_properties'] = custom_props
# Only save if we have data
if task_data:
_task_column_backup[task_id] = task_data
print(f"[DEBUG] Task Column Snapshot: Saved data for {len(_task_column_backup)} tasks")
except Exception as e:
print(f"[ERROR] Task column save failed: {e}")
import traceback
traceback.print_exc()
def restore_task_column_values():
"""Restore task column values including colorType and group assignments"""
global _task_column_backup
try:
context = bpy.context
tprops = getattr(context.scene, 'BIMTaskTreeProperties', None)
if not tprops or not _task_column_backup:
return
restored_count = 0
# Restore task column values for each visible task
for task in tprops.tasks:
task_id = str(task.ifc_definition_id)
if task_id in _task_column_backup:
try:
task_data = _task_column_backup[task_id]
# Restore colorType related values using safe setters
if 'animation_color_schemes' in task_data:
value = task_data['animation_color_schemes']
try:
from ..prop.animation import safe_set_animation_color_schemes
safe_set_animation_color_schemes(task, value)
except:
try:
task.animation_color_schemes = value
except:
pass
if 'selected_colortype_in_active_group' in task_data:
value = task_data['selected_colortype_in_active_group']
try:
from ..prop.callbacks_prop import safe_set_selected_colortype_in_active_group
safe_set_selected_colortype_in_active_group(task, value, skip_validation=True)
except:
try:
setattr(task, 'selected_colortype_in_active_group', value)
except:
pass
# Restore use_active_colortype_group checkbox
if 'use_active_colortype_group' in task_data:
try:
task.use_active_colortype_group = task_data['use_active_colortype_group']
except:
pass
# Restore colortype_group_choices (the assignments summary)
if 'colortype_group_choices' in task_data:
try:
task.colortype_group_choices.clear()
for choice_data in task_data['colortype_group_choices']:
new_choice = task.colortype_group_choices.add()
new_choice.group_name = choice_data.get('group_name', '')
if hasattr(new_choice, 'enabled'):
new_choice.enabled = choice_data.get('enabled', False)
selected = choice_data.get('selected_colortype', '')
if selected:
for attr in ['selected_colortype', 'selected', 'active_colortype', 'colortype']:
if hasattr(new_choice, attr):
try:
setattr(new_choice, attr, selected)
break
except:
pass
except:
pass
# Restore task column values
if 'column_values' in task_data:
for column_name, value in task_data['column_values'].items():
attr_name = column_name.replace('.', '_')
if hasattr(task, attr_name):
setattr(task, attr_name, value)
# Restore PredefinedType
if 'PredefinedType' in task_data:
task.PredefinedType = task_data['PredefinedType']
# Restore custom properties
if 'custom_properties' in task_data:
if hasattr(task, 'custom_properties'):
task.custom_properties = task_data['custom_properties']
restored_count += 1
except Exception as e:
print(f"[ERROR] Failed to restore task {task_id}: {e}")
print(f"[DEBUG] Task Column Snapshot: Restored data for {restored_count} tasks")
except Exception as e:
print(f"[ERROR] Task column restore failed: {e}")
import traceback
traceback.print_exc()
def save_task_column_filters():
"""Save current filter state for task columns"""
global _task_column_backup
try:
ws_props = tool.Sequence.get_work_schedule_props()
if not hasattr(ws_props, 'filters') or not hasattr(ws_props.filters, 'rules'):
return
# Save filter rules state
filter_data = {
'active_rule_index': getattr(ws_props.filters, 'active_rule_index', 0),
'rules': []
}
for rule in ws_props.filters.rules:
rule_data = {
'column': getattr(rule, 'column', ''),
'operator': getattr(rule, 'operator', ''),
'value': getattr(rule, 'value', ''),
'enabled': getattr(rule, 'enabled', True)
}
filter_data['rules'].append(rule_data)
# Store in global backup with special key
_task_column_backup['_FILTER_STATE_'] = filter_data
print(f"[DEBUG] Task Column Snapshot: Saved {len(filter_data['rules'])} filter rules")
except Exception as e:
print(f"[ERROR] Filter state save failed: {e}")
def restore_task_column_filters():
"""Restore filter state for task columns"""
global _task_column_backup
try:
if '_FILTER_STATE_' not in _task_column_backup:
return
ws_props = tool.Sequence.get_work_schedule_props()
if not hasattr(ws_props, 'filters'):
return
filter_data = _task_column_backup['_FILTER_STATE_']
# Restore active rule index
if hasattr(ws_props.filters, 'active_rule_index'):
ws_props.filters.active_rule_index = filter_data.get('active_rule_index', 0)
# Restore filter rules (if the collection supports it)
if hasattr(ws_props.filters, 'rules') and 'rules' in filter_data:
# Clear existing rules if possible
try:
ws_props.filters.rules.clear()
except:
pass
# Add restored rules
for rule_data in filter_data['rules']:
try:
new_rule = ws_props.filters.rules.add()
new_rule.column = rule_data.get('column', '')
new_rule.operator = rule_data.get('operator', '')
new_rule.value = rule_data.get('value', '')
new_rule.enabled = rule_data.get('enabled', True)
except:
pass
print(f"[DEBUG] Task Column Snapshot: Restored filter state")
except Exception as e:
print(f"[ERROR] Filter state restore failed: {e}")
def clear_task_column_backup():
"""Clear the task column backup"""
global _task_column_backup
_task_column_backup.clear()
print("[DEBUG] Task Column Snapshot: Backup cleared")
def get_backup_stats() -> Dict[str, Any]:
"""Get statistics about the current backup"""
global _task_column_backup
task_count = len([k for k in _task_column_backup.keys() if k != '_FILTER_STATE_'])
has_filters = '_FILTER_STATE_' in _task_column_backup
return {
'task_count': task_count,
'has_filters': has_filters,
'total_size': len(_task_column_backup),
'is_empty': len(_task_column_backup) == 0
}
# Convenience functions for integration with ADD TASK COLUMN operations
def snapshot_before_add_column():
"""Complete snapshot before ADD TASK COLUMN operation"""
save_task_column_values()
save_task_column_filters()
def restore_after_add_column():
"""Complete restore after ADD TASK COLUMN operation"""
restore_task_column_values()
restore_task_column_filters()
# Integration with existing colorType system
def save_combined_state():
"""Save both colorType and task column state"""
try:
# Use existing colorType persistence
from .simple_colortype_persistence import save_colortypes_simple
save_colortypes_simple()
except ImportError:
print("[WARNING] Could not import colorType persistence")
# Save task column state
snapshot_before_add_column()
def restore_combined_state():
"""Restore both colorType and task column state"""
try:
# Use existing colorType persistence
from .simple_colortype_persistence import restore_colortypes_simple
restore_colortypes_simple()
except ImportError:
print("[WARNING] Could not import colorType persistence")
# Restore task column state
restore_after_add_column()
@@ -0,0 +1,87 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
# Description: Operators for adding, editing, and managing IfcWorkPlan entities.
import bpy
import bonsai.tool as tool
import bonsai.core.sequence as core
# ============================================================================
# WORK PLAN OPERATORS
# ============================================================================
class AddWorkPlan(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.add_work_plan"
bl_label = "Add Work Plan"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
core.add_work_plan(tool.Ifc)
class EditWorkPlan(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.edit_work_plan"
bl_options = {"REGISTER", "UNDO"}
bl_label = "Edit Work Plan"
def _execute(self, context):
props = tool.Sequence.get_work_plan_props()
core.edit_work_plan(
tool.Ifc,
tool.Sequence,
work_plan=tool.Ifc.get().by_id(props.active_work_plan_id),
)
class RemoveWorkPlan(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.remove_work_plan"
bl_label = "Remove Work Plan"
bl_options = {"REGISTER", "UNDO"}
work_plan: bpy.props.IntProperty()
def _execute(self, context):
core.remove_work_plan(tool.Ifc, work_plan=tool.Ifc.get().by_id(self.work_plan))
class EnableEditingWorkPlan(bpy.types.Operator):
bl_idname = "bim.enable_editing_work_plan"
bl_label = "Enable Editing Work Plan"
bl_options = {"REGISTER", "UNDO"}
work_plan: bpy.props.IntProperty()
def execute(self, context):
core.enable_editing_work_plan(tool.Sequence, work_plan=tool.Ifc.get().by_id(self.work_plan))
return {"FINISHED"}
class DisableEditingWorkPlan(bpy.types.Operator):
bl_idname = "bim.disable_editing_work_plan"
bl_options = {"REGISTER", "UNDO"}
bl_label = "Disable Editing Work Plan"
def execute(self, context):
core.disable_editing_work_plan(tool.Sequence)
return {"FINISHED"}
class EnableEditingWorkPlanSchedules(bpy.types.Operator):
bl_idname = "bim.enable_editing_work_plan_schedules"
bl_label = "Enable Editing Work Plan Schedules"
bl_options = {"REGISTER", "UNDO"}
work_plan: bpy.props.IntProperty()
def execute(self, context):
core.enable_editing_work_plan_schedules(tool.Sequence, work_plan=tool.Ifc.get().by_id(self.work_plan))
return {"FINISHED"}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,73 @@
"""Property Groups for 4D BIM scheduling - Modular Organization.
This package contains PropertyGroup classes organized thematically:
- animation.py: Animation, colors, and colortype management
- camera_hud.py: Camera, orbit, and HUD properties
- task.py: Core Task properties, resources, and products
- schedule.py: WorkSchedules and WorkPlans
- filter.py: Task filtering logic
- calendar.py: Calendar properties and date picker
- misc.py: Miscellaneous properties
All PropertyGroups maintain full compatibility with the original prop.py implementation.
"""
import bpy
from bpy.props import PointerProperty
# --- 1. Import everything from each module to make it accessible from 'prop' ---
# We use 'import *' to put all classes and functions directly
# into the 'prop' namespace, which resolves circular import issues.
from .color_manager_prop import *
from .callbacks_prop import *
from .enums_prop import *
from .task_prop import *
from .schedule_prop import *
from .animation_prop import *
from .camera_prop import *
from .ui_helpers_prop import *
# --- 2. Create a tuple with all the classes to be registered ---
classes = (
# From task_prop.py
TaskFilterRule, BIMTaskFilterProperties, SavedFilterSet,
TaskcolortypeGroupChoice, Task, TaskResource, TaskProduct,
BIMTaskTreeProperties,
# From schedule_prop.py
WorkPlan, BIMWorkPlanProperties, BIMWorkScheduleProperties,
WorkCalendar, RecurrenceComponent, BIMWorkCalendarProperties,
# From animation_prop.py
BIMTaskTypeColor, AnimationColorSchemes, AnimationColorTypeGroupItem,
BIMAnimationProperties,
# From camera_prop.py
BIMCameraOrbitProperties,
# From ui_helpers_prop.py
IFCStatus, BIMStatusProperties, DatePickerProperties, BIMDateTextProperties,
)
# --- 3. Registration and unregistration functions for the entire package ---
def register():
"""Registers all property classes in this package."""
for cls in classes:
bpy.utils.register_class(cls)
BIMWorkScheduleProperties.filters = PointerProperty(type=BIMTaskFilterProperties)
BIMAnimationProperties.camera_orbit = PointerProperty(type=BIMCameraOrbitProperties)
def unregister():
"""Unregisters all classes in reverse order."""
if hasattr(BIMWorkScheduleProperties, 'filters'):
del BIMWorkScheduleProperties.filters
if hasattr(BIMAnimationProperties, 'camera_orbit'):
del BIMAnimationProperties.camera_orbit
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,385 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import bpy
from bpy.types import PropertyGroup
from bpy.props import (
PointerProperty, StringProperty, EnumProperty,
BoolProperty, IntProperty, FloatProperty, FloatVectorProperty, CollectionProperty
)
from typing import TYPE_CHECKING
from . import callbacks_prop as callbacks
from . import enums_prop as enums
class BIMTaskTypeColor(PropertyGroup):
"""Color by task type (legacy - maintain for compatibility)"""
name: StringProperty(name="Name")
animation_type: StringProperty(name="Type")
color: FloatVectorProperty(
name="Color",
subtype="COLOR", size=4,
default=(1.0, 0.0, 0.0, 1.0),
min=0.0,
max=1.0,
)
if TYPE_CHECKING:
name: str
animation_type: str
color: tuple[float, float, float, float]
class AnimationColorSchemes(PropertyGroup):
"""Animation Color Scheme for 4D animation"""
name: StringProperty(name="Color Type Name", default="New Color Type")
# Considered States
consider_start: BoolProperty(
name="Start state",
default=False,
description="When enabled, elements use start appearance throughout the entire animation, "
"useful for existing elements, demolition context, or persistent visibility",
update=callbacks.update_colortype_considerations)
consider_active: BoolProperty(
name="Active state",
default=True,
description="Apply appearance during task execution period",
update=callbacks.update_colortype_considerations)
consider_end: BoolProperty(
name="End state",
default=True,
description="Apply appearance after task completion",
update=callbacks.update_colortype_considerations)
# Colors by State
start_color: FloatVectorProperty(
name="Start Color",
subtype="COLOR",
size=4, min=0.0, max=1.0,
default=(1.0, 1.0, 1.0, 1.0),
)
in_progress_color: FloatVectorProperty(
name="In Progress Color",
subtype="COLOR",
size=4, min=0.0, max=1.0,
default=(0.8, 0.8, 0.0, 1.0),
)
end_color: FloatVectorProperty(
name="End Color",
subtype="COLOR",
size=4, min=0.0, max=1.0,
default=(0.0, 1.0, 0.0, 1.0),
)
# Option to keep original color
use_start_original_color: BoolProperty(name="Start: Use Original Color", default=False)
use_active_original_color: BoolProperty(name="Active: Use Original Color", default=False)
use_end_original_color: BoolProperty(name="End: Use Original Color", default=True)
# Transparency Control
start_transparency: FloatProperty(name="Start Transparency", min=0.0, max=1.0, default=0.0)
active_start_transparency: FloatProperty(name="Active Start Transparency", min=0.0, max=1.0, default=0.0)
active_finish_transparency: FloatProperty(name="Active Finish Transparency", min=0.0, max=1.0, default=0.0)
active_transparency_interpol: FloatProperty(name="Transparency Interpol.", min=0.0, max=1.0, default=1.0)
end_transparency: FloatProperty(name="End Transparency", min=0.0, max=1.0, default=0.0)
hide_at_end: BoolProperty(name="Hide When Finished", description="If enabled, the object will become invisible in the End phase", default=False)
if TYPE_CHECKING:
name: str
start_color: tuple[float, float, float, float]
in_progress_color: tuple[float, float, float, float]
end_color: tuple[float, float, float, float]
use_start_original_color: bool
use_active_original_color: bool
use_end_original_color: bool
start_transparency: float
active_start_transparency: float
active_finish_transparency: float
active_transparency_interpol: float
end_transparency: float
hide_at_end: bool
class AnimationColorTypeGroupItem(PropertyGroup):
"""Item for animation group stack"""
group: EnumProperty(name="Group", items=enums.get_internal_ColorType_sets_enum)
enabled: BoolProperty(name="Use", default=True, update=callbacks.update_legend_hud_on_group_change)
class BIMAnimationProperties(PropertyGroup):
"""Animation properties with improved colortype system"""
# Unified colortype system
active_ColorType_system: EnumProperty(
name="ColorType System",
items=[
("ColorTypeS", "Animation Color Schemes", "Use advanced ColorType system"),
],
default="ColorTypeS"
)
# Animation group stack
animation_group_stack: CollectionProperty(name="Animation Group Stack", type=AnimationColorTypeGroupItem)
animation_group_stack_index: IntProperty(name="Animation Group Stack Index", default=-1)
# State and configuration
is_editing: BoolProperty(name="Is Loaded", default=False)
saved_colortype_name: StringProperty(name="colortype Set Name", default="Default")
# Animation Color Scheme
ColorTypes: CollectionProperty(name="Animation Color Scheme", type=AnimationColorSchemes)
active_ColorType_index: IntProperty(name="Active ColorType Index")
ColorType_groups: EnumProperty(name="ColorType Group", items=enums.get_internal_ColorType_sets_enum, update=callbacks.update_ColorType_group)
# Bandera para controlar si la animation ha sido creada al menos una vez.
is_animation_created: BoolProperty(
name="Is Animation Created",
description="Internal flag to check if the main animation has been created at least once",
default=False
)
# New property, only for the Tasks panel UI, which excludes 'DEFAULT'
task_colortype_group_selector: EnumProperty(
name="Custom colortype Group",
items=enums.get_user_created_groups_enum,
update=callbacks.update_task_colortype_group_selector
)
# UI toggles
show_saved_task_colortypes_panel: BoolProperty(name="Show Saved colortypes", default=False)
should_show_task_bar_options: BoolProperty(name="Show Task Bar Options", default=False)
# Task bar colors
color_full: FloatVectorProperty(
name="Full Bar",
subtype="COLOR", size=4,
default=(1.0, 0.0, 0.0, 1.0),
min=0.0, max=1.0,
description="Color for full task bar",
update=callbacks.update_color_full,
)
color_progress: FloatVectorProperty(
name="Progress Bar",
subtype="COLOR", size=4,
default=(0.0, 1.0, 0.0, 1.0),
min=0.0, max=1.0,
description="Color for progress task bar",
update=callbacks.update_color_progress,
)
# Legacy properties (maintain for compatibility)
saved_color_schemes: EnumProperty(items=enums.get_saved_color_schemes, name="Saved Colour Schemes")
active_color_component_outputs_index: IntProperty(name="Active Color Component Index")
active_color_component_inputs_index: IntProperty(name="Active Color Component Index")
if TYPE_CHECKING:
active_ColorType_system: str
animation_group_stack: bpy.types.bpy_prop_collection_idprop[AnimationColorTypeGroupItem]
animation_group_stack_index: int
is_editing: bool
saved_colortype_name: str
ColorTypes: bpy.types.bpy_prop_collection_idprop[AnimationColorSchemes]
active_ColorType_index: int
ColorType_groups: str
task_colortype_group_selector: str
show_saved_task_colortypes_panel: bool
should_show_task_bar_options: bool
color_full: Color
color_progress: Color
saved_color_schemes: str
active_color_component_outputs_index: int
active_color_component_inputs_index: int
# === Camera & Orbit Settings (safe-inject) ===================================
# We attach properties dynamically to BIMAnimationProperties so we don't depend
# on the exact class body location. This works as long as registration happens
# after these attributes exist.
try:
from bpy.props import FloatProperty, BoolProperty, EnumProperty, PointerProperty
import bpy
from bpy.types import Object as _BpyObject
_C = BIMAnimationProperties # type: ignore[name-defined]
def _add_prop(cls, name, pdef):
# Ensure annotation slot exists for Blender 2.8+ registration
try:
ann = getattr(cls, "__annotations__", None)
if ann is None:
cls.__annotations__ = {}
if name not in cls.__annotations__:
cls.__annotations__[name] = pdef
except Exception:
pass
# Attach descriptor if missing
if not hasattr(cls, name):
setattr(cls, name, pdef)
# --- Camera ---
_add_prop(_C, "camera_focal_mm", FloatProperty(name="Focal (mm)", default=35.0, min=1.0, max=300.0))
_add_prop(_C, "camera_clip_start", FloatProperty(name="Clip Start", default=0.1, min=0.0001))
_add_prop(_C, "camera_clip_end", FloatProperty(name="Clip End", default=10000.0, min=1.0))
# --- Orbit ---
_add_prop(_C, "orbit_mode", EnumProperty(
name="Orbit Mode",
items=[
("NONE", "None (Static)", "No orbit animation"),
("CIRCLE_360", "360", "Full circular orbit"),
("PINGPONG", "Ping-Pong", "Back and forth over an arc"),
],
default="CIRCLE_360"
))
_add_prop(_C, "orbit_radius_mode", EnumProperty(
name="Radius Mode",
items=[("AUTO", "Auto (from bbox)", "Compute radius from WorkSchedule bbox"),
("MANUAL", "Manual", "Use manual radius value")],
default="AUTO"
))
_add_prop(_C, "orbit_radius", FloatProperty(name="Radius (m)", default=10.0, min=0.01))
_add_prop(_C, "orbit_height", FloatProperty(name="Height (Z offset)", default=8.0))
_add_prop(_C, "orbit_start_angle_deg", FloatProperty(name="Start Angle (deg)", default=0.0))
_add_prop(_C, "orbit_direction", EnumProperty(
name="Direction",
items=[("CCW", "CCW", "Counter-clockwise"), ("CW", "CW", "Clockwise")],
default="CCW"
))
# --- Look At ---
_add_prop(_C, "look_at_mode", EnumProperty(
name="Look At",
items=[("AUTO", "Auto (active WorkSchedule area)", "Use bbox center of active WorkSchedule"),
("OBJECT", "Object", "Select object/Empty as target")],
default="AUTO"
))
_add_prop(_C, "look_at_object", PointerProperty(name="Target", type=_BpyObject))
# --- NEW: Path Shape & Custom Path ---
_add_prop(_C, "orbit_path_shape", EnumProperty(
name="Path Shape",
items=[
('CIRCLE', "Circle (Generated)", "The add-on creates a perfect circle"),
('CUSTOM', "Custom Path", "Use your own curve object as the path"),
],
default='CIRCLE',
))
_add_prop(_C, "custom_orbit_path", PointerProperty(
name="Custom Path",
type=_BpyObject,
poll=lambda self, object: getattr(object, "type", None) == 'CURVE'
))
# --- NEW: Interpolation ---
_add_prop(_C, "interpolation_mode", EnumProperty(
name="Interpolation",
items=[
('LINEAR', "Linear (Constant Speed)", "Constant, mechanical speed"),
('BEZIER', "Bezier (Smooth)", "Smooth ease-in and ease-out for a natural feel"),
],
default='LINEAR',
))
_add_prop(_C, "bezier_smoothness_factor", FloatProperty(
name="Smoothness Factor",
description="Controls the intensity of the ease-in/ease-out. Higher values create a more gradual transition",
default=0.35,
min=0.0,
max=2.0,
soft_min=0.0,
soft_max=1.0
))
# --- Animation method & duration ---
_add_prop(_C, "orbit_path_method", EnumProperty(
name="Path Method",
items=[("FOLLOW_PATH", "Follow Path (editable)", "Bezier circle + Follow Path"),
("KEYFRAMES", "Keyframes (lightweight)", "Animate location directly")],
default="FOLLOW_PATH"
))
_add_prop(_C, "orbit_use_4d_duration", BoolProperty(
name="Use 4D total frames", default=True,
description="If enabled, orbit spans the whole 4D animation range"))
_add_prop(_C, "orbit_duration_frames", FloatProperty(
name="Orbit Duration (frames)", default=250.0, min=1.0))
# --- UI toggles ---
_add_prop(_C, "show_camera_orbit_settings", BoolProperty(
name="Camera & Orbit", default=False, description="Toggle Camera & Orbit settings visibility"))
_add_prop(_C, "hide_orbit_path", BoolProperty(
name="Hide Orbit Path", default=False,
description="Hide the visible orbit path (Bezier Circle) in the viewport and render"))
# --- HUD (Heads-Up Display) properties mirrored on BIMAnimationProperties ---
_add_prop(_C, "enable_text_hud", BoolProperty(
name="Enable Text HUD",
description="Attach schedule texts as HUD elements to the active camera",
default=False, update=callbacks.update_gpu_hud_visibility))
_add_prop(_C, "hud_margin_horizontal", FloatProperty(
name="Horizontal Margin",
description="Distance from camera edge (percentage of camera width)",
default=0.05, min=0.0, max=0.3, precision=3,
update=callbacks.update_hud_gpu))
_add_prop(_C, "hud_margin_vertical", FloatProperty(
name="Vertical Margin",
description="Distance from camera edge (percentage of camera height)",
default=0.05, min=0.0, max=0.3, precision=3,
update=callbacks.update_hud_gpu))
_add_prop(_C, "hud_text_spacing", FloatProperty(
name="Text Spacing",
description="Vertical spacing between HUD text elements",
default=0.02, min=0.0, max=0.2, precision=3,
update=callbacks.update_hud_gpu))
_add_prop(_C, "hud_scale_factor", FloatProperty(
name="HUD Scale Factor",
description="Scale multiplier for HUD elements relative to camera distance",
default=1.0, min=0.1, max=5.0, precision=2,
update=callbacks.update_hud_gpu))
_add_prop(_C, "hud_distance", FloatProperty(
name="Distance",
description="Distance from camera to place HUD elements",
default=3.0, min=0.5, max=50.0, precision=1,
update=callbacks.update_hud_gpu))
_add_prop(_C, "hud_position", EnumProperty(
name="HUD Position",
description="Position of HUD elements on screen",
items=[
('TOP_LEFT', "Top Left", "Position HUD at top-left corner"),
('TOP_RIGHT', "Top Right", "Position HUD at top-right corner"),
('BOTTOM_LEFT', "Bottom Left", "Position HUD at bottom-left corner"),
('BOTTOM_RIGHT', "Bottom Right", "Position HUD at bottom-right corner"),
],
default='TOP_RIGHT',
update=callbacks.update_hud_gpu))
except Exception as _e:
# Failsafe: leave file importable if Bonsai internals are not present here
pass
@@ -0,0 +1,150 @@
"""Calendar-related PropertyGroups for 4D BIM scheduling.
This module contains all calendar and date-related PropertyGroup classes extracted from prop.py:
- WorkCalendar: Work calendar properties
- RecurrenceComponent: Date recurrence component properties
- BIMWorkCalendarProperties: Main work calendar management
- DatePickerProperties: Date picker UI properties
- BIMDateTextProperties: Date text animation properties
Each PropertyGroup maintains full compatibility with the original implementation
while being organized thematically for better maintainability.
"""
import bpy
from bpy.props import (
StringProperty,
IntProperty,
BoolProperty,
EnumProperty,
CollectionProperty,
PointerProperty
)
from bpy.types import PropertyGroup
try:
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import bpy.types
from ..prop_types import Attribute
except ImportError:
TYPE_CHECKING = False
# Import callback functions and utilities
try:
import bonsai.tool as tool
from bonsai.bim.prop import Attribute
except ImportError:
# Fallback for development/testing
Attribute = None
def update_selected_date(self: "DatePickerProperties", context: bpy.types.Context) -> None:
"""Update the selected date with time components."""
include_time = True
selected_date = tool.Sequence.parse_isodate_datetime(self.selected_date, include_time)
selected_date = selected_date.replace(hour=self.selected_hour, minute=self.selected_min, second=self.selected_sec)
self.selected_date = tool.Sequence.isodate_datetime(selected_date, include_time)
class WorkCalendar(PropertyGroup):
"""Work calendar properties for scheduling."""
name: StringProperty(name="Name")
ifc_definition_id: IntProperty(name="IFC Definition ID")
if TYPE_CHECKING:
name: str
ifc_definition_id: int
class RecurrenceComponent(PropertyGroup):
"""Component for date recurrence patterns."""
name: StringProperty(name="Name")
is_specified: BoolProperty(name="Is Specified")
if TYPE_CHECKING:
name: str
is_specified: bool
class BIMWorkCalendarProperties(PropertyGroup):
"""Main work calendar properties with comprehensive calendar management."""
work_calendar_attributes: CollectionProperty(name="Work Calendar Attributes", type=Attribute)
work_time_attributes: CollectionProperty(name="Work Time Attributes", type=Attribute)
editing_type: StringProperty(name="Editing Type")
active_work_calendar_id: IntProperty(name="Active Work Calendar Id")
active_work_time_id: IntProperty(name="Active Work Time Id")
# Recurrence components
day_components: CollectionProperty(name="Day Components", type=RecurrenceComponent)
weekday_components: CollectionProperty(name="Weekday Components", type=RecurrenceComponent)
month_components: CollectionProperty(name="Month Components", type=RecurrenceComponent)
# Recurrence settings
position: IntProperty(name="Position")
interval: IntProperty(name="Recurrence Interval")
occurrences: IntProperty(name="Occurs N Times")
recurrence_types: EnumProperty(
items=[
("DAILY", "Daily", "e.g. Every day"),
("WEEKLY", "Weekly", "e.g. Every Friday"),
("MONTHLY_BY_DAY_OF_MONTH", "Monthly on Specified Date", "e.g. Every 2nd of each Month"),
("MONTHLY_BY_POSITION", "Monthly on Specified Weekday", "e.g. Every 1st Friday of each Month"),
("YEARLY_BY_DAY_OF_MONTH", "Yearly on Specified Date", "e.g. Every 2nd of October"),
("YEARLY_BY_POSITION", "Yearly on Specified Weekday", "e.g. Every 1st Friday of October"),
],
name="Recurrence Types",
)
# Time settings
start_time: StringProperty(name="Start Time")
end_time: StringProperty(name="End Time")
if TYPE_CHECKING:
work_calendar_attributes: bpy.types.bpy_prop_collection_idprop[Attribute]
work_time_attributes: bpy.types.bpy_prop_collection_idprop[Attribute]
editing_type: str
active_work_calendar_id: int
active_work_time_id: int
day_components: bpy.types.bpy_prop_collection_idprop[RecurrenceComponent]
weekday_components: bpy.types.bpy_prop_collection_idprop[RecurrenceComponent]
month_components: bpy.types.bpy_prop_collection_idprop[RecurrenceComponent]
position: int
interval: int
occurrences: int
recurrence_types: str
start_time: str
end_time: str
class DatePickerProperties(PropertyGroup):
"""Properties for date picker UI components."""
display_date: StringProperty(
name="Display Date",
description="Needed to keep track of what month is currently opened in date picker without affecting the currently selected date.",
)
selected_date: StringProperty(name="Selected Date")
selected_hour: IntProperty(min=0, max=23, update=update_selected_date)
selected_min: IntProperty(min=0, max=59, update=update_selected_date)
selected_sec: IntProperty(min=0, max=59, update=update_selected_date)
if TYPE_CHECKING:
display_date: str
selected_date: str
selected_hour: int
selected_min: int
selected_sec: int
class BIMDateTextProperties(PropertyGroup):
"""Properties for date text animations in 4D visualizations."""
start_frame: IntProperty(name="Start Frame")
total_frames: IntProperty(name="Total Frames")
start: StringProperty(name="Start")
finish: StringProperty(name="Finish")
if TYPE_CHECKING:
start_frame: int
total_frames: int
start: str
finish: str
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,934 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import bpy
import bonsai.tool as tool
from bpy.types import PropertyGroup
from bpy.props import (
PointerProperty,
StringProperty,
EnumProperty,
BoolProperty,
IntProperty,
FloatProperty,
FloatVectorProperty,
CollectionProperty,
)
from typing import TYPE_CHECKING
from ..operators.camera_operators import _get_animation_cameras, _get_snapshot_cameras
# Import callback functions to avoid duplication
from . import callbacks_prop
# ============================================================================
# CAMERA AND HUD CALLBACK FUNCTIONS
# ============================================================================
# ============================================================================
# CAMERA AND HUD PROPERTY GROUP CLASSES
# ============================================================================
class BIMCameraOrbitProperties(PropertyGroup):
# =====================
# Camera settings
# =====================
camera_focal_mm: FloatProperty(
name="Focal (mm)",
default=35.0,
min=1.0,
max=300.0,
description="Camera focal length in millimeters",
)
camera_clip_start: FloatProperty(
name="Clip Start",
default=0.1,
min=0.0001,
description="Camera near clipping distance",
)
camera_clip_end: FloatProperty(
name="Clip End",
default=10000.0,
min=1.0,
description="Camera far clipping distance",
)
# =====================
# Orbit settings
# =====================
orbit_mode: EnumProperty(
name="Orbit Mode",
items=[
("NONE", "None (Static)", "The camera will not move or be animated."),
("CIRCLE_360", "360", "The camera performs a full 360-degree circular orbit."),
("PINGPONG", "Ping-Pong", "The camera moves back and forth along a 180-degree arc."),
],
default="CIRCLE_360",
)
orbit_radius_mode: EnumProperty(
name="Radius Mode",
items=[
("AUTO", "Auto (from bbox)", "Compute radius from WorkSchedule bbox"),
("MANUAL", "Manual", "Use manual radius value"),
],
default="AUTO",
)
orbit_radius: FloatProperty(
name="Radius (m)",
default=10.0,
min=0.01,
description="Manual orbit radius in meters",
)
orbit_height: FloatProperty(
name="Height (Z offset)",
default=8.0,
description="Height offset from target center",
)
orbit_start_angle_deg: FloatProperty(
name="Start Angle (deg)",
default=0.0,
description="Starting angle in degrees",
)
orbit_direction: EnumProperty(
name="Direction",
items=[("CCW", "CCW", "Counter-clockwise"), ("CW", "CW", "Clockwise")],
default="CCW",
)
# =====================
# Look At settings
# =====================
look_at_mode: EnumProperty(
name="Look At",
items=[
("AUTO", "Auto (active WorkSchedule area)", "Use bbox center of active WorkSchedule"),
("OBJECT", "Object", "Select object/Empty as target"),
],
default="AUTO",
)
look_at_object: PointerProperty(
name="Target",
type=bpy.types.Object,
description="Target object for camera to look at",
)
# =====================
# Path & Interpolation
# =====================
orbit_path_shape: EnumProperty(
name="Path Shape",
items=[
("CIRCLE", "Circle (Generated)", "The add-on creates a perfect circle"),
("CUSTOM", "Custom Path", "Use your own curve object as the path"),
],
default="CIRCLE",
description="Choose between a generated circle or a custom curve for the orbit path",
)
custom_orbit_path: PointerProperty(
name="Custom Path",
type=bpy.types.Object,
description="Select a Curve object for the camera to follow",
poll=lambda self, object: getattr(object, "type", None) == "CURVE",
)
interpolation_mode: EnumProperty(
name="Interpolation",
items=[
("LINEAR", "Linear (Constant Speed)", "Constant, mechanical speed"),
("BEZIER", "Bezier (Smooth)", "Smooth ease-in and ease-out for a natural feel"),
],
default="LINEAR",
description="Controls the smoothness and speed changes of the camera motion",
)
bezier_smoothness_factor: FloatProperty(
name="Smoothness Factor",
description="Controls the intensity of the ease-in/ease-out. Higher values create a more gradual transition",
default=0.35,
min=0.0,
max=2.0,
soft_min=0.0,
soft_max=1.0,
)
# =====================
# Animation settings
# =====================
orbit_path_method: EnumProperty(
name="Path Method",
items=[
("FOLLOW_PATH", "Follow Path (editable)", "Bezier circle + Follow Path"),
("KEYFRAMES", "Keyframes (lightweight)", "Animate location directly"),
],
default="FOLLOW_PATH",
)
orbit_use_4d_duration: BoolProperty(
name="Use 4D total frames",
default=True,
description="If enabled, orbit spans the whole 4D animation range",
)
orbit_duration_frames: FloatProperty(
name="Orbit Duration (frames)",
default=250.0,
min=1.0,
description="Custom orbit duration in frames",
)
# =====================
# UI toggles
# =====================
show_camera_orbit_settings: BoolProperty(
name="Camera & Orbit",
default=False,
description="Toggle Camera & Orbit settings visibility",
)
hide_orbit_path: BoolProperty(
name="Hide Orbit Path",
default=False,
description="Hide the visible orbit path (Bezier Circle) in the viewport and render",
)
# =====================
# 3D Texts
# =====================
show_3d_schedule_texts: BoolProperty(
name="Show 3D HUD Render",
description="Toggle visibility of the 3D objects used as a Heads-Up Display (HUD) for rendering",
default=False,
update=lambda self, context: callbacks_prop.toggle_3d_text_visibility(self, context),
)
# =====================
# HUD (GPU) - Base
# =====================
enable_text_hud: BoolProperty(
name="Enable Viewport HUD",
description="Enable GPU-based HUD overlay for real-time schedule information in the viewport",
default=False,
update=callbacks_prop.update_gpu_hud_visibility,
)
expand_hud_settings: BoolProperty(
name="Expand HUD Settings",
description="Show/hide detailed HUD configuration options",
default=False,
)
expand_schedule_hud: BoolProperty(
name="Expand Schedule HUD",
default=False,
description="Show/hide Schedule HUD settings"
)
expand_timeline_hud: BoolProperty(
name="Expand Timeline HUD",
default=False,
description="Show/hide Timeline HUD settings"
)
expand_legend_hud: BoolProperty(
name="Expand Legend HUD",
default=False,
description="Show/hide Legend HUD settings"
)
expand_3d_hud_render: BoolProperty(
name="Expand 3D HUD Render",
default=False,
description="Show/hide 3D HUD Render settings"
)
hud_show_date: BoolProperty(name="Date", default=True, update=update_hud_gpu)
hud_show_week: BoolProperty(name="Week", default=True, update=update_hud_gpu)
hud_show_day: BoolProperty(name="Day", default=False, update=update_hud_gpu)
hud_show_progress: BoolProperty(name="Progress", default=False, update=callbacks_prop.update_hud_gpu)
hud_position: EnumProperty(
name="Position",
items=[
("TOP_RIGHT", "Top Right", ""),
("TOP_LEFT", "Top Left", ""),
("BOTTOM_RIGHT", "Bottom Right", ""),
("BOTTOM_LEFT", "Bottom Left", ""),
],
default="BOTTOM_LEFT",
update=callbacks_prop.force_hud_refresh,
)
hud_scale_factor: FloatProperty(
name="Scale",
default=1.0,
min=0.1,
max=5.0,
precision=2,
update=callbacks_prop.force_hud_refresh,
)
hud_margin_horizontal: FloatProperty(
name="H-Margin",
default=0.05,
min=0.0,
max=0.3,
precision=3,
update=callbacks_prop.force_hud_refresh,
)
hud_margin_vertical: FloatProperty(
name="V-Margin",
default=0.05,
min=0.0,
max=0.3,
precision=3,
update=callbacks_prop.force_hud_refresh,
)
# Base colors (RGBA)
hud_text_color: FloatVectorProperty(
name="Text Color",
subtype="COLOR",
size=4,
default=(1.0, 1.0, 1.0, 1.0),
min=0.0,
max=1.0,
update=callbacks_prop.force_hud_refresh,
)
hud_background_color: FloatVectorProperty(
name="Background Color",
subtype="COLOR",
size=4,
default=(0.09, 0.114, 0.102, 0.102),
min=0.0,
max=1.0,
update=callbacks_prop.force_hud_refresh,
)
# =====================
# HUD VISUAL ENHANCEMENTS
# =====================
# Spacing & alignment
hud_text_spacing: FloatProperty(
name="Line Spacing",
description="Vertical spacing between HUD text lines",
default=0.02,
min=0.0,
max=0.3,
precision=3,
update=callbacks_prop.force_hud_refresh,
)
hud_text_alignment: EnumProperty(
name="Text Alignment",
items=[
("LEFT", "Left", "Align text to the left"),
("CENTER", "Center", "Center align text"),
("RIGHT", "Right", "Align text to the right"),
],
default="LEFT",
update=callbacks_prop.force_hud_refresh,
)
# Panel padding
hud_padding_horizontal: FloatProperty(
name="H-Padding",
description="Horizontal padding inside the HUD panel",
default=10.0,
min=0.0,
max=50.0,
update=callbacks_prop.force_hud_refresh,
)
hud_padding_vertical: FloatProperty(
name="V-Padding",
description="Vertical padding inside the HUD panel",
default=8.0,
min=0.0,
max=50.0,
update=callbacks_prop.force_hud_refresh,
)
# Borders
hud_border_radius: FloatProperty(
name="Border Radius",
description="Corner rounding of the HUD background",
default=20.0,
min=0.0,
max=50.0,
update=callbacks_prop.force_hud_refresh,
)
hud_border_width: FloatProperty(
name="Border Width",
description="Width of the HUD border",
default=0.0,
min=0.0,
max=5.0,
update=callbacks_prop.force_hud_refresh,
)
hud_border_color: FloatVectorProperty(
name="Border Color",
subtype="COLOR",
size=4,
default=(1.0, 1.0, 1.0, 0.5),
min=0.0,
max=1.0,
update=callbacks_prop.force_hud_refresh,
)
# Text shadow
hud_text_shadow_enabled: BoolProperty(
name="Text Shadow",
description="Enable text shadow for better readability",
default=True,
update=callbacks_prop.force_hud_refresh,
)
hud_text_shadow_offset_x: FloatProperty(
name="Shadow Offset X",
description="Horizontal offset of text shadow",
default=1.0,
min=-10.0,
max=10.0,
update=callbacks_prop.force_hud_refresh,
)
hud_text_shadow_offset_y: FloatProperty(
name="Shadow Offset Y",
description="Vertical offset of text shadow",
default=-1.0,
min=-10.0,
max=10.0,
update=callbacks_prop.force_hud_refresh,
)
hud_text_shadow_color: FloatVectorProperty(
name="Shadow Color",
subtype="COLOR",
size=4,
default=(0.0, 0.0, 0.0, 0.8),
min=0.0,
max=1.0,
update=callbacks_prop.force_hud_refresh,
)
# Background drop shadow
hud_background_shadow_enabled: BoolProperty(
name="Background Shadow",
description="Enable drop shadow for the HUD background",
default=False,
update=callbacks_prop.force_hud_refresh,
)
hud_background_shadow_offset_x: FloatProperty(
name="BG Shadow Offset X",
default=3.0,
min=-20.0,
max=20.0,
update=callbacks_prop.force_hud_refresh,
)
hud_background_shadow_offset_y: FloatProperty(
name="BG Shadow Offset Y",
default=-3.0,
min=-20.0,
max=20.0,
update=callbacks_prop.force_hud_refresh,
)
hud_background_shadow_blur: FloatProperty(
name="BG Shadow Blur",
description="Blur radius of the background shadow",
default=5.0,
min=0.0,
max=20.0,
update=callbacks_prop.force_hud_refresh,
)
hud_background_shadow_color: FloatVectorProperty(
name="BG Shadow Color",
subtype="COLOR",
size=4,
default=(0.0, 0.0, 0.0, 0.6),
min=0.0,
max=1.0,
update=callbacks_prop.force_hud_refresh,
)
# Typography
hud_font_weight: EnumProperty(
name="Font Weight",
items=[
("NORMAL", "Normal", "Normal font weight"),
("BOLD", "Bold", "Bold font weight"),
],
default="NORMAL",
update=callbacks_prop.force_hud_refresh,
)
hud_letter_spacing: FloatProperty(
name="Letter Spacing",
description="Spacing between characters (tracking)",
default=0.0,
min=-2.0,
max=5.0,
precision=2,
update=callbacks_prop.force_hud_refresh,
)
# Background gradient
hud_background_gradient_enabled: BoolProperty(
name="Background Gradient",
description="Enable gradient background instead of solid color",
default=False,
update=callbacks_prop.force_hud_refresh,
)
hud_background_gradient_color: FloatVectorProperty(
name="Gradient Color",
subtype="COLOR",
size=4,
default=(0.1, 0.1, 0.1, 0.9),
min=0.0,
max=1.0,
update=callbacks_prop.force_hud_refresh,
)
hud_gradient_direction: EnumProperty(
name="Gradient Direction",
items=[
("VERTICAL", "Vertical", "Top to bottom gradient"),
("HORIZONTAL", "Horizontal", "Left to right gradient"),
("DIAGONAL", "Diagonal", "Diagonal gradient"),
],
default="VERTICAL",
update=callbacks_prop.force_hud_refresh,
)
# --- START OF CORRECTED CODE ---
# ==========================================
# === TIMELINE HUD (GPU) - NEW PROPERTIES ===
# ==========================================
enable_timeline_hud: BoolProperty(
name="Enable Timeline HUD",
description="Show a graphical timeline at the bottom/top of the viewport",
default=False,
update=callbacks_prop.update_gpu_hud_visibility,
)
timeline_hud_position: EnumProperty(
name="Timeline Position",
items=[
('BOTTOM', "Bottom", "Place the timeline at the bottom"),
('TOP', "Top", "Place the timeline at the top"),
],
default='BOTTOM',
update=callbacks_prop.force_hud_refresh,
)
timeline_hud_margin_vertical: FloatProperty(
name="V-Margin",
description="Vertical margin from the viewport edge, as a percentage of viewport height",
default=0.05,
min=0.0,
max=0.45,
subtype='FACTOR',
precision=3,
update=callbacks_prop.force_hud_refresh,
)
timeline_hud_margin_horizontal: FloatProperty(
name="H-Margin",
description="Horizontal offset from the center, as a percentage of viewport width. 0 is center.",
default=0.055,
min=-0.4,
max=0.4,
subtype='FACTOR',
precision=3,
update=callbacks_prop.force_hud_refresh,
)
timeline_hud_zoom_level: EnumProperty(
name="Timeline Zoom",
items=[
('MONTHS', "Months", "Show years and months"),
('WEEKS', "Weeks", "Show weeks and days"),
('DAYS', "Days", "Show individual days"),
],
default='MONTHS',
update=callbacks_prop.force_hud_refresh,
)
timeline_hud_height: FloatProperty(
name="Height (px)",
description="Height of the timeline bar in pixels",
default=40.0,
min=20.0,
max=100.0,
update=callbacks_prop.force_hud_refresh,
)
timeline_hud_color_inactive_range: FloatVectorProperty(
name="Inactive Range Color",
subtype='COLOR', size=4, min=0.0, max=1.0,
default=(0.588, 0.953, 0.745, 0.1),
update=callbacks_prop.force_hud_refresh,
)
timeline_hud_color_active_range: FloatVectorProperty(
name="Active Range Color",
subtype='COLOR', size=4, min=0.0, max=1.0,
default=(0.588, 0.953, 0.745, 0.1),
update=callbacks_prop.force_hud_refresh,
)
timeline_hud_color_progress: FloatVectorProperty(
name="Progress Bar Color",
subtype='COLOR', size=4, min=0.0, max=1.0,
default=(0.122, 0.663, 0.976, 0.102), # #1FA9F91A
update=callbacks_prop.force_hud_refresh,
)
timeline_hud_color_text: FloatVectorProperty(
name="Timeline Text Color",
subtype='COLOR', size=4, min=0.0, max=1.0,
default=(1.0, 1.0, 1.0, 1.0),
update=callbacks_prop.force_hud_refresh,
)
timeline_hud_border_radius: FloatProperty(
name="Timeline Border Radius",
description="Round corner radius for timeline HUD",
default=10.0, min=0.0, max=50.0,
update=callbacks_prop.force_hud_refresh,
)
timeline_hud_show_progress_bar: BoolProperty(
name="Show Progress Bar",
description="Display progress bar in timeline HUD",
default=True,
update=callbacks_prop.force_hud_refresh,
)
# ==================== LEGEND HUD PROPERTIES ====================
# Note: All legend_hud_* properties are defined in camera_prop.py to avoid duplication
# ==================== 3D LEGEND HUD PROPERTIES ====================
enable_3d_legend_hud: BoolProperty(
name="Enable 3D Legend HUD",
description="Display 3D Legend HUD with current active ColorTypes as 3D objects",
default=False,
update=callbacks_prop.update_gpu_hud_visibility,
)
expand_3d_legend_hud: BoolProperty(
name="Expand 3D Legend HUD Settings",
description="Show/hide 3D Legend HUD settings",
default=False,
)
# Position and Layout
legend_3d_hud_distance: FloatProperty(
name="HUD Distance",
description="Distance from camera in camera space",
default=2.2,
min=0.5,
max=10.0,
step=1,
precision=2,
)
legend_3d_hud_pos_x: FloatProperty(
name="HUD Position X",
description="Horizontal position in camera space",
default=-3.6,
min=-10.0,
max=10.0,
step=1,
precision=2,
)
legend_3d_hud_pos_y: FloatProperty(
name="HUD Position Y",
description="Vertical position in camera space",
default=1.4,
min=-10.0,
max=10.0,
step=1,
precision=2,
)
legend_3d_hud_scale: FloatProperty(
name="HUD Scale",
description="Overall scale of the 3D Legend HUD",
default=1.0,
min=0.1,
max=5.0,
step=1,
precision=2,
)
# Panel Settings
legend_3d_panel_width: FloatProperty(
name="Panel Width",
description="Width of the legend panel",
default=2.2,
min=0.5,
max=10.0,
step=1,
precision=2,
)
legend_3d_panel_radius: FloatProperty(
name="Panel Corner Radius",
description="Corner radius for rounded panel",
default=0.12,
min=0.0,
max=1.0,
step=1,
precision=3,
)
legend_3d_panel_alpha: FloatProperty(
name="Panel Alpha",
description="Panel background transparency",
default=0.85,
min=0.0,
max=1.0,
step=1,
precision=2,
)
# Font Settings
legend_3d_font_size_title: FloatProperty(
name="Title Font Size",
description="Font size for legend title",
default=0.18,
min=0.05,
max=1.0,
step=1,
precision=3,
)
legend_3d_font_size_item: FloatProperty(
name="Item Font Size",
description="Font size for legend items",
default=0.15,
min=0.05,
max=1.0,
step=1,
precision=3,
)
# Layout Settings
legend_3d_padding_x: FloatProperty(
name="Padding X",
description="Horizontal padding inside panel",
default=0.18,
min=0.0,
max=1.0,
step=1,
precision=3,
)
legend_3d_padding_top: FloatProperty(
name="Padding Top",
description="Top padding inside panel",
default=0.20,
min=0.0,
max=1.0,
step=1,
precision=3,
)
legend_3d_padding_bottom: FloatProperty(
name="Padding Bottom",
description="Bottom padding inside panel",
default=0.20,
min=0.0,
max=1.0,
step=1,
precision=3,
)
legend_3d_row_height: FloatProperty(
name="Row Height",
description="Height of each legend item row",
default=0.20,
min=0.05,
max=1.0,
step=1,
precision=3,
)
legend_3d_dot_diameter: FloatProperty(
name="Color Dot Diameter",
description="Diameter of color indicator dots",
default=0.10,
min=0.02,
max=0.5,
step=1,
precision=3,
)
legend_3d_dot_text_gap: FloatProperty(
name="Dot to Text Gap",
description="Gap between color dot and text",
default=0.12,
min=0.01,
max=0.5,
step=1,
precision=3,
)
legend_3d_title_text: StringProperty(
name="Legend Title",
description="Text to display as legend title",
default="Legend",
)
timeline_hud_width: FloatProperty(
name="Timeline Width",
description="Width of the timeline HUD as percentage of viewport width",
default=0.8, min=0.1, max=1.0, subtype='PERCENTAGE',
update=callbacks_prop.force_hud_refresh,
)
timeline_hud_color_indicator: FloatVectorProperty(
name="Current Date Indicator Color",
subtype='COLOR', size=4, min=0.0, max=1.0,
default=(1.0, 0.906, 0.204, 1.0), # #FFE734FF
update=callbacks_prop.force_hud_refresh,
)
# LOCK/UNLOCK controls for manual positioning
text_hud_locked: BoolProperty(
name="Lock Text HUD",
description="When locked, text HUD position is automatic. When unlocked, allows manual positioning",
default=True,
update=callbacks_prop.force_hud_refresh,
)
# Manual positioning coordinates (stored when unlocked)
text_hud_manual_x: FloatProperty(
name="Manual X Position",
description="Manual X position for text HUD when unlocked",
default=0.0,
update=callbacks_prop.force_hud_refresh,
)
text_hud_manual_y: FloatProperty(
name="Manual Y Position",
description="Manual Y position for text HUD when unlocked",
default=0.0,
update=callbacks_prop.force_hud_refresh,
)
timeline_hud_manual_x: FloatProperty(
name="Manual X Position",
description="Manual X position for timeline HUD when unlocked",
default=0.0,
update=callbacks_prop.force_hud_refresh,
)
timeline_hud_manual_y: FloatProperty(
name="Manual Y Position",
description="Manual Y position for timeline HUD when unlocked",
default=0.0,
update=callbacks_prop.force_hud_refresh,
)
# =====================
# 4D Camera Management - Animation Context
# =====================
active_animation_camera: EnumProperty(
name="Active Animation Camera",
description="Select or activate a 4D Animation camera",
items=_get_animation_cameras, # <- CORRECTED! Use the function that filters the list
update=callbacks_prop.update_active_animation_camera,
)
hide_all_animation_cameras: BoolProperty(
name="Hide All Animation Cameras",
description="Toggles the visibility of all 4D animation cameras in the viewport",
default=False,
update=callbacks_prop.update_animation_camera_visibility,
)
# =====================
# 4D Camera Management - Snapshot Context
# =====================
active_snapshot_camera: EnumProperty(
name="Active Snapshot Camera",
description="Select or activate a 4D Snapshot camera",
items=_get_snapshot_cameras, # <- CORRECTED! Use the function that filters the list
update=callbacks_prop.update_active_snapshot_camera,
)
hide_all_snapshot_cameras: BoolProperty(
name="Hide All Snapshot Cameras",
description="Alterna la visibilidad de todas las cámaras de snapshot 4D en la vista",
default=False,
update=callbacks.update_snapshot_camera_visibility,
)
# Legacy property for backward compatibility - will be deprecated
active_4d_camera: PointerProperty(
name="Active 4D Camera (Legacy)",
type=bpy.types.Object,
description="Legacy camera selector - use context-specific selectors instead",
poll=lambda self, obj: (obj and obj.type == 'CAMERA' and
(obj.get('is_4d_camera') or
'4D_Animation_Camera' in obj.name or
'Snapshot_Camera' in obj.name)),
update=callbacks_prop.update_active_4d_camera,
)
# Type checking
if TYPE_CHECKING:
camera_focal_mm: float
camera_clip_start: float
camera_clip_end: float
active_animation_camera: bpy.types.Object
active_snapshot_camera: bpy.types.Object
show_animation_cameras: bool
show_snapshot_cameras: bool
show_camera_orbit_settings: bool
enable_text_hud: bool
expand_hud_settings: bool
expand_schedule_hud: bool
expand_timeline_hud: bool
expand_legend_hud: bool
hud_position: str
hud_scale_factor: float
hud_margin_horizontal: float
hud_margin_vertical: float
hud_text_color: tuple[float, float, float, float]
hud_background_color: tuple[float, float, float, float]
hud_text_spacing: float
hud_text_alignment: str
hud_padding_horizontal: float
hud_padding_vertical: float
hud_border_radius: float
hud_border_width: float
hud_border_color: tuple[float, float, float, float]
hud_text_shadow_enabled: bool
hud_text_shadow_offset_x: float
hud_text_shadow_offset_y: float
hud_text_shadow_color: tuple[float, float, float, float]
hud_background_shadow_enabled: bool
hud_background_shadow_offset_x: float
hud_background_shadow_offset_y: float
hud_background_shadow_blur: float
hud_background_shadow_color: tuple[float, float, float, float]
hud_font_weight: str
hud_letter_spacing: float
hud_background_gradient_enabled: bool
hud_background_gradient_color: tuple[float, float, float, float]
hud_gradient_direction: str
enable_timeline_hud: bool
timeline_hud_position: str
timeline_hud_margin_vertical: float
timeline_hud_margin_horizontal: float
timeline_hud_zoom_level: str
timeline_hud_height: float
timeline_hud_color_inactive_range: tuple[float, float, float, float]
timeline_hud_color_active_range: tuple[float, float, float, float]
timeline_hud_color_progress: tuple[float, float, float, float]
timeline_hud_color_text: tuple[float, float, float, float]
timeline_hud_border_radius: float
timeline_hud_show_progress_bar: bool
enable_legend_hud: bool
legend_hud_position: str
legend_hud_margin_horizontal: float
legend_hud_margin_vertical: float
legend_hud_scale_factor: float
legend_hud_background_color: tuple[float, float, float, float]
legend_hud_text_color: tuple[float, float, float, float]
legend_hud_padding: float
legend_hud_border_radius: float
legend_hud_item_spacing: float
legend_hud_color_box_size: float
legend_hud_show_task_count: bool
legend_hud_show_inactive_types: bool
legend_hud_max_items: int
enable_3d_legend_hud: bool
legend_3d_location: tuple[float, float, float]
legend_3d_scale: float
legend_3d_spacing: float
legend_3d_always_face_camera: bool
force_world_origin_anchor: bool
orbit_speed: float
orbit_radius: float
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,551 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import bpy
import json
from typing import Dict
# ============================================================================
# UNIFIED COLORTYPE MANAGER - CLASE CENTRAL PARA GESTIONAR PERFILES
# ============================================================================
class UnifiedColorTypeManager:
@staticmethod
def ensure_default_group(context):
"""Delegate to consolidated utility function."""
from ..utils.color_schemes_utils import ensure_default_group
return ensure_default_group(context)
@staticmethod
def _read_sets_json(context):
"""Safely reads the profiles JSON from the scene."""
import json
try:
scene = context.scene
key = "BIM_AnimationColorSchemesSets"
raw = scene.get(key, "{}")
data = json.loads(raw) if isinstance(raw, str) else (raw or {})
return data if isinstance(data, dict) else {}
except Exception:
return {}
@staticmethod
def _write_sets_json(context, data):
"""Safely writes the ColorTypes JSON to the scene."""
import json
try:
context.scene["BIM_AnimationColorSchemesSets"] = json.dumps(data)
except Exception:
pass
@staticmethod
def get_all_predefined_types(context) -> list:
"""Gets all PredefinedTypes from loaded tasks to ensure ColorTypes exist for them."""
try:
from bonsai.bim.module.sequence.data import SequenceData
if not SequenceData.is_loaded:
SequenceData.load()
types = {"NOTDEFINED", "USERDEFINED"} # Always include these
tasks_data = (SequenceData.data or {}).get("tasks", {})
for task in tasks_data.values():
if predef_type := task.get("PredefinedType"):
types.add(predef_type)
return sorted(list(types))
except Exception:
# Fallback with common types if reading fails
return [
"ATTENDANCE", "CONSTRUCTION", "DEMOLITION", "DISMANTLE",
"DISPOSAL", "INSTALLATION", "LOGISTIC", "MAINTENANCE",
"MOVE", "OPERATION", "REMOVAL", "RENOVATION", "NOTDEFINED"
]
@staticmethod
def ensure_colortype_in_group(context, group_name: str, colortype_name: str):
"""Ensures that a specific ColorType exists within a group in the JSON."""
if not group_name or not colortype_name:
return
data = UnifiedColorTypeManager._read_sets_json(context)
group = data.setdefault(group_name, {"ColorTypes": []})
existing_colortypes = {p.get("name") for p in group.get("ColorTypes", [])}
if colortype_name not in existing_colortypes:
# DEFAULT: Start disabled by default
consider_start = False if (group_name == "DEFAULT") else True
# Use distinctive colors based on colortype name instead of generic green/gray
color_map = {
"LOGISTIC": {"active": [1,1,0,1], "end": [1,0.8,0.3,1]}, # Amarillo->Naranja
"DEMOLITION": {"active": [1,0,0,1], "end": [0.5,0,0,1]}, # Rojo->Rojo oscuro
"REMOVAL": {"active": [1,0,0,1], "end": [0.5,0,0,1]}, # Rojo->Rojo oscuro
"RENOVATION": {"active": [0,0,1,1], "end": [0.5,0.5,1,1]}, # Azul->Azul claro
"CONSTRUCTION": {"active": [0,1,0,1], "end": [0.3,1,0.3,1]}, # Verde->Verde claro
"INSTALLATION": {"active": [0,1,0,1], "end": [0.3,0.8,0.5,1]}, # Verde->Verde agua
}
colors = color_map.get(colortype_name, {"active": [1,0,1,1], "end": [1,0.5,1,1]}) # Magenta fallback
colortype_payload = {
"name": colortype_name,
"start_color": [1,1,1,0],
"in_progress_color": colors["active"],
"end_color": colors["end"],
"use_end_original_color": True,
# Campos completos para consistencia
"consider_start": consider_start,
"consider_active": True,
"consider_end": True,
"use_start_original_color": False,
"use_active_original_color": False,
"start_transparency": 0.0,
"active_start_transparency": 0.0,
"active_finish_transparency": 0.0,
"active_transparency_interpol": 1.0,
"end_transparency": 0.0
}
group["ColorTypes"].append(colortype_payload)
UnifiedColorTypeManager._write_sets_json(context, data)
@staticmethod
def ensure_default_group_has_predefined_types(context):
"""Ensures that the DEFAULT group contains a ColorType for each existing PredefinedType."""
all_types = UnifiedColorTypeManager.get_all_predefined_types(context)
for p_type in all_types:
UnifiedColorTypeManager.ensure_colortype_in_group(context, "DEFAULT", p_type)
@staticmethod
def ensure_default_group_has_all_predefined_types(context):
"""Ensures that the DEFAULT group contains ALL 13 predefined ColorTypes, regardless of tasks."""
# Lista complete de todos los PredefinedTypes posibles (13 tipos)
all_predefined_types = [
"CONSTRUCTION", "INSTALLATION", "DEMOLITION", "REMOVAL",
"DISPOSAL", "DISMANTLE", "OPERATION", "MAINTENANCE",
"ATTENDANCE", "RENOVATION", "LOGISTIC", "MOVE", "NOTDEFINED"
]
for p_type in all_predefined_types:
UnifiedColorTypeManager.ensure_colortype_in_group(context, "DEFAULT", p_type)
@staticmethod
def sync_default_group_to_predefinedtype(context, task_pg):
"""
Key function: Synchronizes the DEFAULT entry of a task with its current PredefinedType.
This function is responsible for updating the data that will be displayed in the UI.
"""
if not task_pg: return
# 1. Get the current PredefinedType of the task from the cached data.
try:
from bonsai.bim.module.sequence.data import SequenceData
tid = getattr(task_pg, "ifc_definition_id", None)
task_data = (SequenceData.data.get("tasks", {}) or {}).get(tid)
predef_type = (task_data.get("PredefinedType") or "NOTDEFINED") if task_data else "NOTDEFINED"
except Exception:
predef_type = "NOTDEFINED"
# 2. Make sure the profile for this type exists in the DEFAULT group.
UnifiedColorTypeManager.ensure_colortype_in_group(context, "DEFAULT", predef_type)
# 3. Update the 'DEFAULT' entry in the task's collection.
try:
coll = getattr(task_pg, "colortype_group_choices", None)
if coll is None: return
default_entry = next((item for item in coll if item.group_name == "DEFAULT"), None)
if not default_entry:
default_entry = coll.add()
default_entry.group_name = "DEFAULT"
# 4. Assign the profile and make sure it is enabled.
# Use safe property setting to avoid ID class writing restrictions
try:
default_entry.selected_colortype = predef_type
default_entry.enabled = True # The DEFAULT group is always active.
except Exception as prop_error:
# If we can't write properties now, schedule for later
if "Writing to ID classes" in str(prop_error):
# Use timer to defer the property update
import bpy
def deferred_update():
try:
default_entry.selected_colortype = predef_type
default_entry.enabled = True
except Exception:
pass
return None # Don't repeat timer
bpy.app.timers.register(deferred_update, first_interval=0.01)
else:
raise prop_error
except Exception as e:
print(f"[ERROR] Error synchronizing DEFAULT for the task: {e}")
@staticmethod
def initialize_default_for_all_tasks(context) -> bool:
"""Recorre todas las tasks y asegura que su grupo DEFAULT esté inicializado y sincronizado."""
try:
import bonsai.tool as tool
tprops = tool.Sequence.get_task_tree_props()
if not tprops or not hasattr(tprops, 'tasks'):
return False
# First ensure that all necessary profiles exist.
UnifiedColorTypeManager.ensure_default_group_has_predefined_types(context)
for task in tprops.tasks:
UnifiedColorTypeManager.sync_default_group_to_predefinedtype(context, task)
return True
except Exception as e:
print(f"[ERROR] Error al inicializar perfiles DEFAULT para todas las tasks: {e}")
return False
@staticmethod
def get_user_created_groups(context) -> list:
"""Returns a list of group names that are not 'DEFAULT'."""
try:
all_groups = list(UnifiedColorTypeManager._read_sets_json(context).keys())
return sorted([g for g in all_groups if g != "DEFAULT"])
except Exception:
return []
# Methods from the original implementation that are still needed and relevant
@staticmethod
def validate_colortype_data(colortype_data: dict) -> bool:
"""Validates the complete data structure of the colortype"""
required_fields = ['name', 'start_color', 'in_progress_color', 'end_color']
if not all(field in colortype_data for field in required_fields):
return False
# Validate colors
for color_field in ['start_color', 'in_progress_color', 'end_color']:
color = colortype_data.get(color_field)
if not isinstance(color, (list, tuple)) or len(color) not in (3, 4):
return False
# Validate optional values
optional_floats = [
'start_transparency', 'active_start_transparency',
'active_finish_transparency', 'active_transparency_interpol',
'end_transparency'
]
for field in optional_floats:
if field in colortype_data:
try:
val = float(colortype_data[field])
if not 0.0 <= val <= 1.0:
return False
except (TypeError, ValueError):
return False
return True
@staticmethod
def get_group_colortypes(context, group_name: str) -> Dict[str, dict]:
"""Gets colortypes from a specific group"""
# For the DEFAULT group, always return the authoritative, hardcoded list of profiles.
# This prevents inconsistencies from the JSON store and ensures the Legend HUD
# and other components always have the complete, correct data.
if group_name == "DEFAULT":
default_order = [
"CONSTRUCTION", "INSTALLATION", "DEMOLITION", "REMOVAL",
"DISPOSAL", "DISMANTLE", "OPERATION", "MAINTENANCE",
"ATTENDANCE", "RENOVATION", "LOGISTIC", "MOVE", "NOTDEFINED", "USERDEFINED"
]
colortypes = {}
for name in default_order:
# Here we force the use of the method we have already corrected
colortypes[name] = UnifiedColorTypeManager._create_default_colortype_data(name)
return colortypes
try:
data = UnifiedColorTypeManager._read_sets_json(context)
if isinstance(data, dict) and group_name in data:
colortypes = {}
for colortype in data[group_name].get("ColorTypes", []):
if UnifiedColorTypeManager.validate_colortype_data(colortype):
colortypes[colortype["name"]] = colortype
return colortypes
except Exception:
pass
return {}
@staticmethod
def get_all_groups(context) -> list:
"""Returns a list of names of all groups."""
try:
return sorted(list(UnifiedColorTypeManager._read_sets_json(context).keys()))
except Exception:
return []
@staticmethod
def get_colortypes_from_specific_group(context, group_name: str) -> list:
"""Get colortype names from a specific group (for use in enums)"""
try:
colortypes_data = UnifiedColorTypeManager.get_group_colortypes(context, group_name)
return sorted(list(colortypes_data.keys()))
except Exception as e:
print(f"[ERROR] Error getting colortypes from group '{group_name}': {e}")
return []
@staticmethod
def debug_colortype_state(context, task_id: int = None):
"""Debug helper to show profile status"""
try:
# Show all groups
all_groups = UnifiedColorTypeManager.get_all_groups(context)
user_groups = UnifiedColorTypeManager.get_user_created_groups(context)
print(f"All groups: {all_groups}")
print(f"User groups (no DEFAULT): {user_groups}")
# Show animation props
try:
import bonsai.tool as tool
anim_props = tool.Sequence.get_animation_props()
print(f"Active ColorType_groups: {getattr(anim_props, 'ColorType_groups', 'N/A')}")
print(f"Task colortype selector: {getattr(anim_props, 'task_colortype_group_selector', 'N/A')}")
print(f"Loaded ColorTypes count: {len(getattr(anim_props, 'ColorTypes', []))}")
for i, p in enumerate(getattr(anim_props, 'ColorTypes', [])):
print(f" [{i}] {getattr(p, 'name', 'NO_NAME')}")
except Exception as e:
print(f"Error getting anim props: {e}")
# Show specific task data
if task_id:
try:
import bonsai.tool as tool
tprops = tool.Sequence.get_task_tree_props()
wprops = tool.Sequence.get_work_schedule_props()
if tprops.tasks and wprops.active_task_index < len(tprops.tasks):
task = tprops.tasks[wprops.active_task_index]
print(f"Task {task.ifc_definition_id} colortype mappings:")
for choice in getattr(task, 'colortype_group_choices', []):
print(f" {choice.group_name} -> {choice.selected_colortype} (enabled: {choice.enabled})")
print(f" use_active_colortype_group: {getattr(task, 'use_active_colortype_group', 'N/A')}")
print(f" selected_colortype_in_active_group: {getattr(task, 'selected_colortype_in_active_group', 'N/A')}")
except Exception as e:
print(f"Error getting task data: {e}")
except Exception as e:
print(f"[ERROR] Debug failed: {e}")
@staticmethod
def sync_task_colortypes(context, task, group_name: str):
"""Synchronizes task colortypes with the active group - eliminates duplication"""
valid_colortypes = UnifiedColorTypeManager.get_group_colortypes(context, group_name)
if hasattr(task, 'colortype_group_choices'):
# Find or create an entry for the group
entry = None
for choice in task.colortype_group_choices:
if choice.group_name == group_name:
entry = choice
break
if not entry:
entry = task.colortype_group_choices.add()
entry.group_name = group_name
entry.enabled = False
entry.selected_colortype = ""
# Validate selected colortype
if entry.selected_colortype and entry.selected_colortype not in valid_colortypes:
entry.selected_colortype = ""
return entry
return None
@staticmethod
def cleanup_invalid_mappings(context):
"""Cleans up all invalid colortype mappings"""
valid_groups = set(UnifiedColorTypeManager._read_sets_json(context).keys())
try:
import bonsai.tool as tool
tprops = tool.Sequence.get_task_tree_props()
for task in getattr(tprops, "tasks", []):
if hasattr(task, 'colortype_group_choices'):
# Collect indices to remove
to_remove = []
for idx, choice in enumerate(task.colortype_group_choices):
if choice.group_name not in valid_groups:
to_remove.append(idx)
else:
# Validate colortype within the group
colortypes = UnifiedColorTypeManager.get_group_colortypes(context, choice.group_name)
if choice.selected_colortype and choice.selected_colortype not in colortypes:
choice.selected_colortype = ""
# Remove invalid entries
for offset, idx in enumerate(to_remove):
task.colortype_group_choices.remove(idx - offset)
except Exception as e:
print(f"Error cleaning invalid mappings: {e}")
@staticmethod
def load_colortypes_into_collection(props, context, group_name: str):
"""Loads colortypes from a group into the property collection"""
# Guard to prevent unnecessary reloading if already correctly populated
if group_name == "DEFAULT":
default_order = [
"CONSTRUCTION", "INSTALLATION", "DEMOLITION", "REMOVAL",
"DISPOSAL", "DISMANTLE", "OPERATION", "MAINTENANCE",
"ATTENDANCE", "RENOVATION", "LOGISTIC", "MOVE", "NOTDEFINED", "USERDEFINED"
]
# Check if collection is already correctly populated
if (len(props.ColorTypes) == len(default_order) and
all(props.ColorTypes[i].name == default_order[i] for i in range(len(default_order)))):
# Collection is already correctly populated, no need to reload
return
# For DEFAULT, ensure that ALL profiles exist
# Always ensure DEFAULT profiles exist when DEFAULT group is specifically loaded
if group_name == "DEFAULT":
user_groups = UnifiedColorTypeManager.get_user_created_groups(context)
# Always load DEFAULT profiles when explicitly loading DEFAULT group
UnifiedColorTypeManager.ensure_default_group_has_predefined_types(context)
if user_groups:
print("[WARNING] Custom groups detected - but DEFAULT group is being explicitly loaded with full profiles")
colortypes_data = UnifiedColorTypeManager.get_group_colortypes(context, group_name)
try:
props.ColorTypes.clear()
# For DEFAULT, ensure specific order and completeness
if group_name == "DEFAULT":
# Complete list in specific order
default_order = [
"CONSTRUCTION", "INSTALLATION", "DEMOLITION", "REMOVAL",
"DISPOSAL", "DISMANTLE", "OPERATION", "MAINTENANCE",
"ATTENDANCE", "RENOVATION", "LOGISTIC", "MOVE", "NOTDEFINED", "USERDEFINED"
]
# Load in the specified order
for colortype_name in default_order:
# ALWAYS use the hardcoded default data for the DEFAULT group to ensure correctness.
# This ignores any potentially incorrect data stored in the JSON.
colortype_data = UnifiedColorTypeManager._create_default_colortype_data(colortype_name)
p = props.ColorTypes.add()
p.name = colortype_name
UnifiedColorTypeManager._apply_colortype_data_to_property(p, colortype_data)
else:
# For custom groups, normal behavior
for colortype_name, colortype_data in colortypes_data.items():
p = props.ColorTypes.add()
p.name = colortype_name
UnifiedColorTypeManager._apply_colortype_data_to_property(p, colortype_data)
if props.ColorTypes:
props.active_ColorType_index = 0
except Exception as e:
print(f"Error loading colortypes: {e}")
@staticmethod
def _create_default_colortype_data(colortype_name: str) -> dict:
"""Creates default colortype data for a given colortype name"""
# Define specific colors for each type
color_map = {
"CONSTRUCTION": {"start": [1,1,1,0], "active": [0,1,0,1], "end": [0.3,1,0.3,1]},
"INSTALLATION": {"start": [1,1,1,0], "active": [0,1,0,1], "end": [0.3,0.8,0.5,1]},
"DEMOLITION": {"start": [1,1,1,1], "active": [1,0,0,1], "end": [0,0,0,0], "hide": True},
"REMOVAL": {"start": [1,1,1,1], "active": [1,0,0,1], "end": [0,0,0,0], "hide": True},
"DISPOSAL": {"start": [1,1,1,1], "active": [1,0,0,1], "end": [0,0,0,0], "hide": True},
"DISMANTLE": {"start": [1,1,1,1], "active": [1,0,0,1], "end": [0,0,0,0], "hide": True},
"OPERATION": {"start": [1,1,1,1], "active": [0,0,1,1], "end": [1,1,1,1]},
"MAINTENANCE": {"start": [1,1,1,1], "active": [0,0,1,1], "end": [1,1,1,1]},
"ATTENDANCE": {"start": [1,1,1,1], "active": [0,0,1,1], "end": [1,1,1,1]},
"RENOVATION": {"start": [1,1,1,1], "active": [0,0,1,1], "end": [0.9,0.9,0.9,1]},
"LOGISTIC": {"start": [1,1,1,1], "active": [1,1,0,1], "end": [1,0.8,0.3,1]},
"MOVE": {"start": [1,1,1,1], "active": [1,1,0,1], "end": [0.8,0.6,0,1]},
"NOTDEFINED": {"start": [0.7,0.7,0.7,1], "active": [0.5,0.5,0.5,1], "end": [0.3,0.3,0.3,1]},
"USERDEFINED": {"start": [0.7,0.7,0.7,1], "active": [0.5,0.5,0.5,1], "end": [0.3,0.3,0.3,1]},
}
colors = color_map.get(colortype_name, color_map["NOTDEFINED"])
return {
"name": colortype_name,
"start_color": colors["start"],
"in_progress_color": colors["active"],
"end_color": colors["end"],
"consider_start": False,
"consider_active": True,
"consider_end": True,
"use_start_original_color": False,
"use_active_original_color": False,
"use_end_original_color": not colors.get("hide", False),
"start_transparency": 0.0,
"active_start_transparency": 0.8,
"active_finish_transparency": 0.3,
"active_transparency_interpol": 1.0,
"end_transparency": 0.0,
"hide_at_end": colors.get("hide", False)
}
@staticmethod
def _apply_colortype_data_to_property(property_obj, colortype_data: dict):
"""Applies colortype data to a property object with safe fallbacks"""
try:
# Colors with safe fallbacks
for attr in ("start_color", "in_progress_color", "end_color"):
col = colortype_data.get(attr, [1, 1, 1, 1])
if isinstance(col, (list, tuple)) and len(col) >= 3:
rgba = list(col) + [1.0] * (4 - len(col))
setattr(property_obj, attr, rgba[:4])
else:
setattr(property_obj, attr, [1.0, 1.0, 1.0, 1.0])
# Booleans with fallbacks
bool_attrs = {
"use_start_original_color": False,
"use_active_original_color": False,
"use_end_original_color": True,
"consider_start": False,
"consider_active": True,
"consider_end": True,
"hide_at_end": False
}
for attr, default in bool_attrs.items():
if hasattr(property_obj, attr):
setattr(property_obj, attr, bool(colortype_data.get(attr, default)))
# Transparencies with fallbacks
float_attrs = {
"active_start_transparency": 0.0,
"active_finish_transparency": 0.0,
"active_transparency_interpol": 1.0,
"start_transparency": 0.0,
"end_transparency": 0.0
}
for attr, default in float_attrs.items():
if hasattr(property_obj, attr):
try:
val = float(colortype_data.get(attr, default))
setattr(property_obj, attr, max(0.0, min(1.0, val)))
except (TypeError, ValueError):
setattr(property_obj, attr, default)
except Exception as e:
print(f"Error applying colortype data: {e}")
@@ -0,0 +1,335 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import bonsai.tool as tool
from bonsai.bim.module.sequence.data import SequenceData, AnimationColorSchemeData
from .color_manager_prop import UnifiedColorTypeManager
def get_operator_items(self, context):
"""
Genera dinámicamente la lista de operadores según el tipo de dato de la columna seleccionada.
"""
data_type = getattr(self, 'data_type', 'string')
common_ops = [
('EQUALS', "Equals", "The value is exactly the same"),
('NOT_EQUALS', "Does not equal", "The value is different"),
('EMPTY', "Is empty", "The field has no value"),
('NOT_EMPTY', "Is not empty", "The field has a value"),
]
if data_type in ('integer', 'real', 'float'):
return [
('GREATER', "Greater than", ">"),
('LESS', "Less than", "<"),
('GTE', "Greater or Equal", ">="),
('LTE', "Less or Equal", "<="),
] + common_ops
elif data_type == 'date':
return [
('GREATER', "After Date", "The date is after the specified one"),
('LESS', "Before Date", "The date is before the specified one"),
('GTE', "On or After Date", "The date is on or after the specified one"),
('LTE', "On or Before Date", "The date is on or before the specified one"),
] + common_ops
elif data_type == 'boolean':
return [
('EQUALS', "Is", "The value is true or false"),
('NOT_EQUALS', "Is not", "The value is the opposite"),
]
else: # string, enum, y otros por defecto
return [
('CONTAINS', "Contains", "The text string is contained"),
('NOT_CONTAINS', "Does not contain", "The text string is not contained"),
] + common_ops
# ============================================================================
# CALLBACK FUNCTIONS - Improved with the new system
# ============================================================================
def getTaskColumns(self, context):
if not SequenceData.is_loaded:
SequenceData.load()
return SequenceData.data["task_columns_enum"]
def getTaskTimeColumns(self, context):
if not SequenceData.is_loaded:
SequenceData.load()
return SequenceData.data["task_time_columns_enum"]
def getWorkSchedules(self, context):
if not SequenceData.is_loaded:
SequenceData.load()
return SequenceData.data["work_schedules_enum"]
def getWorkCalendars(self, context):
if not SequenceData.is_loaded:
SequenceData.load()
return SequenceData.data["work_calendars_enum"]
def get_animation_color_schemes_items(self, context):
"""Gets colortype items for dropdown"""
props = tool.Sequence.get_animation_props()
items = []
try:
for i, p in enumerate(props.ColorTypes):
name = p.name or f"colortype {i+1}"
items.append((name, name, "", i))
except Exception:
pass
if not items:
items = [("", "<no colortypes>", "", 0)]
return items
def get_custom_group_colortype_items(self, context):
"""
Gets colortype items ONLY from the selected custom group (excludes DEFAULT).
This version reads directly from the JSON and is more lenient to allow UI selection
even if colortype data is incomplete.
"""
items = []
try:
anim_props = tool.Sequence.get_animation_props()
selected_group = getattr(anim_props, "task_colortype_group_selector", "")
# Debug prints removed for performance - were causing spam
if selected_group and selected_group != "DEFAULT":
# Direct and flexible reading from JSON
all_sets = UnifiedColorTypeManager._read_sets_json(context)
group_data = all_sets.get(selected_group, {})
colortypes_list = group_data.get("ColorTypes", [])
colortype_names = []
for colortype in colortypes_list:
if isinstance(colortype, dict) and "name" in colortype:
# Ensure we only add valid non-numeric string names
name = str(colortype["name"])
if name and not name.isdigit():
colortype_names.append(name)
# Always include an empty option first to prevent enum errors
items.append(("", "<none>", "No colortype selected", 0))
for i, name in enumerate(sorted(colortype_names)):
items.append((name, name, f"colortype from {selected_group}", i + 1))
# Debug: Found {len(colortype_names)} colortypes for {selected_group}
else:
pass # No valid group selected
# If there are no profiles, ensure that at least the null option exists to avoid enum errors.
except Exception as e:
print(f"Error getting custom group colortypes: {e}")
items.append(("", "<error loading colortypes>", "", 0))
if not items:
anim_props = tool.Sequence.get_animation_props()
selected_group = getattr(anim_props, "task_colortype_group_selector", "")
if not selected_group:
items.append(("", "<select custom group first>", "", 0))
elif selected_group == "DEFAULT":
items.append(("", "<DEFAULT not allowed here>", "", 0))
else:
items.append(("", f"<no colortypes in {selected_group}>", "", 0))
# Ensure that the null option is always present if there are no other items
if not items:
items.append(("", "<none>", "No colortypes available", 0))
# --- END OF CORRECTION ---
print(f"🔍 Final items returned: {[(item[0], item[1]) for item in items]}")
# Ensure empty option is ALWAYS first and present
if not any(item[0] == "" for item in items):
print("🚨 CRITICAL: No empty option found, forcing one")
items.insert(0, ("", "<none>", "No colortype selected", 0))
# Ensure the empty option is always first
empty_item = None
non_empty_items = []
for item in items:
if item[0] == "":
empty_item = item
else:
non_empty_items.append(item)
if empty_item:
final_items = [empty_item] + non_empty_items
else:
final_items = [("", "<none>", "No colortype selected", 0)] + non_empty_items
print(f"🔍 FINAL SORTED items: {[(item[0], item[1]) for item in final_items]}")
return final_items
def get_task_colortype_items(self, context):
"""Enum items function for task colortypes - separated from update function"""
items = []
try:
anim_props = tool.Sequence.get_animation_props()
selected_group = getattr(anim_props, "task_colortype_group_selector", "")
print(f"🔍 Getting colortypes for custom group: '{selected_group}'")
# Solo mostrar perfiles si hay un grupo personalizado seleccionado
if selected_group and selected_group != "DEFAULT":
from bonsai.bim.module.sequence.prop import UnifiedColorTypeManager
colortypes = UnifiedColorTypeManager.get_group_colortypes(context, selected_group)
for i, name in enumerate(sorted(colortypes.keys())):
items.append((name, name, f"colortype from {selected_group}", i))
print(f"[DEBUG] Found {len(items)} colortypes in group '{selected_group}'")
except Exception as e:
print(f"[ERROR] Error getting custom group colortypes: {e}")
items = [("", "<error loading colortypes>", "", 0)]
if not items:
anim_props = tool.Sequence.get_animation_props()
selected_group = getattr(anim_props, "task_colortype_group_selector", "")
if not selected_group:
items = [("", "<select custom group first>", "", 0)]
elif selected_group == "DEFAULT":
items = [("", "<DEFAULT not allowed here>", "", 0)]
else:
items = [("", f"<no colortypes in {selected_group}>", "", 0)]
return items
def get_schedule_predefined_types(self, context):
if not SequenceData.is_loaded:
SequenceData.load()
return SequenceData.data["schedule_predefined_types_enum"]
def get_saved_color_schemes(self, context):
"""Gets saved color schemes (legacy - maintain for compatibility)"""
if not AnimationColorSchemeData.is_loaded:
AnimationColorSchemeData.load()
return AnimationColorSchemeData.data.get("saved_color_schemes", [])
def get_internal_ColorType_sets_enum(self, context):
"""Gets enum of ALL available colortype groups, including DEFAULT."""
from bonsai.bim.module.sequence.prop import UnifiedColorTypeManager
try:
# Get all groups directly from the source
all_groups = sorted(list(UnifiedColorTypeManager._read_sets_json(context).keys()))
if all_groups:
# Ensure "DEFAULT" appears first for convenience
if "DEFAULT" in all_groups:
all_groups.remove("DEFAULT")
all_groups.insert(0, "DEFAULT")
return [(name, name, f"colortype group: {name}") for name in all_groups]
except Exception:
pass
# Fallback - always have at least DEFAULT
return [("DEFAULT", "DEFAULT", "Auto-managed default group")]
def get_all_groups_enum(self, context):
"""Enum para todos los grupos (incluyendo DEFAULT)."""
try:
groups = UnifiedColorTypeManager.get_all_groups(context)
items = []
for i, group in enumerate(sorted(groups)):
desc = "Auto-managed colortypes by PredefinedType" if group == "DEFAULT" else "Custom colortype group"
items.append((group, group, desc, i))
return items if items else [("DEFAULT", "DEFAULT", "Auto-managed default group", 0)]
except Exception:
return [("DEFAULT", "DEFAULT", "Auto-managed default group", 0)]
def get_user_created_groups_enum(self, context):
"""Returns EnumProperty items for user-created groups, excluding 'DEFAULT'."""
from bonsai.bim.module.sequence.prop import UnifiedColorTypeManager
try:
user_groups = UnifiedColorTypeManager.get_user_created_groups(context)
if user_groups:
return [(name, name, f"colortype group: {name}") for name in user_groups]
except Exception:
pass
return [("NONE", "<no custom groups>", "Create custom groups in the Animation Color Schemes panel")]
def get_all_task_columns_enum(self, context):
"""
Genera una lista EnumProperty con TODAS las columnas filtrables,
incluyendo el tipo de dato en el identificador para uso interno.
"""
if not SequenceData.is_loaded:
SequenceData.load()
items = []
# 1. Special columns (manually defined)
# The format is: "InternalName||data_type", "UI Label", "Description"
items.append(("Special.OutputsCount||integer", "Element 3D", "Total number of 3D elements assigned to task (inputs + outputs)."))
items.append(("Special.VarianceStatus||string", "Variance Status", "Task variance status (Delayed, Ahead, On Time)"))
items.append(("Special.VarianceDays||integer", "Variance (Days)", "Task variance in days"))
# --- END OF MODIFICATION ---
# 2. Columnas de IfcTask
for name_type, label, desc in SequenceData.data.get("task_columns_enum", []):
try:
name, data_type = name_type.split('/')
identifier = f"IfcTask.{name}||{data_type}"
items.append((identifier, f"Task: {label}", desc))
except Exception:
continue
# 3. Columnas de IfcTaskTime
for name_type, label, desc in SequenceData.data.get("task_time_columns_enum", []):
try:
name, data_type = name_type.split('/')
# We correct so that dates are treated as 'date'
final_data_type = 'date' if any(s in label.lower() for s in ['date', 'start', 'finish']) else data_type
identifier = f"IfcTaskTime.{name}||{final_data_type}"
items.append((identifier, f"Time: {label}", desc))
except Exception:
continue
return sorted(items, key=lambda x: x[1])
def get_date_source_items(self, context):
"""Helper for EnumProperty items to select date sources."""
return [
('SCHEDULE', "Schedule", "Use Schedule dates"),
('ACTUAL', "Actual", "Use Actual dates"),
('EARLY', "Early", "Use Early dates"),
('LATE', "Late", "Use Late dates"),
]
@@ -0,0 +1,579 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import bpy
from bonsai.bim.module.sequence.data import SequenceData
from bpy.types import PropertyGroup
from bpy.props import (
StringProperty,
EnumProperty,
BoolProperty,
IntProperty,
FloatProperty,
CollectionProperty,
)
from typing import TYPE_CHECKING
# ============================================================================
# FILTER CALLBACK FUNCTIONS
# ============================================================================
def get_operator_items(self, context):
"""
Genera dinámicamente la lista de operadores según el tipo de dato de la columna seleccionada.
Dynamically generates the operator list based on the data type of the selected column.
"""
data_type = getattr(self, 'data_type', 'string')
common_ops = [
('EQUALS', "Equals", "The value is exactly the same"),
('NOT_EQUALS', "Does not equal", "The value is different"),
('EMPTY', "Is empty", "The field has no value"),
('NOT_EMPTY', "Is not empty", "The field has a value"),
]
if data_type in ('integer', 'real', 'float'):
return [
('GREATER', "Greater than", ">"),
('LESS', "Less than", "<"),
('GTE', "Greater or Equal", ">="),
('LTE', "Less or Equal", "<="),
] + common_ops
elif data_type == 'date':
return [
('BEFORE', "Before", "Date is earlier than the specified value"),
('AFTER', "After", "Date is later than the specified value"),
('ON', "On", "Date is exactly the specified value"),
] + common_ops
elif data_type == 'boolean':
return [
('IS_TRUE', "Is True", "Boolean value is True"),
('IS_FALSE', "Is False", "Boolean value is False"),
] + common_ops
else: # string and others
return [
('CONTAINS', "Contains", "Text contains the specified value"),
('NOT_CONTAINS', "Does not contain", "Text does not contain the specified value"),
('STARTS_WITH', "Starts with", "Text begins with the specified value"),
('ENDS_WITH', "Ends with", "Text ends with the specified value"),
] + common_ops
def update_filter_column(self, context):
"""
Callback que se ejecuta al cambiar la columna del filtro.
Identifica el tipo de dato y resetea los valores para evitar inconsistencias.
Callback that runs when changing the filter column.
It identifies the data type and resets the values to avoid inconsistencies.
"""
try:
# The identifier is now 'IfcTask.Name||string'. We extract the data type.
parts = (self.column or "").split('||')
if len(parts) == 2:
self.data_type = parts[1]
else:
self.data_type = 'string' # Safe default type
# Reset all value fields to start from scratch
self.value_string = ""
self.value_integer = 0
self.value_float = 0.0
self.value_boolean = False
except Exception as e:
print(f"Error in update_filter_column: {e}")
self.data_type = 'string'
def get_all_task_columns_enum(self, context):
"""
Genera una lista EnumProperty con TODAS las columnas filtrables,
incluyendo el tipo de dato en el identificador para uso interno.
Generates an EnumProperty list with ALL filterable columns,
including the data type in the identifier for internal use.
"""
if not SequenceData.is_loaded:
SequenceData.load()
items = []
# 1. Special columns (manually defined)
# The format is: "InternalName||data_type", "UI Label", "Description"
items.append(("Special.OutputsCount||integer", "Outputs 3D", "Number of elements assigned as task outputs."))
items.append(("Special.VarianceStatus||string", "Variance Status", "Task variance status (Delayed, Ahead, On Time)"))
items.append(("Special.VarianceDays||integer", "Variance (Days)", "Task variance in days"))
# 2. IfcTask columns
for name_type, label, desc in SequenceData.data.get("task_columns_enum", []):
try:
name, data_type = name_type.split('/')
# Reformat to the new standard: "IfcTask.PropertyName||data_type"
internal_id = f"IfcTask.{name}||{data_type}"
items.append((internal_id, label, desc))
except ValueError:
# If the format is unexpected, use a safe default
internal_id = f"IfcTask.{name_type}||string"
items.append((internal_id, label, desc))
# 3. IfcTaskTime columns
for name_type, label, desc in SequenceData.data.get("task_time_columns_enum", []):
try:
name, data_type = name_type.split('/')
# Reformat to the new standard: "IfcTaskTime.PropertyName||data_type"
internal_id = f"IfcTaskTime.{name}||{data_type}"
items.append((internal_id, label, desc))
except ValueError:
# If the format is unexpected, use a safe default
internal_id = f"IfcTaskTime.{name_type}||string"
items.append((internal_id, label, desc))
# 4. Fallback if no data is available
if not items:
items = [("IfcTask.Name||string", "Task Name", "Name of the task")]
return items
# ============================================================================
# FILTER PROPERTY GROUP CLASSES
# ============================================================================
class TaskFilterRule(PropertyGroup):
"""Define una regla de filtrado con soporte para múltiples tipos de datos."""
is_active: BoolProperty(
name="Active",
default=True,
description="Enable or disable this filter rule"
)
column: EnumProperty(
name="Column",
description="The column to apply the filter on",
items=get_all_task_columns_enum,
update=update_filter_column
)
operator: EnumProperty(
name="Operator",
description="The comparison operation to perform",
items=get_operator_items
)
# Data type (automatically set by update_filter_column)
data_type: StringProperty(
name="Data Type",
default="string",
description="Internal field to store the data type of the selected column"
)
# Multiple value fields for different data types
value_string: StringProperty(
name="Text Value",
description="String value for text-based filters"
)
value_integer: IntProperty(
name="Integer Value",
description="Integer value for numeric filters"
)
value_float: FloatProperty(
name="Float Value",
description="Float value for decimal numeric filters",
precision=2
)
value_boolean: BoolProperty(
name="Boolean Value",
description="Boolean value for true/false filters"
)
value_date: StringProperty(
name="Date Value",
description="Date value in ISO format (YYYY-MM-DD) for date filters"
)
# Case sensitivity for string operations
case_sensitive: BoolProperty(
name="Case Sensitive",
description="Whether string comparisons should be case sensitive",
default=False
)
if TYPE_CHECKING:
is_active: bool
column: str
operator: str
data_type: str
value_string: str
value_integer: int
value_float: float
value_boolean: bool
value_date: str
case_sensitive: bool
class BIMTaskFilterProperties(PropertyGroup):
"""Stores the complete configuration of the filter system."""
rules: CollectionProperty(
name="Filter Rules",
type=TaskFilterRule,
)
active_rule_index: IntProperty(
name="Active Filter Rule Index",
)
logic: EnumProperty(
name="Filter Logic",
description="How multiple filter rules are combined",
items=[
('AND', "Match All (AND)", "Show tasks that meet ALL active rules"),
('OR', "Match Any (OR)", "Show tasks that meet AT LEAST ONE active rule"),
],
default='AND'
)
# Quick filter options for common use cases
show_only_active_tasks: BoolProperty(
name="Show Only Active Tasks",
description="Filter to show only tasks that are currently in progress",
default=False
)
show_only_delayed_tasks: BoolProperty(
name="Show Only Delayed Tasks",
description="Filter to show only tasks that are behind schedule",
default=False
)
show_only_tasks_with_outputs: BoolProperty(
name="Show Only Tasks with 3D Outputs",
description="Filter to show only tasks that have 3D elements assigned",
default=False
)
# Search functionality
quick_search: StringProperty(
name="Quick Search",
description="Search tasks by name or identification",
default=""
)
search_in_columns: EnumProperty(
name="Search In",
description="Which columns to search in for quick search",
items=[
('NAME', "Name", "Search in task names only"),
('ID', "Identification", "Search in task identification only"),
('BOTH', "Name & ID", "Search in both name and identification"),
('ALL', "All Text Columns", "Search in all text-based columns"),
],
default='BOTH'
)
# Filter performance settings
enable_live_filtering: BoolProperty(
name="Live Filtering",
description="Update filter results in real-time as you type",
default=True
)
# Filter result statistics
total_tasks: IntProperty(
name="Total Tasks",
description="Total number of tasks in the schedule",
default=0
)
filtered_tasks: IntProperty(
name="Filtered Tasks",
description="Number of tasks after applying filters",
default=0
)
if TYPE_CHECKING:
rules: bpy.types.bpy_prop_collection_idprop[TaskFilterRule]
active_rule_index: int
logic: str
show_only_active_tasks: bool
show_only_delayed_tasks: bool
show_only_tasks_with_outputs: bool
quick_search: str
search_in_columns: str
enable_live_filtering: bool
total_tasks: int
filtered_tasks: int
class SavedFilterSet(PropertyGroup):
"""Almacena un conjunto de reglas de filtro con un nombre."""
name: StringProperty(
name="Set Name",
description="Name for this saved filter set"
)
rules: CollectionProperty(
name="Filter Rules",
type=TaskFilterRule,
description="Collection of filter rules in this set"
)
# Metadata for saved filter sets
description: StringProperty(
name="Description",
description="Optional description of what this filter set does",
default=""
)
created_date: StringProperty(
name="Created Date",
description="Date when this filter set was created",
default=""
)
last_used_date: StringProperty(
name="Last Used Date",
description="Date when this filter set was last used",
default=""
)
use_count: IntProperty(
name="Use Count",
description="Number of times this filter set has been applied",
default=0
)
is_favorite: BoolProperty(
name="Favorite",
description="Mark this filter set as a favorite for quick access",
default=False
)
if TYPE_CHECKING:
name: str
rules: bpy.types.bpy_prop_collection_idprop[TaskFilterRule]
description: str
created_date: str
last_used_date: str
use_count: int
is_favorite: bool
# ============================================================================
# FILTER HELPER FUNCTIONS
# ============================================================================
def apply_task_filters(context, task_list, filter_props):
"""
Apply the configured filters to a list of tasks and return the filtered results.
Args:
context: Blender context
task_list: List of task objects to filter
filter_props: BIMTaskFilterProperties with filter configuration
Returns:
List of tasks that match the filter criteria
"""
if not filter_props.rules:
return task_list
filtered_tasks = []
for task in task_list:
task_matches = False
active_rules = [rule for rule in filter_props.rules if rule.is_active]
if not active_rules:
# No active rules means no filtering
filtered_tasks.append(task)
continue
if filter_props.logic == 'AND':
# All rules must match
task_matches = all(evaluate_filter_rule(task, rule) for rule in active_rules)
else: # OR logic
# At least one rule must match
task_matches = any(evaluate_filter_rule(task, rule) for rule in active_rules)
if task_matches:
filtered_tasks.append(task)
return filtered_tasks
def evaluate_filter_rule(task, rule):
"""
Evaluate a single filter rule against a task.
Args:
task: Task object to evaluate
rule: TaskFilterRule to apply
Returns:
bool: True if the task matches the rule, False otherwise
"""
try:
# Extract column information
column_parts = rule.column.split('||')
if len(column_parts) != 2:
return False
column_path, data_type = column_parts
# Get the actual value from the task
task_value = get_task_column_value(task, column_path, data_type)
# Get the comparison value from the rule
if data_type == 'string':
compare_value = rule.value_string
elif data_type in ('integer', 'int'):
compare_value = rule.value_integer
elif data_type in ('float', 'real'):
compare_value = rule.value_float
elif data_type == 'boolean':
compare_value = rule.value_boolean
elif data_type == 'date':
compare_value = rule.value_date
else:
compare_value = rule.value_string
# Apply the operator
return apply_filter_operator(task_value, rule.operator, compare_value, data_type, rule.case_sensitive)
except Exception as e:
print(f"Error evaluating filter rule: {e}")
return False
def get_task_column_value(task, column_path, data_type):
"""
Extract a value from a task based on the column path.
Args:
task: Task object
column_path: String like "IfcTask.Name" or "Special.OutputsCount"
data_type: Expected data type
Returns:
The extracted value, converted to the appropriate type
"""
try:
if column_path.startswith("Special."):
# Handle special columns
special_field = column_path.split('.')[1]
if special_field == "OutputsCount":
return getattr(task, 'outputs_count', 0)
elif special_field == "VarianceStatus":
return getattr(task, 'variance_status', '')
elif special_field == "VarianceDays":
return getattr(task, 'variance_days', 0)
elif column_path.startswith("IfcTask."):
# Handle regular IFC task properties
field_name = column_path.split('.')[1].lower()
return getattr(task, field_name, None)
elif column_path.startswith("IfcTaskTime."):
# Handle task time properties
field_name = column_path.split('.')[1].lower()
# These would typically come from the derived fields
return getattr(task, f"derived_{field_name}", None)
except Exception as e:
print(f"Error getting task column value for {column_path}: {e}")
return None
def apply_filter_operator(task_value, operator, compare_value, data_type, case_sensitive=False):
"""
Apply a filter operator to compare task_value with compare_value.
Args:
task_value: Value from the task
operator: Comparison operator
compare_value: Value to compare against
data_type: Type of data being compared
case_sensitive: Whether string comparisons should be case sensitive
Returns:
bool: Result of the comparison
"""
# Handle None/empty values
if operator == 'EMPTY':
return task_value is None or task_value == '' or task_value == 0
elif operator == 'NOT_EMPTY':
return task_value is not None and task_value != '' and task_value != 0
if task_value is None:
return False
# String operations
if data_type == 'string':
task_str = str(task_value)
compare_str = str(compare_value)
if not case_sensitive:
task_str = task_str.lower()
compare_str = compare_str.lower()
if operator == 'EQUALS':
return task_str == compare_str
elif operator == 'NOT_EQUALS':
return task_str != compare_str
elif operator == 'CONTAINS':
return compare_str in task_str
elif operator == 'NOT_CONTAINS':
return compare_str not in task_str
elif operator == 'STARTS_WITH':
return task_str.startswith(compare_str)
elif operator == 'ENDS_WITH':
return task_str.endswith(compare_str)
# Numeric operations
elif data_type in ('integer', 'int', 'float', 'real'):
try:
task_num = float(task_value) if data_type in ('float', 'real') else int(task_value)
compare_num = float(compare_value) if data_type in ('float', 'real') else int(compare_value)
if operator == 'EQUALS':
return task_num == compare_num
elif operator == 'NOT_EQUALS':
return task_num != compare_num
elif operator == 'GREATER':
return task_num > compare_num
elif operator == 'LESS':
return task_num < compare_num
elif operator == 'GTE':
return task_num >= compare_num
elif operator == 'LTE':
return task_num <= compare_num
except (ValueError, TypeError):
return False
# Boolean operations
elif data_type == 'boolean':
if operator == 'IS_TRUE':
return bool(task_value) is True
elif operator == 'IS_FALSE':
return bool(task_value) is False
elif operator == 'EQUALS':
return bool(task_value) == bool(compare_value)
elif operator == 'NOT_EQUALS':
return bool(task_value) != bool(compare_value)
# Date operations (would need proper date parsing)
elif data_type == 'date':
# This would need proper implementation with date parsing
# For now, treat as string comparison
return apply_filter_operator(task_value, operator, compare_value, 'string', case_sensitive)
return False
@@ -0,0 +1,68 @@
"""Miscellaneous PropertyGroups for 4D BIM scheduling.
This module contains miscellaneous PropertyGroup classes that don't fit into other
thematic categories:
- BIMTaskTypeColor: Legacy color system for task types
- IFCStatus: IFC status properties for visibility control
- BIMStatusProperties: Main status management properties
Each PropertyGroup maintains full compatibility with the original implementation.
"""
import bpy
from bpy.props import (
StringProperty,
BoolProperty,
FloatVectorProperty,
CollectionProperty
)
from bpy.types import PropertyGroup
try:
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import bpy.types
except ImportError:
TYPE_CHECKING = False
class BIMTaskTypeColor(PropertyGroup):
"""Color by task type (legacy - maintain for compatibility)"""
name: StringProperty(name="Name")
animation_type: StringProperty(name="Type")
color: FloatVectorProperty(
name="Color",
subtype="COLOR", size=4,
default=(1.0, 0.0, 0.0, 1.0),
min=0.0,
max=1.0,
)
if TYPE_CHECKING:
name: str
animation_type: str
color: tuple[float, float, float, float]
class IFCStatus(PropertyGroup):
"""IFC status properties for visibility control."""
name: StringProperty(name="Name")
is_visible: BoolProperty(
name="Is Visible",
default=True,
update=lambda x, y: (None, bpy.ops.bim.activate_status_filters())[0]
)
if TYPE_CHECKING:
name: str
is_visible: bool
class BIMStatusProperties(PropertyGroup):
"""Main status management properties."""
is_enabled: BoolProperty(name="Is Enabled")
statuses: CollectionProperty(name="Statuses", type=IFCStatus)
if TYPE_CHECKING:
is_enabled: bool
statuses: bpy.types.bpy_prop_collection_idprop[IFCStatus]
@@ -0,0 +1,626 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import bpy
import bonsai.tool as tool
import bonsai.core.sequence as core
from bonsai.bim.module.sequence.data import SequenceData
from bonsai.bim.prop import Attribute, ISODuration
from . import callbacks_prop
from . import enums_prop
# Import snapshot functions
try:
from ..operators.operator import snapshot_all_ui_state
from ..operators.schedule_task_operators import restore_all_ui_state
except ImportError:
# Fallback functions if imports fail
def snapshot_all_ui_state(context):
pass
def restore_all_ui_state(context):
pass
# Global flag to prevent recursive callback execution during restore
_CALLBACK_LOCK = False
from dateutil import parser
from bpy.types import PropertyGroup
from bpy.props import (
PointerProperty,
StringProperty,
EnumProperty,
BoolProperty,
IntProperty,
FloatProperty,
CollectionProperty,
)
from typing import TYPE_CHECKING, Literal
# Import from other modules
try:
from .task import TaskResource, TaskProduct, get_date_source_items
from .filter import BIMTaskFilterProperties, SavedFilterSet
except ImportError:
# Fallback for when running from the original location
from .task import TaskResource, TaskProduct, get_date_source_items
from .filter import BIMTaskFilterProperties, SavedFilterSet
# ============================================================================
# SCHEDULE CALLBACK FUNCTIONS
# ============================================================================
def update_variance_calculation(self, context):
"""Callback when chronogram types change - does NOT automatically calculate variance."""
# FIXED: Remove automatic calculation when changing chronogram type
# User must press Calculate button to calculate variance
pass
def update_active_work_schedule_id(self, context):
"""
Callback que se ejecuta cuando cambia el schedule activo.
Guarda automáticamente los perfiles del schedule anterior y carga los del nuevo.
"""
try:
import bonsai.tool as tool
# DEBUG: Check that the callback is running
current_ws_id = getattr(self, 'active_work_schedule_id', 0)
previous_ws_id = getattr(context.scene, '_previous_work_schedule_id', 0)
print(f"🔄 DEBUG: Callback ejecutado - Cambio de WS {previous_ws_id}{current_ws_id}")
# Skip if it's the same schedule or invalid
if current_ws_id == previous_ws_id or current_ws_id <= 0:
return
# Save current state
context.scene['_previous_work_schedule_id'] = current_ws_id
print(f"[DEBUG] DEBUG: _previous_work_schedule_id guardado: {current_ws_id}")
# Defer the heavy loading operations
def deferred_load():
try:
print(f"🔄 Loading tasks for work schedule: {current_ws_id}")
core.load_task_tree(tool.Sequence, work_schedule=tool.Ifc.get().by_id(current_ws_id))
print(f"[DEBUG] Tasks loaded for work schedule: {current_ws_id}")
except Exception as e:
print(f"[ERROR] Error loading tasks for work schedule {current_ws_id}: {e}")
return None
bpy.app.timers.register(deferred_load, first_interval=0.1)
except Exception as e:
print(f"[ERROR] Error in update_active_work_schedule_id: {e}")
def update_active_task_index(self, context):
"""
Updates active task index, synchronizes colortypes,
and selects associated 3D objects in the viewport (for single click).
"""
task_ifc = tool.Sequence.get_highlighted_task()
self.highlighted_task_id = task_ifc.id() if task_ifc else 0
tool.Sequence.update_task_ICOM(task_ifc)
# Import pset data module
import bonsai.bim.module.pset.data
bonsai.bim.module.pset.data.refresh()
if self.editing_task_type == "SEQUENCE":
return
tprops = tool.Sequence.get_task_tree_props()
if not tprops.tasks or self.active_task_index >= len(tprops.tasks):
return
task = tprops.tasks[self.active_task_index]
# --- START: Automatic synchronization ---
try:
# Import UnifiedColorTypeManager from animation module
from .animation import UnifiedColorTypeManager
# Sync DEFAULT group (only if no custom groups exist)
user_groups = UnifiedColorTypeManager.get_user_created_groups(context)
if not user_groups:
UnifiedColorTypeManager.sync_default_group_to_predefinedtype(context, task)
print(f"[DEBUG] Task {task.ifc_definition_id}: DEFAULT group synchronized")
# Load active animation colortype group colortypes (only if selected)
anim_props = tool.Sequence.get_animation_props()
selected_group = getattr(anim_props, "task_colortype_group_selector", "")
if selected_group and selected_group != "DEFAULT":
UnifiedColorTypeManager.load_colortypes_into_collection(anim_props, context, selected_group)
print(f"[DEBUG] Animation colortypes loaded for group: {selected_group}")
except Exception as e:
print(f"[WARNING] Error in automatic colortype synchronization: {e}")
# --- 3D SELECTION LOGIC FOR SINGLE CLICK ---
props = tool.Sequence.get_work_schedule_props()
if props.should_select_3d_on_task_click:
if not task_ifc:
try:
import bpy
bpy.ops.object.select_all(action='DESELECT')
except RuntimeError:
# Occurs if we're not in object mode, safe to ignore
pass
return
try:
import bpy
outputs = tool.Sequence.get_task_outputs(task_ifc)
# Deselect everything first
if bpy.context.view_layer.objects.active:
bpy.ops.object.mode_set(mode='OBJECT')
bpy.ops.object.select_all(action='DESELECT')
if outputs:
objects_to_select = [tool.Ifc.get_object(p) for p in outputs if tool.Ifc.get_object(p)]
if objects_to_select:
for obj in objects_to_select:
# Make sure object is visible and selectable
obj.hide_set(False)
obj.hide_select = False
obj.select_set(True)
# Set the first object as active
bpy.context.view_layer.objects.active = objects_to_select[0]
print(f"🎯 3D Task: Selected {len(objects_to_select)} objects for task '{task_ifc.Name or task_ifc.id()}'")
except Exception as e:
print(f"[WARNING] Error in 3D selection: {e}")
def update_work_schedule_predefined_type(self: "BIMWorkScheduleProperties", context: bpy.types.Context) -> None:
"""Se ejecuta cuando cambia el tipo de schedule - NO limpiar automáticamente"""
try:
print(f"🔄 Work schedule predefined type changed to: {self.work_schedule_predefined_types}")
print("️ Variance colors will remain active - use Clear Variance button to reset")
except Exception as e:
print(f"[WARNING] Error in update_work_schedule_predefined_type: {e}")
def update_visualisation_start(self: "BIMWorkScheduleProperties", context: bpy.types.Context) -> None:
update_visualisation_start_finish(self, context, "visualisation_start")
def update_visualisation_finish(self: "BIMWorkScheduleProperties", context: bpy.types.Context) -> None:
update_visualisation_start_finish(self, context, "visualisation_finish")
def update_visualisation_start_finish(
self: "BIMWorkScheduleProperties",
context: bpy.types.Context,
startfinish: Literal["visualisation_start", "visualisation_finish"],
) -> None:
startfinish_value = getattr(self, startfinish)
try:
startfinish_datetime = parser.isoparse(startfinish_value)
except Exception:
try:
startfinish_datetime = parser.parse(startfinish_value, dayfirst=True, fuzzy=True)
except Exception:
# If parsing fails, don't crash - just return
return
# Store the parsed datetime back as ISO format
setattr(self, startfinish, startfinish_datetime.isoformat())
# Update frame range when dates change
if self.visualisation_start and self.visualisation_finish:
try:
start_datetime = parser.isoparse(self.visualisation_start)
finish_datetime = parser.isoparse(self.visualisation_finish)
# Calculate duration and frame range
duration_days = (finish_datetime - start_datetime).days
if duration_days > 0:
# Set reasonable frame range based on duration
frame_end = max(250, min(duration_days * 10, 10000))
context.scene.frame_end = frame_end
print(f"[DEBUG] Updated frame range to {frame_end} frames for {duration_days} day duration")
except Exception as e:
print(f"[WARNING] Error updating frame range: {e}")
def update_sort_reversed(self: "BIMWorkScheduleProperties", context: bpy.types.Context) -> None:
print(f"[DEBUG CALLBACK] *** SCHEDULE.PY update_sort_reversed CALLED (THIS SHOULD NOT BE USED) ***")
global _CALLBACK_LOCK
# Prevent recursive execution during restore operations
if _CALLBACK_LOCK:
print(f"[DEBUG] schedule.py update_sort_reversed: SKIPPED - callback locked during restore")
return
if self.active_work_schedule_id:
try:
_CALLBACK_LOCK = True
print(f"[DEBUG] schedule.py update_sort_reversed: Starting - taking snapshot")
snapshot_all_ui_state(context)
print(f"[DEBUG] schedule.py update_sort_reversed: Loading task tree")
core.load_task_tree(
tool.Sequence,
work_schedule=tool.Ifc.get().by_id(self.active_work_schedule_id),
)
# MISSING: load_task_properties() - this might be the key difference!
tool.Sequence.load_task_properties()
print(f"[DEBUG] schedule.py update_sort_reversed: Restoring snapshot")
restore_all_ui_state(context)
# FORCE a delayed restore to ensure it sticks
def delayed_restore():
print(f"[DEBUG] schedule.py update_sort_reversed: DELAYED restore attempt")
restore_all_ui_state(context)
global _CALLBACK_LOCK
_CALLBACK_LOCK = False # Unlock after delayed restore
return None
import bpy
bpy.app.timers.register(delayed_restore, first_interval=0.1)
print(f"[DEBUG] schedule.py update_sort_reversed: Completed")
except Exception as e:
_CALLBACK_LOCK = False # Unlock on error
print(f"[ERROR] schedule.py update_sort_reversed failed: {e}")
raise
def update_filter_by_active_schedule(self: "BIMWorkScheduleProperties", context: bpy.types.Context) -> None:
if obj := context.active_object:
product = tool.Ifc.get_entity(obj)
assert product
core.load_product_related_tasks(tool.Sequence, product=product)
def update_date_source_type(self, context):
"""
Simple callback when the user changes schedule type.
Only updates date range using Guess functionality.
"""
try:
print(f"📅 Date source changed to: {self.date_source_type}")
# Store previous dates for sync animation
previous_start = self.visualisation_start
previous_finish = self.visualisation_finish
# Update date range for the new schedule type using Guess
bpy.ops.bim.guess_date_range('INVOKE_DEFAULT', work_schedule=self.active_work_schedule_id)
# Call sync animation if it exists
try:
bpy.ops.bim.sync_animation_by_date(
'INVOKE_DEFAULT',
previous_start_date=previous_start,
previous_finish_date=previous_finish
)
except Exception as e:
print(f"[WARNING] Animation sync failed: {e}")
except Exception as e:
print(f"[ERROR] update_date_source_type: Error: {e}")
import traceback
traceback.print_exc()
# Helper functions for enums
def update_active_task_outputs(self, context):
"""Update callback for nested task outputs"""
task_ifc = tool.Sequence.get_highlighted_task()
if task_ifc:
tool.Sequence.update_task_outputs(task_ifc, show_nested=self.show_nested_outputs)
def update_active_task_resources(self, context):
"""Update callback for nested task resources"""
task_ifc = tool.Sequence.get_highlighted_task()
if task_ifc:
tool.Sequence.update_task_resources(task_ifc, show_nested=self.show_nested_resources)
def update_active_task_inputs(self, context):
"""Update callback for nested task inputs"""
task_ifc = tool.Sequence.get_highlighted_task()
if task_ifc:
tool.Sequence.update_task_inputs(task_ifc, show_nested=self.show_nested_inputs)
# ============================================================================
# SCHEDULE PROPERTY GROUP CLASSES
# ============================================================================
class WorkPlan(PropertyGroup):
"""Work plan properties"""
name: StringProperty(name="Name")
ifc_definition_id: IntProperty(name="IFC Definition ID")
if TYPE_CHECKING:
name: str
ifc_definition_id: int
class BIMWorkPlanProperties(PropertyGroup):
"""Work plan management properties"""
work_plan_attributes: CollectionProperty(name="Work Plan Attributes", type=Attribute)
editing_type: EnumProperty(
items=[("WORK_PLAN", "Work Plan", ""), ("WORK_SCHEDULE", "Work Schedule", "")],
name="Editing Type"
)
work_plans: CollectionProperty(name="Work Plans", type=WorkPlan)
active_work_plan_index: IntProperty(name="Active Work Plan Index")
active_work_plan_id: IntProperty(name="Active Work Plan Id")
work_schedules: EnumProperty(items=enums_prop.getWorkSchedules, name="Work Schedules")
if TYPE_CHECKING:
work_plan_attributes: bpy.types.bpy_prop_collection_idprop[Attribute]
editing_type: str
work_plans: bpy.types.bpy_prop_collection_idprop[WorkPlan]
active_work_plan_index: int
active_work_plan_id: int
work_schedules: str
class BIMWorkScheduleProperties(PropertyGroup):
"""Main work schedule properties with comprehensive task and animation management"""
# Basic schedule properties
work_schedule_predefined_types: EnumProperty(
items=enums_prop.get_schedule_predefined_types,
name="Predefined Type",
default=None,
update=update_work_schedule_predefined_type
)
object_type: StringProperty(name="Object Type")
durations_attributes: CollectionProperty(name="Durations Attributes", type=ISODuration)
work_calendars: EnumProperty(items=lambda self, context: [], name="Work Calendars") # Will be populated
work_schedule_attributes: CollectionProperty(name="Work Schedule Attributes", type=Attribute)
editing_type: StringProperty(name="Editing Type")
editing_task_type: StringProperty(name="Editing Task Type")
# Active schedule and task management
active_work_schedule_index: IntProperty(name="Active Work Schedules Index")
active_work_schedule_id: IntProperty(name="Active Work Schedules Id", update=update_active_work_schedule_id)
active_task_index: IntProperty(name="Active Task Index", update=update_active_task_index)
active_task_id: IntProperty(name="Active Task Id")
highlighted_task_id: IntProperty(name="Highlighted Task Id")
# Task attributes and editing
task_attributes: CollectionProperty(name="Task Attributes", type=Attribute)
is_task_update_enabled: BoolProperty(name="Is Task Update Enabled", default=True)
# UI toggles and options
should_show_visualisation_ui: BoolProperty(name="Should Show Visualisation UI", default=True, update=callbacks_prop.switch_options)
should_show_task_bar_selection: BoolProperty(name="Add to task bar", default=False)
should_show_snapshot_ui: BoolProperty(name="Should Show Snapshot UI", default=False, update=callbacks_prop.switch_options2)
should_show_column_ui: BoolProperty(name="Should Show Column UI", default=False)
should_show_schedule_baseline_ui: BoolProperty(name="Baselines", default=False)
should_select_3d_on_task_click: BoolProperty(
name="Select 3D on Task Click",
description="Automatically select 3D elements when a task is selected in the list",
default=True
)
# Column management
columns: CollectionProperty(name="Columns", type=Attribute)
active_column_index: IntProperty(name="Active Column Index")
sort_column: StringProperty(name="Sort Column")
is_sort_reversed: BoolProperty(name="Is Sort Reversed", update=update_sort_reversed)
column_types: EnumProperty(
items=[
("IfcTask", "IfcTask", ""),
("IfcTaskTime", "IfcTaskTime", ""),
("Special", "Special", ""),
],
name="Column Types",
)
task_columns: EnumProperty(items=enums_prop.getTaskColumns, name="Task Columns")
task_time_columns: EnumProperty(items=enums_prop.getTaskTimeColumns, name="Task Time Columns")
other_columns: EnumProperty(
items=[
("Controls.Calendar", "Calendar", ""),
],
name="Special Columns",
)
# Column navigation properties
column_start_index: IntProperty(
name="Column Start Index",
description="Starting index for visible columns",
default=0,
min=0
)
columns_per_view: IntProperty(
name="Columns Per View",
description="Maximum number of columns to display at once",
default=5,
min=1,
max=20
)
# Task time and sequence management
active_task_time_id: IntProperty(name="Active Task Time Id")
task_time_attributes: CollectionProperty(name="Task Time Attributes", type=Attribute)
contracted_tasks: StringProperty(name="Contracted Task Items", default="[]")
task_bars: StringProperty(name="Checked Task Items", default="[]")
editing_sequence_type: StringProperty(name="Editing Sequence Type")
active_sequence_id: IntProperty(name="Active Sequence Id")
sequence_attributes: CollectionProperty(name="Sequence Attributes", type=Attribute)
lag_time_attributes: CollectionProperty(name="Time Lag Attributes", type=Attribute)
# Date source and visualization
date_source_type: EnumProperty(
name="Date Source",
description="Choose which set of dates to use for animation and snapshots",
items=[
('SCHEDULE', "Schedule", "Use ScheduleStart and ScheduleFinish dates"),
('ACTUAL', "Actual", "Use ActualStart and ActualFinish dates"),
('EARLY', "Early", "Use EarlyStart and EarlyFinish dates"),
('LATE', "Late", "Use LateStart and LateFinish dates"),
],
default='SCHEDULE',
update=update_date_source_type
)
visualisation_start: StringProperty(name="Visualisation Start", update=update_visualisation_start)
visualisation_finish: StringProperty(name="Visualisation Finish", update=update_visualisation_finish)
# Animation speed and timing
speed_multiplier: FloatProperty(name="Speed Multiplier", default=10000)
speed_animation_duration: StringProperty(name="Speed Animation Duration", default="1 s")
speed_animation_frames: IntProperty(name="Speed Animation Frames", default=24)
speed_real_duration: StringProperty(name="Speed Real Duration", default="1 w")
speed_types: EnumProperty(
items=[
("FRAME_SPEED", "Frame-based", "e.g. 25 frames = 1 real week"),
("DURATION_SPEED", "Duration-based", "e.g. 1 video second = 1 real week"),
("MULTIPLIER_SPEED", "Multiplier", "e.g. 1000 x real life speed"),
],
name="Speed Type",
default="FRAME_SPEED",
)
# Task resources and products
task_resources: CollectionProperty(name="Task Resources", type=TaskResource)
active_task_resource_index: IntProperty(name="Active Task Resource Index")
task_inputs: CollectionProperty(name="Task Inputs", type=TaskProduct)
active_task_input_index: IntProperty(name="Active Task Input Index")
task_outputs: CollectionProperty(name="Task Outputs", type=TaskProduct)
active_task_output_index: IntProperty(name="Active Task Output Index")
product_input_tasks: CollectionProperty(name="Product Task Inputs", type=TaskProduct)
product_output_tasks: CollectionProperty(name="Product Task Outputs", type=TaskProduct)
active_product_output_task_index: IntProperty(name="Active Product Output Task Index")
active_product_input_task_index: IntProperty(name="Active Product Input Task Index")
# Display options for nested items
show_saved_colortypes_section: BoolProperty(name="Show Saved colortypes", default=True)
show_nested_outputs: BoolProperty(name="Show Nested Tasks", default=False, update=update_active_task_outputs)
show_nested_resources: BoolProperty(name="Show Nested Tasks", default=False, update=update_active_task_resources)
show_nested_inputs: BoolProperty(name="Show Nested Tasks", default=False, update=update_active_task_inputs)
# Task management options
enable_reorder: BoolProperty(name="Enable Reorder", default=False)
show_task_operators: BoolProperty(name="Show Task Options", default=True)
filter_by_active_schedule: BoolProperty(
name="Filter By Active Schedule",
default=False,
update=update_filter_by_active_schedule
)
selected_tasks_count: IntProperty(name="Selected Tasks Count", default=0)
# Lookahead analysis
last_lookahead_window: StringProperty(
name="Last Lookahead Window",
description="Stores the last selected lookahead time window to allow re-applying it automatically.",
default=""
)
# Filtering and saved filter sets
filters: PointerProperty(type=BIMTaskFilterProperties)
saved_filter_sets: CollectionProperty(type=SavedFilterSet)
active_saved_filter_set_index: IntProperty()
# Variance analysis
variance_source_a: EnumProperty(
name="Compare",
items=get_date_source_items,
default=0,
description="The baseline date set for comparison",
update=update_variance_calculation,
)
variance_source_b: EnumProperty(
name="With",
items=get_date_source_items,
default=1,
description="The date set to compare against the baseline",
update=update_variance_calculation,
)
if TYPE_CHECKING:
saved_filter_sets: bpy.types.bpy_prop_collection_idprop[SavedFilterSet]
active_saved_filter_set_index: int
work_schedule_predefined_types: str
object_type: str
durations_attributes: bpy.types.bpy_prop_collection_idprop[ISODuration]
work_calendars: str
work_schedule_attributes: bpy.types.bpy_prop_collection_idprop[Attribute]
editing_type: str
editing_task_type: str
active_work_schedule_index: int
active_work_schedule_id: int
active_task_index: int
active_task_id: int
last_lookahead_window: str
date_source_type: str
highlighted_task_id: int
task_attributes: bpy.types.bpy_prop_collection_idprop[Attribute]
should_show_visualisation_ui: bool
should_show_task_bar_selection: bool
should_show_snapshot_ui: bool
should_show_column_ui: bool
should_show_schedule_baseline_ui: bool
should_select_3d_on_task_click: bool
columns: bpy.types.bpy_prop_collection_idprop[Attribute]
active_column_index: int
sort_column: str
is_sort_reversed: bool
column_types: str
task_columns: str
task_time_columns: str
other_columns: str
column_start_index: int
columns_per_view: int
active_task_time_id: int
task_time_attributes: bpy.types.bpy_prop_collection_idprop[Attribute]
contracted_tasks: str
task_bars: str
is_task_update_enabled: bool
editing_sequence_type: str
active_sequence_id: int
sequence_attributes: bpy.types.bpy_prop_collection_idprop[Attribute]
lag_time_attributes: bpy.types.bpy_prop_collection_idprop[Attribute]
visualisation_start: str
visualisation_finish: str
speed_multiplier: float
speed_animation_duration: str
speed_animation_frames: int
speed_real_duration: str
speed_types: str
task_resources: bpy.types.bpy_prop_collection_idprop[TaskResource]
active_task_resource_index: int
task_inputs: bpy.types.bpy_prop_collection_idprop[TaskProduct]
active_task_input_index: int
task_outputs: bpy.types.bpy_prop_collection_idprop[TaskProduct]
active_task_output_index: int
product_input_tasks: bpy.types.bpy_prop_collection_idprop[TaskProduct]
product_output_tasks: bpy.types.bpy_prop_collection_idprop[TaskProduct]
active_product_output_task_index: int
active_product_input_task_index: int
show_saved_colortypes_section: bool
show_nested_outputs: bool
show_nested_resources: bool
show_nested_inputs: bool
enable_reorder: bool
show_task_operators: bool
filter_by_active_schedule: bool
selected_tasks_count: int
variance_source_a: str
variance_source_b: str
# ============================================================================
# ADDITIONAL SCHEDULE HELPER FUNCTIONS
# ============================================================================
def getWorkCalendars(self, context):
"""Work calendars enum function"""
if not SequenceData.is_loaded:
SequenceData.load()
return SequenceData.data["work_calendars_enum"]
@@ -0,0 +1,335 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import bpy
from bpy.types import PropertyGroup
from bpy.props import (
PointerProperty, StringProperty, EnumProperty,
BoolProperty, IntProperty, FloatProperty, CollectionProperty
)
from typing import TYPE_CHECKING, Literal, get_args
from . import callbacks_prop as callbacks
from . import enums_prop as enums
from .task_prop import BIMTaskFilterProperties, SavedFilterSet, TaskResource, TaskProduct, WorkPlanEditingType
from bonsai.bim.prop import Attribute, ISODuration
class WorkPlan(PropertyGroup):
name: StringProperty(name="Name")
ifc_definition_id: IntProperty(name="IFC Definition ID")
if TYPE_CHECKING:
name: str
ifc_definition_id: int
class BIMWorkPlanProperties(PropertyGroup):
work_plan_attributes: CollectionProperty(name="Work Plan Attributes", type=Attribute)
editing_type: EnumProperty(
items=[(i, i, "") for i in get_args(WorkPlanEditingType)],
)
work_plans: CollectionProperty(name="Work Plans", type=WorkPlan)
active_work_plan_index: IntProperty(name="Active Work Plan Index")
active_work_plan_id: IntProperty(name="Active Work Plan Id")
work_schedules: EnumProperty(items=enums.getWorkSchedules, name="Work Schedules")
if TYPE_CHECKING:
work_plan_attributes: bpy.types.bpy_prop_collection_idprop[Attribute]
editing_type: WorkPlanEditingType
work_plans: bpy.types.bpy_prop_collection_idprop[WorkPlan]
active_work_plan_index: int
active_work_plan_id: int
work_schedules: str
class WorkCalendar(PropertyGroup):
name: StringProperty(name="Name")
ifc_definition_id: IntProperty(name="IFC Definition ID")
if TYPE_CHECKING:
name: str
ifc_definition_id: int
class RecurrenceComponent(PropertyGroup):
name: StringProperty(name="Name")
is_specified: BoolProperty(name="Is Specified")
if TYPE_CHECKING:
name: str
is_specified: bool
class BIMWorkCalendarProperties(PropertyGroup):
work_calendar_attributes: CollectionProperty(name="Work Calendar Attributes", type=Attribute)
work_time_attributes: CollectionProperty(name="Work Time Attributes", type=Attribute)
editing_type: StringProperty(name="Editing Type")
active_work_calendar_id: IntProperty(name="Active Work Calendar Id")
active_work_time_id: IntProperty(name="Active Work Time Id")
day_components: CollectionProperty(name="Day Components", type=RecurrenceComponent)
weekday_components: CollectionProperty(name="Weekday Components", type=RecurrenceComponent)
month_components: CollectionProperty(name="Month Components", type=RecurrenceComponent)
position: IntProperty(name="Position")
interval: IntProperty(name="Recurrence Interval")
occurrences: IntProperty(name="Occurs N Times")
recurrence_types: EnumProperty(
items=[
("DAILY", "Daily", "e.g. Every day"),
("WEEKLY", "Weekly", "e.g. Every Friday"),
("MONTHLY_BY_DAY_OF_MONTH", "Monthly on Specified Date", "e.g. Every 2nd of each Month"),
("MONTHLY_BY_POSITION", "Monthly on Specified Weekday", "e.g. Every 1st Friday of each Month"),
("YEARLY_BY_DAY_OF_MONTH", "Yearly on Specified Date", "e.g. Every 2nd of October"),
("YEARLY_BY_POSITION", "Yearly on Specified Weekday", "e.g. Every 1st Friday of October"),
],
name="Recurrence Types",
)
start_time: StringProperty(name="Start Time")
end_time: StringProperty(name="End Time")
if TYPE_CHECKING:
work_calendar_attributes: bpy.types.bpy_prop_collection_idprop[Attribute]
work_time_attributes: bpy.types.bpy_prop_collection_idprop[Attribute]
editing_type: str
active_work_calendar_id: int
active_work_time_id: int
day_components: bpy.types.bpy_prop_collection_idprop[RecurrenceComponent]
weekday_components: bpy.types.bpy_prop_collection_idprop[RecurrenceComponent]
month_components: bpy.types.bpy_prop_collection_idprop[RecurrenceComponent]
position: int
interval: int
occurrences: int
recurrence_types: str
start_time: str
end_time: str
class BIMWorkScheduleProperties(PropertyGroup):
work_schedule_predefined_types: EnumProperty(
items=enums.get_schedule_predefined_types, name="Predefined Type", default=None, update=callbacks.update_work_schedule_predefined_type
)
object_type: StringProperty(name="Object Type")
durations_attributes: CollectionProperty(name="Durations Attributes", type=ISODuration)
work_calendars: EnumProperty(items=enums.getWorkCalendars, name="Work Calendars")
work_schedule_attributes: CollectionProperty(name="Work Schedule Attributes", type=Attribute)
editing_type: StringProperty(name="Editing Type")
editing_task_type: StringProperty(name="Editing Task Type")
active_work_schedule_index: IntProperty(name="Active Work Schedules Index")
active_work_schedule_id: IntProperty(name="Active Work Schedules Id", update=callbacks.update_active_work_schedule_id)
active_task_index: IntProperty(name="Active Task Index", update=callbacks.update_active_task_index)
active_task_id: IntProperty(name="Active Task Id")
highlighted_task_id: IntProperty(name="Highlited Task Id")
task_attributes: CollectionProperty(name="Task Attributes", type=Attribute)
should_show_visualisation_ui: BoolProperty(name="Should Show Visualisation UI", default=True, update=callbacks.switch_options)
should_show_task_bar_selection: BoolProperty(name="Add to task bar", default=False)
should_show_snapshot_ui: BoolProperty(name="Should Show Snapshot UI", default=False, update=callbacks.switch_options2)
should_show_column_ui: BoolProperty(name="Should Show Column UI", default=False)
columns: CollectionProperty(name="Columns", type=Attribute)
active_column_index: IntProperty(name="Active Column Index")
sort_column: StringProperty(name="Sort Column")
is_sort_reversed: BoolProperty(name="Is Sort Reversed", update=callbacks.update_sort_reversed)
column_types: EnumProperty(
items=[
("IfcTask", "IfcTask", ""),
("IfcTaskTime", "IfcTaskTime", ""),
("Special", "Special", ""),
],
name="Column Types",
)
task_columns: EnumProperty(items=enums.getTaskColumns, name="Task Columns")
task_time_columns: EnumProperty(items=enums.getTaskTimeColumns, name="Task Time Columns")
other_columns: EnumProperty(
items=[
("Controls.Calendar", "Calendar", ""),
],
name="Special Columns",
)
active_task_time_id: IntProperty(name="Active Task Time Id")
task_time_attributes: CollectionProperty(name="Task Time Attributes", type=Attribute)
contracted_tasks: StringProperty(name="Contracted Task Items", default="[]")
task_bars: StringProperty(name="Checked Task Items", default="[]")
is_task_update_enabled: BoolProperty(name="Is Task Update Enabled", default=True)
editing_sequence_type: StringProperty(name="Editing Sequence Type")
active_sequence_id: IntProperty(name="Active Sequence Id")
date_source_type: EnumProperty(
name="Date Source",
description="Choose which set of dates to use for animation and snapshots",
items=[
('SCHEDULE', "Schedule", "Use ScheduleStart and ScheduleFinish dates"),
('ACTUAL', "Actual", "Use ActualStart and ActualFinish dates"),
('EARLY', "Early", "Use EarlyStart and EarlyFinish dates"),
('LATE', "Late", "Use LateStart and LateFinish dates"),
],
default='SCHEDULE'
)
last_lookahead_window: StringProperty(
name="Last Lookahead Window",
description="Stores the last selected lookahead time window to allow re-applying it automatically.",
default=""
)
sequence_attributes: CollectionProperty(name="Sequence Attributes", type=Attribute)
lag_time_attributes: CollectionProperty(name="Time Lag Attributes", type=Attribute)
visualisation_start: StringProperty(name="Visualisation Start", update=callbacks.update_visualisation_start)
visualisation_finish: StringProperty(name="Visualisation Finish", update=callbacks.update_visualisation_finish)
speed_multiplier: FloatProperty(name="Speed Multiplier", default=10000)
speed_animation_duration: StringProperty(name="Speed Animation Duration", default="1 s")
speed_animation_frames: IntProperty(name="Speed Animation Frames", default=24)
speed_real_duration: StringProperty(name="Speed Real Duration", default="1 w")
speed_types: EnumProperty(
items=[
("FRAME_SPEED", "Frame-based", "e.g. 25 frames = 1 real week"),
("DURATION_SPEED", "Duration-based", "e.g. 1 video second = 1 real week"),
("MULTIPLIER_SPEED", "Multiplier", "e.g. 1000 x real life speed"),
],
name="Speed Type",
default="FRAME_SPEED",
)
task_resources: CollectionProperty(name="Task Resources", type=TaskResource)
active_task_resource_index: IntProperty(name="Active Task Resource Index")
task_inputs: CollectionProperty(name="Task Inputs", type=TaskProduct)
active_task_input_index: IntProperty(name="Active Task Input Index")
task_outputs: CollectionProperty(name="Task Outputs", type=TaskProduct)
active_task_output_index: IntProperty(name="Active Task Output Index")
show_saved_colortypes_section: BoolProperty(name="Show Saved colortypes", default=True)
show_nested_outputs: BoolProperty(name="Show Nested Tasks", default=False, update=callbacks.update_active_task_outputs)
show_nested_resources: BoolProperty(name="Show Nested Tasks", default=False, update=callbacks.update_active_task_resources)
show_nested_inputs: BoolProperty(name="Show Nested Tasks", default=False, update=callbacks.update_active_task_inputs)
product_input_tasks: CollectionProperty(name="Product Task Inputs", type=TaskProduct)
product_output_tasks: CollectionProperty(name="Product Task Outputs", type=TaskProduct)
active_product_output_task_index: IntProperty(name="Active Product Output Task Index")
active_product_input_task_index: IntProperty(name="Active Product Input Task Index")
enable_reorder: BoolProperty(name="Enable Reorder", default=False)
show_task_operators: BoolProperty(name="Show Task Options", default=True)
should_show_schedule_baseline_ui: BoolProperty(name="Baselines", default=False)
should_select_3d_on_task_click: BoolProperty(
name="Select 3D on Task Click",
description="Automatically select 3D elements when a task is selected in the list",
default=True
)
filter_by_active_schedule: BoolProperty(
name="Filter By Active Schedule", default=False, update=callbacks.update_filter_by_active_schedule
)
# New property to show selected tasks count
selected_tasks_count: IntProperty(name="Selected Tasks Count", default=0)
# Property that will contain the filter configuration
filters: PointerProperty(type=BIMTaskFilterProperties)
saved_filter_sets: CollectionProperty(type=SavedFilterSet)
active_saved_filter_set_index: IntProperty()
variance_source_a: EnumProperty(
name="Compare",
items=enums.get_date_source_items,
default=0,
description="The baseline date set for comparison",
update=callbacks.update_variance_calculation,
)
variance_source_b: EnumProperty(
name="With",
items=enums.get_date_source_items,
default=1,
description="The date set to compare against the baseline",
update=callbacks.update_variance_calculation,
)
# --- START COLUMN NAVIGATION PROPERTIES ---
column_start_index: IntProperty(
name="Column Start Index",
description="Starting index for visible columns",
default=0,
min=0
)
columns_per_view: IntProperty(
name="Columns Per View",
description="Maximum number of columns to display at once",
default=5,
min=1,
max=20
)
# --- END COLUMN NAVIGATION PROPERTIES ---
if TYPE_CHECKING:
saved_filter_sets: bpy.types.bpy_prop_collection_idprop[SavedFilterSet]
active_saved_filter_set_index: int
work_schedule_predefined_types: str
object_type: str
durations_attributes: bpy.types.bpy_prop_collection_idprop[ISODuration]
work_calendars: str
work_schedule_attributes: bpy.types.bpy_prop_collection_idprop[Attribute]
editing_type: str
editing_task_type: str
active_work_schedule_index: int
active_work_schedule_id: int
active_task_index: int
active_task_id: int
last_lookahead_window: str
date_source_type: str
highlighted_task_id: int
task_attributes: bpy.types.bpy_prop_collection_idprop[Attribute]
should_show_visualisation_ui: bool
should_show_task_bar_selection: bool
should_show_snapshot_ui: bool
should_show_column_ui: bool
columns: bpy.types.bpy_prop_collection_idprop[Attribute]
active_column_index: int
sort_column: str
is_sort_reversed: bool
column_types: str
task_columns: str
task_time_columns: str
other_columns: str
active_task_time_id: int
task_time_attributes: bpy.types.bpy_prop_collection_idprop[Attribute]
contracted_tasks: str
task_bars: str
is_task_update_enabled: bool
editing_sequence_type: str
active_sequence_id: int
sequence_attributes: bpy.types.bpy_prop_collection_idprop[Attribute]
lag_time_attributes: bpy.types.bpy_prop_collection_idprop[Attribute]
visualisation_start: str
visualisation_finish: str
speed_multiplier: float
speed_animation_duration: str
speed_animation_frames: int
speed_real_duration: str
speed_types: str
task_resources: bpy.types.bpy_prop_collection_idprop[TaskResource]
active_task_resource_index: int
task_inputs: bpy.types.bpy_prop_collection_idprop[TaskProduct]
active_task_input_index: int
task_outputs: bpy.types.bpy_prop_collection_idprop[TaskProduct]
active_task_output_index: int
show_nested_outputs: bool
show_nested_resources: bool
show_nested_inputs: bool
product_input_tasks: bpy.types.bpy_prop_collection_idprop[TaskProduct]
product_output_tasks: bpy.types.bpy_prop_collection_idprop[TaskProduct]
active_product_output_task_index: int
active_product_input_task_index: int
enable_reorder: bool
show_task_operators: bool
should_show_schedule_baseline_ui: bool
filter_by_active_schedule: bool
selected_tasks_count: int
filters: 'BIMTaskFilterProperties'
variance_source_a: str
variance_source_b: str
@@ -0,0 +1,547 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import bpy
import ifcopenshell.api
import ifcopenshell.api.sequence
import ifcopenshell.util.attribute
import ifcopenshell.util.date
import bonsai.tool as tool
import bonsai.core.sequence as core
from bonsai.bim.module.sequence.data import SequenceData, refresh as refresh_sequence_data
from bpy.types import PropertyGroup
from bpy.props import (
PointerProperty,
StringProperty,
EnumProperty,
BoolProperty,
IntProperty,
FloatProperty,
FloatVectorProperty,
CollectionProperty,
)
from typing import TYPE_CHECKING, Literal
# Import functions from animation module
try:
from .animation import (
get_animation_color_schemes_items,
get_custom_group_colortype_items,
UnifiedColorTypeManager
)
except ImportError:
# Fallback for when running from the original location
from .animation import (
get_animation_color_schemes_items,
get_custom_group_colortype_items,
UnifiedColorTypeManager
)
# ============================================================================
# TASK CALLBACK FUNCTIONS
# ============================================================================
def update_task_checkbox_selection(self, context):
"""
Callback that is executed when checking/unchecking a checkbox.
Uses a timer to execute the 3D selection logic safely.
"""
def apply_selection():
try:
# Get the properties to check if 3D selection is active
props = tool.Sequence.get_work_schedule_props()
if props.should_select_3d_on_task_click:
# Execute the 3D selection logic
ids = tool.Sequence.get_selected_task_ids()
if ids:
bpy.ops.bim.select_task_related_products(task_ids=list(ids))
print(f"[DEBUG] 3D Selection applied for {len(ids)} tasks")
except Exception as e:
print(f"[ERROR] Error in 3D selection: {e}")
return None
# Use a timer to ensure this runs safely in the next context update
bpy.app.timers.register(apply_selection, first_interval=0.1)
def updateTaskName(self: "Task", context: bpy.types.Context) -> None:
props = tool.Sequence.get_work_schedule_props()
if not props.is_task_update_enabled or self.name == "Unnamed":
return
ifc_file = tool.Ifc.get()
ifcopenshell.api.sequence.edit_task(
ifc_file,
task=ifc_file.by_id(self.ifc_definition_id),
attributes={"Name": self.name},
)
SequenceData.load()
def updateTaskIdentification(self: "Task", context: bpy.types.Context) -> None:
props = tool.Sequence.get_work_schedule_props()
if not props.is_task_update_enabled or self.identification == "XXX":
return
ifc_file = tool.Ifc.get()
ifcopenshell.api.sequence.edit_task(
ifc_file,
task=ifc_file.by_id(self.ifc_definition_id),
attributes={"Identification": self.identification},
)
SequenceData.load()
def updateTaskTimeStart(self: "Task", context: bpy.types.Context) -> None:
updateTaskTimeDateTime(self, context, "start", "Schedule")
def updateTaskTimeFinish(self: "Task", context: bpy.types.Context) -> None:
updateTaskTimeDateTime(self, context, "finish", "Schedule")
def updateTaskTimeActualStart(self: "Task", context: bpy.types.Context) -> None:
updateTaskTimeDateTime(self, context, "actual_start", "Actual")
def updateTaskTimeActualFinish(self: "Task", context: bpy.types.Context) -> None:
updateTaskTimeDateTime(self, context, "actual_finish", "Actual")
def updateTaskTimeEarlyStart(self: "Task", context: bpy.types.Context) -> None:
updateTaskTimeDateTime(self, context, "early_start", "Early")
def updateTaskTimeEarlyFinish(self: "Task", context: bpy.types.Context) -> None:
updateTaskTimeDateTime(self, context, "early_finish", "Early")
def updateTaskTimeLateStart(self: "Task", context: bpy.types.Context) -> None:
updateTaskTimeDateTime(self, context, "late_start", "Late")
def updateTaskTimeLateFinish(self: "Task", context: bpy.types.Context) -> None:
updateTaskTimeDateTime(self, context, "late_finish", "Late")
def updateTaskTimeDateTime(
self: "Task",
context: bpy.types.Context,
prop_name: str,
ifc_date_type: Literal["Schedule", "Actual", "Early", "Late"],
) -> None:
props = tool.Sequence.get_work_schedule_props()
if not props.is_task_update_enabled:
return
date_string = getattr(self, prop_name)
# Handle empty or None values
if not date_string or date_string in ["-", "null", "NULL", ""]:
date_value = None
else:
try:
parsed_date = ifcopenshell.util.date.parse_datetime(date_string)
if parsed_date is None:
self.setattr(prop_name, "-")
return
date_value = parsed_date
except Exception:
self.setattr(prop_name, "-")
return
# Determine IFC attribute name
if prop_name.endswith("_start"):
ifc_prop_name = "ScheduleStart" if "Schedule" in ifc_date_type else f"{ifc_date_type}Start"
elif prop_name.endswith("_finish"):
ifc_prop_name = "ScheduleFinish" if "Schedule" in ifc_date_type else f"{ifc_date_type}Finish"
else:
# For backward compatibility with "start" and "finish"
ifc_prop_name = f"Schedule{prop_name.capitalize()}"
ifc_file = tool.Ifc.get()
task = ifc_file.by_id(self.ifc_definition_id)
task_time = ifcopenshell.util.date.get_task_time_by_type(task, ifc_date_type)
if task_time:
# Update existing task time
ifcopenshell.api.sequence.edit_task_time(
ifc_file,
task_time=task_time,
attributes={ifc_prop_name: date_value},
)
elif date_value:
# Create new task time if date value is provided
ifcopenshell.api.sequence.add_task_time(
ifc_file,
task=task,
attributes={
"TaskTimeType": ifc_date_type.upper(),
ifc_prop_name: date_value,
},
)
# If date_value is None and no task_time exists, do nothing
SequenceData.load()
def updateTaskDuration(self: "Task", context: bpy.types.Context) -> None:
props = tool.Sequence.get_work_schedule_props()
if not props.is_task_update_enabled:
return
if self.duration == "-":
return
duration = ifcopenshell.util.date.parse_duration(self.duration)
if not duration:
self.duration = "-"
return
ifc_file = tool.Ifc.get()
task = ifc_file.by_id(self.ifc_definition_id)
task_time = task.TaskTime
if task_time:
ifcopenshell.api.sequence.edit_task_time(
ifc_file,
task_time=task_time,
attributes={"ScheduleDuration": duration},
)
else:
ifcopenshell.api.sequence.add_task_time(
ifc_file,
task=task,
attributes={"ScheduleDuration": duration},
)
SequenceData.load()
def updateTaskPredefinedType(self: "Task", context: bpy.types.Context) -> None:
"""Callback when PredefinedType changes - auto-syncs to DEFAULT group"""
props = tool.Sequence.get_work_schedule_props()
if not props.is_task_update_enabled:
return
try:
# The IFC attribute editing logic is already handled by the attribute's callback.
# This callback should only be concerned with UI synchronization.
# 1. Get the new PredefinedType value directly from the task.
# This is more reliable than reading from cached data during edit.
ifc_file = tool.Ifc.get()
task_ifc = ifc_file.by_id(self.ifc_definition_id)
current_predefined_type = getattr(task_ifc, 'PredefinedType', 'NOTDEFINED') or 'NOTDEFINED'
print(f"🔄 PredefinedType changed callback: Task {self.ifc_definition_id}{current_predefined_type}")
# 2. Sync to DEFAULT group (only if no custom groups exist to avoid confusion)
user_groups = UnifiedColorTypeManager.get_user_created_groups(context)
if not user_groups:
UnifiedColorTypeManager.sync_default_group_to_predefinedtype(context, self)
print(f"[DEBUG] Task {self.ifc_definition_id} DEFAULT group synced to {current_predefined_type}")
else:
print(f"[WARNING] Custom groups detected - DEFAULT sync skipped for task {self.ifc_definition_id}")
# 3. Force UI refresh to reflect changes
refresh_sequence_data()
except Exception as e:
print(f"[ERROR] Error in updateTaskPredefinedType: {e}")
import traceback
traceback.print_exc()
def updateAssignedResourceName(self, context):
pass
def updateAssignedResourceUsage(self: "TaskResource", context: object) -> None:
props = tool.Resource.get_resource_props()
if not props.is_resource_update_enabled:
return
if not self.schedule_usage:
return
resource = tool.Ifc.get().by_id(self.ifc_definition_id)
if resource.Usage and resource.Usage.ScheduleUsage == self.schedule_usage:
return
tool.Resource.run_edit_resource_time(resource, attributes={"ScheduleUsage": self.schedule_usage})
tool.Sequence.load_task_properties()
def update_task_bar_list(self: "Task", context: bpy.types.Context) -> None:
props = tool.Sequence.get_work_schedule_props()
if not props.is_task_update_enabled:
return
# Add or remove from the list
if self.has_bar_visual:
tool.Sequence.add_task_bar(self.ifc_definition_id)
else:
tool.Sequence.remove_task_bar(self.ifc_definition_id)
# Force viewport refresh to update bars
for area in bpy.context.screen.areas:
if area.type == 'VIEW_3D':
area.tag_redraw()
def update_use_active_colortype_group(self: "Task", context):
"""Updates usage of the active colortype group"""
try:
anim_props = tool.Sequence.get_animation_props()
selected_group = getattr(anim_props, "task_colortype_group_selector", "")
# CRITICAL: Use the group selected in task_colortype_group_selector, NOT ColorType_groups
if selected_group and selected_group != "DEFAULT":
entry = UnifiedColorTypeManager.sync_task_colortypes(context, self, selected_group)
if entry:
entry.enabled = bool(self.use_active_colortype_group)
print(f"[DEBUG] Task {self.ifc_definition_id}: Active group '{selected_group}' enabled = {entry.enabled}")
# NEW FUNCTIONALITY: Auto-sync animation_color_schemes
if entry.enabled and entry.selected_colortype:
from .animation import safe_set_animation_color_schemes
safe_set_animation_color_schemes(self, entry.selected_colortype)
print(f"🔄 AUTO-SYNC: animation_color_schemes = '{entry.selected_colortype}'")
else:
print(f"[ERROR] Could not sync task {self.ifc_definition_id} with group '{selected_group}'")
else:
print(f"[WARNING] No valid custom group selected: '{selected_group}'")
except Exception as e:
print(f"[ERROR] Error in update_use_active_colortype_group: {e}")
def update_selected_colortype_in_active_group(self: "Task", context):
"""Updates the selected colortype in the active group"""
try:
# Validate that the current value is not a numeric string or invalid
current_value = self.selected_colortype_in_active_group
# Get valid enum items to check against
valid_items = get_custom_group_colortype_items(self, context)
valid_values = [item[0] for item in valid_items]
# Check for invalid values
if current_value and (current_value.isdigit() or current_value not in valid_values):
print(f"🚫 Invalid colortype value '{current_value}' detected, correcting...")
# Don't assign anything - let the enum system handle it
return
# Get animation properties to determine active group
anim_props = tool.Sequence.get_animation_props()
selected_group = getattr(anim_props, "task_colortype_group_selector", "")
if not selected_group or selected_group == "DEFAULT":
print(f"[WARNING] No valid custom group selected for task {self.ifc_definition_id}")
return
# Update the mapping in the task's colortype_group_choices
entry = UnifiedColorTypeManager.sync_task_colortypes(context, self, selected_group)
if entry:
entry.selected_colortype = current_value
print(f"[DEBUG] Task {self.ifc_definition_id}: Group '{selected_group}' colortype = '{current_value}'")
# NEW FUNCTIONALITY: Auto-sync with animation_color_schemes if the group is active
if entry.enabled and current_value:
from .animation import safe_set_animation_color_schemes
safe_set_animation_color_schemes(self, current_value)
print(f"🔄 AUTO-SYNC: animation_color_schemes = '{current_value}'")
except Exception as e:
print(f"[ERROR] Error in update_selected_colortype_in_active_group: {e}")
def update_variance_color_mode(self, context):
"""Updates variance color mode visualization"""
try:
wprops = tool.Sequence.get_work_schedule_props()
if self.is_variance_color_selected:
# Add task to variance selection if not already present
if str(self.ifc_definition_id) not in wprops.variance_selected_tasks:
task_item = wprops.variance_selected_tasks.add()
task_item.task_id = str(self.ifc_definition_id)
print(f"[DEBUG] Added task {self.ifc_definition_id} to variance color selection")
else:
# Remove task from variance selection
to_remove = []
for i, item in enumerate(wprops.variance_selected_tasks):
if item.task_id == str(self.ifc_definition_id):
to_remove.append(i)
for idx in reversed(to_remove):
wprops.variance_selected_tasks.remove(idx)
print(f"[DEBUG] Removed task {self.ifc_definition_id} from variance color selection")
# Refresh viewport to update colors
for area in bpy.context.screen.areas:
if area.type == 'VIEW_3D':
area.tag_redraw()
except Exception as e:
print(f"[ERROR] Error in update_variance_color_mode: {e}")
# ============================================================================
# TASK PROPERTY GROUP CLASSES
# ============================================================================
class TaskcolortypeGroupChoice(PropertyGroup):
"""colortype group mapping for each task"""
group_name: StringProperty(name="Group Name")
enabled: BoolProperty(name="Enabled")
selected_colortype: StringProperty(name="Selected colortype")
if TYPE_CHECKING:
group_name: str
enabled: bool
selected_colortype: str
class Task(PropertyGroup):
"""Task properties with improved colortype support"""
# colortype mapping by group
colortype_group_choices: CollectionProperty(name="colortype Group Choices", type=TaskcolortypeGroupChoice)
use_active_colortype_group: BoolProperty(
name="Use Active Group",
default=False,
update=update_use_active_colortype_group
)
selected_colortype_in_active_group: EnumProperty(
name="colortype in Active Group",
description="Select colortype within the active custom group (excludes DEFAULT)",
items=get_custom_group_colortype_items,
update=update_selected_colortype_in_active_group
)
# Basic task properties
animation_color_schemes: EnumProperty(name="Animation Color Scheme", items=get_animation_color_schemes_items)
name: StringProperty(name="Name", update=updateTaskName)
identification: StringProperty(name="Identification", update=updateTaskIdentification)
ifc_definition_id: IntProperty(name="IFC Definition ID")
has_children: BoolProperty(name="Has Children")
is_selected: BoolProperty(
name="Is Selected",
update=update_task_checkbox_selection
)
is_expanded: BoolProperty(name="Is Expanded")
has_bar_visual: BoolProperty(name="Show Task Bar Animation", default=False, update=update_task_bar_list)
level_index: IntProperty(name="Level Index")
# Times
duration: StringProperty(name="Duration", update=updateTaskDuration)
start: StringProperty(name="Start", update=updateTaskTimeStart)
finish: StringProperty(name="Finish", update=updateTaskTimeFinish)
actual_start: StringProperty(name="Actual Start", update=updateTaskTimeActualStart)
actual_finish: StringProperty(name="Actual Finish", update=updateTaskTimeActualFinish)
early_start: StringProperty(name="Early Start", update=updateTaskTimeEarlyStart)
early_finish: StringProperty(name="Early Finish", update=updateTaskTimeEarlyFinish)
late_start: StringProperty(name="Late Start", update=updateTaskTimeLateStart)
late_finish: StringProperty(name="Late Finish", update=updateTaskTimeLateFinish)
calendar: StringProperty(name="Calendar")
derived_start: StringProperty(name="Derived Start")
derived_finish: StringProperty(name="Derived Finish")
derived_actual_start: StringProperty(name="Derived Actual Start")
derived_actual_finish: StringProperty(name="Derived Actual Finish")
derived_early_start: StringProperty(name="Derived Early Start")
derived_early_finish: StringProperty(name="Derived Early Finish")
derived_late_start: StringProperty(name="Derived Late Start")
derived_late_finish: StringProperty(name="Derived Late Finish")
derived_duration: StringProperty(name="Derived Duration")
derived_calendar: StringProperty(name="Derived Calendar")
# Relationships
is_predecessor: BoolProperty(name="Is Predecessor")
is_successor: BoolProperty(name="Is Successor")
# Variance Analysis Properties
variance_status: StringProperty(
name="Variance Status",
description="Shows if the task is Ahead, Delayed, or On Time based on the last variance calculation"
)
variance_days: IntProperty(
name="Variance (Days)",
description="The difference in days between the two compared date sets (positive for delayed, negative for ahead)"
)
outputs_count: IntProperty(name="Element 3D Count", description="Total number of 3D elements assigned to task (inputs + outputs)")
# Variance color mode checkbox
is_variance_color_selected: BoolProperty(
name="Variance Color Mode",
description="Select this task for variance color mode visualization",
default=False,
update=update_variance_color_mode
)
if TYPE_CHECKING:
colortype_group_choices: bpy.types.bpy_prop_collection_idprop[TaskcolortypeGroupChoice]
use_active_colortype_group: bool
selected_colortype_in_active_group: str
animation_color_schemes: str
name: str
identification: str
ifc_definition_id: int
has_children: bool
is_selected: bool
is_expanded: bool
has_bar_visual: bool
level_index: int
duration: str
start: str
finish: str
calendar: str
derived_start: str
derived_finish: str
derived_duration: str
derived_calendar: str
actual_start: str
actual_finish: str
derived_actual_start: str
derived_actual_finish: str
early_start: str
early_finish: str
derived_early_start: str
derived_early_finish: str
late_start: str
late_finish: str
derived_late_start: str
derived_late_finish: str
is_predecessor: bool
is_successor: bool
outputs_count: int
variance_status: str
variance_days: int
is_variance_color_selected: bool
class TaskResource(PropertyGroup):
"""Task resource properties"""
name: StringProperty(name="Name", update=updateAssignedResourceName)
ifc_definition_id: IntProperty(name="IFC Definition ID")
schedule_usage: FloatProperty(name="Schedule Usage", update=updateAssignedResourceUsage)
if TYPE_CHECKING:
name: str
ifc_definition_id: int
schedule_usage: float
class TaskProduct(PropertyGroup):
"""Task product properties"""
name: StringProperty(name="Name")
ifc_definition_id: IntProperty(name="IFC Definition ID")
if TYPE_CHECKING:
name: str
ifc_definition_id: int
class BIMTaskTreeProperties(PropertyGroup):
"""Task tree collection - separate for performance reasons"""
# This belongs by itself for performance reasons. https://developer.blender.org/T87737
tasks: CollectionProperty(name="Tasks", type=Task)
if TYPE_CHECKING:
tasks: bpy.types.bpy_prop_collection_idprop[Task]
# ============================================================================
# HELPER FUNCTIONS
# ============================================================================
def get_date_source_items(self, context):
"""Helper for EnumProperty items to select date sources."""
return [
('SCHEDULE', "Schedule", "Use Schedule dates"),
('ACTUAL', "Actual", "Use Actual dates"),
('EARLY', "Early", "Use Early dates"),
('LATE', "Late", "Use Late dates"),
]
@@ -0,0 +1,278 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import bpy
from bpy.types import PropertyGroup
from bpy.props import (
PointerProperty, StringProperty, EnumProperty,
BoolProperty, IntProperty, FloatProperty, CollectionProperty
)
from typing import TYPE_CHECKING, Literal
from . import callbacks_prop as callbacks, enums_prop as enums, filter
class TaskFilterRule(PropertyGroup):
"""Defines a filter rule with support for multiple data types."""
is_active: BoolProperty(name="Active", default=True, description="Enable or disable this filter rule")
column: EnumProperty(
name="Column",
description="The column to apply the filter on",
items=enums.get_all_task_columns_enum,
update=filter.update_filter_column
)
operator: EnumProperty(
name="Operator",
description="The comparison operation to perform",
items=enums.get_operator_items
)
# Internal property to store the current data type
data_type: StringProperty(name="Data Type", default='string')
# Specific value fields for each data type
value_string: StringProperty(name="Value", description="Value for text or date filters")
value_integer: IntProperty(name="Value", description="Value for integer number filters")
value_float: FloatProperty(name="Value", description="Value for decimal number filters")
value_boolean: BoolProperty(name="Value", description="Value for true/false filters")
class BIMTaskFilterProperties(PropertyGroup):
"""Stores the complete configuration of the filter system."""
rules: CollectionProperty(
name="Filter Rules",
type=TaskFilterRule,
)
active_rule_index: IntProperty(
name="Active Filter Rule Index",
)
logic: EnumProperty(
name="Filter Logic",
description="How multiple filter rules are combined",
items=[
('AND', "Match All (AND)", "Show tasks that meet ALL active rules"),
('OR', "Match Any (OR)", "Show tasks that meet AT LEAST ONE active rule"),
],
default='AND',
)
show_filters: BoolProperty(
name="Show Filters",
description="Shows or hides the filter configuration panel",
default=False,
)
# --- ADDED PROPERTY ---
show_saved_filters: BoolProperty(
name="Show Saved Filters",
description="Shows or hides the saved filters panel",
default=False,
)
def to_json_data(self):
"""Serializes the filter state to a Python dictionary."""
rules_data = []
for rule in self.rules:
rules_data.append({
"is_active": rule.is_active,
"column": rule.column,
"operator": rule.operator,
"data_type": rule.data_type,
"value_string": rule.value_string,
"value_integer": rule.value_integer,
"value_float": rule.value_float,
"value_boolean": rule.value_boolean,
})
return {
"rules": rules_data,
"logic": self.logic,
"show_filters": self.show_filters,
"show_saved_filters": self.show_saved_filters,
"active_rule_index": self.active_rule_index,
}
def from_json_data(self, data):
"""Restores the filter state from a Python dictionary."""
self.rules.clear()
self.logic = data.get("logic", "AND")
self.show_filters = data.get("show_filters", False)
self.show_saved_filters = data.get("show_saved_filters", False)
for rule_data in data.get("rules", []):
new_rule = self.rules.add()
for key, value in rule_data.items():
if hasattr(new_rule, key):
setattr(new_rule, key, value)
self.active_rule_index = data.get("active_rule_index", 0)
class SavedFilterSet(PropertyGroup):
"""Stores a set of filter rules with a name."""
name: StringProperty(name="Set Name")
rules: CollectionProperty(type=TaskFilterRule)
class TaskcolortypeGroupChoice(PropertyGroup):
"""colortype group mapping for each task"""
group_name: StringProperty(name="Group Name")
enabled: BoolProperty(name="Enabled")
selected_colortype: StringProperty(name="Selected colortype")
if TYPE_CHECKING:
group_name: str
enabled: bool
selected_colortype: str
class Task(PropertyGroup):
"""Task properties with improved colortype support"""
# colortype mapping by group
colortype_group_choices: CollectionProperty(name="colortype Group Choices", type=TaskcolortypeGroupChoice)
use_active_colortype_group: BoolProperty(
name="Use Active Group",
default=False,
update=callbacks.update_use_active_colortype_group
)
selected_colortype_in_active_group: EnumProperty(
name="colortype in Active Group",
description="Select colortype within the active custom group (excludes DEFAULT)",
items=enums.get_custom_group_colortype_items, # <-- CRITICAL CHANGE HERE
update=callbacks.update_selected_colortype_in_active_group
)
# Basic task properties
animation_color_schemes: EnumProperty(name="Animation Color Scheme", items=enums.get_animation_color_schemes_items)
name: StringProperty(name="Name", update=callbacks.updateTaskName)
identification: StringProperty(name="Identification", update=callbacks.updateTaskIdentification)
ifc_definition_id: IntProperty(name="IFC Definition ID")
has_children: BoolProperty(name="Has Children")
is_selected: BoolProperty(
name="Is Selected",
update=callbacks.update_task_checkbox_selection
)
is_expanded: BoolProperty(name="Is Expanded")
has_bar_visual: BoolProperty(name="Show Task Bar Animation", default=False, update=callbacks.update_task_bar_list)
level_index: IntProperty(name="Level Index")
# Times
duration: StringProperty(name="Duration", update=callbacks.updateTaskDuration)
start: StringProperty(name="Start", update=callbacks.updateTaskTimeStart)
finish: StringProperty(name="Finish", update=callbacks.updateTaskTimeFinish)
actual_start: StringProperty(name="Actual Start", update=callbacks.updateTaskTimeActualStart)
actual_finish: StringProperty(name="Actual Finish", update=callbacks.updateTaskTimeActualFinish)
early_start: StringProperty(name="Early Start", update=callbacks.updateTaskTimeEarlyStart)
early_finish: StringProperty(name="Early Finish", update=callbacks.updateTaskTimeEarlyFinish)
late_start: StringProperty(name="Late Start", update=callbacks.updateTaskTimeLateStart)
late_finish: StringProperty(name="Late Finish", update=callbacks.updateTaskTimeLateFinish)
calendar: StringProperty(name="Calendar")
derived_start: StringProperty(name="Derived Start")
derived_finish: StringProperty(name="Derived Finish")
derived_actual_start: StringProperty(name="Derived Actual Start")
derived_actual_finish: StringProperty(name="Derived Actual Finish")
derived_early_start: StringProperty(name="Derived Early Start")
derived_early_finish: StringProperty(name="Derived Early Finish")
derived_late_start: StringProperty(name="Derived Late Start")
derived_late_finish: StringProperty(name="Derived Late Finish")
derived_duration: StringProperty(name="Derived Duration")
derived_calendar: StringProperty(name="Derived Calendar")
# Relationships
is_predecessor: BoolProperty(name="Is Predecessor")
is_successor: BoolProperty(name="Is Successor")
# --- START: Variance Analysis Properties ---
variance_status: StringProperty(
name="Variance Status",
description="Shows if the task is Ahead, Delayed, or On Time based on the last variance calculation"
)
variance_days: IntProperty(
name="Variance (Days)",
description="The difference in days between the two compared date sets (positive for delayed, negative for ahead)"
)
outputs_count: IntProperty(name="Element 3D Count", description="Total number of 3D elements assigned to task (inputs + outputs)")
inputs_count: IntProperty(name="Inputs Count", description="Number of elements assigned as task inputs")
# Variance color mode checkbox
is_variance_color_selected: BoolProperty(
name="Variance Color Mode",
description="Select this task for variance color mode visualization",
default=False,
update=lambda self, context: callbacks.update_variance_color_mode(self, context)
)
if TYPE_CHECKING:
animation_color_schemes: str
name: str
identification: str
ifc_definition_id: int
has_children: bool
is_selected: bool
is_expanded: bool
has_bar_visual: bool
level_index: int
duration: str
start: str
finish: str
calendar: str
derived_start: str
derived_finish: str
derived_duration: str
derived_calendar: str
actual_start: str
actual_finish: str
derived_actual_start: str
derived_actual_finish: str
early_start: str
early_finish: str
derived_early_start: str
derived_early_finish: str
late_start: str
late_finish: str
derived_late_start: str
derived_late_finish: str
is_predecessor: bool
is_successor: bool
outputs_count: int
variance_status: str
variance_days: int
class TaskResource(PropertyGroup):
name: StringProperty(name="Name", update=callbacks.updateAssignedResourceName)
ifc_definition_id: IntProperty(name="IFC Definition ID")
schedule_usage: FloatProperty(name="Schedule Usage", update=callbacks.updateAssignedResourceUsage)
if TYPE_CHECKING:
name: str
ifc_definition_id: int
schedule_usage: float
class TaskProduct(PropertyGroup):
name: StringProperty(name="Name")
ifc_definition_id: IntProperty(name="IFC Definition ID")
if TYPE_CHECKING:
name: str
ifc_definition_id: int
WorkPlanEditingType = Literal["-", "ATTRIBUTES", "SCHEDULES", "WORK_SCHEDULE", "TASKS", "WORKTIMES"]
class BIMTaskTreeProperties(PropertyGroup):
# This belongs by itself for performance reasons. https://developer.blender.org/T87737
tasks: CollectionProperty(name="Tasks", type=Task)
if TYPE_CHECKING:
tasks: bpy.types.bpy_prop_collection_idprop[Task]
@@ -0,0 +1,78 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import bpy
from bpy.types import PropertyGroup
from bpy.props import StringProperty, BoolProperty, IntProperty, CollectionProperty
from typing import TYPE_CHECKING
from . import callbacks_prop as callbacks
class IFCStatus(PropertyGroup):
name: StringProperty(name="Name")
is_visible: BoolProperty(
name="Is Visible", default=True, update=lambda x, y: (None, bpy.ops.bim.activate_status_filters())[0]
)
if TYPE_CHECKING:
name: str
is_visible: bool
class BIMStatusProperties(PropertyGroup):
is_enabled: BoolProperty(name="Is Enabled")
statuses: CollectionProperty(name="Statuses", type=IFCStatus)
if TYPE_CHECKING:
is_enabled: bool
statuses: bpy.types.bpy_prop_collection_idprop[IFCStatus]
class DatePickerProperties(PropertyGroup):
display_date: StringProperty(
name="Display Date",
description="Needed to keep track of what month is currently opened in date picker without affecting the currently selected date.",
)
selected_date: StringProperty(name="Selected Date")
selected_hour: IntProperty(min=0, max=23, update=callbacks.update_selected_date)
selected_min: IntProperty(min=0, max=59, update=callbacks.update_selected_date)
selected_sec: IntProperty(min=0, max=59, update=callbacks.update_selected_date)
if TYPE_CHECKING:
display_date: str
selected_date: str
selected_hour: int
selected_min: int
selected_sec: int
class BIMDateTextProperties(PropertyGroup):
start_frame: IntProperty(name="Start Frame")
total_frames: IntProperty(name="Total Frames")
start: StringProperty(name="Start")
finish: StringProperty(name="Finish")
if TYPE_CHECKING:
start_frame: int
total_frames: int
start: str
finish: str
@@ -0,0 +1,86 @@
"""User Interface (UI) Panels for 4D BIM Scheduling - Modular Organization.
This package contains the Panel (bpy.types.Panel) and UIList (bpy.types.UIList)
classes, thematically organized to build the addon's interface in Blender.
The structure is as follows:
- panels_schedule.py: Panels for managing schedules, tasks, variance analysis, and ICOM.
- panels_animation.py: Panels dedicated to 4D animation configuration, color schemes, camera control, orbit, and HUD settings.
- panels_workplan.py: Panels for managing Work Plans and Calendars.
- lists_ui.py: Contains reusable UI elements, primarily the UIList classes that display data lists.
The __init__.py file acts as an orchestrator, importing all components and managing
their centralized registration and unregistration within Blender.
"""
import bpy
# 1. Import all classes from the new UI modules
from .animation_ui import (
BIM_PT_animation_tools,
BIM_PT_animation_color_schemes,
)
from .lists_ui import (
BIM_UL_animation_group_stack,
BIM_UL_task_columns,
BIM_UL_task_filters,
BIM_UL_saved_filter_sets,
BIM_UL_task_inputs,
BIM_UL_task_resources,
BIM_UL_animation_colors,
BIM_UL_task_outputs,
BIM_UL_product_input_tasks,
BIM_UL_product_output_tasks,
BIM_UL_tasks,
)
from .management_ui import (
BIM_PT_work_plans,
BIM_PT_work_calendars,
BIM_PT_4D_Tools,
)
from .panels_schedule import (
BIM_PT_status,
BIM_PT_work_schedules,
BIM_PT_task_icom,
BIM_PT_variance_analysis,
)
# 2. Group all classes into a single tuple for easy registration
classes = (
# Animation Panels
BIM_PT_animation_tools,
BIM_PT_animation_color_schemes,
# UIList Classes
BIM_UL_animation_group_stack,
BIM_UL_task_columns,
BIM_UL_task_filters,
BIM_UL_saved_filter_sets,
BIM_UL_task_inputs,
BIM_UL_task_resources,
BIM_UL_animation_colors,
BIM_UL_task_outputs,
BIM_UL_product_input_tasks,
BIM_UL_product_output_tasks,
BIM_UL_tasks,
# Management Panels
BIM_PT_work_plans,
BIM_PT_work_calendars,
BIM_PT_4D_Tools,
# Schedule Panels
BIM_PT_status,
BIM_PT_work_schedules,
BIM_PT_task_icom,
BIM_PT_variance_analysis,
)
# 3. Registration and unregistration functions for this UI package
def register():
"""Registers all UI classes from this module."""
for cls in classes:
bpy.utils.register_class(cls)
def unregister():
"""Unregisters all UI classes in reverse order."""
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,472 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
# Toggle to show/hide saved ColorTypes section
import bpy
import re
from bpy.types import UIList
import bonsai.tool as tool
from bonsai.bim.module.sequence.data import SequenceData
from typing import Any, Optional
class BIM_UL_animation_group_stack(UIList):
bl_idname = "BIM_UL_animation_group_stack"
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index=0):
row = layout.row(align=True)
row.prop(item, "enabled", text="")
row.label(text=item.group)
def invoke(self, context, event):
pass
class BIM_UL_task_columns(UIList):
def draw_item(
self,
context,
layout: bpy.types.UILayout,
data: "BIMWorkScheduleProperties",
item: "Attribute",
icon,
active_data,
active_propname,
):
props = tool.Sequence.get_work_schedule_props()
if item:
row = layout.row(align=True)
row.prop(item, "name", emboss=False, text="")
if props.sort_column == item.name:
row.label(text="", icon="SORTALPHA")
row.operator("bim.remove_task_column", text="", icon="X").name = item.name
class BIM_UL_task_filters(UIList):
"""Draws the list of filter rules."""
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index=0):
# 'item' is an instance of TaskFilterRule
if self.layout_type in {'DEFAULT', 'COMPACT'}:
# The data type is now read directly from the rule's property
data_type = getattr(item, 'data_type', 'string')
row = layout.row(align=True)
# Common controls (checkbox, column, operator)
row.prop(item, "is_active", text="")
row.prop(item, "column", text="")
row.prop(item, "operator", text="")
# The value field is only enabled if the operator requires it
value_row = row.row(align=True)
value_row.enabled = item.operator not in {'EMPTY', 'NOT_EMPTY'}
# Conditional logic to draw the correct value widget
if data_type == 'integer':
value_row.prop(item, "value_integer", text="")
elif data_type in ('float', 'real'):
value_row.prop(item, "value_float", text="")
elif data_type == 'boolean':
value_row.prop(item, "value_boolean", text="")
elif data_type == 'date':
# For dates, we show the text and a button that opens the calendar
value_row.prop(item, "value_string", text="")
# [DEBUG] CORRECTED CALENDAR BUTTON
op = value_row.operator("bim.filter_datepicker", text="", icon="OUTLINER_DATA_CAMERA")
op.rule_index = index # [DEBUG] THIS IS CRUCIAL - pass the index
else: # By default, use string (for text, enums, etc.)
value_row.prop(item, "value_string", text="")
class BIM_UL_saved_filter_sets(UIList):
"""Draws the list of saved filter sets."""
def draw_item(self, context, layout, data, item, icon, active_data, active_propname):
# 'item' es una instancia de SavedFilterSet
if self.layout_type in {'DEFAULT', 'COMPACT'}:
row = layout.row(align=True)
row.label(text=item.name, icon='FILTER')
class BIM_UL_task_inputs(UIList):
def draw_item(self, context, layout, data, item, icon, active_data, active_propname):
if item:
row = layout.row(align=True)
op = row.operator("bim.select_product", text="", icon="RESTRICT_SELECT_OFF")
op.product = item.ifc_definition_id
row.prop(item, "name", emboss=False, text="")
# row.operator("bim.remove_task_column", text="", icon="X").name = item.name
class BIM_UL_task_resources(UIList):
def draw_item(self, context, layout, data, item, icon, active_data, active_propname):
if item:
row = layout.row(align=True)
row.operator("bim.go_to_resource", text="", icon="STYLUS_PRESSURE").resource = item.ifc_definition_id
row.prop(item, "name", emboss=False, text="")
row.prop(item, "schedule_usage", emboss=False, text="")
class BIM_UL_animation_colors(UIList):
def draw_item(self, context, layout, data, item, icon, active_data, active_propname):
if item:
row = layout.row()
row.prop(item, "color", text="")
row.prop(item, "name", text="")
class BIM_UL_task_outputs(UIList):
def draw_item(self, context, layout, data, item, icon, active_data, active_propname):
if item:
row = layout.row(align=True)
op = row.operator("bim.select_product", text="", icon="RESTRICT_SELECT_OFF")
op.product = item.ifc_definition_id
row.prop(item, "name", emboss=False, text="")
class BIM_UL_product_input_tasks(UIList):
def draw_item(self, context, layout, data, item, icon, active_data, active_propname):
if item:
row = layout.row(align=True)
op = row.operator("bim.go_to_task", text="", icon="STYLUS_PRESSURE")
op.task = item.ifc_definition_id
row.split(factor=0.8)
row.prop(item, "name", text="")
class BIM_UL_product_output_tasks(UIList):
def draw_item(self, context, layout, data, item, icon, active_data, active_propname):
if item:
row = layout.row(align=True)
op = row.operator("bim.go_to_task", text="", icon="STYLUS_PRESSURE")
op.task = item.ifc_definition_id
row.split(factor=0.8)
row.prop(item, "name", text="")
class BIM_UL_tasks(UIList):
@classmethod
def draw_header(cls, layout: bpy.types.UILayout):
props = tool.Sequence.get_work_schedule_props()
row = layout.row(align=True)
# Apply original COPIA alignment system with virtual columns support
split1 = row.split(factor=0.1)
# Header "ID" + quick sort-by-ID button (with spacing)
hdr = split1.row(align=False) # Changed to False to avoid tight alignment
hdr.label(text="ID", icon="BLANK1")
hdr.operator("bim.sort_schedule_by_id_asc", text="", icon="SORTALPHA")
# Calculate split factor accounting for 2 virtual columns (Element 3D + Variance)
# Drastically reduced to compensate for 18+ spaces in virtual columns manual spacing
split2 = split1.split(factor=0.4 - min(0.2, 0.1 * len(props.columns)))
split2.label(text="Name", icon="BLANK1")
# Use same split2 for custom columns to ensure perfect alignment
split3 = cls.draw_custom_columns(props, split2, header=True)
# Virtual columns headers using the returned split from draw_custom_columns
split3.label(text=" Element 3D ") # Add manual spacing
split3.label(text="Variance")
def draw_item(
self,
context,
layout: bpy.types.UILayout,
data: "BIMTaskTreeProperties",
item: "Task",
icon,
active_data,
active_propname,
):
if item:
self.props = tool.Sequence.get_work_schedule_props()
task = SequenceData.data["tasks"][item.ifc_definition_id]
row = layout.row(align=True)
self.draw_hierarchy(row, item)
# Apply original COPIA alignment system with virtual columns support
split1 = row.split(factor=0.1)
split1.prop(item, "identification", emboss=False, text="")
# Use SAME split calculation as header for perfect alignment
# Drastically reduced to compensate for 18+ spaces in virtual columns manual spacing
split2 = split1.split(factor=0.4 - min(0.2, 0.1 * len(self.props.columns)))
# Align Name values to start at beginning of "Name" title - remove icon and add left alignment
name_row = split2.row(align=False)
name_row.alignment = 'LEFT'
name_row.label(text=item.name or "Unnamed") # No padding, no icon - pure left align
# Use same split2 for custom columns to ensure perfect alignment
split3 = BIM_UL_tasks.draw_custom_columns(self.props, split2, item, task)
# Virtual columns data using the returned split from draw_custom_columns
split3.label(text=f" {item.outputs_count} ") # Move back left - better center for 3D Outputs
# Variance column with status and colored background
variance_status_text = item.variance_status or ""
icon = 'BLANK1'
# Status field display based on variance result
if "Delayed" in variance_status_text:
icon = 'TIME'
red_box = split3.box()
red_box.alert = True
red_row = red_box.row()
red_row.scale_y = 0.9
red_row.label(text=variance_status_text, icon=icon)
elif "Ahead" in variance_status_text:
# GREEN: Use box with active = True for green
icon = 'TIME'
green_box = split3.box()
green_box.active = True
green_row = green_box.row()
green_row.scale_y = 0.9
# Use a label so it's not a button
green_row.label(text=variance_status_text, icon=icon)
elif "On Time" in variance_status_text:
# BLUE: Use box with active = False for blue
icon = 'TIME'
blue_box = split3.box()
blue_box.active = False
blue_box.enabled = True
blue_row = blue_box.row()
blue_row.scale_y = 0.9
# Use a label so it's not a button
blue_row.label(text=variance_status_text, icon=icon)
else:
# Default
icon = 'BLANK1'
split3.label(text=variance_status_text, icon=icon)
# Add variance color mode checkbox - ALL FUNCTIONAL
if variance_status_text:
checkbox_container = row.row(align=True)
checkbox_container.scale_x = 0.8
# CONDITIONAL based on variance result - ALL use real prop()
if "Delayed" in variance_status_text:
# RED: alert works perfectly
checkbox_container.alert = True
elif "Ahead" in variance_status_text:
# GREEN: box with active = True
checkbox_box = checkbox_container.box()
checkbox_box.active = True
checkbox_box.scale_y = 0.9
checkbox_container = checkbox_box.row()
elif "On Time" in variance_status_text:
# BLUE: box with active = False
checkbox_box = checkbox_container.box()
checkbox_box.active = False
checkbox_box.enabled = True
checkbox_box.scale_y = 0.9
checkbox_container = checkbox_box.row()
# ALL use the real prop() to function
checkbox_container.prop(
item,
"is_variance_color_selected",
text="",
icon="CHECKBOX_HLT" if item.is_variance_color_selected else "CHECKBOX_DEHLT",
)
if self.props.active_task_id and self.props.editing_task_type == "ATTRIBUTES":
row.prop(
item,
"is_selected",
icon="CHECKBOX_HLT" if item.is_selected else "CHECKBOX_DEHLT",
text="",
emboss=False,
)
if self.props.should_show_task_bar_selection:
row.prop(
item,
"has_bar_visual",
icon="COLLECTION_COLOR_04" if item.has_bar_visual else "OUTLINER_COLLECTION",
text="",
emboss=False,
)
if self.props.enable_reorder:
self.draw_order_operator(row, item.ifc_definition_id)
if self.props.editing_task_type == "SEQUENCE" and self.props.highlighted_task_id != item.ifc_definition_id:
if item.is_predecessor:
op = row.operator("bim.unassign_predecessor", text="", icon="BACK", emboss=False)
else:
op = row.operator("bim.assign_predecessor", text="", icon="TRACKING_BACKWARDS", emboss=False)
op.task = item.ifc_definition_id
if item.is_successor:
op = row.operator("bim.unassign_successor", text="", icon="FORWARD", emboss=False)
else:
op = row.operator("bim.assign_successor", text="", icon="TRACKING_FORWARDS", emboss=False)
op.task = item.ifc_definition_id
def draw_order_operator(self, row: bpy.types.UILayout, ifc_definition_id: int) -> None:
task = SequenceData.data["tasks"][ifc_definition_id]
if task["NestingIndex"] is not None:
if task["NestingIndex"] == 0:
op = row.operator("bim.reorder_task_nesting", icon="TRIA_DOWN", text="")
op.task = ifc_definition_id
op.new_index = task["NestingIndex"] + 1
elif task["NestingIndex"] > 0:
op = row.operator("bim.reorder_task_nesting", icon="TRIA_UP", text="")
op.task = ifc_definition_id
op.new_index = task["NestingIndex"] - 1
def draw_hierarchy(self, row: bpy.types.UILayout, item: bpy.types.PropertyGroup) -> None:
for i in range(0, item.level_index):
row.label(text="", icon="BLANK1")
if item.has_children:
if item.is_expanded:
row.operator("bim.contract_task", text="", emboss=False, icon="DISCLOSURE_TRI_DOWN").task = (
item.ifc_definition_id
)
else:
row.operator("bim.expand_task", text="", emboss=False, icon="DISCLOSURE_TRI_RIGHT").task = (
item.ifc_definition_id
)
else:
row.label(text="", icon="DOT")
@classmethod
def draw_custom_columns(
cls,
props: bpy.types.PropertyGroup,
row: bpy.types.UILayout,
item: Optional[bpy.types.PropertyGroup] = None,
task: Optional[dict[str, Any]] = None,
*,
header: bool = False,
) -> bpy.types.UILayout:
"""Original COPIA alignment system: simple and perfect alignment"""
if not header:
assert item and task, "Item and task must be provided when not drawing a header"
# Apply original COPIA system: simple iteration through all columns
# This ensures perfect alignment between headers and data
for column in props.columns:
column_name = column.name
# --- Generalized handling for all date columns ---
date_match = re.match(r"IfcTaskTime\.(Schedule|Actual|Early|Late)(Start|Finish)", column_name)
if date_match:
date_type = date_match.group(1).lower()
date_part = date_match.group(2)
prop_name = f"{date_type.lower()}_{date_part.lower()}"
if date_type == "schedule":
prop_name = date_part.lower()
derived_prop_name = f"derived_{prop_name}"
if header:
# Show full names: Schedule Start, Actual Start, Late Start, etc.
full_name = f"{date_type.title()} {date_part}"
# Create sub-split for Schedule columns to reduce spacing
if date_type == "schedule":
subsplit = row.split(factor=0.6) # Reduce space for Schedule columns
subsplit.label(text=full_name)
else:
row.label(text=full_name)
else:
derived_value = getattr(item, derived_prop_name, "")
if derived_value:
# Add manual spacing to move Finish dates right - adjusted only for Schedule columns
if date_part == "Finish" and date_type == "schedule":
subsplit = row.split(factor=0.6) # Reduce space for Schedule columns
subsplit.label(text=f" {derived_value}*") # 5 spaces - Schedule Finish (reduced from 10)
elif date_part == "Start" and date_type == "schedule":
subsplit = row.split(factor=0.6) # Reduce space for Schedule columns
subsplit.label(text=f" {derived_value}*") # 5 spaces - Schedule Start (corrected from 10)
elif date_part == "Finish" and date_type == "actual":
row.label(text=f" {derived_value}*") # 10 spaces - Actual Finish (original)
elif date_part == "Start" and date_type == "actual":
row.label(text=f" {derived_value}*") # 5 spaces - Actual Start (original)
elif date_part == "Finish" and date_type == "early":
row.label(text=f" {derived_value}*") # 10 spaces - Early Finish (original)
elif date_part == "Start" and date_type == "early":
row.label(text=f" {derived_value}*") # 5 spaces - Early Start (original)
elif date_part == "Finish" and date_type == "late":
row.label(text=f" {derived_value}*") # 10 spaces - Late Finish (original)
elif date_part == "Start" and date_type == "late":
row.label(text=f" {derived_value}*") # 5 spaces - Late Start (original)
else:
row.label(text=derived_value + "*")
else:
# Apply same alignment logic when there's no derived_value
if date_part in ["Finish", "Start"] and date_type in ["schedule", "actual", "early", "late"]:
# Get the actual value and apply the same spacing as derived_value
actual_value = getattr(item, prop_name, "")
if actual_value:
if date_type == "schedule":
subsplit = row.split(factor=0.6) # Reduce space for Schedule columns
if date_part == "Start":
subsplit.label(text=f" {actual_value}") # 5 spaces - Schedule Start
else: # Finish
subsplit.label(text=f" {actual_value}") # 5 spaces - Schedule Finish
else:
if date_part == "Start":
row.label(text=f" {actual_value}") # 5 spaces - Other Start columns (original)
else: # Finish
row.label(text=f" {actual_value}") # 10 spaces - Other Finish columns (original)
else:
row.prop(item, prop_name, emboss=False, text="")
else:
row.prop(item, prop_name, emboss=False, text="")
continue
if column_name == "IfcTaskTime.ScheduleDuration":
if header:
row.label(text="Duration")
else:
if item.derived_duration:
row.label(text=f" {item.derived_duration}*") # 5 spaces - Duration
else:
# Apply same 15-space alignment for editable fields
duration_value = getattr(item, "duration", "")
if duration_value:
row.label(text=f" {duration_value}") # 10 spaces - Duration
else:
row.prop(item, "duration", emboss=False, text="")
elif column_name == "Controls.Calendar":
if header:
row.label(text="Calendar")
else:
if item.derived_calendar:
row.label(text=f" {item.derived_calendar}*") # 15 spaces - Calendar
else:
calendar_value = item.calendar or "-"
row.label(text=f" {calendar_value}") # 15 spaces - Calendar
else:
ifc_class, name = column_name.split(".")
if header:
row.label(text=name)
else:
if ifc_class == "IfcTask":
value = task[name]
elif ifc_class == "IfcTaskTime":
if (task_time_id := task["TaskTime"]) is None:
value = None
else:
value = SequenceData.data["task_times"][task_time_id][name]
else:
assert False, f"Unexpected ifc_class '{ifc_class}'."
if value is None:
row.label(text=f" -") # 15 spaces - NULL value
else:
row.label(text=f" {str(value)}") # 15 spaces - All other columns
# Return the row for virtual columns to use
return row
@@ -0,0 +1,327 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import bpy
from bpy.types import Panel
from typing import Any
import bonsai.tool as tool
from bonsai.bim.helper import draw_attributes
from bonsai.bim.module.sequence.data import WorkPlansData, SequenceData
class BIM_PT_work_plans(Panel):
bl_label = "Work Plans"
bl_idname = "BIM_PT_work_plans"
bl_options = {"DEFAULT_CLOSED"}
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_parent_id = "BIM_PT_tab_sequence"
@classmethod
def poll(cls, context):
file = tool.Ifc.get()
return file and file.schema != "IFC2X3"
def draw(self, context):
if not WorkPlansData.is_loaded:
WorkPlansData.load()
assert self.layout
self.props = tool.Sequence.get_work_plan_props()
row = self.layout.row()
if WorkPlansData.data["total_work_plans"]:
row.label(text=f"{WorkPlansData.data['total_work_plans']} Work Plans Found", icon="TEXT")
else:
row.label(text="No Work Plans found.", icon="TEXT")
row.operator("bim.add_work_plan", icon="ADD", text="")
for work_plan in WorkPlansData.data["work_plans"]:
self.draw_work_plan_ui(work_plan)
def draw_work_plan_ui(self, work_plan: dict[str, Any]) -> None:
row = self.layout.row(align=True)
row.label(text=work_plan["name"], icon="TEXT")
if self.props.active_work_plan_id == work_plan["id"]:
if self.props.editing_type == "ATTRIBUTES":
row.operator("bim.edit_work_plan", text="", icon="CHECKMARK")
row.operator("bim.disable_editing_work_plan", text="Cancel", icon="CANCEL")
elif self.props.active_work_plan_id:
row.operator("bim.remove_work_plan", text="", icon="X").work_plan = work_plan["id"]
else:
op = row.operator("bim.enable_editing_work_plan_schedules", text="", icon="LINENUMBERS_ON")
op.work_plan = work_plan["id"]
op = row.operator("bim.enable_editing_work_plan", text="", icon="GREASEPENCIL")
op.work_plan = work_plan["id"]
row.operator("bim.remove_work_plan", text="", icon="X").work_plan = work_plan["id"]
if self.props.active_work_plan_id == work_plan["id"]:
if self.props.editing_type == "ATTRIBUTES":
self.draw_editable_ui()
elif self.props.editing_type == "SCHEDULES":
self.draw_work_schedule_ui()
def draw_editable_ui(self) -> None:
draw_attributes(self.props.work_plan_attributes, self.layout)
def draw_work_schedule_ui(self) -> None:
if WorkPlansData.data["has_work_schedules"]:
row = self.layout.row(align=True)
row.prop(self.props, "work_schedules", text="")
op = row.operator("bim.assign_work_schedule", text="", icon="ADD")
op.work_plan = self.props.active_work_plan_id
op.work_schedule = int(self.props.work_schedules)
for work_schedule in WorkPlansData.data["active_work_plan_schedules"]:
row = self.layout.row(align=True)
row.label(text=work_schedule["name"], icon="LINENUMBERS_ON")
op = row.operator("bim.unassign_work_schedule", text="", icon="X")
op.work_plan = self.props.active_work_plan_id
op.work_schedule = work_schedule["id"]
else:
row = self.layout.row()
row.label(text="No schedules found. See Work Schedule Panel", icon="INFO")
class BIM_PT_work_calendars(Panel):
bl_label = "Work Calendars"
bl_idname = "BIM_PT_work_calendars"
bl_options = {"DEFAULT_CLOSED"}
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_parent_id = "BIM_PT_tab_sequence"
@classmethod
def poll(cls, context):
file = tool.Ifc.get()
return file and hasattr(file, "schema") and file.schema != "IFC2X3"
layout: bpy.types.UILayout
def draw(self, context):
if not SequenceData.is_loaded:
SequenceData.load()
self.props = tool.Sequence.get_work_calendar_props()
row = self.layout.row()
if SequenceData.data["has_work_calendars"]:
row.label(
text="{} Work Calendars Found".format(SequenceData.data["number_of_work_calendars_loaded"]),
icon="TEXT",
)
else:
row.label(text="No Work Calendars found.", icon="TEXT")
row.operator("bim.add_work_calendar", icon="ADD", text="")
for work_calendar_id, work_calendar in SequenceData.data["work_calendars"].items():
self.draw_work_calendar_ui(work_calendar_id, work_calendar)
def draw_work_calendar_ui(self, work_calendar_id, work_calendar):
row = self.layout.row(align=True)
row.label(text=work_calendar["Name"] or "Unnamed", icon="VIEW_ORTHO")
if self.props.active_work_calendar_id == work_calendar_id:
if self.props.editing_type == "ATTRIBUTES":
row.operator("bim.edit_work_calendar", icon="CHECKMARK")
row.operator("bim.disable_editing_work_calendar", text="", icon="CANCEL")
elif self.props.active_work_calendar_id:
row.operator("bim.remove_work_calendar", text="", icon="X").work_calendar = work_calendar_id
else:
op = row.operator("bim.enable_editing_work_calendar_times", text="", icon="MESH_GRID")
op.work_calendar = work_calendar_id
op = row.operator("bim.enable_editing_work_calendar", text="", icon="GREASEPENCIL")
op.work_calendar = work_calendar_id
row.operator("bim.remove_work_calendar", text="", icon="X").work_calendar = work_calendar_id
if self.props.active_work_calendar_id == work_calendar_id:
if self.props.editing_type == "ATTRIBUTES":
self.draw_editable_ui()
elif self.props.editing_type == "WORKTIMES":
self.draw_work_times_ui(work_calendar_id, work_calendar)
def draw_work_times_ui(self, work_calendar_id, work_calendar):
row = self.layout.row(align=True)
op = row.operator("bim.add_work_time", text="Add Work Time", icon="ADD")
op.work_calendar = work_calendar_id
op.time_type = "WorkingTimes"
op = row.operator("bim.add_work_time", text="Add Exception Time", icon="ADD")
op.work_calendar = work_calendar_id
op.time_type = "ExceptionTimes"
for work_time_id in work_calendar["WorkingTimes"]:
self.draw_work_time_ui(SequenceData.data["work_times"][work_time_id], time_type="WorkingTimes")
for work_time_id in work_calendar["ExceptionTimes"]:
self.draw_work_time_ui(SequenceData.data["work_times"][work_time_id], time_type="ExceptionTimes")
def draw_work_time_ui(self, work_time, time_type):
row = self.layout.row(align=True)
row.label(text=work_time["Name"] or "Unnamed", icon="AUTO" if time_type == "WorkingTimes" else "HOME")
if work_time["Start"] or work_time["Finish"]:
row.label(text="{} - {}".format(work_time["Start"] or "*", work_time["Finish"] or "*"))
if self.props.active_work_time_id == work_time["id"]:
row.operator("bim.edit_work_time", text="", icon="CHECKMARK")
row.operator("bim.disable_editing_work_time", text="Cancel", icon="CANCEL")
elif self.props.active_work_time_id:
op = row.operator("bim.remove_work_time", text="", icon="X")
op.work_time = work_time["id"]
else:
op = row.operator("bim.enable_editing_work_time", text="", icon="GREASEPENCIL")
op.work_time = work_time["id"]
op = row.operator("bim.remove_work_time", text="", icon="X")
op.work_time = work_time["id"]
if self.props.active_work_time_id == work_time["id"]:
self.draw_editable_work_time_ui(work_time)
def draw_editable_work_time_ui(self, work_time: dict[str, Any]) -> None:
draw_attributes(self.props.work_time_attributes, self.layout)
if work_time["RecurrencePattern"]:
self.draw_editable_recurrence_pattern_ui(
SequenceData.data["recurrence_patterns"][work_time["RecurrencePattern"]]
)
else:
row = self.layout.row(align=True)
row.prop(self.props, "recurrence_types", icon="RECOVER_LAST", text="")
op = row.operator("bim.assign_recurrence_pattern", icon="ADD", text="")
op.work_time = work_time["id"]
op.recurrence_type = self.props.recurrence_types
def draw_editable_recurrence_pattern_ui(self, recurrence_pattern):
box = self.layout.box()
row = box.row(align=True)
row.label(text=recurrence_pattern["RecurrenceType"], icon="RECOVER_LAST")
op = row.operator("bim.unassign_recurrence_pattern", text="", icon="X")
op.recurrence_pattern = recurrence_pattern["id"]
row = box.row(align=True)
row.prop(self.props, "start_time", text="")
row.prop(self.props, "end_time", text="")
op = row.operator("bim.add_time_period", text="", icon="ADD")
op.recurrence_pattern = recurrence_pattern["id"]
for time_period_id in recurrence_pattern["TimePeriods"]:
time_period = SequenceData.data["time_periods"][time_period_id]
row = box.row(align=True)
row.label(text="{} - {}".format(time_period["StartTime"], time_period["EndTime"]), icon="TIME")
op = row.operator("bim.remove_time_period", text="", icon="X")
op.time_period = time_period_id
applicable_data = {
"DAILY": ["Interval", "Occurrences"],
"WEEKLY": ["WeekdayComponent", "Interval", "Occurrences"],
"MONTHLY_BY_DAY_OF_MONTH": ["DayComponent", "Interval", "Occurrences"],
"MONTHLY_BY_POSITION": ["WeekdayComponent", "Position", "Interval", "Occurrences"],
"BY_DAY_COUNT": ["Interval", "Occurrences"],
"BY_WEEKDAY_COUNT": ["WeekdayComponent", "Interval", "Occurrences"],
"YEARLY_BY_DAY_OF_MONTH": ["DayComponent", "MonthComponent", "Interval", "Occurrences"],
"YEARLY_BY_POSITION": ["WeekdayComponent", "MonthComponent", "Position", "Interval", "Occurrences"],
}
if "Position" in applicable_data[recurrence_pattern["RecurrenceType"]]:
row = box.row()
row.prop(self.props, "position")
if "DayComponent" in applicable_data[recurrence_pattern["RecurrenceType"]]:
for i, component in enumerate(self.props.day_components):
if i % 7 == 0:
row = box.row(align=True)
row.prop(component, "is_specified", text=component.name)
if "WeekdayComponent" in applicable_data[recurrence_pattern["RecurrenceType"]]:
row = box.row(align=True)
for component in self.props.weekday_components:
row.prop(component, "is_specified", text=component.name)
if "MonthComponent" in applicable_data[recurrence_pattern["RecurrenceType"]]:
for i, component in enumerate(self.props.month_components):
if i % 4 == 0:
row = box.row(align=True)
row.prop(component, "is_specified", text=component.name)
row = box.row()
row.prop(self.props, "interval")
row = box.row()
row.prop(self.props, "occurrences")
def draw_editable_ui(self):
draw_attributes(self.props.work_calendar_attributes, self.layout)
class BIM_PT_4D_Tools(Panel):
bl_label = "4D Tools"
bl_idname = "BIM_PT_4D_Tools"
bl_options = {"DEFAULT_CLOSED"}
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_parent_id = "BIM_PT_tab_sequence"
bl_order = 5
def draw(self, context):
self.props = tool.Sequence.get_work_schedule_props()
# --- Active Work Schedule Info ---
row = self.layout.row()
try:
if self.props.active_work_schedule_id:
file = tool.Ifc.get()
if file:
ws = file.by_id(self.props.active_work_schedule_id)
if ws:
row.label(text=f"Active Schedule: {getattr(ws, 'Name', None) or 'Unnamed'}", icon="TIME")
else:
row.label(text="No valid schedule selected", icon="ERROR")
else:
row.label(text="No IFC file loaded", icon="ERROR")
else:
row.label(text="No schedule selected", icon="INFO")
except Exception:
row.label(text="No valid schedule selected", icon="ERROR")
# --- Actions ---
row = self.layout.row()
row.operator("bim.load_product_related_tasks", text="Load Tasks", icon="FILE_REFRESH")
row.prop(self.props, "filter_by_active_schedule", text="Filter by Active Schedule")
# --- Lists ---
grid = self.layout.grid_flow(columns=2, even_columns=True)
col1 = grid.column()
col1.label(text="Product Input Tasks")
col1.template_list(
"BIM_UL_product_input_tasks",
"",
self.props,
"product_input_tasks",
self.props,
"active_product_input_task_index",
)
col2 = grid.column()
col2.label(text="Product Output Tasks")
col2.template_list(
"BIM_UL_product_output_tasks",
"",
self.props,
"product_output_tasks",
self.props,
"active_product_output_task_index",
)
@@ -0,0 +1,856 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import bpy
import isodate
from bpy.types import Panel
from typing import Any
import bonsai.tool as tool
import bonsai.bim.helper
from bonsai.bim.helper import draw_attributes
from bonsai.bim.module.sequence.data import SequenceData, WorkScheduleData, TaskICOMData
# Import the UIList of tasks from our new module
from .lists_ui import BIM_UL_tasks
class BIM_PT_status(Panel):
bl_label = "Status"
bl_idname = "BIM_PT_status"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_parent_id = "BIM_PT_tab_status"
bl_options = {"HIDE_HEADER"}
@classmethod
def poll(cls, context):
return tool.Ifc.get()
def draw(self, context):
self.props = tool.Sequence.get_status_props()
assert self.layout
if not self.props.is_enabled:
row = self.layout.row()
row.operator("bim.enable_status_filters", icon="GREASEPENCIL")
return
row = self.layout.row(align=True)
row.label(text="Elements Statuses:")
row.operator("bim.activate_status_filters", icon="FILE_REFRESH", text="")
row.operator("bim.disable_status_filters", icon="CANCEL", text="")
box = self.layout.box()
for status in self.props.statuses:
row = box.row(align=True)
row.label(text=status.name)
# Check if status has elements (simplified without StatusData)
if hasattr(status, 'has_elements') and status.has_elements:
row.label(text="", icon="ASSET_MANAGER")
row.prop(status, "is_visible", text="", emboss=False, icon="HIDE_OFF" if status.is_visible else "HIDE_ON")
row.operator("bim.select_status_filter", icon="RESTRICT_SELECT_OFF", text="").status = status.name
row.operator("bim.assign_status", icon="BRUSH_DATA", text="").status = status.name
class BIM_PT_work_schedules(Panel):
bl_label = "Work Schedules"
bl_idname = "BIM_PT_work_schedules"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_parent_id = "BIM_PT_tab_sequence"
@classmethod
def poll(cls, context):
file = tool.Ifc.get()
return file and hasattr(file, "schema") and file.schema != "IFC2X3"
def draw(self, context):
if not SequenceData.is_loaded:
SequenceData.load()
if not WorkScheduleData.is_loaded:
WorkScheduleData.load()
self.props = tool.Sequence.get_work_schedule_props()
self.tprops = tool.Sequence.get_task_tree_props()
if not self.props.active_work_schedule_id:
row = self.layout.row(align=True)
if SequenceData.data["has_work_schedules"]:
row.label(
text="{} Work Schedules Found".format(SequenceData.data["number_of_work_schedules_loaded"]),
icon="TEXT",
)
else:
row.label(text="No Work Schedules found.", icon="TEXT")
row.operator("bim.add_work_schedule", text="", icon="ADD")
row.operator("bim.import_work_schedule_csv", text="", icon="IMPORT")
for work_schedule_id, work_schedule in SequenceData.data["work_schedules"].items():
self.draw_work_schedule_ui(work_schedule_id, work_schedule)
def draw_work_schedule_ui(self, work_schedule_id: int, work_schedule: dict[str, Any]) -> None:
assert self.layout
# BASELINE schedules should be editable normally.
# Only use readonly UI for specific cases, not for the BASELINE type
# if work_schedule["PredefinedType"] == "BASELINE":
# self.draw_readonly_work_schedule_ui(work_schedule_id)
# else:
row = self.layout.row(align=True)
if self.props.active_work_schedule_id == work_schedule_id:
row.label(
text="Currently editing: {}[{}]".format(work_schedule["Name"], work_schedule["PredefinedType"]),
icon="LINENUMBERS_ON",
)
if self.props.editing_type == "WORK_SCHEDULE":
row.operator("bim.edit_work_schedule", text="", icon="CHECKMARK")
row.operator("bim.disable_editing_work_schedule", text="", icon="CANCEL")
elif self.props.editing_type == "TASKS":
grid = self.layout.grid_flow(columns=2, even_columns=True)
col = grid.column()
row1 = col.row(align=True)
row1.alignment = "LEFT"
row1.label(text="Schedule tools")
row1 = col.row(align=True)
row1.alignment = "RIGHT"
row1.operator("bim.generate_gantt_chart", text="Generate Gantt", icon="NLA").work_schedule = (
work_schedule_id
)
row1.operator(
"bim.recalculate_schedule", text="Re-calculate Schedule", icon="FILE_REFRESH"
).work_schedule = work_schedule_id
row2 = col.row(align=True)
row2.alignment = "RIGHT"
row2.operator(
"bim.select_work_schedule_products", text="Select Assigned", icon="RESTRICT_SELECT_OFF"
).work_schedule = work_schedule_id
row2.operator(
"bim.select_unassigned_work_schedule_products",
text="Select Unassigned",
icon="RESTRICT_SELECT_OFF",
).work_schedule = work_schedule_id
if WorkScheduleData.data["can_have_baselines"]:
row3 = col.row()
row3.alignment = "RIGHT"
row3.prop(self.props, "should_show_schedule_baseline_ui", icon="RESTRICT_INSTANCED_OFF")
col = grid.column()
row1 = col.row(align=True)
row1.alignment = "LEFT"
row1.label(text="Settings")
row1 = col.row(align=True)
row1.alignment = "RIGHT"
row1.prop(self.props, "should_show_column_ui", text="Schedule Columns", toggle=True, icon="SHORTDISPLAY")
row1.prop(self.props.filters, "show_filters", text="Filter Tasks", toggle=True, icon="FILTER")
# --- COPY 3D AND SYNC 3D BUTTONS ---
row_io = col.row(align=True)
row_io.alignment = 'RIGHT'
row_io.operator("bim.copy_3d", text="Copy 3D", icon="DUPLICATE")
row_io.operator("bim.sync_3d", text="Sync 3D", icon="FILE_REFRESH")
# --- COPY 3D AND SYNC 3D BUTTONS ---
row2 = col.row(align=True)
row.operator("bim.disable_editing_work_schedule", text="Cancel", icon="CANCEL")
else:
# Show the 4 buttons for any schedule
grid = self.layout.grid_flow(columns=2, even_columns=True)
col1 = grid.column()
col1.label(
text="{}[{}]".format(work_schedule["Name"], work_schedule["PredefinedType"]) or "Unnamed",
icon="LINENUMBERS_ON",
)
col2 = grid.column()
row = col2.row(align=True)
row.alignment = "RIGHT"
row.operator("bim.enable_editing_work_schedule_tasks", text="", icon="ACTION").work_schedule = (
work_schedule_id
)
row.operator("bim.enable_editing_work_schedule", text="", icon="GREASEPENCIL").work_schedule = (
work_schedule_id
)
row.operator("bim.copy_work_schedule", text="", icon="DUPLICATE").work_schedule = work_schedule_id
row.operator("bim.remove_work_schedule", text="", icon="X").work_schedule = work_schedule_id
# Additional UI when this specific schedule is being edited
if self.props.active_work_schedule_id == work_schedule_id:
if self.props.editing_type == "WORK_SCHEDULE":
self.draw_editable_work_schedule_ui()
elif self.props.editing_type == "TASKS":
self.draw_baseline_ui(work_schedule_id)
self.draw_column_ui()
# RETURN TO THE ORIGINAL SYSTEM - Only call if activated
if getattr(self.props.filters, "show_filters", False):
self.draw_filter_ui()
self.draw_editable_task_ui(work_schedule_id)
def draw_task_operators(self) -> None:
row = self.layout.row(align=True)
row.alignment = "RIGHT"
ifc_definition_id = None
if self.tprops.tasks and self.props.active_task_index < len(self.tprops.tasks):
task = self.tprops.tasks[self.props.active_task_index]
ifc_definition_id = task.ifc_definition_id
if ifc_definition_id:
if self.props.active_task_id:
if self.props.editing_task_type == "TASKTIME":
row.operator("bim.edit_task_time", text="", icon="CHECKMARK")
elif self.props.editing_task_type == "ATTRIBUTES":
row.operator("bim.edit_task", text="", icon="CHECKMARK")
row.operator("bim.disable_editing_task", text="Cancel", icon="CANCEL")
elif self.props.editing_task_type == "SEQUENCE":
row.operator("bim.disable_editing_task", text="Cancel", icon="CANCEL")
else:
row.prop(self.props, "show_task_operators", text="Edit", icon="GREASEPENCIL")
if self.props.show_task_operators:
row2 = self.layout.row(align=True)
row2.alignment = "RIGHT"
row2.prop(self.props, "enable_reorder", text="", icon="SORTALPHA")
row2.operator("bim.enable_editing_task_sequence", text="", icon="TRACKING")
row2.operator("bim.enable_editing_task_time", text="", icon="TIME").task = ifc_definition_id
row2.operator("bim.enable_editing_task_calendar", text="", icon="VIEW_ORTHO").task = (
ifc_definition_id
)
row2.operator("bim.enable_editing_task_attributes", text="", icon="GREASEPENCIL").task = (
ifc_definition_id
)
row.operator("bim.add_task", text="Add", icon="ADD").task = ifc_definition_id
row.operator("bim.duplicate_task", text="Copy", icon="DUPLICATE").task = ifc_definition_id
row.operator("bim.remove_task", text="Delete", icon="X").task = ifc_definition_id
def draw_column_ui(self) -> None:
if not self.props.should_show_column_ui:
return
assert self.layout
row = self.layout.row()
row.operator("bim.setup_default_task_columns", text="Setup Default Columns", icon="ANCHOR_BOTTOM")
row.alignment = "RIGHT"
row = self.layout.row(align=True)
row.prop(self.props, "column_types", text="")
column_type = self.props.column_types
if column_type == "IfcTask":
row.prop(self.props, "task_columns", text="")
name, data_type = self.props.task_columns.split("/")
elif column_type == "IfcTaskTime":
row.prop(self.props, "task_time_columns", text="")
name, data_type = self.props.task_time_columns.split("/")
elif column_type == "Special":
row.prop(self.props, "other_columns", text="")
column_type, name = self.props.other_columns.split(".")
data_type = "string"
row.operator("bim.set_task_sort_column", text="", icon="SORTALPHA").column = f"{column_type}.{name}"
row.prop(
self.props, "is_sort_reversed", text="", icon="SORT_DESC" if self.props.is_sort_reversed else "SORT_ASC"
)
op = row.operator("bim.add_task_column", text="", icon="ADD")
op.column_type = column_type
op.name = name
op.data_type = data_type
self.layout.template_list("BIM_UL_task_columns", "", self.props, "columns", self.props, "active_column_index")
# Replace the existing draw_filter_ui method in ui.py with this one
def draw_filter_ui(self) -> None:
"""Draws the filter configuration panel with the final corrected structure."""
props = self.props
if not getattr(props.filters, "show_filters", False):
return
main_box = self.layout.box()
# 1. Static title "Smart Filter"
header_row = main_box.row(align=True)
header_row.label(text="Smart Filter", icon="FILTER")
# Date source selector for filters, making it more accessible.
date_source_row = main_box.row(align=True)
date_source_row.label(text="Filter Date Source:")
date_source_row.prop(props, "date_source_type", text="")
# 2. Active filters panel
active_filters_box = main_box.box()
row = active_filters_box.row(align=True)
row.prop(props.filters, "logic", text="")
# Presets Menu
row.operator_menu_enum("bim.apply_lookahead_filter", "time_window", text="WLA", icon="TIME")
row.operator("bim.add_task_filter", text="", icon='ADD')
row.operator("bim.remove_task_filter", text="", icon='REMOVE')
row.separator()
row.operator("bim.apply_task_filters", text="Apply Filters", icon="FILE_REFRESH")
row.operator("bim.clear_all_task_filters", text="Clean", icon="CANCEL")
active_filters_box.template_list(
"BIM_UL_task_filters", "",
props.filters, "rules",
props.filters, "active_rule_index"
)
active_filters_count = len([r for r in props.filters.rules if r.is_active])
if active_filters_count > 0:
info_row = active_filters_box.row()
info_row.label(text=f"[INFO] {active_filters_count} active filter(s)", icon='INFO')
# 3. Saved Filters Panel (now collapsible)
saved_filters_box = main_box.box()
row = saved_filters_box.row(align=True)
# The title is now a button to show/hide the section
icon = 'TRIA_DOWN' if props.filters.show_saved_filters else 'TRIA_RIGHT'
row.prop(props.filters, "show_saved_filters", text="Saved Filters", icon=icon, emboss=False)
# --- END OF MODIFICATION ---
# The content is only drawn if the section is expanded
if props.filters.show_saved_filters:
saved_filters_box.template_list(
"BIM_UL_saved_filter_sets", "",
props, "saved_filter_sets",
props, "active_saved_filter_set_index"
)
row_ops = saved_filters_box.row(align=True)
row_ops.enabled = len(props.saved_filter_sets) > 0
load_op = row_ops.operator("bim.load_filter_set", text="Load", icon="FILE_TICK")
load_op.set_index = props.active_saved_filter_set_index
update_op = row_ops.operator("bim.update_saved_filter_set", text="Update", icon="FILE_REFRESH")
update_op.set_index = props.active_saved_filter_set_index
remove_op = row_ops.operator("bim.remove_filter_set", text="Remove", icon="TRASH")
remove_op.set_index = props.active_saved_filter_set_index
row_io = saved_filters_box.row(align=True)
row_io.operator("bim.save_filter_set", text="Save Current", icon="PINNED")
row_io.operator("bim.import_filter_set", text="Import Library", icon="IMPORT")
row_io.operator("bim.export_filter_set", text="Export Library", icon="EXPORT")
def draw_editable_work_schedule_ui(self):
draw_attributes(self.props.work_schedule_attributes, self.layout)
def draw_editable_task_ui(self, work_schedule_id: int) -> None:
assert self.layout
# The call to self.draw_filter_ui() was removed from here
# as it is correctly called in draw_work_schedule_ui()
row = self.layout.row(align=True)
row.label(text="Task Tools")
row = self.layout.row(align=True)
# 1. Split the row. The first column (left) will be for the checkbox.
# We use a small factor to give it limited space.
split = row.split(factor=0.15)
# 2. Place the checkbox in the left column.
# It is now contained and cannot stretch beyond this 25%.
col_izquierda = split.column()
col_izquierda.prop(self.props, "should_select_3d_on_task_click", text="3D Task", icon="RESTRICT_SELECT_OFF")
# 3. Use the second column for the buttons on the right.
col_derecha = split.column()
# 4. Create a sub-row INSIDE the right column to align the buttons.
sub_fila_botones = col_derecha.row(align=True)
sub_fila_botones.alignment = 'RIGHT' # This sticks them to the right of THEIR column.
sub_fila_botones.operator("bim.refresh_task_output_counts", text="", icon="FILE_REFRESH")
sub_fila_botones.operator("bim.add_summary_task", text="Add Summary Task", icon="ADD").work_schedule = work_schedule_id
sub_fila_botones.operator("bim.expand_all_tasks", text="Expand All")
sub_fila_botones.operator("bim.contract_all_tasks", text="Contract All")
row = self.layout.row(align=True)
self.draw_task_operators()
BIM_UL_tasks.draw_header(self.layout)
self.layout.template_list(
"BIM_UL_tasks",
"",
self.tprops,
"tasks",
self.props,
"active_task_index",
)
if self.props.active_task_id and self.props.editing_task_type == "ATTRIBUTES":
self.draw_editable_task_attributes_ui()
elif self.props.active_task_id and self.props.editing_task_type == "CALENDAR":
self.draw_editable_task_calendar_ui()
elif self.props.highlighted_task_id and self.props.editing_task_type == "SEQUENCE":
self.draw_editable_task_sequence_ui()
elif self.props.active_task_time_id and self.props.editing_task_type == "TASKTIME":
self.draw_editable_task_time_attributes_ui()
def draw_editable_task_sequence_ui(self):
task = SequenceData.data["tasks"][self.props.highlighted_task_id]
row = self.layout.row()
row.label(text="{} Predecessors".format(len(task["IsSuccessorFrom"])), icon="BACK")
for sequence_id in task["IsSuccessorFrom"]:
self.draw_editable_sequence_ui(SequenceData.data["sequences"][sequence_id], "RelatingProcess")
row = self.layout.row()
row.label(text="{} Successors".format(len(task["IsPredecessorTo"])), icon="FORWARD")
for sequence_id in task["IsPredecessorTo"]:
self.draw_editable_sequence_ui(SequenceData.data["sequences"][sequence_id], "RelatedProcess")
def draw_editable_sequence_ui(self, sequence, process_type):
task = SequenceData.data["tasks"][sequence[process_type]]
row = self.layout.row(align=True)
row.operator("bim.go_to_task", text="", icon="RESTRICT_SELECT_OFF").task = task["id"]
row.label(text=task["Identification"] or "XXX")
row.label(text=task["Name"] or "Unnamed")
row.label(text=sequence["SequenceType"] or "N/A")
if sequence["TimeLag"]:
row.operator("bim.unassign_lag_time", text="", icon="X").sequence = sequence["id"]
row.label(text=isodate.duration_isoformat(SequenceData.data["lag_times"][sequence["TimeLag"]]["LagValue"]))
else:
row.operator("bim.assign_lag_time", text="Add Time Lag", icon="ADD").sequence = sequence["id"]
if self.props.active_sequence_id == sequence["id"]:
if self.props.editing_sequence_type == "ATTRIBUTES":
row.operator("bim.edit_sequence_attributes", text="", icon="CHECKMARK")
row.operator("bim.disable_editing_sequence", text="Cancel", icon="CANCEL")
self.draw_editable_sequence_attributes_ui()
elif self.props.editing_sequence_type == "LAG_TIME":
op = row.operator("bim.edit_sequence_lag_time", text="", icon="CHECKMARK")
op.lag_time = sequence["TimeLag"]
row.operator("bim.disable_editing_sequence", text="Cancel", icon="CANCEL")
self.draw_editable_sequence_lag_time_ui()
else:
if sequence["TimeLag"]:
op = row.operator("bim.enable_editing_sequence_lag_time", text="Edit Time Lag", icon="CON_LOCKTRACK")
op.sequence = sequence["id"]
op.lag_time = sequence["TimeLag"]
op = row.operator("bim.enable_editing_sequence_attributes", text="Edit Sequence", icon="GREASEPENCIL")
op.sequence = sequence["id"]
if process_type == "RelatingProcess":
op = row.operator("bim.unassign_predecessor", text="", icon="X")
elif process_type == "RelatedProcess":
op = row.operator("bim.unassign_successor", text="", icon="X")
op.task = task["id"]
def draw_editable_sequence_attributes_ui(self):
bonsai.bim.helper.draw_attributes(self.props.sequence_attributes, self.layout)
def draw_editable_sequence_lag_time_ui(self):
bonsai.bim.helper.draw_attributes(self.props.lag_time_attributes, self.layout)
def draw_editable_task_calendar_ui(self):
task = SequenceData.data["tasks"][self.props.active_task_id]
if task["HasAssignmentsWorkCalendar"]:
row = self.layout.row(align=True)
calendar = SequenceData.data["work_calendars"][task["HasAssignmentsWorkCalendar"][0]]
row.label(text=calendar["Name"] or "Unnamed")
op = row.operator("bim.remove_task_calendar", text="", icon="X")
op.work_calendar = task["HasAssignmentsWorkCalendar"][0]
op.task = self.props.active_task_id
elif SequenceData.data["has_work_calendars"]:
row = self.layout.row(align=True)
row.prop(self.props, "work_calendars", text="")
op = row.operator("bim.edit_task_calendar", text="", icon="ADD")
op.work_calendar = int(self.props.work_calendars)
op.task = self.props.active_task_id
else:
row = self.layout.row(align=True)
row.label(text="Must Create a Calendar First. See Work Calendar Panel", icon="INFO")
def draw_editable_task_attributes_ui(self):
# Draw attributes but inject Animation Color Schemes after 'Priority'
try:
attrs = [a for a in self.props.task_attributes if a.name != "PredefinedType"]
except Exception:
attrs = list(self.props.task_attributes)
# Split at Priority
before = []
after = []
found = False
for a in attrs:
before.append(a)
if a.name == "Priority":
found = True
break
if found:
after = attrs[len(before):]
import bonsai.bim.helper as _h
_h.draw_attributes(before, self.layout, copy_operator="bim.copy_task_attribute")
# --- Draw PredefinedType exactly below Priority (as in Blender 4.2.1) ---
try:
_predef = None
for _a in self.props.task_attributes:
if getattr(_a, "name", "") == "PredefinedType":
_predef = _a
break
if _predef is not None:
_h.draw_attributes([_predef], self.layout, copy_operator="bim.copy_task_attribute")
except Exception:
pass
# --- end PredefinedType ---
# Ensures that the active task has its DEFAULT group synchronized when drawn
try:
from ..prop.animation import UnifiedColorTypeManager
tprops = tool.Sequence.get_task_tree_props()
if tprops.tasks and self.props.active_task_index < len(tprops.tasks):
active_task_pg = tprops.tasks[self.props.active_task_index]
# Call to the central logic to synchronize at the time of drawing
# Only if there are no custom groups
user_groups = UnifiedColorTypeManager.get_user_created_groups(bpy.context)
if not user_groups:
UnifiedColorTypeManager.sync_default_group_to_predefinedtype(bpy.context, active_task_pg)
except Exception as e:
# It should not break the UI if something fails
print(f"⚠ Error synchronizing DEFAULT in the UI: {e}")
# === Custom Appearance Groups ===
try:
if self.tprops.tasks and self.props.active_task_index < len(self.tprops.tasks):
_task = self.tprops.tasks[self.props.active_task_index]
animation_props = tool.Sequence.get_animation_props()
# Use the correctly implemented functions
all_groups = UnifiedColorTypeManager.get_all_groups(bpy.context)
user_groups = UnifiedColorTypeManager.get_user_created_groups(bpy.context)
# Show information of selected tasks
selected_count = len([task for task in self.tprops.tasks if getattr(task, 'is_selected', False)])
# Always show the section if there are available groups
if all_groups:
box = self.layout.box()
# Header with information
header_row = box.row(align=True)
header_row.label(text="colortype Group Assignment:", icon="GROUP")
# Information of selected tasks
if selected_count > 0:
info_row = box.row()
info_row.label(text=f"📋 {selected_count} tasks selected for copying", icon='INFO')
# Copy button
copy_row = box.row(align=True)
copy_op = copy_row.operator("bim.copy_task_custom_colortype_group", text="Copy Configuration to Selected", icon="COPYDOWN")
copy_op.enabled = selected_count > 0
# Custom group selector (only custom groups)
if user_groups:
row = box.row(align=True)
row.label(text="Custom Group:")
row.prop(animation_props, "task_colortype_group_selector", text="")
# Show profile selector if a group is selected
current_group = getattr(animation_props, 'task_colortype_group_selector', '')
if current_group and current_group != "DEFAULT":
row = box.row(align=True)
row.label(text="colortype:")
row.prop(_task, "selected_colortype_in_active_group", text="")
# Toggle to enable/disable
if getattr(_task, "selected_colortype_in_active_group", ""):
row = box.row(align=True)
row.prop(_task, "use_active_colortype_group", text="Enable custom assignment")
else:
# Show message when no group is selected
info_row = box.row()
info_row.label(text="[INFO] Select a custom group to assign colortypes", icon='INFO')
else:
# Message when there are no custom groups
info_row = box.row()
info_row.label(text="[INFO] No custom groups available. Create one in Animation Color Scheme.", icon='INFO')
# Collapsible section of saved profiles (simplified)
row_saved = self.layout.row(align=True)
icon = 'TRIA_DOWN' if self.props.show_saved_colortypes_section else 'TRIA_RIGHT'
row_saved.prop(self.props, "show_saved_colortypes_section", text="colortype Assignments Summary", icon=icon, emboss=False)
if self.props.show_saved_colortypes_section:
sumbox = self.layout.box()
# Show assignments of the current task
if hasattr(_task, "colortype_group_choices") and _task.colortype_group_choices:
# Sort: DEFAULT first, then alphabetically
sorted_choices = sorted(_task.colortype_group_choices,
key=lambda x: (x.group_name != "DEFAULT", x.group_name))
for choice in sorted_choices:
row = sumbox.row(align=True)
# Different icon for DEFAULT vs custom
icon = 'PINNED' if choice.group_name == "DEFAULT" else 'DOT'
if choice.enabled:
icon = 'RADIOBUT_ON' if choice.group_name != "DEFAULT" else 'PINNED'
# Show information
colortype_name = choice.selected_colortype or "(no colortype)"
status = "" if choice.enabled else ""
row.label(text=f"{status} {choice.group_name}{colortype_name}", icon=icon)
else:
# Initialize if there is no data
info_row = sumbox.row()
info_row.label(text="No colortype assignments found", icon='INFO')
init_button = sumbox.row()
init_button.operator('bim.initialize_colortype_system', text="Initialize colortype Assignments", icon='PLUS')
except Exception as e:
# Fallback si algo falla
# Fallback if something fails
error_box = self.layout.box()
error_box.label(text=f"colortype system error: {str(e)}", icon='ERROR')
error_box.operator('bim.initialize_colortype_system', text="Repair colortype System", icon='TOOL_SETTINGS')
def draw_editable_task_time_attributes_ui(self):
bonsai.bim.helper.draw_attributes(self.props.task_time_attributes, self.layout)
def draw_baseline_ui(self, work_schedule_id):
if not self.props.should_show_schedule_baseline_ui:
return
row3 = self.layout.row()
row3.alignment = "RIGHT"
row3.operator("bim.create_baseline", text="Add Baseline", icon="ADD").work_schedule = work_schedule_id
if WorkScheduleData.data["active_work_schedule_baselines"]:
for baseline in WorkScheduleData.data["active_work_schedule_baselines"]:
baseline_row = self.layout.row()
baseline_row.alignment = "RIGHT"
baseline_row.label(
text="{} @ {}".format(baseline["name"], baseline["date"]), icon="RESTRICT_INSTANCED_OFF"
)
baseline_row.operator("bim.generate_gantt_chart", text="Compare", icon="NLA").work_schedule = baseline[
"id"
]
baseline_row.operator(
"bim.enable_editing_work_schedule_tasks", text="Display Schedule", icon="ACTION"
).work_schedule = baseline["id"]
baseline_row.operator("bim.remove_work_schedule", text="", icon="X").work_schedule = baseline["id"]
def draw_readonly_work_schedule_ui(self, work_schedule_id):
if self.props.active_work_schedule_id == work_schedule_id:
row = self.layout.row()
row.alignment = "RIGHT"
row.operator("bim.disable_editing_work_schedule", text="Disable editing", icon="CANCEL")
grid = self.layout.grid_flow(columns=2, even_columns=True)
col = grid.column()
row1 = col.row(align=True)
row1.alignment = "LEFT"
row1.label(text="Settings")
row1 = col.row(align=True)
row1.alignment = "RIGHT"
row1.prop(self.props, "should_show_column_ui", text="Schedule Columns", icon="SHORTDISPLAY")
row2 = col.row(align=True)
if self.props.editing_type == "TASKS":
self.draw_column_ui()
# self.draw_filter_ui() - esto puede estar causando el problema
self.layout.template_list(
"BIM_UL_tasks",
"",
self.tprops,
"tasks",
self.props,
"active_task_index",
)
else:
# dd a bar with 4 buttons for BASELINE when it is not active
# This is similar to lines 254-271 but for the BASELINE case
# This function should no longer be used for BASELINE schedules
# BASELINEs now use the normal UI to allow full editing
work_schedule = SequenceData.data["work_schedules"].get(work_schedule_id, {})
grid = self.layout.grid_flow(columns=2, even_columns=True)
col1 = grid.column()
col1.label(
text="{}[{}]".format(work_schedule.get("Name", "Unnamed"), work_schedule.get("PredefinedType", "BASELINE")),
icon="LINENUMBERS_ON",
)
col2 = grid.column()
row = col2.row(align=True)
row.alignment = "RIGHT"
# The 4 specific buttons that should appear for readonly cases
row.operator("bim.enable_editing_work_schedule_tasks", text="", icon="ACTION").work_schedule = work_schedule_id
row.operator("bim.enable_editing_work_schedule", text="", icon="GREASEPENCIL").work_schedule = work_schedule_id
row.operator("bim.copy_work_schedule", text="", icon="DUPLICATE").work_schedule = work_schedule_id
row.operator("bim.remove_work_schedule", text="", icon="X").work_schedule = work_schedule_id
class BIM_PT_task_icom(Panel):
bl_label = "Task ICOM"
bl_idname = "BIM_PT_task_icom"
bl_options = {"DEFAULT_CLOSED"}
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_parent_id = "BIM_PT_work_schedules"
bl_order = 1
@classmethod
def poll(cls, context):
props = tool.Sequence.get_work_schedule_props()
if not props.active_work_schedule_id:
return False
tprops = tool.Sequence.get_task_tree_props()
total_tasks = len(tprops.tasks)
if total_tasks > 0 and props.active_task_index < total_tasks:
return True
return False
def draw(self, context):
if not TaskICOMData.is_loaded:
TaskICOMData.load()
self.props = tool.Sequence.get_work_schedule_props()
self.tprops = tool.Sequence.get_task_tree_props()
task = self.tprops.tasks[self.props.active_task_index]
grid = self.layout.grid_flow(columns=3, even_columns=True)
# Column1
col = grid.column()
row2 = col.row(align=True)
total_task_inputs = len(self.props.task_inputs)
row2.label(text="Inputs ({})".format(total_task_inputs))
if context.selected_objects:
op = row2.operator("bim.assign_process", icon="ADD", text="")
op.task = task.ifc_definition_id
op.related_object_type = "PRODUCT"
if total_task_inputs:
op = row2.operator("bim.unassign_process", icon="REMOVE", text="")
op.task = task.ifc_definition_id
op.related_object_type = "PRODUCT"
if not context.selected_objects and self.props.active_task_input_index < total_task_inputs:
input_id = self.props.task_inputs[self.props.active_task_input_index].ifc_definition_id
op.related_object = input_id
op = row2.operator("bim.select_task_related_inputs", icon="RESTRICT_SELECT_OFF", text="Select")
op.task = task.ifc_definition_id
row2 = col.row()
row2.prop(self.props, "show_nested_inputs", text="Show Nested")
row2 = col.row()
row2.template_list("BIM_UL_task_inputs", "", self.props, "task_inputs", self.props, "active_task_input_index")
# Column2
col = grid.column()
row2 = col.row(align=True)
total_task_resources = len(self.props.task_resources)
row2.label(text="Resources ({})".format(total_task_resources))
op = row2.operator("bim.calculate_task_duration", text="", icon="TEMP")
op.task = task.ifc_definition_id
if TaskICOMData.data["can_active_resource_be_assigned"]:
op = row2.operator("bim.assign_process", icon="ADD", text="")
op.task = task.ifc_definition_id
op.related_object_type = "RESOURCE"
if total_task_resources and self.props.active_task_resource_index < total_task_resources:
op = row2.operator("bim.unassign_process", icon="REMOVE", text="")
op.task = task.ifc_definition_id
op.related_object_type = "RESOURCE"
op.resource = self.props.task_resources[self.props.active_task_resource_index].ifc_definition_id
row2 = col.row()
row2.prop(self.props, "show_nested_resources", text="Show Nested")
row2 = col.row()
row2.template_list(
"BIM_UL_task_resources", "", self.props, "task_resources", self.props, "active_task_resource_index"
)
# Column3
col = grid.column()
row2 = col.row(align=True)
total_task_outputs = len(self.props.task_outputs)
row2.label(text="Outputs ({})".format(total_task_outputs))
if context.selected_objects:
op = row2.operator("bim.assign_product", icon="ADD", text="")
op.task = task.ifc_definition_id
if total_task_outputs:
op = row2.operator("bim.unassign_product", icon="REMOVE", text="")
op.task = task.ifc_definition_id
if (
total_task_outputs
and not context.selected_objects
and self.props.active_task_output_index < total_task_outputs
):
output_id = self.props.task_outputs[self.props.active_task_output_index].ifc_definition_id
op.relating_product = output_id
op = row2.operator("bim.select_task_related_products", icon="RESTRICT_SELECT_OFF", text="Select")
op.task = task.ifc_definition_id
row2 = col.row()
row2.prop(self.props, "show_nested_outputs", text="Show Nested")
row2 = col.row()
row2.template_list(
"BIM_UL_task_outputs", "", self.props, "task_outputs", self.props, "active_task_output_index"
)
class BIM_PT_variance_analysis(Panel):
bl_label = "Variance Analysis"
bl_idname = "BIM_PT_variance_analysis"
bl_options = {"DEFAULT_CLOSED"}
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_parent_id = "BIM_PT_tab_sequence"
@classmethod
def poll(cls, context):
props = tool.Sequence.get_work_schedule_props()
# Only show if a schedule is being edited for tasks
return props.active_work_schedule_id and props.editing_type == "TASKS"
def draw(self, context):
layout = self.layout
props = tool.Sequence.get_work_schedule_props()
row = layout.row(align=True)
row.prop(props, "variance_source_a", text="Compare")
row.prop(props, "variance_source_b", text="With")
row.operator("bim.calculate_schedule_variance", text="Calculate", icon="PLAY")
row.operator("bim.clear_schedule_variance", text="", icon="TRASH")
# Variance color mode controls
is_variance_active = context.scene.get('BIM_VarianceColorModeActive', False)
if is_variance_active:
col = layout.column(align=True)
col.separator()
variance_row = col.row(align=True)
variance_row.label(text="Variance Mode Active", icon="RESTRICT_COLOR_OFF")
variance_row.operator("bim.deactivate_variance_color_mode", text="Deactivate", icon="CANCEL")
@@ -0,0 +1,29 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
"""
Bonsai utilities package for shared common functionality.
"""
# Export all utility modules for external access
from . import helper_utils
from . import colortype_cache
from . import performance_cache
from . import ifc_lookup
from . import batch_processor
from . import color_schemes_utils
@@ -0,0 +1,217 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
# Batch Processor for Blender Operations
# Processes multiple objects in batches for maximum performance
import bpy
import time
from typing import List, Dict, Tuple, Any
from collections import defaultdict
class BlenderBatchProcessor:
"""Ultra-efficient batch processor for massive operations in Blender"""
def __init__(self, batch_size: int = 500):
self.batch_size = batch_size
self.visibility_operations = []
self.color_operations = []
self.keyframe_operations = []
def add_visibility_operation(self, obj, frame: int, hide_viewport: bool, hide_render: bool):
"""Adds a visibility operation to the batch"""
self.visibility_operations.append({
'obj': obj,
'frame': frame,
'hide_viewport': hide_viewport,
'hide_render': hide_render
})
def add_color_operation(self, obj, frame: int, color: Tuple[float, float, float, float]):
"""Adds a color operation to the batch"""
self.color_operations.append({
'obj': obj,
'frame': frame,
'color': color
})
def add_keyframe_operation(self, obj, frame: int, data_path: str, value: Any):
"""Adds a keyframe operation to the batch"""
self.keyframe_operations.append({
'obj': obj,
'frame': frame,
'data_path': data_path,
'value': value
})
def execute_visibility_batch(self):
"""Executes all visibility operations in a batch"""
if not self.visibility_operations:
return
start_time = time.time()
# Group by frame to minimize context switches
operations_by_frame = defaultdict(list)
for op in self.visibility_operations:
operations_by_frame[op['frame']].append(op)
total_ops = 0
for frame, ops in operations_by_frame.items():
bpy.context.scene.frame_set(frame)
# Process in batches
for i in range(0, len(ops), self.batch_size):
batch = ops[i:i + self.batch_size]
for op in batch:
obj = op['obj']
obj.hide_viewport = op['hide_viewport']
obj.hide_render = op['hide_render']
# CRITICAL: Insert keyframes to create animation
obj.keyframe_insert(data_path="hide_viewport", frame=frame)
obj.keyframe_insert(data_path="hide_render", frame=frame)
total_ops += len(batch)
elapsed = time.time() - start_time
print(f"[INFO] BATCH: {total_ops} visibility ops in {elapsed:.2f}s")
self.visibility_operations.clear()
def execute_color_batch(self):
"""Executes all color operations in a batch"""
if not self.color_operations:
return
start_time = time.time()
# Group by frame
operations_by_frame = defaultdict(list)
for op in self.color_operations:
operations_by_frame[op['frame']].append(op)
total_ops = 0
for frame, ops in operations_by_frame.items():
bpy.context.scene.frame_set(frame)
for i in range(0, len(ops), self.batch_size):
batch = ops[i:i + self.batch_size]
for op in batch:
op['obj'].color = op['color']
# CRITICAL: Insert keyframes to create animation
op['obj'].keyframe_insert(data_path="color", frame=frame)
total_ops += len(batch)
elapsed = time.time() - start_time
print(f"[INFO] BATCH: {total_ops} color ops in {elapsed:.2f}s")
self.color_operations.clear()
def execute_keyframe_batch(self):
"""Executes all keyframe operations in a batch"""
if not self.keyframe_operations:
return
start_time = time.time()
# Group by object and data_path for consecutive keyframes
grouped_ops = defaultdict(lambda: defaultdict(list))
for op in self.keyframe_operations:
obj_key = op['obj'].name
grouped_ops[obj_key][op['data_path']].append(op)
total_ops = 0
for obj_name, data_paths in grouped_ops.items():
obj = bpy.data.objects.get(obj_name)
if not obj:
continue
for data_path, ops in data_paths.items():
# Sort by frame for efficient sequential keyframing
ops.sort(key=lambda x: x['frame'])
for op in ops:
frame = op['frame']
value = op['value']
# Set value
try:
if data_path == 'color':
obj.color = value
elif data_path == 'hide_viewport':
obj.hide_viewport = value
elif data_path == 'hide_render':
obj.hide_render = value
# Insert keyframe
obj.keyframe_insert(data_path=data_path, frame=frame)
total_ops += 1
except Exception as e:
print(f"[WARNING] Keyframe error {obj_name}.{data_path}@{frame}: {e}")
elapsed = time.time() - start_time
print(f"[INFO] BATCH: {total_ops} keyframes in {elapsed:.2f}s")
self.keyframe_operations.clear()
def execute_all_batches(self):
"""Executes all pending batches"""
self.execute_visibility_batch()
self.execute_color_batch()
self.execute_keyframe_batch()
def clear_all(self):
"""Clears all pending batches"""
self.visibility_operations.clear()
self.color_operations.clear()
self.keyframe_operations.clear()
class VisibilityBatchOptimizer:
"""Specific optimizer for massive visibility operations"""
@staticmethod
def batch_hide_objects(objects_to_hide: List, objects_to_show: List):
"""Hides/shows objects in an ultra-efficient batch"""
start_time = time.time()
# Hide in batch
for obj in objects_to_hide:
obj.hide_viewport = True
obj.hide_render = True
# Show in batch
for obj in objects_to_show:
obj.hide_viewport = False
obj.hide_render = False
elapsed = time.time() - start_time
total = len(objects_to_hide) + len(objects_to_show)
print(f"[INFO] VISIBILITY BATCH: {total} objects in {elapsed:.2f}s")
@staticmethod
def batch_set_colors(objects_with_colors: List[Tuple]):
"""Sets colors in batch (obj, color)"""
start_time = time.time()
for obj, color in objects_with_colors:
obj.color = color
elapsed = time.time() - start_time
print(f"[INFO] COLOR BATCH: {len(objects_with_colors)} colors set in {elapsed:.2f}s")
@@ -0,0 +1,216 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
"""
Color scheme utility functions for animation and display management.
Consolidated from duplicate implementations to maintain consistency.
"""
import json
def ensure_default_group(context):
"""Ensures the DEFAULT group exists with 13 predefined profiles and complete properties.
Only creates DEFAULT group if no custom groups exist to avoid confusion.
Args:
context: Blender context
Returns:
bool: True if DEFAULT group was created or already existed
"""
scene = context.scene
key = "BIM_AnimationColorSchemesSets"
raw = scene.get(key, "{}")
try:
data = json.loads(raw) if isinstance(raw, str) else (raw or {})
if not isinstance(data, dict):
data = {}
except Exception:
data = {}
# Check if custom groups already exist
user_groups = [g for g in data.keys() if g != "DEFAULT"]
# Create DEFAULT if it does not exist AND there are no custom groups
if "DEFAULT" not in data:
if not user_groups:
default_colortypes = [
{"name": "CONSTRUCTION", "start_color": [1,1,1,0], "in_progress_color": [0,1,0,1], "end_color": [0.3,1,0.3,1]},
{"name": "INSTALLATION", "start_color": [1,1,1,0], "in_progress_color": [0,1,0,1], "end_color": [0.3,0.8,0.5,1]},
{"name": "DEMOLITION", "start_color": [1,1,1,1], "in_progress_color": [1,0,0,1], "end_color": [0,0,0,0]},
{"name": "REMOVAL", "start_color": [1,1,1,1], "in_progress_color": [1,0,0,1], "end_color": [0,0,0,0]},
{"name": "DISPOSAL", "start_color": [1,1,1,1], "in_progress_color": [1,0,0,1], "end_color": [0,0,0,0]},
{"name": "DISMANTLE", "start_color": [1,1,1,1], "in_progress_color": [1,0,0,1], "end_color": [0,0,0,0]},
{"name": "OPERATION", "start_color": [1,1,1,1], "in_progress_color": [0,0,1,1], "end_color": [1,1,1,1]},
{"name": "MAINTENANCE", "start_color": [1,1,1,1], "in_progress_color": [0,0,1,1], "end_color": [1,1,1,1]},
{"name": "ATTENDANCE", "start_color": [1,1,1,1], "in_progress_color": [0,0,1,1], "end_color": [1,1,1,1]},
{"name": "RENOVATION", "start_color": [1,1,1,1], "in_progress_color": [0,0,1,1], "end_color": [0.9,0.9,0.9,1]},
{"name": "LOGISTIC", "start_color": [1,1,1,1], "in_progress_color": [1,1,0,1], "end_color": [1,0.8,0.3,1]},
{"name": "MOVE", "start_color": [1,1,1,1], "in_progress_color": [1,1,0,1], "end_color": [0.8,0.6,0,1]},
{"name": "NOTDEFINED", "start_color": [0.7,0.7,0.7,1], "in_progress_color": [0.5,0.5,0.5,1], "end_color": [0.3,0.3,0.3,1]},
{"name": "USERDEFINED", "start_color": [0.7,0.7,0.7,1], "in_progress_color": [0.5,0.5,0.5,1], "end_color": [0.3,0.3,0.3,1]},
]
# Add complete properties to each colortype
for colortype in default_colortypes:
colortype.update({
"consider_start": False,
"consider_active": True,
"consider_end": True,
"use_start_original_color": False,
"use_active_original_color": False,
"use_end_original_color": colortype["name"] not in ["DEMOLITION", "REMOVAL", "DISPOSAL", "DISMANTLE"],
"start_transparency": 0.0,
"active_start_transparency": 0.0,
"active_finish_transparency": 0.0,
"active_transparency_interpol": 1.0,
"end_transparency": 0.0
})
data["DEFAULT"] = {"ColorTypes": default_colortypes}
scene[key] = json.dumps(data)
print("✅ DEFAULT ColorType group created with 13 predefined profiles")
return True
else:
print("️ DEFAULT group not created - custom groups already exist")
return False
else:
print("️ DEFAULT group already exists")
return True
def ensure_colortype_in_group(context, group_name, colortype_name):
"""Ensures a specific colortype exists in a group with default properties.
Args:
context: Blender context
group_name (str): Name of the group
colortype_name (str): Name of the colortype to ensure exists
Returns:
bool: True if colortype was created or already existed
"""
scene = context.scene
key = "BIM_AnimationColorSchemesSets"
raw = scene.get(key, "{}")
try:
data = json.loads(raw) if isinstance(raw, str) else (raw or {})
if not isinstance(data, dict):
data = {}
except Exception:
data = {}
# Ensure group exists
if group_name not in data:
data[group_name] = {"ColorTypes": []}
# Check if colortype already exists in group
existing_colortypes = data[group_name].get("ColorTypes", [])
colortype_names = [ct.get("name") for ct in existing_colortypes]
if colortype_name not in colortype_names:
# Create default colortype based on name
default_colortype = _get_default_colortype_properties(colortype_name)
existing_colortypes.append(default_colortype)
data[group_name]["ColorTypes"] = existing_colortypes
scene[key] = json.dumps(data)
print(f"✅ ColorType '{colortype_name}' added to group '{group_name}'")
return True
else:
print(f"️ ColorType '{colortype_name}' already exists in group '{group_name}'")
return False
def _get_default_colortype_properties(colortype_name):
"""Get default properties for a colortype based on its name.
Args:
colortype_name (str): Name of the colortype
Returns:
dict: Default properties for the colortype
"""
# Define color schemes based on colortype category
demolition_types = ["DEMOLITION", "REMOVAL", "DISPOSAL", "DISMANTLE"]
construction_types = ["CONSTRUCTION", "INSTALLATION"]
operation_types = ["OPERATION", "MAINTENANCE", "ATTENDANCE"]
logistic_types = ["LOGISTIC", "MOVE"]
if colortype_name in demolition_types:
base_colors = {
"start_color": [1,1,1,1],
"in_progress_color": [1,0,0,1],
"end_color": [0,0,0,0]
}
elif colortype_name in construction_types:
base_colors = {
"start_color": [1,1,1,0],
"in_progress_color": [0,1,0,1],
"end_color": [0.3,1,0.3,1] if colortype_name == "CONSTRUCTION" else [0.3,0.8,0.5,1]
}
elif colortype_name in operation_types:
base_colors = {
"start_color": [1,1,1,1],
"in_progress_color": [0,0,1,1],
"end_color": [1,1,1,1] if colortype_name in ["OPERATION", "MAINTENANCE", "ATTENDANCE"] else [0.9,0.9,0.9,1]
}
elif colortype_name in logistic_types:
base_colors = {
"start_color": [1,1,1,1],
"in_progress_color": [1,1,0,1],
"end_color": [1,0.8,0.3,1] if colortype_name == "LOGISTIC" else [0.8,0.6,0,1]
}
else:
# Default for NOTDEFINED, USERDEFINED, or unknown types
base_colors = {
"start_color": [0.7,0.7,0.7,1],
"in_progress_color": [0.5,0.5,0.5,1],
"end_color": [0.3,0.3,0.3,1]
}
return {
"name": colortype_name,
**base_colors,
"consider_start": False,
"consider_active": True,
"consider_end": True,
"use_start_original_color": False,
"use_active_original_color": False,
"use_end_original_color": colortype_name not in demolition_types,
"start_transparency": 0.0,
"active_start_transparency": 0.0,
"active_finish_transparency": 0.0,
"active_transparency_interpol": 1.0,
"end_transparency": 0.0
}
def get_available_colortypes():
"""Get list of all available standard colortypes.
Returns:
list: List of standard colortype names
"""
return [
"CONSTRUCTION", "INSTALLATION", "DEMOLITION", "REMOVAL",
"DISPOSAL", "DISMANTLE", "OPERATION", "MAINTENANCE",
"ATTENDANCE", "RENOVATION", "LOGISTIC", "MOVE",
"NOTDEFINED", "USERDEFINED"
]
@@ -0,0 +1,292 @@
"""
ColorType Cache Module - Ultra-fast Task-to-ColorProfile Mapping
==============================================================
PROBLEM SOLVED:
ColorType logic is computationally expensive when executed for 8,000 objects.
For each object, it must search the active group, check custom assignments,
fall back to PredefinedType, etc. This becomes the main bottleneck.
SOLUTION:
Pre-calculate the complex ColorType logic once per TASK (not per object).
Create an instant map: task_id -> color_profile_final.
BENEFIT:
- Pre-calculation: ~1 second
- Subsequent queries: instantaneous
- Accelerates: Animation Creation + Variance Calculation
USAGE:
cache = ColorTypeCache()
cache.build_cache(context)
color_profile = cache.get_task_color_profile(task_id)
"""
import bpy
import time
from typing import Dict, Optional, Tuple, Any
class ColorTypeCache:
"""Ultra-fast cache for task-to-color-profile mappings"""
def __init__(self):
self._cache: Dict[int, Dict[str, Any]] = {}
self._cache_built = False
self._build_time = 0.0
self._stats = {
'tasks_processed': 0,
'cache_hits': 0,
'cache_misses': 0
}
def build_cache(self, context) -> float:
"""
Pre-calculate color profiles for all tasks.
Returns build time in seconds.
"""
start_time = time.time()
self._cache.clear()
self._cache_built = False
self._stats = {'tasks_processed': 0, 'cache_hits': 0, 'cache_misses': 0}
try:
# Import required modules
import bonsai.tool as tool
try:
from .prop import UnifiedColorTypeManager
except ImportError:
# Fallback for direct execution
try:
import sys, os
sys.path.insert(0, os.path.dirname(__file__))
from prop import UnifiedColorTypeManager
except ImportError:
# Ultimate fallback - we'll work without it
UnifiedColorTypeManager = None
# Get task tree properties
tprops = tool.Sequence.get_task_tree_props()
if not tprops or not hasattr(tprops, 'tasks'):
print("[WARNING] ColorTypeCache: No tasks found")
return 0.0
# Get animation properties for active group
anim_props = tool.Sequence.get_animation_props()
active_group = getattr(anim_props, 'ColorType_groups', '') or 'DEFAULT'
print(f"[INFO] ColorTypeCache: Building cache for {len(tprops.tasks)} tasks with group '{active_group}'...")
# Process each task once
for task in tprops.tasks:
task_id = task.ifc_definition_id
if task_id == 0:
continue
# Calculate final color profile for this task
color_profile = self._calculate_task_color_profile(task, active_group, context)
# Store in cache
self._cache[task_id] = color_profile
self._stats['tasks_processed'] += 1
self._cache_built = True
self._build_time = time.time() - start_time
print(f"[INFO] ColorTypeCache: Built cache in {self._build_time:.3f}s for {self._stats['tasks_processed']} tasks")
return self._build_time
except Exception as e:
print(f"[ERROR] ColorTypeCache: Build failed: {e}")
import traceback
traceback.print_exc()
return 0.0
def _calculate_task_color_profile(self, task, active_group: str, context) -> Dict[str, Any]:
"""
Calculate the final color profile for a single task.
This is the expensive operation we're caching.
"""
try:
# Default profile
profile = {
'color': (0.5, 0.5, 0.5, 1.0), # Gray fallback
'colortype': 'UNDEFINED',
'source': 'fallback'
}
# Method 1: Check custom task assignment in active group
if active_group != 'DEFAULT':
try:
custom_colortype = getattr(task, 'selected_colortype_in_active_group', '')
if custom_colortype:
color = self._get_colortype_color(custom_colortype, active_group, context)
if color:
profile.update({
'color': color,
'colortype': custom_colortype,
'source': 'custom_assignment'
})
return profile
except:
pass
# Method 2: Use PredefinedType from task
predefined_type = getattr(task, 'PredefinedType', '')
if predefined_type and predefined_type != 'NOTDEFINED':
color = self._get_colortype_color(predefined_type, active_group, context)
if color:
profile.update({
'color': color,
'colortype': predefined_type,
'source': 'predefined_type'
})
return profile
# Method 3: Use animation_color_schemes if available
try:
animation_colortype = getattr(task, 'animation_color_schemes', '')
if animation_colortype:
color = self._get_colortype_color(animation_colortype, active_group, context)
if color:
profile.update({
'color': color,
'colortype': animation_colortype,
'source': 'animation_color_schemes'
})
return profile
except:
pass
# Method 4: Fallback to UNDEFINED colortype
color = self._get_colortype_color('UNDEFINED', active_group, context)
if color:
profile.update({
'color': color,
'colortype': 'UNDEFINED',
'source': 'undefined_fallback'
})
return profile
except Exception as e:
print(f"[WARNING] ColorTypeCache: Error calculating profile for task {task.ifc_definition_id}: {e}")
return profile
def _get_colortype_color(self, colortype_name: str, group_name: str, context) -> Optional[Tuple[float, float, float, float]]:
"""Get color for a specific colortype in a group"""
try:
import bonsai.tool as tool
# Get animation props and search for the colortype
anim_props = tool.Sequence.get_animation_props()
if not hasattr(anim_props, 'ColorTypes'):
return None
# Find matching colortype in the active group
for colortype in anim_props.ColorTypes:
if hasattr(colortype, 'name') and colortype.name == colortype_name:
if hasattr(colortype, 'group') and colortype.group == group_name:
# Get color from colortype
if hasattr(colortype, 'color') and len(colortype.color) >= 3:
color = colortype.color
# Ensure RGBA format
if len(color) == 3:
return (color[0], color[1], color[2], 1.0)
else:
return (color[0], color[1], color[2], color[3])
# If not found in specific group, try DEFAULT group
if group_name != 'DEFAULT':
return self._get_colortype_color(colortype_name, 'DEFAULT', context)
return None
except Exception as e:
print(f"[WARNING] ColorTypeCache: Error getting color for {colortype_name}: {e}")
return None
def get_task_color_profile(self, task_id: int) -> Optional[Dict[str, Any]]:
"""
Get cached color profile for a task.
Ultra-fast O(1) lookup.
"""
if not self._cache_built:
print("[WARNING] ColorTypeCache: Cache not built. Call build_cache() first.")
self._stats['cache_misses'] += 1
return None
if task_id in self._cache:
self._stats['cache_hits'] += 1
return self._cache[task_id]
else:
self._stats['cache_misses'] += 1
return None
def get_task_color(self, task_id: int) -> Optional[Tuple[float, float, float, float]]:
"""Get just the color tuple for a task"""
profile = self.get_task_color_profile(task_id)
return profile['color'] if profile else None
def get_task_colortype(self, task_id: int) -> Optional[str]:
"""Get just the colortype name for a task"""
profile = self.get_task_color_profile(task_id)
return profile['colortype'] if profile else None
def is_cache_valid(self) -> bool:
"""Check if cache is built and valid"""
return self._cache_built and len(self._cache) > 0
def get_stats(self) -> Dict[str, Any]:
"""Get cache performance statistics"""
total_requests = self._stats['cache_hits'] + self._stats['cache_misses']
hit_rate = (self._stats['cache_hits'] / total_requests * 100) if total_requests > 0 else 0
return {
**self._stats,
'cache_size': len(self._cache),
'build_time': self._build_time,
'hit_rate_percent': hit_rate,
'is_built': self._cache_built
}
def clear_cache(self):
"""Clear the cache"""
self._cache.clear()
self._cache_built = False
self._build_time = 0.0
self._stats = {'tasks_processed': 0, 'cache_hits': 0, 'cache_misses': 0}
print("[INFO] ColorTypeCache: Cache cleared")
# Global cache instance
_global_colortype_cache = None
def get_colortype_cache() -> ColorTypeCache:
"""Get the global ColorType cache instance"""
global _global_colortype_cache
if _global_colortype_cache is None:
_global_colortype_cache = ColorTypeCache()
return _global_colortype_cache
def clear_global_cache():
"""Clear the global cache"""
global _global_colortype_cache
if _global_colortype_cache:
_global_colortype_cache.clear_cache()
_global_colortype_cache = None
# Convenience functions for easy integration
def build_task_color_cache(context) -> float:
"""Build the global color cache. Returns build time."""
cache = get_colortype_cache()
return cache.build_cache(context)
def get_task_color_fast(task_id: int) -> Optional[Tuple[float, float, float, float]]:
"""Ultra-fast task color lookup"""
cache = get_colortype_cache()
return cache.get_task_color(task_id)
def get_task_colortype_fast(task_id: int) -> Optional[str]:
"""Ultra-fast task colortype lookup"""
cache = get_colortype_cache()
return cache.get_task_colortype(task_id)
@@ -0,0 +1,146 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import isodate
import bpy
from dateutil import parser
import ifcopenshell.util.date
from datetime import timedelta, datetime
from typing import Union, Any
from bonsai.bim.prop import ISODuration
def parse_datetime(value):
try:
return parser.isoparse(value)
except:
try:
return parser.parse(value, dayfirst=True, fuzzy=True)
except:
return None
def parse_duration(value):
return ifcopenshell.util.date.parse_duration(value)
def canonicalise_time(time: Union[datetime, None]) -> str:
"""Actually canonicalizes datetime as just a date, time is not included."""
if not time:
return "-"
return time.strftime("%Y-%m-%d")
def parse_duration_as_blender_props(dt: Union[Any, str]) -> dict[str, int]:
if True:
if isinstance(dt, str):
dt = ifcopenshell.util.date.ifc2datetime(dt)
seconds = getattr(dt, "seconds", 0)
hours, seconds = divmod(seconds, 3600)
minutes, seconds = divmod(seconds, 60)
days = getattr(dt, "days", 0)
months = int(getattr(dt, "months", 0))
years = int(getattr(dt, "years", 0))
return {
"years": years,
"months": months,
"days": days,
"hours": hours,
"minutes": minutes,
"seconds": seconds,
}
def blender_props_to_iso_duration(
durations_attributes: bpy.types.bpy_prop_collection_idprop[ISODuration],
duration_type: Union[str, None],
prop_name: str,
) -> Union[str, None]:
duration_props = None
for collection in durations_attributes:
if collection.name == prop_name:
duration_props = collection
break
if duration_props and (not duration_type or duration_type == "ELAPSEDTIME"):
duration_string = "P{}Y{}M{}DT{}H{}M{}S".format(
duration_props.years if duration_props.years else 0,
duration_props.months if duration_props.months else 0,
duration_props.days if duration_props.days else 0,
duration_props.hours if duration_props.hours else 0,
duration_props.minutes if duration_props.minutes else 0,
duration_props.seconds if duration_props.seconds else 0,
)
duration_object = ifcopenshell.util.date.ifc2datetime(duration_string)
elif duration_props and duration_type == "WORKTIME":
years = (duration_props.years * 365 * 24 * 60 * 60) if duration_props.years else 0
months = (duration_props.months * 30 * 24 * 60 * 60) if duration_props.months else 0
days = (duration_props.days * 24 * 60 * 60) if duration_props.days else 0
days_subtotal = (years + months + days) / (24 * 60 * 60)
hours = (duration_props.hours * 60 * 60) if duration_props.hours else 0
minutes = (duration_props.minutes * 60) if duration_props.minutes else 0
seconds = duration_props.seconds if duration_props.seconds else 0
total_seconds = hours + minutes + seconds
# TODO: implement actual calendar worktime
calendar_seconds_per_day = 8 * 60 * 60
extra_days, seconds_left = divmod(total_seconds, calendar_seconds_per_day)
total_days = days_subtotal + extra_days
duration_object = timedelta(days=total_days, seconds=seconds_left)
else:
return None
if duration_object:
total_days = int(duration_object.days)
seconds_left = int(duration_object.seconds)
years, days = divmod(total_days, 365)
if hasattr(duration_object, "years"):
years += duration_object.years
months, days = divmod(days, 30)
if hasattr(duration_object, "months"):
months += duration_object.months
if months >= 12:
extra_years, months = divmod(months, 12)
years += extra_years
hours, seconds = divmod(seconds_left, 3600)
minutes, seconds = divmod(seconds, 60)
if years > 0 or months > 0 or total_days > 0 or hours > 0 or minutes > 0 or seconds > 0:
duration_string = "P"
duration_string += "{}Y".format(int(years)) if years > 0 else ""
duration_string += "{}M".format(int(months)) if months > 0 else ""
duration_string += "{}D".format(int(total_days)) if total_days > 0 else ""
if hours > 0 or minutes > 0 or seconds > 0:
duration_string += "T"
if hours > 0:
duration_string += "{}H".format(int(hours))
if minutes > 0:
duration_string += "{}M".format(int(minutes))
if seconds > 0:
duration_string += "{}S".format(int(seconds))
return duration_string
else:
return None
else:
return None
@@ -0,0 +1,201 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
# Ultra-Fast IFC Entity Lookup System
# Precomputed mappings for instant access
import time
from typing import Dict, List, Set, Optional, Any
import bonsai.tool as tool
import ifcopenshell.util.sequence
class IFCLookupOptimizer:
"""Ultra-fast lookup system for IFC entities and task relationships"""
def __init__(self):
self.product_to_output_tasks: Dict[int, List] = {}
self.product_to_input_tasks: Dict[int, List] = {}
self.task_to_outputs: Dict[int, List] = {}
self.task_to_inputs: Dict[int, List] = {}
self.task_hierarchy: Dict[int, List] = {}
self.all_tasks_flat: List = []
self.product_ids: Set[int] = set()
self.lookup_built = False
def build_lookup_tables(self, work_schedule):
"""Builds all lookup tables at once - 10x faster"""
start_time = time.time()
print("[INFO] Building IFC lookup tables...")
# 1. Get all tasks at once
root_tasks = ifcopenshell.util.sequence.get_root_tasks(work_schedule)
self.all_tasks_flat = []
def collect_all_tasks(task):
self.all_tasks_flat.append(task)
nested = ifcopenshell.util.sequence.get_nested_tasks(task)
for subtask in nested:
collect_all_tasks(subtask)
for root_task in root_tasks:
collect_all_tasks(root_task)
print(f"[INFO] Found {len(self.all_tasks_flat)} tasks")
# 2. Pre-compute ALL relationships at once
for task in self.all_tasks_flat:
task_id = task.id()
# Task Outputs
try:
outputs = list(ifcopenshell.util.sequence.get_task_outputs(task))
self.task_to_outputs[task_id] = outputs
for output in outputs:
product_id = output.id()
self.product_ids.add(product_id)
if product_id not in self.product_to_output_tasks:
self.product_to_output_tasks[product_id] = []
self.product_to_output_tasks[product_id].append(task)
except:
self.task_to_outputs[task_id] = []
# Task Inputs
try:
inputs = tool.Sequence.get_task_inputs(task) if hasattr(tool.Sequence, 'get_task_inputs') else []
if not inputs:
inputs = list(ifcopenshell.util.sequence.get_task_inputs(task))
self.task_to_inputs[task_id] = inputs
for input_prod in inputs:
product_id = input_prod.id()
self.product_ids.add(product_id)
if product_id not in self.product_to_input_tasks:
self.product_to_input_tasks[product_id] = []
self.product_to_input_tasks[product_id].append(task)
except:
self.task_to_inputs[task_id] = []
self.lookup_built = True
elapsed = time.time() - start_time
print(f"[INFO] Lookup tables built in {elapsed:.2f}s")
print(f"[INFO] {len(self.product_ids)} products, {len(self.all_tasks_flat)} tasks")
# Debug: Show some details
print(f"[DEBUG] task_to_outputs entries: {len(self.task_to_outputs)}")
print(f"[DEBUG] task_to_inputs entries: {len(self.task_to_inputs)}")
# Show first few tasks with their products
tasks_with_products = 0
for task in self.all_tasks_flat[:5]: # Check first 5 tasks
task_id = task.id()
outputs = self.task_to_outputs.get(task_id, [])
inputs = self.task_to_inputs.get(task_id, [])
if outputs or inputs:
tasks_with_products += 1
task_name = getattr(task, 'Name', f'Task_{task_id}')
print(f"[DEBUG] {task_name}: {len(outputs)} outputs, {len(inputs)} inputs")
print(f"[DEBUG] Tasks with products (first 5): {tasks_with_products}/5")
def get_tasks_for_product(self, product_id: int) -> tuple[List, List]:
"""Gets input/output tasks for a product instantly"""
if not self.lookup_built:
return [], []
output_tasks = self.product_to_output_tasks.get(product_id, [])
input_tasks = self.product_to_input_tasks.get(product_id, [])
return input_tasks, output_tasks
def get_outputs_for_task(self, task_id: int) -> List:
"""Gets outputs for a task instantly"""
return self.task_to_outputs.get(task_id, [])
def get_inputs_for_task(self, task_id: int) -> List:
"""Gets inputs for a task instantly"""
return self.task_to_inputs.get(task_id, [])
def get_all_products(self) -> Set[int]:
"""Gets all product IDs"""
return self.product_ids.copy()
def get_all_tasks(self) -> List:
"""Gets all tasks"""
return self.all_tasks_flat.copy()
def invalidate(self):
"""Invalidates the lookup"""
self.product_to_output_tasks.clear()
self.product_to_input_tasks.clear()
self.task_to_outputs.clear()
self.task_to_inputs.clear()
self.task_hierarchy.clear()
self.all_tasks_flat.clear()
self.product_ids.clear()
self.lookup_built = False
class TaskDateCache:
"""Ultra-efficient cache for task dates"""
def __init__(self):
self.date_cache: Dict[str, Optional[Any]] = {}
def get_date(self, task, date_type: str, is_earliest: bool = False, is_latest: bool = False):
"""Gets date with cache"""
cache_key = f"{task.id()}_{date_type}_{is_earliest}_{is_latest}"
if cache_key not in self.date_cache:
try:
if is_earliest:
date = ifcopenshell.util.sequence.derive_date(task, date_type, is_earliest=True)
elif is_latest:
date = ifcopenshell.util.sequence.derive_date(task, date_type, is_latest=True)
else:
date = ifcopenshell.util.sequence.derive_date(task, date_type)
self.date_cache[cache_key] = date
except:
self.date_cache[cache_key] = None
return self.date_cache[cache_key]
def invalidate(self):
"""Clears the cache"""
self.date_cache.clear()
# Global singleton instances
_ifc_lookup = IFCLookupOptimizer()
_date_cache = TaskDateCache()
def get_ifc_lookup() -> IFCLookupOptimizer:
"""Gets the global lookup optimizer"""
return _ifc_lookup
def get_date_cache() -> TaskDateCache:
"""Gets the global date cache"""
return _date_cache
def invalidate_all_lookups():
"""Invalidates all caches and lookups"""
_ifc_lookup.invalidate()
_date_cache.invalidate()
@@ -0,0 +1,130 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
# Performance Cache System for 4D Animation.
# Reduces processing time for 8000 objects from 40s to ~3-5s.
import bpy
import time
from typing import Dict, List, Optional, Any
import bonsai.tool as tool
import ifcopenshell.util.sequence
class AnimationPerformanceCache:
"""Ultra-efficient cache system for large 4D animations"""
def __init__(self) -> None:
self.ifc_entity_cache: Dict[str, Any] = {}
self.task_outputs_cache: Dict[int, List] = {}
self.task_inputs_cache: Dict[int, List] = {}
self.date_cache: Dict[str, Any] = {}
self.product_to_objects_cache: Dict[int, List] = {}
self.scene_objects_cache: List = []
self.cache_valid = False
def invalidate(self) -> None:
"""Invalidates the entire cache."""
self.ifc_entity_cache.clear()
self.task_outputs_cache.clear()
self.task_inputs_cache.clear()
self.date_cache.clear()
self.product_to_objects_cache.clear()
self.scene_objects_cache.clear()
self.cache_valid = False
def build_scene_cache(self) -> None:
"""Pre-builds the scene objects cache - RUN ONLY ONCE."""
start_time = time.time()
# 1. Cache all IFC objects at once.
self.scene_objects_cache.clear()
ifc_objects = []
for obj in bpy.data.objects:
if obj.type == 'MESH':
self.scene_objects_cache.append(obj)
entity = tool.Ifc.get_entity(obj)
if entity and not entity.is_a("IfcSpace"):
self.ifc_entity_cache[obj.name] = entity
ifc_objects.append((obj, entity))
# Build product->objects mapping.
product_id = entity.id()
if product_id not in self.product_to_objects_cache:
self.product_to_objects_cache[product_id] = []
self.product_to_objects_cache[product_id].append(obj)
self.cache_valid = True
elapsed = time.time() - start_time
print(f"[INFO] CACHE: Built in {elapsed:.2f}s - {len(ifc_objects)} IFC objects")
def get_ifc_entity(self, obj) -> Optional[Any]:
"""Gets an IFC entity from the cache."""
return self.ifc_entity_cache.get(obj.name)
def get_objects_for_product(self, product_id: int) -> List:
"""Gets Blender objects for an IFC product."""
return self.product_to_objects_cache.get(product_id, [])
def get_task_outputs_cached(self, task) -> List:
"""Caches task outputs."""
task_id = task.id()
if task_id not in self.task_outputs_cache:
self.task_outputs_cache[task_id] = list(ifcopenshell.util.sequence.get_task_outputs(task))
return self.task_outputs_cache[task_id]
def get_task_inputs_cached(self, task) -> List:
"""Caches task inputs."""
task_id = task.id()
if task_id not in self.task_inputs_cache:
# Use the existing method from tool.Sequence if available.
try:
inputs = tool.Sequence.get_task_inputs(task)
self.task_inputs_cache[task_id] = inputs if inputs else []
except:
# Fallback to the direct method.
self.task_inputs_cache[task_id] = list(ifcopenshell.util.sequence.get_task_inputs(task))
return self.task_inputs_cache[task_id]
def get_date_cached(self, task, date_type: str, is_earliest: bool = False, is_latest: bool = False) -> Optional[Any]:
"""Caches task dates."""
cache_key = f"{task.id()}_{date_type}_{is_earliest}_{is_latest}"
if cache_key not in self.date_cache:
try:
if is_earliest:
date = ifcopenshell.util.sequence.derive_date(task, date_type, is_earliest=True)
elif is_latest:
date = ifcopenshell.util.sequence.derive_date(task, date_type, is_latest=True)
else:
date = ifcopenshell.util.sequence.derive_date(task, date_type)
self.date_cache[cache_key] = date
except:
self.date_cache[cache_key] = None
return self.date_cache[cache_key]
# Global cache singleton.
_performance_cache = AnimationPerformanceCache()
def get_performance_cache() -> AnimationPerformanceCache:
"""Gets the global performance cache."""
return _performance_cache
def invalidate_cache():
"""Invalidates the global cache."""
_performance_cache.invalidate()
+423 -34
View File
@@ -1,5 +1,5 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
@@ -17,13 +17,11 @@
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import TYPE_CHECKING, Optional, Union
if TYPE_CHECKING:
import bpy
import ifcopenshell
import bonsai.tool as tool
@@ -53,9 +51,101 @@ def edit_work_plan(ifc: type[tool.Ifc], sequence: type[tool.Sequence], work_plan
def edit_work_schedule(
ifc: type[tool.Ifc], sequence: type[tool.Sequence], work_schedule: ifcopenshell.entity_instance
) -> None:
attributes = sequence.get_work_schedule_attributes()
ifc.run("sequence.edit_work_schedule", work_schedule=work_schedule, attributes=attributes)
sequence.disable_editing_work_schedule()
try:
# Validate that the work_schedule exists before editing
if not work_schedule or not hasattr(work_schedule, 'is_a'):
print("ERROR: WorkSchedule is invalid or doesn't exist")
return
# Get ID for debug
schedule_id = work_schedule.id()
schedule_name = getattr(work_schedule, 'Name', 'Unnamed')
print(f"DEBUG: Editing WorkSchedule ID {schedule_id} - Name: {schedule_name}")
# Get attributes
attributes = sequence.get_work_schedule_attributes()
print(f"DEBUG: Attributes to update: {attributes}")
# Validate that we have valid attributes
if not attributes:
print("WARNING: No attributes to update")
sequence.disable_editing_work_schedule()
return
# Execute the edit
ifc.run("sequence.edit_work_schedule", work_schedule=work_schedule, attributes=attributes)
# Verify that the work_schedule still exists after editing
try:
# Try to access the work_schedule to verify that it was not deleted
test_id = work_schedule.id()
test_name = getattr(work_schedule, 'Name', 'Unnamed')
print(f"DEBUG: WorkSchedule still exists after edit - ID {test_id} - Name: {test_name}")
except:
print("ERROR: WorkSchedule was deleted during edit operation!")
sequence.disable_editing_work_schedule()
return
# SMART SOLUTION: Preserve original schedule instead of resetting automatically
try:
# Refresh ALL schedule-related data
from bonsai.bim.module.sequence.data import SequenceData, WorkScheduleData
SequenceData.load()
WorkScheduleData.load()
props = sequence.get_work_schedule_props()
current_active_id = props.active_work_schedule_id
# CRITICAL: Only reset if NO other schedules are available
# Instead of resetting automatically, look for a fallback schedule
ifc_file = tool.Ifc.get()
all_schedules = ifc_file.by_type("IfcWorkSchedule")
if all_schedules:
# If schedules are available, prefer originals over copies
original_schedules = [ws for ws in all_schedules
if not (ws.Name and ws.Name.startswith("Copy of "))]
if original_schedules:
# Use the first original schedule found
fallback_id = original_schedules[0].id()
props.active_work_schedule_id = fallback_id
props.editing_type = "TASKS"
print(f"[INFO] EDIT_WORK_SCHEDULE: Preserving original schedule ID {fallback_id} - '{original_schedules[0].Name}'")
elif all_schedules:
# If there are no originals, use any available schedule
fallback_id = all_schedules[0].id()
props.active_work_schedule_id = fallback_id
props.editing_type = "TASKS"
print(f"[INFO] EDIT_WORK_SCHEDULE: Using available schedule ID {fallback_id} - '{all_schedules[0].Name}'")
else:
# Only reset if there are really no schedules
props.active_work_schedule_id = 0
props.editing_type = ""
print("[INFO] EDIT_WORK_SCHEDULE: No schedules available, resetting")
else:
# Only reset if there are really no schedules
props.active_work_schedule_id = 0
props.editing_type = ""
print("[INFO] EDIT_WORK_SCHEDULE: No schedules available, resetting")
# Force UI update
import bpy
for area in bpy.context.screen.areas:
if area.type in ['PROPERTIES', 'OUTLINER']:
area.tag_redraw()
print(f"INFO: WorkSchedule {work_schedule.id()} - UI reset completed successfully")
except Exception as refresh_error:
print(f"WARNING: Failed to reset UI after edit: {refresh_error}")
sequence.disable_editing_work_schedule()
except Exception as e:
print(f"ERROR in edit_work_schedule: {e}")
import traceback
traceback.print_exc()
sequence.disable_editing_work_schedule()
def enable_editing_work_plan_schedules(
@@ -65,10 +155,16 @@ def enable_editing_work_plan_schedules(
def add_work_schedule(ifc: type[tool.Ifc], sequence: type[tool.Sequence], name: str) -> ifcopenshell.entity_instance:
predefined_type, object_type = sequence.get_user_predefined_type()
res = sequence.get_user_predefined_type()
if not isinstance(res, tuple) or len(res) != 2:
# User cancelled or UI not available: use defaults
predefined_type, object_type = "NOTDEFINED", ""
else:
predefined_type, object_type = res
return ifc.run("sequence.add_work_schedule", name=name, predefined_type=predefined_type, object_type=object_type)
def remove_work_schedule(ifc: type[tool.Ifc], work_schedule: ifcopenshell.entity_instance) -> None:
ifc.run("sequence.remove_work_schedule", work_schedule=work_schedule)
@@ -96,9 +192,9 @@ def enable_editing_work_schedule(sequence: type[tool.Sequence], work_schedule: i
def enable_editing_work_schedule_tasks(
sequence: type[tool.Sequence], work_schedule: ifcopenshell.entity_instance
) -> None:
# Only set active schedule, DO NOT load task tree immediately
# The operator will handle the correct sequence: callback → load_task_tree → restore
sequence.enable_editing_work_schedule_tasks(work_schedule)
sequence.load_task_tree(work_schedule)
sequence.load_task_properties()
def load_task_tree(sequence: type[tool.Sequence], work_schedule) -> None:
@@ -172,10 +268,53 @@ def enable_editing_task_attributes(sequence: type[tool.Sequence], task: ifcopens
sequence.enable_editing_task_attributes(task)
def _auto_sync_task_predefined_type(task_id: int, predefined_type: str) -> None:
"""
Synchronizes the PredefinedType with the task's DEFAULT ColorType AND FORCES UI REDRAW.
"""
try:
import bpy
import bonsai.tool as tool
from bonsai.bim.module.sequence.prop import UnifiedColorTypeManager
# 1. Find the task in UI properties (task_pg)
tprops = tool.Sequence.get_task_tree_props()
task_pg = next((t for t in tprops.tasks if t.ifc_definition_id == task_id), None)
if task_pg:
# 2. Call central logic to update the data
UnifiedColorTypeManager.sync_default_group_to_predefinedtype(bpy.context, task_pg)
print(f"[INFO] Auto-Sync: Task {task_id} updated to DEFAULT ColorType '{predefined_type}'.")
# 3. THE KEY PART! Force Properties UI redraw.
# Blender doesn't always detect changes in nested collections, so we force it.
for window in bpy.context.window_manager.windows:
for area in window.screen.areas:
if area.type == 'PROPERTIES':
area.tag_redraw()
except Exception as e:
print(f"[ERROR] ERROR in _auto_sync_task_predefined_type: {e}")
def edit_task(ifc: type[tool.Ifc], sequence: type[tool.Sequence], task: ifcopenshell.entity_instance) -> None:
"""Edits a task, reloads data, and triggers DEFAULT ColorType synchronization."""
attributes = sequence.get_task_attributes()
old_predefined = getattr(task, "PredefinedType", "NOTDEFINED") or "NOTDEFINED"
# 1. Save changes to the IFC file
ifc.run("sequence.edit_task", task=task, attributes=attributes)
# 2. Reload data from IFC so cache is updated
from bonsai.bim.module.sequence.data import SequenceData
SequenceData.load() # Complete reload to ensure consistency
sequence.load_task_properties(task=task)
# 3. Trigger synchronization if PredefinedType changed
new_predefined = attributes.get("PredefinedType", old_predefined)
if new_predefined != old_predefined:
_auto_sync_task_predefined_type(task.id(), new_predefined)
sequence.disable_editing_task()
@@ -424,7 +563,7 @@ def edit_task_calendar(
task: ifcopenshell.entity_instance,
work_calendar: ifcopenshell.entity_instance,
) -> None:
ifc.run("control.assign_control", relating_control=work_calendar, related_objects=[task])
ifc.run("control.assign_control", relating_control=work_calendar, related_object=task)
ifc.run("sequence.cascade_schedule", task=task)
sequence.load_task_properties()
@@ -435,7 +574,7 @@ def remove_task_calendar(
task: ifcopenshell.entity_instance,
work_calendar: ifcopenshell.entity_instance,
) -> None:
ifc.run("control.unassign_control", relating_control=work_calendar, related_objects=[task])
ifc.run("control.unassign_control", relating_control=work_calendar, related_object=task)
ifc.run("sequence.cascade_schedule", task=task)
sequence.load_task_properties()
@@ -584,45 +723,169 @@ def setup_default_task_columns(sequence: type[tool.Sequence]) -> None:
sequence.setup_default_task_columns()
def add_task_bars(sequence: type[tool.Sequence]) -> None:
tasks = sequence.get_animation_bar_tasks()
if tasks:
sequence.create_bars(tasks)
def add_task_bars(sequence: "type[tool.Sequence]") -> set[str]:
"""Generates the visual bars for the selected tasks.
- Synchronizes `has_bar_visual` from the tree with `props.task_bars` (JSON).
- Calls `sequence.refresh_task_bars()` to create/delete bars in the viewport.
- Returns {'FINISHED'} to maintain operator-style semantics.
"""
# Update has_bar_visual synchronization with task_bars
task_tree = sequence.get_task_tree_props()
selected_tasks = []
for task in getattr(task_tree, "tasks", []):
try:
tid = int(task.ifc_definition_id)
except Exception:
continue
if getattr(task, "has_bar_visual", False):
selected_tasks.append(tid)
sequence.add_task_bar(tid)
else:
sequence.remove_task_bar(tid)
# Generate/update visual bars
sequence.refresh_task_bars()
return {"FINISHED"}
def load_default_animation_color_scheme(sequence: type[tool.Sequence]) -> None:
sequence.load_default_animation_color_scheme()
def visualise_work_schedule_date_range(
sequence: type[tool.Sequence], work_schedule: ifcopenshell.entity_instance
) -> None:
sequence.clear_objects_animation(include_blender_objects=False)
settings = sequence.get_animation_settings()
if settings:
product_frames = sequence.get_animation_product_frames(work_schedule, settings)
if not sequence.has_animation_colors():
sequence.load_default_animation_color_scheme()
load_animation_color_scheme(sequence, scheme=sequence.get_animation_color_scheme())
sequence.animate_objects(settings, product_frames, "date_range")
sequence.add_text_animation_handler(settings)
add_task_bars(sequence)
sequence.set_object_shading()
"""
Creates 4D animation using ONLY the Animation Color Schemes system.
No fallback to old system - if no ColorTypes exist, creates DEFAULT.
Process:
1. Clears previous animations
2. Calculates time configuration
3. Verifies/creates DEFAULT group if necessary
4. Generates frames with state information
5. Applies animation with ColorTypes
6. Adds additional elements (timeline, bars)
"""
# Clear any previous animation
sequence.clear_objects_animation(include_blender_objects=False)
# Get time configuration (dates, frames, speed)
settings = sequence.get_animation_settings()
if not settings:
print("[ERROR] Error: Could not calculate animation configuration")
return
# NEW: Validate date range
import ifcopenshell.util.sequence
all_tasks = []
for root_task in ifcopenshell.util.sequence.get_root_tasks(work_schedule):
all_tasks.extend(ifcopenshell.util.sequence.get_all_nested_tasks(root_task))
# Check for out-of-range tasks
out_of_range_count = 0
earliest_task_date = None
latest_task_date = None
for task in all_tasks:
start = ifcopenshell.util.sequence.derive_date(task, "ScheduleStart", is_earliest=True)
finish = ifcopenshell.util.sequence.derive_date(task, "ScheduleFinish", is_latest=True)
if start and finish:
if not earliest_task_date or start < earliest_task_date:
earliest_task_date = start
if not latest_task_date or finish > latest_task_date:
latest_task_date = finish
if finish < settings["start"] or start > settings["finish"]:
out_of_range_count += 1
# Warn if there are discrepancies
if earliest_task_date and earliest_task_date < settings["start"]:
print(f"[WARNING] Warning: Earliest task starts {(settings['start'] - earliest_task_date).days} days before visualization range")
if latest_task_date and latest_task_date > settings["finish"]:
print(f"[WARNING] Warning: Latest task ends {(latest_task_date - settings['finish']).days} days after visualization range")
if out_of_range_count > 0:
print(f"[WARNING] {out_of_range_count} tasks are completely outside the visualization range")
# Get animation properties
animation_props = sequence.get_animation_props()
# If no groups configured, create and use DEFAULT
if not animation_props.animation_group_stack:
print("[WARNING] No ColorType groups configured")
print(" Creating DEFAULT group automatically...")
# Create DEFAULT group with basic ColorTypes
sequence.create_default_ColorType_group()
# Add DEFAULT to the animation stack
item = animation_props.animation_group_stack.add()
item.group = "DEFAULT"
item.enabled = True
print("[INFO] DEFAULT group added to the stack")
# Generate frame information with states
print(f"[INFO] Processing schedule tasks...")
product_frames = sequence.get_animation_product_frames_enhanced(
work_schedule, settings
)
if not product_frames:
print("[WARNING] No products found to animate")
return
print(f"[INFO] {len(product_frames)} products found")
# ALWAYS use ColorType system (no fallback)
print(f"[INFO] Applying animation with Animation Color Schemes...")
sequence.animate_objects_with_ColorTypes(settings, product_frames)
# Add additional elements
sequence.add_text_animation_handler(settings) # Timeline with date
add_task_bars(sequence) # Gantt bars (optional)
sequence.set_object_shading() # Configure view for colors
print(f"[INFO] Animation created: {settings['total_frames']:.0f} frames")
print(f" From: {settings['start'].strftime('%Y-%m-%d')}")
print(f" To: {settings['finish'].strftime('%Y-%m-%d')}")
def visualise_work_schedule_date(sequence: type[tool.Sequence], work_schedule: ifcopenshell.entity_instance) -> None:
sequence.clear_objects_animation(include_blender_objects=False)
start_date = sequence.get_start_date()
product_states = sequence.process_construction_state(work_schedule, start_date)
"""Visualizes the schedule state at a specific date.
CORRECTION: Processes ALL visible elements, not just active ones."""
# Clear keyframes and previous visual states is crucial for clean snapshot.
sequence.clear_objects_animation(include_blender_objects=True)
# Parse the visualization date
props = sequence.get_work_schedule_props()
date_text = props.visualisation_start
if not date_text:
return
try:
from dateutil import parser
current_date = parser.parse(date_text, yearfirst=True, fuzzy=True)
except Exception:
return
# Process ALL states up to current date
product_states = sequence.process_construction_state(work_schedule, current_date)
# Apply snapshot with correct ColorTypes
sequence.show_snapshot(product_states)
# Ensure colors are visible
sequence.set_object_shading()
def generate_gantt_chart(sequence: type[tool.Sequence], work_schedule: ifcopenshell.entity_instance) -> None:
json = sequence.create_tasks_json(work_schedule)
sequence.generate_gantt_browser_chart(json, work_schedule)
def load_product_related_tasks(
sequence: type[tool.Sequence], product: ifcopenshell.entity_instance
) -> Union[list[ifcopenshell.entity_instance], str]:
@@ -672,3 +935,129 @@ def add_animation_camera(sequence: type[tool.Sequence]) -> None:
def save_animation_color_scheme(sequence: type[tool.Sequence], name: str) -> None:
sequence.save_animation_color_scheme(name)
def generate_gantt_chart(sequence: type[tool.Sequence], work_schedule: ifcopenshell.entity_instance) -> None:
"""Generates the task data and sends it to the web UI for Gantt chart rendering."""
json_data = sequence.create_tasks_json(work_schedule)
sequence.generate_gantt_browser_chart(json_data, work_schedule)
def save_ColorTypes_to_ifc_core(ifc_file: "ifcopenshell.file", work_schedule: "ifcopenshell.entity_instance", ColorType_data: dict) -> None:
"""
(Core) Saves 4D ColorTypes configuration to an IfcPropertySet associated with the active IfcWorkSchedule.
"""
import json
import ifcopenshell.api
from datetime import datetime
pset_name = "Pset_Bonsai4DColorTypeConfig"
prop_name = "ColorTypeDataJSON"
# 1. Serialize all configuration to a JSON string
ColorType_json = json.dumps(ColorType_data, ensure_ascii=False, indent=2)
# 2. Create properties that will go in the Pset
try:
properties_to_add = {
prop_name: ifc_file.create_entity(
"IfcPropertySingleValue",
Name=prop_name,
NominalValue=ifc_file.create_entity("IfcText", ColorType_json),
),
"Version": ifc_file.create_entity(
"IfcPropertySingleValue",
Name="Version",
NominalValue=ifc_file.create_entity("IfcLabel", "1.0"),
),
"LastModified": ifc_file.create_entity(
"IfcPropertySingleValue",
Name="LastModified",
NominalValue=ifc_file.create_entity("IfcText", datetime.now().isoformat()),
),
}
# 3. Use ifcopenshell API to create or edit Pset robustly
ifcopenshell.api.run(
"pset.edit_pset",
ifc_file,
product=work_schedule,
name=pset_name,
properties=properties_to_add,
)
except Exception:
# Robust fallback: use simple values instead of entities
ifcopenshell.api.run(
"pset.edit_pset",
ifc_file,
product=work_schedule,
name=pset_name,
properties={
prop_name: ColorType_json,
"Version": "1.0",
"LastModified": datetime.now().isoformat(),
},
)
print(f"Bonsai INFO: 4D ColorTypes automatically saved to WorkSchedule Pset '{pset_name}'.")
def load_ColorTypes_from_ifc_core(work_schedule: "ifcopenshell.entity_instance") -> dict | None:
"""
(Core) Loads 4D ColorTypes configuration from the IfcPropertySet associated with the IfcWorkSchedule.
"""
import json
pset_name = "Pset_Bonsai4DColorTypeConfig"
prop_name = "ColorTypeDataJSON"
if not getattr(work_schedule, "IsDefinedBy", None):
return None
for rel in work_schedule.IsDefinedBy:
# Ensure the relationship is for properties
if not rel.is_a("IfcRelDefinesByProperties"):
continue
pset = rel.RelatingPropertySet
if getattr(pset, "Name", None) == pset_name:
for prop in getattr(pset, "HasProperties", []) or []:
if getattr(prop, "Name", None) == prop_name and prop.is_a("IfcPropertySingleValue"):
try:
nominal = getattr(prop, "NominalValue", None)
# Robust handling of the nominal value (wrappedValue or direct value)
if hasattr(nominal, "wrappedValue"):
raw = nominal.wrappedValue
else:
raw = nominal
data = json.loads(raw) if isinstance(raw, str) else None
if isinstance(data, dict):
print(f"Bonsai INFO: 4D ColorTypes loaded from IFC for WorkSchedule '{getattr(work_schedule, 'Name', '')}'.")
return data
except Exception as e:
print(f"Bonsai ERROR: Could not decode ColorTypes JSON from IFC: {e}")
return None
return None
def refresh_task_output_counts(SequenceTool, work_schedule=None):
"""
Safely recalculates the 'Outputs' counters per task.
This is a shim to avoid AttributeError if the real logic lives in tool.Sequence.
"""
try:
ws = work_schedule or SequenceTool.get_active_work_schedule()
except Exception:
ws = None
try:
# If the tool already has the native method, use it
if hasattr(SequenceTool, "refresh_task_output_counts"):
if ws is not None:
SequenceTool.refresh_task_output_counts(ws)
else:
SequenceTool.refresh_task_output_counts()
else:
# Reasonable fallback: reload tree and properties
if ws is not None:
SequenceTool.load_task_tree(ws)
SequenceTool.load_task_properties()
except Exception as e:
print(f"Bonsai WARNING: refresh_task_output_counts shim failed: {e}")
+106
View File
@@ -0,0 +1,106 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
from collections.abc import Iterable
import ifcopenshell
# --- STEP 1: Import all the refactored module classes ---
from .props_sequence import PropsSequence
from .camera_sequence import CameraSequence
from .text_sequence import TextSequence
from .task_bars_sequence import TaskBarsSequence
from .date_utils_sequence import DateUtilsSequence
from .color_management_sequence import ColorManagementSequence
from .task_tree_sequence import TaskTreeSequence
from .task_icom_sequence import TaskIcomSequence
from .snapshot_sequence import SnapshotSequence
from .animation_setup_sequence import AnimationSetupSequence
from .animation_frames_sequence import AnimationFramesSequence
from .animation_baker_sequence import AnimationBakerSequence
from .task_attributes_sequence import TaskAttributesSequence
from .schedule_management_sequence import ScheduleManagementSequence
from .calendar_sequence import CalendarSequence
from .variance_sequence import VarianceSequence
from .sync_config_sequence import SyncConfigSequence
from .sequence_relations_sequence import SequenceRelationsSequence
from .ui_helpers_sequence import UiHelpersSequence
from .gantt_chart_sequence import GanttChartSequence
from .schedule_utils_sequence import ScheduleUtilsSequence
# --- STEP 2: Assemble the final "Sequence" class with the CORRECT INHERITANCE ORDER ---
class Sequence(
# --- LEVEL 4: High-Level Tools (depend on almost everything else) ---
SyncConfigSequence,
# --- LEVEL 3: Core Engines and Main Logic ---
SnapshotSequence,
VarianceSequence,
# --- REFACTORED ANIMATION LOGIC ---
AnimationBakerSequence, # 3. Executes the animation in Blender
AnimationFramesSequence, # 2. Calculates the frame data
AnimationSetupSequence, # 1. Configures the base settings
# --- END OF ANIMATION LOGIC ---
# --- LEVEL 2: Managers and Complex Utilities ---
ScheduleManagementSequence,
TaskTreeSequence,
ScheduleUtilsSequence,
TextSequence,
TaskBarsSequence,
# --- LEVEL 1: Base Modules and Data Providers ---
CalendarSequence,
SequenceRelationsSequence,
TaskAttributesSequence,
ColorManagementSequence,
TaskIcomSequence,
CameraSequence,
DateUtilsSequence,
UiHelpersSequence,
GanttChartSequence,
# --- LEVEL 0: The Fundamental Base for Properties ---
PropsSequence,
):
"""
The main Sequence tool class, assembled from refactored mixins
with a consistent Method Resolution Order (MRO).
"""
ELEMENT_STATUSES = ("NEW", "EXISTING", "DEMOLISH", "TEMPORARY", "OTHER", "NOTKNOWN", "UNSET")
@classmethod
def are_entities_same_class(cls, entities: list[ifcopenshell.entity_instance]) -> bool:
if not entities: return False
if len(entities) == 1: return True
first_class = entities[0].is_a()
for entity in entities:
if entity.is_a() != first_class: return False
return True
@classmethod
def select_products(cls, products: Iterable[ifcopenshell.entity_instance]) -> None:
import bpy
import bonsai.tool as tool
[obj.select_set(False) for obj in bpy.context.selected_objects]
for product in products:
obj = tool.Ifc.get_object(product)
obj.select_set(True) if obj else None
@@ -0,0 +1,963 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import bpy
import time
import json
import bonsai.tool as tool
# Try to import data module for interpolate_ColorType_values function
try:
from bonsai.bim.module.sequence.data import interpolate_ColorType_values
class _seq_data:
interpolate_ColorType_values = staticmethod(interpolate_ColorType_values)
except ImportError:
try:
import bonsai.data as _seq_data
except ImportError:
# Fallback: Create a dummy data module
class _seq_data:
@staticmethod
def interpolate_ColorType_values(ColorType, state, progress=0.0):
# Return default values if optimization is not available
return {'alpha': 1.0}
class AnimationBakerSequence:
"""Mixin class for applying (baking) animation data to Blender objects."""
@classmethod
def animate_objects_with_ColorTypes_new(cls, settings, product_frames):
"""
NEW BATCH PROCESSING VERSION: Refactored for performance with thousands of objects.
Replaces the old animate_objects_with_ColorTypes function.
"""
import time
start_time = time.time()
print("Starting new animation with ColorTypes")
print("[ANIM] STARTING NEW BATCH ANIMATION SYSTEM")
# IFC MAPPING - From COMPLETE_SYSTEM_ULTRA_FAST
print("Building IFC mapping...")
map_start = time.time()
ifc_to_blender = {}
assigned_objects = set()
for obj in bpy.data.objects:
if obj.type == 'MESH':
element = tool.Ifc.get_entity(obj)
if element and not element.is_a("IfcSpace"):
ifc_to_blender[element.id()] = obj
# Check if this object is assigned to tasks
if element.id() in product_frames:
assigned_objects.add(obj)
map_time = time.time() - map_start
print(f"Mapped {len(ifc_to_blender)} IFC objects ({len(assigned_objects)} assigned) in {map_time:.3f}s")
# Save original colors using existing system
if not bpy.context.scene.get('BIM_VarianceOriginalObjectColors'):
cls._save_original_object_colors()
# Use original build_animation_plan approach
print("Using original stable animation system...")
# Phase 1: Build animation plan (original approach)
animation_plan = cls.build_animation_plan(bpy.context, settings, product_frames)
# Phase 2: Execute animation plan (original approach)
cls.execute_animation_plan(bpy.context, animation_plan)
# Set viewport shading and frame range (preserve existing functionality)
area = tool.Blender.get_view3d_area()
try:
area.spaces[0].shading.color_type = "OBJECT"
except Exception:
pass
bpy.context.scene.frame_start = settings["start_frame"]
bpy.context.scene.frame_end = int(settings["start_frame"] + settings["total_frames"] + 1)
# SAFE BASELINE TIMING REPORT
total_time = time.time() - start_time
print(f"[INFO] IFC mapping + Original stable system - Total time: {total_time:.2f}s")
print("Reverted aggressive optimizations to prevent crashes")
print("[ANIM] SAFE BASELINE SYSTEM COMPLETE")
@classmethod
def animate_objects_with_ColorTypes_optimized(cls, settings, product_frames, cache):
"""
ULTRA-OPTIMIZED version using cache.
Optimized animation with pre-built plan.
"""
import time
from datetime import datetime
start_time = time.time()
print("Starting optimized animation with ColorTypes")
print(f"[OPTIMIZED] OPTIMIZED ANIMATION: Planning for {len(product_frames)} products")
# Get properties once
animation_props = tool.Sequence.get_animation_props()
active_group_name = cls._get_active_group_optimized(animation_props)
# Save original colors if not already saved
if not bpy.context.scene.get('BIM_VarianceOriginalObjectColors'):
cls._save_original_colors_optimized(cache)
# Local lists to build the animation plan
visibility_ops = []
color_ops = []
total_objects_processed = 0
# Use cache for direct product->objects mapping
for product_id, frame_data_list in product_frames.items():
objects = cache.get_objects_for_product(product_id)
if not objects:
continue
for obj in objects:
total_objects_processed += 1
# Process frame data for this object
for frame_data in frame_data_list:
task = frame_data.get("task")
if not task:
continue
# Get ColorType with caching
colortype = cls._get_colortype_optimized(task, animation_props, active_group_name)
if not colortype:
continue
# Get original color from cache
original_color = cache.get_original_color(obj)
# Apply optimized object animation
cls._apply_object_animation_optimized(
obj, frame_data, colortype, settings, visibility_ops, color_ops
)
# Execute batch operations
print(f"[OPTIMIZED] Executing {len(visibility_ops)} visibility ops, {len(color_ops)} color ops")
# Set all visibility operations
for op in visibility_ops:
bpy.context.scene.frame_set(op['frame'])
op['obj'].hide_viewport = op['hide']
op['obj'].hide_render = op['hide']
op['obj'].keyframe_insert(data_path="hide_viewport", frame=op['frame'])
op['obj'].keyframe_insert(data_path="hide_render", frame=op['frame'])
# Set all color operations
for op in color_ops:
bpy.context.scene.frame_set(op['frame'])
if hasattr(op['obj'].data, 'materials') and op['obj'].data.materials:
material = op['obj'].data.materials[0]
if material and material.use_nodes:
principled = material.node_tree.nodes.get("Principled BSDF")
if principled:
principled.inputs["Base Color"].default_value = op['color']
principled.inputs["Base Color"].keyframe_insert(data_path="default_value", frame=op['frame'])
# Set viewport shading
cls._setup_viewport_shading_optimized()
# Set frame range
bpy.context.scene.frame_start = settings["start_frame"]
bpy.context.scene.frame_end = int(settings["start_frame"] + settings["total_frames"])
elapsed = time.time() - start_time
print(f"[OPTIMIZED] OPTIMIZED ANIMATION COMPLETE in {elapsed:.3f}s")
print(f"[OPTIMIZED] Objects processed: {total_objects_processed}")
@classmethod
def build_animation_plan(cls, context, settings, product_frames):
"""
Phase 1: Planning - Builds animation plan without modifying Blender scene.
Returns a structured plan dict for batch execution.
"""
import time
start_time = time.time()
print("[INFO] Building animation plan...")
print(f"[INFO] BUILDING PLAN for {len(product_frames)} products")
# Build IFC->Blender mapping once
ifc_to_blender = {}
for obj in bpy.data.objects:
if obj.type == 'MESH':
element = tool.Ifc.get_entity(obj)
if element and not element.is_a("IfcSpace"):
ifc_to_blender[element.id()] = obj
# Load original colors once
original_object_colors = bpy.context.scene.get('BIM_VarianceOriginalObjectColors')
if original_object_colors:
original_colors = json.loads(original_object_colors)
else:
print("[WARNING] Original colors not found, using white as fallback")
original_colors = {}
# Plan structure: frame -> list of operations
animation_plan = {}
objects_planned = 0
for product_id, frame_data_list in product_frames.items():
obj = ifc_to_blender.get(product_id)
if not obj:
continue
objects_planned += 1
original_color = original_colors.get(obj.name, [0.8, 0.8, 0.8, 1.0])
# Process each frame data entry for this product
for frame_data in frame_data_list:
task = frame_data.get("task")
if not task:
continue
# Get appropriate ColorType for this task
try:
animation_props = tool.Sequence.get_animation_props()
ColorType = tool.Sequence._get_best_ColorType_for_task(task, animation_props)
except Exception as e:
print(f"[ERROR] Error getting ColorType for task {task.id()}: {e}")
continue
# Plan object animation
cls._plan_object_animation(animation_plan, obj, frame_data, ColorType, original_color)
print(f"[INFO] PLANNING COMPLETE: Plan contains {len(animation_plan)} frames")
return dict(animation_plan)
@classmethod
def _plan_object_animation(cls, animation_plan, obj, frame_data, ColorType, original_color):
"""Helper function to plan animation keyframes for a single object"""
# Plan START state
start_state_frames = frame_data["states"]["before_start"]
start_f, end_f = start_state_frames
is_construction = frame_data.get("relationship") == "output"
consider_start = getattr(ColorType, 'consider_start', False)
should_be_visible_at_start = not is_construction or consider_start
if end_f >= start_f:
frame_ops = animation_plan.setdefault(start_f, [])
if should_be_visible_at_start:
frame_ops.append({
'type': 'visibility',
'obj': obj,
'hide_viewport': False,
'hide_render': False
})
# Apply color
if consider_start:
if getattr(ColorType, 'use_start_original_color', False):
color = original_color
else:
start_color = getattr(ColorType, 'start_color', [0.8, 0.8, 0.8, 1.0])
start_transparency = getattr(ColorType, 'start_transparency', 0.0)
color = [start_color[0], start_color[1], start_color[2], 1.0 - start_transparency]
else:
color = original_color
frame_ops.append({
'type': 'color',
'obj': obj,
'color': color
})
else:
frame_ops.append({
'type': 'visibility',
'obj': obj,
'hide_viewport': True,
'hide_render': True
})
# Plan ACTIVE state
active_frames = frame_data["states"]["active"]
start_f, end_f = active_frames
if end_f >= start_f and getattr(ColorType, 'consider_active', True):
frame_ops = animation_plan.setdefault(start_f, [])
frame_ops.append({
'type': 'visibility',
'obj': obj,
'hide_viewport': False,
'hide_render': False
})
# Apply active color
if getattr(ColorType, 'use_active_original_color', False):
color = original_color
else:
active_color = getattr(ColorType, 'active_color', [0.0, 1.0, 0.0, 1.0])
active_transparency = getattr(ColorType, 'active_transparency', 0.0)
color = [active_color[0], active_color[1], active_color[2], 1.0 - active_transparency]
frame_ops.append({
'type': 'color',
'obj': obj,
'color': color
})
# Plan END state
end_state_frames = frame_data["states"]["after_end"]
start_f, end_f = end_state_frames
if end_f >= start_f and getattr(ColorType, 'consider_end', True):
frame_ops = animation_plan.setdefault(start_f, [])
frame_ops.append({
'type': 'visibility',
'obj': obj,
'hide_viewport': False,
'hide_render': False
})
# Apply end color
if getattr(ColorType, 'use_end_original_color', False):
color = original_color
else:
end_color = getattr(ColorType, 'end_color', [1.0, 0.0, 0.0, 1.0])
end_transparency = getattr(ColorType, 'end_transparency', 0.0)
color = [end_color[0], end_color[1], end_color[2], 1.0 - end_transparency]
frame_ops.append({
'type': 'color',
'obj': obj,
'color': color
})
@classmethod
def execute_animation_plan(cls, context, animation_plan):
"""
Phase 2: Execution - Applies the animation plan efficiently using batch operations.
"""
import time
start_time = time.time()
print("[INFO] EXECUTING animation plan...")
frames_to_execute = sorted(animation_plan.keys())
for frame in frames_to_execute:
context.scene.frame_set(frame)
operations = animation_plan[frame]
for op in operations:
obj = op['obj']
if op['type'] == 'visibility':
obj.hide_viewport = op['hide_viewport']
obj.hide_render = op['hide_render']
obj.keyframe_insert(data_path="hide_viewport", frame=frame)
obj.keyframe_insert(data_path="hide_render", frame=frame)
elif op['type'] == 'color':
color = op['color']
if hasattr(obj.data, 'materials') and obj.data.materials:
material = obj.data.materials[0]
if material and material.use_nodes:
principled = material.node_tree.nodes.get("Principled BSDF")
if principled:
principled.inputs["Base Color"].default_value = color
principled.inputs["Base Color"].keyframe_insert(data_path="default_value", frame=frame)
elapsed = time.time() - start_time
print(f"[INFO] EXECUTION complete in {elapsed:.3f}s for {len(frames_to_execute)} frames")
@classmethod
def apply_ColorType_animation(cls, obj, frame_data, ColorType, original_color, settings):
"""
Applies animation to an object based on its appearance profile.
"""
# Check if this is priority mode (start only)
# We detect by the consider_start_active flag in frame_data
is_priority_mode = frame_data.get("consider_start_active", False)
if is_priority_mode:
print(f"🔒 apply_ColorType_animation: {obj.name} detected consider_start_active=True (Priority start)")
# Only apply start appearance for the entire active range
active_frames = frame_data["states"]["active"]
start_f, end_f = active_frames
if end_f >= start_f:
print(f" Applying START appearance for entire active range: {start_f} to {end_f}")
cls.apply_state_appearance(obj, ColorType, "start", start_f, end_f, original_color, frame_data)
return
# Standard logic for regular tasks (not priority mode)
is_start_considered = getattr(ColorType, 'consider_start', False)
is_active_considered = getattr(ColorType, 'consider_active', True)
is_end_considered = getattr(ColorType, 'consider_end', True)
# Special case: Only START is enabled
if is_start_considered and not is_active_considered and not is_end_considered:
print(f" Only START enabled for {obj.name}. Applying for entire range.")
full_start = min([s[0] for s in frame_data["states"].values() if s[1] >= s[0]])
full_end = max([s[1] for s in frame_data["states"].values() if s[1] >= s[0]])
print(f" Calling apply_state_appearance(state='start')")
cls.apply_state_appearance(obj, ColorType, "start", start_f, end_f, original_color, frame_data)
print(f" Visibility after: viewport={not obj.hide_viewport}, render={not obj.hide_render}")
return
# Standard state-by-state processing
for state_name, (start_f, end_f) in frame_data["states"].items():
if end_f < start_f:
continue
print(f" Processing state '{state_name}' for {obj.name}: frames {start_f}-{end_f}")
if state_name == "before_start":
state = "start"
elif state_name == "active":
state = "in_progress"
elif state_name == "after_end":
state = "end"
else:
continue
# Apply states based on consider flags
if state == "start":
if not is_start_considered:
# Hide the object if start is not considered and it's construction (output)
if frame_data.get("relationship") == "output":
print(f" Hiding {obj.name} in START state (output without consider_start)")
bpy.context.scene.frame_set(start_f)
obj.hide_viewport = True
obj.hide_render = True
obj.keyframe_insert(data_path="hide_viewport", frame=start_f)
obj.keyframe_insert(data_path="hide_render", frame=start_f)
continue
cls.apply_state_appearance(obj, ColorType, "start", start_f, end_f, original_color, frame_data)
elif state == "in_progress":
if not is_active_considered:
continue
cls.apply_state_appearance(obj, ColorType, "in_progress", start_f, end_f, original_color, frame_data)
elif state == "end":
if not is_end_considered:
continue
# Check if we should hide the object after completion
if frame_data.get("relationship") == "input":
print(f" Hiding {obj.name} in END state (input after use)")
bpy.context.scene.frame_set(start_f)
obj.hide_viewport = True
obj.hide_render = True
obj.keyframe_insert(data_path="hide_viewport", frame=start_f)
obj.keyframe_insert(data_path="hide_render", frame=start_f)
continue
cls.apply_state_appearance(obj, ColorType, "end", start_f, end_f, original_color, frame_data)
@classmethod
def apply_state_appearance(cls, obj, ColorType, state, start_frame, end_frame, original_color, frame_data=None):
"""Apply appearance for a specific state"""
if state == "start":
# When consider_start=True, the object should always be visible
bpy.context.scene.frame_set(start_frame)
obj.hide_viewport = False
obj.hide_render = False
obj.keyframe_insert(data_path="hide_viewport", frame=start_frame)
obj.keyframe_insert(data_path="hide_render", frame=start_frame)
# Apply start color
use_original = getattr(ColorType, 'use_start_original_color', False)
if use_original:
color = original_color
else:
start_color = getattr(ColorType, 'start_color', [0.8, 0.8, 0.8, 1.0])
transparency = getattr(ColorType, 'start_transparency', 0.0)
color = [start_color[0], start_color[1], start_color[2], 1.0 - transparency]
# Set color keyframe
if hasattr(obj.data, 'materials') and obj.data.materials:
material = obj.data.materials[0]
if material and material.use_nodes:
principled = material.node_tree.nodes.get("Principled BSDF")
if principled:
principled.inputs["Base Color"].default_value = color
principled.inputs["Base Color"].keyframe_insert(data_path="default_value", frame=start_frame)
elif state == "in_progress":
# Make visible
bpy.context.scene.frame_set(start_frame)
obj.hide_viewport = False
obj.hide_render = False
obj.keyframe_insert(data_path="hide_viewport", frame=start_frame)
obj.keyframe_insert(data_path="hide_render", frame=start_frame)
# Apply active color
use_original = getattr(ColorType, 'use_active_original_color', False)
if use_original:
color = original_color
else:
active_color = getattr(ColorType, 'active_color', [0.0, 1.0, 0.0, 1.0])
transparency = getattr(ColorType, 'active_transparency', 0.0)
color = [active_color[0], active_color[1], active_color[2], 1.0 - transparency]
# Apply color animation with interpolation
try:
# Interpolate color values over time if needed
interpolated_values = _seq_data.interpolate_ColorType_values(ColorType, state, 0.0)
alpha = interpolated_values.get('alpha', 1.0 - transparency)
color = [active_color[0], active_color[1], active_color[2], alpha]
except Exception:
pass
if hasattr(obj.data, 'materials') and obj.data.materials:
material = obj.data.materials[0]
if material and material.use_nodes:
principled = material.node_tree.nodes.get("Principled BSDF")
if principled:
principled.inputs["Base Color"].default_value = color
principled.inputs["Base Color"].keyframe_insert(data_path="default_value", frame=start_frame)
elif state == "end":
# Make visible
bpy.context.scene.frame_set(start_frame)
obj.hide_viewport = False
obj.hide_render = False
obj.keyframe_insert(data_path="hide_viewport", frame=start_frame)
obj.keyframe_insert(data_path="hide_render", frame=start_frame)
# Apply end color
use_original = getattr(ColorType, 'use_end_original_color', False)
if use_original:
color = original_color
else:
end_color = getattr(ColorType, 'end_color', [1.0, 0.0, 0.0, 1.0])
transparency = getattr(ColorType, 'end_transparency', 0.0)
color = [end_color[0], end_color[1], end_color[2], 1.0 - transparency]
if hasattr(obj.data, 'materials') and obj.data.materials:
material = obj.data.materials[0]
if material and material.use_nodes:
principled = material.node_tree.nodes.get("Principled BSDF")
if principled:
principled.inputs["Base Color"].default_value = color
principled.inputs["Base Color"].keyframe_insert(data_path="default_value", frame=start_frame)
@classmethod
def _plan_complete_system_animation(cls, obj, states, ColorType, original_color, frame_data, visibility_ops, color_ops):
"""Plans operations using REAL ColorType with full support - FROM COMPLETE_SYSTEM_ULTRA_FAST"""
# Handle priority mode (START only activated) first
if frame_data.get("consider_start_active", False):
print(f"🔒 PLAN_COMPLETE_SYSTEM: {obj.name} detectado consider_start_active=True (Start prioritario)")
active_frames = states.get("active", (0, -1))
if active_frames[1] >= active_frames[0]:
print(f" Making object visible for entire range: {active_frames[0]} to {active_frames[1]}")
visibility_ops.append({'obj': obj, 'frame': active_frames[0], 'hide': False})
use_original = getattr(ColorType, 'use_start_original_color', False)
if use_original:
color = original_color
else:
start_color = getattr(ColorType, 'start_color', [0.8, 0.8, 0.8, 1.0])
transparency = getattr(ColorType, 'start_transparency', 0.0)
color = [start_color[0], start_color[1], start_color[2], 1.0 - transparency]
color_ops.append({'obj': obj, 'frame': active_frames[0], 'color': color})
color_ops.append({'obj': obj, 'frame': active_frames[1], 'color': color})
print(f" Priority mode: added visibility (hide=False) and color ops")
return
# Regular processing for non-priority mode
consider_start = getattr(ColorType, 'consider_start', False)
consider_active = getattr(ColorType, 'consider_active', True)
consider_end = getattr(ColorType, 'consider_end', True)
is_construction = frame_data.get("relationship") == "output"
# Process each state
for state_name, (start_f, end_f) in states.items():
if end_f < start_f:
continue
should_process = False
state_color = original_color
if state_name == "before_start" and consider_start:
should_process = True
if not getattr(ColorType, 'use_start_original_color', False):
start_color = getattr(ColorType, 'start_color', [0.8, 0.8, 0.8, 1.0])
transparency = getattr(ColorType, 'start_transparency', 0.0)
state_color = [start_color[0], start_color[1], start_color[2], 1.0 - transparency]
elif state_name == "active" and consider_active:
should_process = True
if not getattr(ColorType, 'use_active_original_color', False):
active_color = getattr(ColorType, 'active_color', [0.0, 1.0, 0.0, 1.0])
transparency = getattr(ColorType, 'active_transparency', 0.0)
state_color = [active_color[0], active_color[1], active_color[2], 1.0 - transparency]
elif state_name == "after_end" and consider_end:
should_process = True
if not getattr(ColorType, 'use_end_original_color', False):
end_color = getattr(ColorType, 'end_color', [1.0, 0.0, 0.0, 1.0])
transparency = getattr(ColorType, 'end_transparency', 0.0)
state_color = [end_color[0], end_color[1], end_color[2], 1.0 - transparency]
if should_process:
# Determine visibility
should_hide = False
if state_name == "before_start" and is_construction and not consider_start:
should_hide = True
elif state_name == "after_end" and not is_construction:
should_hide = True
visibility_ops.append({'obj': obj, 'frame': start_f, 'hide': should_hide})
if not should_hide:
color_ops.append({'obj': obj, 'frame': start_f, 'color': state_color})
@classmethod
def animate_objects_with_ColorTypes(cls, settings, product_frames):
"""
ULTRA-OPTIMIZED ANIMATION SYSTEM with ALL optimizations integrated:
- ColorType cache, IFC lookup, Performance cache, Batch processor
"""
import time
start_time = time.time()
print("Starting ultra-optimized animation with ColorTypes")
print(f"[OPTIMIZED] ULTRA-OPTIMIZED ANIMATION: Planning for {len(product_frames)} products")
# Get original colors from saved state
original_object_colors = bpy.context.scene.get('BIM_VarianceOriginalObjectColors')
if original_object_colors:
original_colors = json.loads(original_object_colors)
else:
print("[WARNING] No original colors found, using fallback approach")
original_colors = {}
# Build mapping once
ifc_to_blender = {}
for obj in bpy.data.objects:
if obj.type == 'MESH':
element = tool.Ifc.get_entity(obj)
if element and not element.is_a("IfcSpace"):
ifc_to_blender[element.id()] = obj
# Get animation properties once
animation_props = tool.Sequence.get_animation_props()
# Batch operations for performance
objects_to_hide = []
keyframe_operations = []
total_objects_processed = 0
# Process each product and its frames
for product_id, frame_data_list in product_frames.items():
obj = ifc_to_blender.get(product_id)
if not obj:
continue
total_objects_processed += 1
original_color = original_colors.get(obj.name, [0.8, 0.8, 0.8, 1.0])
# Process each frame data entry for this product
for frame_data in frame_data_list:
task = frame_data.get("task")
if not task:
continue
# Get ColorType for this task
try:
ColorType = tool.Sequence._get_best_ColorType_for_task(task, animation_props)
except Exception as e:
print(f"[ERROR] Error getting ColorType for task {task.id()}: {e}")
continue
# Apply animation using optimized method
cls.apply_ColorType_animation(obj, frame_data, ColorType, original_color, settings)
# === EXECUTE BATCH OPERATIONS ===
print(f"[INFO] Executing batch operations: {len(objects_to_hide)} hide, {len(keyframe_operations)} keyframes")
# Execute hide operations
for obj in objects_to_hide:
obj.hide_viewport = True
obj.hide_render = True
# Execute keyframe operations
for operation in keyframe_operations:
operation['execute']()
# Set viewport shading
area = tool.Blender.get_view3d_area()
try:
area.spaces[0].shading.color_type = "OBJECT"
except Exception:
pass
# Set frame range
bpy.context.scene.frame_start = settings["start_frame"]
bpy.context.scene.frame_end = int(settings["start_frame"] + settings["total_frames"])
elapsed = time.time() - start_time
print(f"[OPTIMIZED] ULTRA-OPTIMIZED ANIMATION COMPLETE in {elapsed:.3f}s")
print(f"[OPTIMIZED] Objects processed: {total_objects_processed}")
@classmethod
def apply_visibility_animation(cls, obj, frame_data, ColorType):
"""Applies only the visibility (hide/show) keyframes for live update mode."""
# Note: Keyframes at frame 0 were already set in animate_objects_with_ColorTypes
for state_name, (start_f, end_f) in frame_data["states"].items():
if end_f < start_f:
continue
# Logic for hiding objects based on state and ColorType properties
is_hidden = False
if state_name == "before_start" and not getattr(ColorType, 'consider_start', False) and frame_data.get("relationship") == "output":
is_hidden = True
elif state_name == "after_end" and frame_data.get("relationship") == "input":
is_hidden = True
# Set visibility keyframes
bpy.context.scene.frame_set(start_f)
obj.hide_viewport = is_hidden
obj.hide_render = is_hidden
obj.keyframe_insert(data_path="hide_viewport", frame=start_f)
obj.keyframe_insert(data_path="hide_render", frame=start_f)
@classmethod
def _apply_ColorType_to_object(cls, obj, frame_data, ColorType, original_color, settings):
print(f"Applying ColorType to object {obj.name}")
for state_name, (start_f, end_f) in frame_data["states"].items():
if end_f < start_f:
continue
if state_name == "before_start":
state = "start"
elif state_name == "active":
state = "in_progress"
elif state_name == "after_end":
state = "end"
else:
continue
if state == "start" and not getattr(ColorType, 'consider_start', False):
if frame_data.get("relationship") == "output":
obj.hide_viewport = True
obj.hide_render = True
obj.keyframe_insert(data_path="hide_viewport", frame=start_f)
obj.keyframe_insert(data_path="hide_render", frame=start_f)
continue
elif state == "in_progress" and not getattr(ColorType, 'consider_active', True):
continue
elif state == "end" and not getattr(ColorType, 'consider_end', True):
if frame_data.get("relationship") == "input":
obj.hide_viewport = True
obj.hide_render = True
obj.keyframe_insert(data_path="hide_viewport", frame=start_f)
obj.keyframe_insert(data_path="hide_render", frame=start_f)
continue
cls.apply_state_appearance(obj, ColorType, state, start_f, end_f, original_color, frame_data)
# Transparency: fade during active stretch
try:
if state == 'in_progress':
# Get color values with interpolation
interpolated_values = _seq_data.interpolate_ColorType_values(ColorType, state, 0.5)
alpha = interpolated_values.get('alpha', 1.0)
if hasattr(obj.data, 'materials') and obj.data.materials:
material = obj.data.materials[0]
if material and material.use_nodes:
principled = material.node_tree.nodes.get("Principled BSDF")
if principled:
current_color = list(principled.inputs["Base Color"].default_value)
current_color[3] = alpha
principled.inputs["Base Color"].default_value = current_color
principled.inputs["Base Color"].keyframe_insert(data_path="default_value", frame=start_f)
except Exception as e:
print(f"[WARNING] Error applying transparency: {e}")
@classmethod
def clear_objects_animation(
cls,
include_blender_objects: bool = True,
*,
only_selected: bool = False,
):
"""Clear all animation keyframes from objects."""
import time
start_time = time.time()
print("Clearing object animations...")
objects_to_clear = []
if only_selected:
objects_to_clear = [obj for obj in bpy.context.selected_objects if obj.type == 'MESH']
else:
objects_to_clear = [obj for obj in bpy.data.objects if obj.type == 'MESH']
cleared_objects = 0
for obj in objects_to_clear:
# Clear visibility keyframes
if obj.animation_data and obj.animation_data.action:
for fcurve in obj.animation_data.action.fcurves[:]:
if fcurve.data_path in ["hide_viewport", "hide_render"]:
obj.animation_data.action.fcurves.remove(fcurve)
cleared_objects += 1
# Clear material color keyframes
if hasattr(obj.data, 'materials') and obj.data.materials:
for material in obj.data.materials:
if material and material.use_nodes and material.node_tree:
principled = material.node_tree.nodes.get("Principled BSDF")
if principled and material.animation_data and material.animation_data.action:
for fcurve in material.animation_data.action.fcurves[:]:
if "Base Color" in fcurve.data_path:
material.animation_data.action.fcurves.remove(fcurve)
# Reset visibility
obj.hide_viewport = False
obj.hide_render = False
# Restore original colors if they exist
original_object_colors = bpy.context.scene.get('BIM_VarianceOriginalObjectColors')
if original_object_colors:
original_colors = json.loads(original_object_colors)
for obj in objects_to_clear:
if obj.name in original_colors:
color = original_colors[obj.name]
if hasattr(obj.data, 'materials') and obj.data.materials:
material = obj.data.materials[0]
if material and material.use_nodes:
principled = material.node_tree.nodes.get("Principled BSDF")
if principled:
principled.inputs["Base Color"].default_value = color
elapsed = time.time() - start_time
print(f"[INFO] Animation cleared for {cleared_objects} objects in {elapsed:.3f}s")
# Optimization helper methods
@classmethod
def _get_active_group_optimized(cls, animation_props):
"""Get active animation group name with optimization"""
for item in getattr(animation_props, 'animation_group_stack', []):
if getattr(item, 'enabled', False) and getattr(item, 'group', None):
return item.group
return 'DEFAULT'
@classmethod
def _save_original_colors_optimized(cls, cache):
"""Save original colors using cache"""
if bpy.context.scene.get('BIM_VarianceOriginalObjectColors'):
return
original_colors = {}
for obj in bpy.data.objects:
if obj.type == 'MESH':
color = cache.get_original_color(obj)
if color:
original_colors[obj.name] = color
bpy.context.scene['BIM_VarianceOriginalObjectColors'] = json.dumps(original_colors)
@classmethod
def _get_colortype_optimized(cls, task, animation_props, active_group_name):
"""Get ColorType for task with optimization"""
try:
return tool.Sequence._get_best_ColorType_for_task(task, animation_props)
except Exception as e:
print(f"[WARNING] Error getting ColorType for task {task.id()}: {e}")
return None
@classmethod
def _apply_object_animation_optimized(cls, obj, frame_data, colortype, settings, visibility_ops, color_ops):
"""Apply optimized object animation using batch operations"""
states = frame_data.get("states", {})
original_color = [0.8, 0.8, 0.8, 1.0] # Default fallback
# Handle priority mode
if frame_data.get("consider_start_active", False):
active_frames = states.get("active", (0, -1))
if active_frames[1] >= active_frames[0]:
visibility_ops.append({'obj': obj, 'frame': active_frames[0], 'hide': False})
use_original = getattr(colortype, 'use_start_original_color', False)
if use_original:
color = original_color
else:
start_color = getattr(colortype, 'start_color', [0.8, 0.8, 0.8, 1.0])
transparency = getattr(colortype, 'start_transparency', 0.0)
color = [start_color[0], start_color[1], start_color[2], 1.0 - transparency]
color_ops.append({'obj': obj, 'frame': active_frames[0], 'color': color})
return
# Regular processing
cls._plan_complete_system_animation(obj, states, colortype, original_color, frame_data, visibility_ops, color_ops)
@classmethod
def _setup_viewport_shading_optimized(cls):
"""Setup viewport shading optimized"""
area = tool.Blender.get_view3d_area()
if area and area.spaces:
try:
area.spaces[0].shading.color_type = "OBJECT"
except Exception:
pass
@classmethod
def clear_objects_animation_optimized(cls, include_blender_objects=True):
"""Optimized animation cleanup"""
import time
start_time = time.time()
print("[OPTIMIZED] Clearing animations with optimization...")
objects_cleared = 0
for obj in bpy.data.objects:
if obj.type != 'MESH':
continue
# Clear object keyframes
if obj.animation_data and obj.animation_data.action:
fcurves_to_remove = []
for fcurve in obj.animation_data.action.fcurves:
if fcurve.data_path in ["hide_viewport", "hide_render"]:
fcurves_to_remove.append(fcurve)
for fcurve in fcurves_to_remove:
obj.animation_data.action.fcurves.remove(fcurve)
objects_cleared += 1
# Clear material keyframes
if hasattr(obj.data, 'materials'):
for material in obj.data.materials or []:
if material and material.animation_data and material.animation_data.action:
fcurves_to_remove = []
for fcurve in material.animation_data.action.fcurves:
if "Base Color" in fcurve.data_path:
fcurves_to_remove.append(fcurve)
for fcurve in fcurves_to_remove:
material.animation_data.action.fcurves.remove(fcurve)
# Reset visibility
obj.hide_viewport = False
obj.hide_render = False
elapsed = time.time() - start_time
print(f"[OPTIMIZED] Animation cleared for {objects_cleared} objects in {elapsed:.3f}s")
@@ -0,0 +1,598 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import bpy
import ifcopenshell
import ifcopenshell.util.sequence
import ifcopenshell.util.date
import bonsai.tool as tool
from typing import Any
# Try to import data module for interpolate_ColorType_values function
try:
from bonsai.bim.module.sequence.data import interpolate_ColorType_values
class _seq_data:
interpolate_ColorType_values = staticmethod(interpolate_ColorType_values)
except ImportError:
try:
import bonsai.data as _seq_data
except ImportError:
# Fallback: Create a dummy data module
class _seq_data:
@staticmethod
def interpolate_ColorType_values(ColorType, state, progress=0.0):
# Return default values if optimization is not available
return {'alpha': 1.0}
class AnimationFramesSequence:
"""Mixin class for calculating animation frame data for IFC products."""
@classmethod
def get_task_for_product(cls, product):
"""Gets the task associated with an IFC product."""
element = tool.Ifc.get_entity(product) if hasattr(product, 'name') else product
if not element:
return None
# Search in outputs
for rel in element.ReferencedBy or []:
if rel.is_a("IfcRelAssignsToProduct"):
for task in rel.RelatedObjects:
if task.is_a("IfcTask"):
return task
# Search in inputs
for rel in element.HasAssignments or []:
if rel.is_a("IfcRelAssignsToProcess"):
task = rel.RelatingProcess
if task.is_a("IfcTask"):
return task
return None
def get_animation_product_frames(self, work_schedule, settings):
"""
Calculates animation frames with a high-performance approach.
Uses date caching and drastically reduces repetitive operations.
"""
import time
from datetime import datetime
print("Starting frame calculation with final optimization...")
start_time = time.time()
# Get all products from the work schedule by collecting from all tasks
import ifcopenshell.util.sequence
products = []
def get_all_tasks_recursive(tasks):
all_tasks = []
for task in tasks:
all_tasks.append(task)
nested = ifcopenshell.util.sequence.get_nested_tasks(task)
if nested:
all_tasks.extend(get_all_tasks_recursive(nested))
return all_tasks
root_tasks = ifcopenshell.util.sequence.get_root_tasks(work_schedule)
all_tasks = get_all_tasks_recursive(root_tasks)
# Collect all products from tasks (outputs and inputs)
for task in all_tasks:
task_outputs = ifcopenshell.util.sequence.get_task_outputs(task)
if task_outputs:
products.extend(task_outputs)
task_inputs = ifcopenshell.util.sequence.get_task_inputs(task)
if task_inputs:
products.extend(task_inputs)
# Remove duplicates
products = list(set(products))
if not products:
return {}
start_date_str = settings.get("start")
finish_date_str = settings.get("finish")
if not start_date_str or not finish_date_str:
print(" - No start/end dates provided. Calculating from tasks...")
all_dates = []
for task in all_tasks:
task_start = ifcopenshell.util.sequence.derive_date(task, "ScheduleStart", is_earliest=True)
task_end = ifcopenshell.util.sequence.derive_date(task, "ScheduleFinish", is_latest=True)
if task_start: all_dates.append(task_start)
if task_end: all_dates.append(task_end)
if not all_dates: return {}
start_date = min(all_dates)
finish_date = max(all_dates)
else:
start_date = datetime.fromisoformat(start_date_str)
finish_date = datetime.fromisoformat(finish_date_str)
duration = (finish_date - start_date).total_seconds()
if duration <= 0:
return {}
total_frames = settings.get("total_frames", 250)
start_frame = settings.get("start_frame", 1)
# --- KEY OPTIMIZATION: DATE CACHE ---
date_to_frame_cache = {}
def date_to_frame(d):
# Avoid recalculating if the date was already processed
if d in date_to_frame_cache:
return date_to_frame_cache[d]
progress = (d - start_date).total_seconds() / duration
frame = start_frame + (total_frames * progress)
result = max(start_frame, min(start_frame + total_frames, frame))
date_to_frame_cache[d] = result
return result
result = {}
# --- OPTIMIZED LOOP ---
for product in products:
# Find tasks that have this product as input or output
tasks = {}
for task in all_tasks:
task_outputs = ifcopenshell.util.sequence.get_task_outputs(task)
if product in task_outputs:
tasks[task] = "output"
task_inputs = ifcopenshell.util.sequence.get_task_inputs(task)
if product in task_inputs:
tasks[task] = "input"
if not tasks:
continue
product_frames = []
for task, rel in tasks.items():
task_start = ifcopenshell.util.sequence.derive_date(task, "ScheduleStart", is_earliest=True)
task_end = ifcopenshell.util.sequence.derive_date(task, "ScheduleFinish", is_latest=True)
if not task_start or not task_end or task_start >= task_end:
continue
frame_start = date_to_frame(task_start)
frame_end = date_to_frame(task_end)
product_frames.append({
"task": task,
"relationship": rel,
"states": {
"before_start": (start_frame, frame_start - 1),
"active": (frame_start, frame_end),
"after_end": (frame_end + 1, start_frame + total_frames),
},
})
if product_frames:
result[product.id()] = product_frames
end_time = time.time()
print(f" - TOOL: Frame calculation completed in {end_time - start_time:.3f}s for {len(result)} products.")
return result
@classmethod
def get_animation_product_frames_enhanced(cls, work_schedule: ifcopenshell.entity_instance, settings: dict[str, Any]):
animation_start = int(settings["start_frame"])
animation_end = int(settings["start_frame"] + settings["total_frames"])
viz_start = settings["start"]
viz_finish = settings["finish"]
viz_duration = settings["duration"]
product_frames: dict[int, list] = {}
# --- Get the date source from properties ---
props = tool.Sequence.get_work_schedule_props()
date_source = getattr(props, "date_source_type", "SCHEDULE")
start_date_type = f"{date_source.capitalize()}Start"
finish_date_type = f"{date_source.capitalize()}Finish"
def add_product_frame_enhanced(product_id, task, start_date, finish_date, start_frame, finish_frame, relationship):
if finish_date < viz_start:
states = {
"before_start": (animation_start, animation_start - 1),
"active": (animation_start, animation_start - 1),
"after_end": (animation_start, animation_end),
}
elif start_date > viz_finish:
return
else:
s_vis = max(animation_start, int(start_frame))
f_vis = min(animation_end, int(finish_frame))
if f_vis < s_vis:
s_vis = max(animation_start, min(animation_end, s_vis))
f_vis = s_vis
before_end = s_vis - 1
after_start = f_vis + 1
states = {
"before_start": (animation_start, before_end) if before_end >= animation_start else (animation_start, animation_start - 1),
"active": (s_vis, f_vis),
"after_end": (after_start if after_start <= animation_end else animation_end + 1, animation_end),
}
product_frames.setdefault(product_id, []).append({
"task": task, "task_id": task.id(),
"type": getattr(task, "PredefinedType", "NOTDEFINED"),
"relationship": relationship,
"start_date": start_date, "finish_date": finish_date,
"STARTED": int(start_frame), "COMPLETED": int(finish_frame),
"start_frame": max(animation_start, int(start_frame)),
"finish_frame": min(animation_end, int(finish_frame)),
"states": states,
})
def add_product_frame_full_range(product_id, task, relationship):
states = { "active": (animation_start, animation_end) }
frame_data = {
"task": task, "task_id": task.id(),
"type": getattr(task, "PredefinedType", "NOTDEFINED"),
"relationship": relationship,
"start_date": viz_start, "finish_date": viz_finish,
"STARTED": animation_start, "COMPLETED": animation_end,
"start_frame": animation_start, "finish_frame": animation_end,
"states": states,
"consider_start_active": True,
}
product_frames.setdefault(product_id, []).append(frame_data)
# Product created with consider_start_active=True
print(f" Task: {task.Name}")
print(f" Relationship: {relationship}")
print(f" States: {states}")
print(f" Frame data: {frame_data}")
def preprocess_task(task):
for subtask in ifcopenshell.util.sequence.get_nested_tasks(task):
preprocess_task(subtask)
# --- Use the selected date source ---
task_start = ifcopenshell.util.sequence.derive_date(task, start_date_type, is_earliest=True)
task_finish = ifcopenshell.util.sequence.derive_date(task, finish_date_type, is_latest=True)
if not task_start or not task_finish:
return
# Get the complete profile to verify the state combination, not just 'consider_start'.
ColorType = tool.Sequence._get_best_ColorType_for_task(task, tool.Sequence.get_animation_props())
consider_start = getattr(ColorType, 'consider_start', False)
consider_active = getattr(ColorType, 'consider_active', True)
consider_end = getattr(ColorType, 'consider_end', True)
is_priority_mode = (consider_start and not consider_active and not consider_end)
# Task processing started
print(f" ColorType: {getattr(ColorType, 'name', 'Unknown')}")
print(f" consider_start: {consider_start}")
print(f" consider_active: {consider_active}")
print(f" consider_end: {consider_end}")
print(f" is_priority_mode: {is_priority_mode}")
# If it is priority mode, IGNORE DATES and use the full range.
if is_priority_mode:
print(f"🔒 Task '{task.Name}' in priority mode. Ignoring dates.")
for output in ifcopenshell.util.sequence.get_task_outputs(task):
add_product_frame_full_range(output.id(), task, "output")
for input_prod in tool.Sequence.get_task_inputs(task):
add_product_frame_full_range(input_prod.id(), task, "input")
return
# If it is NOT priority mode, use the task dates to calculate the frames.
if task_start > viz_finish:
return
if viz_duration.total_seconds() > 0:
start_progress = (task_start - viz_start).total_seconds() / viz_duration.total_seconds()
finish_progress = (task_finish - viz_start).total_seconds() / viz_duration.total_seconds()
else:
start_progress, finish_progress = 0.0, 1.0
sf = int(round(settings["start_frame"] + (start_progress * settings["total_frames"])))
ff = int(round(settings["start_frame"] + (finish_progress * settings["total_frames"])))
for output in ifcopenshell.util.sequence.get_task_outputs(task):
add_product_frame_enhanced(output.id(), task, task_start, task_finish, sf, ff, "output")
for input_prod in tool.Sequence.get_task_inputs(task):
add_product_frame_enhanced(input_prod.id(), task, task_start, task_finish, sf, ff, "input")
for root_task in ifcopenshell.util.sequence.get_root_tasks(work_schedule):
preprocess_task(root_task)
return product_frames
@classmethod
def get_product_frames_with_ColorTypes(cls, work_schedule, settings):
"""Enhanced version with profile support and compatible 'states'.
If the 'get_animation_product_frames_enhanced' method exists, it uses it and returns its structure,
thus ensuring compatibility with apply_ColorType_animation.
"""
# Guarantees the DEFAULT group exists if the user has not configured anything
try:
from bonsai.bim.module.sequence.prop import UnifiedColorTypeManager
UnifiedColorTypeManager.ensure_default_group(bpy.context)
except Exception:
pass
# We prefer the existing 'enhanced' path to maintain compatibility
try:
frames = cls.get_animation_product_frames_enhanced(work_schedule, settings)
if isinstance(frames, dict):
return frames
except Exception:
pass
# Fallback: build product->frames with minimal states from the basic method
basic = cls.get_animation_product_frames(work_schedule, settings)
product_frames = {}
for pid, items in (basic or {}).items():
product_frames[pid] = []
for it in items:
start_f = it.get("STARTED")
finish_f = it.get("COMPLETED")
if start_f is None or finish_f is None:
continue
product_frames[pid].append({
"task": None,
"task_id": 0,
"type": it.get("type") or "NOTDEFINED",
"relationship": it.get("relationship") or "output",
"start_date": settings.get("start"),
"finish_date": settings.get("finish"),
"STARTED": start_f,
"COMPLETED": finish_f,
"start_frame": start_f,
"finish_frame": finish_f,
"states": {
"before_start": (settings["start_frame"], max(settings["start_frame"], int(start_f) - 1)),
"active": (int(start_f), int(finish_f)),
"after_end": (min(int(finish_f) + 1, int(settings["start_frame"] + settings["total_frames"])), int(settings["start_frame"] + settings["total_frames"])),
},
})
return product_frames
@classmethod
def get_animation_product_frames_enhanced_optimized(cls, work_schedule, settings, lookup_optimizer, date_cache):
"""ULTRA-OPTIMIZED version using pre-computed lookup tables and cache"""
import time
start_time = time.time()
print("Using optimized animation product frames with priority mode support")
animation_start = int(settings["start_frame"])
animation_end = int(settings["start_frame"] + settings["total_frames"])
viz_start = settings["start"]
viz_finish = settings["finish"]
viz_duration = settings["duration"]
product_frames = {}
# Get date source from properties
props = tool.Sequence.get_work_schedule_props()
date_source = getattr(props, "date_source_type", "SCHEDULE")
start_date_type = f"{date_source.capitalize()}Start"
finish_date_type = f"{date_source.capitalize()}Finish"
all_tasks = lookup_optimizer.get_all_tasks()
print(f"[OPTIMIZED] OPTIMIZED FRAMES: Processing {len(all_tasks)} tasks")
# Debug: Check if lookup optimizer has products
total_products_in_lookup = len(lookup_optimizer.product_ids) if hasattr(lookup_optimizer, 'product_ids') else 0
print(f"[DEBUG] Total products in lookup optimizer: {total_products_in_lookup}")
tasks_processed = 0
for task in all_tasks:
try:
print(f"[DEBUG] Processing task {task.id()}: {getattr(task, 'Name', 'N/A')}")
# Check for priority mode
ColorType = tool.Sequence._get_best_ColorType_for_task(task, tool.Sequence.get_animation_props())
is_priority_mode = (
getattr(ColorType, 'consider_start', False) and
not getattr(ColorType, 'consider_active', True) and
not getattr(ColorType, 'consider_end', True)
)
# If priority mode, use full range and skip date-based logic
if is_priority_mode:
print(f"🔒 OPTIMIZED PRIORITY MODE DETECTED: Task '{task.Name}' in priority mode.")
print(f" ColorType: {getattr(ColorType, 'name', 'Unknown')}")
print(f" consider_start: {getattr(ColorType, 'consider_start', False)}")
print(f" consider_active: {getattr(ColorType, 'consider_active', True)}")
print(f" consider_end: {getattr(ColorType, 'consider_end', True)}")
outputs_processed = 0
for output in lookup_optimizer.get_outputs_for_task(task.id()):
print(f" - Adding OUTPUT product {output.id()} in PRIORITY mode")
cls._add_optimized_priority_frame(
product_frames, output.id(), task, "output", animation_start, animation_end
)
outputs_processed += 1
inputs_processed = 0
for input_prod in lookup_optimizer.get_inputs_for_task(task.id()):
print(f" - Adding INPUT product {input_prod.id()} in PRIORITY mode")
cls._add_optimized_priority_frame(
product_frames, input_prod.id(), task, "input", animation_start, animation_end
)
inputs_processed += 1
print(f" Products added: {outputs_processed} outputs, {inputs_processed} inputs")
print(f" - Current product_frames count: {len(product_frames)}")
continue
# Use date cache for instant access
task_start = date_cache.get_date(task, start_date_type, is_earliest=True)
task_finish = date_cache.get_date(task, finish_date_type, is_latest=True)
if not task_start or not task_finish or task_start > viz_finish:
continue
# Calculate frame positions with cache
if viz_duration.total_seconds() > 0:
start_progress = (task_start - viz_start).total_seconds() / viz_duration.total_seconds()
finish_progress = (task_finish - viz_start).total_seconds() / viz_duration.total_seconds()
else:
start_progress, finish_progress = 0.0, 1.0
sf = int(round(settings["start_frame"] + (start_progress * settings["total_frames"])))
ff = int(round(settings["start_frame"] + (finish_progress * settings["total_frames"])))
outputs_added = 0
for output in lookup_optimizer.get_outputs_for_task(task.id()):
print(f" - Adding OUTPUT product {output.id()} in NORMAL mode")
cls._add_optimized_product_frame(
product_frames, output.id(), task, task_start, task_finish,
sf, ff, "output", animation_start, animation_end, viz_start, viz_finish
)
outputs_added += 1
inputs_added = 0
for input_prod in lookup_optimizer.get_inputs_for_task(task.id()):
print(f" - Adding INPUT product {input_prod.id()} in NORMAL mode")
cls._add_optimized_product_frame(
product_frames, input_prod.id(), task, task_start, task_finish,
sf, ff, "input", animation_start, animation_end, viz_start, viz_finish
)
inputs_added += 1
print(f" - Added {outputs_added} outputs, {inputs_added} inputs in NORMAL mode")
print(f"[DEBUG] - Current product_frames count: {len(product_frames)}")
tasks_processed += 1
except Exception as e:
print(f"[WARNING] Error processing task {task.id()}: {e}")
continue
elapsed = time.time() - start_time
print(f"[OPTIMIZED] Optimized frames calculation completed in {elapsed:.3f}s")
print(f"[OPTIMIZED] Tasks processed: {tasks_processed}")
print(f"[OPTIMIZED] Product frames created: {len(product_frames)}")
return product_frames
@classmethod
def _add_optimized_priority_frame(cls, product_frames, product_id, task, relationship, animation_start, animation_end):
"""Add priority mode frame (START only activated) to optimized product frames"""
states = { "active": (animation_start, animation_end) }
frame_data = {
"task": task, "task_id": task.id(),
"type": getattr(task, "PredefinedType", "NOTDEFINED"),
"relationship": relationship,
"start_date": None, "finish_date": None, # Dates ignored in priority mode
"STARTED": animation_start, "COMPLETED": animation_end,
"start_frame": animation_start, "finish_frame": animation_end,
"states": states,
"consider_start_active": True, # KEY FLAG for priority mode
}
product_frames.setdefault(product_id, []).append(frame_data)
print(f" Added priority frame for product {product_id}: consider_start_active={frame_data['consider_start_active']}")
@classmethod
def _add_optimized_product_frame(cls, product_frames, product_id, task, start_date, finish_date,
start_frame, finish_frame, relationship, animation_start, animation_end,
viz_start, viz_finish):
"""Optimized version of add_product_frame_enhanced"""
# Fast state calculation
if finish_date < viz_start:
states = {
"before_start": (animation_start, animation_start - 1),
"active": (animation_start, animation_start - 1),
"after_end": (animation_start, animation_end),
}
elif start_date > viz_finish:
return
else:
s_vis = max(animation_start, int(start_frame))
f_vis = min(animation_end, int(finish_frame))
if f_vis < s_vis:
s_vis = max(animation_start, min(animation_end, s_vis))
f_vis = s_vis
before_end = s_vis - 1
after_start = f_vis + 1
states = {
"before_start": (animation_start, before_end) if before_end >= animation_start else (animation_start, animation_start - 1),
"active": (s_vis, f_vis),
"after_end": (after_start if after_start <= animation_end else animation_end + 1, animation_end),
}
# Create optimized frame data
frame_data = {
"task": task,
"task_id": task.id(),
"type": getattr(task, "PredefinedType", "NOTDEFINED"),
"relationship": relationship,
"start_date": start_date,
"finish_date": finish_date,
"STARTED": int(start_frame),
"COMPLETED": int(finish_frame),
"start_frame": max(animation_start, int(start_frame)),
"finish_frame": min(animation_end, int(finish_frame)),
"states": states,
}
product_frames.setdefault(product_id, []).append(frame_data)
@classmethod
def _process_task_with_ColorTypes(cls, task, settings, product_frames, anim_props, ColorType_cache):
"""Recursively processes a task, adding frames with states.
Maintains compatibility with the 'enhanced' structure."""
for subtask in ifcopenshell.util.sequence.get_nested_tasks(task):
cls._process_task_with_ColorTypes(subtask, settings, product_frames, anim_props, ColorType_cache)
# Dates
start = ifcopenshell.util.sequence.derive_date(task, "ScheduleStart", is_earliest=True)
finish = ifcopenshell.util.sequence.derive_date(task, "ScheduleFinish", is_latest=True)
if not start or not finish:
return
start_frame = int(round(settings["start_frame"] + ((start - settings["start"]).total_seconds() / settings["duration"].total_seconds()) * settings["total_frames"]))
finish_frame = int(round(settings["start_frame"] + ((finish - settings["start"]).total_seconds() / settings["duration"].total_seconds()) * settings["total_frames"]))
# ColorType Cache
task_id = task.id()
if task_id not in ColorType_cache:
ColorType_cache[task_id] = tool.Sequence._get_best_ColorType_for_task(task, anim_props)
def _add(pid, relationship):
product_frames.setdefault(pid, []).append({
"task": task,
"task_id": task.id(),
"type": task.PredefinedType or "NOTDEFINED",
"relationship": relationship,
"start_date": start,
"finish_date": finish,
"STARTED": start_frame,
"COMPLETED": finish_frame,
"start_frame": start_frame,
"finish_frame": finish_frame,
"states": {
"before_start": (settings["start_frame"], max(settings["start_frame"], start_frame - 1)),
"active": (start_frame, finish_frame),
"after_end": (min(finish_frame + 1, int(settings["start_frame"] + settings["total_frames"])), int(settings["start_frame"] + settings["total_frames"])),
},
})
# Add outputs and inputs
for output in ifcopenshell.util.sequence.get_task_outputs(task):
_add(output.id(), "output")
for input_product in tool.Sequence.get_task_inputs(task):
_add(input_product.id(), "input")
@@ -0,0 +1,253 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import bpy
from datetime import datetime
import ifcopenshell
import ifcopenshell.util.date
import bonsai.tool as tool
class AnimationSetupSequence:
"""Mixin class for the 4D animation setup and general utilities."""
@classmethod
def set_object_shading(cls):
area = tool.Blender.get_view3d_area()
if area:
# Use area.spaces.active for stability in newer Blender versions
space = area.spaces.active
if space and space.type == 'VIEW_3D':
space.shading.color_type = "OBJECT"
@classmethod
def get_animation_settings(cls):
"""
CORRECTION: Ensure that it uses the configured visualization dates,
not dates derived from tasks.
"""
def calculate_total_frames(fps):
if props.speed_types == "FRAME_SPEED":
return calculate_using_frames(
start,
finish,
props.speed_animation_frames,
ifcopenshell.util.date.parse_duration(props.speed_real_duration),
)
elif props.speed_types == "DURATION_SPEED":
animation_duration = ifcopenshell.util.date.parse_duration(props.speed_animation_duration)
real_duration = ifcopenshell.util.date.parse_duration(props.speed_real_duration)
return calculate_using_duration(
start,
finish,
fps,
animation_duration,
real_duration,
)
elif props.speed_types == "MULTIPLIER_SPEED":
return calculate_using_multiplier(
start,
finish,
1,
props.speed_multiplier,
)
def calculate_using_multiplier(start, finish, fps, multiplier):
animation_time = (finish - start) / multiplier
return animation_time.total_seconds() * fps
def calculate_using_duration(start, finish, fps, animation_duration, real_duration):
return calculate_using_multiplier(start, finish, fps, real_duration / animation_duration)
def calculate_using_frames(start, finish, animation_frames, real_duration):
return ((finish - start) / real_duration) * animation_frames
props = tool.Sequence.get_work_schedule_props()
# Get visualization dates: first UI, if missing, infer from active schedule
viz_start_prop = getattr(props, "visualisation_start", None)
viz_finish_prop = getattr(props, "visualisation_finish", None)
inferred_start = None
inferred_finish = None
if not (viz_start_prop and viz_finish_prop):
try:
ws = cls.get_active_work_schedule()
if ws:
inferred_start, inferred_finish = cls.guess_date_range(ws)
except Exception:
inferred_start, inferred_finish = (None, None)
def _to_dt(v):
try:
from datetime import datetime as _dt, date as _d
if isinstance(v, _dt):
return v.replace(microsecond=0)
if isinstance(v, _d):
return _dt(v.year, v.month, v.day)
s = str(v)
try:
if "T" in s or " " in s:
s2 = s.replace(" ", "T")
return _dt.fromisoformat(s2[:19])
if len(s) >= 10 and s[4] == "-" and s[7] == "-":
return _dt.fromisoformat(s[:10])
except Exception:
pass
from dateutil import parser as _p
return _p.parse(s, yearfirst=True, dayfirst=False, fuzzy=True)
except Exception:
try:
from dateutil import parser as _p
return _p.parse(str(v), yearfirst=True, dayfirst=True, fuzzy=True)
except Exception:
return None
if viz_start_prop and viz_finish_prop:
start = cls.get_start_date()
finish = cls.get_finish_date()
else:
start = _to_dt(inferred_start)
finish = _to_dt(inferred_finish)
try:
if start and finish:
props.visualisation_start = ifcopenshell.util.date.canonicalise_time(start)
props.visualisation_finish = ifcopenshell.util.date.canonicalise_time(finish)
except Exception:
pass
if not start or not finish:
print("[ERROR] Could not determine visualization dates (neither UI nor inferred)")
return None
try:
start = start.replace(microsecond=0)
finish = finish.replace(microsecond=0)
except Exception:
pass
if finish <= start:
try:
from datetime import timedelta as _td
if finish == start:
finish = start + _td(days=1)
else:
print(f"[ERROR] Error: Finish date ({finish}) must be after start date ({start})")
return None
except Exception:
print(f"[ERROR] Error adjusting date range: start={start}, finish={finish}")
return None
duration = finish - start
# Use frame_start from the scene if it exists; default 1
try:
start_frame = int(getattr(bpy.context.scene, 'frame_start', 1) or 1)
except Exception:
start_frame = 1
# Calculate total frames based on speed settings
try:
fps = int(getattr(bpy.context.scene.render, 'fps', 24) or 24)
except Exception:
fps = 24
total_frames = int(round(calculate_total_frames(fps)))
print(f"[INFO] Animation Settings:")
try:
print(f" Start Date: {start.strftime('%Y-%m-%d')}")
print(f" Finish Date: {finish.strftime('%Y-%m-%d')}")
except Exception:
print(f" Start Date: {start}")
print(f" Finish Date: {finish}")
print(f" Duration: {duration.days} days")
print(f" Start Frame: {start_frame}")
print(f" Total Frames: {total_frames}")
return {
"start": start,
"finish": finish,
"duration": duration,
"start_frame": start_frame,
"total_frames": total_frames,
"schedule_start": None,
"schedule_finish": None,
}
@classmethod
def _get_best_ColorType_for_task(cls, task, anim_props):
"""Determines the most appropriate profile for a task considering the group stack and task choice."""
try:
# Determine the active group (first enabled group in the stack) or DEFAULT
agn = None
for it in getattr(anim_props, 'animation_group_stack', []):
if getattr(it, 'enabled', False) and getattr(it, 'group', None):
agn = it.group
break
if not agn:
agn = 'DEFAULT'
ColorType = tool.Sequence.get_assigned_ColorType_for_task(task, anim_props, agn)
if ColorType:
return ColorType
except Exception:
pass
predefined_type = task.PredefinedType or "NOTDEFINED"
# Try in DEFAULT
try:
prof = cls.load_ColorType_from_group("DEFAULT", predefined_type)
if prof:
return prof
except Exception:
pass
# Fallback to NotDefined
try:
prof = cls.load_ColorType_from_group("DEFAULT", "NOTDEFINED")
if prof:
return prof
except Exception:
pass
# Final fallback: return DEFAULT profile with all flags set to False
from types import SimpleNamespace
return SimpleNamespace(
name="DEFAULT",
consider_start=False,
consider_active=True,
consider_end=True,
)
@classmethod
def debug_ColorType_application(cls, obj, ColorType, frame_data):
"""Debug helper to verify profile application"""
print("ColorType Application Check:")
print(f" Object: {obj.name}")
print(f" ColorType: {getattr(ColorType, 'name', 'Unknown')}")
print(f" consider_start: {getattr(ColorType, 'consider_start', False)}")
print(f" consider_active: {getattr(ColorType, 'consider_active', True)}")
print(f" consider_end: {getattr(ColorType, 'consider_end', True)}")
print(f" Frame states: {frame_data.get('states', {})}")
print(f" Relationship: {frame_data.get('relationship', 'unknown')}")
@classmethod
def _task_has_consider_start_ColorType(cls, task):
"""Helper to check if a task's resolved ColorType has consider_start=True."""
try:
# Re-use existing logic to find the best ColorType for the task
anim_props = tool.Sequence.get_animation_props()
ColorType = tool.Sequence._get_best_ColorType_for_task(task, anim_props)
return getattr(ColorType, 'consider_start', False)
except Exception as e:
print(f"[WARNING] Error in _task_has_consider_start_ColorType for task {getattr(task, 'Name', 'N/A')}: {e}")
return False
@@ -0,0 +1,188 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import bpy
from datetime import datetime
from typing import Any, Union
from dateutil import parser
import ifcopenshell
import bonsai.bim.helper
import bonsai.tool as tool
from .props_sequence import PropsSequence
class CalendarSequence:
"""Mixin class for managing IfcWorkCalendar, IfcWorkTime, and IfcRecurrencePattern."""
@classmethod
def get_active_work_time(cls) -> ifcopenshell.entity_instance:
props = tool.Sequence.get_work_calendar_props()
return tool.Ifc.get().by_id(props.active_work_time_id)
@classmethod
def enable_editing_work_calendar_times(cls, work_calendar: ifcopenshell.entity_instance) -> None:
props = tool.Sequence.get_work_calendar_props()
props.active_work_calendar_id = work_calendar.id()
props.editing_type = "WORKTIMES"
@classmethod
def load_work_calendar_attributes(cls, work_calendar: ifcopenshell.entity_instance) -> dict[str, Any]:
props = tool.Sequence.get_work_calendar_props()
props.work_calendar_attributes.clear()
return bonsai.bim.helper.import_attributes(work_calendar, props.work_calendar_attributes)
@classmethod
def enable_editing_work_calendar(cls, work_calendar: ifcopenshell.entity_instance) -> None:
props = tool.Sequence.get_work_calendar_props()
props.active_work_calendar_id = work_calendar.id()
props.editing_type = "ATTRIBUTES"
@classmethod
def disable_editing_work_calendar(cls) -> None:
props = tool.Sequence.get_work_calendar_props()
props.active_work_calendar_id = 0
@classmethod
def get_work_calendar_attributes(cls) -> dict[str, Any]:
props = tool.Sequence.get_work_calendar_props()
return bonsai.bim.helper.export_attributes(props.work_calendar_attributes)
@classmethod
def load_work_time_attributes(cls, work_time: ifcopenshell.entity_instance) -> None:
props = tool.Sequence.get_work_calendar_props()
props.work_time_attributes.clear()
bonsai.bim.helper.import_attributes(work_time, props.work_time_attributes)
@classmethod
def enable_editing_work_time(cls, work_time: ifcopenshell.entity_instance) -> None:
def initialise_recurrence_components(props):
if len(props.day_components) == 0:
for i in range(0, 31):
new = props.day_components.add()
new.name = str(i + 1)
if len(props.weekday_components) == 0:
for d in ["M", "T", "W", "T", "F", "S", "S"]:
new = props.weekday_components.add()
new.name = d
if len(props.month_components) == 0:
for m in ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]:
new = props.month_components.add()
new.name = m
def load_recurrence_pattern_data(work_time, props):
props.position = 0
props.interval = 0
props.occurrences = 0
props.start_time = ""
props.end_time = ""
for component in props.day_components:
component.is_specified = False
for component in props.weekday_components:
component.is_specified = False
for component in props.month_components:
component.is_specified = False
if not work_time.RecurrencePattern:
return
recurrence_pattern = work_time.RecurrencePattern
for attribute in ["Position", "Interval", "Occurrences"]:
if getattr(recurrence_pattern, attribute):
setattr(props, attribute.lower(), getattr(recurrence_pattern, attribute))
for component in recurrence_pattern.DayComponent or []:
props.day_components[component - 1].is_specified = True
for component in recurrence_pattern.WeekdayComponent or []:
props.weekday_components[component - 1].is_specified = True
for component in recurrence_pattern.MonthComponent or []:
props.month_components[component - 1].is_specified = True
props = tool.Sequence.get_work_calendar_props()
initialise_recurrence_components(props)
load_recurrence_pattern_data(work_time, props)
props.active_work_time_id = work_time.id()
props.editing_type = "WORKTIMES"
@classmethod
def get_work_time_attributes(cls) -> dict[str, Any]:
import bonsai.bim.module.sequence.utils.helper_utils as helper
def callback(attributes: dict[str, Any], prop: Attribute) -> bool:
if "Start" in prop.name or "Finish" in prop.name:
if prop.is_null:
attributes[prop.name] = None
return True
attributes[prop.name] = helper.parse_datetime(prop.string_value)
return True
return False
props = tool.Sequence.get_work_calendar_props()
return bonsai.bim.helper.export_attributes(props.work_time_attributes, callback)
@classmethod
def get_recurrence_pattern_attributes(cls, recurrence_pattern):
props = tool.Sequence.get_work_calendar_props()
attributes = {
"Interval": props.interval if props.interval > 0 else None,
"Occurrences": props.occurrences if props.occurrences > 0 else None,
}
applicable_data = {
"DAILY": ["Interval", "Occurrences"],
"WEEKLY": ["WeekdayComponent", "Interval", "Occurrences"],
"MONTHLY_BY_DAY_OF_MONTH": ["DayComponent", "Interval", "Occurrences"],
"MONTHLY_BY_POSITION": ["WeekdayComponent", "Position", "Interval", "Occurrences"],
"BY_DAY_COUNT": ["Interval", "Occurrences"],
"BY_WEEKDAY_COUNT": ["WeekdayComponent", "Interval", "Occurrences"],
"YEARLY_BY_DAY_OF_MONTH": ["DayComponent", "MonthComponent", "Interval", "Occurrences"],
"YEARLY_BY_POSITION": ["WeekdayComponent", "MonthComponent", "Position", "Interval", "Occurrences"],
}
if "Position" in applicable_data[recurrence_pattern.RecurrenceType]:
attributes["Position"] = props.position if props.position != 0 else None
if "DayComponent" in applicable_data[recurrence_pattern.RecurrenceType]:
attributes["DayComponent"] = [i + 1 for i, c in enumerate(props.day_components) if c.is_specified]
if "WeekdayComponent" in applicable_data[recurrence_pattern.RecurrenceType]:
attributes["WeekdayComponent"] = [i + 1 for i, c in enumerate(props.weekday_components) if c.is_specified]
if "MonthComponent" in applicable_data[recurrence_pattern.RecurrenceType]:
attributes["MonthComponent"] = [i + 1 for i, c in enumerate(props.month_components) if c.is_specified]
return attributes
@classmethod
def disable_editing_work_time(cls) -> None:
props = tool.Sequence.get_work_calendar_props()
props.active_work_time_id = 0
@classmethod
def get_recurrence_pattern_times(cls) -> Union[tuple[datetime, datetime], None]:
props = tool.Sequence.get_work_calendar_props()
try:
start_time = parser.parse(props.start_time)
end_time = parser.parse(props.end_time)
return start_time, end_time
except parser.ParserError:
return None # improve UI / refactor to add user hints
@classmethod
def reset_time_period(cls) -> None:
props = tool.Sequence.get_work_calendar_props()
props.start_time = ""
props.end_time = ""
@classmethod
def disable_editing_task_time(cls) -> None:
props = tool.Sequence.get_work_schedule_props()
props.active_task_id = 0
props.active_task_time_id = 0
@@ -0,0 +1,978 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import bpy
import math
import mathutils
from mathutils import Vector
import traceback
from .props_sequence import PropsSequence
import bonsai.tool as tool
class CameraSequence:
"""Mixin class for managing 4D animation and snapshot cameras."""
@classmethod
def is_bonsai_camera(cls, obj):
"""Checks if an object is a camera managed by Bonsai 4D/Snapshot tools."""
if not obj or obj.type != 'CAMERA':
return False
# Most reliable way is to check the custom property
if obj.get('camera_context') in ['animation', 'snapshot']:
return True
# Fallback to name conventions for compatibility
if '4D_Animation_Camera' in obj.name or 'Snapshot_Camera' in obj.name:
return True
return False
@classmethod
def is_bonsai_animation_camera(cls, obj):
"""Checks if an object is a camera specific for Animation Settings."""
if not obj or obj.type != 'CAMERA':
return False
# DURING ANIMATION: Only allow STATIC cameras
try:
anim_props = cls.get_animation_props()
if getattr(anim_props, 'is_animation_created', False):
# During active animation, only show static cameras
if (obj.get('camera_type') == 'STATIC' or
'4D_Camera_Static' in obj.name or
obj.get('orbit_mode') == 'NONE'):
return True
else:
# Don't show 360°/ping-pong cameras during animation
return False
except Exception:
pass
# Normal behavior
# Primary identification is through custom property
if obj.get('camera_context') == 'animation':
return True
# Fallback to name convention (ensuring it's not a snapshot camera)
if '4D_Animation_Camera' in obj.name and 'Snapshot' not in obj.name:
return True
# Also include static cameras created from animation align view
if (obj.get('camera_type') == 'STATIC' and
obj.get('created_from') == 'animation_align_view'):
return True
return False
@classmethod
def is_bonsai_snapshot_camera(cls, obj):
"""Checks if an object is a camera specific for Snapshot Settings."""
if not obj or obj.type != 'CAMERA':
return False
# Primary identification is through custom property
if obj.get('camera_context') == 'snapshot':
return True
# Fallback to name convention
if 'Snapshot_Camera' in obj.name:
return True
return False
@classmethod
def _get_active_schedule_bbox(cls):
"""Return (center (Vector), dims (Vector), obj_list) for active WorkSchedule products.
Fallbacks to visible mesh objects if empty."""
import bpy, mathutils
ws = cls.get_active_work_schedule()
objs = []
if ws:
try:
products = cls.get_work_schedule_products(ws) # IFC entities
want_ids = {p.id() for p in products if hasattr(p, "id")}
for obj in bpy.data.objects:
try:
if not hasattr(obj, "type") or obj.type not in {"MESH", "CURVE", "SURFACE", "META", "FONT"}:
continue
if (ifc_id := tool.Blender.get_ifc_definition_id(obj)) and ifc_id in want_ids:
objs.append(obj)
except Exception:
continue
except Exception:
pass
if not objs:
# Fallback: all visible mesh objs
objs = [o for o in bpy.data.objects if getattr(o, "type", "") == "MESH" and not o.hide_get()]
if not objs:
c = mathutils.Vector((0.0, 0.0, 0.0))
d = mathutils.Vector((10.0, 10.0, 5.0))
return c, d, []
mins = mathutils.Vector(( 1e18, 1e18, 1e18))
maxs = mathutils.Vector((-1e18, -1e18, -1e18))
for o in objs:
try:
for corner in o.bound_box:
wc = o.matrix_world @ mathutils.Vector(corner)
mins.x = min(mins.x, wc.x); mins.y = min(mins.y, wc.y); mins.z = min(mins.z, wc.z)
maxs.x = max(maxs.x, wc.x); maxs.y = max(maxs.y, wc.y); maxs.z = max(maxs.z, wc.z)
except Exception:
continue
center = (mins + maxs) * 0.5
dims = (maxs - mins)
return center, dims, objs
@classmethod
def _get_or_create_target(cls, center, name="4D_OrbitTarget", should_hide=False):
name = name or "4D_OrbitTarget"
obj = bpy.data.objects.get(name)
if obj is None:
obj = bpy.data.objects.new(name, None)
obj.empty_display_type = 'PLAIN_AXES'
try:
bpy.context.collection.objects.link(obj)
except Exception:
bpy.context.scene.collection.objects.link(obj)
obj.location = center
obj.hide_viewport = should_hide
obj.hide_render = should_hide
return obj
@classmethod
def add_animation_camera(cls):
"""Create a camera using Animation Settings (Camera/Orbit) and optionally animate it."""
import bpy, math, mathutils
from mathutils import Vector
import traceback
anim = cls.get_animation_props()
camera_props = anim.camera_orbit
ws_props = cls.get_work_schedule_props()
current_orbit_mode = getattr(camera_props, 'orbit_mode', 'NONE')
existing_camera = bpy.context.scene.camera
center, dims, _ = cls._get_active_schedule_bbox()
cam_data = bpy.data.cameras.new("4D_Animation_Camera")
cam_data.lens = camera_props.camera_focal_mm
cam_data.clip_start = max(0.0001, camera_props.camera_clip_start)
clip_end = camera_props.camera_clip_end
auto_scale = max(dims.x, dims.y, dims.z) * 5.0
cam_data.clip_end = max(clip_end, auto_scale)
cam_obj = bpy.data.objects.new("4D_Animation_Camera", cam_data)
cam_obj['is_4d_camera'] = True
cam_obj['is_animation_camera'] = True
cam_obj['camera_context'] = 'animation'
try:
bpy.context.collection.objects.link(cam_obj)
except Exception:
bpy.context.scene.collection.objects.link(cam_obj)
target_name = f"4D_OrbitTarget_for_{cam_obj.name}"
if camera_props.look_at_mode == "OBJECT" and camera_props.look_at_object:
target = camera_props.look_at_object
else:
# Check if animation cameras are globally hidden for target visibility
cameras_globally_hidden = getattr(camera_props, 'hide_all_animation_cameras', False)
target = cls._get_or_create_target(center, target_name, should_hide=cameras_globally_hidden)
tcon = cam_obj.constraints.new(type='TRACK_TO')
tcon.target = target
tcon.track_axis = 'TRACK_NEGATIVE_Z'
tcon.up_axis = 'UP_Y'
if camera_props.orbit_radius_mode == "AUTO":
base = max(dims.x, dims.y)
if base > 0:
r = base * 1.5 # More generous factor
else:
r = 15.0 # Larger fallback
else:
r = max(0.01, camera_props.orbit_radius)
z = center.z + camera_props.orbit_height
sign = -1.0 if camera_props.orbit_direction == "CW" else 1.0
angle0 = 0.0 # Fixed at 0 degrees (Start Angle only controls offset_factor)
# Initial placement (fixed at 0 degrees, Start Angle only controls offset_factor)
initial_x = center.x + r # Start at angle 0 (east position)
initial_y = center.y
cam_obj.location = Vector((initial_x, initial_y, z))
# Orbit animation
mode = camera_props.orbit_mode
if mode == "NONE":
# For STATIC mode, generate unique name and markers
base_name = "4D_Camera_Static"
counter = 1
unique_name = base_name
while bpy.data.objects.get(unique_name):
unique_name = f"{base_name}_{counter:02d}"
counter += 1
cam_obj.name = unique_name
cam_obj.data.name = unique_name
cam_obj['orbit_mode'] = 'NONE'
cam_obj['camera_type'] = 'STATIC'
bpy.context.scene.camera = cam_obj
return cam_obj
# Determine timeline
try:
settings = cls.get_animation_settings()
if settings:
total_frames_4d = int(settings["total_frames"])
start_frame = int(settings["start_frame"])
else:
raise Exception("No animation settings")
except Exception as e:
total_frames_4d = 250
start_frame = 1
# Timeline calculation
if camera_props.orbit_use_4d_duration:
end_frame = start_frame + max(1, total_frames_4d - 1)
else:
end_frame = start_frame + int(max(1, camera_props.orbit_duration_frames))
dur = max(1, end_frame - start_frame)
# Orbit animation implementation
# Keep original method but with fluidity improvements
if camera_props.orbit_path_method == "FOLLOW_PATH":
cls._create_follow_path_orbit(cam_obj, center, r, z, angle0, start_frame, end_frame, sign, mode)
else:
cls._create_keyframe_orbit(cam_obj, center, r, z, angle0, start_frame, end_frame, sign, mode)
bpy.context.scene.camera = cam_obj
return cam_obj
@classmethod
def add_snapshot_camera(cls):
"""Create a camera specifically for Snapshot Settings."""
import bpy, math, mathutils
from mathutils import Vector
# Props - use animation structure for basic configuration
anim = cls.get_animation_props()
camera_props = anim.camera_orbit
# Get scene bounding box
center, dims, _ = cls._get_active_schedule_bbox()
# Camera data
cam_data = bpy.data.cameras.new("Snapshot_Camera")
cam_data.lens = camera_props.camera_focal_mm
cam_data.clip_start = max(0.0001, camera_props.camera_clip_start)
# Scale clip_end with scene size
clip_end = camera_props.camera_clip_end
auto_scale = max(dims.length * 2.0, 100.0)
cam_data.clip_end = max(clip_end, auto_scale)
cam_obj = bpy.data.objects.new("Snapshot_Camera", cam_data)
cam_obj['is_4d_camera'] = True
cam_obj['is_snapshot_camera'] = True
cam_obj['camera_context'] = 'snapshot'
try:
bpy.context.collection.objects.link(cam_obj)
except Exception:
bpy.context.scene.collection.objects.link(cam_obj)
# Position camera at a reasonable distance from scene center
radius = max(dims.length * 1.5, 20.0)
height = center.z + dims.z * 0.5
cam_obj.location = Vector((center.x + radius, center.y + radius, height))
# Create and track target
target = cls._get_or_create_target(center, "Snapshot_Target")
tcon = cam_obj.constraints.new(type='TRACK_TO')
tcon.target = target
tcon.track_axis = 'TRACK_NEGATIVE_Z'
tcon.up_axis = 'UP_Y'
bpy.context.scene.camera = cam_obj
# --- ALWAYS CREATE PARENT EMPTY (needed for 3D texts) ---
try:
# Create the parent Empty if it doesn't exist
parent_name = "Schedule_Display_Parent"
parent_empty = bpy.data.objects.get(parent_name)
if not parent_empty:
parent_empty = bpy.data.objects.new(parent_name, None)
bpy.context.scene.collection.objects.link(parent_empty)
parent_empty.empty_display_type = 'PLAIN_AXES'
parent_empty.empty_display_size = 2
else:
pass
# Now create the 3D Legend HUD if enabled
anim_props = cls.get_animation_props()
camera_props = anim_props.camera_orbit
legend_hud_enabled = getattr(camera_props, "enable_3d_legend_hud", False)
show_3d_texts = getattr(camera_props, "show_3d_schedule_texts", False)
legend_hud_exists = any(obj.get("is_3d_legend_hud", False) for obj in bpy.data.objects)
if not legend_hud_exists and legend_hud_enabled:
if show_3d_texts:
bpy.ops.bim.setup_3d_legend_hud()
else:
bpy.ops.bim.setup_3d_legend_hud()
# Hide immediately if the option is disabled
for obj in bpy.data.objects:
if obj.get("is_3d_legend_hud", False):
obj.hide_viewport = True
obj.hide_render = True
elif legend_hud_exists:
pass
except Exception as e:
import traceback
traceback.print_exc()
# --- CREATE 3D TEXTS LATER (so they can be parented to the Empty) ---
# 3D texts are created when adding the camera, not when creating the snapshot
try:
# Get basic configurations for texts
ws_props = cls.get_work_schedule_props()
active_schedule_id = getattr(ws_props, "active_work_schedule_id", None)
if active_schedule_id:
import bonsai.tool as tool
work_schedule = tool.Ifc.get().by_id(active_schedule_id)
schedule_name = work_schedule.Name if work_schedule and hasattr(work_schedule, 'Name') else 'No Schedule'
# --- CREATE STATIC TEXTS FOR THE SNAPSHOT ---
try:
from datetime import datetime
snapshot_date = datetime.now() # Default date
# Try to get the actual snapshot date
snapshot_date_str = getattr(ws_props, "visualisation_start", None)
if snapshot_date_str and snapshot_date_str != "-":
try:
snapshot_date = cls.parse_isodate_datetime(snapshot_date_str)
except Exception:
pass
snapshot_settings = {
"start": snapshot_date,
"finish": snapshot_date,
"start_frame": bpy.context.scene.frame_current,
"total_frames": 1,
}
# Create specific static texts for snapshot
cls.create_text_objects_static(snapshot_settings)
except Exception as static_error:
# FALLBACK: Create basic texts if the above fails
cls._create_basic_snapshot_texts(schedule_name)
# --- APPLY VISIBILITY ACCORDING TO THE UI CHECKBOX ---
anim_props = cls.get_animation_props()
camera_props = anim_props.camera_orbit
should_hide = not getattr(camera_props, "show_3d_schedule_texts", False)
# Update the collection after creating it
texts_collection = bpy.data.collections.get("Schedule_Display_Texts")
if texts_collection:
texts_collection.hide_viewport = should_hide
texts_collection.hide_render = should_hide
# Auto-arrange texts for default positioning
try:
bpy.ops.bim.arrange_schedule_texts()
except Exception as e:
pass
else:
pass
except Exception as e:
import traceback
traceback.print_exc()
return cam_obj
@classmethod
def align_snapshot_camera_to_view(cls):
"""Aligns the active snapshot camera to the current 3D view."""
import bpy
# 1. Find the active 3D viewport
area = next((a for a in bpy.context.screen.areas if a.type == 'VIEW_3D'), None)
if not area:
raise Exception("No 3D viewport found.")
space = next((s for s in area.spaces if s.type == 'VIEW_3D'), None)
if not space:
raise Exception("No 3D space data found.")
# 2. Get the active camera from the scene
cam_obj = bpy.context.scene.camera
if not cam_obj:
raise Exception("No active camera in the scene.")
# 3. (Optional) Verify it's a snapshot camera
if not cls.is_bonsai_snapshot_camera(cam_obj):
pass
# 4. Get the view matrix and apply it to the camera
# The view matrix gives us the transformation from the view's perspective.
# We need its inverse to position the camera correctly in the world.
region_3d = space.region_3d
if region_3d:
cam_obj.matrix_world = region_3d.view_matrix.inverted()
else:
raise Exception("Could not get 3D view region data.")
@classmethod
def align_animation_camera_to_view(cls):
"""
Aligns the active animation camera to the 3D view and converts it to static.
"""
import bpy
area = next((a for a in bpy.context.screen.areas if a.type == 'VIEW_3D'), None)
space = next((s for s in area.spaces if s.type == 'VIEW_3D'), None) if area else None
cam_obj = bpy.context.scene.camera
if not all([area, space, cam_obj]):
raise Exception("Camera or 3D view not found.")
# Clear animation and camera constraints to stop the orbit
if cam_obj.animation_data:
cam_obj.animation_data_clear()
for c in list(cam_obj.constraints):
cam_obj.constraints.remove(c)
# Move the camera to the view position
region_3d = space.region_3d
if region_3d:
cam_obj.matrix_world = region_3d.view_matrix.inverted()
else:
raise Exception("Could not get 3D region data.")
# (Key Step) Update the UI so that the orbit mode is set to "Static"
try:
anim_props = cls.get_animation_props()
camera_props = anim_props.camera_orbit
camera_props.orbit_mode = 'NONE' # 'NONE' se muestra como "Static" en la UI
except Exception as e:
pass
@classmethod
def update_animation_camera(cls, cam_obj):
"""
Updates an existing 4D camera. Cleans its old data and applies
the current UI configuration.
"""
import bpy, math, mathutils
from mathutils import Vector
# 1. Clear previous camera configuration - but preserve TRACK_TO for static cameras
if cam_obj.animation_data:
cam_obj.animation_data_clear()
# Check if this is a static camera before clearing constraints
is_static_camera = cam_obj.get('camera_type') == 'STATIC' or cam_obj.get('orbit_mode') == 'NONE'
# PRESERVE offset_factor from Follow Path constraint (set by Start Angle callback)
preserved_offset_factor = None
for c in cam_obj.constraints:
if c.type == 'FOLLOW_PATH':
preserved_offset_factor = c.offset_factor
break
for c in list(cam_obj.constraints):
# For static cameras, preserve TRACK_TO constraint
if is_static_camera and c.type == 'TRACK_TO':
continue
cam_obj.constraints.remove(c)
# Clear the orbit path and target if they exist (unique names per camera) - but preserve target for static cameras
path_name = f"4D_OrbitPath_for_{cam_obj.name}"
path_obj = bpy.data.objects.get(path_name)
if path_obj:
bpy.data.objects.remove(path_obj, do_unlink=True)
# Try both target naming conventions
target_names = [f"4D_Target_for_{cam_obj.name}", f"4D_OrbitTarget_for_{cam_obj.name}"]
for target_name in target_names:
tgt_obj = bpy.data.objects.get(target_name)
if tgt_obj:
# For static cameras, preserve the target object
if is_static_camera:
pass
else:
bpy.data.objects.remove(tgt_obj, do_unlink=True)
# 1.5. Save panel values to the camera object BEFORE updating
anim = cls.get_animation_props()
camera_props = anim.camera_orbit
# Save all panel values to the camera
cam_obj['orbit_mode'] = camera_props.orbit_mode
cam_obj['orbit_radius'] = camera_props.orbit_radius
cam_obj['orbit_height'] = camera_props.orbit_height
cam_obj['orbit_start_angle_deg'] = camera_props.orbit_start_angle_deg
cam_obj['orbit_direction'] = camera_props.orbit_direction
cam_obj['orbit_radius_mode'] = camera_props.orbit_radius_mode
cam_obj['orbit_path_shape'] = camera_props.orbit_path_shape
cam_obj['orbit_path_method'] = camera_props.orbit_path_method
cam_obj['interpolation_mode'] = camera_props.interpolation_mode
# 2. Re-apply all settings, using the logic of add_animation_camera
camera_props = anim.camera_orbit
# Recalculate target and dimensions
center, dims, _ = cls._get_active_schedule_bbox()
target_name = f"4D_OrbitTarget_for_{cam_obj.name}"
if camera_props.look_at_mode == "OBJECT" and camera_props.look_at_object:
target = camera_props.look_at_object
else:
target = cls._get_or_create_target(center, target_name)
# Reconfigure camera data (lens, clipping)
cam_data = cam_obj.data
cam_data.lens = camera_props.camera_focal_mm
cam_data.clip_start = camera_props.camera_clip_start
cam_data.clip_end = max(camera_props.camera_clip_end, max(dims.x, dims.y, dims.z) * 5.0)
# Check if we're in static mode BEFORE repositioning the camera
mode = camera_props.orbit_mode
if mode == "NONE":
# In static mode, do NOT reposition the camera or add tracking constraints
# Keep the manually aligned position intact
pass
else:
# Recalculate position only for non-static modes
if camera_props.orbit_radius_mode == "AUTO":
r = max(dims.x, dims.y) * 1.5 if max(dims.x, dims.y) > 0 else 15.0
else:
r = max(0.01, camera_props.orbit_radius)
z = center.z + camera_props.orbit_height
angle0 = 0.0 # Fixed at 0 degrees (Start Angle only controls offset_factor)
# Fixed position at 0 degrees (Start Angle only controls offset_factor)
cam_obj.location = Vector((center.x + r, center.y, z))
# Re-create tracking constraint only for non-static modes
tcon = cam_obj.constraints.new(type='TRACK_TO')
tcon.target = target
tcon.track_axis = 'TRACK_NEGATIVE_Z'
tcon.up_axis = 'UP_Y'
# Re-create orbit animation if configured
if mode != "NONE":
settings = cls.get_animation_settings()
start_frame = settings["start_frame"]
if camera_props.orbit_use_4d_duration:
end_frame = start_frame + settings["total_frames"] -1
else:
end_frame = start_frame + int(camera_props.orbit_duration_frames)
sign = -1.0 if camera_props.orbit_direction == "CW" else 1.0
if camera_props.orbit_path_method == "FOLLOW_PATH":
# Pass preserved offset_factor to maintain Start Angle setting
cls._create_follow_path_orbit(cam_obj, center, r, z, angle0, start_frame, end_frame, sign, mode, preserved_offset_factor)
else:
cls._create_keyframe_orbit(cam_obj, center, r, z, angle0, start_frame, end_frame, sign, mode)
bpy.context.scene.camera = cam_obj
return cam_obj
@classmethod
def clear_camera_animation(cls, cam_obj):
"""
Robustly cleans the animation, constraints, and associated objects (path, target) of a camera.
"""
import bpy
if not cam_obj:
return
try:
# 1. Clear animation data (keyframes)
if getattr(cam_obj, "animation_data", None):
cam_obj.animation_data_clear()
# 2. Clear constraints - but preserve TRACK_TO for static cameras
is_static_camera = cam_obj.get('camera_type') == 'STATIC' or cam_obj.get('orbit_mode') == 'NONE'
for c in list(getattr(cam_obj, "constraints", [])):
try:
# For static cameras, preserve TRACK_TO constraint
if is_static_camera and c.type == 'TRACK_TO':
continue
cam_obj.constraints.remove(c)
except Exception:
pass
# 3. Clear auxiliary objects (path and target) - but preserve target for static cameras
path_name = f"4D_OrbitPath_for_{cam_obj.name}"
path_obj = bpy.data.objects.get(path_name)
if path_obj:
bpy.data.objects.remove(path_obj, do_unlink=True)
target_name = f"4D_OrbitTarget_for_{cam_obj.name}"
tgt_obj = bpy.data.objects.get(target_name)
if tgt_obj:
# For static cameras, preserve the target object
if is_static_camera:
pass
else:
bpy.data.objects.remove(tgt_obj, do_unlink=True)
except Exception as e:
pass
@classmethod
def _create_follow_path_orbit(cls, cam_obj, center, radius, z, angle0, start_frame, end_frame, sign, mode, preserved_offset_factor=None):
import bpy, math, mathutils
anim = cls.get_animation_props()
camera_props = anim.camera_orbit
path_object = None
if camera_props.orbit_path_shape == 'CUSTOM' and camera_props.custom_orbit_path:
path_object = camera_props.custom_orbit_path
# Check if animation cameras are globally hidden
cameras_globally_hidden = getattr(camera_props, 'hide_all_animation_cameras', False)
# Hide path if either the orbit path setting OR the global camera visibility is disabled
should_hide_path = camera_props.hide_orbit_path or cameras_globally_hidden
path_object.hide_viewport = should_hide_path
path_object.hide_render = should_hide_path
else:
path_name = f"4D_OrbitPath_for_{cam_obj.name}"
# Check if path already exists (to preserve Follow Path constraint connection)
existing_path = bpy.data.objects.get(path_name)
if existing_path and existing_path.data:
path_object = existing_path
curve = path_object.data
# Clear existing splines to rebuild
curve.splines.clear()
else:
# Create new path only if it doesn't exist
curve = bpy.data.curves.new(path_name, type='CURVE')
curve.dimensions = '3D'
curve.resolution_u = 64
path_object = bpy.data.objects.new(path_name, curve)
try:
bpy.context.collection.objects.link(path_object)
except Exception:
bpy.context.scene.collection.objects.link(path_object)
# Check if animation cameras are globally hidden
cameras_globally_hidden = getattr(camera_props, 'hide_all_animation_cameras', False)
# Hide path if either the orbit path setting OR the global camera visibility is disabled
should_hide_path = camera_props.hide_orbit_path or cameras_globally_hidden
path_object.hide_viewport = should_hide_path
path_object.hide_render = should_hide_path
# Create a mathematically perfect circle with only 4 Bezier points
spline = curve.splines.new('BEZIER')
spline.bezier_points.add(3) # 4 points total (0,1,2,3)
# Calculate perfect circle control points (4-point Bezier circle)
kappa = 4 * (math.sqrt(2) - 1) / 3 # Magic number for perfect Bezier circle
points = [
(radius, 0), # Right
(0, radius), # Top
(-radius, 0), # Left
(0, -radius) # Bottom
]
for i, (x, y) in enumerate(points):
bp = spline.bezier_points[i]
bp.co = mathutils.Vector((center.x + x, center.y + y, z))
bp.handle_left_type = 'ALIGNED'
bp.handle_right_type = 'ALIGNED'
# Perfect circle handles using kappa constant
if i == 0: # Right point
bp.handle_left = mathutils.Vector((center.x + x, center.y + y - radius * kappa, z))
bp.handle_right = mathutils.Vector((center.x + x, center.y + y + radius * kappa, z))
elif i == 1: # Top point
bp.handle_left = mathutils.Vector((center.x + x + radius * kappa, center.y + y, z))
bp.handle_right = mathutils.Vector((center.x + x - radius * kappa, center.y + y, z))
elif i == 2: # Left point
bp.handle_left = mathutils.Vector((center.x + x, center.y + y + radius * kappa, z))
bp.handle_right = mathutils.Vector((center.x + x, center.y + y - radius * kappa, z))
else: # Bottom point
bp.handle_left = mathutils.Vector((center.x + x - radius * kappa, center.y + y, z))
bp.handle_right = mathutils.Vector((center.x + x + radius * kappa, center.y + y, z))
spline.use_cyclic_u = True
# Check if Follow Path constraint already exists (to preserve offset_factor from Start Angle)
existing_fcon = None
for constraint in cam_obj.constraints:
if constraint.type == 'FOLLOW_PATH':
existing_fcon = constraint
break
if existing_fcon:
# Use existing constraint (preserves offset_factor set by Start Angle callback)
fcon = existing_fcon
fcon.target = path_object # Update target to new path
else:
# Create new constraint only if none exists
fcon = cam_obj.constraints.new(type='FOLLOW_PATH')
fcon.target = path_object
# Restore preserved offset_factor from Update operation (Start Angle setting)
if preserved_offset_factor is not None:
fcon.offset_factor = preserved_offset_factor
else:
pass
# RESTORED: Settings that worked well with tracking
fcon.use_curve_follow = True # Allows the object to follow the curve's rotation
fcon.use_fixed_location = True # Maintains position on the path
def key_offset(offset, frame):
fcon.offset_factor = offset
fcon.keyframe_insert("offset_factor", frame=frame)
# Start Angle now controls offset_factor via callback
# Get current offset_factor from constraint (set by Start Angle callback or preserved from Update)
current_offset = fcon.offset_factor
if preserved_offset_factor is not None:
pass
else:
pass
if sign > 0: # Counter-clockwise
s0, s1 = current_offset, current_offset + 1.0
else: # Clockwise
s0, s1 = current_offset, current_offset - 1.0
if mode == "CIRCLE_360":
# SIMPLE METHOD: Only 2 keyframes for perfectly smooth rotation
key_offset(s0, start_frame)
key_offset(s1, end_frame)
elif mode == "PINGPONG":
# SIMPLE PINGPONG: Only 3 keyframes for smooth constant velocity
mid_frame = start_frame + (end_frame - start_frame) // 2
# Go from start angle to 180° and back - perfectly linear
key_offset(s0, start_frame) # Start position
key_offset(s0 + (s1 - s0) * 0.5, mid_frame) # Opposite side (180°)
key_offset(s0, end_frame) # Back to start
# AGGRESSIVE FORCE: Always LINEAR for 360° (ignore all user settings)
if cam_obj.animation_data and cam_obj.animation_data.action:
for fcurve in cam_obj.animation_data.action.fcurves:
if "offset_factor" in fcurve.data_path:
if mode == "CIRCLE_360":
# Force LINEAR for all keyframes - no exceptions
for kf in fcurve.keyframe_points:
kf.interpolation = 'LINEAR'
# Also reset handles to avoid any Bezier remnants
kf.handle_left_type = 'AUTO'
kf.handle_right_type = 'AUTO'
fcurve.update()
else:
# PINGPONG: Always use LINEAR for consistency and smoothness
for kf in fcurve.keyframe_points:
kf.interpolation = 'LINEAR'
# RESTORED: Add Track-To constraint for proper camera aiming
target_name = f"4D_Target_for_{cam_obj.name}"
target_obj = bpy.data.objects.get(target_name)
if target_obj:
tcon = cam_obj.constraints.new(type='TRACK_TO')
tcon.target = target_obj
tcon.track_axis = 'TRACK_NEGATIVE_Z'
tcon.up_axis = 'UP_Y'
@classmethod
def _create_empty_pivot_orbit(cls, cam_obj, center, radius, z, angle0, start_frame, end_frame, sign):
"""OPTIMIZED method for 360° orbit using Empty pivot - maximum smoothness"""
import bpy, math
# 1. Create Empty pivot at center
empty_name = f"Camera_Pivot_{cam_obj.name}"
# Remove existing pivot if it exists
if empty_name in bpy.data.objects:
bpy.data.objects.remove(bpy.data.objects[empty_name], do_unlink=True)
# Create new empty at center
bpy.ops.object.empty_add(type='PLAIN_AXES', location=center)
pivot = bpy.context.active_object
pivot.name = empty_name
# 2. Clear any existing camera constraints to avoid conflicts
for constraint in list(cam_obj.constraints):
cam_obj.constraints.remove(constraint)
# 3. Parent camera to empty and position it
cam_obj.parent = pivot
cam_obj.parent_type = 'OBJECT'
cam_obj.location = (radius, 0, z - center.z) # Relative to pivot
# 4. Set initial rotation for starting angle
pivot.rotation_euler[2] = angle0
# 5. Animate pivot rotation with only 2 keyframes (PERFECTION!)
pivot.rotation_euler[2] = angle0
pivot.keyframe_insert("rotation_euler", index=2, frame=start_frame)
pivot.rotation_euler[2] = angle0 + sign * 2 * math.pi
pivot.keyframe_insert("rotation_euler", index=2, frame=end_frame)
# 6. FORCE LINEAR interpolation for constant velocity + micro-jitter elimination
if pivot.animation_data and pivot.animation_data.action:
for fcurve in pivot.animation_data.action.fcurves:
if fcurve.data_path == "rotation_euler" and fcurve.array_index == 2:
for kf in fcurve.keyframe_points:
kf.interpolation = 'LINEAR'
kf.handle_left_type = 'AUTO'
kf.handle_right_type = 'AUTO'
fcurve.update()
# MEJORA: Lock other rotation axes to prevent micro-jitter
pivot.lock_rotation[0] = True # Lock X rotation
pivot.lock_rotation[1] = True # Lock Y rotation
# Z rotation remains unlocked for animation
# 7. Add Track-To constraint to camera for proper aim
tcon = cam_obj.constraints.new(type='TRACK_TO')
tcon.target = bpy.data.objects.get(f"4D_Target_for_{cam_obj.name}") # Use existing target
if tcon.target:
tcon.track_axis = 'TRACK_NEGATIVE_Z'
tcon.up_axis = 'UP_Y'
@classmethod
def _create_keyframe_orbit(cls, cam_obj, center, radius, z, angle0, start_frame, end_frame, sign, mode):
import math, mathutils
anim = cls.get_animation_props()
camera_props = anim.camera_orbit
def pt(theta):
x = center.x + radius * math.cos(theta)
y = center.y + radius * math.sin(theta)
return mathutils.Vector((x, y, z))
def key_loc(obj, loc, frame):
obj.location = loc
obj.keyframe_insert("location", frame=frame)
if mode == "CIRCLE_360":
# Use Empty pivot with only 2 keyframes for perfect smoothness
# 1. Create Empty pivot at center
import bpy
empty_name = f"Camera_Pivot_{cam_obj.name}"
# Remove existing pivot if it exists
if empty_name in bpy.data.objects:
bpy.data.objects.remove(bpy.data.objects[empty_name], do_unlink=True)
# Create new empty at center
bpy.ops.object.empty_add(type='PLAIN_AXES', location=center)
pivot = bpy.context.active_object
pivot.name = empty_name
# 2. Parent camera to empty and position it
cam_obj.parent = pivot
cam_obj.parent_type = 'OBJECT'
cam_obj.location = (radius, 0, z - center.z) # Relative to pivot
# 3. Set initial rotation for starting angle
pivot.rotation_euler[2] = angle0
# 4. Animate pivot rotation with only 2 keyframes
pivot.rotation_euler[2] = angle0
pivot.keyframe_insert("rotation_euler", index=2, frame=start_frame)
pivot.rotation_euler[2] = angle0 + sign * 2 * math.pi
pivot.keyframe_insert("rotation_euler", index=2, frame=end_frame)
# 5. AGGRESSIVE FORCE LINEAR interpolation for constant velocity
if pivot.animation_data and pivot.animation_data.action:
for fcurve in pivot.animation_data.action.fcurves:
if fcurve.data_path == "rotation_euler" and fcurve.array_index == 2:
for kf in fcurve.keyframe_points:
kf.interpolation = 'LINEAR'
kf.handle_left_type = 'AUTO'
kf.handle_right_type = 'AUTO'
fcurve.update()
elif mode == "PINGPONG":
# SIMPLE PINGPONG: Only 3 keyframes for smooth constant velocity
mid_frame = start_frame + (end_frame - start_frame) // 2
# Go from start angle to 180° and back - perfectly linear
key_loc(cam_obj, pt(angle0), start_frame) # Start position
key_loc(cam_obj, pt(angle0 + sign * math.pi), mid_frame) # Opposite side (180°)
key_loc(cam_obj, pt(angle0), end_frame) # Back to start
# AGGRESSIVE FORCE: Always LINEAR for 360° keyframe method
if cam_obj.animation_data and cam_obj.animation_data.action:
for fcurve in cam_obj.animation_data.action.fcurves:
if fcurve.data_path == "location":
if mode == "CIRCLE_360":
# Force LINEAR for all keyframes - no exceptions
for kp in fcurve.keyframe_points:
kp.interpolation = 'LINEAR'
kp.handle_left_type = 'AUTO'
kp.handle_right_type = 'AUTO'
fcurve.update()
else:
# PINGPONG: Always use LINEAR for consistency
for kp in fcurve.keyframe_points:
kp.interpolation = 'LINEAR'
@@ -0,0 +1,566 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import bpy
import json
from typing import Optional
import ifcopenshell
from .props_sequence import PropsSequence
import bonsai.tool as tool
# Assume that UnifiedColorTypeManager will be in the prop module
try:
from bonsai.bim.module.sequence.prop import UnifiedColorTypeManager
except ImportError:
UnifiedColorTypeManager = None
class ColorManagementSequence:
"""Mixin class for managing 4D animation color schemes and profiles (ColorTypes)."""
@classmethod
def load_ColorType_from_group(cls, group_name, ColorType_name):
import bpy, json
scene = bpy.context.scene
raw = scene.get("BIM_AnimationColorSchemesSets", "{}")
try:
data = json.loads(raw) if isinstance(raw, str) else (raw or {})
except Exception:
data = {}
# Debug: Show full DEFAULT group data
if group_name == "DEFAULT":
# Checking scene data for BIM_AnimationColorSchemesSets
print(f" Raw type: {type(raw)}")
print(f" Raw content: {raw[:200] if isinstance(raw, str) else str(raw)[:200]}...")
print(f" Parsed data keys: {list(data.keys())}")
if "DEFAULT" in data:
print(f" DEFAULT group structure: {data['DEFAULT']}")
group_data = data.get(group_name, {})
available_types = [prof.get("name") for prof in group_data.get("ColorTypes", [])]
# Debug info for DEFAULT group specifically
if group_name == "DEFAULT":
print(f"Loading ColorType '{ColorType_name}' from DEFAULT group")
print(f" Available ColorTypes in DEFAULT: {available_types}")
print(f" Group data has {len(group_data.get('ColorTypes', []))} ColorTypes")
# Show color details for each ColorType in DEFAULT
for prof in group_data.get("ColorTypes", []):
if prof.get("name") == ColorType_name:
print(f" ColorType '{ColorType_name}' colors:")
print(f" start_color: {prof.get('start_color', 'N/A')}")
print(f" in_progress_color: {prof.get('in_progress_color', 'N/A')}")
print(f" end_color: {prof.get('end_color', 'N/A')}")
# AUTO-FIX: Detect problematic green/gray colors and force recreation
in_progress = prof.get('in_progress_color', [])
end_color = prof.get('end_color', [])
if (in_progress == [0, 1, 0, 1] and end_color == [0.7, 0.7, 0.7, 1]):
print(f"Warning: Detected old green/gray colors in '{ColorType_name}' - forcing recreation!")
cls.force_recreate_default_group()
return cls.load_ColorType_from_group(group_name, ColorType_name) # Retry with new data
for prof_data in group_data.get("ColorTypes", []):
if prof_data.get("name") == ColorType_name:
if group_name == "DEFAULT":
print(f"Found ColorType '{ColorType_name}' in DEFAULT group")
return type('AnimationColorSchemes', (object,), {
'name': prof_data.get("name", ""),
'consider_start': prof_data.get("consider_start", True),
'consider_active': prof_data.get("consider_active", True),
'consider_end': prof_data.get("consider_end", True),
'start_color': prof_data.get("start_color", [1,1,1,1]),
'in_progress_color': prof_data.get("in_progress_color", [1,1,0,1]),
'end_color': prof_data.get("end_color", [0,1,0,1]),
'use_start_original_color': prof_data.get("use_start_original_color", False),
'use_active_original_color': prof_data.get("use_active_original_color", False),
'use_end_original_color': prof_data.get("use_end_original_color", True),
'start_transparency': prof_data.get("start_transparency", 0.0),
'active_start_transparency': prof_data.get("active_start_transparency", 0.0),
'active_finish_transparency': prof_data.get("active_finish_transparency", 0.0),
'active_transparency_interpol': prof_data.get("active_transparency_interpol", 1.0),
'end_transparency': prof_data.get("end_transparency", 0.0),
'hide_at_end': bool(prof_data.get("hide_at_end", prof_data.get("name") in {"DEMOLITION","REMOVAL","DISPOSAL","DISMANTLE"})),
})()
# Debug when ColorType not found in DEFAULT group
if group_name == "DEFAULT":
print(f"Error: ColorType '{ColorType_name}' not found in DEFAULT group")
return None
@classmethod
def force_recreate_default_group(cls):
"""Force recreation of DEFAULT group with correct colors"""
import bpy, json
scene = bpy.context.scene
key = "BIM_AnimationColorSchemesSets"
# Load existing data
raw = scene.get(key, "{}")
try:
data = json.loads(raw) if isinstance(raw, str) else {}
except Exception:
data = {}
print("[PROCESS] FORCE RECREATING DEFAULT group with distinctive colors...")
# Complete list of ColorTypes for the DEFAULT group (14 types)
default_ColorTypes = {
# Green Group (Construction)
"CONSTRUCTION": {"start": [1, 1, 1, 0], "active": [0, 1, 0, 1], "end": [0.3, 1, 0.3, 1]},
"INSTALLATION": {"start": [1, 1, 1, 0], "active": [0, 1, 0, 1], "end": [0.3, 0.8, 0.5, 1]},
# Red Group (Demolition)
"DEMOLITION": {"start": [1, 1, 1, 1], "active": [1, 0, 0, 1], "end": [0, 0, 0, 0]},
"REMOVAL": {"start": [1, 1, 1, 1], "active": [1, 0, 0, 1], "end": [0, 0, 0, 0]},
"DISPOSAL": {"start": [1, 1, 1, 1], "active": [1, 0, 0, 1], "end": [0, 0, 0, 0]},
"DISMANTLE": {"start": [1, 1, 1, 1], "active": [1, 0, 0, 1], "end": [0, 0, 0, 0]},
# Blue Group (Operation / Maintenance)
"OPERATION": {"start": [1, 1, 1, 0], "active": [0, 0, 1, 1], "end": [1, 1, 1, 1]},
"MAINTENANCE": {"start": [1, 1, 1, 1], "active": [0, 0, 1, 1], "end": [1, 1, 1, 1]},
# Orange Group (Moving / Transport)
"MOVING": {"start": [1, 1, 1, 1], "active": [1, 0.5, 0, 1], "end": [1, 1, 1, 1]},
"TRANSPORT": {"start": [1, 1, 1, 1], "active": [1, 0.5, 0, 1], "end": [1, 1, 1, 1]},
# Purple Group (Temporary / Event)
"TEMPORARY": {"start": [1, 1, 1, 0], "active": [0.5, 0, 1, 1], "end": [1, 1, 1, 1]},
"EVENT": {"start": [1, 1, 1, 0], "active": [0.5, 0, 1, 1], "end": [1, 1, 1, 1]},
# Yellow Group (Logistics)
"LOGISTIC": {"start": [1, 1, 1, 0], "active": [1, 1, 0, 1], "end": [1, 1, 1, 1]},
# Gray Group (Undefined)
"NOTDEFINED": {"start": [1, 1, 1, 1], "active": [0.5, 0.5, 0.5, 1], "end": [0.8, 0.8, 0.8, 1]},
}
ColorTypes = []
for name, colors in default_ColorTypes.items():
disappears = name in {"DEMOLITION", "REMOVAL", "DISPOSAL", "DISMANTLE"}
ColorTypes.append({
"name": name,
"consider_start": True,
"consider_active": True,
"consider_end": True,
"start_color": colors["start"],
"in_progress_color": colors["active"],
"end_color": colors["end"],
"use_start_original_color": False,
"use_active_original_color": False,
"use_end_original_color": not disappears,
"start_transparency": 0.0,
"active_start_transparency": 0.0,
"active_finish_transparency": 0.0,
"active_transparency_interpol": 1.0,
"end_transparency": 0.0,
"hide_at_end": disappears
})
# Override the DEFAULT group
data["DEFAULT"] = {"ColorTypes": ColorTypes}
scene[key] = json.dumps(data)
print(f"[OK] FORCED RECREATION of DEFAULT group with {len(ColorTypes)} distinctive ColorTypes")
# Debug: Show the new colors
for ct in ColorTypes[:3]: # Only show the first 3
print(f" {ct['name']}: start={ct['start_color']}, active={ct['in_progress_color']}, end={ct['end_color']}")
@classmethod
def load_ColorType_group_data(cls, group_name):
"""Loads data from a specific profile group"""
import bpy, json
scene = bpy.context.scene
raw = scene.get("BIM_AnimationColorSchemesSets", "{}")
try:
data = json.loads(raw) if isinstance(raw, str) else (raw or {})
return data.get(group_name, {})
except Exception:
return {}
@classmethod
def get_all_ColorType_groups(cls):
"""Gets all available profile groups"""
import bpy
return UnifiedColorTypeManager.get_all_groups(bpy.context)
@classmethod
def get_custom_ColorType_groups(cls):
"""Gets only custom groups (without DEFAULT)"""
import bpy
return UnifiedColorTypeManager.get_user_created_groups(bpy.context)
@classmethod
def has_animation_colors(cls):
return bpy.context.scene.BIMAnimationProperties.task_output_colors
@classmethod
def load_default_animation_color_scheme(cls):
"""Corrected: Restores original ColorTypes of each task instead of overwriting with hardcoded values"""
print("Reset: Restoring original ColorTypes of tasks...")
try:
import bpy
context = bpy.context
tprops = getattr(context.scene, 'BIMTaskTreeProperties', None)
if not tprops:
print("Error: Task properties not found")
return
reset_count = 0
for task in tprops.tasks:
task_id = str(task.ifc_definition_id)
if task_id == "0":
continue
try:
# 1. Get the original custom group of the task
original_group = None
if hasattr(task, 'animation_group_stack') and task.animation_group_stack:
for group_item in task.animation_group_stack:
if group_item.enabled:
original_group = group_item.group
break
# 2. If the task has a custom group, restore its ColorTypes
if original_group and original_group != "DEFAULT":
print(f"Reset: Restoring task {task_id} from group '{original_group}'")
# Restore ColorType from original group
predefined_type = getattr(task, 'PredefinedType', 'NOTDEFINED') or 'NOTDEFINED'
original_colortype = cls.load_ColorType_from_group(original_group, predefined_type)
if original_colortype:
# Apply original ColorType to the task
if hasattr(task, 'animation_color_schemes'):
task.animation_color_schemes = predefined_type
# Restore group configuration in the task
if hasattr(task, 'selected_colortype_in_active_group'):
try:
task.selected_colortype_in_active_group = predefined_type
except:
task.selected_colortype_in_active_group = ""
reset_count += 1
print(f"Reset: Task {task_id} restored with ColorType '{predefined_type}' from group '{original_group}'")
else:
pass
# 3. If task uses DEFAULT, maintain current configuration
elif not original_group or original_group == "DEFAULT":
pass
# Do not make changes for tasks that use DEFAULT
except Exception as e:
print(f"Error processing task {task_id}: {e}")
print(f"Reset completed: {reset_count} tasks restored to their original custom groups")
except Exception as e:
# Fallback to previous behavior only if there's a critical error
cls._legacy_load_default_colors()
@classmethod
def _legacy_load_default_colors(cls):
"""Legacy method as fallback in case of error"""
def _to_rgba(col):
try:
if isinstance(col, (list, tuple)):
if len(col) >= 4:
return (float(col[0]), float(col[1]), float(col[2]), float(col[3]))
if len(col) == 3:
return (float(col[0]), float(col[1]), float(col[2]), 1.0)
except Exception:
pass
return (1.0, 0.0, 0.0, 1.0)
groups = {
"CREATION": {"PredefinedType": ["CONSTRUCTION", "INSTALLATION"], "Color": (0.0, 1.0, 0.0)},
"OPERATION": {"PredefinedType": ["ATTENDANCE", "MAINTENANCE", "OPERATION", "RENOVATION"], "Color": (0.0, 0.0, 1.0)},
"MOVEMENT_TO": {"PredefinedType": ["LOGISTIC", "MOVE"], "Color": (1.0, 1.0, 0.0)},
"DESTRUCTION": {"PredefinedType": ["DEMOLITION", "DISMANTLE", "DISPOSAL", "REMOVAL"], "Color": (1.0, 0.0, 0.0)},
"MOVEMENT_FROM": {"PredefinedType": ["LOGISTIC", "MOVE"], "Color": (1.0, 0.5, 0.0)},
"USERDEFINED": {"PredefinedType": ["USERDEFINED", "NOTDEFINED"], "Color": (0.2, 0.2, 0.2)},
}
props = tool.Sequence.get_animation_props()
props.task_output_colors.clear()
props.task_input_colors.clear()
for group, data in groups.items():
for predefined_type in data["PredefinedType"]:
if group in ["CREATION", "OPERATION", "MOVEMENT_TO"]:
item = props.task_output_colors.add()
elif group in ["MOVEMENT_FROM"]:
item = props.task_input_colors.add()
elif group in ["USERDEFINED", "DESTRUCTION"]:
item = props.task_input_colors.add()
item2 = props.task_output_colors.add()
item2.name = predefined_type
item2.color = _to_rgba(data["Color"])
item.name = predefined_type
item.color = _to_rgba(data["Color"])
@classmethod
def create_default_ColorType_group(cls):
"""
Automatically creates the DEFAULT group with profiles for each PredefinedType.
This group is used when the user has not configured any profiles.
"""
import json
scene = bpy.context.scene
key = "BIM_AnimationColorSchemesSets"
raw = scene.get(key, "{}")
try:
data = json.loads(raw) if isinstance(raw, str) else {}
except Exception:
data = {}
if "DEFAULT" not in data:
default_ColorTypes = {
# Green Group (Construction)
"CONSTRUCTION": {"start": [1, 1, 1, 0], "active": [0, 1, 0, 1], "end": [0.3, 1, 0.3, 1]},
"INSTALLATION": {"start": [1, 1, 1, 0], "active": [0, 1, 0, 1], "end": [0.3, 0.8, 0.5, 1]},
# Red Group (Demolition)
"DEMOLITION": {"start": [1, 1, 1, 1], "active": [1, 0, 0, 1], "end": [0, 0, 0, 0], "hide_at_end": True},
"REMOVAL": {"start": [1, 1, 1, 1], "active": [1, 0, 0, 1], "end": [0, 0, 0, 0], "hide_at_end": True},
"DISPOSAL": {"start": [1, 1, 1, 1], "active": [1, 0, 0, 1], "end": [0, 0, 0, 0], "hide_at_end": True},
"DISMANTLE": {"start": [1, 1, 1, 1], "active": [1, 0, 0, 1], "end": [0, 0, 0, 0], "hide_at_end": True},
# Blue Group (Operation / Maintenance)
"OPERATION": {"start": [1, 1, 1, 1], "active": [0, 0, 1, 1], "end": [1, 1, 1, 1]}, # Changed from gray end color
"MAINTENANCE": {"start": [1, 1, 1, 1], "active": [0, 0, 1, 1], "end": [1, 1, 1, 1]},
"ATTENDANCE": {"start": [1, 1, 1, 1], "active": [0, 0, 1, 1], "end": [1, 1, 1, 1]},
"RENOVATION": {"start": [1, 1, 1, 1], "active": [0, 0, 1, 1], "end": [0.9, 0.9, 0.9, 1]},
# Yellow Group (Logistics)
"LOGISTIC": {"start": [1, 1, 1, 1], "active": [1, 1, 0, 1], "end": [1, 0.8, 0.3, 1]},
"MOVE": {"start": [1, 1, 1, 1], "active": [1, 1, 0, 1], "end": [0.8, 0.6, 0, 1]},
# Gray Group (Undefined / Others)
"NOTDEFINED": {"start": [0.7, 0.7, 0.7, 1], "active": [0.5, 0.5, 0.5, 1], "end": [0.3, 0.3, 0.3, 1]},
"USERDEFINED": {"start": [0.7, 0.7, 0.7, 1], "active": [0.5, 0.5, 0.5, 1], "end": [0.3, 0.3, 0.3, 1]}
}
ColorTypes = []
for name, colors in default_ColorTypes.items():
disappears = name in ["DEMOLITION", "REMOVAL", "DISPOSAL", "DISMANTLE"]
ColorTypes.append({
"name": name,
"consider_start": False, # DEFAULT: Objects should NOT appear at start (v110 behavior)
"consider_active": True,
"consider_end": True,
"start_color": colors["start"],
"in_progress_color": colors["active"],
"end_color": colors["end"],
"use_start_original_color": False,
"use_active_original_color": False,
"use_end_original_color": not disappears,
"start_transparency": 0.0,
"active_start_transparency": 0.0,
"active_finish_transparency": 0.0,
"active_transparency_interpol": 1.0,
"end_transparency": 0.0
})
data["DEFAULT"] = {"ColorTypes": ColorTypes}
scene[key] = json.dumps(data)
@classmethod
def get_assigned_ColorType_for_task(cls, task: ifcopenshell.entity_instance, animation_props, active_group_name: Optional[str] = None):
"""Gets the profile for a task GIVEN a specific active group."""
# Resolve active group if not provided
if not active_group_name:
try:
ag = None
for it in getattr(animation_props, 'animation_group_stack', []):
if getattr(it, 'enabled', False) and getattr(it, 'group', None):
ag = it.group
break
if not ag:
ag = getattr(animation_props, 'ColorType_groups', None)
active_group_name = ag or "DEFAULT"
except Exception:
active_group_name = "DEFAULT"
# Debug info when DEFAULT is active
if active_group_name == "DEFAULT":
task_id_str = str(task.id()) if task is not None else "None"
print(f"DEFAULT group is active for task {task_id_str} (PredefinedType: {getattr(task, 'PredefinedType', 'NOTDEFINED') if task is not None else 'NOTDEFINED'})")
# Get task configuration from the persistent cache instead of the UI list.
# This makes the function independent of the current UI filters.
import bpy, json
context = bpy.context
task_id_str = str(task.id()) if task is not None else "None"
task_config = None
try:
cache_key = "_task_colortype_snapshot_cache_json"
cache_raw = context.scene.get(cache_key)
if cache_raw:
cached_data = json.loads(cache_raw)
task_config = cached_data.get(task_id_str)
except Exception as e:
print(f"Bonsai WARNING: Could not read task config cache: {e}")
task_config = None
# 1) Specific assignment by group in the task
if task_config:
for choice in task_config.get("groups", []):
is_enabled = choice.get("enabled", False)
group_name = choice.get("group_name")
selected_value = choice.get("selected_value") or choice.get("selected_colortype")
if group_name == active_group_name and is_enabled and selected_value:
ColorType = cls.load_ColorType_from_group(active_group_name, selected_value)
if ColorType:
return ColorType
task_predefined_type = getattr(task, "PredefinedType", "NOTDEFINED") if task is not None else "NOTDEFINED"
# 2) PredefinedType in active group
ColorType = cls.load_ColorType_from_group(active_group_name, task_predefined_type)
if ColorType:
return ColorType
# 3) If no profile found and active group is not DEFAULT, try DEFAULT group
if active_group_name != "DEFAULT":
default_profile = cls.load_ColorType_from_group("DEFAULT", task_predefined_type)
if default_profile:
return default_profile
# Try NOTDEFINED in DEFAULT as fallback
notdefined_profile = cls.load_ColorType_from_group("DEFAULT", "NOTDEFINED")
if notdefined_profile:
return notdefined_profile
# 4) If active group IS DEFAULT, ensure we try NOTDEFINED profile in DEFAULT
elif active_group_name == "DEFAULT":
notdefined_profile = cls.load_ColorType_from_group("DEFAULT", "NOTDEFINED")
if notdefined_profile:
return notdefined_profile
# 5) Final fallback: create a basic ColorType only if DEFAULT group has no profiles
task_id_for_warning = task.id() if task is not None else "None"
print(f"[WARNING] WARNING: No ColorType found for task {task_id_for_warning} (PredefinedType: {task_predefined_type}) in group '{active_group_name}' - using fallback")
return cls.create_fallback_ColorType(task_predefined_type)
@classmethod
def create_fallback_ColorType(cls, predefined_type):
"""Creates a fallback ColorType when none is found in any group"""
# Use the original default color scheme based on PredefinedType
color_map = {
"CONSTRUCTION": (0.0, 1.0, 0.0, 1.0), # Green for construction
"INSTALLATION": (0.0, 1.0, 0.0, 1.0), # Green for installation
"DEMOLITION": (1.0, 0.0, 0.0, 1.0), # Red for demolition
"REMOVAL": (1.0, 0.0, 0.0, 1.0), # Red for removal
"DISPOSAL": (1.0, 0.0, 0.0, 1.0), # Red for disposal
"DISMANTLE": (1.0, 0.0, 0.0, 1.0), # Red for dismantle
"LOGISTIC": (1.0, 1.0, 0.0, 1.0), # Yellow for logistic
"MOVE": (1.0, 1.0, 0.0, 1.0), # Yellow for move
"MAINTENANCE": (0.0, 0.0, 1.0, 1.0), # Blue for maintenance
"OPERATION": (0.0, 0.0, 1.0, 1.0), # Blue for operation
"RENOVATION": (0.0, 0.0, 1.0, 1.0), # Blue for renovation
"ATTENDANCE": (0.0, 0.0, 1.0, 1.0), # Blue for attendance
}
# Default colors for unknown types
start_color = color_map.get(predefined_type, (0.8, 0.8, 0.8, 1.0)) # Light gray default
in_progress_color = color_map.get(predefined_type, (1.0, 1.0, 0.0, 1.0)) # Yellow default
end_color = color_map.get(predefined_type, (0.0, 1.0, 0.0, 1.0)) # Green default
return type('FallbackColorType', (object,), {
'name': predefined_type,
'consider_start': True,
'consider_active': True,
'consider_end': True,
'start_color': start_color,
'in_progress_color': in_progress_color,
'end_color': end_color,
'use_start_original_color': False,
'use_active_original_color': False,
'use_end_original_color': False,
'start_transparency': 0.0,
'active_start_transparency': 0.0,
'active_finish_transparency': 0.0,
'active_transparency_interpol': 1.0,
'end_transparency': 0.0,
'hide_at_end': predefined_type in {"DEMOLITION", "REMOVAL", "DISPOSAL", "DISMANTLE"},
})()
@classmethod
def sync_active_group_to_json(cls):
"""Synchronizes the active group profiles from the UI to the scene JSON"""
import bpy, json
scene = bpy.context.scene
anim_props = cls.get_animation_props()
active_group = getattr(anim_props, "ColorType_groups", None)
if not active_group:
return
if active_group == "DEFAULT":
# Temporary: Allow recreating the DEFAULT group to fix colors
print("[PROCESS] RECREATING DEFAULT group with correct colors...")
cls.force_recreate_default_group()
return
raw = scene.get("BIM_AnimationColorSchemesSets", "{}")
try:
data = json.loads(raw) if isinstance(raw, str) else (raw or {})
except Exception:
data = {}
ColorTypes_data = []
for ColorType in getattr(anim_props, "ColorTypes", []):
try:
ColorTypes_data.append({
"name": ColorType.name,
"consider_start": bool(getattr(ColorType, "consider_start", False)),
"consider_active": bool(getattr(ColorType, "consider_active", True)),
"consider_end": bool(getattr(ColorType, "consider_end", True)),
"start_color": list(getattr(ColorType, "start_color", [1,1,1,1])),
"in_progress_color": list(getattr(ColorType, "in_progress_color", [1,1,0,1])),
"end_color": list(getattr(ColorType, "end_color", [0,1,0,1])),
"use_start_original_color": bool(getattr(ColorType, "use_start_original_color", False)),
"use_active_original_color": bool(getattr(ColorType, "use_active_original_color", False)),
"use_end_original_color": bool(getattr(ColorType, "use_end_original_color", True)),
"start_transparency": float(getattr(ColorType, "start_transparency", 0.0)),
"active_start_transparency": float(getattr(ColorType, "active_start_transparency", 0.0)),
"active_finish_transparency": float(getattr(ColorType, "active_finish_transparency", 0.0)),
"active_transparency_interpol": float(getattr(ColorType, "active_transparency_interpol", 1.0)),
"end_transparency": float(getattr(ColorType, "end_transparency", 0.0)),
"hide_at_end": bool(getattr(ColorType, "hide_at_end", getattr(ColorType, "name", "") in {"DEMOLITION","REMOVAL","DISPOSAL","DISMANTLE"})),
})
except Exception:
pass
data[active_group] = {"ColorTypes": ColorTypes_data}
scene["BIM_AnimationColorSchemesSets"] = json.dumps(data)
@@ -0,0 +1,312 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import Union
from datetime import datetime
import ifcopenshell
import ifcopenshell.util.date
from .props_sequence import PropsSequence
import bonsai.tool as tool
class DateUtilsSequence:
"""Mixin class for date and time utility functions."""
@classmethod
def parse_isodate_datetime(cls, value, include_time: bool = True):
"""Parses ISO dates (or datetime/date) and returns a datetime object without microseconds.
- Accepts 'YYYY-MM-DD', 'YYYY-MM', 'YYYY', 'YYYY-MM-DDTHH:MM[:SS][Z|±HH:MM]'.
- If include_time is False, it normalizes to 00:00:00.
- If it cannot parse, it returns None.
"""
try:
import datetime as _dt, re as _re
if value is None:
return None
if isinstance(value, _dt.datetime):
return value.replace(microsecond=0) if include_time else value.replace(hour=0, minute=0, second=0, microsecond=0)
if isinstance(value, _dt.date):
return _dt.datetime.combine(value, _dt.time())
if isinstance(value, str):
s = value.strip()
if not s:
return None
# If contains time or timezone
if 'T' in s or ' ' in s or 'Z' in s or '+' in s:
ss = s.replace(' ', 'T').replace('Z', '+00:00')
try:
dtv = _dt.datetime.fromisoformat(ss)
except ValueError:
# Try without seconds: YYYY-MM-DDTHH:MM
m = _re.match(r'^(\d{4}-\d{2}-\d{2})[T ](\d{2}):(\d{2})$', ss)
if m:
dtv = _dt.datetime.fromisoformat(m.group(1) + 'T' + m.group(2) + ':' + m.group(3) + ':00')
else:
return None
return dtv.replace(microsecond=0) if include_time else dtv.replace(hour=0, minute=0, second=0, microsecond=0)
# Date-only variants
try:
d = _dt.date.fromisoformat(s)
except ValueError:
if _re.match(r'^\d{4}-\d{2}$', s):
y, m = s.split('-')
d = _dt.date(int(y), int(m), 1)
elif _re.match(r'^\d{4}$', s):
d = _dt.date(int(s), 1, 1)
else:
return None
return _dt.datetime.combine(d, _dt.time())
# Fallback
return None
except Exception:
return None
@classmethod
def isodate_datetime(cls, value, include_time: bool = True) -> str:
"""
Returns an ISO-8601 string.
- If include_time is False => YYYY-MM-DD
- If include_time is True => YYYY-MM-DDTHH:MM:SS (without microseconds)
Accepts datetime/date or string and is tolerant to None.
"""
try:
import datetime as _dt
if value is None:
return ""
# If it is already a str, return as is (assumed to be ISO or valid for UI)
if isinstance(value, str):
return value
# If it is datetime/date
if isinstance(value, _dt.datetime):
return (value.replace(microsecond=0).isoformat()
if include_time else value.date().isoformat())
if isinstance(value, _dt.date):
return value.isoformat()
# Any other type: try to convert
return str(value)
except Exception:
return ""
@classmethod
def get_start_date(cls) -> Union[datetime, None]:
"""Returns the configured start date (visualisation_start) or None.
Robust parsing: ISO-8601 first (YYYY-MM-DD), then dateutil with yearfirst=True.
"""
props = tool.Sequence.get_work_schedule_props()
s = getattr(props, "visualisation_start", None)
if not s or s == "-":
return None
try:
from datetime import datetime as _dt
if isinstance(s, str):
try:
if "T" in s or " " in s:
s2 = s.replace(" ", "T")
dt = _dt.fromisoformat(s2[:19])
else:
dt = _dt.fromisoformat(s[:10])
return dt.replace(microsecond=0)
except Exception:
pass
if isinstance(s, (_dt, )):
return s.replace(microsecond=0)
except Exception:
pass
try:
from dateutil import parser
dt = parser.parse(str(s), yearfirst=True, dayfirst=False, fuzzy=True)
return dt.replace(microsecond=0)
except Exception:
try:
dt = parser.parse(str(s), yearfirst=True, dayfirst=True, fuzzy=True)
return dt.replace(microsecond=0)
except Exception as e:
print(f"[ERROR] Error parseando visualisation_start: {s} -> {e}")
return None
@classmethod
def get_finish_date(cls) -> Union[datetime, None]:
"""Returns the configured finish date (visualisation_finish) or None.
Robust parsing: ISO-8601 first, then dateutil with yearfirst=True.
"""
props = tool.Sequence.get_work_schedule_props()
s = getattr(props, "visualisation_finish", None)
if not s or s == "-":
return None
try:
from datetime import datetime as _dt
if isinstance(s, str):
try:
if "T" in s or " " in s:
s2 = s.replace(" ", "T")
dt = _dt.fromisoformat(s2[:19])
else:
dt = _dt.fromisoformat(s[:10])
return dt.replace(microsecond=0)
except Exception:
pass
if isinstance(s, (_dt, )):
return s.replace(microsecond=0)
except Exception:
pass
try:
from dateutil import parser
dt = parser.parse(str(s), yearfirst=True, dayfirst=False, fuzzy=True)
return dt.replace(microsecond=0)
except Exception:
try:
dt = parser.parse(str(s), yearfirst=True, dayfirst=True, fuzzy=True)
return dt.replace(microsecond=0)
except Exception as e:
print(f"[ERROR] Error parseando visualisation_finish: {s} -> {e}")
return None
@classmethod
def get_schedule_date_range(cls, work_schedule=None):
"""
Gets the REAL date range of the active schedule (not the visualization dates).
OPTIMIZED: Now uses SequenceCache for fast access.
Returns:
tuple: (schedule_start: datetime, schedule_finish: datetime) or (None, None) on failure
"""
try:
if not work_schedule:
work_schedule = cls.get_active_work_schedule()
if not work_schedule:
return None, None
# TEMPORARILY DISABLED: Cache optimization to prevent infinite loops
# NEW: Use cache-optimized date retrieval
# work_schedule_id = work_schedule.id()
# props = tool.Sequence.get_work_schedule_props()
# date_source = getattr(props, "date_source_type", "SCHEDULE")
#
# cached_dates = SequenceCache.get_schedule_dates(work_schedule_id, date_source)
# if cached_dates and cached_dates['date_range'][0] and cached_dates['date_range'][1]:
# schedule_start, schedule_finish = cached_dates['date_range']
# print(f"[FAST] Schedule dates (cached): {schedule_start.strftime('%Y-%m-%d')} to {schedule_finish.strftime('%Y-%m-%d')}")
# return schedule_start, schedule_finish
# Fallback to original logic if cache fails
schedule_start = None
schedule_finish = None
try:
infer = getattr(cls, "_infer_schedule_date_range", None)
if infer:
schedule_start, schedule_finish = infer(work_schedule)
except Exception as e:
pass
if schedule_start and schedule_finish:
return schedule_start, schedule_finish
# Final fallback: use guess_date_range
try:
schedule_start, schedule_finish = cls.guess_date_range(work_schedule)
if schedule_start and schedule_finish:
return schedule_start, schedule_finish
except Exception as e:
pass
return None, None
except Exception as e:
return None, None
@classmethod
def guess_date_range(cls, work_schedule: ifcopenshell.entity_instance) -> tuple[Any, Any] | None:
"""
Guesses the date range for a work schedule, respecting the date source type
(Schedule, Actual, etc.) set in the UI. It now calculates the range based
on ALL tasks in the schedule, ignoring any UI filters.
"""
if not work_schedule:
return None
# Helper function to get all tasks recursively, ignoring UI filters.
def get_all_tasks_from_schedule(schedule):
all_tasks = []
root_tasks = ifcopenshell.util.sequence.get_root_tasks(schedule)
def recurse(tasks):
for task in tasks:
all_tasks.append(task)
nested = ifcopenshell.util.sequence.get_nested_tasks(task)
if nested:
recurse(nested)
recurse(root_tasks)
return all_tasks
all_schedule_tasks = get_all_tasks_from_schedule(work_schedule)
if not all_schedule_tasks:
return None
props = tool.Sequence.get_work_schedule_props()
date_source = getattr(props, "date_source_type", "SCHEDULE")
start_attr = f"{date_source.capitalize()}Start"
finish_attr = f"{date_source.capitalize()}Finish"
all_starts = []
all_finishes = []
found_dates_count = 0
# Iterate over ALL tasks from the schedule, not just the visible ones.
for task in all_schedule_tasks:
start_date = ifcopenshell.util.sequence.derive_date(task, start_attr, is_earliest=True)
if start_date:
all_starts.append(start_date)
finish_date = ifcopenshell.util.sequence.derive_date(task, finish_attr, is_latest=True)
if finish_date:
all_finishes.append(finish_date)
if start_date or finish_date:
found_dates_count += 1
if not all_starts or not all_finishes:
return None
result_start = min(all_starts)
result_finish = max(all_finishes)
return result_start, result_finish
@classmethod
def get_visualization_date_range(cls):
"""
Gets the visualization date range configured in the UI.
Returns:
tuple: (viz_start: datetime, viz_finish: datetime) or (None, None) if not configured
"""
try:
props = tool.Sequence.get_work_schedule_props()
viz_start = cls.get_start_date() # This function already exists
viz_finish = cls.get_finish_date() # This function already exists
return viz_start, viz_finish
except Exception as e:
return None, None
@@ -0,0 +1,129 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import bpy
from typing import Any
import ifcopenshell
import ifcopenshell.util.sequence
import ifcopenshell.util.date
import bonsai.tool as tool
class GanttChartSequence:
"""Mixin class for generating and displaying the web-based Gantt chart."""
@classmethod
def create_tasks_json(cls, work_schedule: ifcopenshell.entity_instance) -> list[dict[str, Any]]:
sequence_type_map = {
None: "FS",
"START_START": "SS",
"START_FINISH": "SF",
"FINISH_START": "FS",
"FINISH_FINISH": "FF",
"USERDEFINED": "FS",
"NOTDEFINED": "FS",
}
is_baseline = False
if work_schedule.PredefinedType == "BASELINE":
is_baseline = True
relating_work_schedule = work_schedule.IsDeclaredBy[0].RelatingObject
work_schedule = relating_work_schedule
tasks_json = []
for task in ifcopenshell.util.sequence.get_root_tasks(work_schedule):
if is_baseline:
cls.create_new_task_json(task, tasks_json, sequence_type_map, baseline_schedule=work_schedule)
else:
cls.create_new_task_json(task, tasks_json, sequence_type_map)
return tasks_json
@classmethod
def create_new_task_json(cls, task, json, type_map=None, baseline_schedule=None):
task_time = task.TaskTime
resources = ifcopenshell.util.sequence.get_task_resources(task, is_deep=False)
string_resources = ""
resources_usage = ""
for resource in resources:
string_resources += resource.Name + ", "
resources_usage += str(resource.Usage.ScheduleUsage) + ", " if resource.Usage else "-, "
schedule_start = task_time.ScheduleStart if task_time else ""
schedule_finish = task_time.ScheduleFinish if task_time else ""
baseline_task = None
if baseline_schedule:
for rel in task.Declares:
for baseline_task in rel.RelatedObjects:
if baseline_schedule.id() == ifcopenshell.util.sequence.get_task_work_schedule(baseline_task).id():
baseline_task = task
break
if baseline_task and baseline_task.TaskTime:
compare_start = baseline_task.TaskTime.ScheduleStart
compare_finish = baseline_task.TaskTime.ScheduleFinish
else:
compare_start = schedule_start
compare_finish = schedule_finish
task_name = task.Name or "Unnamed"
task_name = task_name.replace("\n", "")
data = {
"pID": task.id(),
"pName": task_name,
"pCaption": task_name,
"pStart": schedule_start,
"pEnd": schedule_finish,
"pPlanStart": compare_start,
"pPlanEnd": compare_finish,
"pMile": 1 if task.IsMilestone else 0,
"pRes": string_resources,
"pComp": 0,
"pGroup": 1 if task.IsNestedBy else 0,
"pParent": task.Nests[0].RelatingObject.id() if task.Nests else 0,
"pOpen": 1,
"pCost": 1,
"ifcduration": (
str(ifcopenshell.util.date.ifc2datetime(task_time.ScheduleDuration))
if (task_time and task_time.ScheduleDuration)
else ""
),
"resourceUsage": resources_usage,
}
if task_time and task_time.IsCritical:
data["pClass"] = "gtaskred"
elif data["pGroup"]:
data["pClass"] = "ggroupblack"
elif data["pMile"]:
data["pClass"] = "gmilestone"
else:
data["pClass"] = "gtaskblue"
data["pDepend"] = ",".join(
[f"{rel.RelatingProcess.id()}{type_map[rel.SequenceType]}" for rel in task.IsSuccessorFrom or []]
)
json.append(data)
for nested_task in ifcopenshell.util.sequence.get_nested_tasks(task):
cls.create_new_task_json(nested_task, json, type_map, baseline_schedule)
@classmethod
def generate_gantt_browser_chart(
cls, task_json: list[dict[str, Any]], work_schedule: ifcopenshell.entity_instance
) -> None:
if not bpy.context.scene.WebProperties.is_connected:
bpy.ops.bim.connect_websocket_server(page="sequencing")
gantt_data = {"tasks": task_json, "work_schedule": work_schedule.get_info(recursive=True)}
tool.Web.send_webui_data(data=gantt_data, data_key="gantt_data", event="gantt_data")
@@ -0,0 +1,73 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import bpy
from typing import TYPE_CHECKING
import bonsai.tool as tool
if TYPE_CHECKING:
from bonsai.bim.module.sequence.prop import (
BIMAnimationProperties,
BIMStatusProperties,
BIMTaskTreeProperties,
BIMWorkCalendarProperties,
BIMWorkPlanProperties,
BIMWorkScheduleProperties,
)
class PropsSequence:
"""Mixin class for accessing BIM sequence properties from the Blender scene."""
@classmethod
def get_work_schedule_props(cls) -> BIMWorkScheduleProperties:
assert (scene := bpy.context.scene)
return scene.BIMWorkScheduleProperties
@classmethod
def get_task_tree_props(cls) -> BIMTaskTreeProperties:
assert (scene := bpy.context.scene)
return scene.BIMTaskTreeProperties
@classmethod
def get_animation_props(cls) -> BIMAnimationProperties:
assert (scene := bpy.context.scene)
return scene.BIMAnimationProperties
@classmethod
def get_status_props(cls) -> BIMStatusProperties:
assert (scene := bpy.context.scene)
return scene.BIMStatusProperties
@classmethod
def get_work_plan_props(cls) -> BIMWorkPlanProperties:
assert (scene := bpy.context.scene)
return scene.BIMWorkPlanProperties
@classmethod
def get_work_calendar_props(cls) -> BIMWorkCalendarProperties:
assert (scene := bpy.context.scene)
return scene.BIMWorkCalendarProperties
@@ -0,0 +1,343 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import bpy
from typing import Any, Union, Literal
import ifcopenshell
import ifcopenshell.api
import bonsai.bim.helper
import bonsai.tool as tool
from .props_sequence import PropsSequence
from .task_attributes_sequence import TaskAttributesSequence
class ScheduleManagementSequence:
"""Mixin class for managing IfcWorkPlan and IfcWorkSchedule entities."""
last_duplication_mapping = {}
@classmethod
def get_work_plan_attributes(cls) -> dict[str, Any]:
import bonsai.bim.module.sequence.utils.helper_utils as helper
def callback(attributes: dict[str, Any], prop: Attribute) -> bool:
if "Date" in prop.name or "Time" in prop.name:
if prop.is_null:
attributes[prop.name] = None
return True
attributes[prop.name] = helper.parse_datetime(prop.string_value)
return True
elif prop.name == "Duration" or prop.name == "TotalFloat":
if prop.is_null:
attributes[prop.name] = None
return True
attributes[prop.name] = helper.parse_duration(prop.string_value)
return True
return False
props = tool.Sequence.get_work_plan_props()
return bonsai.bim.helper.export_attributes(props.work_plan_attributes, callback)
@classmethod
def load_work_plan_attributes(cls, work_plan: ifcopenshell.entity_instance) -> None:
def callback(name: str, prop: Union[Attribute, None], data: dict[str, Any]) -> None | Literal[True]:
if name in ["CreationDate", "StartTime", "FinishTime"]:
assert prop
prop.string_value = "" if prop.is_null else data[name]
return True
props = tool.Sequence.get_work_plan_props()
props.work_plan_attributes.clear()
bonsai.bim.helper.import_attributes(work_plan, props.work_plan_attributes, callback)
@classmethod
def enable_editing_work_plan(cls, work_plan: Union[ifcopenshell.entity_instance, None]) -> None:
if work_plan:
props = tool.Sequence.get_work_plan_props()
props.active_work_plan_id = work_plan.id()
props.editing_type = "ATTRIBUTES"
@classmethod
def disable_editing_work_plan(cls) -> None:
props = tool.Sequence.get_work_plan_props()
props.active_work_plan_id = 0
@classmethod
def enable_editing_work_plan_schedules(cls, work_plan: Union[ifcopenshell.entity_instance, None]) -> None:
if work_plan:
props = tool.Sequence.get_work_plan_props()
props.active_work_plan_id = work_plan.id()
props.editing_type = "SCHEDULES"
@classmethod
def get_work_schedule_attributes(cls) -> dict[str, Any]:
import bonsai.bim.module.sequence.utils.helper_utils as helper
def callback(attributes: dict[str, Any], prop: Attribute) -> bool:
if "Date" in prop.name or "Time" in prop.name:
if prop.is_null:
attributes[prop.name] = None
return True
attributes[prop.name] = helper.parse_datetime(prop.string_value)
return True
elif prop.special_type == "DURATION":
return cls.export_duration_prop(prop, attributes)
return False
props = tool.Sequence.get_work_schedule_props()
return bonsai.bim.helper.export_attributes(props.work_schedule_attributes, callback)
@classmethod
def load_work_schedule_attributes(cls, work_schedule: ifcopenshell.entity_instance) -> None:
schema = tool.Ifc.schema()
entity = schema.declaration_by_name("IfcWorkSchedule").as_entity()
assert entity
def callback(name: str, prop: Union[Attribute, None], data: dict[str, Any]) -> None | Literal[True]:
if name in ["CreationDate", "StartTime", "FinishTime"]:
assert prop
prop.string_value = "" if prop.is_null else data[name]
return True
else:
attr = entity.attribute_by_index(entity.attribute_index(name))
if not attr.type_of_attribute()._is("IfcDuration"):
return
assert prop
cls.add_duration_prop(prop, data[name])
props = tool.Sequence.get_work_schedule_props()
props.work_schedule_attributes.clear()
bonsai.bim.helper.import_attributes(work_schedule, props.work_schedule_attributes, callback)
@classmethod
def copy_work_schedule(cls, work_schedule: ifcopenshell.entity_instance) -> None:
"""Creates a deep copy of the given work schedule, including all its tasks and relationships."""
import ifcopenshell
import ifcopenshell.api
import ifcopenshell.util.sequence
import ifcopenshell.guid
file = tool.Ifc.get()
if not work_schedule or not file:
return
try:
import ifcopenshell.api.clone
new_schedule = ifcopenshell.api.run("clone.clone_deep", file, element=work_schedule)
original_name = getattr(work_schedule, "Name", "Unnamed Schedule")
new_schedule.Name = f"Copy of {original_name}"
return
except (ImportError, ModuleNotFoundError) as e:
pass
except Exception as e:
pass
# --- Manual deep copy for very old IfcOpenShell versions ---
def _create_entity_copy(ifc_file, entity: ifcopenshell.entity_instance):
"""Creates a TRUE copy of an IFC entity with a new unique ID."""
if not entity:
return None
try:
# Use create_entity to create a new entity with new ID
# Do NOT use .add() as it only reuses the existing entity
entity_info = entity.get_info()
# Remove fields that must be unique for the new entity
if 'id' in entity_info:
del entity_info['id']
if 'GlobalId' in entity_info:
entity_info['GlobalId'] = ifcopenshell.guid.new()
# Create a new entity with the copied data
new_entity = ifc_file.create_entity(entity.is_a(), **entity_info)
return new_entity
except Exception as e:
# Fallback: try with API method if available
try:
if entity.is_a("IfcTask"):
# For tasks, use specific API if available
work_schedule = None # Will be assigned later
new_task = ifcopenshell.api.run("sequence.add_task", ifc_file, parent_task=None, work_schedule=work_schedule)
# Copy important attributes
if hasattr(entity, 'Name') and entity.Name:
new_task.Name = entity.Name
if hasattr(entity, 'Description') and entity.Description:
new_task.Description = entity.Description
if hasattr(entity, 'Identification') and entity.Identification:
new_task.Identification = entity.Identification
if hasattr(entity, 'PredefinedType') and entity.PredefinedType:
new_task.PredefinedType = entity.PredefinedType
return new_task
except Exception as api_error:
pass
return None
try:
old_to_new_tasks = {} # Map old task IDs to new task entities
cls.last_duplication_mapping = {} # For later use in ColorType
def _copy_task_recursive(old_task, new_parent):
# 1. Create a completely new task with unique ID
try:
if new_parent.is_a("IfcWorkSchedule"):
# For root tasks, use API with work_schedule
new_task = ifcopenshell.api.run("sequence.add_task", file,
work_schedule=new_parent,
parent_task=None)
else:
# For nested tasks, use the API with parent_task
work_schedule = ifcopenshell.util.sequence.get_task_work_schedule(new_parent)
new_task = ifcopenshell.api.run("sequence.add_task", file,
work_schedule=work_schedule,
parent_task=new_parent)
# Copy all important attributes
if hasattr(old_task, 'Name') and old_task.Name:
new_task.Name = old_task.Name
if hasattr(old_task, 'Description') and old_task.Description:
new_task.Description = old_task.Description
if hasattr(old_task, 'Identification') and old_task.Identification:
new_task.Identification = old_task.Identification
if hasattr(old_task, 'PredefinedType') and old_task.PredefinedType:
new_task.PredefinedType = old_task.PredefinedType
if hasattr(old_task, 'Priority') and old_task.Priority:
new_task.Priority = old_task.Priority
if hasattr(old_task, 'Status') and old_task.Status:
new_task.Status = old_task.Status
if hasattr(old_task, 'WorkMethod') and old_task.WorkMethod:
new_task.WorkMethod = old_task.WorkMethod
if hasattr(old_task, 'IsMilestone') and old_task.IsMilestone is not None:
new_task.IsMilestone = old_task.IsMilestone
except Exception as api_error:
# Fallback to entity copy method
new_task = _create_entity_copy(file, old_task)
if not new_task:
return
# 2. Copy TaskTime if it exists
if old_task.TaskTime:
try:
# Create new TaskTime using API
new_task_time = ifcopenshell.api.run("sequence.add_task_time", file, task=new_task)
# Copy time attributes
old_time = old_task.TaskTime
if hasattr(old_time, 'DurationType') and old_time.DurationType:
new_task_time.DurationType = old_time.DurationType
if hasattr(old_time, 'ScheduleDuration') and old_time.ScheduleDuration:
new_task_time.ScheduleDuration = old_time.ScheduleDuration
if hasattr(old_time, 'ScheduleStart') and old_time.ScheduleStart:
new_task_time.ScheduleStart = old_time.ScheduleStart
if hasattr(old_time, 'ScheduleFinish') and old_time.ScheduleFinish:
new_task_time.ScheduleFinish = old_time.ScheduleFinish
if hasattr(old_time, 'ActualStart') and old_time.ActualStart:
new_task_time.ActualStart = old_time.ActualStart
if hasattr(old_time, 'ActualFinish') and old_time.ActualFinish:
new_task_time.ActualFinish = old_time.ActualFinish
except Exception as time_error:
# Fallback: copy TaskTime with direct method
try:
new_task.TaskTime = _create_entity_copy(file, old_task.TaskTime)
except:
pass
old_to_new_tasks[old_task.id()] = new_task
cls.last_duplication_mapping[old_task.id()] = new_task.id()
# 3. Relationships were already created automatically with the API.
# Copy products and resources to the new task
for product in ifcopenshell.util.sequence.get_task_outputs(old_task):
ifcopenshell.api.run("sequence.assign_product", file, relating_product=product, related_object=new_task)
for product_input in ifcopenshell.util.sequence.get_task_inputs(old_task):
ifcopenshell.api.run("sequence.assign_process", file, relating_process=new_task, related_object=product_input)
for resource in ifcopenshell.util.sequence.get_task_resources(old_task):
ifcopenshell.api.run("sequence.assign_process", file, relating_process=new_task, related_object=resource)
# 4. Recursively copy nested tasks
for child_task in ifcopenshell.util.sequence.get_nested_tasks(old_task):
_copy_task_recursive(child_task, new_task)
# 1. Create the new, empty work schedule
new_schedule = ifcopenshell.api.run("sequence.add_work_schedule", file, name=f"Copy of {getattr(work_schedule, 'Name', 'Unnamed')}")
# 2. Start the recursive copy from the root tasks
for root_task in ifcopenshell.util.sequence.get_root_tasks(work_schedule):
_copy_task_recursive(root_task, new_schedule)
# 3. Re-link predecessors and successors
for old_id, new_task in old_to_new_tasks.items():
old_task = file.by_id(old_id)
for rel in getattr(old_task, 'IsSuccessorFrom', []):
old_predecessor = rel.RelatingProcess
if old_predecessor.id() in old_to_new_tasks:
new_predecessor = old_to_new_tasks[old_predecessor.id()]
time_lag = _create_entity_copy(file, rel.TimeLag) if getattr(rel, 'TimeLag', None) else None
# The 'time_lag' argument is not supported in older ifcopenshell API versions.
# We create the relationship first, then assign the time lag manually for compatibility.
new_rel = ifcopenshell.api.run(
"sequence.assign_sequence",
file,
relating_process=new_predecessor,
related_process=new_task,
sequence_type=rel.SequenceType,
)
if time_lag:
new_rel.TimeLag = time_lag
except Exception as e:
import traceback
traceback.print_exc()
@classmethod
def enable_editing_work_schedule(cls, work_schedule: ifcopenshell.entity_instance) -> None:
props = tool.Sequence.get_work_schedule_props()
props.active_work_schedule_id = work_schedule.id()
props.editing_type = "WORK_SCHEDULE"
@classmethod
def disable_editing_work_schedule(cls) -> None:
props = tool.Sequence.get_work_schedule_props()
props.active_work_schedule_id = 0
@classmethod
def enable_editing_work_schedule_tasks(cls, work_schedule: Union[ifcopenshell.entity_instance, None]) -> None:
if work_schedule:
props = tool.Sequence.get_work_schedule_props()
props.active_work_schedule_id = work_schedule.id()
props.editing_type = "TASKS"
@classmethod
def get_active_work_schedule(cls) -> Union[ifcopenshell.entity_instance, None]:
props = tool.Sequence.get_work_schedule_props()
if not props.active_work_schedule_id:
return None
return tool.Ifc.get().by_id(props.active_work_schedule_id)
@classmethod
def disable_work_schedule(cls) -> None:
props = tool.Sequence.get_work_schedule_props()
props.active_work_schedule_id = 0
@@ -0,0 +1,444 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import Union
import bpy
import ifcopenshell
import ifcopenshell.util.element
import bonsai.tool as tool
from .props_sequence import PropsSequence
from .task_tree_sequence import TaskTreeSequence
class ScheduleUtilsSequence:
"""Mixin for miscellaneous utility and helper functions related to schedules and tasks."""
@classmethod
def apply_selection_from_checkboxes(cls):
"""
Selects 3D objects in the viewport for all tasks marked with checkboxes.
Deselects everything else.
"""
try:
tprops = tool.Sequence.get_task_tree_props()
if not tprops:
return
# Get all tasks that are marked with the checkbox
selected_tasks_pg = [task_pg for task_pg in tprops.tasks if getattr(task_pg, 'is_selected', False)]
# Deselect everything in the scene
bpy.ops.object.select_all(action='DESELECT')
# If no tasks are marked, finish
if not selected_tasks_pg:
return
# Collect all objects to select (OUTPUTS + INPUTS)
objects_to_select = []
for task_pg in selected_tasks_pg:
task_ifc = tool.Ifc.get().by_id(task_pg.ifc_definition_id)
if not task_ifc:
continue
# Include both outputs and inputs
outputs = tool.Sequence.get_task_outputs(task_ifc) or []
inputs = tool.Sequence.get_task_inputs(task_ifc) or []
# Combine both, removing duplicates
all_products = list(set(outputs + inputs))
for product in all_products:
obj = tool.Ifc.get_object(product)
if obj:
objects_to_select.append(obj)
# Select all collected objects
if objects_to_select:
for obj in objects_to_select:
obj.select_set(True)
# Make the first object in the list the active one
bpy.context.view_layer.objects.active = objects_to_select[0]
except Exception as e:
import traceback
traceback.print_exc()
@classmethod
def set_visibility_by_status(cls, visible_statuses: set[str]) -> None:
"""
Hides or shows objects based on their IFC status property.
"""
import bpy
import ifcopenshell.util.element
import bonsai.tool as tool
ifc_file = tool.Ifc.get()
if not ifc_file:
return
all_products = ifc_file.by_type("IfcProduct")
for product in all_products:
obj = tool.Ifc.get_object(product)
if not obj:
continue
current_status = "No Status"
psets = ifcopenshell.util.element.get_psets(product)
for pset_name, pset_props in psets.items():
if "Status" in pset_props:
if pset_name.startswith("Pset_") and pset_name.endswith("Common"):
current_status = pset_props["Status"]
break
elif pset_name == "EPset_Status":
current_status = pset_props["Status"]
break
obj.hide_viewport = current_status not in visible_statuses
obj.hide_render = current_status not in visible_statuses
@classmethod
def disable_selecting_deleted_task(cls) -> None:
props = tool.Sequence.get_work_schedule_props()
if props.active_task_id not in [
task.ifc_definition_id for task in tool.Sequence.get_task_tree_props().tasks
]: # Task was deleted
props.active_task_id = 0
props.active_task_time_id = 0
@classmethod
def get_checked_tasks(cls) -> list[ifcopenshell.entity_instance]:
return [
tool.Ifc.get().by_id(task.ifc_definition_id) for task in tool.Sequence.get_task_tree_props().tasks if task.is_selected
] or []
@classmethod
def get_highlighted_task(cls) -> Union[ifcopenshell.entity_instance, None]:
tasks = tool.Sequence.get_task_tree_props().tasks
props = tool.Sequence.get_work_schedule_props()
if len(tasks) and len(tasks) > props.active_task_index:
return tool.Ifc.get().by_id(tasks[props.active_task_index].ifc_definition_id)
@classmethod
def get_direct_task_outputs(cls, task: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]:
return ifcopenshell.util.sequence.get_direct_task_outputs(task)
@classmethod
def find_related_input_tasks(cls, product):
related_tasks = []
for assignment in product.HasAssignments:
if assignment.is_a("IfcRelAssignsToProcess") and assignment.RelatingProcess.is_a("IfcTask"):
related_tasks.append(assignment.RelatingProcess)
return related_tasks
@classmethod
def find_related_output_tasks(cls, product):
related_tasks = []
for reference in product.ReferencedBy:
if reference.is_a("IfcRelAssignsToProduct") and reference.RelatedObjects[0].is_a("IfcTask"):
related_tasks.append(reference.RelatedObjects[0])
return related_tasks
@classmethod
def get_work_schedule(cls, task: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]:
for rel in task.HasAssignments or []:
if rel.is_a("IfcRelAssignsToControl") and rel.RelatingControl.is_a("IfcWorkSchedule"):
return rel.RelatingControl
for rel in task.Nests or []:
return cls.get_work_schedule(rel.RelatingObject)
@classmethod
def is_work_schedule_active(cls, work_schedule):
props = tool.Sequence.get_work_schedule_props()
return True if work_schedule.id() == props.active_work_schedule_id else False
@classmethod
def update_visualisation_date(cls, start_date, finish_date):
props = tool.Sequence.get_work_schedule_props()
if start_date and finish_date:
start_iso = ifcopenshell.util.date.canonicalise_time(start_date)
finish_iso = ifcopenshell.util.date.canonicalise_time(finish_date)
# Debug the actual update
props.visualisation_start = start_iso
props.visualisation_finish = finish_iso
# Verify the update worked
else:
props.visualisation_start = ""
props.visualisation_finish = ""
@classmethod
def validate_task_object(cls, task, operation_name="operation"):
"""
Validates that a task object is valid before processing it.
Args:
task: The task object to validate
operation_name: Name of the operation for logging
Returns:
bool: True if the task is valid, False otherwise
"""
if task is None:
print(f"[WARNING] Warning: None task in {operation_name}")
return False
if not hasattr(task, 'id') or not callable(getattr(task, 'id', None)):
print(f"[WARNING] Warning: Invalid task object in {operation_name}: {task}")
return False
try:
task_id = task.id()
if task_id is None or task_id <= 0:
print(f"[WARNING] Warning: Invalid task ID in {operation_name}: {task_id}")
return False
except Exception as e:
print(f"[WARNING] Error getting task ID in {operation_name}: {e}")
return False
return True
@classmethod
def get_work_schedule_products(cls, work_schedule: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]:
"""
Gets all products associated with a work schedule.
Args:
work_schedule: The IFC work schedule
Returns:
List of IFC products (can be empty)
"""
try:
products: list[ifcopenshell.entity_instance] = []
# Get all tasks from the schedule
if hasattr(work_schedule, 'Controls') and work_schedule.Controls:
for rel in work_schedule.Controls:
for task in rel.RelatedObjects:
if task.is_a("IfcTask"):
# Get output products
task_outputs = tool.Sequence.get_task_outputs(task) or []
products.extend(task_outputs)
# Get input products
task_inputs = tool.Sequence.get_task_inputs(task) or []
products.extend(task_inputs)
# Remove duplicates while maintaining order
seen: set[int] = set()
unique_products: list[ifcopenshell.entity_instance] = []
for product in products:
try:
pid = product.id()
except Exception:
pid = None
if pid and pid not in seen:
seen.add(pid)
unique_products.append(product)
return unique_products
except Exception as e:
print(f"Error getting work schedule products: {e}")
return []
@classmethod
def select_work_schedule_products(cls, work_schedule: ifcopenshell.entity_instance) -> str:
"""
Selects all products associated with a work schedule.
Args:
work_schedule: The IFC work schedule
Returns:
Result message
"""
try:
products = cls.get_work_schedule_products(work_schedule)
if not products:
return "No products found in work schedule"
# Use the safe spatial function to select products
tool.Spatial.select_products(products)
return f"Selected {len(products)} products from work schedule"
except Exception as e:
print(f"Error selecting work schedule products: {e}")
return f"Error selecting products: {str(e)}"
@classmethod
def select_unassigned_work_schedule_products(cls) -> str:
"""
Selects products that are not assigned to any work schedule.
Returns:
Result message
"""
try:
ifc_file = tool.Ifc.get()
if not ifc_file:
return "No IFC file loaded"
# Get all products
all_products = list(ifc_file.by_type("IfcProduct"))
# Get products assigned to schedules
schedule_products: set[int] = set()
for work_schedule in ifc_file.by_type("IfcWorkSchedule"):
ws_products = cls.get_work_schedule_products(work_schedule) or []
for product in ws_products:
try:
pid = product.id()
except Exception:
pid = None
if pid:
schedule_products.add(pid)
# Filter unassigned products
unassigned_products: list[ifcopenshell.entity_instance] = []
for product in all_products:
try:
pid = product.id()
except Exception:
pid = None
if pid and pid not in schedule_products:
# Check that it is not a spatial element
try:
is_spatial = tool.Root.is_spatial_element(product)
except Exception:
is_spatial = False
if not is_spatial:
unassigned_products.append(product)
if not unassigned_products:
return "No unassigned products found"
# Select unassigned products
tool.Spatial.select_products(unassigned_products)
return f"Selected {len(unassigned_products)} unassigned products"
except Exception as e:
print(f"Error selecting unassigned products: {e}")
return f"Error selecting unassigned products: {str(e)}"
@classmethod
def get_direct_nested_tasks(cls, task: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]:
return ifcopenshell.util.sequence.get_nested_tasks(task)
@classmethod
def get_tasks_for_product(cls, product, work_schedule=None):
"""
Gets the input and output tasks for a specific product.
Args:
product: The IFC product
work_schedule: The work schedule (optional)
Returns:
tuple: (task_inputs, task_outputs)
"""
try:
# Use existing methods to find related tasks
input_tasks = cls.find_related_input_tasks(product)
output_tasks = cls.find_related_output_tasks(product)
# If work_schedule is provided, filter only the tasks from that schedule
if work_schedule:
# Get all tasks controlled by the work_schedule
controlled_task_ids = set()
for rel in work_schedule.Controls or []:
for obj in rel.RelatedObjects:
if obj.is_a("IfcTask"):
controlled_task_ids.add(obj.id())
# Filter input tasks
filtered_input_tasks = []
for task in input_tasks:
if task.id() in controlled_task_ids:
filtered_input_tasks.append(task)
# Filter output tasks
filtered_output_tasks = []
for task in output_tasks:
if task.id() in controlled_task_ids:
filtered_output_tasks.append(task)
return filtered_input_tasks, filtered_output_tasks
return input_tasks, output_tasks
except Exception as e:
print(f"Error en get_tasks_for_product: {e}")
return [], []
@classmethod
def load_product_related_tasks(cls, product):
"""
Loads tasks related to a product and displays them in the UI.
Args:
product: The IFC product for which to search for tasks
Returns:
str: Result message or list of tasks
"""
try:
props = tool.Sequence.get_work_schedule_props()
# Get the active work_schedule if it exists
active_work_schedule = None
if props.active_work_schedule_id:
active_work_schedule = tool.Ifc.get().by_id(props.active_work_schedule_id)
# Call the method with the work_schedule
task_inputs, task_outputs = cls.get_tasks_for_product(product, active_work_schedule)
# Clear existing lists
props.product_input_tasks.clear()
props.product_output_tasks.clear()
# Load input tasks
for task in task_inputs:
new_input = props.product_input_tasks.add()
new_input.ifc_definition_id = task.id()
new_input.name = task.Name or "Unnamed"
# Load output tasks
for task in task_outputs:
new_output = props.product_output_tasks.add()
new_output.ifc_definition_id = task.id()
new_output.name = task.Name or "Unnamed"
total_tasks = len(task_inputs) + len(task_outputs)
if total_tasks == 0:
return "No related tasks found for this product"
return f"Found {len(task_inputs)} input tasks and {len(task_outputs)} output tasks"
except Exception as e:
print(f"Error in load_product_related_tasks: {e}")
return f"Error loading tasks: {str(e)}"
@@ -0,0 +1,91 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import Any, Union, Literal, TYPE_CHECKING
import ifcopenshell
import ifcopenshell.util.date
import bonsai.bim.helper
import bonsai.tool as tool
from .props_sequence import PropsSequence
if TYPE_CHECKING:
from bonsai.bim.prop import Attribute
class SequenceRelationsSequence:
"""Mixin class for managing IfcRelSequence relationships between tasks."""
@classmethod
def enable_editing_task_sequence(cls) -> None:
props = tool.Sequence.get_work_schedule_props()
props.editing_task_type = "SEQUENCE"
@classmethod
def load_rel_sequence_attributes(cls, rel_sequence: ifcopenshell.entity_instance) -> None:
props = tool.Sequence.get_work_schedule_props()
props.sequence_attributes.clear()
bonsai.bim.helper.import_attributes(rel_sequence, props.sequence_attributes)
@classmethod
def enable_editing_rel_sequence_attributes(cls, rel_sequence: ifcopenshell.entity_instance) -> None:
props = tool.Sequence.get_work_schedule_props()
props.active_sequence_id = rel_sequence.id()
props.editing_sequence_type = "ATTRIBUTES"
@classmethod
def load_lag_time_attributes(cls, lag_time: ifcopenshell.entity_instance) -> None:
props = tool.Sequence.get_work_schedule_props()
def callback(name: str, prop: Union[Attribute, None], data: dict[str, Any]) -> None | Literal[True]:
if name == "LagValue":
prop = props.lag_time_attributes.add()
prop.name = name
prop.is_null = data[name] is None
prop.is_optional = False
prop.data_type = "string"
prop.string_value = (
"" if prop.is_null else ifcopenshell.util.date.datetime2ifc(data[name].wrappedValue, "IfcDuration")
)
return True
props.lag_time_attributes.clear()
bonsai.bim.helper.import_attributes(lag_time, props.lag_time_attributes, callback)
@classmethod
def enable_editing_sequence_lag_time(cls, rel_sequence: ifcopenshell.entity_instance) -> None:
props = tool.Sequence.get_work_schedule_props()
props.active_sequence_id = rel_sequence.id()
props.editing_sequence_type = "LAG_TIME"
@classmethod
def get_rel_sequence_attributes(cls) -> dict[str, Any]:
props = tool.Sequence.get_work_schedule_props()
return bonsai.bim.helper.export_attributes(props.sequence_attributes)
@classmethod
def disable_editing_rel_sequence(cls) -> None:
props = tool.Sequence.get_work_schedule_props()
props.active_sequence_id = 0
@classmethod
def get_lag_time_attributes(cls) -> dict[str, Any]:
props = tool.Sequence.get_work_schedule_props()
return bonsai.bim.helper.export_attributes(props.lag_time_attributes)
@@ -0,0 +1,449 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import bpy
import json
from datetime import datetime
from typing import Any, List
import ifcopenshell
import bonsai.tool as tool
# Try to import SequenceCache for optimization, fallback if not available
try:
from bonsai.bim.module.sequence.data import SequenceCache
except ImportError:
try:
import bonsai.data
SequenceCache = bonsai.data.SequenceCache
except ImportError:
# Fallback: Create a dummy SequenceCache that returns None
class SequenceCache:
@staticmethod
def get_vectorized_task_states(*args, **kwargs):
return None
@staticmethod
def get_schedule_dates(*args, **kwargs):
return None
@staticmethod
def get_task_products(*args, **kwargs):
return None
class SnapshotSequence:
"""Mixin class for processing and displaying construction state snapshots."""
to_build = set()
in_construction = set()
completed = set()
to_demolish = set()
in_demolition = set()
demolished = set()
@classmethod
def process_construction_state(
cls,
work_schedule: ifcopenshell.entity_instance,
date: datetime,
viz_start: datetime = None,
viz_finish: datetime = None,
date_source: str = "SCHEDULE") -> dict[str, Any]:
"""
OPTIMIZED: Processes states considering the configured visualization range.
Now uses cached data structures for massive performance improvement.
Args:
work_schedule: Work schedule
date: Current date of the snapshot
viz_start: Visualization start date (optional)
date_source: The type of date to use ('SCHEDULE', 'ACTUAL', etc.)
viz_finish: Visualization end date (optional)
"""
# Initialize state sets
cls.to_build = set()
cls.in_construction = set()
cls.completed = set()
cls.to_demolish = set()
cls.in_demolition = set()
cls.demolished = set()
# Try NumPy vectorized computation with fallbacks
work_schedule_id = work_schedule.id()
try:
vectorized_result = SequenceCache.get_vectorized_task_states(
work_schedule_id, date, date_source, viz_start, viz_finish
)
if vectorized_result and vectorized_result.get('vectorized'):
cls.to_build = vectorized_result["TO_BUILD"]
cls.in_construction = vectorized_result["IN_CONSTRUCTION"]
cls.completed = vectorized_result["COMPLETED"]
cls.to_demolish = vectorized_result.get("TO_DEMOLISH", set())
cls.in_demolition = vectorized_result.get("IN_DEMOLITION", set())
cls.demolished = vectorized_result.get("DEMOLISHED", set())
return
except Exception:
pass
try:
cached_dates = SequenceCache.get_schedule_dates(work_schedule_id, date_source)
cached_products = SequenceCache.get_task_products(work_schedule_id)
if cached_dates and cached_products:
tasks_data = cached_dates.get('tasks_dates', [])
for task_id, start_date, finish_date in tasks_data:
if not start_date or not finish_date:
continue
if viz_start and viz_finish:
if finish_date < viz_start:
cls.completed.update(cached_products.get(task_id, []))
continue
elif start_date > viz_finish:
continue
if start_date > date:
cls.to_build.update(cached_products.get(task_id, []))
elif finish_date < date:
cls.completed.update(cached_products.get(task_id, []))
else:
cls.in_construction.update(cached_products.get(task_id, []))
return
except Exception:
pass
for rel in work_schedule.Controls or []:
for related_object in rel.RelatedObjects:
if related_object.is_a("IfcTask"):
cls.process_task_status(related_object, date, viz_start, viz_finish, date_source=date_source)
return {
"TO_BUILD": cls.to_build,
"IN_CONSTRUCTION": cls.in_construction,
"COMPLETED": cls.completed,
"TO_DEMOLISH": cls.to_demolish,
"IN_DEMOLITION": cls.in_demolition,
"DEMOLISHED": cls.demolished,
}
@classmethod
def _process_task_status_cached(
cls,
task_id: int,
product_ids: List[int],
start_date: datetime,
finish_date: datetime,
current_date: datetime,
viz_start: datetime = None,
viz_finish: datetime = None
):
"""Fast version of task status processing using cached data."""
if not product_ids:
return
if viz_start and finish_date < viz_start:
cls.completed.update(product_ids)
return
if viz_finish and start_date > viz_finish:
return
if current_date < start_date:
cls.to_build.update(product_ids)
elif start_date <= current_date <= finish_date:
cls.in_construction.update(product_ids)
else:
cls.completed.update(product_ids)
@classmethod
def process_task_status(
cls,
task: ifcopenshell.entity_instance,
date: datetime,
viz_start: datetime = None,
viz_finish: datetime = None,
date_source: str = "SCHEDULE"
) -> None:
"""Process a task's state considering the visualization range."""
for rel in task.IsNestedBy or []:
[cls.process_task_status(related_object, date, viz_start, viz_finish, date_source=date_source) for related_object in rel.RelatedObjects]
start_date_type = f"{date_source.capitalize()}Start"
finish_date_type = f"{date_source.capitalize()}Finish"
start = ifcopenshell.util.sequence.derive_date(task, start_date_type, is_earliest=True)
finish = ifcopenshell.util.sequence.derive_date(task, finish_date_type, is_latest=True)
if not start or not finish:
return
outputs = ifcopenshell.util.sequence.get_task_outputs(task) or []
inputs = tool.Sequence.get_task_inputs(task) or []
if viz_finish and start > viz_finish:
return
if viz_start and finish < viz_start:
[cls.completed.add(tool.Ifc.get_object(output)) for output in outputs]
[cls.demolished.add(tool.Ifc.get_object(input)) for input in inputs]
return
if date < start:
[cls.to_build.add(tool.Ifc.get_object(output)) for output in outputs]
[cls.to_demolish.add(tool.Ifc.get_object(input)) for input in inputs]
elif date <= finish:
[cls.in_construction.add(tool.Ifc.get_object(output)) for output in outputs]
[cls.in_demolition.add(tool.Ifc.get_object(input)) for input in inputs]
else:
[cls.completed.add(tool.Ifc.get_object(output)) for output in outputs]
[cls.demolished.add(tool.Ifc.get_object(input)) for input in inputs]
@classmethod
def show_snapshot(cls, product_states):
"""Display a visual snapshot of all IFC objects at the specified date."""
import bpy
import json
if not bpy.context.scene.get('BIM_VarianceOriginalObjectColors'):
cls._save_original_object_colors()
original_properties = {}
for obj in bpy.data.objects:
if obj.type == 'MESH' and tool.Ifc.get_entity(obj):
original_color = None
try:
if obj.material_slots and obj.material_slots[0].material:
material = obj.material_slots[0].material
if material.use_nodes:
principled = tool.Blender.get_material_node(material, "BSDF_PRINCIPLED")
if principled and principled.inputs.get("Base Color"):
base_color = principled.inputs["Base Color"].default_value
original_color = [base_color[0], base_color[1], base_color[2], base_color[3]]
except Exception:
pass
if original_color is None:
original_color = list(obj.color)
original_properties[obj.name] = {
"color": original_color,
"hide_viewport": obj.hide_viewport,
"hide_render": obj.hide_render,
}
bpy.context.scene['bonsai_snapshot_original_props'] = json.dumps(original_properties)
for obj in bpy.data.objects:
if getattr(obj, "animation_data", None):
obj.animation_data_clear()
ws_props = tool.Sequence.get_work_schedule_props()
snapshot_date_str = getattr(ws_props, "visualisation_start", None)
if not snapshot_date_str or snapshot_date_str == "-":
return
try:
snapshot_date = tool.Sequence.parse_isodate_datetime(snapshot_date_str)
except Exception:
return
date_source = getattr(ws_props, "date_source_type", "SCHEDULE")
anim_props = tool.Sequence.get_animation_props()
active_group_name = None
for item in anim_props.animation_group_stack:
if item.enabled and item.group:
active_group_name = item.group
break
if not active_group_name:
active_group_name = "DEFAULT"
applied_count = 0
for obj in bpy.data.objects:
element = tool.Ifc.get_entity(obj)
if not element or not obj.type == 'MESH':
continue
task = cls.get_task_for_product(element)
if not task:
obj.hide_viewport = True
obj.hide_render = True
continue
start_attr = f"{date_source.capitalize()}Start"
finish_attr = f"{date_source.capitalize()}Finish"
task_start = ifcopenshell.util.sequence.derive_date(task, start_attr, is_earliest=True)
task_finish = ifcopenshell.util.sequence.derive_date(task, finish_attr, is_latest=True)
if not task_start or not task_finish:
obj.hide_viewport = True
obj.hide_render = True
continue
state = ""
if snapshot_date < task_start:
state = "start"
elif task_start <= snapshot_date <= task_finish:
state = "in_progress"
else:
state = "end"
ColorType = tool.Sequence.get_assigned_ColorType_for_task(task, anim_props, active_group_name)
original_color = original_properties.get(obj.name, {}).get("color", [1,1,1,1])
consider_start = getattr(ColorType, 'consider_start', False)
consider_active = getattr(ColorType, 'consider_active', True)
consider_end = getattr(ColorType, 'consider_end', True)
is_priority_mode = (consider_start and not consider_active and not consider_end)
is_demolition = (getattr(task, "PredefinedType", "") or "").upper() in {"DEMOLITION", "REMOVAL", "DISPOSAL", "DISMANTLE"}
is_construction = not is_demolition
if is_priority_mode:
obj.hide_viewport = False
state = "start"
else:
if state == "start":
if consider_start:
obj.hide_viewport = False
else:
if is_construction:
obj.hide_viewport = True
else:
obj.hide_viewport = False
elif state == "in_progress" and not consider_active:
obj.hide_viewport = True
elif state == "end":
if not consider_end:
obj.hide_viewport = True
elif getattr(ColorType, 'hide_at_end', False):
obj.hide_viewport = True
else:
obj.hide_viewport = False
else:
if is_demolition:
if state == "start":
obj.hide_viewport = False
elif state == "in_progress":
obj.hide_viewport = False
else:
obj.hide_viewport = True
else:
if state == "start":
obj.hide_viewport = True
else:
obj.hide_viewport = False
if not obj.hide_viewport:
color_to_apply = original_color
transparency = 0.0
if state == "start":
if not getattr(ColorType, 'use_start_original_color', False):
color_to_apply = list(ColorType.start_color)
transparency = getattr(ColorType, 'start_transparency', 0.0)
elif state == "in_progress":
if not getattr(ColorType, 'use_active_original_color', False):
color_to_apply = list(ColorType.in_progress_color)
transparency = getattr(ColorType, 'active_start_transparency', 0.0)
else:
if getattr(ColorType, 'hide_at_end', False):
obj.hide_viewport = True
elif not getattr(ColorType, 'use_end_original_color', True):
color_to_apply = list(ColorType.end_color)
transparency = getattr(ColorType, 'end_transparency', 0.0)
if not obj.hide_viewport:
alpha = 1.0 - transparency
color_rgb = list(color_to_apply[:3])
while len(color_rgb) < 3:
color_rgb.append(1.0)
obj.color = (color_rgb[0], color_rgb[1], color_rgb[2], alpha)
obj.hide_render = obj.hide_viewport
applied_count += 1
cls.set_object_shading()
@classmethod
def get_task_for_product(cls, product):
"""Get the task associated with an IFC product."""
element = tool.Ifc.get_entity(product) if hasattr(product, 'name') else product
if not element:
return None
# Search in outputs
for rel in element.ReferencedBy or []:
if rel.is_a("IfcRelAssignsToProduct"):
for task in rel.RelatedObjects:
if task.is_a("IfcTask"):
return task
# Search in inputs
for rel in element.HasAssignments or []:
if rel.is_a("IfcRelAssignsToProcess"):
task = rel.RelatingProcess
if task.is_a("IfcTask"):
return task
return None
@classmethod
def _save_original_object_colors(cls):
"""Save original object colors for restoration."""
import bpy
import json
original_properties = {}
for obj in bpy.data.objects:
if obj.type == 'MESH' and tool.Ifc.get_entity(obj):
original_color = None
try:
if obj.material_slots and obj.material_slots[0].material:
material = obj.material_slots[0].material
if material.use_nodes:
principled = tool.Blender.get_material_node(material, "BSDF_PRINCIPLED")
if principled and principled.inputs.get("Base Color"):
base_color = principled.inputs["Base Color"].default_value
original_color = [base_color[0], base_color[1], base_color[2], base_color[3]]
except Exception:
pass
if original_color is None:
original_color = list(obj.color)
original_properties[obj.name] = {
"color": original_color,
"hide_viewport": obj.hide_viewport,
"hide_render": obj.hide_render,
}
bpy.context.scene['BIM_VarianceOriginalObjectColors'] = json.dumps(original_properties)
@classmethod
def set_object_shading(cls):
"""Set proper object shading for visualization."""
import bpy
for area in bpy.context.screen.areas:
if area.type == 'VIEW_3D':
for space in area.spaces:
if space.type == 'VIEW_3D':
space.shading.type = 'SOLID'
space.shading.color_type = 'OBJECT'
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,146 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021-2023 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import bpy
from datetime import datetime
from typing import Any, Union, Literal, TYPE_CHECKING
import ifcopenshell
import bonsai.bim.helper
import bonsai.tool as tool
from .props_sequence import PropsSequence
if TYPE_CHECKING:
from bonsai.bim.prop import Attribute
class TaskAttributesSequence:
"""Mixin class for managing attributes of individual IfcTask and IfcTaskTime entities."""
@classmethod
def get_task_attribute_value(cls, attribute_name: str) -> Any:
props = tool.Sequence.get_work_schedule_props()
return props.task_attributes[attribute_name].get_value()
@classmethod
def get_active_task(cls) -> ifcopenshell.entity_instance:
props = tool.Sequence.get_work_schedule_props()
return tool.Ifc.get().by_id(props.active_task_id)
@classmethod
def get_task_time(cls, task: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]:
return task.TaskTime or None
@classmethod
def load_task_attributes(cls, task: ifcopenshell.entity_instance) -> None:
props = tool.Sequence.get_work_schedule_props()
props.task_attributes.clear()
bonsai.bim.helper.import_attributes(task, props.task_attributes)
@classmethod
def enable_editing_task_attributes(cls, task: ifcopenshell.entity_instance) -> None:
props = tool.Sequence.get_work_schedule_props()
props.active_task_id = task.id()
props.editing_task_type = "ATTRIBUTES"
@classmethod
def get_task_attributes(cls) -> dict[str, Any]:
props = tool.Sequence.get_work_schedule_props()
return bonsai.bim.helper.export_attributes(props.task_attributes)
@classmethod
def load_task_time_attributes(cls, task_time: ifcopenshell.entity_instance) -> None:
props = tool.Sequence.get_work_schedule_props()
schema = tool.Ifc.schema()
entity = schema.declaration_by_name("IfcTaskTime").as_entity()
assert entity
def callback(name: str, prop: Union[Attribute, None], data: dict[str, Any]) -> Union[bool, None]:
attr = entity.attribute_by_index(entity.attribute_index(name))
if attr.type_of_attribute()._is("IfcDuration"):
assert prop
cls.add_duration_prop(prop, data[name])
if isinstance(data[name], datetime):
assert prop
prop.string_value = "" if prop.is_null else data[name].isoformat()
return True
props.task_time_attributes.clear()
props.durations_attributes.clear()
bonsai.bim.helper.import_attributes(task_time, props.task_time_attributes, callback)
@classmethod
def enable_editing_task_time(cls, task: ifcopenshell.entity_instance) -> None:
props = tool.Sequence.get_work_schedule_props()
props.active_task_id = task.id()
props.active_task_time_id = task.TaskTime.id()
props.editing_task_type = "TASKTIME"
@classmethod
def disable_editing_task(cls) -> None:
props = tool.Sequence.get_work_schedule_props()
props.active_task_id = 0
props.active_task_time_id = 0
props.editing_task_type = ""
@classmethod
def get_task_time_attributes(cls) -> dict[str, Any]:
import bonsai.bim.module.sequence.utils.helper_utils as helper
def callback(attributes: dict[str, Any], prop: Attribute) -> bool:
if "Start" in prop.name or "Finish" in prop.name or prop.name == "StatusTime":
if prop.is_null:
attributes[prop.name] = None
return True
attributes[prop.name] = helper.parse_datetime(prop.string_value)
return True
elif prop.special_type == "DURATION":
return cls.export_duration_prop(prop, attributes)
return False
props = tool.Sequence.get_work_schedule_props()
return bonsai.bim.helper.export_attributes(props.task_time_attributes, callback)
@classmethod
def add_duration_prop(cls, prop: Attribute, duration_value: Union[str, None]) -> None:
import bonsai.bim.module.sequence.utils.helper_utils as helper
props = tool.Sequence.get_work_schedule_props()
prop.special_type = "DURATION"
duration_props = props.durations_attributes.add()
duration_props.name = prop.name
if duration_value is None:
return
for key, value in helper.parse_duration_as_blender_props(duration_value).items():
setattr(duration_props, key, value)
@classmethod
def export_duration_prop(cls, prop: Attribute, out_attributes: dict[str, Any]) -> Literal[True]:
import bonsai.bim.module.sequence.utils.helper_utils as helper
props = tool.Sequence.get_work_schedule_props()
if prop.is_null:
out_attributes[prop.name] = None
else:
duration_type = out_attributes["DurationType"] if "DurationType" in out_attributes else None
time_split_iso_duration = helper.blender_props_to_iso_duration(
props.durations_attributes, duration_type, prop.name
)
out_attributes[prop.name] = time_split_iso_duration
return True
@@ -0,0 +1,423 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import bpy
import json
import mathutils
import ifcopenshell
import bonsai.tool as tool
from .props_sequence import PropsSequence
from .date_utils_sequence import DateUtilsSequence
class TaskBarsSequence:
"""Mixin class for managing 3D task bars visualization."""
@classmethod
def get_task_bar_list(cls) -> list[int]:
"""
Gets the list of task IDs that should display a visual bar.
Returns a list of task IDs.
"""
props = tool.Sequence.get_work_schedule_props()
try:
task_bars = json.loads(props.task_bars)
return task_bars if isinstance(task_bars, list) else []
except Exception:
return []
@classmethod
def add_task_bar(cls, task_id: int) -> None:
"""Adds a task to the list of visual bars."""
props = tool.Sequence.get_work_schedule_props()
try:
task_bars = json.loads(props.task_bars)
except Exception:
task_bars = []
if task_id not in task_bars:
task_bars.append(task_id)
props.task_bars = json.dumps(task_bars)
@classmethod
def remove_task_bar(cls, task_id: int) -> None:
"""Removes a task from the list of visual bars."""
props = tool.Sequence.get_work_schedule_props()
try:
task_bars = json.loads(props.task_bars)
except Exception:
task_bars = []
if task_id in task_bars:
task_bars.remove(task_id)
props.task_bars = json.dumps(task_bars)
@classmethod
def get_animation_bar_tasks(cls) -> list:
"""Gets the IFC tasks that have visual bars enabled."""
task_ids = cls.get_task_bar_list()
tasks = []
ifc_file = tool.Ifc.get()
if ifc_file:
for task_id in task_ids:
try:
task = ifc_file.by_id(task_id)
if task and cls.validate_task_object(task, "get_animation_bar_tasks"):
tasks.append(task)
except Exception as e:
pass
return tasks
@classmethod
def refresh_task_bars(cls) -> None:
"""Updates the visualization of task bars in the viewport."""
try:
# Check if animation is active for safer operations
try:
anim_props = tool.Sequence.get_animation_props()
is_animation_active = getattr(anim_props, 'is_animation_created', False)
if is_animation_active:
# Continue but with more care.
pass
except Exception:
pass # If it cannot be verified, continue normally.
tasks = cls.get_animation_bar_tasks()
if not tasks:
if "Bar Visual" in bpy.data.collections:
collection = bpy.data.collections["Bar Visual"]
for obj in list(collection.objects):
bpy.data.objects.remove(obj)
return
cls.create_bars(tasks)
except Exception:
# Do not re-raise to avoid crashes - just log the error.
import traceback
traceback.print_exc()
@classmethod
def clear_task_bars(cls) -> None:
"""Clears and removes all 3D task bars and resets the state in the UI."""
import bpy
# 1. Clear the list of tasks marked to have bars.
props = tool.Sequence.get_work_schedule_props()
props.task_bars = "[]" # Reset to an empty JSON list.
# 2. Uncheck all checkboxes in the user interface.
tprops = tool.Sequence.get_task_tree_props()
for task in getattr(tprops, "tasks", []):
if getattr(task, "has_bar_visual", False):
task.has_bar_visual = False
# 3. Remove the 3D objects collection from the bars.
collection_name = "Bar Visual"
if collection_name in bpy.data.collections:
collection = bpy.data.collections[collection_name]
# Remove all objects within the collection.
for obj in list(collection.objects):
bpy.data.objects.remove(obj, do_unlink=True)
# Remove the empty collection.
bpy.data.collections.remove(collection)
@classmethod
def create_bars(cls, tasks):
full_bar_thickness = 0.2
size = 1.0
vertical_spacing = 3.5
vertical_increment = 0
size_to_duration_ratio = 1 / 30
margin = 0.2
# Filter invalid tasks before any use.
if tasks:
_valid = []
for _t in tasks:
if cls.validate_task_object(_t, "create_bars"):
_valid.append(_t)
else:
pass
if not _valid:
return
tasks = _valid
else:
print("[WARNING] Warning: No tasks provided to create_bars")
return
def process_task_data(task, settings):
# Verify valid task.
if not cls.validate_task_object(task, "process_task_data"):
return None
try:
task_start_date = ifcopenshell.util.sequence.derive_date(task, "ScheduleStart", is_earliest=True)
finish_date = ifcopenshell.util.sequence.derive_date(task, "ScheduleFinish", is_latest=True)
except Exception as e:
print(f"[WARNING] Error deriving dates for task {getattr(task, 'Name', 'Unknown')}: {e}")
return None
if not (task_start_date and finish_date):
print(f"[WARNING] Warning: Task {getattr(task, 'Name', 'Unknown')} has no valid dates")
return None
try:
# Use the schedule dates for calculations.
schedule_start = settings["viz_start"]
schedule_finish = settings["viz_finish"]
schedule_duration = schedule_finish - schedule_start
if schedule_duration.total_seconds() <= 0:
print(f"[WARNING] Invalid schedule duration: {schedule_duration}")
return None
total_frames = settings["end_frame"] - settings["start_frame"]
# Calculate task position within the full schedule.
task_start_progress = (task_start_date - schedule_start).total_seconds() / schedule_duration.total_seconds()
task_finish_progress = (finish_date - schedule_start).total_seconds() / schedule_duration.total_seconds()
# Convert to frames.
task_start_frame = round(settings["start_frame"] + (task_start_progress * total_frames))
task_finish_frame = round(settings["start_frame"] + (task_finish_progress * total_frames))
# Validate that frames are in valid range.
task_start_frame = max(settings["start_frame"], min(settings["end_frame"], task_start_frame))
task_finish_frame = max(settings["start_frame"], min(settings["end_frame"], task_finish_frame))
return {
"name": getattr(task, "Name", "Unnamed"),
"start_date": task_start_date,
"finish_date": finish_date,
"start_frame": task_start_frame,
"finish_frame": task_finish_frame,
}
except Exception as e:
print(f"[WARNING] Error calculating frames for task {getattr(task, 'Name', 'Unknown')}: {e}")
return None
def create_task_bar_data(tasks, vertical_increment, collection):
# Use active schedule dates, NOT visualization dates.
schedule_start, schedule_finish = cls.get_schedule_date_range()
if not (schedule_start and schedule_finish):
# Fallback: if there are no schedule dates, show message and abort.
print("[ERROR] Cannot create Task Bars: schedule dates not available")
return None
settings = {
# Use schedule dates instead of visualization.
"viz_start": schedule_start,
"viz_finish": schedule_finish,
"start_frame": bpy.context.scene.frame_start,
"end_frame": bpy.context.scene.frame_end,
}
print(f"🎯 Task Bars using schedule dates:")
print(f" Schedule Start: {schedule_start.strftime('%Y-%m-%d')}")
print(f" Schedule Finish: {schedule_finish.strftime('%Y-%m-%d')}")
print(f" Timeline: frames {settings['start_frame']} to {settings['end_frame']}")
material_progress, material_full = get_animation_materials()
empty = bpy.data.objects.new("collection_origin", None)
link_collection(empty, collection)
for task in tasks:
task_data = process_task_data(task, settings)
if task_data:
position_shift = task_data["start_frame"] * size_to_duration_ratio
bar_size = (task_data["finish_frame"] - task_data["start_frame"]) * size_to_duration_ratio
anim_props = tool.Sequence.get_animation_props()
color_progress = anim_props.color_progress
bar = add_bar(
material=material_progress,
vertical_increment=vertical_increment,
collection=collection,
parent=empty,
task=task_data,
scale=True,
color=(color_progress[0], color_progress[1], color_progress[2], 1.0),
shift_x=position_shift,
name=task_data["name"] + "/Progress Bar",
)
color_full = anim_props.color_full
bar2 = add_bar(
material=material_full,
vertical_increment=vertical_increment,
parent=empty,
collection=collection,
task=task_data,
color=(color_full[0], color_full[1], color_full[2], 1.0),
shift_x=position_shift,
name=task_data["name"] + "/Full Bar",
)
bar2.color = (color_full[0], color_full[1], color_full[2], 1.0)
bar2.scale = (full_bar_thickness, bar_size, 1)
shift_object(bar2, y=((size + full_bar_thickness) / 2))
start_text = add_text(
task_data["start_date"].strftime("%d/%m/%y"),
0,
"RIGHT",
vertical_increment,
parent=empty,
collection=collection,
)
start_text.name = task_data["name"] + "/Start Date"
shift_object(start_text, x=position_shift - margin, y=-(size + full_bar_thickness))
task_text = add_text(
task_data["name"],
0,
"RIGHT",
vertical_increment,
parent=empty,
collection=collection,
)
task_text.name = task_data["name"] + "/Task Name"
shift_object(task_text, x=position_shift, y=0.2)
finish_text = add_text(
task_data["finish_date"].strftime("%d/%m/%y"),
bar_size,
"LEFT",
vertical_increment,
parent=empty,
collection=collection,
)
finish_text.name = task_data["name"] + "/Finish Date"
shift_object(finish_text, x=position_shift + margin, y=-(size + full_bar_thickness))
vertical_increment += vertical_spacing
return empty.select_set(True) if empty else None
def set_material(name, r, g, b):
material = bpy.data.materials.new(name)
material.use_nodes = True
tool.Blender.get_material_node(material, "BSDF_PRINCIPLED").inputs[0].default_value = (r, g, b, 1.0)
return material
def get_animation_materials():
if "color_progress" in bpy.data.materials:
material_progress = bpy.data.materials["color_progress"]
else:
material_progress = set_material("color_progress", 0.0, 1.0, 0.0)
if "color_full" in bpy.data.materials:
material_full = bpy.data.materials["color_full"]
else:
material_full = set_material("color_full", 1.0, 0.0, 0.0)
return material_progress, material_full
def animate_scale(bar, task):
scale = (1, size_to_duration_ratio, 1)
bar.scale = scale
bar.keyframe_insert(data_path="scale", frame=task["start_frame"])
scale2 = (1, (task["finish_frame"] - task["start_frame"]) * size_to_duration_ratio, 1)
bar.scale = scale2
bar.keyframe_insert(data_path="scale", frame=task["finish_frame"])
def animate_color(bar, task, color):
bar.keyframe_insert(data_path="color", frame=task["start_frame"])
bar.color = color
bar.keyframe_insert(data_path="color", frame=task["start_frame"] + 1)
bar.color = color
def place_bar(bar, vertical_increment):
for vertex in bar.data.vertices:
vertex.co[1] += 0.5
bar.rotation_euler[2] = -1.5708
shift_object(bar, y=-vertical_increment)
def shift_object(obj, x=0.0, y=0.0, z=0.0):
vec = mathutils.Vector((x, y, z))
inv = obj.matrix_world.copy()
inv.invert()
vec_rot = vec @ inv
obj.location = obj.location + vec_rot
def link_collection(obj, collection):
if collection:
collection.objects.link(obj)
if obj.name in bpy.context.scene.collection.objects.keys():
bpy.context.scene.collection.objects.unlink(obj)
return obj
def create_plane(material, collection, vertical_increment):
x = 0.5
y = 0.5
vert = [(-x, -y, 0.0), (x, -y, 0.0), (-x, y, 0.0), (x, y, 0.0)]
fac = [(0, 1, 3, 2)]
mesh = bpy.data.meshes.new("PL")
mesh.from_pydata(vert, [], fac)
obj = bpy.data.objects.new("PL", mesh)
obj.data.materials.append(material)
place_bar(obj, vertical_increment)
link_collection(obj, collection)
return obj
def add_text(text, x_position, align, vertical_increment, parent=None, collection=None):
data = bpy.data.curves.new(type="FONT", name="Timeline")
data.align_x = align
data.align_y = "CENTER"
data.body = text
obj = bpy.data.objects.new(name="Unnamed", object_data=data)
link_collection(obj, collection)
shift_object(obj, x=x_position, y=-(vertical_increment - 1))
if parent:
obj.parent = parent
return obj
def add_bar(
material,
vertical_increment,
parent=None,
collection=None,
task=None,
color=False,
scale=False,
shift_x=None,
name=None,
):
plane = create_plane(material, collection, vertical_increment)
if parent:
plane.parent = parent
if color:
animate_color(plane, task, color)
if scale:
animate_scale(plane, task)
if shift_x:
shift_object(plane, x=shift_x)
if name:
plane.name = name
return plane
if "Bar Visual" in bpy.data.collections:
collection = bpy.data.collections["Bar Visual"]
for obj in collection.objects:
bpy.data.objects.remove(obj)
else:
collection = bpy.data.collections.new("Bar Visual")
bpy.context.scene.collection.children.link(collection)
if tasks:
create_task_bar_data(tasks, vertical_increment, collection)
@@ -0,0 +1,102 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import Union
import ifcopenshell
import ifcopenshell.util.sequence
import bonsai.tool as tool
from .props_sequence import PropsSequence
class TaskIcomSequence:
"""Mixin class for managing task Inputs, Outputs, and Resources."""
@classmethod
def update_task_ICOM(cls, task: Union[ifcopenshell.entity_instance, None]) -> None:
"""Refreshes the ICOM data (Outputs, Inputs, Resources) for the active task panel.
If there is no task, it clears the lists to avoid showing data from a previous task."""
props = tool.Sequence.get_work_schedule_props()
if task:
# Outputs
outputs = tool.Sequence.get_task_outputs(task) or []
cls.load_task_outputs(outputs)
# Inputs
inputs = tool.Sequence.get_task_inputs(task) or []
cls.load_task_inputs(inputs)
# Resources
cls.load_task_resources(task)
else:
props.task_outputs.clear()
props.task_inputs.clear()
props.task_resources.clear()
@classmethod
def load_task_resources(cls, task: ifcopenshell.entity_instance) -> None:
props = tool.Sequence.get_work_schedule_props()
rprops = tool.Resource.get_resource_props()
props.task_resources.clear()
rprops.is_resource_update_enabled = False
for resource in cls.get_task_resources(task) or []:
new = props.task_resources.add()
new.ifc_definition_id = resource.id()
new.name = resource.Name or "Unnamed"
new.schedule_usage = resource.Usage.ScheduleUsage or 0 if resource.Usage else 0
rprops.is_resource_update_enabled = True
@classmethod
def get_task_outputs(cls, task: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]:
props = tool.Sequence.get_work_schedule_props()
is_deep = props.show_nested_outputs
return ifcopenshell.util.sequence.get_task_outputs(task, is_deep)
@classmethod
def get_task_resources(
cls, task: Union[ifcopenshell.entity_instance, None]
) -> Union[list[ifcopenshell.entity_instance], None]:
if not task:
return
props = tool.Sequence.get_work_schedule_props()
is_deep = props.show_nested_resources
return ifcopenshell.util.sequence.get_task_resources(task, is_deep)
@classmethod
def load_task_inputs(cls, inputs: list[ifcopenshell.entity_instance]) -> None:
props = tool.Sequence.get_work_schedule_props()
props.task_inputs.clear()
for input in inputs:
new = props.task_inputs.add()
new.ifc_definition_id = input.id()
new.name = input.Name or "Unnamed"
@classmethod
def load_task_outputs(cls, outputs: list[ifcopenshell.entity_instance]) -> None:
props = tool.Sequence.get_work_schedule_props()
props.task_outputs.clear()
if outputs:
for output in outputs:
new = props.task_outputs.add()
new.ifc_definition_id = output.id()
new.name = output.Name or "Unnamed"
@classmethod
def get_task_inputs(cls, task: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]:
props = tool.Sequence.get_work_schedule_props()
is_deep = props.show_nested_inputs
return ifcopenshell.util.sequence.get_task_inputs(task, is_deep)
@@ -0,0 +1,440 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import bpy
import json
import re
import ifcopenshell
import ifcopenshell.util.sequence
import ifcopenshell.util.date
import bonsai.tool as tool
from typing import Optional
from .props_sequence import PropsSequence
class TaskTreeSequence:
"""Mixin class for managing the task tree UI."""
@classmethod
def load_task_tree(cls, work_schedule: ifcopenshell.entity_instance) -> None:
props = tool.Sequence.get_task_tree_props()
props.tasks.clear()
schedule_props = tool.Sequence.get_work_schedule_props()
cls.contracted_tasks = json.loads(schedule_props.contracted_tasks)
# 1. Get ALL root tasks, as before
root_tasks = ifcopenshell.util.sequence.get_root_tasks(work_schedule)
# 2. APPLY FILTER: Pass the root tasks list to our new filtering function
filtered_root_tasks = cls.get_filtered_tasks(root_tasks)
# 3. Sort only the tasks that passed the filter
related_objects_ids = cls.get_sorted_tasks_ids(filtered_root_tasks)
# 4. Create UI elements only for the filtered and sorted tasks
for related_object_id in related_objects_ids:
cls.create_new_task_li(related_object_id, 0)
# 5. AUTOFIX: Detect and fix variance inconsistencies after task reload
try:
from .variance_sequence import VarianceSequence
VarianceSequence.detect_and_fix_variance_inconsistency()
except Exception as e:
print(f"[WARNING] Could not run variance autofix: {e}")
@classmethod
def get_sorted_tasks_ids(cls, tasks: list[ifcopenshell.entity_instance]) -> list[int]:
props = tool.Sequence.get_work_schedule_props()
def get_sort_key(task):
# Sorting only applies to actual tasks, not the WBS
# for rel in task.IsNestedBy:
# for object in rel.RelatedObjects:
# if object.is_a("IfcTask"):
# return "0000000000" + (task.Identification or "")
column_type, name = props.sort_column.split(".")
if column_type == "IfcTask":
return task.get_info(task)[name] or ""
elif column_type == "IfcTaskTime" and task.TaskTime:
return task.TaskTime.get_info(task)[name] if task.TaskTime.get_info(task)[name] else ""
return task.Identification or ""
def natural_sort_key(i, _nsre=re.compile("([0-9]+)")):
s = sort_keys[i]
return [int(text) if text.isdigit() else text.lower() for text in _nsre.split(s)]
if props.sort_column:
sort_keys = {task.id(): get_sort_key(task) for task in tasks}
related_object_ids = sorted(sort_keys, key=natural_sort_key)
else:
related_object_ids = [task.id() for task in tasks]
if props.is_sort_reversed:
related_object_ids.reverse()
return related_object_ids
@classmethod
def get_filtered_tasks(cls, tasks: list[ifcopenshell.entity_instance]) -> list[ifcopenshell.entity_instance]:
"""
Filters a list of tasks (and their children) based on active rules.
If a parent task doesn't meet the filter, its children won't be shown either.
"""
props = tool.Sequence.get_work_schedule_props()
try:
filter_rules = [r for r in getattr(props, "filters").rules if r.is_active]
except Exception:
return tasks
if not filter_rules:
return tasks
filter_logic_is_and = getattr(props.filters, "logic", 'AND') == 'AND'
def get_task_value(task, column_identifier):
"""Enhanced helper function to get the value of a column for a task."""
if not task or not column_identifier:
return None
column_name = column_identifier.split('||')[0]
if column_name == "Special.OutputsCount":
try:
# Get both outputs and inputs for total Element 3D count
outputs_count = len(ifcopenshell.util.sequence.get_task_outputs(task, is_deep=False))
inputs_count = len(ifcopenshell.util.sequence.get_task_inputs(task, is_deep=False))
return outputs_count + inputs_count
except Exception:
return 0
if column_name in ("Special.VarianceStatus", "Special.VarianceDays"):
ws_props = tool.Sequence.get_work_schedule_props()
source_a = ws_props.variance_source_a
source_b = ws_props.variance_source_b
if source_a == source_b:
return None
finish_attr_a = f"{source_a.capitalize()}Finish"
finish_attr_b = f"{source_b.capitalize()}Finish"
date_a = ifcopenshell.util.sequence.derive_date(task, finish_attr_a, is_latest=True)
date_b = ifcopenshell.util.sequence.derive_date(task, finish_attr_b, is_latest=True)
if date_a and date_b:
delta = date_b.date() - date_a.date()
variance_days = delta.days
if column_name == "Special.VarianceDays":
return variance_days
else: # VarianceStatus
if variance_days > 0:
return f"Delayed (+{variance_days}d)"
elif variance_days < 0:
return f"Ahead ({variance_days}d)"
else:
return "On Time"
return "N/A"
try:
ifc_class, attr_name = column_name.split('.', 1)
if ifc_class == "IfcTask":
return getattr(task, attr_name, None)
elif ifc_class == "IfcTaskTime":
task_time = getattr(task, "TaskTime", None)
return getattr(task_time, attr_name, None) if task_time else None
except Exception:
return None
return None
def task_matches_filters(task):
"""Checks if a single task meets the set of filters."""
results = []
for rule in filter_rules:
task_value = get_task_value(task, rule.column)
data_type = getattr(rule, 'data_type', 'string')
op = rule.operator
match = False
if op == 'EMPTY':
match = task_value is None or str(task_value).strip() == ""
elif op == 'NOT_EMPTY':
match = task_value is not None and str(task_value).strip() != ""
else:
try:
if data_type == 'integer':
rule_value = rule.value_integer
task_value_num = int(task_value)
if op == 'EQUALS': match = task_value_num == rule_value
elif op == 'NOT_EQUALS': match = task_value_num != rule_value
elif op == 'GREATER': match = task_value_num > rule_value
elif op == 'LESS': match = task_value_num < rule_value
elif op == 'GTE': match = task_value_num >= rule_value
elif op == 'LTE': match = task_value_num <= rule_value
elif data_type in ('float', 'real'):
rule_value = rule.value_float
task_value_num = float(task_value)
if op == 'EQUALS': match = task_value_num == rule_value
elif op == 'NOT_EQUALS': match = task_value_num != rule_value
elif op == 'GREATER': match = task_value_num > rule_value
elif op == 'LESS': match = task_value_num < rule_value
elif op == 'GTE': match = task_value_num >= rule_value
elif op == 'LTE': match = task_value_num <= rule_value
elif data_type == 'boolean':
rule_value = bool(rule.value_boolean)
task_value_bool = bool(task_value)
if op == 'EQUALS': match = task_value_bool == rule_value
elif op == 'NOT_EQUALS': match = task_value_bool != rule_value
elif data_type == 'date':
task_date = bonsai.bim.module.sequence.utils.helper_utils.parse_datetime(str(task_value))
rule_date = bonsai.bim.module.sequence.utils.helper_utils.parse_datetime(rule.value_string)
if task_date and rule_date:
if op == 'EQUALS': match = task_date.date() == rule_date.date()
elif op == 'NOT_EQUALS': match = task_date.date() != rule_date.date()
elif op == 'GREATER': match = task_date > rule_date
elif op == 'LESS': match = task_date < rule_date
elif op == 'GTE': match = task_date >= rule_date
elif op == 'LTE': match = task_date <= rule_date
elif data_type == 'variance_status':
# Special handling for variance status filtering
rule_value = rule.value_variance_status
task_value_str = str(task_value) if task_value is not None else ""
if op == 'EQUALS': match = rule_value in task_value_str
elif op == 'NOT_EQUALS': match = rule_value not in task_value_str
elif op == 'CONTAINS': match = rule_value in task_value_str
elif op == 'NOT_CONTAINS': match = rule_value not in task_value_str
else: # string, enums, etc.
rule_value = (rule.value_string or "").lower()
task_value_str = (str(task_value) if task_value is not None else "").lower()
if op == 'CONTAINS': match = rule_value in task_value_str
elif op == 'NOT_CONTAINS': match = rule_value not in task_value_str
elif op == 'EQUALS': match = rule_value == task_value_str
elif op == 'NOT_EQUALS': match = rule_value != task_value_str
except (ValueError, TypeError, AttributeError):
match = False
results.append(match)
if not results:
return True
return all(results) if filter_logic_is_and else any(results)
filtered_list = []
for task in tasks:
nested_tasks = ifcopenshell.util.sequence.get_nested_tasks(task)
filtered_children = cls.get_filtered_tasks(nested_tasks) if nested_tasks else []
if task_matches_filters(task) or len(filtered_children) > 0:
filtered_list.append(task)
return filtered_list
@classmethod
def create_new_task_li(cls, related_object_id: int, level_index: int) -> None:
task = tool.Ifc.get().by_id(related_object_id)
props = tool.Sequence.get_task_tree_props()
new = props.tasks.add()
new.ifc_definition_id = related_object_id
new.is_expanded = related_object_id not in cls.contracted_tasks
new.level_index = level_index
if task.IsNestedBy:
new.has_children = True
if new.is_expanded:
for related_object_id in cls.get_sorted_tasks_ids(ifcopenshell.util.sequence.get_nested_tasks(task)):
cls.create_new_task_li(related_object_id, level_index + 1)
@classmethod
def _load_task_date_properties(cls, item, task, date_type_prefix):
"""Helper to load a pair of dates (e.g., ScheduleStart/Finish) for a task item."""
prop_prefix = date_type_prefix.lower()
start_attr, finish_attr = f"{date_type_prefix}Start", f"{date_type_prefix}Finish"
# Map 'schedule' to the old property names for compatibility
if prop_prefix == "schedule":
item_start, item_finish = "start", "finish"
else:
item_start, item_finish = f"{prop_prefix}_start", f"{prop_prefix}_finish"
derived_start, derived_finish = f"derived_{item_start}", f"derived_{item_finish}"
task_time = getattr(task, "TaskTime", None)
if task_time and (getattr(task_time, start_attr, None) or getattr(task_time, finish_attr, None)):
start_val = getattr(task_time, start_attr, None)
finish_val = getattr(task_time, finish_attr, None)
setattr(item, item_start, ifcopenshell.util.date.canonicalise_time(ifcopenshell.util.date.ifc2datetime(start_val)) if start_val else "-")
setattr(item, item_finish, ifcopenshell.util.date.canonicalise_time(ifcopenshell.util.date.ifc2datetime(finish_val)) if finish_val else "-")
setattr(item, derived_start, "")
setattr(item, derived_finish, "")
else:
d_start = ifcopenshell.util.sequence.derive_date(task, start_attr, is_earliest=True)
d_finish = ifcopenshell.util.sequence.derive_date(task, finish_attr, is_latest=True)
setattr(item, derived_start, ifcopenshell.util.date.canonicalise_time(d_start) if d_start else "")
setattr(item, derived_finish, ifcopenshell.util.date.canonicalise_time(d_finish) if d_finish else "")
setattr(item, item_start, "-")
setattr(item, item_finish, "-")
@classmethod
def load_task_properties(cls, task: Optional[ifcopenshell.entity_instance] = None) -> None:
props = tool.Sequence.get_work_schedule_props()
task_props = tool.Sequence.get_task_tree_props()
tasks_with_visual_bar = cls.get_task_bar_list()
props.is_task_update_enabled = False
for item in task_props.tasks:
task = tool.Ifc.get().by_id(item.ifc_definition_id)
item.name = task.Name or "Unnamed"
item.identification = task.Identification or "XXX"
item.has_bar_visual = item.ifc_definition_id in tasks_with_visual_bar
if props.highlighted_task_id:
item.is_predecessor = props.highlighted_task_id in [
rel.RelatedProcess.id() for rel in task.IsPredecessorTo
]
item.is_successor = props.highlighted_task_id in [
rel.RelatingProcess.id() for rel in task.IsSuccessorFrom
]
calendar = ifcopenshell.util.sequence.derive_calendar(task)
if ifcopenshell.util.sequence.get_calendar(task):
item.calendar = calendar.Name or "Unnamed" if calendar else ""
else:
item.calendar = ""
item.derived_calendar = calendar.Name or "Unnamed" if calendar else ""
# Load all date pairs using the helper
cls._load_task_date_properties(item, task, "Schedule")
cls._load_task_date_properties(item, task, "Actual")
cls._load_task_date_properties(item, task, "Early")
cls._load_task_date_properties(item, task, "Late")
# Duration logic (remains the same, based on Schedule dates)
task_time = task.TaskTime
if task_time and task_time.ScheduleDuration:
item.duration = str(ifcopenshell.util.date.readable_ifc_duration(task_time.ScheduleDuration))
else:
derived_start = ifcopenshell.util.sequence.derive_date(task, "ScheduleStart", is_earliest=True)
derived_finish = ifcopenshell.util.sequence.derive_date(task, "ScheduleFinish", is_latest=True)
if derived_start and derived_finish:
derived_duration = ifcopenshell.util.sequence.count_working_days(
derived_start, derived_finish, calendar
)
item.derived_duration = str(ifcopenshell.util.date.readable_ifc_duration(f"P{derived_duration}D"))
else:
item.derived_duration = ""
item.duration = "-"
# After processing all tasks, refresh the Outputs count so UI stays accurate.
try:
cls.refresh_task_3d_counts()
except Exception:
# Be defensive; never break UI loading if counting fails.
pass
props.is_task_update_enabled = True
@classmethod
def refresh_task_3d_counts(cls) -> None:
"""
Recalculates and saves the total count of 3D elements (Inputs + Outputs)
per task in the UI tree.
"""
try:
tprops = tool.Sequence.get_task_tree_props()
if not hasattr(tprops, "tasks"):
return
except Exception:
return
try:
from bonsai import tool as _tool
import ifcopenshell.util.sequence
except Exception:
return
for item in getattr(tprops, "tasks", []):
try:
task = _tool.Ifc.get().by_id(item.ifc_definition_id)
if not task:
if hasattr(item, "outputs_count"):
item.outputs_count = 0
continue
# Calculate outputs
outputs_count = len(ifcopenshell.util.sequence.get_task_outputs(task, is_deep=False))
# Calculate inputs
inputs_count = len(ifcopenshell.util.sequence.get_task_inputs(task, is_deep=False))
# Save the sum in the 'outputs_count' property
if hasattr(item, "outputs_count"):
item.outputs_count = outputs_count + inputs_count
except Exception:
if hasattr(item, "outputs_count"):
item.outputs_count = 0
continue
@classmethod
def expand_task(cls, task: ifcopenshell.entity_instance) -> None:
props = tool.Sequence.get_work_schedule_props()
contracted_tasks = json.loads(props.contracted_tasks)
contracted_tasks.remove(task.id())
props.contracted_tasks = json.dumps(contracted_tasks)
@classmethod
def expand_all_tasks(cls) -> None:
props = tool.Sequence.get_work_schedule_props()
props.contracted_tasks = json.dumps([])
@classmethod
def contract_all_tasks(cls) -> None:
props = tool.Sequence.get_work_schedule_props()
tprops = tool.Sequence.get_task_tree_props()
contracted_tasks = json.loads(props.contracted_tasks)
for task_item in tprops.tasks:
if task_item.is_expanded:
contracted_tasks.append(task_item.ifc_definition_id)
props.contracted_tasks = json.dumps(contracted_tasks)
@classmethod
def contract_task(cls, task: ifcopenshell.entity_instance) -> None:
props = tool.Sequence.get_work_schedule_props()
contracted_tasks = json.loads(props.contracted_tasks)
contracted_tasks.append(task.id())
props.contracted_tasks = json.dumps(contracted_tasks)
@classmethod
def go_to_task(cls, task):
props = tool.Sequence.get_work_schedule_props()
def get_ancestor_ids(task):
ids = []
for rel in task.Nests or []:
ids.append(rel.RelatingObject.id())
ids.extend(get_ancestor_ids(rel.RelatingObject))
return ids
contracted_tasks = json.loads(props.contracted_tasks)
for ancestor_id in get_ancestor_ids(task):
if ancestor_id in contracted_tasks:
contracted_tasks.remove(ancestor_id)
props.contracted_tasks = json.dumps(contracted_tasks)
work_schedule = cls.get_active_work_schedule()
cls.load_task_tree(work_schedule)
cls.load_task_properties()
task_props = tool.Sequence.get_task_tree_props()
expanded_tasks = [item.ifc_definition_id for item in task_props.tasks]
props.active_task_index = expanded_tasks.index(task.id()) or 0
@@ -0,0 +1,691 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import bpy
from datetime import timedelta, datetime
from .props_sequence import PropsSequence
# Import the date utility class that we will create next
from .date_utils_sequence import DateUtilsSequence
import bonsai.tool as tool
class TextSequence:
"""Mixin class for creating and managing 3D schedule texts."""
_frame_change_handler = None
@classmethod
def _create_basic_snapshot_texts(cls, schedule_name):
"""Creates basic 3D texts manually when animation settings are not available."""
import bpy
# Create or get collection
collection_name = "Schedule_Display_Texts"
if collection_name not in bpy.data.collections:
collection = bpy.data.collections.new(collection_name)
try:
bpy.context.scene.collection.children.link(collection)
except Exception:
pass
else:
collection = bpy.data.collections[collection_name]
# Basic text configurations for snapshot
text_configs = [
{"name": "Schedule_Name", "position": (0, 10, 6), "content": f"Schedule: {schedule_name}", "type": "schedule_name"},
{"name": "Schedule_Date", "position": (0, 10, 5), "content": "Date: [Dynamic]", "type": "date"},
{"name": "Schedule_Week", "position": (0, 10, 4), "content": "Week: [Dynamic]", "type": "week"},
{"name": "Schedule_Day_Counter", "position": (0, 10, 3), "content": "Day: [Dynamic]", "type": "day_counter"},
{"name": "Schedule_Progress", "position": (0, 10, 2), "content": "Progress: [Dynamic]", "type": "progress"},
]
for config in text_configs:
try:
# Create text data
text_data = bpy.data.curves.new(name=config["name"], type='FONT')
text_obj = bpy.data.objects.new(name=config["name"], object_data=text_data)
# Set content and properties
text_data.body = config["content"]
text_data['text_type'] = config["type"]
# Set alignment for consistent positioning
if hasattr(text_data, 'align_x'):
text_data.align_x = 'CENTER'
if hasattr(text_data, 'align_y'):
text_data.align_y = 'BOTTOM_BASELINE'
# Position the text
text_obj.location = config["position"]
# Add to collection
collection.objects.link(text_obj)
except Exception as e:
pass
@classmethod
def add_text_animation_handler(cls, settings):
"""Creates multiple animated text objects to display schedule information"""
from datetime import timedelta
collection_name = "Schedule_Display_Texts"
if collection_name in bpy.data.collections:
collection = bpy.data.collections[collection_name]
for obj in list(collection.objects):
try:
bpy.data.objects.remove(obj, do_unlink=True)
except Exception:
pass
else:
collection = bpy.data.collections.new(collection_name)
try:
bpy.context.scene.collection.children.link(collection)
except Exception:
pass
schedule_name = "Unknown Schedule"
try:
import bonsai.tool as tool
ws_props = tool.Sequence.get_work_schedule_props()
if ws_props and hasattr(ws_props, 'active_work_schedule_id'):
ws_id = ws_props.active_work_schedule_id
if ws_id:
work_schedule = tool.Ifc.get().by_id(ws_id)
if work_schedule and hasattr(work_schedule, 'Name'):
schedule_name = work_schedule.Name or "Unnamed Schedule"
except Exception:
pass
text_configs = [
{"name": "Schedule_Name", "position": (0, 10, 6), "size": 1.4, "align": "CENTER", "color": (1, 1, 1, 1), "type": "schedule_name", "content": f"Schedule: {schedule_name}"},
{"name": "Schedule_Date", "position": (0, 10, 5), "size": 1.2, "align": "CENTER", "color": (1, 1, 1, 1), "type": "date"},
{"name": "Schedule_Week", "position": (0, 10, 4), "size": 1.0, "align": "CENTER", "color": (1, 1, 1, 1), "type": "week"},
{"name": "Schedule_Day_Counter", "position": (0, 10, 3), "size": 0.8, "align": "CENTER", "color": (1, 1, 1, 1), "type": "day_counter"},
{"name": "Schedule_Progress", "position": (0, 10, 2), "size": 1.0, "align": "CENTER", "color": (1, 1, 1, 1), "type": "progress"},
]
created_texts = []
for config in text_configs:
try:
text_obj = cls._create_animated_text(config, settings, collection)
created_texts.append(text_obj)
except Exception:
pass
try:
scene = bpy.context.scene
if scene.camera and "4D_Animation_Camera" in scene.camera.name:
anim_props = tool.Sequence.get_animation_props()
camera_props = anim_props.camera_orbit
if not getattr(camera_props, "enable_text_hud", False):
camera_props.enable_text_hud = True
def setup_hud_deferred():
try:
bpy.ops.bim.setup_text_hud()
except Exception:
pass
bpy.app.timers.register(setup_hud_deferred, first_interval=0.3)
else:
def update_hud_deferred():
try:
bpy.ops.bim.update_text_hud_positions()
except Exception:
pass
bpy.app.timers.register(update_hud_deferred, first_interval=0.1)
except Exception:
pass
try:
cls._register_multi_text_handler(settings)
except Exception:
pass
return created_texts
@classmethod
def create_text_objects_static(cls, settings):
"""Creates static 3D text objects for snapshot mode (NO animation handler registration)"""
from datetime import timedelta
collection_name = "Schedule_Display_Texts"
if collection_name in bpy.data.collections:
collection = bpy.data.collections[collection_name]
for obj in list(collection.objects):
try:
bpy.data.objects.remove(obj, do_unlink=True)
except Exception:
pass
else:
collection = bpy.data.collections.new(collection_name)
try:
bpy.context.scene.collection.children.link(collection)
except Exception:
pass
schedule_name = "Unknown Schedule"
try:
import bonsai.tool as tool
ws_props = tool.Sequence.get_work_schedule_props()
if ws_props and hasattr(ws_props, 'active_work_schedule_id'):
ws_id = ws_props.active_work_schedule_id
if ws_id:
work_schedule = tool.Ifc.get().by_id(ws_id)
if work_schedule and hasattr(work_schedule, 'Name'):
schedule_name = work_schedule.Name or "Unnamed Schedule"
except Exception:
pass
text_configs = [
{"name": "Schedule_Name", "position": (0, 10, 6), "size": 1.4, "align": "CENTER", "color": (1, 1, 1, 1), "type": "schedule_name", "content": f"Schedule: {schedule_name}"},
{"name": "Schedule_Date", "position": (0, 10, 5), "size": 1.2, "align": "CENTER", "color": (1, 1, 1, 1), "type": "date"},
{"name": "Schedule_Week", "position": (0, 10, 4), "size": 1.0, "align": "CENTER", "color": (1, 1, 1, 1), "type": "week"},
{"name": "Schedule_Day_Counter", "position": (0, 10, 3), "size": 0.8, "align": "CENTER", "color": (1, 1, 1, 1), "type": "day_counter"},
{"name": "Schedule_Progress", "position": (0, 10, 2), "size": 1.0, "align": "CENTER", "color": (1, 1, 1, 1), "type": "progress"},
]
created_texts = []
for config in text_configs:
text_obj = cls._create_static_text(config, settings, collection)
created_texts.append(text_obj)
parent_name = "Schedule_Display_Parent"
parent_empty = bpy.data.objects.get(parent_name)
if not parent_empty:
parent_empty = bpy.data.objects.new(parent_name, None)
collection.objects.link(parent_empty)
parent_empty.empty_display_type = 'PLAIN_AXES'
parent_empty.empty_display_size = 2
parent_empty.location = (0, 0, 0)
for text_obj in created_texts:
if text_obj and text_obj.parent != parent_empty:
try:
text_obj.parent = parent_empty
except Exception:
pass
return created_texts
@classmethod
def _create_static_text(cls, config, settings, collection):
"""Creates a single static 3D text object with fixed content based on snapshot date"""
text_curve = bpy.data.curves.new(name=config["name"], type='FONT')
text_curve.size = config["size"]
text_curve.align_x = config["align"]
text_curve.align_y = 'CENTER'
text_curve["text_type"] = config["type"]
# Get the snapshot date from settings
snapshot_date = settings.get("start") if isinstance(settings, dict) else getattr(settings, "start", None)
# Set the text content based on the type and snapshot date
text_type = config["type"].lower()
# Check if content is pre-defined in config (for schedule_name)
if "content" in config:
text_curve.body = config["content"]
elif text_type == "date":
if snapshot_date:
try:
text_curve.body = snapshot_date.strftime("%d/%m/%Y")
except Exception:
text_curve.body = str(snapshot_date).split("T")[0]
else:
text_curve.body = "Date: --"
elif text_type == "week":
text_curve.body = cls._calculate_static_week_text(snapshot_date)
elif text_type == "day_counter":
text_curve.body = cls._calculate_static_day_text(snapshot_date)
elif text_type == "progress":
text_curve.body = cls._calculate_static_progress_text(snapshot_date)
elif text_type == "schedule_name":
text_curve.body = "Schedule: Unknown"
else:
text_curve.body = f"Static {config['type']}"
# Create the text object
text_obj = bpy.data.objects.new(config["name"], text_curve)
text_obj.location = config["position"]
# Set color if available
if "color" in config:
color = config["color"]
if hasattr(text_obj, "color"):
text_obj.color = color
collection.objects.link(text_obj)
# NOTE: Parenting is handled in the main create_text_objects_static method
return text_obj
@classmethod
def _calculate_static_week_text(cls, snapshot_date):
"""Calculate static week text for snapshot mode"""
try:
if not snapshot_date:
return "Week --"
# Get schedule range for week calculation
sch_start, sch_finish = tool.Sequence.get_schedule_date_range()
if not sch_start:
return "Week --"
cd_d = snapshot_date.date()
fss_d = sch_start.date()
delta_days = (cd_d - fss_d).days
if cd_d < fss_d:
week_number = 0
else:
week_number = max(1, (delta_days // 7) + 1)
return f"Week {week_number}"
except Exception:
return "Week --"
@classmethod
def _calculate_static_day_text(cls, snapshot_date):
"""Calculate static day text for snapshot mode"""
try:
if not snapshot_date:
return "Day --"
# Get schedule range for day calculation
sch_start, sch_finish = tool.Sequence.get_schedule_date_range()
if not sch_start:
return "Day --"
cd_d = snapshot_date.date()
fss_d = sch_start.date()
delta_days = (cd_d - fss_d).days
if cd_d < fss_d:
day_number = 0
else:
day_number = max(1, delta_days + 1)
return f"Day {day_number}"
except Exception:
return "Day --"
@classmethod
def _calculate_static_progress_text(cls, snapshot_date):
"""Calculate static progress text for snapshot mode"""
try:
if not snapshot_date:
return "Progress: --%"
# Get schedule range for progress calculation
sch_start, sch_finish = tool.Sequence.get_schedule_date_range()
if not (sch_start and sch_finish):
return "Progress: --%"
cd_d = snapshot_date.date()
fss_d = sch_start.date()
fse_d = sch_finish.date()
if cd_d < fss_d:
progress_pct = 0
elif cd_d >= fse_d:
progress_pct = 100
else:
total_schedule_days = (fse_d - fss_d).days
if total_schedule_days <= 0:
progress_pct = 100
else:
delta_days = (cd_d - fss_d).days
progress_pct = (delta_days / total_schedule_days) * 100
progress_pct = round(progress_pct)
progress_pct = max(0, min(100, progress_pct))
return f"Progress: {progress_pct}%"
except Exception:
return "Progress: --%"
@classmethod
def _create_animated_text(cls, config, settings, collection):
try:
text_curve = bpy.data.curves.new(name=config["name"], type='FONT')
text_curve.size = config["size"]
text_curve.align_x = config["align"]
text_curve.align_y = 'CENTER'
text_curve["text_type"] = config["type"]
if config["type"] == "schedule_name" and "content" in config:
text_curve["content"] = config["content"]
try:
start = settings.get("start") if isinstance(settings, dict) else getattr(settings, "start", None)
finish = settings.get("finish") if isinstance(settings, dict) else getattr(settings, "finish", None)
start_frame = int(settings.get("start_frame", 1)) if isinstance(settings, dict) else int(getattr(settings, "start_frame", 1))
total_frames = int(settings.get("total_frames", 250)) if isinstance(settings, dict) else int(getattr(settings, "total_frames", 250))
if hasattr(start, "isoformat"):
start_iso = start.isoformat()
else:
start_iso = str(start)
if hasattr(finish, "isoformat"):
finish_iso = finish.isoformat()
else:
finish_iso = str(finish)
except Exception:
start_iso = ""
finish_iso = ""
text_curve["animation_settings"] = {
"start_frame": start_frame,
"total_frames": total_frames,
"start_date": start_iso,
"finish_date": finish_iso,
}
text_obj = bpy.data.objects.new(name=config["name"], object_data=text_curve)
try:
collection.objects.link(text_obj)
except Exception:
try:
bpy.context.scene.collection.objects.link(text_obj)
except Exception:
pass
text_obj.location = config["position"]
cls._setup_text_material_colored(text_obj, config["color"], config["name"])
cls._animate_text_by_type(text_obj, config["type"], settings)
return text_obj
except Exception as e:
raise
@classmethod
def _setup_text_material_colored(cls, text_obj, color, mat_name_suffix):
mat_name = f"Schedule_Text_Mat_{mat_name_suffix}"
mat = bpy.data.materials.get(mat_name) or bpy.data.materials.new(name=mat_name)
try:
mat.use_nodes = True
nt = mat.node_tree
bsdf = nt.nodes.get("Principled BSDF")
if bsdf:
bsdf.inputs["Base Color"].default_value = tuple(list(color[:3]) + [1.0])
bsdf.inputs["Emission"].default_value = tuple(list(color[:3]) + [1.0])
bsdf.inputs["Emission Strength"].default_value = 1.5
except Exception:
pass
try:
text_obj.data.materials.clear()
text_obj.data.materials.append(mat)
except Exception:
pass
@classmethod
def _animate_text_by_type(cls, text_obj, text_type, settings):
try:
from datetime import timedelta, datetime as _dt
start_date = settings.get("start") if isinstance(settings, dict) else getattr(settings, "start", None)
finish_date = settings.get("finish") if isinstance(settings, dict) else getattr(settings, "finish", None)
start_frame = int(settings.get("start_frame", 1)) if isinstance(settings, dict) else int(getattr(settings, "start_frame", 1))
total_frames = int(settings.get("total_frames", 250)) if isinstance(settings, dict) else int(getattr(settings, "total_frames", 250))
if isinstance(start_date, str):
try:
from dateutil import parser as _parser
start_date = _dt.fromisoformat(start_date.replace(' ', 'T')[:19]) if '-' in start_date else _parser.parse(start_date, yearfirst=True)
except Exception:
start_date = _dt.now()
if isinstance(finish_date, str):
try:
from dateutil import parser as _parser
finish_date = _dt.fromisoformat(finish_date.replace(' ', 'T')[:19]) if '-' in finish_date else _parser.parse(finish_date, yearfirst=True)
except Exception:
finish_date = start_date
duration = finish_date - start_date
step_days = 7 if duration.days > 365 else (3 if duration.days > 90 else 1)
current_date = start_date
while current_date <= finish_date:
if duration.total_seconds() > 0:
progress = (current_date - start_date).total_seconds() / duration.total_seconds()
else:
progress = 0.0
frame = start_frame + (progress * total_frames)
if text_type == "date":
text_content = cls._format_date(current_date)
elif text_type == "week":
text_content = cls._format_week(current_date, start_date)
elif text_type == "day_counter":
text_content = cls._format_day_counter(current_date, start_date, finish_date)
elif text_type == "progress":
text_content = cls._format_progress(current_date, start_date, finish_date)
else:
text_content = ""
text_obj.data.body = text_content
try:
text_obj.data.keyframe_insert(data_path="body", frame=int(frame))
except Exception:
pass
current_date += timedelta(days=step_days)
if current_date > finish_date and current_date - timedelta(days=step_days) < finish_date:
current_date = finish_date
except Exception as e:
raise
@classmethod
def _format_date(cls, current_date):
try:
return current_date.strftime("%d/%m/%Y")
except Exception:
return str(current_date)
@classmethod
def _format_week(cls, current_date, start_date):
try:
# Get full schedule dates (same as HUD logic)
try:
sch_start, sch_finish = tool.Sequence.get_schedule_date_range()
if sch_start and sch_finish:
# Use same logic as HUD Schedule
cd_d = current_date.date()
fss_d = sch_start.date()
delta_days = (cd_d - fss_d).days
if cd_d < fss_d:
week_number = 0
else:
week_number = max(1, (delta_days // 7) + 1)
return f"Week {week_number}"
except Exception:
pass
# Fallback: use animation range
days_elapsed = (current_date - start_date).days
current_week = (days_elapsed // 7) + 1
return f"Week {current_week}"
except Exception:
return "Week ?"
@classmethod
def _format_day_counter(cls, current_date, start_date, finish_date):
try:
# Get full schedule dates (same as HUD logic)
try:
sch_start, sch_finish = tool.Sequence.get_schedule_date_range()
if sch_start and sch_finish:
# Use same logic as HUD Schedule
cd_d = current_date.date()
fss_d = sch_start.date()
delta_days = (cd_d - fss_d).days
if cd_d < fss_d:
day_from_schedule = 0
else:
day_from_schedule = max(1, delta_days + 1)
return f"Day {day_from_schedule}"
except Exception:
pass
# Fallback: use animation range
days_elapsed = (current_date - start_date).days + 1
return f"Day {days_elapsed}"
except Exception:
return "Day ?"
@classmethod
def _format_progress(cls, current_date, start_date, finish_date):
try:
# Get full schedule dates (same as HUD logic)
try:
sch_start, sch_finish = tool.Sequence.get_schedule_date_range()
if sch_start and sch_finish:
# Use same logic as HUD Schedule
cd_d = current_date.date()
fss_d = sch_start.date()
fse_d = sch_finish.date()
if cd_d < fss_d:
progress_pct = 0
elif cd_d >= fse_d:
progress_pct = 100
else:
total_schedule_days = (fse_d - fss_d).days
if total_schedule_days <= 0:
progress_pct = 100
else:
delta_days = (cd_d - fss_d).days
progress_pct = (delta_days / total_schedule_days) * 100
progress_pct = round(progress_pct)
progress_pct = max(0, min(100, progress_pct))
return f"Progress: {progress_pct}%"
except Exception:
pass
# Fallback: use animation range
total = (finish_date - start_date).days
if total > 0:
progress = ((current_date - start_date).days / total) * 100.0
else:
progress = 100.0
return f"Progress: {progress:.0f}%"
except Exception:
return "Progress: ?%"
@classmethod
def _register_multi_text_handler(cls, settings):
from datetime import datetime as _dt
cls._unregister_frame_change_handler()
def update_all_schedule_texts(scene):
collection_name = "Schedule_Display_Texts"
coll = bpy.data.collections.get(collection_name)
if not coll:
return
current_frame = int(scene.frame_current)
for text_obj in list(coll.objects):
anim_settings = text_obj.data.get("animation_settings") if getattr(text_obj, "data", None) else None
if not anim_settings:
continue
start_frame = int(anim_settings.get("start_frame", 1))
total_frames = int(anim_settings.get("total_frames", 250))
if current_frame < start_frame:
progress = 0.0
elif current_frame > start_frame + total_frames:
progress = 1.0
else:
progress = (current_frame - start_frame) / float(total_frames or 1)
try:
start_date = _dt.fromisoformat(anim_settings.get("start_date"))
finish_date = _dt.fromisoformat(anim_settings.get("finish_date"))
except Exception:
continue
duration = finish_date - start_date
current_date = start_date + (duration * progress)
ttype = text_obj.data.get("text_type", "date")
if ttype == "date":
text_obj.data.body = cls._format_date(current_date)
elif ttype == "week":
text_obj.data.body = cls._format_week(current_date, start_date)
elif ttype == "day_counter":
text_obj.data.body = cls._format_day_counter(current_date, start_date, finish_date)
elif ttype == "progress":
text_obj.data.body = cls._format_progress(current_date, start_date, finish_date)
elif ttype == "schedule_name":
# Schedule name is static, get it from the original content if available
if "content" in text_obj.data:
text_obj.data.body = text_obj.data["content"]
else:
# Fallback: get schedule name dynamically
try:
import bonsai.tool as tool
ws_props = tool.Sequence.get_work_schedule_props()
if ws_props and hasattr(ws_props, 'active_work_schedule_id'):
ws_id = ws_props.active_work_schedule_id
if ws_id:
work_schedule = tool.Ifc.get().by_id(ws_id)
if work_schedule and hasattr(work_schedule, 'Name'):
schedule_name = work_schedule.Name or "Unnamed Schedule"
text_obj.data.body = f"Schedule: {schedule_name}"
else:
text_obj.data.body = "Schedule: Unknown"
else:
text_obj.data.body = "Schedule: Unknown"
else:
text_obj.data.body = "Schedule: Unknown"
except Exception:
text_obj.data.body = "Schedule: Unknown"
bpy.app.handlers.frame_change_post.append(update_all_schedule_texts)
cls._frame_change_handler = update_all_schedule_texts
@classmethod
def _unregister_frame_change_handler(cls):
try:
if getattr(cls, "_frame_change_handler", None) in bpy.app.handlers.frame_change_post:
bpy.app.handlers.frame_change_post.remove(cls._frame_change_handler)
except Exception:
pass
cls._frame_change_handler = None
@@ -0,0 +1,168 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import ifcopenshell
import bonsai.tool as tool
from .props_sequence import PropsSequence
class UiHelpersSequence:
"""Mixin class for UI helper functions like column and stack management."""
@classmethod
def add_task_column(cls, column_type: str, name: str, data_type: str) -> None:
props = tool.Sequence.get_work_schedule_props()
new = props.columns.add()
new.name = f"{column_type}.{name}"
new.data_type = data_type
@classmethod
def setup_default_task_columns(cls) -> None:
props = tool.Sequence.get_work_schedule_props()
props.columns.clear()
default_columns = ["ScheduleStart", "ScheduleFinish", "ScheduleDuration"]
for item in default_columns:
new = props.columns.add()
new.name = f"IfcTaskTime.{item}"
new.data_type = "string"
@classmethod
def remove_task_column(cls, name: str) -> None:
props = tool.Sequence.get_work_schedule_props()
props.columns.remove(props.columns.find(name))
if props.sort_column == name:
props.sort_column = ""
@classmethod
def set_task_sort_column(cls, column: str) -> None:
props = tool.Sequence.get_work_schedule_props()
props.sort_column = column
@staticmethod
def add_group_to_animation_stack():
"""Add a new group to the animation group stack"""
try:
anim_props = tool.Sequence.get_animation_props()
if not hasattr(anim_props, 'animation_group_stack'):
print("[ERROR] animation_group_stack not found in animation properties")
return
# Check if DEFAULT group already exists
for existing_item in anim_props.animation_group_stack:
if existing_item.group == "DEFAULT":
print("[WARNING] DEFAULT group already exists in animation stack. Cannot create multiple DEFAULT groups.")
return
# Add a new item to the stack
item = anim_props.animation_group_stack.add()
item.group = "DEFAULT" # Default group name
item.enabled = True
# Set as the active item
anim_props.animation_group_stack_index = len(anim_props.animation_group_stack) - 1
print(f"[OK] Added group '{item.group}' to animation stack")
except Exception as e:
print(f"[ERROR] Error adding group to animation stack: {e}")
import traceback
traceback.print_exc()
@staticmethod
def remove_group_from_animation_stack():
"""Remove the selected group from the animation group stack"""
try:
anim_props = tool.Sequence.get_animation_props()
if not hasattr(anim_props, 'animation_group_stack'):
print("[ERROR] animation_group_stack not found in animation properties")
return
idx = anim_props.animation_group_stack_index
if 0 <= idx < len(anim_props.animation_group_stack):
removed_group = anim_props.animation_group_stack[idx].group
anim_props.animation_group_stack.remove(idx)
# Adjust the index if needed
if anim_props.animation_group_stack_index >= len(anim_props.animation_group_stack):
anim_props.animation_group_stack_index = len(anim_props.animation_group_stack) - 1
print(f"[OK] Removed group '{removed_group}' from animation stack")
else:
print("[ERROR] No valid group selected to remove")
except Exception as e:
print(f"[ERROR] Error removing group from animation stack: {e}")
import traceback
traceback.print_exc()
@staticmethod
def move_group_in_animation_stack(direction):
"""Move the selected group up or down in the animation group stack"""
try:
anim_props = tool.Sequence.get_animation_props()
if not hasattr(anim_props, 'animation_group_stack'):
print("[ERROR] animation_group_stack not found in animation properties")
return
idx = anim_props.animation_group_stack_index
stack_len = len(anim_props.animation_group_stack)
if not (0 <= idx < stack_len):
print("[ERROR] No valid group selected to move")
return
new_idx = idx
if direction == "UP" and idx > 0:
new_idx = idx - 1
elif direction == "DOWN" and idx < stack_len - 1:
new_idx = idx + 1
else:
print(f"[ERROR] Cannot move {direction} from position {idx}")
return
# Move the item by removing and re-inserting it
item = anim_props.animation_group_stack[idx]
group_name = item.group
enabled = item.enabled
# Remove the old item
anim_props.animation_group_stack.remove(idx)
# Add at the new position
new_item = anim_props.animation_group_stack.add()
anim_props.animation_group_stack.move(len(anim_props.animation_group_stack) - 1, new_idx)
# Restore the properties
anim_props.animation_group_stack[new_idx].group = group_name
anim_props.animation_group_stack[new_idx].enabled = enabled
# Update index
anim_props.animation_group_stack_index = new_idx
print(f"[OK] Moved group '{group_name}' {direction} to position {new_idx}")
except Exception as e:
print(f"[ERROR] Error moving group in animation stack: {e}")
import traceback
traceback.print_exc()
@classmethod
def enable_editing_task_calendar(cls, task: ifcopenshell.entity_instance) -> None:
props = tool.Sequence.get_work_schedule_props()
props.active_task_id = task.id()
props.editing_task_type = "CALENDAR"
@@ -0,0 +1,982 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021, 2022 Dion Moult <dion@thinkmoult.com>, Yassine Oualid <yassine@sigmadimensions.com>, Federico Eraso <feraso@svisuals.net
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import bpy
import bonsai.tool as tool
from .props_sequence import PropsSequence
from .task_tree_sequence import TaskTreeSequence
from .task_icom_sequence import TaskIcomSequence
class VarianceSequence:
"""Mixin class for schedule variance calculation and visualization."""
_original_colors = {}
@classmethod
def has_variance_calculation_in_tasks(cls):
"""
Verifies if there is variance calculation in current tasks.
Returns True if at least one task has variance_status calculated.
"""
try:
tprops = tool.Sequence.get_task_tree_props()
if not tprops or not tprops.tasks:
return False
variance_count = 0
for task in tprops.tasks:
variance_status = getattr(task, 'variance_status', '')
if variance_status and variance_status.strip():
variance_count += 1
print(f"[CHECK] Found {variance_count} tasks with variance calculation out of {len(tprops.tasks)} total tasks")
return variance_count > 0
except Exception as e:
print(f"[ERROR] Error checking variance calculation: {e}")
return False
@classmethod
def detect_and_fix_variance_inconsistency(cls):
"""
AUTOFIX: Detecta y corrige automáticamente estados inconsistentes de varianza.
Esto ocurre cuando se pierde el cálculo de varianza por refresh pero quedan
restos del sistema de colores que pueden romper animaciones.
"""
try:
print("[AUTOFIX] Detecting variance system inconsistencies...")
# Check if there's any variance calculation
has_variance_calc = cls.has_variance_calculation_in_tasks()
# Check if there are any variance checkboxes active
has_active_checkboxes = cls.has_active_variance_color_checkboxes()
# Check for variance-related scene properties
variance_scene_props = [key for key in bpy.context.scene.keys() if 'variance' in key.lower()]
print(f"[CHECK] Variance calc: {has_variance_calc}, Active checkboxes: {has_active_checkboxes}, Scene props: {len(variance_scene_props)}")
# INCONSISTENCY: No variance calc but has checkboxes or scene props
if not has_variance_calc and (has_active_checkboxes or len(variance_scene_props) > 0):
print("[INCONSISTENCY] Found variance system inconsistency - fixing automatically...")
# Clear all variance checkboxes
tprops = tool.Sequence.get_task_tree_props()
if tprops:
cleared_checkboxes = 0
for task in tprops.tasks:
if getattr(task, 'is_variance_color_selected', False):
task.is_variance_color_selected = False
cleared_checkboxes += 1
print(f"[AUTOFIX] Cleared {cleared_checkboxes} orphaned variance checkboxes")
# Remove variance scene properties
removed_props = 0
for key in variance_scene_props:
try:
del bpy.context.scene[key]
removed_props += 1
except Exception:
pass
print(f"[AUTOFIX] Removed {removed_props} orphaned variance scene properties")
# Clear any variance-related object colors by resetting them to material-based colors
reset_objects = 0
for obj in bpy.context.scene.objects:
if obj.type == 'MESH' and tool.Ifc.get_entity(obj) and hasattr(obj, 'color'):
try:
# Get original color from material if possible
original_color = None
if obj.material_slots and obj.material_slots[0].material:
material = obj.material_slots[0].material
if material.use_nodes:
principled = tool.Blender.get_material_node(material, "BSDF_PRINCIPLED")
if principled and principled.inputs.get("Base Color"):
base_color = principled.inputs["Base Color"].default_value
original_color = (base_color[0], base_color[1], base_color[2], base_color[3])
# Fallback to neutral white if can't get material color
if original_color is None:
original_color = (1.0, 1.0, 1.0, 1.0)
obj.color = original_color
reset_objects += 1
except Exception as e:
print(f"[WARNING] Could not reset color for {obj.name}: {e}")
print(f"[AUTOFIX] Reset {reset_objects} objects to clean state")
# Force viewport refresh
bpy.context.view_layer.update()
for area in bpy.context.screen.areas:
if area.type == 'VIEW_3D':
area.tag_redraw()
print("[AUTOFIX] Variance system inconsistency resolved - safe for animation creation")
return True
else:
print("[CHECK] Variance system is consistent - no fixes needed")
return False
except Exception as e:
print(f"[ERROR] Error in variance inconsistency detection: {e}")
return False
@classmethod
def detect_and_clear_active_variance_colors(cls):
"""
SPECIFIC FIX: Detecta y limpia colores de varianza activos en objetos 3D
antes de crear animación/snapshot para evitar romper el sistema de colores.
"""
try:
print("[AUTOFIX] Checking for active variance colors on 3D objects...")
# Detectar si hay checkboxes de varianza activos
has_active_variance_checkboxes = cls.has_active_variance_color_checkboxes()
if has_active_variance_checkboxes:
print("[DETECTED] Active variance color checkboxes found - clearing to prevent color system conflicts")
# Desactivar todos los checkboxes de varianza
tprops = tool.Sequence.get_task_tree_props()
if tprops:
cleared_checkboxes = 0
for task in tprops.tasks:
if getattr(task, 'is_variance_color_selected', False):
task.is_variance_color_selected = False
cleared_checkboxes += 1
print(f"[AUTOFIX] Deactivated {cleared_checkboxes} variance color checkboxes")
# Limpiar colores de varianza de los objetos 3D
reset_objects = 0
for obj in bpy.context.scene.objects:
if obj.type == 'MESH' and tool.Ifc.get_entity(obj) and hasattr(obj, 'color'):
try:
# Restaurar color original desde material
original_color = None
if obj.material_slots and obj.material_slots[0].material:
material = obj.material_slots[0].material
if material.use_nodes:
principled = tool.Blender.get_material_node(material, "BSDF_PRINCIPLED")
if principled and principled.inputs.get("Base Color"):
base_color = principled.inputs["Base Color"].default_value
original_color = (base_color[0], base_color[1], base_color[2], base_color[3])
# Fallback a blanco neutral si no se puede obtener del material
if original_color is None:
original_color = (1.0, 1.0, 1.0, 1.0)
obj.color = original_color
reset_objects += 1
except Exception as e:
print(f"[WARNING] Could not reset color for {obj.name}: {e}")
print(f"[AUTOFIX] Reset {reset_objects} objects from variance colors to material colors")
# Forzar actualización del viewport
bpy.context.view_layer.update()
for area in bpy.context.screen.areas:
if area.type == 'VIEW_3D':
area.tag_redraw()
print("[AUTOFIX] Variance colors cleared - safe for animation/snapshot creation")
return True
else:
print("[CHECK] No active variance colors detected - safe to proceed")
return False
except Exception as e:
print(f"[ERROR] Error detecting/clearing active variance colors: {e}")
return False
@classmethod
def clear_variance_colors_only(cls):
"""
SAFE: Clears ONLY variance 3D colors, preserving colortype system.
Used when filters change and there's no variance calculation.
"""
try:
print("[CLEAN] Safely clearing variance 3D colors (preserving colortypes)...")
# STRATEGY: Instead of forcing colors, just remove variance mode flags
# This allows the normal colortype system to take over
# Remove variance-specific scene flags but keep colortype data intact
scene_flags_removed = 0
variance_keys = [key for key in bpy.context.scene.keys() if 'variance' in key.lower() and 'colormode' in key.lower()]
for key in variance_keys:
try:
del bpy.context.scene[key]
scene_flags_removed += 1
print(f"[CLEAN] Removed variance flag: {key}")
except Exception as e:
print(f"[WARNING] Could not remove flag {key}: {e}")
# Clear variance checkboxes to deactivate variance colors
tprops = tool.Sequence.get_task_tree_props()
if tprops:
cleared_checkboxes = 0
for task in tprops.tasks:
if getattr(task, 'is_variance_color_selected', False):
task.is_variance_color_selected = False
cleared_checkboxes += 1
print(f"[OK] Cleared {cleared_checkboxes} variance checkboxes")
# DON'T force object colors - let colortype system handle it naturally
print(f"[OK] Variance mode safely deactivated - colortype system remains intact")
# Force viewport update to reflect changes
bpy.context.view_layer.update()
for area in bpy.context.screen.areas:
if area.type == 'VIEW_3D':
area.tag_redraw()
except Exception as e:
print(f"[ERROR] Error safely clearing variance colors: {e}")
import traceback
traceback.print_exc()
@classmethod
def clear_variance_color_mode(cls):
"""
Clears variance color mode and restores original colors.
Called when variance is cleared or schedule type changes.
"""
try:
print("[CLEAN] CLEAR_VARIANCE_COLOR_MODE: Starting cleanup process...")
# Deactivate all variance checkboxes
tprops = tool.Sequence.get_task_tree_props()
if tprops:
cleared_checkboxes = 0
total_tasks = len(tprops.tasks)
print(f"[CHECK] Found {total_tasks} total tasks")
for task in tprops.tasks:
if getattr(task, 'is_variance_color_selected', False):
task.is_variance_color_selected = False
cleared_checkboxes += 1
print(f"[OK] Cleared checkbox for task {task.ifc_definition_id}")
print(f"[OK] Cleared {cleared_checkboxes} variance checkboxes out of {total_tasks} tasks")
else:
print("[ERROR] No task tree properties found")
restored_count = 0
# Method 1: Try to restore from cached original colors
if hasattr(cls, '_original_colors') and cls._original_colors:
print(f"Attempting to restore from cached colors ({len(cls._original_colors)} stored)")
for obj in bpy.context.scene.objects:
if obj.type == 'MESH' and tool.Ifc.get_entity(obj):
if obj.name in cls._original_colors and hasattr(obj, 'color'):
try:
original_color = cls._original_colors[obj.name]
obj.color = original_color
restored_count += 1
print(f"[OK] Restored cached color for {obj.name}")
except Exception as e:
print(f"[ERROR] Error restoring cached color for {obj.name}: {e}")
# Clear original colors cache
cls._original_colors = {}
print("[CLEAN] Cleared original colors cache")
# Method 2: Try to restore from scene saved colors
if restored_count == 0:
variance_colors = bpy.context.scene.get('BIM_VarianceOriginalObjectColors', {})
if variance_colors:
print(f"Attempting to restore from scene saved colors ({len(variance_colors)} stored)")
for obj in bpy.context.scene.objects:
if obj.type == 'MESH' and tool.Ifc.get_entity(obj) and obj.name in variance_colors:
try:
obj.color = variance_colors[obj.name]
obj.hide_viewport = False
obj.hide_render = False
restored_count += 1
except Exception as e:
print(f"[ERROR] Error restoring scene color for {obj.name}: {e}")
# Method 3: Try to get colors from material IFC if still no restoration
if restored_count == 0:
print("No cached colors found, attempting to restore from IFC materials")
for obj in bpy.context.scene.objects:
if obj.type == 'MESH' and tool.Ifc.get_entity(obj):
try:
restored = False
# Try to get color from original IFC object material
try:
if obj.material_slots and obj.material_slots[0].material:
material = obj.material_slots[0].material
if material.use_nodes:
principled = tool.Blender.get_material_node(material, "BSDF_PRINCIPLED")
if principled and principled.inputs.get("Base Color"):
base_color = principled.inputs["Base Color"].default_value
obj.color = (base_color[0], base_color[1], base_color[2], base_color[3])
restored = True
except Exception:
pass
# Only apply gray if it could not be obtained from the material
if not restored:
obj.color = (0.9, 0.9, 0.9, 1.0) # Almost neutral white instead of dark gray
obj.hide_viewport = False
obj.hide_render = False
restored_count += 1
except Exception as e:
print(f"[ERROR] Error resetting color for {obj.name}: {e}")
print(f"[OK] Total objects restored/reset: {restored_count}")
# Method 3: Force complete viewport refresh
try:
# Clear animation data that might be affecting colors
for obj in bpy.context.scene.objects:
if obj.type == 'MESH' and obj.animation_data:
obj.animation_data_clear()
# Force viewport update
bpy.context.view_layer.update()
# Force redraw of all 3D viewports
for area in bpy.context.screen.areas:
if area.type == 'VIEW_3D':
area.tag_redraw()
for space in area.spaces:
if space.type == 'VIEW_3D':
space.shading.color_type = 'OBJECT' # Ensure object colors are visible
print("Forced complete viewport refresh")
except Exception as e:
print(f"[WARNING] Error during viewport refresh: {e}")
except Exception as e:
print(f"[ERROR] Error clearing variance color mode: {e}")
import traceback
traceback.print_exc()
@classmethod
def update_individual_variance_colors(cls):
"""
Updates colors based on individual checkboxes for each task.
Each task works independently.
"""
try:
print("🎯 Updating individual variance colors...")
# Ensure correct viewport
cls._ensure_viewport_shading()
# Get tasks
tprops = tool.Sequence.get_task_tree_props()
if not tprops:
print("[ERROR] No task tree properties found")
return
# Save original colors the first time (if they are not saved)
if not hasattr(cls, '_original_colors'):
cls._original_colors = {}
for obj in bpy.context.scene.objects:
if obj.type == 'MESH' and tool.Ifc.get_entity(obj):
if hasattr(obj, 'color'):
# Try to get the color from the original IFC material first
original_color = None
try:
if obj.material_slots and obj.material_slots[0].material:
material = obj.material_slots[0].material
if material.use_nodes:
principled = tool.Blender.get_material_node(material, "BSDF_PRINCIPLED")
if principled and principled.inputs.get("Base Color"):
base_color = principled.inputs["Base Color"].default_value
original_color = tuple([base_color[0], base_color[1], base_color[2], base_color[3]])
except Exception:
pass
# Fallback: use the current viewport color if it could not be obtained from the material
if original_color is None:
original_color = tuple(obj.color)
cls._original_colors[obj.name] = original_color
print(f"[CACHE] Saved original colors for {len(cls._original_colors)} objects (from materials where possible)")
# Identify tasks with active checkbox
variance_selected_tasks = [t for t in tprops.tasks if getattr(t, 'is_variance_color_selected', False)]
print(f"[CHECK] Found {len(variance_selected_tasks)} tasks with active variance checkbox")
if variance_selected_tasks:
for task in variance_selected_tasks:
print(f" 📋 Task {task.ifc_definition_id}: {task.name} (Status: {getattr(task, 'variance_status', 'No status')})")
else:
print("🎯 No active checkboxes → restoring original colors")
# Restore original colors of all IFC objects
mesh_objects = [obj for obj in bpy.context.scene.objects if obj.type == 'MESH']
restored_count = 0
for obj in mesh_objects:
element = tool.Ifc.get_entity(obj)
if element and hasattr(obj, 'color'):
try:
# Use saved color or default white
if hasattr(cls, '_original_colors') and obj.name in cls._original_colors:
original_color = cls._original_colors[obj.name]
print(f"Restoring saved color for {obj.name}: {original_color}")
else:
original_color = (1.0, 1.0, 1.0, 1.0) # Default white
print(f"Using default color for {obj.name}")
obj.color = original_color
restored_count += 1
except Exception as e:
print(f"[ERROR] Error restoring color for {obj.name}: {e}")
print(f"[OK] Restored {restored_count} objects to original colors")
# Do not clear cache here - keep saved colors for future use
# Force viewport update
bpy.context.view_layer.update()
return
# Create mapping of objects to tasks
object_to_task_map = cls._build_object_task_mapping(tprops.tasks)
# Count mesh objects in the scene
mesh_objects = [obj for obj in bpy.context.scene.objects if obj.type == 'MESH']
ifc_objects = []
for obj in mesh_objects:
element = tool.Ifc.get_entity(obj)
if element:
ifc_objects.append(obj)
print(f"[CHECK] Scene analysis: {len(mesh_objects)} mesh objects, {len(ifc_objects)} have IFC data, {len(object_to_task_map)} task mappings")
# Process each object in the scene
processed_count = 0
colored_count = 0
for obj in mesh_objects:
element = tool.Ifc.get_entity(obj)
if not element:
print(f"[WARNING] {obj.name} → No IFC element → SKIP")
continue
processed_count += 1
# Use the new method to determine color
color = cls._get_variance_color_for_object_real(obj, element, object_to_task_map, variance_selected_tasks)
if color is None:
# Do not change color (no active checkboxes)
continue
# Apply color to object
cls._apply_color_to_object_simple(obj, color)
colored_count += 1
print(f"[STATS] SUMMARY: Processed {processed_count} objects, {colored_count} got variance colors")
# Force viewport update
bpy.context.view_layer.update()
for area in bpy.context.screen.areas:
if area.type == 'VIEW_3D':
area.tag_redraw()
print("[OK] Individual variance colors updated successfully")
except Exception as e:
print(f"[ERROR] Error updating individual variance colors: {e}")
import traceback
traceback.print_exc()
@classmethod
def activate_variance_color_mode(cls):
"""
Activates variance color mode integrating with the existing ColorTypes system
"""
try:
print("Activating variance color mode...")
# Save original colors of objects before changing them
cls._save_original_object_colors()
# Mark that variance mode is active
bpy.context.scene['BIM_VarianceColorModeActive'] = True
# Create a special ColorType group for variance
cls._create_variance_colortype_group()
# Activate the color update system
anim_props = tool.Sequence.get_animation_props()
# Trigger immediate color update
cls._trigger_variance_color_update()
print("[OK] Variance color mode activated successfully")
except Exception as e:
print(f"[ERROR] Error activating variance color mode: {e}")
import traceback
traceback.print_exc()
@classmethod
def _save_original_object_colors(cls):
"""Save the original colors of all objects from IFC material if possible"""
try:
original_colors = {}
for obj in bpy.context.scene.objects:
if obj.type == 'MESH' and hasattr(obj, 'color'):
# Try to get the color from the original IFC material first
original_color = None
try:
if obj.material_slots and obj.material_slots[0].material:
material = obj.material_slots[0].material
if material.use_nodes:
principled = tool.Blender.get_material_node(material, "BSDF_PRINCIPLED")
if principled and principled.inputs.get("Base Color"):
base_color = principled.inputs["Base Color"].default_value
original_color = tuple([base_color[0], base_color[1], base_color[2], base_color[3]])
except Exception:
pass
# Fallback: use the current viewport color if it could not be obtained from the material
if original_color is None:
original_color = tuple(obj.color)
original_colors[obj.name] = original_color
bpy.context.scene['BIM_VarianceOriginalObjectColors'] = original_colors
print(f"Saved original colors for {len(original_colors)} objects (from materials where possible)")
except Exception as e:
print(f"[ERROR] Error saving original object colors: {e}")
@classmethod
def _restore_original_object_colors(cls):
"""Restore the original colors of all objects"""
try:
original_colors = bpy.context.scene.get('BIM_VarianceOriginalObjectColors', {})
restored_count = 0
for obj in bpy.context.scene.objects:
if obj.type == 'MESH' and hasattr(obj, 'color') and obj.name in original_colors:
obj.color = original_colors[obj.name]
restored_count += 1
# Clear saved data
if 'BIM_VarianceOriginalObjectColors' in bpy.context.scene:
del bpy.context.scene['BIM_VarianceOriginalObjectColors']
print(f"[OK] Restored original colors for {restored_count} objects")
except Exception as e:
print(f"[ERROR] Error restoring original object colors: {e}")
@classmethod
def deactivate_variance_color_mode(cls):
"""
Deactivates variance color mode and restores original colors
"""
try:
print("Deactivating variance color mode...")
# Restore original colors of objects
cls._restore_original_object_colors()
# Unmark that variance mode is active
if 'BIM_VarianceColorModeActive' in bpy.context.scene:
del bpy.context.scene['BIM_VarianceColorModeActive']
# Clear variance data
if 'BIM_VarianceColorTypes' in bpy.context.scene:
del bpy.context.scene['BIM_VarianceColorTypes']
# Force viewport update
bpy.context.view_layer.update()
for area in bpy.context.screen.areas:
if area.type == 'VIEW_3D':
area.tag_redraw()
print("[OK] Variance color mode deactivated successfully")
except Exception as e:
print(f"[ERROR] Error deactivating variance color mode: {e}")
import traceback
traceback.print_exc()
@classmethod
def _create_variance_colortype_group(cls):
"""Creates a special ColorTypes group for variance"""
try:
# Define variance ColorTypes
variance_colortypes = {
"DELAYED": {
"Color": (1.0, 0.2, 0.2),
"Transparency": 0.0,
"Description": "Tasks that are delayed"
},
"AHEAD": {
"Color": (0.2, 1.0, 0.2),
"Transparency": 0.0,
"Description": "Tasks that are ahead of schedule"
},
"ONTIME": {
"Color": (0.2, 0.2, 1.0),
"Transparency": 0.0,
"Description": "Tasks that are on time"
},
"UNSELECTED": {
"Color": (0.8, 0.8, 0.8),
"Transparency": 0.7,
"Description": "Tasks not selected for variance view"
}
}
# Create the group configuration file
variance_group_data = {
"name": "VARIANCE_MODE",
"description": "Special ColorType group for variance analysis mode",
"ColorTypes": variance_colortypes
}
# Store in memory for immediate use - convert to serializable format
serializable_colortypes = {}
for name, data in variance_colortypes.items():
serializable_colortypes[name] = {
"Color": tuple(data["Color"]), # Ensure it is a tuple
"Transparency": float(data["Transparency"]),
"Description": str(data["Description"])
}
bpy.context.scene['BIM_VarianceColorTypes'] = serializable_colortypes
print("[OK] Created variance ColorType group")
except Exception as e:
print(f"[ERROR] Error creating variance ColorType group: {e}")
@classmethod
def _trigger_variance_color_update(cls):
"""Forces a color update using the existing system"""
try:
# Use existing color update handler with variance logic
cls._variance_aware_color_update()
except Exception as e:
print(f"[ERROR] Error triggering variance color update: {e}")
@classmethod
def _variance_aware_color_update(cls):
"""Color update that takes variance mode into account"""
try:
is_variance_mode = bpy.context.scene.get('BIM_VarianceColorModeActive', False)
if not is_variance_mode:
# If not in variance mode, use normal system
return
print("🎯 Applying variance-aware color update...")
# Ensure the viewport is in Material Preview or Rendered mode
cls._ensure_viewport_shading()
# Get tasks with variance selected
tprops = tool.Sequence.get_task_tree_props()
if not tprops:
return
variance_selected_tasks = [
task for task in tprops.tasks
if getattr(task, 'is_variance_color_selected', False) and task.variance_status
]
variance_colortypes = bpy.context.scene.get('BIM_VarianceColorTypes', {})
# Create mapping of IFC objects to real tasks
object_to_task_map = cls._build_object_task_mapping(tprops.tasks)
# Iterate over all objects and apply variance colors
for obj in bpy.context.scene.objects:
if obj.type != 'MESH':
continue
element = tool.Ifc.get_entity(obj)
if not element:
continue
# Determine color based on real task-object relationship
color = cls._get_variance_color_for_object_real(obj, element, object_to_task_map, variance_selected_tasks)
if color:
cls._apply_color_to_object_simple(obj, color)
# Force viewport update
bpy.context.view_layer.update()
# Also update the depsgraph
for area in bpy.context.screen.areas:
if area.type == 'VIEW_3D':
area.tag_redraw()
except Exception as e:
print(f"[ERROR] Error in variance aware color update: {e}")
@classmethod
def _ensure_viewport_shading(cls):
"""Ensure the viewport is in Solid mode with object colors"""
try:
for area in bpy.context.screen.areas:
if area.type == 'VIEW_3D':
for space in area.spaces:
if space.type == 'VIEW_3D':
current_shading = space.shading.type
print(f"[CHECK] Current viewport shading: {current_shading}")
# Ensure Solid mode with object colors
if current_shading != 'SOLID':
space.shading.type = 'SOLID'
print("Changed viewport to Solid mode")
if hasattr(space.shading, 'color_type'):
space.shading.color_type = 'OBJECT'
print("Set solid shading to OBJECT color mode")
break
except Exception as e:
print(f"[WARNING] Could not ensure viewport shading: {e}")
@classmethod
def _build_object_task_mapping(cls, all_tasks):
"""Builds mapping using the correct Bonsai system"""
object_task_map = {}
print(f"[CHECK] Building object-task mapping for {len(all_tasks)} tasks using Bonsai system...")
# Use the correct Bonsai method to get outputs
ifc_file = tool.Ifc.get()
if not ifc_file:
print("[ERROR] No IFC file available")
return object_task_map
for task_pg in all_tasks:
try:
task_ifc = ifc_file.by_id(task_pg.ifc_definition_id)
if not task_ifc:
continue
# 1. Get both inputs and outputs
# We use 'or []' to avoid errors if the function returns None
outputs = tool.Sequence.get_task_outputs(task_ifc) or []
# We assume an analogous method exists for inputs
# If the method has another name, adjust it here.
inputs = tool.Sequence.get_task_inputs(task_ifc) or []
# 2. Combine both lists into one
all_related_objects = outputs + inputs
if all_related_objects:
# We use a set to efficiently handle duplicates
processed_ids = set()
print(f"📋 Task {task_pg.ifc_definition_id} ({task_pg.name}) has {len(all_related_objects)} related objects (Inputs & Outputs):")
for item in all_related_objects:
item_id = item.id()
if item_id not in processed_ids:
object_task_map[item_id] = task_pg
print(f" → Object {item_id} ({item.Name}) assigned to task")
processed_ids.add(item_id)
else:
print(f"Task {task_pg.ifc_definition_id} ({task_pg.name}) has no inputs or outputs assigned.")
except Exception as e:
print(f"[ERROR] Error mapping task {task_pg.ifc_definition_id}: {e}")
continue
print(f"[OK] Built mapping: {len(object_task_map)} object-task relationships")
return object_task_map
@classmethod
def _get_variance_color_for_object_real(cls, obj, element, object_task_map, variance_selected_tasks):
"""Determines a simple and direct color"""
try:
element_id = element.id()
assigned_task = object_task_map.get(element_id)
# If this object belongs to a task with active checkbox
if assigned_task and getattr(assigned_task, 'is_variance_color_selected', False):
variance_status = getattr(assigned_task, 'variance_status', '')
# Color according to variance status
if "Delayed" in variance_status:
print(f"🔴 {obj.name} -> Task {assigned_task.ifc_definition_id} -> DELAYED")
return (1.0, 0.2, 0.2, 1.0) # Red
elif "Ahead" in variance_status:
print(f"🟢 {obj.name} -> Task {assigned_task.ifc_definition_id} -> AHEAD")
return (0.2, 1.0, 0.2, 1.0) # Green
elif "On Time" in variance_status:
print(f"🔵 {obj.name} -> Task {assigned_task.ifc_definition_id} -> ONTIME")
return (0.2, 0.2, 1.0, 1.0) # Blue
else:
print(f"{obj.name} -> Task {assigned_task.ifc_definition_id} -> Unknown status: '{variance_status}'")
return (0.8, 0.8, 0.8, 0.3) # Transparent gray
else:
# Object without selected task → transparent gray
return (0.8, 0.8, 0.8, 0.3)
except Exception as e:
print(f"[ERROR] Error getting color for object {obj.name}: {e}")
return (0.8, 0.8, 0.8, 0.3)
@classmethod
def _apply_color_to_object_simple(cls, obj, color):
"""Apply color only to the object (for Solid viewport)"""
try:
print(f"Applying variance color {color} to {obj.name}")
# ONLY apply object color (for Solid > Object mode)
if hasattr(obj, 'color'):
obj.color = color[:4] if len(color) >= 4 else color[:3] + (1.0,)
print(f"[OK] Set object color for {obj.name}: {obj.color}")
else:
print(f"[WARNING] Object {obj.name} does not have color property")
except Exception as e:
print(f"[ERROR] Error applying simple color to {obj.name}: {e}")
import traceback
traceback.print_exc()
@classmethod
def clear_schedule_variance(cls):
"""
Clear schedule variance data, colors and reset objects to their original state.
Called when clearing variance or switching schedule types.
"""
try:
print("[CLEAN] CLEAR_SCHEDULE_VARIANCE: Starting comprehensive cleanup process...")
# Clear variance DATA from all tasks (this was missing!)
tprops = tool.Sequence.get_task_tree_props()
if tprops and tprops.tasks:
cleared_tasks = 0
print(f"[CLEAN] CLEAR_SCHEDULE_VARIANCE: Found {len(tprops.tasks)} tasks to clean")
for task in tprops.tasks:
# Clear the variance data properties set by CalculateScheduleVariance
if hasattr(task, 'variance_days'):
task.variance_days = 0
cleared_tasks += 1
if hasattr(task, 'variance_status'):
task.variance_status = ""
# Clear variance color selection checkbox
if hasattr(task, 'is_variance_color_selected'):
task.is_variance_color_selected = False
print(f"[OK] CLEAR_SCHEDULE_VARIANCE: Cleared variance data from {cleared_tasks} tasks")
else:
print("[WARNING] CLEAR_SCHEDULE_VARIANCE: No task properties found")
# Clear variance color mode and restore original colors
print("[CLEAN] CLEAR_SCHEDULE_VARIANCE: Clearing variance color mode...")
# Assuming cls.clear_variance_color_mode() exists and works correctly.
# If the problem persists, the error could also be within that function.
# cls.clear_variance_color_mode() # This line may be redundant if we reset manually below
# Remove variance mode flags from scene
scene_flags_cleared = 0
if hasattr(bpy.context.scene, 'BIM_VarianceColorModeActive'):
del bpy.context.scene['BIM_VarianceColorModeActive']
scene_flags_cleared += 1
print("[CLEAN] Removed BIM_VarianceColorModeActive flag from scene")
# Clear any other variance-related scene properties
variance_keys = [key for key in bpy.context.scene.keys() if 'variance' in key.lower()]
for key in variance_keys:
try:
del bpy.context.scene[key]
scene_flags_cleared += 1
print(f"[CLEAN] Removed scene property: {key}")
except Exception as e:
print(f"[WARNING] Could not remove scene property {key}: {e}")
print(f"[OK] CLEAR_SCHEDULE_VARIANCE: Cleared {scene_flags_cleared} scene flags")
# SAFE RESET: Only clear variance-specific overrides, preserve colortype system
print("[CLEAN] CLEAR_SCHEDULE_VARIANCE: Safely removing variance overrides (preserving colortypes)...")
reset_objects = 0
for obj in bpy.context.scene.objects:
if obj.type == 'MESH' and tool.Ifc.get_entity(obj):
try:
# STRATEGY: Instead of forcing white, only remove variance-specific overrides
# This allows colortypes to work naturally without interference
# Only reset visibility if it was hidden by variance
if obj.hide_viewport or obj.hide_render:
obj.hide_viewport = False
obj.hide_render = False
# Clear animation data that might be variance-related
if obj.animation_data:
# Only clear if it's variance-related animation
try:
if 'variance' in str(obj.animation_data).lower():
obj.animation_data_clear()
except Exception:
# If we can't determine, be conservative and don't clear
pass
# DON'T force object color - let colortype system manage colors
reset_objects += 1
except Exception as e:
print(f"[WARNING] Error safely resetting object {obj.name}: {e}")
print(f"[OK] CLEAR_SCHEDULE_VARIANCE: Safely reset {reset_objects} objects (colortypes preserved)")
# Force complete viewport refresh
print("[CLEAN] CLEAR_SCHEDULE_VARIANCE: Forcing viewport refresh...")
try:
# Update view layer
bpy.context.view_layer.update()
# Force redraw of all 3D viewports
for area in bpy.context.screen.areas:
if area.type == 'VIEW_3D':
area.tag_redraw()
print("[OK] CLEAR_SCHEDULE_VARIANCE: Viewport refresh completed")
except Exception as e:
print(f"[WARNING] Error during viewport refresh: {e}")
print("[OK] CLEAR_SCHEDULE_VARIANCE: Comprehensive cleanup completed successfully")
except Exception as e:
print(f"[ERROR] Error in clear_schedule_variance: {e}")
import traceback
traceback.print_exc()
+76 -78
View File
@@ -17,46 +17,43 @@
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import json
from collections import defaultdict
from collections.abc import Generator, Iterable
from math import pi
from typing import TYPE_CHECKING, Any, Literal, Optional, Union
import bmesh
import bpy
import bmesh
import shapely
import shapely.ops
import ifcopenshell
import ifcopenshell.api.attribute
import ifcopenshell.api.type
import ifcopenshell.geom
import ifcopenshell.util.classification
import ifcopenshell.util.element
import ifcopenshell.util.placement
import ifcopenshell.util.representation
import ifcopenshell.util.classification
import ifcopenshell.util.shape_builder
import ifcopenshell.util.type
import ifcopenshell.util.unit
import numpy as np
import shapely
import shapely.ops
from mathutils import Matrix, Vector
from natsort import natsorted
from shapely import Polygon
import bonsai.core.geometry
import bonsai.core.type
import bonsai.core.tool
import bonsai.core.root
import bonsai.core.spatial
import bonsai.core.tool
import bonsai.core.type
import bonsai.core.geometry
import bonsai.core.unit
import bonsai.tool as tool
import json
import numpy as np
from math import pi
from mathutils import Vector, Matrix
from shapely import Polygon
from typing import Optional, Union, Literal, Any, TYPE_CHECKING
from collections.abc import Generator, Iterable
from collections import defaultdict
from natsort import natsorted
if TYPE_CHECKING:
from bonsai.bim.module.spatial.prop import (
BIMGridProperties,
BIMObjectSpatialProperties,
BIMSpatialDecompositionProperties,
BIMObjectSpatialProperties,
)
@@ -74,25 +71,9 @@ class Spatial(bonsai.core.tool.Spatial):
return bpy.context.scene.BIMGridProperties
@classmethod
def get_decomposition(cls, element: ifcopenshell.entity_instance) -> list(ifcopenshell.entity_instance):
return ifcopenshell.util.element.get_decomposition(element)
@classmethod
def get_root_element(cls, element: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance:
while True:
if parent := (
ifcopenshell.util.element.get_aggregate(element)
or ifcopenshell.util.element.get_nest(element)
or ifcopenshell.util.element.get_filled_void(element)
or ifcopenshell.util.element.get_voided_element(element)
):
element = parent
else:
break
return element
@classmethod
def can_contain(cls, container: ifcopenshell.entity_instance, element: ifcopenshell.entity_instance) -> bool:
def can_contain(cls, container: ifcopenshell.entity_instance, element_obj: Union[bpy.types.Object, None]) -> bool:
if not (element := tool.Ifc.get_entity(element_obj)):
return False
if tool.Ifc.get_schema() == "IFC2X3":
if not container.is_a("IfcSpatialStructureElement"):
return False
@@ -163,15 +144,15 @@ class Spatial(bonsai.core.tool.Spatial):
@classmethod
def run_spatial_assign_container(
cls, container: ifcopenshell.entity_instance, objs: list[bpy.types.Object]
cls, container: ifcopenshell.entity_instance, element_obj: bpy.types.Object
) -> Union[ifcopenshell.entity_instance, None]:
return bonsai.core.spatial.assign_container(
tool.Ifc, tool.Collector, tool.Spatial, container=container, objs=objs
tool.Ifc, tool.Collector, tool.Spatial, container=container, element_obj=element_obj
)
@classmethod
def run_spatial_import_spatial_decomposition(cls) -> None:
return bonsai.core.spatial.import_spatial_decomposition(tool.Spatial)
return cls.import_spatial_decomposition()
@classmethod
def select_object(cls, obj: bpy.types.Object) -> None:
@@ -194,15 +175,54 @@ class Spatial(bonsai.core.tool.Spatial):
@classmethod
def select_products(cls, products: Iterable[ifcopenshell.entity_instance], unhide: bool = False) -> None:
assert (view_layer := bpy.context.view_layer)
# Update view layer, otherwise `objects` might be missing just created objects.
view_layer.update()
for product in products:
obj = tool.Ifc.get_object(product)
if obj and view_layer.objects.get(obj.name):
if unhide:
obj.hide_set(False)
obj.select_set(True)
"""
Selecciona productos en el viewport de Blender.
Args:
products: Iterable de productos IFC (puede ser None)
unhide: Si debe mostrar objects ocultos
"""
try:
# Security validation: handle None and non-iterable types
if products is None:
print("Warning: products is None in select_products")
return
# Convertir a lista si no es iterable
try:
product_list = list(products)
except TypeError:
print(f"Warning: products is not iterable: {type(products)}")
return
# Deseleccionar todos los objects primero
bpy.ops.object.select_all(action="DESELECT")
selected_count = 0
for product in product_list:
if product is None:
continue
try:
obj = tool.Ifc.get_object(product)
if obj and bpy.context.view_layer.objects.get(obj.name):
if unhide:
obj.hide_set(False)
obj.select_set(True)
selected_count += 1
except Exception as e:
# Obtener ID seguro del producto para el log
try:
product_id = product.id() if hasattr(product, 'id') else 'unknown'
except Exception:
product_id = 'unknown'
print(f"Error selecting object for product {product_id}: {e}")
continue
print(f"Selected {selected_count} objects")
except Exception as e:
print(f"Error in select_products: {e}")
@classmethod
def filter_products(
@@ -515,9 +535,7 @@ class Spatial(bonsai.core.tool.Spatial):
new.long_name = element.LongName or ""
if not element.is_a("IfcProject"):
elevation = ifcopenshell.util.placement.get_storey_elevation(element)
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
elevation_in_meters = elevation * unit_scale
new.elevation = tool.Unit.format_distance(elevation_in_meters)
new["elevation"] = tool.Unit.format_value(elevation)
new.is_expanded = element.id() not in cls.contracted_containers
new.level_index = level_index
children = ifcopenshell.util.element.get_parts(element)
@@ -1193,7 +1211,6 @@ class Spatial(bonsai.core.tool.Spatial):
@classmethod
def assign_type_to_obj(cls, obj: bpy.types.Object) -> None:
# TODO this code looks in the wrong spot and suspicious
props = tool.Model.get_model_props()
ifc_file = tool.Ifc.get()
relating_type_id = props.relating_type_id
@@ -1213,7 +1230,7 @@ class Spatial(bonsai.core.tool.Spatial):
element: ifcopenshell.entity_instance,
relating_type: ifcopenshell.entity_instance,
) -> None:
bonsai.core.type.assign_type(ifc, tool.Model, type, element=element, type=relating_type)
bonsai.core.type.assign_type(ifc, type, element=element, type=relating_type)
@classmethod
def regen_obj_representation(cls, obj: bpy.types.Object, body: ifcopenshell.entity_instance) -> None:
@@ -1222,30 +1239,11 @@ class Spatial(bonsai.core.tool.Spatial):
tool.Geometry,
obj=obj,
representation=body,
should_reload=True,
is_global=True,
should_sync_changes_first=False,
)
@classmethod
def set_space_visibility(cls, is_visible: bool) -> None:
if tool.Ifc.get().schema == "IFC2X3":
elements = tool.Ifc.get().by_type("IfcSpatialStructureElement")
else:
elements = tool.Ifc.get().by_type("IfcSpatialElement")
for element in elements:
if obj := tool.Ifc.get_object(element):
if obj.hide_viewport is True and is_visible:
obj.hide_viewport = False
elif obj.hide_viewport is False and not is_visible:
obj.hide_viewport = True
@classmethod
def set_grid_visibility(cls, is_visible: bool) -> None:
for element in tool.Ifc.get().by_type("IfcGrid") + tool.Ifc.get().by_type("IfcGridAxis"):
if obj := tool.Ifc.get_object(element):
if obj.hide_viewport is True and is_visible:
obj.hide_viewport = False
elif obj.hide_viewport is False and not is_visible:
obj.hide_viewport = True
@classmethod
def toggle_spaces_visibility_wired_and_textured(cls, spaces: list[ifcopenshell.entity_instance]) -> None:
first_obj = tool.Ifc.get_object(spaces[0])
+14 -14
View File
@@ -1,14 +1,14 @@
Hierarchy,Identification,Name,ScheduleStart,ScheduleFinish,ScheduleDuration,ActualStart,ActualFinish,ActualDuration,Relationships
1,1,Demolition,,,,,,,
,,,,,,,,,
2,1.1,Building A,,,,,,,
,,,,,,,,,
3,1.1.1,Soft strip,01/01/23,,5d,07/01/23,10/01/23,,111FS112
3,1.1.2,Hard Strip,,,5d,,,,112FS113
3,1.1.3,Hazmat,,,7d,,,,113FS121
,,,,,,,,,
2,1.2,Building B,,,,,,,
,,,,,,,,,
3,1.2.1,Soft strip,,,3d,,,,121FS122
3,1.2.2,Hard Strip,,,4h,,,,122FS123
3,1.2.3,Hazmat,,,2d16h,,,,
Hierarchy,Identification,Name,ScheduleStart,ScheduleFinish,ScheduleDuration,ActualStart,ActualFinish,ActualDuration,EarlyStart,EarlyFinish,LateStart,LateFinish,IsCritical,Completion,Relationships
1.0,1,Demolition,,,,,,,,,,,False,0.0,
,,,,,,,,,,,,,False,0.0,
2.0,1.1,Building A,,,,,,,,,,,False,0.0,
,,,,,,,,,,,,,False,0.0,
3.0,1.1.1,Soft strip,01/01/23,,5d,07/01/23,10/01/23,,,,,,False,0.0,111FS112
3.0,1.1.2,Hard Strip,,,5d,,,,,,,,False,0.0,112FS113
3.0,1.1.3,Hazmat,,,7d,,,,,,,,False,0.0,113FS121
,,,,,,,,,,,,,False,0.0,
2.0,1.2,Building B,,,,,,,,,,,False,0.0,
,,,,,,,,,,,,,False,0.0,
3.0,1.2.1,Soft strip,,,3d,,,,,,,,False,0.0,121FS122
3.0,1.2.2,Hard Strip,,,4h,,,,,,,,False,0.0,122FS123
3.0,1.2.3,Hazmat,,,2d16h,,,,,,,,False,0.0,
1 Hierarchy Identification Name ScheduleStart ScheduleFinish ScheduleDuration ActualStart ActualFinish ActualDuration EarlyStart EarlyFinish LateStart LateFinish IsCritical Completion Relationships
2 1 1.0 1 Demolition False 0.0
3 False 0.0
4 2 2.0 1.1 Building A False 0.0
5 False 0.0
6 3 3.0 1.1.1 Soft strip 01/01/23 5d 07/01/23 10/01/23 False 0.0 111FS112
7 3 3.0 1.1.2 Hard Strip 5d False 0.0 112FS113
8 3 3.0 1.1.3 Hazmat 7d False 0.0 113FS121
9 False 0.0
10 2 2.0 1.2 Building B False 0.0
11 False 0.0
12 3 3.0 1.2.1 Soft strip 3d False 0.0 121FS122
13 3 3.0 1.2.2 Hard Strip 4h False 0.0 122FS123
14 3 3.0 1.2.3 Hazmat 2d16h False 0.0
+124 -93
View File
@@ -18,13 +18,13 @@
import csv
import locale
import re
import ifcopenshell
import ifcopenshell.api
import ifcopenshell.api.root
import ifcopenshell.api.sequence
import ifcopenshell.util.date
import locale
import re
class Csv2Ifc:
@@ -48,7 +48,7 @@ class Csv2Ifc:
with open(self.csv, "r", encoding="ISO-8859-1") as csv_file:
reader = csv.reader(csv_file)
for row in reader:
if not row[0]:
if not row or not row[0]:
continue
if row[0] == "Hierarchy":
for i, col in enumerate(row):
@@ -57,80 +57,88 @@ class Csv2Ifc:
self.headers[col] = i
continue
task = self.get_row_task(row)
hierarchy_key = int(row[0])
hierarchy_key = int(float(row[0]))
if hierarchy_key == 1:
self.tasks.append(task)
else:
self.parents[hierarchy_key - 1]["children"].append(task)
if hierarchy_key - 1 in self.parents:
self.parents[hierarchy_key - 1]["children"].append(task)
self.parents[hierarchy_key] = task
def parse_task_rel(self, task_relationships_str):
rels = []
if task_relationships_str:
for rel_str in task_relationships_str.split(";"):
rel_parts = re.split(r"(\d+)", rel_str)
task_1 = rel_parts[1]
rel_type = rel_parts[2]
task_2 = rel_parts[3]
rels.append(
{
rel_str = rel_str.strip()
match = re.search(r"([A-Z]{2})", rel_str)
if not match:
continue
rel_type = match.group(1)
parts = rel_str.split(rel_type)
task_1 = parts[0]
remaining_part = parts[1]
lag_match = re.match(r"([+\-]\w+)(.*)", remaining_part)
if lag_match:
lag = lag_match.group(1)
task_2 = lag_match.group(2)
else:
lag = None
task_2 = remaining_part
if task_1 and task_2 and rel_type:
rels.append({
"rel_type": rel_type,
"task_1": task_1,
"task_2": task_2,
}
)
"lag": lag
})
return rels
def get_row_task(self, row):
hours_per_day = 8
hierarchy = row[self.headers["Hierarchy"]]
identification = row[self.headers["Identification"]]
task_name = row[self.headers["Name"]]
task_relationships = self.parse_task_rel(row[self.headers.get("Relationships", -1)] if "Relationships" in self.headers else "")
task_relationships = self.parse_task_rel(row[self.headers["Relationships"]])
def get_date(col_name):
if col_name in self.headers and row[self.headers[col_name]]:
return ifcopenshell.util.date.string_to_date(row[self.headers[col_name]])
return None
scheduled_start_date = (
ifcopenshell.util.date.string_to_date(row[self.headers["ScheduleStart"]])
if row[self.headers["ScheduleStart"]]
else None
)
scheduled_finish_date = (
ifcopenshell.util.date.string_to_date(row[self.headers["ScheduleFinish"]])
if row[self.headers["ScheduleFinish"]]
else None
)
scheduled_duration = (
ifcopenshell.util.date.string_to_duration(row[self.headers["ScheduleDuration"]])
if row[self.headers["ScheduleDuration"]]
else None
)
actual_start_date = (
ifcopenshell.util.date.string_to_date(row[self.headers["ActualStart"]])
if row[self.headers["ActualStart"]]
else None
)
actual_finish_date = (
ifcopenshell.util.date.string_to_date(row[self.headers["ActualFinish"]])
if row[self.headers["ActualFinish"]]
else None
)
actual_duration = (
ifcopenshell.util.date.string_to_duration(row[self.headers["ActualDuration"]])
if row[self.headers["ActualDuration"]]
else None
)
def get_duration(col_name):
if col_name in self.headers and row[self.headers[col_name]]:
return ifcopenshell.util.date.string_to_duration(row[self.headers[col_name]])
return None
def get_bool(col_name):
if col_name in self.headers and row[self.headers[col_name]]:
return row[self.headers[col_name]].upper() == 'TRUE'
return False
def get_float(col_name):
if col_name in self.headers and row[self.headers[col_name]]:
try:
return float(row[self.headers[col_name]])
except (ValueError, TypeError):
return None
return None
return {
"Hierarchy": hierarchy,
"Identification": identification,
"Name": task_name,
"Hierarchy": row[self.headers["Hierarchy"]],
"Identification": row[self.headers["Identification"]],
"Name": row[self.headers["Name"]],
"Relationships": task_relationships,
"ScheduleStart": scheduled_start_date,
"ScheduleFinish": scheduled_finish_date,
"ScheduleDuration": scheduled_duration,
"ActualStart": actual_start_date,
"ActualFinish": actual_finish_date,
"ActualDuration": actual_duration,
"ScheduleStart": get_date("ScheduleStart"),
"ScheduleFinish": get_date("ScheduleFinish"),
"ScheduleDuration": get_duration("ScheduleDuration"),
"ActualStart": get_date("ActualStart"),
"ActualFinish": get_date("ActualFinish"),
"ActualDuration": get_duration("ActualDuration"),
"EarlyStart": get_date("EarlyStart"),
"EarlyFinish": get_date("EarlyFinish"),
"LateStart": get_date("LateStart"),
"LateFinish": get_date("LateFinish"),
"IsCritical": get_bool("IsCritical"),
"Completion": get_float("Completion"),
"children": [],
}
@@ -141,7 +149,16 @@ class Csv2Ifc:
self.work_plan = ifcopenshell.api.sequence.add_work_plan(self.file)
self.work_schedule = self.create_work_schedule()
self.create_tasks(self.tasks)
self.create_rel_sequences(self.tasks)
self.sequence_type_map = {
"FF": "FINISH_FINISH",
"FS": "FINISH_START",
"SF": "START_FINISH",
"SS": "START_START",
}
all_tasks = self.get_all_tasks_flat()
for task in all_tasks:
self.create_relationships_for_task(task)
def create_boilerplate_ifc(self):
self.file = ifcopenshell.file(schema="IFC4")
@@ -152,16 +169,6 @@ class Csv2Ifc:
for task in tasks:
self.create_task(task, parent)
def create_rel_sequences(self, tasks=None):
self.sequence_type_map = {
"FF": "FINISH_FINISH",
"FS": "FINISH_START",
"SF": "START_FINISH",
"SS": "START_START",
}
for task in tasks:
self.create_rel_sequence(task)
def create_work_schedule(self):
return ifcopenshell.api.sequence.add_work_schedule(self.file, name="import", work_plan=self.work_plan)
@@ -177,10 +184,10 @@ class Csv2Ifc:
attributes={
"Name": task["Name"],
"Identification": task["Identification"],
# "IsMilestone": task["ScheduleStart"] == task["ScheduleFinish"],
},
)
task_time = ifcopenshell.api.sequence.add_task_time(self.file, task=task["ifc"])
ifcopenshell.api.sequence.edit_task_time(
self.file,
task_time=task_time,
@@ -192,35 +199,59 @@ class Csv2Ifc:
"ActualStart": task["ActualStart"],
"ActualFinish": task["ActualFinish"],
"ActualDuration": task["ActualDuration"],
"EarlyStart": task["EarlyStart"],
"EarlyFinish": task["EarlyFinish"],
"LateStart": task["LateStart"],
"LateFinish": task["LateFinish"],
"IsCritical": task["IsCritical"],
"Completion": task["Completion"],
},
)
self.create_tasks(task["children"], task["ifc"])
def get_entity_by_identification(self, tasks, task_identification):
for task in tasks or []:
if task["Identification"].replace(".", "").replace(" ", "") == task_identification.replace(" ", ""):
return task["ifc"]
else:
entity = self.get_entity_by_identification(task["children"], task_identification)
if entity:
return entity
def get_all_tasks_flat(self, tasks=None):
if tasks is None:
tasks = self.tasks
flat_list = []
for task in tasks:
flat_list.append(task)
flat_list.extend(self.get_all_tasks_flat(task["children"]))
return flat_list
def get_entity_by_identification(self, task_id):
all_tasks = self.get_all_tasks_flat()
for task in all_tasks:
if task["Identification"].replace(".", "").replace(" ", "") == task_id.replace(".", "").replace(" ", ""):
return task.get("ifc")
return None
def create_rel_sequence(self, task=None):
for rel in task["Relationships"] or []:
task_1 = self.get_entity_by_identification(self.tasks, rel["task_1"])
task_2 = self.get_entity_by_identification(self.tasks, rel["task_2"])
rel_type = self.sequence_type_map[rel["rel_type"]]
rel_sequence = ifcopenshell.api.sequence.assign_sequence(
self.file,
related_process=task_2,
relating_process=task_1,
sequence_type=rel_type,
)
def create_relationships_for_task(self, task):
for rel in task.get("Relationships", []) or []:
task_1_ifc = self.get_entity_by_identification(rel.get("task_1"))
task_2_ifc = self.get_entity_by_identification(rel.get("task_2"))
if not task_1_ifc or not task_2_ifc:
continue
time_lag = None
if rel.get("lag"):
try:
duration_str = rel["lag"].replace("+", "")
duration = ifcopenshell.util.date.string_to_duration(duration_str)
time_lag = self.file.create_entity(
"IfcLagTime",
LagValue=duration,
DurationType="WORKTIME"
)
except Exception as e:
print(f"Advertencia: No se pudo analizar el tiempo de retraso '{rel['lag']}': {e}")
pass
rel_type = self.sequence_type_map.get(rel.get("rel_type"))
if rel_type:
ifcopenshell.api.sequence.edit_sequence(
ifcopenshell.api.sequence.assign_sequence(
self.file,
rel_sequence=rel_sequence,
attributes={"SequenceType": rel_type},
related_process=task_2_ifc,
relating_process=task_1_ifc,
sequence_type=rel_type
)
self.create_rel_sequences(task["children"])