From c9c78f61279b2c681ebeb02e2cd1f28dcb47d4e6 Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Sun, 19 Jul 2026 18:27:01 +0300 Subject: [PATCH] Bonsai: refresh the arc/circle decorator immediately after duplicating a loop theoryshaw's follow-up on #6944: after the profile/curve reconstruction fix (previous commit), the arc/circle marker for a freshly Shift+D-duplicated loop wouldn't appear until leaving and re-entering Edit Mode. Root cause: ProfileDecorator groups arc/circle vertices purely by IFCARCINDEX/IFCCIRCLE vertex-group index every draw call (it has no cache to go stale, it fully recomputes from the live edit-mesh bmesh each frame). Duplicating a loop copies its vertex-group weights onto the new geometry, since Blender allocates no new group for a duplicate, so the source loop and its live duplicate land in the same dict entry. That entry then fails the "exactly 2 verts per circle / 3 per arc" check and is skipped entirely, so BOTH the original and the duplicate stop being drawn until the mesh is reimported and gets fresh, distinct groups. Verified live in headless Blender: built a bmesh with an IFCCIRCLE loop and an IFCARCINDEX loop, then ran bmesh.ops.duplicate on each (the same bmesh-level operation underlying Shift+D) and called ProfileDecorator's draw method directly. Before this change, duplicating either loop dropped both the original and the duplicate from the decorator (0 circle/arc batches drawn instead of 2). After, both draw immediately, with no change to the non-duplicated case (still 1) or to genuinely distinct loops (5 independent circles still resolve to 5, not merged). 500-circle timing is unchanged (~14.3ms/draw before and after), so the added connectivity split is not a hot-path regression. Added test/bim/module/model/test_profile_decorator_duplicate_loop.py pinning the new _connected_components helper's behavior for single and duplicated circle/arc loops. This contribution was produced with the assistance of an AI coding tool. (cherry picked from commit 32ac20e8e3b16dfee068b48e20f3b1b99e5f998e) --- .../bonsai/bim/module/model/decorator.py | 28 +++- .../test_profile_decorator_duplicate_loop.py | 141 ++++++++++++++++++ 2 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 src/bonsai/test/bim/module/model/test_profile_decorator_duplicate_loop.py diff --git a/src/bonsai/bonsai/bim/module/model/decorator.py b/src/bonsai/bonsai/bim/module/model/decorator.py index b88f2eb247..7728da27da 100644 --- a/src/bonsai/bonsai/bim/module/model/decorator.py +++ b/src/bonsai/bonsai/bim/module/model/decorator.py @@ -93,6 +93,30 @@ def _stroke_lines_alpha( gpu.state.blend_set("NONE") +def _connected_components( + vertex_groups: dict[int, list[bmesh.types.BMVert]], +) -> list[list[bmesh.types.BMVert]]: + """Split each vertex group's members into their connected components, + since a duplicated arc/circle loop shares its source loop's group index.""" + components = [] + for verts in vertex_groups.values(): + remaining = set(verts) + while remaining: + seed = remaining.pop() + stack = [seed] + component = [seed] + while stack: + v = stack.pop() + for edge in v.link_edges: + other = edge.other_vert(v) + if other in remaining: + remaining.discard(other) + stack.append(other) + component.append(other) + components.append(component) + return components + + class ProfileDecorator: installed = None @@ -265,7 +289,7 @@ class ProfileDecorator: # Draw arcs arc_centroids = [] arc_segments = [] - for arc in arcs.values(): + for arc in _connected_components(arcs): if len(arc) != 3: continue sorted_arc = [None, None, None] @@ -292,7 +316,7 @@ class ProfileDecorator: # Draw circles circle_centroids = [] circle_segments = [] - for circle in circles.values(): + for circle in _connected_components(circles): if len(circle) != 2: continue p1 = obj.matrix_world @ circle[0].co diff --git a/src/bonsai/test/bim/module/model/test_profile_decorator_duplicate_loop.py b/src/bonsai/test/bim/module/model/test_profile_decorator_duplicate_loop.py new file mode 100644 index 0000000000..149582acdf --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_profile_decorator_duplicate_loop.py @@ -0,0 +1,141 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 +# +# 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 . +# +# This file was generated with the assistance of an AI coding tool. + +"""Regression tests for ``ProfileDecorator``'s ``_connected_components`` helper. + +Duplicating a circle/arc void's loop in Edit Mode (Tab, Tab, select the +loop, Shift+D) copies the ``IFCARCINDEX``/``IFCCIRCLE`` vertex-group +weights onto the new geometry, since Blender's mesh duplicate never +allocates a new vertex group. Before this fix, ``ProfileDecorator`` +grouped arc/circle vertices by group index alone, so the duplicate's +vertices piled into the same entry as the source loop's, failing the +"exactly 2/3 verts" check and silently dropping *both* loops from the +decorator until the mesh was re-imported (e.g. by leaving and re-entering +Edit Mode). ``_connected_components`` splits each group's vertices back +into their connected loops so a duplicate is drawn immediately, see #6944.""" + +import bmesh +import bpy +import pytest + +from bonsai.bim.module.model.decorator import _connected_components + +pytestmark = pytest.mark.model + + +def _bm_with_groups(verts, edges, groups): + """Build a standalone bmesh with a deform layer, and populate ``groups``: + a list of (group_index, [vert_indices]) pairs, mirroring how + ``tool.Model.import_profile``/``convert_curve_to_mesh`` assign one + vertex group per circle/arc loop.""" + bm = bmesh.new() + deform_layer = bm.verts.layers.deform.new() + bm_verts = [bm.verts.new(v) for v in verts] + bm.verts.ensure_lookup_table() + for a, b in edges: + bm.edges.new((bm_verts[a], bm_verts[b])) + bm.verts.ensure_lookup_table() + bm_verts = list(bm.verts) + for group_index, vert_indices in groups: + for vi in vert_indices: + bm_verts[vi][deform_layer][group_index] = 1.0 + return bm, bm_verts, deform_layer + + +def _group_dict(bm_verts, deform_layer, group_index, vert_indices): + return {group_index: [bm_verts[i] for i in vert_indices]} + + +def test_single_circle_loop_is_one_component(): + # A circle is exactly 2 verts joined by 1 edge (tool.Model.convert_curve_to_mesh). + bm, bm_verts, deform_layer = _bm_with_groups( + verts=[(0, -1, 0), (0, 1, 0)], + edges=[(0, 1)], + groups=[(0, [0, 1])], + ) + circles = _group_dict(bm_verts, deform_layer, 0, [0, 1]) + components = _connected_components(circles) + assert len(components) == 1 + assert len(components[0]) == 2 + bm.free() + + +def test_single_arc_loop_is_one_component(): + # An arc is exactly 3 verts: endpoint-midpoint-endpoint (2 edges). + bm, bm_verts, deform_layer = _bm_with_groups( + verts=[(0, -1, 0), (0, 0, 0.3), (0, 1, 0)], + edges=[(0, 1), (1, 2)], + groups=[(0, [0, 1, 2])], + ) + arcs = _group_dict(bm_verts, deform_layer, 0, [0, 1, 2]) + components = _connected_components(arcs) + assert len(components) == 1 + assert len(components[0]) == 3 + bm.free() + + +def test_duplicated_circle_loop_splits_into_two_components(): + # Duplicating verts 0-1 (Shift+D) yields verts 2-3, connected to each + # other but NOT to the source loop, while keeping the same group index + # (0) -- exactly what bmesh.ops.duplicate produces mid Edit-Mode. + bm, bm_verts, deform_layer = _bm_with_groups( + verts=[(0, -1, 0), (0, 1, 0), (5, -1, 0), (5, 1, 0)], + edges=[(0, 1), (2, 3)], + groups=[(0, [0, 1, 2, 3])], + ) + circles = _group_dict(bm_verts, deform_layer, 0, [0, 1, 2, 3]) + components = _connected_components(circles) + assert len(components) == 2 + assert sorted(len(c) for c in components) == [2, 2] + bm.free() + + +def test_duplicated_arc_loop_splits_into_two_components(): + bm, bm_verts, deform_layer = _bm_with_groups( + verts=[(0, -1, 0), (0, 0, 0.3), (0, 1, 0), (5, -1, 0), (5, 0, 0.3), (5, 1, 0)], + edges=[(0, 1), (1, 2), (3, 4), (4, 5)], + groups=[(0, [0, 1, 2, 3, 4, 5])], + ) + arcs = _group_dict(bm_verts, deform_layer, 0, [0, 1, 2, 3, 4, 5]) + components = _connected_components(arcs) + assert len(components) == 2 + assert sorted(len(c) for c in components) == [3, 3] + bm.free() + + +def test_distinct_circle_groups_stay_separate_and_correctly_sized(): + # Multiple genuinely different circles (distinct group indices) must + # each still resolve to their own single 2-vert component. + verts = [] + edges = [] + groups = [] + for i in range(5): + base = len(verts) + verts += [(i * 3, -1, 0), (i * 3, 1, 0)] + edges.append((base, base + 1)) + groups.append((i, [base, base + 1])) + bm, bm_verts, deform_layer = _bm_with_groups(verts, edges, groups) + circles = {} + for group_index, vert_indices in groups: + circles[group_index] = [bm_verts[i] for i in vert_indices] + components = _connected_components(circles) + assert len(components) == 5 + assert all(len(c) == 2 for c in components) + bm.free()