-
-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathexport_indicators.py
More file actions
669 lines (551 loc) · 23.4 KB
/
export_indicators.py
File metadata and controls
669 lines (551 loc) · 23.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
# export_indicators.py
"""
Handles visual indicators (object colour changes) for recently
exported objects.
Relies on custom properties set by the main export operator:
- mesh_export_timestamp: Time of export.
- mesh_export_status: Current status (FRESH, STALE, NONE).
Requires the user to set their 3D Viewport shading colour
type to 'Object' in Solid display mode to see the colour changes.
"""
import bpy
import time
import logging
from enum import Enum
from bpy.types import Operator
# --- Setup Logger ---
logger = logging.getLogger(__name__)
# Clear any existing handlers to prevent accumulation on addon reload
if logger.handlers:
logger.handlers.clear()
handler = logging.StreamHandler()
formatter = logging.Formatter("%(name)s:%(levelname)s: %(message)s")
handler.setFormatter(formatter)
logger.addHandler(handler)
logger.setLevel(logging.INFO) # Default level
# Prevent propagation to avoid duplicate logs
logger.propagate = False
# --- Constants ---
class ExportStatus(Enum):
"""Enum to represent the export status based on time."""
FRESH = 0 # Just exported (green)
STALE = 1 # Exported a while ago (yellow)
NONE = 2 # No indicator needed / Expired
# Timing constants (in seconds)
FRESH_DURATION_SECONDS = 60 # 1 minute
STALE_DURATION_SECONDS = 300 # 5 minutes
# FRESH_DURATION_SECONDS = 5 # debug
# STALE_DURATION_SECONDS = 10 # debug
# Custom property names
EXPORT_TIME_PROP = "mesh_export_timestamp"
EXPORT_STATUS_PROP = "mesh_export_status"
ORIGINAL_COLOUR_PROP = "mesh_exporter_original_colour"
# Caching for performance optimization
_exported_objects_cache = []
_cache_last_update = 0
_cache_update_interval = 10.0 # Update cache every 10 seconds
# Export status colours (RGBA tuple)
STATUS_COLOURS = {
ExportStatus.FRESH.value: (0.2, 0.8, 0.2, 1.0), # Green
ExportStatus.STALE.value: (0.8, 0.8, 0.2, 1.0), # Yellow
}
# Timer interval
_TIMER_INTERVAL_SECONDS = 5.0
# --- Core Functions ---
def mark_object_as_exported(obj):
"""
Mark an object as just exported by setting custom properties.
This is used to track the export status of objects.
Args:
obj (bpy.types.Object): The object to mark as exported.
Returns:
None
"""
if obj is None or obj.type != "MESH":
return
# Check if export indicators are enabled in scene properties
scene = bpy.context.scene
if hasattr(scene, "mesh_exporter") and scene.mesh_exporter:
if not scene.mesh_exporter.mesh_export_show_indicators:
logger.debug(f"Export indicators disabled, skipping marking for {obj.name}")
return
# Ensure timer is registered
if not bpy.app.timers.is_registered(update_timer_callback):
logger.warning("Export indicators timer not registered - registering now")
try:
bpy.app.timers.register(
update_timer_callback,
first_interval=_TIMER_INTERVAL_SECONDS,
persistent=True,
)
except Exception as e:
logger.error(f"Failed to register timer in mark_object_as_exported: {e}")
# Mark the object
obj[EXPORT_TIME_PROP] = time.time()
obj[EXPORT_STATUS_PROP] = ExportStatus.FRESH.value
set_object_colour(obj)
logger.info(f"Marked {obj.name} as freshly exported")
# Invalidate cache since we added a new exported object
global _cache_last_update
_cache_last_update = 0
def _delete_prop(obj, prop_name):
"""
Safely delete a custom property from an object if it exists.
Args:
obj (bpy.types.Object): The object to modify.
prop_name (str): The name of the property to delete.
Returns:
bool: True if the property was deleted or didn't exist, False on error.
"""
if not obj or not hasattr(obj, "name"):
logger.debug(f"Cannot delete prop {prop_name}, object invalid.")
return False
if obj.get(prop_name) is None:
return True # Property doesn't exist, consider it "deleted"
try:
del obj[prop_name]
logger.debug(f"Deleted prop {prop_name} for {obj.name}")
return True
except (ReferenceError, KeyError):
logger.debug(f"Prop {prop_name} already gone/obj invalid for {obj.name}.")
# Consider it deleted if it's gone
return True
except Exception as e:
logger.warning(f"Error removing prop {prop_name} from {obj.name}: {e}")
return False
def restore_object_colour(obj):
"""
Restore object's original viewport colour if stored previously.
Also ensures the original colour property is removed.
Args:
obj (bpy.types.Object): The object whose colour to restore.
Returns:
bool: True if colour was restored or not needed, False on failure.
"""
if not obj or not hasattr(obj, "name"):
logger.debug("Skipping restore: object invalid.")
return False
if ORIGINAL_COLOUR_PROP not in obj:
logger.debug(f"No original colour stored for {obj.name}, skipping restore.")
return True # Nothing needed to restore
logger.debug(f"Attempting to restore colour for {obj.name}...")
original_colour_stored = None
restore_success = False
try:
original_colour_stored = obj[ORIGINAL_COLOUR_PROP]
log_type = type(original_colour_stored)
logger.debug(
f"Retrieved stored colour prop for {obj.name}: "
f"{original_colour_stored} (Type: {log_type})"
)
# Check if it behaves like a sequence of length 4
is_valid_sequence = False
if hasattr(original_colour_stored, "__len__") and hasattr(
original_colour_stored, "__getitem__"
):
try:
if len(original_colour_stored) == 4:
is_valid_sequence = True
except Exception as e:
logger.warning(
f"Error checking len/getitem for stored colour on {obj.name}: {e}"
)
if is_valid_sequence:
try:
original_colour_tuple = tuple(float(c) for c in original_colour_stored)
current_colour_tuple = tuple(float(c) for c in obj.color)
if current_colour_tuple != original_colour_tuple:
obj.color = original_colour_tuple
logger.info(
f"Restored colour for {obj.name} to {original_colour_tuple}"
)
else:
logger.debug(f"Colour for {obj.name} already matches original.")
restore_success = True
except (AttributeError, TypeError, ValueError) as e:
logger.error(
f"Failed to apply stored colour {original_colour_stored} "
f"for {obj.name}: {e}"
)
except ReferenceError:
logger.warning(f"Object {obj.name} became invalid during colour apply.")
else:
logger.warning(
f"Invalid original colour data stored for {obj.name}: "
f"Not a sequence of length 4 "
f"(Value: {original_colour_stored}, Type: {log_type}). "
f"Cannot restore."
)
except KeyError:
logger.debug(f"Original colour prop key missing for {obj.name}.")
restore_success = True # Prop gone, consider restore "successful"
except ReferenceError:
logger.warning(f"Object {obj.name} became invalid during restore read.")
except Exception as e:
logger.error(f"Unexpected error restoring colour for {obj.name}: {e}")
# Always ensure the original colour prop
# is removed after attempting restore.
logger.debug(f"Ensuring cleanup of original colour prop for {obj.name}")
_delete_prop(obj, ORIGINAL_COLOUR_PROP)
return restore_success
def set_object_colour(obj):
"""
Set object viewport colour based on its current export status.
Stores the original colour if setting an indicator colour
for the first time.
Args:
obj (bpy.types.Object): The object whose colour to set.
"""
if not obj or not hasattr(obj, "name"):
logger.debug("Skipping set_object_colour: object invalid.")
return
if EXPORT_STATUS_PROP not in obj:
logger.debug(f"Skipping set_object_colour for {obj.name}: No status prop.")
return
try:
status = obj.get(EXPORT_STATUS_PROP, ExportStatus.NONE.value)
target_colour = STATUS_COLOURS.get(status)
if not target_colour:
logger.debug(f"No target colour for {obj.name} (status={status}).")
return
# --- Store Original Colour (If Needed) ---
if ORIGINAL_COLOUR_PROP not in obj:
try:
current_colour_prop = obj.color
logger.debug(
f"Reading obj.color for {obj.name}: "
f"{current_colour_prop} "
f"(Type: {type(current_colour_prop)})"
)
# Store as list of floats
original_colour_list = [float(c) for c in current_colour_prop]
if len(original_colour_list) == 4:
obj[ORIGINAL_COLOUR_PROP] = original_colour_list
logger.debug(
f"Stored original colour for {obj.name} "
f"as list: {original_colour_list}"
)
else:
logger.warning(
f"Could not store original colour for {obj.name}: "
f"obj.color returned unexpected length: "
f"{current_colour_prop}"
)
except (AttributeError, TypeError, ValueError, ReferenceError) as e:
logger.warning(
f"Could not read or store original colour for {obj.name}: "
f"{e}. Restore may fail."
)
# --- Apply Status Colour ---
try:
current_colour_tuple = tuple(float(c) for c in obj.color)
if current_colour_tuple != target_colour:
obj.color = target_colour
logger.debug(f"Set colour for {obj.name} to {target_colour}")
# Property exists since Blender 2.8 according to docs
if hasattr(obj, "show_instancer_for_viewport"):
obj.show_instancer_for_viewport = True
except (AttributeError, TypeError, ValueError, ReferenceError) as e:
logger.error(f"Failed to set status colour/property for {obj.name}: {e}")
except ReferenceError:
logger.debug(f"Object {obj.name} became invalid during set_object_colour.")
except Exception as e:
logger.error(f"Unexpected error in set_object_colour for {obj.name}: {e}")
def _get_cached_exported_objects():
"""Get cached list of objects with export properties.
Returns:
list: List of valid mesh objects with export properties
"""
global _exported_objects_cache, _cache_last_update
current_time = time.time()
# Update cache if it's stale
if (current_time - _cache_last_update) >= _cache_update_interval:
valid_objects = []
for obj in bpy.data.objects:
try:
# Verify object is still valid by accessing a property
if obj and obj.type == "MESH" and EXPORT_TIME_PROP in obj:
valid_objects.append(obj)
except ReferenceError:
# Object was deleted, skip it
logger.debug("Skipping invalid object reference in cache update")
continue
_exported_objects_cache = valid_objects
_cache_last_update = current_time
logger.debug(
f"Updated exported objects cache: {len(_exported_objects_cache)} objects"
)
# Filter out any invalid references that may have appeared since last update
return [obj for obj in _exported_objects_cache if _is_valid_object(obj)]
def _is_valid_object(obj):
"""Check if an object reference is still valid.
Args:
obj: The object to check
Returns:
bool: True if object is valid, False otherwise
"""
if not obj:
return False
try:
# Access a property to trigger ReferenceError if object was deleted
_ = obj.type
return True
except ReferenceError:
return False
def update_all_export_statuses():
"""
Iterate through objects, update export status based on elapsed time.
Returns True if any object's status changed, indicating a redraw is needed.
"""
current_time = time.time()
needs_redraw = False
status_changes = []
if not bpy.data or not bpy.data.objects:
logger.debug("No objects found to update statuses")
return False
# Use cached list of exported objects for better performance
exported_objects = _get_cached_exported_objects()
# Iterate over cached exported objects only
for obj in exported_objects:
try:
if not (obj and obj.type == "MESH" and EXPORT_TIME_PROP in obj):
continue
export_time = obj.get(EXPORT_TIME_PROP, 0)
if not export_time:
logger.warning(f"Object {obj.name} missing timestamp prop.")
continue
elapsed_time = current_time - export_time
old_status_val = obj.get(EXPORT_STATUS_PROP, ExportStatus.NONE.value)
old_status_name = (
ExportStatus(old_status_val).name
if isinstance(old_status_val, int)
else "UNKNOWN"
)
new_status = ExportStatus.NONE
if elapsed_time < FRESH_DURATION_SECONDS:
new_status = ExportStatus.FRESH
elif elapsed_time < STALE_DURATION_SECONDS:
new_status = ExportStatus.STALE
new_status_val = new_status.value
# Only log when there's a status change
if new_status_val != old_status_val:
status_changes.append(
(obj.name, old_status_name, new_status.name, elapsed_time)
)
needs_redraw = True
# State transition logic
if new_status == ExportStatus.NONE:
# Restores colour, removes original prop
restore_object_colour(obj)
# Remove remaining tracking props
_delete_prop(obj, EXPORT_TIME_PROP)
_delete_prop(obj, EXPORT_STATUS_PROP)
else:
# Becoming FRESH or STALE: Set new status prop, then colour
obj[EXPORT_STATUS_PROP] = new_status_val
set_object_colour(obj)
except ReferenceError:
logger.debug("Object became invalid during status update loop.")
continue
except Exception as e:
obj_name = obj.name if obj and hasattr(obj, "name") else "N/A"
logger.error(f"Error updating status for object {obj_name}: {e}")
continue
# Log status changes together for easier debugging
if status_changes:
logger.info(f"Status changes detected: {len(status_changes)} objects updated")
for change in status_changes:
name, old, new, elapsed = change
logger.info(f" → {name}: {old} → {new} (elapsed: {elapsed:.1f}s)")
return needs_redraw
def get_recently_exported_objects():
"""Get a list of objects with active FRESH/STALE status, sorted."""
exported_objects = []
if not bpy.data or not bpy.data.objects:
return []
for obj in bpy.data.objects:
try:
if obj and obj.type == "MESH" and EXPORT_TIME_PROP in obj:
status = obj.get(EXPORT_STATUS_PROP, ExportStatus.NONE.value)
if status != ExportStatus.NONE.value:
timestamp = obj.get(EXPORT_TIME_PROP, 0)
exported_objects.append((obj, timestamp))
except ReferenceError:
continue # Object invalid
return sorted(exported_objects, key=lambda item: item[1], reverse=True)
# --- Timer Logic ---
def update_timer_callback():
"""Function called periodically by Blender's timer."""
try:
current_time = time.time()
logger.debug(f"[MESH_EXPORTER] Timer tick at {current_time}")
# Check if indicators are enabled
scene = bpy.context.scene
if hasattr(scene, "mesh_exporter") and scene.mesh_exporter:
if not scene.mesh_exporter.mesh_export_show_indicators:
logger.debug("Export indicators disabled, skipping timer update")
# Still return interval to keep timer registered
return _TIMER_INTERVAL_SECONDS
status_updated = update_all_export_statuses()
if status_updated:
# More aggressive UI updating
context = bpy.context
if (
context
and hasattr(context, "window_manager")
and context.window_manager
):
for window in context.window_manager.windows:
if not hasattr(window, "screen") or not window.screen:
continue
for area in window.screen.areas:
try:
# Force update for all area types
area.tag_redraw()
except Exception as e:
# Log potential errors during redraw
# without stopping timer
logger.debug(f"Error redrawing area {area.type}: {e}")
pass
else:
logger.warning("Timer callback couldn't redraw: invalid context")
except Exception as e:
# Log but don't stop the timer
logger.error(f"[MESH_EXPORTER] Timer error: {e}", exc_info=True)
# Always return the interval to keep the timer running
return _TIMER_INTERVAL_SECONDS
# --- Operators ---
class MESH_OT_clear_all_indicators(Operator):
"""Clears export indicators from all mesh objects."""
bl_idname = "mesh.clear_export_indicators"
bl_label = "Clear All Export Indicators"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
"""Runs the clear operation."""
count = 0
if not bpy.data or not bpy.data.objects:
logger.warning("Clear Indicators: No Blender data found.")
return {"CANCELLED"}
logger.info("Clearing all export indicators...")
# Iterate over list copy for safety
for obj in list(bpy.data.objects):
if (
obj
and obj.type == "MESH"
and (EXPORT_TIME_PROP in obj or ORIGINAL_COLOUR_PROP in obj)
):
obj_name = obj.name
try:
# Handles original colour prop removal
restore_object_colour(obj)
_delete_prop(obj, EXPORT_TIME_PROP)
_delete_prop(obj, EXPORT_STATUS_PROP)
count += 1
except Exception as e:
logger.warning(f"Error clearing indicators for {obj_name}: {e}")
msg = f"Cleared export indicators from {count} objects."
self.report({"INFO"}, msg)
logger.info(msg)
# Trigger redraw after clearing
if context and context.window_manager:
for window in context.window_manager.windows:
if not window.screen:
continue
for area in window.screen.areas:
try:
area.tag_redraw()
except ReferenceError:
pass # Area might close
return {"FINISHED"}
class MESH_OT_debug_update_indicators(Operator):
"""Forces an immediate update of all export indicators."""
bl_idname = "mesh.debug_update_indicators"
bl_label = "Update Export Indicators"
bl_options = {"REGISTER"}
def execute(self, context):
"""Runs the update operation."""
status_changed = update_all_export_statuses()
msg = f"Export indicators updated. Status changed: {status_changed}"
self.report({"INFO"}, msg)
logger.info(msg)
# Force redraw
for window in context.window_manager.windows:
if window.screen:
for area in window.screen.areas:
area.tag_redraw()
return {"FINISHED"}
# --- Registration ---
operators_to_register = (
MESH_OT_clear_all_indicators,
MESH_OT_debug_update_indicators,
)
def register():
"""Registers classes and starts the timer."""
logger.debug("Registering export indicator classes...")
for cls in operators_to_register:
try:
bpy.utils.register_class(cls)
except ValueError:
logger.debug(f"Class {cls.__name__} already registered.")
pass
# Always unregister first to prevent duplicate timers
if bpy.app.timers.is_registered(update_timer_callback):
try:
bpy.app.timers.unregister(update_timer_callback)
logger.debug("Unregistered existing timer before re-registering")
except Exception as e:
logger.warning(f"Failed to unregister existing timer: {e}")
# Now register a fresh timer with persistence
try:
bpy.app.timers.register(
update_timer_callback,
first_interval=_TIMER_INTERVAL_SECONDS,
persistent=True, # Make timer survive file loads
)
logger.info(
f"Export indicator timer registered (interval: "
f"{_TIMER_INTERVAL_SECONDS}s, persistent)"
)
except Exception as e:
logger.error(f"Failed to register timer: {e}", exc_info=True)
def unregister():
"""Unregisters classes, stops timer, and cleans up objects."""
logger.info("Unregistering export indicators...")
# Stop and unregister timer
if bpy.app.timers.is_registered(update_timer_callback):
try:
bpy.app.timers.unregister(update_timer_callback)
logger.info("Export indicator timer unregistered.")
except Exception as e:
logger.error(f"Failed to unregister timer: {e}")
# No longer restoring viewport settings automatically
# Cleanup object properties and colours
try:
count_cleaned = 0
if bpy.data and bpy.data.objects:
for obj in list(bpy.data.objects): # Use list copy
if (
obj
and obj.type == "MESH"
and (EXPORT_TIME_PROP in obj or ORIGINAL_COLOUR_PROP in obj)
):
obj_name = obj.name
try:
restore_object_colour(obj)
_delete_prop(obj, EXPORT_TIME_PROP)
_delete_prop(obj, EXPORT_STATUS_PROP)
count_cleaned += 1
except Exception as inner_e:
logger.warning(f"Error cleaning up {obj_name}: {inner_e}")
if count_cleaned > 0:
logger.info(f"Cleaned up indicators for {count_cleaned} objects.")
except Exception as e:
logger.error(f"Error during object property cleanup: {e}")
# Unregister operators
for cls in reversed(operators_to_register):
try:
bpy.utils.unregister_class(cls)
except RuntimeError:
logger.debug(f"Class {cls.__name__} already unregistered.")
pass
logger.debug("Export indicator unregistration finished.")