mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-05 20:06:25 +00:00
Keep render override transparency alive across geometry edits
Transparency vanished after editing an element and did not come back, e.g. the enable_editing_extrusion_profile -> direct_profile_edit -> edit_extrusion_profile round trip. 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 dropped. Nothing notified this module, so the preview was lost until the toggle was cycled. Worse, the loss was permanent and destructive. bpy.data.materials.remove unlinks from every user, so deleting a temp material that had ridden onto the rebuilt mesh emptied the live slot -- stripping the element's material entirely, after which there was nothing left to make transparent. Each edit cycle claimed another one. - restore_transparency only undoes a slot that still holds the material it put there, and remaps any other remaining reference back to the original before deleting, so a temp material is never removed out from under a live slot. - A depsgraph_update_post handler re-applies after a rebuild. The OBJECT-mode check is inside the deferred timer, not at scheduling time: edit mode is entered in between, and syncing then found slots mid-flux. The same pass reworks this for real models, where a rule can match thousands of objects rather than the handful it was built against: - Temp materials are shared per (source material, amount) instead of copied per slot, so the datablock count is a handful rather than one per occurrence. Sharing is safe for user_remap because every user of a temp material had the same original, recorded on it as SOURCE_KEY. - sync_live_effects is incremental. Slots already carrying the right material are kept, only what is no longer wanted is undone, and only what is uncovered is applied. A steady-state re-sync now touches nothing, and it can no longer dismantle a preview it is unable to rebuild. - Staleness checks are scoped to the objects a depsgraph update actually touched, and the expensive probes are guarded so they are not evaluated when tracing is off. - Sync adopts orphans: a temp material left on a rebuilt mesh is traced back via SOURCE_KEY and re-registered, rather than silently baked into the saved file. Tracing is left in place behind DEBUG (currently on) since this needs further exercise on real models. NOTES.md documents the reload-survival design, the scale approach, and the confirmed limitation that elements with no material cannot be made transparent, because the effect works by copying an existing material. Smoke-tested headless on Blender 4.5.7: three objects sharing one source material produce exactly one temp material, and a stray reference is remapped rather than emptied. Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user