diff --git a/src/bonsai/bonsai/bim/module/status_render/NOTES.md b/src/bonsai/bonsai/bim/module/status_render/NOTES.md index 77438309e3..d8465d2c5f 100644 --- a/src/bonsai/bonsai/bim/module/status_render/NOTES.md +++ b/src/bonsai/bonsai/bim/module/status_render/NOTES.md @@ -71,6 +71,84 @@ keys by `.identifier`, not `.name` — relevant if ever touching pass sockets ag - `render_complete` / `render_cancel` → `clear_compositor` (materials persist) - `save_pre` → `restore_transparency` (never bake temp materials into the .blend) - `save_post`, `load_post` → `sync_live_effects` (re-apply the live preview) +- `depsgraph_update_post` → re-apply after a geometry edit rebuilt an object we swapped + (see below) + +### Surviving representation reloads + +Editing geometry replaces the object's data outright — `Geometry.change_object_data` +does `obj.data = data` — so the material slots come back from IFC and the temporary +transparent copies are silently dropped. Nothing notifies this module, so the preview +would just vanish until the toggle was cycled. Reproduced with: + +``` +bim.enable_editing_extrusion_profile → bim.direct_profile_edit → editmode_toggle + → bim.edit_extrusion_profile → bim.direct_profile_edit +``` + +Two parts to the fix: + +- `_transparency_restore` records the temp material alongside the original, and + `restore_transparency` only undoes a slot that **still holds that temp material**. + Otherwise the restore writes the pre-edit material over whatever the reload just put + there. +- `depsgraph_update_post` → `live_state_is_stale()` → deferred `sync_live_effects`. Kept + cheap: returns immediately unless transparency is applied *and* the update names an + object in `_tracked_objects`. Skipped outside OBJECT mode (leaving edit mode fires + another update), and deferred through `bpy.app.timers` so material writes happen + outside the notification, as with the msgbus camera subscription. The staleness check + is what stops it looping: our own re-apply leaves nothing stale. + +### Scale: a rule can match thousands of objects + +The first version copied a material **per slot**, so a rule matching a few thousand +elements created a few thousand material datablocks (each with its own node tree), and +every re-sync tore all of it down and rebuilt it. Two changes make that workable: + +- **Shared temp materials.** `get_temp_material(material, amount)` caches by + `(source material name, amount)`, so the count is the number of distinct + material/amount pairs — usually a handful — not the number of slots. Sharing stays + safe for `user_remap` precisely because every user of a temp material had the same + original. The source material name is recorded on the temp as `SOURCE_KEY`. +- **Incremental sync.** `sync_live_effects` no longer restores everything and re-applies. + Pass 1 keeps slots already carrying the right temp material and undoes only what is no + longer wanted; pass 2 applies only to what is not already covered. As a side effect it + can no longer destroy a preview it is unable to rebuild, which was its own bug. + +Two supporting details: + +- `live_state_is_stale(names)` is scoped to the objects a depsgraph update actually + touched. Walking every swapped slot on every update is wasted work at this scale. +- `_tracked_objects` holds what the rules **want**, not what was successfully applied, so + an object whose mesh is mid-rebuild is still watched and picked up when it returns. + Objects with nothing swappable never report stale, or they would request a resync + forever. + +`sync_live_effects` also **adopts orphans** — a slot holding one of our temp materials +with no matching entry, left behind by a rebuild we did not observe. It is traced back +via `SOURCE_KEY` and re-registered, rather than silently baked into the saved file. + +Smoke-tested headless on 4.5.7: three objects sharing one source material produce exactly +one temp material, and a stray reference is remapped rather than emptied. + +### Known limitation: elements with no material + +Transparency is produced by **copying the element's existing material** and mixing a +Transparent BSDF into it. An element with no Blender material — no IFC surface style — +has nothing to copy, so `apply_transparency` skips it and the rule silently does +nothing for that element (`has NO material slots` in the debug trace). Confirmed +2026-09-02: assigning the elements a material makes it work. + +Making it work regardless means inventing a material and appending a slot, which +mutates `obj.data` — IFC-linked geometry that Bonsai checksums via +`Geometry.record_object_materials` to decide whether styles need writing back. Not done +for that reason; revisit only with a test that the model is not dirtied. + +Do not confuse this with a slot that exists but is *empty*. That was a bug in this +module — `bpy.data.materials.remove()` unlinks from every user, so deleting a temp +material that had ridden onto a rebuilt mesh emptied the live slot. Fixed by +`user_remap`-ing back to the original first and never force-deleting a temp material +that still has users. ## Storage / gating @@ -80,9 +158,12 @@ keys by `.identifier`, not `.name` — relevant if ever touching pass sockets ag `status_render_{rule_index}` (see `tool/search.py`). - The render path is the compositor, which only runs for F12 and `render.render()` drawing underlays. Viewport/OpenGL drawings bypass it, so the panel disables the - toggle when the *applied* shading style (`EPset_Drawing.CurrentShadingStyle`) is - not "Default" render type. The drawing also needs an underlay for the override to - appear in it. + toggle when the style is not a "Default" render type. The gate reads exactly what + `generate_underlay` branches on — `BIMCameraProperties.get_active_drawing_style()`, + the row highlighted in the Drawing Styles list — **not** + `EPset_Drawing.CurrentShadingStyle`, which is only rewritten by + `bim.activate_drawing_style` and readily goes stale (see issue #9319). The drawing + also needs an underlay for the override to appear in it. ## IFC persistence (coarse auto-sync) diff --git a/src/bonsai/bonsai/bim/module/status_render/operator.py b/src/bonsai/bonsai/bim/module/status_render/operator.py index db0f7856da..88f7d19ea2 100644 --- a/src/bonsai/bonsai/bim/module/status_render/operator.py +++ b/src/bonsai/bonsai/bim/module/status_render/operator.py @@ -42,6 +42,23 @@ import bonsai.tool as tool # without touching the user's other compositor nodes. MARKER = "bim_status_render_override" +# TEMP DEBUG: tracing why the live transparency is not re-applied after a +# representation reload (profile edit round trip). Set False to silence. +DEBUG = True + + +def _dbg(message): + if DEBUG: + print(f"[transp-debug] {message}") + + +def _brief(names, limit=3): + """Format a name list for logging without dumping thousands of entries.""" + names = list(names) + head = ', '.join(str(n) for n in names[:limit]) + extra = len(names) - limit + return f"[{head}{f', +{extra} more' if extra > 0 else ''}] ({len(names)})" + def get_filtered_elements(filter_groups): """Resolve a rule's filter groups to a set of IFC elements. @@ -178,14 +195,20 @@ def add_mix_node(tree): # Transparency is a real material effect (so geometry behind shows through), applied # before the render and restored after. These hold the swap state between the render # handlers. Only one render runs at a time, so module-level state is safe. -_transparency_restore = [] # list of (object, slot_index, original_material) -_temp_materials = [] # temporary transparent materials to delete on restore +_transparency_restore = [] # (object, slot_index, original_material, temp_material, amount) +_temp_material_cache = {} # (source material name, amount) -> shared temp material +_tracked_objects = set() # names the rules WANT transparent, for depsgraph filtering + +# Recorded on each temp material so an orphan (one left on a mesh rebuilt behind our +# back) can still be traced to what it replaced. +SOURCE_KEY = "bim_status_render_source" def make_transparent_material(material, amount): """Copy a material and mix a Transparent BSDF into its surface by ``amount``.""" dup = material.copy() dup[MARKER] = True + dup[SOURCE_KEY] = material.name tool.Style.set_use_nodes(dup, True) # deprecated no-op on Blender 5+, where it is always on tree = dup.node_tree output = next((n for n in tree.nodes if n.type == "OUTPUT_MATERIAL" and n.is_active_output), None) @@ -206,33 +229,106 @@ def make_transparent_material(material, amount): return dup -def apply_transparency(objects, amount): - """Swap each object's materials for transparent copies, remembering the originals.""" - for obj in objects: - for index, slot in enumerate(obj.material_slots): - material = slot.material - if material is None or material.get(MARKER): - continue # no material, or already a temp material from another rule - dup = make_transparent_material(material, amount) - _temp_materials.append(dup) - _transparency_restore.append((obj, index, material)) - slot.material = dup +def get_temp_material(material, amount): + """A transparent copy of ``material``, shared by every slot that needs it. + + Copying per slot meant one material datablock -- and one node tree -- per + occurrence, so thousands on a real model. Keyed by source material and amount + instead, which is a handful. Sharing stays safe for user_remap because every user + of a given temp material had the same original. + """ + key = (material.name, round(amount, 4)) + temp = _temp_material_cache.get(key) + if temp is not None: + try: + temp.name # liveness probe; raises if it was deleted behind our back + return temp + except ReferenceError: + pass + temp = make_transparent_material(material, amount) + _temp_material_cache[key] = temp + return temp + + +def release_unused_temp_materials(): + """Drop cached temp materials nothing references any more.""" + in_use = {entry[3] for entry in _transparency_restore} + for key, temp in list(_temp_material_cache.items()): + try: + if temp in in_use or temp.users: + continue + bpy.data.materials.remove(temp) + except (ReferenceError, RuntimeError): + pass + _temp_material_cache.pop(key, None) def restore_transparency(): - """Put the original materials back and delete the temporary transparent copies.""" - for obj, index, material in _transparency_restore: + """Put every original material back and drop the temporary copies. + + Full teardown, for saving and for switching the override off. sync_live_effects + does the incremental version. + """ + for obj, index, material, temp, _amount in _transparency_restore: try: - obj.material_slots[index].material = material + slot = obj.material_slots[index] + if slot.material is temp: + slot.material = material except (IndexError, ReferenceError): pass _transparency_restore.clear() - for material in _temp_materials: + _tracked_objects.clear() + for temp in list(_temp_material_cache.values()): try: - bpy.data.materials.remove(material) - except (ReferenceError, RuntimeError): - pass - _temp_materials.clear() + # Anything still pointing at a temp material must be remapped back before + # deletion -- a representation reload can carry one onto the rebuilt mesh, + # or onto a replacement object our (obj, index) no longer names. + # bpy.data.materials.remove unlinks from every user, so without this it + # leaves those slots EMPTY and the element ends up with no material. + if temp.users: + source = bpy.data.materials.get(temp.get(SOURCE_KEY, "") or "") + if source is not None: + temp.user_remap(source) + if not temp.users: + bpy.data.materials.remove(temp) + except (ReferenceError, RuntimeError, AttributeError) as e: + _dbg(f"temp material cleanup failed: {type(e).__name__}: {e}") + _temp_material_cache.clear() + + +def live_state_is_stale(names=None): + """True if the live state no longer matches what the rules ask for. + + ``names`` limits the check to the objects a depsgraph update actually touched -- + walking every swapped slot on every update is wasted work when thousands match. + """ + applied = set() + for obj, index, _original, temp, _amount in _transparency_restore: + try: + name = obj.name + applied.add(name) + if names is not None and name not in names: + continue + if obj.material_slots[index].material is not temp: + return True + except (IndexError, ReferenceError): + return True + # A wanted object carrying none of our materials is stale too -- its mesh may have + # just been rebuilt. Objects with nothing swappable (no slots, or only empty ones) + # deliberately do NOT count, or they would request a resync forever. + for name in names or (): + if name in applied: + continue + obj = bpy.data.objects.get(name) + if obj is None: + continue + try: + for slot in obj.material_slots: + if slot.material is not None and not slot.material.get(MARKER): + return True + except ReferenceError: + continue + return False def build_color_rule(tree, scene, view_layer, rule, objects, input_socket, x, y): @@ -433,21 +529,95 @@ def drawing_cameras(): # The same enable toggle drives both. -def sync_live_effects(scene): - """Establish the correct live (viewport) material state for the active drawing. - - Removes any previous temp materials, then -- if the toggle is on -- applies - transparency for the active camera's rules. Idempotent; safe to call any time. - """ - restore_transparency() - props = get_camera_override_props(scene.camera) +def desired_transparency(scene): + """{object: amount} that the active drawing's rules currently ask to be transparent.""" + props = get_camera_override_props(getattr(scene, "camera", None)) if not props or not props.enabled: - return + return {} + desired = {} for rule in props.rules: if rule.transparency > 0: - objects = get_rule_objects(rule) - if objects: - apply_transparency(objects, rule.transparency) + for obj in get_rule_objects(rule): + desired[obj] = rule.transparency # a later rule wins, as before + return desired + + +def sync_live_effects(scene): + """Bring the live (viewport) material state in line with the active drawing's rules. + + Incremental: slots already carrying the right temp material are left untouched, so a + re-sync on a model where thousands of objects match does not tear everything down and + rebuild it. That also means it can no longer destroy a working preview it is unable to + restore. Idempotent; safe to call any time. + """ + if scene is None: + return + desired = desired_transparency(scene) + + # Pass 1 -- keep what is already right, undo what is no longer wanted. + kept, covered, restored = [], set(), 0 + for entry in _transparency_restore: + obj, index, original, temp, amount = entry + try: + slot = obj.material_slots[index] + except (IndexError, ReferenceError): + continue # object or slot is gone; nothing of ours left to undo + if slot.material is not temp: + continue # replaced behind our back (e.g. a representation reload) + if desired.get(obj) == amount: + kept.append(entry) + covered.add((obj.name, index)) + continue + slot.material = original # no longer wanted, or the amount changed + restored += 1 + _transparency_restore[:] = kept + + # Pass 2 -- apply to anything wanted that is not already covered. + applied, no_slots, empty_slots = 0, [], [] + for obj, amount in desired.items(): + try: + slots = obj.material_slots + if not len(slots): + no_slots.append(obj.name) + continue + for index, slot in enumerate(slots): + if (obj.name, index) in covered: + continue + material = slot.material + if material is None: + empty_slots.append(obj.name) + continue + if material.get(MARKER): + # An orphan of ours, left by a rebuild we never saw. Adopt it via the + # source recorded on it, so it is tracked (and undone) again rather + # than silently baked into the file. + original = bpy.data.materials.get(material.get(SOURCE_KEY, "") or "") + if original is None: + continue + else: + original = material + temp = get_temp_material(original, amount) + if slot.material is not temp: + slot.material = temp + _transparency_restore.append((obj, index, original, temp, amount)) + applied += 1 + except ReferenceError: + continue + + # Tracked = what the rules WANT, not just what we managed to apply, so an object whose + # mesh is mid-rebuild is still watched and picked up when it comes back. + _tracked_objects.clear() + _tracked_objects.update(obj.name for obj in desired) + release_unused_temp_materials() + if DEBUG: + _dbg( + f"sync: wanted={len(desired)} applied={applied} kept={len(kept)} " + f"restored={restored} temp_materials={len(_temp_material_cache)}" + ) + if no_slots: + _dbg(f" no material slots (nothing to make transparent): {_brief(no_slots)}") + if empty_slots: + _dbg(f" empty material slot: {_brief(empty_slots)}") def clear_compositor(scene): @@ -524,6 +694,52 @@ def subscribe_camera_change(): ) +def _deferred_live_resync(): + scene = bpy.context.scene + if scene is None: + return None + # The mode is re-checked HERE, not only when the timer was scheduled: entering edit + # mode happens in between, and an object mid-edit has its slots in flux. + if bpy.context.mode != "OBJECT": + _dbg("deferred resync: not in OBJECT mode -- waiting") + return 0.2 + if not live_state_is_stale(): + return None + sync_live_effects(scene) + return None # run once + + +@persistent +def depsgraph_handler(scene, depsgraph): + """Re-apply the live transparency after something rebuilt an object we swapped. + + Nothing notifies this module when a representation is reloaded, so without this the + preview is silently lost after a geometry edit until the toggle is cycled. + + This runs on every depsgraph update, so it stays cheap: it returns immediately unless + the update names an object the rules want, and the staleness check is scoped to just + those names. Material writes are deferred out of the notification, as with the msgbus + camera subscription. + """ + if not _tracked_objects: + return + matched = { + name + for name in (getattr(update.id, "name", None) for update in depsgraph.updates) + if name in _tracked_objects + } + if not matched: + return + if bpy.context.mode != "OBJECT": + return # mid-edit; leaving edit mode fires another update + if not live_state_is_stale(matched): + return # nothing to do -- do not churn a timer on every update + if not bpy.app.timers.is_registered(_deferred_live_resync): + if DEBUG: + _dbg(f"depsgraph: stale after {_brief(matched)} -- scheduling resync") + bpy.app.timers.register(_deferred_live_resync, first_interval=0.0) + + @persistent def load_post_handler(*args): # IFC is the source of truth: fill any empty drawing camera from its pset. (For a @@ -595,6 +811,7 @@ _HANDLERS = ( ("render_init", "render_init_handler"), ("render_complete", "render_end_handler"), ("render_cancel", "render_end_handler"), + ("depsgraph_update_post", "depsgraph_handler"), ("load_post", "load_post_handler"), ("save_pre", "save_pre_handler"), ("save_post", "save_post_handler"),