Merge remote-tracking branch 'origin/v0.8.0' into ifcviewer-wgpu

This commit is contained in:
Thomas Krijnen
2026-07-09 13:21:39 +02:00
373 changed files with 22411 additions and 4242 deletions
+1 -1
View File
@@ -106,7 +106,7 @@ endif
endif # def PLATFORM
# Current build commit hash.
OLD:=1c5b825
OLD:=3e7b739
.PHONY: bump
bump:
ifndef NEW
+1
View File
@@ -90,6 +90,7 @@ modules = {
"web": None,
"light": None,
"alignment": None,
"clip_box": None,
# Uncomment this line to enable loading of the demo module. Happy hacking!
# The name "demo" must correlate to a folder name in `bim/module/`.
# "demo": None,
@@ -5,7 +5,7 @@ FILE_NAME('EPset_Drawing.ifc','2020-01-01T00:00:00',$,$,'EPset_Drawing','EPset_D
FILE_SCHEMA(('IFC4'));
ENDSEC;
DATA;
#1=IFCPROPERTYSETTEMPLATE('2JhNIvqZrFnAgxfhK0XVQX',$,'EPset_Drawing','',.PSET_OCCURRENCEDRIVEN.,'IfcAnnotation/DRAWING',(#23,#22,#27,#24,#19,#12,#26,#9,#8,#7,#6,#4,#18,#11,#5,#20,#25,#14,#10,#17,#28,#16,#3,#21,#13,#15,#2));
#1=IFCPROPERTYSETTEMPLATE('2JhNIvqZrFnAgxfhK0XVQX',$,'EPset_Drawing','',.PSET_OCCURRENCEDRIVEN.,'IfcAnnotation/DRAWING',(#23,#22,#27,#24,#29,#30,#19,#12,#26,#9,#8,#7,#6,#4,#18,#11,#5,#20,#25,#14,#10,#17,#28,#16,#3,#21,#13,#15,#2));
#2=IFCSIMPLEPROPERTYTEMPLATE('23JavTMk98ZxXhrUEnjAcf',$,'TargetView','',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
#3=IFCSIMPLEPROPERTYTEMPLATE('1yVWUt5H9DAOuu0OaMMLpe',$,'Scale','The scale of this drawing represented as a numerator and denominator, such as 1/100',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
#4=IFCSIMPLEPROPERTYTEMPLATE('3gsuPBtU93b8f0gg1pjkq6',$,'HumanScale','The scale of this drawing in human readable format, such as 1:100',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
@@ -33,5 +33,7 @@ DATA;
#26=IFCSIMPLEPROPERTYTEMPLATE('2iwERDOW55Pf4hCbuFRe1Q',$,'FillMode','Method to fill areas seen in projection',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
#27=IFCSIMPLEPROPERTYTEMPLATE('1YF$qLzBzF19Io8aB2N8cE',$,'CutMode','Method for cutting geometry',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
#28=IFCSIMPLEPROPERTYTEMPLATE('1YSnFzurrEyRNtoLdmmddP',$,'BringToFront','The objects with these SVG classes will render in front of all other objects.Ex: IfcBeam, IfcColumn',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.);
#29=IFCSIMPLEPROPERTYTEMPLATE('0lP6Y8q9v2QhDnR4sT7uVx',$,'PerspectiveShiftX','Horizontal perspective camera shift stored as drawing metadata using Blender camera shift units.',.P_SINGLEVALUE.,'IfcReal',$,$,$,$,$,.READWRITE.);
#30=IFCSIMPLEPROPERTYTEMPLATE('2mR8b1NcW5EoFyG7hJ9kLp',$,'PerspectiveShiftY','Vertical perspective camera shift stored as drawing metadata using Blender camera shift units.',.P_SINGLEVALUE.,'IfcReal',$,$,$,$,$,.READWRITE.);
ENDSEC;
END-ISO-10303-21;
+7
View File
@@ -51,9 +51,11 @@ from bonsai.bim.module.model.decorator import (
BoundingBoxDecorator,
DoorSwingReadonlyDecorator,
MEPSegmentExtendPreviewDecorator,
MEPSystemPathDecorator,
SlabDirectionDecorator,
WallAxisDecorator,
WallFilletPreviewDecorator,
WallSystemPathDecorator,
)
from bonsai.bim.module.model.wall import WallGizmoPreviewDecorator
from bonsai.bim.module.nest.decorator import NestDecorator
@@ -513,6 +515,8 @@ def _install_viewport_overlays() -> None:
NestDecorator.uninstall()
WallAxisDecorator.uninstall()
SlabDirectionDecorator.uninstall()
MEPSystemPathDecorator.uninstall()
WallSystemPathDecorator.uninstall()
WallFilletPreviewDecorator.uninstall()
BendPreviewDecorator.uninstall()
MEPSegmentExtendPreviewDecorator.uninstall()
@@ -532,6 +536,9 @@ def _install_viewport_overlays() -> None:
WallAxisDecorator.install(bpy.context)
if model_props.show_slab_direction:
SlabDirectionDecorator.install(bpy.context)
if model_props.show_paths:
MEPSystemPathDecorator.install(bpy.context)
WallSystemPathDecorator.install(bpy.context)
if model_props.show_bounding_box:
BoundingBoxDecorator.install(bpy.context)
# Always-installed: draw() self-polls on Scene.BIMPreviewProperties.
+106
View File
@@ -61,6 +61,44 @@ class TransactionStep(TypedDict):
operations: list[Operation]
# Set when ``IfcStore.get_cache`` observes an external lock on the HDF5 cache —
# signal that another Blender process has the same IFC file open. Project panel
# polls ``is_cache_locked_by_other_process`` to warn the user. The dismissed
# flag is sticky per-session so the warning doesn't re-nag once the user has
# acknowledged it.
_cache_locked_by_other_process: bool = False
_multi_instance_warning_dismissed: bool = False
def is_cache_locked_by_other_process() -> bool:
return _cache_locked_by_other_process and not _multi_instance_warning_dismissed
def dismiss_multi_instance_warning() -> None:
global _multi_instance_warning_dismissed
_multi_instance_warning_dismissed = True
def get_cache_or_detect_lock() -> ifcopenshell.geom.serializers.hdf5 | None:
"""Like ``IfcStore.get_cache`` but tracks the multi-instance lock flag — sets
it on ``PermissionError``, clears it (along with the dismiss flag) when a
subsequent call succeeds. Returns ``None`` on lock; other exceptions
propagate. Callers that don't need the warning side effect can use
``IfcStore.get_cache`` directly."""
global _cache_locked_by_other_process, _multi_instance_warning_dismissed
try:
cache = IfcStore.get_cache()
except PermissionError:
_cache_locked_by_other_process = True
return None
if _cache_locked_by_other_process:
# Lock released — clear both flags so a future re-locking re-surfaces
# the warning rather than staying suppressed by the previous dismiss.
_cache_locked_by_other_process = False
_multi_instance_warning_dismissed = False
return cache
class IfcStore:
path: str = ""
"""Should be set only using ``tool.Ifc.set_path``."""
@@ -123,6 +161,74 @@ class IfcStore:
if IfcStore.path and not os.path.isabs(IfcStore.path):
IfcStore.path = os.path.abspath(os.path.join(bpy.path.abspath("//"), IfcStore.path))
@staticmethod
def generate_cache_path() -> str:
"""Generate cache path based on the active file and it's path."""
assert IfcStore.file
ifc_key = IfcStore.path + IfcStore.file.header.file_name.time_stamp
ifc_hash = hashlib.md5(ifc_key.encode("utf-8")).hexdigest()
prefs = tool.Blender.get_addon_preferences()
cache_path = os.path.join(prefs.cache_dir, f"{ifc_hash}.h5")
return cache_path
@staticmethod
def get_cache() -> ifcopenshell.geom.serializers.hdf5 | None:
"""Get existing cache for the current file or create a new one.
.h5 cache name reflects IFC filepath and it's current header's timestamp.
"""
if IfcStore.cache is None and IfcStore.path:
cache_path = IfcStore.generate_cache_path()
os.makedirs(os.path.dirname(cache_path), exist_ok=True)
IfcStore.cache_path = cache_path
cache_path = Path(IfcStore.cache_path)
cache_settings = ifcopenshell.geom.settings()
serializer_settings = ifcopenshell.geom.serializer_settings()
cache_preexists = cache_path.exists()
try:
IfcStore.cache = ifcopenshell.geom.serializers.hdf5(
IfcStore.cache_path, cache_settings, serializer_settings
)
if cache_preexists:
print(f"Successfully loaded existing cache: {cache_path.name}.")
else:
print("New cache was created.")
except Exception as e:
if cache_preexists:
print(f"Failed to create a cache from existing file '{cache_path.name}': {str(e)}.")
else:
print(f"Failed to create a cache: {str(e)}.")
# No point to trying again the same operation.
return
os.remove(IfcStore.cache_path)
try:
IfcStore.cache = ifcopenshell.geom.serializers.hdf5(
IfcStore.cache_path, cache_settings, serializer_settings
)
print("New cache was created.")
except Exception as e:
print(f"Failed to create a cache: {str(e)}.")
return
return IfcStore.cache
@staticmethod
def update_cache() -> None:
"""Update cache filename after timestamp was updated."""
if not IfcStore.cache:
return
assert IfcStore.cache_path
new_cache_path = IfcStore.generate_cache_path()
IfcStore.cache = None
try:
shutil.move(IfcStore.cache_path, new_cache_path)
except PermissionError:
try:
shutil.copy2(IfcStore.cache_path, new_cache_path)
except PermissionError:
pass # Well we tried. No cache for you!
get_cache_or_detect_lock()
@staticmethod
def load_file(path: str) -> None:
if not os.path.isfile(path):
+12 -1
View File
@@ -223,6 +223,7 @@ class IfcImporter:
self.elements: set[ifcopenshell.entity_instance] = set()
self.annotations: set[ifcopenshell.entity_instance] = set()
self.gross_elements: set[ifcopenshell.entity_instance] = set()
self.broken_arrays: set[ifcopenshell.entity_instance] = set()
self.element_types: set[ifcopenshell.entity_instance] = set()
self.spatial_elements: set[ifcopenshell.entity_instance] = set()
self.meshes: dict[str, OBJECT_DATA_TYPE] = {}
@@ -1216,7 +1217,17 @@ class IfcImporter:
continue
for i in range(len(data)):
tool.Array.set_children_lock_state(element, i, True)
tool.Array.constrain_children_to_parent(element)
tool.Array.constrain_children_to_parent(element)
for layer in data:
for child_guid in layer.get("children", ()):
try:
self.file.by_guid(child_guid)
except RuntimeError:
print(
f"setup_arrays: array parent {element.GlobalId} references missing "
f"child GUID {child_guid!r}."
)
self.broken_arrays.add(element)
def update_linked_aggregates(self):
# TODO Remove this after a while. See commit 17d6b8a
@@ -20,7 +20,6 @@ import blf
import bpy
import gpu
import ifcopenshell.util.element
from bpy.types import SpaceView3D
from bpy_extras import view3d_utils
from gpu_extras.batch import batch_for_shader
from mathutils import Vector
@@ -28,12 +27,6 @@ from mathutils import Vector
import bonsai.tool as tool
def transparent_color(color, alpha=0.1):
color = [i for i in color]
color[3] = alpha
return color
def create_bounding_box(objs):
# Initialize the bounding box coordinates
min_x, min_y, min_z = float("inf"), float("inf"), float("inf")
@@ -79,26 +72,8 @@ def create_bounding_box(objs):
return indices, edges
class AggregateDecorator:
is_installed = False
handlers = []
@classmethod
def install(cls, context):
if cls.is_installed:
cls.uninstall()
handler = cls()
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_aggregate, (context,), "WINDOW", "POST_VIEW"))
cls.is_installed = True
@classmethod
def uninstall(cls):
for handler in cls.handlers:
try:
SpaceView3D.draw_handler_remove(handler, "WINDOW")
except ValueError:
pass
cls.is_installed = False
class AggregateDecorator(tool.Blender.ViewportDecorator):
draw_method = "draw_aggregate"
def dotted_line_shader(self):
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface")
@@ -153,14 +128,6 @@ class AggregateDecorator:
shader.uniform_float("u_Scale", 25)
batch.draw(shader)
def draw_batch(self, shader_type, content_pos, color, indices=None):
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
return
shader = self.line_shader if shader_type == "LINES" else self.shader
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
shader.uniform_float("color", color)
batch.draw(shader)
def draw_aggregate(self, context):
props = tool.Aggregate.get_aggregate_props()
self.addon_prefs = tool.Blender.get_addon_preferences()
@@ -191,12 +158,13 @@ class AggregateDecorator:
aggregates.append(obj)
continue
aggregate = None
aggregates_list = tool.Aggregate.get_aggregates_recursively(element)
if props.in_aggregate_mode and props.editing_aggregate:
index = aggregates_list.index(tool.Ifc.get_entity(props.editing_aggregate))
if index > 0:
aggregate = aggregates_list[index - 1]
else:
elif aggregates_list:
aggregate = aggregates_list[-1]
if aggregate:
aggregates.append(tool.Ifc.get_object(aggregate))
@@ -225,39 +193,11 @@ class AggregateDecorator:
self.draw_custom_batch(line, decorator_color_unselected)
class AggregateModeDecorator:
is_installed = False
handlers = []
@classmethod
def install(cls, context):
if cls.is_installed:
cls.uninstall()
handler = cls()
cls.handlers.append(
SpaceView3D.draw_handler_add(handler.draw_aggregate_name, (context,), "WINDOW", "POST_PIXEL")
)
cls.handlers.append(
SpaceView3D.draw_handler_add(handler.draw_aggregate_empty, (context,), "WINDOW", "POST_VIEW")
)
cls.is_installed = True
@classmethod
def uninstall(cls):
for handler in cls.handlers:
try:
SpaceView3D.draw_handler_remove(handler, "WINDOW")
except ValueError:
pass
cls.is_installed = False
def draw_batch(self, shader_type, content_pos, color, indices=None):
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
return
shader = self.line_shader if shader_type == "LINES" else self.shader
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
shader.uniform_float("color", color)
batch.draw(shader)
class AggregateModeDecorator(tool.Blender.ViewportDecorator):
draw_methods = (
("draw_aggregate_name", "POST_PIXEL"),
("draw_aggregate_empty", "POST_VIEW"),
)
def draw_aggregate_name(self, context):
if context.mode == "EDIT_MESH":
@@ -56,11 +56,6 @@ class BoundaryDecorator:
unselected_elements_color = self.addon_prefs.decorator_color_unselected
special_elements_color = self.addon_prefs.decorator_color_special
def transparent_color(color, alpha=0.1):
color = [i for i in color]
color[3] = alpha
return color
gpu.state.point_size_set(6)
gpu.state.blend_set("ALPHA")
@@ -109,7 +104,11 @@ class BoundaryDecorator:
if unselected_edges:
self.draw_batch("LINES", unselected_vertices, special_elements_color, unselected_edges)
self.draw_batch("TRIS", unselected_vertices, transparent_color(special_elements_color), unselected_tris)
self.draw_batch(
"TRIS", unselected_vertices, tool.Blender.transparent_color(special_elements_color), unselected_tris
)
if selected_edges:
self.draw_batch("LINES", selected_vertices, selected_elements_color, selected_edges)
self.draw_batch("TRIS", selected_vertices, transparent_color(selected_elements_color), selected_tris)
self.draw_batch(
"TRIS", selected_vertices, tool.Blender.transparent_color(selected_elements_color), selected_tris
)
+26 -5
View File
@@ -345,9 +345,17 @@ class CadArcFrom3Points(bpy.types.Operator):
class CadOffset(bpy.types.Operator):
bl_idname = "bim.cad_offset"
bl_label = "CAD Offset"
bl_description = "Copy selected mesh geometry at provided offset. Mesh copied based on the current viewport angle."
bl_description = (
"Offset selected mesh geometry at provided distance, based on the current viewport angle. "
"Creates a copy by default, or moves the existing edges if Copy is disabled."
)
bl_options = {"REGISTER", "UNDO"}
distance: bpy.props.FloatProperty(name="Distance", default=0.1, subtype="DISTANCE")
copy: bpy.props.BoolProperty(
name="Copy",
description="Create a new offset copy of the geometry. If disabled, move the existing edges to the offset location",
default=True,
)
@classmethod
def poll(cls, context):
@@ -405,6 +413,11 @@ class CadOffset(bpy.types.Operator):
rotation = Matrix.Rotation(pi / 2, 2, "Z")
rotation_i = Matrix.Rotation(-pi / 2, 2, "Z")
# When not copying, the offset positions are gathered here and applied to
# the existing verts only after all loops are processed, so that the
# original coordinates are still available while computing offsets.
moved_verts = []
# Create loops from edges
loop_edges = set(edges)
loops = []
@@ -517,12 +530,15 @@ class CadOffset(bpy.types.Operator):
offset_length = self.distance / sqrt((1 + normals[0].dot(normals[1])) / 2)
offset = mw.inverted().to_quaternion() @ (wp.to_quaternion() @ (new_normal * offset_length).to_3d())
new_vert = v1.co + offset
new_verts.append(bm.verts.new(new_vert))
else:
normal = (normals[0] * self.distance).to_3d()
offset = mw.inverted().to_quaternion() @ (wp.to_quaternion() @ normal)
new_vert = v1.co + offset
if self.copy:
new_verts.append(bm.verts.new(new_vert))
else:
moved_verts.append((v1, new_vert))
processed_verts.add(v1.index)
@@ -531,9 +547,14 @@ class CadOffset(bpy.types.Operator):
v1 = v2
[bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(new_verts) - 1)]
if is_closed:
bm.edges.new((new_verts[len(new_verts) - 1], new_verts[0]))
if self.copy:
[bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(new_verts) - 1)]
if is_closed:
bm.edges.new((new_verts[len(new_verts) - 1], new_verts[0]))
# Move the existing edges to the offset location.
for vert, new_co in moved_verts:
vert.co = new_co
bm.verts.index_update()
bm.edges.index_update()
+6
View File
@@ -27,6 +27,11 @@ class BIMCadProperties(PropertyGroup):
resolution: bpy.props.IntProperty(name="Arc Resolution", min=1, default=1)
radius: bpy.props.FloatProperty(name="Radius", default=0.1, subtype="DISTANCE")
distance: bpy.props.FloatProperty(name="Distance", default=0.1, subtype="DISTANCE")
copy: bpy.props.BoolProperty(
name="Copy",
description="Create a new offset copy of the geometry. If disabled, move the existing edges to the offset location",
default=True,
)
x: bpy.props.FloatProperty(name="X", default=0.2, subtype="DISTANCE")
y: bpy.props.FloatProperty(name="Y", default=0.1, subtype="DISTANCE")
gable_roof_edge_angle: bpy.props.FloatProperty(
@@ -37,6 +42,7 @@ class BIMCadProperties(PropertyGroup):
resolution: int
radius: float
distance: float
copy: bool
x: float
y: float
gable_roof_edge_angle: float
@@ -256,6 +256,8 @@ class CadHotkey(bpy.types.Operator):
elif self.hotkey == "S_O":
row = self.layout.row()
row.prop(props, "distance")
row = self.layout.row()
row.prop(props, "copy")
elif self.hotkey == "S_R":
if tool.Geometry.is_profile_object_active():
@@ -291,7 +293,7 @@ class CadHotkey(bpy.types.Operator):
bpy.ops.bim.cad_fillet(resolution=self.props.resolution, radius=self.props.radius)
def hotkey_S_O(self):
bpy.ops.bim.cad_offset(distance=self.props.distance)
bpy.ops.bim.cad_offset(distance=self.props.distance, copy=self.props.copy)
def hotkey_S_Q(self):
obj = bpy.context.active_object
@@ -18,43 +18,17 @@
import blf
import gpu
from bpy.types import SpaceView3D
from bpy_extras.view3d_utils import location_3d_to_region_2d
from gpu_extras.batch import batch_for_shader
from mathutils import Vector
import bonsai.tool as tool
class ClashDecorator:
is_installed = False
handlers = []
@classmethod
def install(cls, context):
if cls.is_installed:
cls.uninstall()
handler = cls()
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_text, (context,), "WINDOW", "POST_PIXEL"))
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_geometry, (context,), "WINDOW", "POST_VIEW"))
cls.is_installed = True
@classmethod
def uninstall(cls):
for handler in cls.handlers:
try:
SpaceView3D.draw_handler_remove(handler, "WINDOW")
except ValueError:
pass
cls.is_installed = False
def draw_batch(self, shader_type, content_pos, color, indices=None):
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
return
shader = self.line_shader if shader_type == "LINES" else self.shader
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
shader.uniform_float("color", color)
batch.draw(shader)
class ClashDecorator(tool.Blender.ViewportDecorator):
draw_methods = (
("draw_text", "POST_PIXEL"),
("draw_geometry", "POST_VIEW"),
)
def draw_text(self, context):
self.addon_prefs = tool.Blender.get_addon_preferences()
@@ -0,0 +1,130 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# 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/>.
#
# This file was generated with the assistance of an AI coding tool.
import bpy
from bpy.app.handlers import persistent
import bonsai.tool as tool
from . import face_quad, gizmos, operator, prop, ui
classes = (
operator.BIM_OT_add_clip_box,
operator.BIM_OT_add_clip_box_for_source,
operator.BIM_OT_align_view_to_clip_face,
operator.BIM_OT_duplicate_clip_box,
operator.BIM_OT_remove_clip_box,
operator.BIM_OT_set_active_clip_box,
operator.BIM_OT_toggle_clip_box_enabled,
prop.BIMClipBoxProperties,
prop.BIMSceneClipBoxProperties,
face_quad.BIM_GT_box_face_quad,
face_quad.BIM_GT_box_face_outline,
gizmos.OBJECT_GGT_bim_clip_box,
ui.BIM_MT_clip_box_add_for_source,
ui.BIM_MT_clip_box_info,
ui.BIM_MT_clip_box_settings,
ui.BIM_UL_clip_box,
ui.BIM_PT_clip_box,
)
@persistent
def _on_depsgraph_update(scene, depsgraph):
tool.ClipBox.on_depsgraph_update(scene, depsgraph)
tool.ClipBox.on_depsgraph_update_caps(scene, depsgraph)
@persistent
def _on_load_pre(filepath):
# Tear down any in-flight clip-box timers before Blender frees the
# WM / screens / areas / regions for the loading file. A refresh timer
# that survives the teardown fires against the new file's freshly-
# allocated regions before their GPU state is wired, CTD-ing inside
# GPU_matrix_ortho_set. The gate also blocks the depsgraph IFC-reload
# branch and is held closed until on_pre_view fires for the first time
# on the new file (first paint = GPU contexts wired).
tool.ClipBox._file_loading = True
tool.ClipBox._post_load_paint_pending = True
tool.ClipBox._cancel_pending_refresh()
tool.ClipBox._cancel_pending_cap_rebuild()
@persistent
def _on_load_post(filepath):
# The _file_loading gate is NOT cleared here: load_post fires before
# the new file's first paint, so GPU contexts may still be uninitialised.
# on_pre_view consumes _post_load_paint_pending to open the gate at the
# safe moment and kick the post-load re-arm.
# Restore the per-scene clip-box list from the project's BBIM_ClipBoxes
# pset. Runs after the standard load_post that creates Blender objects.
tool.ClipBox._last_seen_object_matrices.clear()
tool.ClipBox.load_from_project_pset()
_draw_handler_pre = None
_draw_handler_post = None
def register():
global _draw_handler_pre, _draw_handler_post
bpy.types.Object.BIMClipBoxProperties = bpy.props.PointerProperty(type=prop.BIMClipBoxProperties)
bpy.types.Scene.BIMSceneClipBoxProperties = bpy.props.PointerProperty(type=prop.BIMSceneClipBoxProperties)
tool.ClipBox.reset_ownership()
if _on_depsgraph_update not in bpy.app.handlers.depsgraph_update_post:
bpy.app.handlers.depsgraph_update_post.append(_on_depsgraph_update)
if _on_load_pre not in bpy.app.handlers.load_pre:
bpy.app.handlers.load_pre.append(_on_load_pre)
if _on_load_post not in bpy.app.handlers.load_post:
bpy.app.handlers.load_post.append(_on_load_post)
if _draw_handler_pre is None:
_draw_handler_pre = bpy.types.SpaceView3D.draw_handler_add(tool.ClipBox.on_pre_view, (), "WINDOW", "PRE_VIEW")
if _draw_handler_post is None:
_draw_handler_post = bpy.types.SpaceView3D.draw_handler_add(
tool.ClipBox.on_post_view_caps, (), "WINDOW", "POST_VIEW"
)
def unregister():
global _draw_handler_pre, _draw_handler_post
if _draw_handler_post is not None:
try:
bpy.types.SpaceView3D.draw_handler_remove(_draw_handler_post, "WINDOW")
except ValueError:
pass
_draw_handler_post = None
if _draw_handler_pre is not None:
try:
bpy.types.SpaceView3D.draw_handler_remove(_draw_handler_pre, "WINDOW")
except ValueError:
pass
_draw_handler_pre = None
if _on_load_post in bpy.app.handlers.load_post:
bpy.app.handlers.load_post.remove(_on_load_post)
if _on_load_pre in bpy.app.handlers.load_pre:
bpy.app.handlers.load_pre.remove(_on_load_pre)
if _on_depsgraph_update in bpy.app.handlers.depsgraph_update_post:
bpy.app.handlers.depsgraph_update_post.remove(_on_depsgraph_update)
tool.ClipBox._cancel_pending_refresh()
tool.ClipBox._cancel_pending_cap_rebuild()
tool.ClipBox._last_seen_object_matrices.clear()
tool.ClipBox.clear_clip_planes()
del bpy.types.Object.BIMClipBoxProperties
del bpy.types.Scene.BIMSceneClipBoxProperties
@@ -0,0 +1,212 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# 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/>.
#
# This file was generated with the assistance of an AI coding tool.
"""EnumProperty ``items=`` callbacks for the source-based clip-box picker.
Each callback returns ``[(id_str, label, description)]`` where ``id_str`` is
an IFC entity id stringified for entity-driven kinds, an IFC class name for
``CLASS``, or a fixed status name for ``STATUS``. The clip-box operator
turns the picked id into a ``matrix_world`` via the source-preset helper.
"""
from __future__ import annotations
import bonsai.tool as tool
EnumItems = list[tuple[str, str, str]]
# Module-level cache. Blender's EnumProperty stores raw char pointers from the
# tuples a callback returns, so the Python strings must outlive the draw call.
# Stashing the latest result per kind keeps them alive across callback firings.
_items_cache: dict[str, EnumItems] = {}
# Sentinel id used for the "no options available" placeholder. The operator
# treats this as an invalid pick and surfaces an ERROR.
NO_OPTIONS_ID = "__none__"
def _cache(kind: str, items: EnumItems) -> EnumItems:
_items_cache[kind] = items
return items
def _no_options(label: str) -> EnumItems:
# Blender refuses to draw an EnumProperty with zero entries — show a
# placeholder so the dialog renders and the user sees the empty state.
return [(NO_OPTIONS_ID, label, "")]
def _label(entity, ifc_class: str | None = None) -> str:
name = (getattr(entity, "Name", None) or "Unnamed").strip() or "Unnamed"
return f"{ifc_class}: {name}" if ifc_class else name
def _build_items(kind: str, empty_label: str, build_fn) -> EnumItems:
"""Shared shape for the IFC-driven enum callbacks.
Returns the no-IFC placeholder if no file is loaded, then runs
``build_fn(ifc_file)``, sorts the result alphabetically by label, and
returns the empty-result placeholder if nothing matched. The output is
always routed through the module cache.
"""
ifc = tool.Ifc.get()
if ifc is None:
return _cache(kind, _no_options("No IFC loaded"))
items = build_fn(ifc)
items.sort(key=lambda t: t[1].lower())
if not items:
return _cache(kind, _no_options(empty_label))
return _cache(kind, items)
# Top-down spatial hierarchy so the picker reads in the order an architect
# already thinks in, rather than a flat alphabetical mix. IfcSpace is excluded
# — spaces are typically empty volumes used for room metadata, so clipping to
# one rarely matches the user intent of "show me what's in this container".
SPATIAL_CLASSES: tuple[str, ...] = (
"IfcProject",
"IfcSite",
"IfcBuilding",
"IfcBuildingStorey",
)
def spatial_items(self, context) -> EnumItems:
# Special-case: per-class sort within the hierarchy order rather than a
# flat alphabetical sort, so the dropdown reads project → site → building.
ifc = tool.Ifc.get()
if ifc is None:
return _cache("SPATIAL", _no_options("No IFC loaded"))
items: EnumItems = []
for ifc_class in SPATIAL_CLASSES:
try:
entities = ifc.by_type(ifc_class, include_subtypes=False)
except RuntimeError:
continue
for entity in sorted(entities, key=lambda e: (e.Name or "").lower()):
items.append((str(entity.id()), _label(entity, ifc_class), ""))
if not items:
return _cache("SPATIAL", _no_options("No spatial containers"))
return _cache("SPATIAL", items)
def class_items(self, context) -> EnumItems:
# Special-case: the picker value IS the IFC class name, not an entity id,
# so the build shape differs from the other entity-driven callbacks.
ifc = tool.Ifc.get()
if ifc is None:
return _cache("CLASS", _no_options("No IFC loaded"))
# List only IFC classes ACTUALLY present in the file (not the whole
# schema), so the user picks from classes that can produce a non-empty
# clip volume. ``e.is_a()`` returns the most specific class per element.
present = sorted({e.is_a() for e in ifc.by_type("IfcProduct")})
if not present:
return _cache("CLASS", _no_options("No products"))
return _cache("CLASS", [(cls, cls, "") for cls in present])
def type_items(self, context) -> EnumItems:
return _build_items(
"TYPE",
"No types defined",
lambda ifc: [(str(e.id()), _label(e, e.is_a()), "") for e in ifc.by_type("IfcTypeProduct")],
)
def material_items(self, context) -> EnumItems:
return _build_items(
"MATERIAL",
"No materials defined",
lambda ifc: [(str(e.id()), _label(e), "") for e in ifc.by_type("IfcMaterial")],
)
def profile_items(self, context) -> EnumItems:
# ProfileName is optional. Skip unnamed profiles — they can't be
# meaningfully picked from a flat list.
return _build_items(
"PROFILE",
"No named profiles",
lambda ifc: [
(str(e.id()), f"{e.is_a()}: {e.ProfileName}", "")
for e in ifc.by_type("IfcProfileDef")
if getattr(e, "ProfileName", None)
],
)
def drawing_items(self, context) -> EnumItems:
return _build_items(
"DRAWING",
"No drawings defined",
lambda ifc: [(str(e.id()), _label(e), "") for e in ifc.by_type("IfcAnnotation") if e.ObjectType == "DRAWING"],
)
# Display labels for each status value. The id strings on the left are the
# canonical Pset_*Common.Status enum values accepted by Bonsai's status query.
STATUS_LABELS: tuple[tuple[str, str], ...] = (
("No Status", "No Status"),
("NEW", "New"),
("EXISTING", "Existing"),
("DEMOLISH", "Demolish"),
("TEMPORARY", "Temporary"),
("OTHER", "Other"),
("NOTKNOWN", "Not Known"),
("UNSET", "Unset"),
)
def status_items(self, context) -> EnumItems:
# Fixed enum; no IFC needed. Still routed through the cache to share the
# same string-lifetime guarantee as the other callbacks.
return _cache("STATUS", [(value, label, "") for value, label in STATUS_LABELS])
def system_items(self, context) -> EnumItems:
# IfcStructuralAnalysisModel is a structural-grouping container, not a
# distribution system — excluded to match Bonsai's other system pickers.
return _build_items(
"SYSTEM",
"No systems defined",
lambda ifc: [
(str(e.id()), _label(e, e.is_a()), "")
for e in ifc.by_type("IfcSystem")
if not e.is_a("IfcStructuralAnalysisModel")
],
)
def group_items(self, context) -> EnumItems:
# include_subtypes=False so IfcSystem and IfcZone instances don't appear
# under Group as well — those get their own picker entries.
return _build_items(
"GROUP",
"No groups defined",
lambda ifc: [(str(e.id()), _label(e), "") for e in ifc.by_type("IfcGroup", include_subtypes=False)],
)
def zone_items(self, context) -> EnumItems:
return _build_items(
"ZONE",
"No zones defined",
lambda ifc: [(str(e.id()), _label(e), "") for e in ifc.by_type("IfcZone")],
)
@@ -0,0 +1,879 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# 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/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Generic face-quad resize gizmos for any axis-aligned local box.
This module contains the box-agnostic core of the interactive
face-resize gizmos: two Gizmo classes (a near-invisible click target
welded to each face, and a thin colored edge outline), a per-redraw
orchestrator that places six of each on a box, and the pure one-sided
resize arithmetic. None of it knows about IFC, clip boxes, or
``BIMSceneClipBoxProperties`` a future camera-view-box adapter can
reuse the same classes and helpers.
Consumer contract the adapter group must:
1. Create six ``BIM_GT_box_face_quad`` and six ``BIM_GT_box_face_outline``
instances at ``setup()`` time, in :data:`FACE_ROUTES` order, and bind
each quad's ``move_get_cb`` / ``move_set_cb`` to closures that read
and mutate the box's host (e.g. an Empty's ``location`` / ``scale``).
2. Call :func:`apply_face_quad_layout` from ``refresh()`` /
``draw_prepare()`` with the box's local-frame ``bmin`` / ``bmax``,
the host's ``matrix_world``, the OBB rotation as a 4x4
(``Matrix.Identity(4)`` when the rotation rides in ``matrix_world``),
and the current ``region`` / ``rv3d``.
3. Implement ``_lock_for(active_gz)`` / ``_unlock_all()`` on the group
for drag mutual exclusion; the quad's ``invoke`` / ``exit`` call them.
The resize arithmetic in :func:`compute_face_resize` is pure: feed it
the modal scalar plus drag-start snapshots and it returns the host's
new scale-on-axis and new origin location.
"""
from __future__ import annotations
import math
from collections.abc import Sequence
from typing import Any
import bpy
from bpy_extras.view3d_utils import location_3d_to_region_2d, region_2d_to_location_3d
from mathutils import Matrix, Vector
# ---------------------------------------------------------------------------
# Public iteration order
# ---------------------------------------------------------------------------
# (axis, is_max) pairs. The adapter group's ``setup()`` MUST create its
# six face-quad gizmos in this order so positional indexing into the
# layout helper stays correct.
FACE_ROUTES: tuple[tuple[int, bool], ...] = (
(0, False),
(0, True),
(1, False),
(1, True),
(2, False),
(2, True),
)
# ---------------------------------------------------------------------------
# Public visual constants (adapter reads these in setup())
# ---------------------------------------------------------------------------
# Standard XYZ axis colors (Blender convention).
AXIS_COLOR: dict[int, tuple[float, float, float]] = {
0: (1.0, 0.2, 0.2),
1: (0.2, 1.0, 0.2),
2: (0.2, 0.4, 1.0),
}
# Documented "selectable but unpainted" trick: the GPU still writes the
# selection buffer at this alpha so clicks register, but no visible
# pixels are produced.
FACE_QUAD_ALPHA: float = 0.001
# Very faint hover tint — just enough to confirm "you're aiming at this
# face" without painting visibly over geometry behind it.
FACE_QUAD_ALPHA_HIGHLIGHT: float = 0.04
# Setup-time default for ``select_bias``; the layout helper overwrites
# it per frame to the front-facing or halo value below. Kept below the
# canonical arrow bias so a bailed frame can't let a front quad steal
# clicks meant for a hidden control.
FACE_QUAD_SELECT_BIAS: float = 0.5
# ---------------------------------------------------------------------------
# Internal constants
# ---------------------------------------------------------------------------
# Unit quad in the local XY plane spanning [-0.5, 0.5]^2 at z=0. Two
# CCW triangles viewed from +Z. matrix_basis stretches it onto the
# face's perpendicular extents.
_QUAD_TRIS: list[tuple[float, float, float]] = [
(-0.5, -0.5, 0.0),
(0.5, -0.5, 0.0),
(0.5, 0.5, 0.0),
(-0.5, -0.5, 0.0),
(0.5, 0.5, 0.0),
(-0.5, 0.5, 0.0),
]
# Unit-quad outline as 4 line segments in the local XY plane at z=0.
_QUAD_OUTLINE_LINES: list[tuple[float, float, float]] = [
(-0.5, -0.5, 0.0),
(0.5, -0.5, 0.0),
(0.5, -0.5, 0.0),
(0.5, 0.5, 0.0),
(0.5, 0.5, 0.0),
(-0.5, 0.5, 0.0),
(-0.5, 0.5, 0.0),
(-0.5, -0.5, 0.0),
]
# Degenerate zero-area triangle for hidden back-facing quads with no
# visible-adjacent neighbours (rare orientation). Blender tolerates
# this; the gizmo is hidden anyway so nothing renders.
_EMPTY_TRIS: list[tuple[float, float, float]] = [
(0.0, 0.0, 0.0),
(0.0, 0.0, 0.0),
(0.0, 0.0, 0.0),
]
# Rotates the gizmo's local +Z onto the outward face normal in the
# box's local frame. Right-hand rotation around the named axis.
_AXIS_ORIENT: dict[tuple[int, bool], Matrix] = {
(0, False): Matrix.Rotation(-math.pi / 2, 4, "Y"),
(0, True): Matrix.Rotation(math.pi / 2, 4, "Y"),
(1, False): Matrix.Rotation(math.pi / 2, 4, "X"),
(1, True): Matrix.Rotation(-math.pi / 2, 4, "X"),
(2, False): Matrix.Rotation(math.pi, 4, "X"),
(2, True): Matrix.Identity(4),
}
# Per-face mapping from face-quad local axes to local box axes for the
# perpendicular-extent scale. ``(w_axis, h_axis)`` — the box-local axis
# indices the quad's local X and Y span after the orientation rotation.
_QUAD_PERP_AXES: dict[tuple[int, bool], tuple[int, int]] = {
(0, False): (2, 1),
(0, True): (2, 1),
(1, False): (0, 2),
(1, True): (0, 2),
(2, False): (0, 1),
(2, True): (0, 1),
}
# Front-facing quad sits ABOVE the halo strips so the cursor on the
# visible face area always grabs the visible face, never accidentally
# routes to a back-face halo strip in an adjacent screen region.
_FACE_QUAD_FRONT_FACING_SELECT_BIAS: float = 1.5
_FACE_QUAD_HALO_FRAME_SELECT_BIAS: float = 1.0
# Target halo-strip thickness in screen pixels. The world-space margin
# is recomputed per frame so the rim stays a roughly constant on-screen
# size regardless of viewport zoom.
_FACE_QUAD_HALO_TARGET_PIXELS: float = 20.0
# Minimum world half-extent a face resize may shrink to. Stops a drag
# from collapsing the host to zero or negative scale.
_MIN_HALF_EXTENT: float = 1e-4
# ---------------------------------------------------------------------------
# Pure predicates (testable without Blender)
# ---------------------------------------------------------------------------
Vec3 = tuple[float, float, float]
def face_outward_axis_local(axis: int, is_max: bool) -> Vec3:
"""Un-rotated outward face normal in the box's local AABB coords.
For ``(axis=0, is_max=True)`` returns ``(+1, 0, 0)``; for the X
face ``(-1, 0, 0)``; etc. The rotated world normal is obtained by
applying the host's rotation and the OBB rotation:
``mw_rot @ cage_rotation @ this``.
"""
sign = 1.0 if is_max else -1.0
out = [0.0, 0.0, 0.0]
out[axis] = sign
return (out[0], out[1], out[2])
def front_facing_face_mask(
face_normals_world: Sequence[Vec3],
view_dir_world: Vec3,
eps: float = 1e-6,
) -> tuple[bool, ...]:
"""Which of the 6 box faces point toward the camera.
A face is front-facing iff its outward normal points AGAINST the
view direction (``dot(normal, view_dir) < -eps``). The ``-eps``
margin prevents flicker at grazing angles.
``face_normals_world`` must be in :data:`FACE_ROUTES` order; returns
a 6-tuple of bool parallel to that order.
"""
if len(face_normals_world) != 6:
msg = f"expected 6 face normals, got {len(face_normals_world)}"
raise ValueError(msg)
vx, vy, vz = view_dir_world
return tuple((n[0] * vx + n[1] * vy + n[2] * vz) < -eps for n in face_normals_world)
def view_axis_parallel_face_mask(
face_normals_world: Sequence[Vec3],
view_dir_world: Vec3,
threshold: float = 0.95,
) -> tuple[bool, ...]:
"""Which faces have normals (anti-)parallel to the view direction.
True iff ``abs(dot(normal, view_dir)) >= threshold`` i.e. the
face is nearly perpendicular to the screen plane. Provided as a
pure predicate for callers that want to detect degenerate-drag
conditions; the layout helper itself no longer gates on it.
"""
if len(face_normals_world) != 6:
msg = f"expected 6 face normals, got {len(face_normals_world)}"
raise ValueError(msg)
vx, vy, vz = view_dir_world
return tuple(abs(n[0] * vx + n[1] * vy + n[2] * vz) >= threshold for n in face_normals_world)
# ---------------------------------------------------------------------------
# Pure resize arithmetic
# ---------------------------------------------------------------------------
def compute_face_resize(
*,
value: float,
init_world_half: float,
init_location: tuple[float, float, float],
world_axis: tuple[float, float, float],
display_size: float,
) -> tuple[float, tuple[float, float, float]]:
"""Pure one-sided face-resize arithmetic.
Returns ``(new_scale_axis, new_location)`` the host's new scale
on the dragged axis and its new world origin such that the
dragged face moves by the modal's outward delta while the OPPOSITE
face stays put.
``value`` is ``init + delta``, where ``init`` is the unsigned
drag-start world half-extent and ``delta`` is the cursor projection
onto the face's OUTWARD world normal. Realized half-extent is
clamped to a small floor; the location shift uses the realized
(post-clamp) delta so the opposite face stays fixed even at the
clamp.
"""
face_delta = value - init_world_half
new_world_half = init_world_half + 0.5 * face_delta
if new_world_half < _MIN_HALF_EXTENT:
new_world_half = _MIN_HALF_EXTENT
realized_delta = 2.0 * (new_world_half - init_world_half)
ds = display_size if display_size != 0.0 else 1.0
new_scale_axis = new_world_half / ds
shift = 0.5 * realized_delta
new_location = (
init_location[0] + shift * world_axis[0],
init_location[1] + shift * world_axis[1],
init_location[2] + shift * world_axis[2],
)
return new_scale_axis, new_location
# ---------------------------------------------------------------------------
# Internal geometry helpers
# ---------------------------------------------------------------------------
def _compute_face_quad_scale(bmin: Any, bmax: Any, axis: int, is_max: bool) -> tuple[float, float]:
"""Return ``(w, h)`` for the face quad's scale matrix."""
w_axis, h_axis = _QUAD_PERP_AXES[(axis, is_max)]
w = float(bmax[w_axis] - bmin[w_axis])
h = float(bmax[h_axis] - bmin[h_axis])
return w, h
def _shared_edge_corner_keys(
axis_a: int, is_max_a: bool, axis_b: int, is_max_b: bool
) -> tuple[tuple[int, int, int], tuple[int, int, int]] | None:
"""Return the 2 corner-bit triples shared by two adjacent faces.
Corner keys are 3-tuples of bits (0 = bmin, 1 = bmax). The two
returned corners are ordered with the free-axis bit ascending.
"""
if axis_a == axis_b:
return None
free_axis = 3 - axis_a - axis_b
bit_a = 1 if is_max_a else 0
bit_b = 1 if is_max_b else 0
corner_lo = [0, 0, 0]
corner_hi = [0, 0, 0]
corner_lo[axis_a] = bit_a
corner_hi[axis_a] = bit_a
corner_lo[axis_b] = bit_b
corner_hi[axis_b] = bit_b
corner_lo[free_axis] = 0
corner_hi[free_axis] = 1
return (
(corner_lo[0], corner_lo[1], corner_lo[2]),
(corner_hi[0], corner_hi[1], corner_hi[2]),
)
def _face_corner_keys(axis: int, is_max: bool) -> tuple[
tuple[int, int, int],
tuple[int, int, int],
tuple[int, int, int],
tuple[int, int, int],
]:
"""Return the 4 corner-bit triples of a face in CCW order.
Triangulation as ``[(0,1,2), (0,2,3)]`` covers the whole face with
two non-overlapping triangles.
"""
fixed_bit = 1 if is_max else 0
free_axes = [a for a in (0, 1, 2) if a != axis]
fa0, fa1 = free_axes
corners = []
for ka, kb in ((0, 0), (1, 0), (1, 1), (0, 1)):
key = [0, 0, 0]
key[axis] = fixed_bit
key[fa0] = ka
key[fa1] = kb
corners.append((key[0], key[1], key[2]))
return (corners[0], corners[1], corners[2], corners[3])
def _build_strip_tris_relative(
edge_p0_local: tuple[float, float, float],
edge_p1_local: tuple[float, float, float],
extrusion_local: tuple[float, float, float],
) -> list[tuple[float, float, float]]:
"""Build two CCW triangles (6 vertices) for a thin halo strip.
All inputs are in coords relative to the gizmo's ``matrix_basis``
anchor. The strip runs along ``[edge_p0_local, edge_p1_local]`` and
extrudes by ``extrusion_local`` perpendicular to the edge.
"""
p0x, p0y, p0z = edge_p0_local
p1x, p1y, p1z = edge_p1_local
ex, ey, ez = extrusion_local
p0e = (p0x + ex, p0y + ey, p0z + ez)
p1e = (p1x + ex, p1y + ey, p1z + ez)
return [
(p0x, p0y, p0z),
p0e,
p1e,
(p0x, p0y, p0z),
p1e,
(p1x, p1y, p1z),
]
def _strips_geometry_changed(quad_gz, face_quad_local, all_tris) -> bool:
"""True if the back-face quad's geometry differs from the cached upload.
Pure orbit/pan doesn't change either the box pose or the cage
rotation, so the computed strip vertices are byte-identical to the
previous frame's. Hitting the cache lets the back-facing branch
skip ``new_custom_shape`` and the GPU upload.
"""
cached = getattr(quad_gz, "_strips_cache_key", None)
last_state = getattr(quad_gz, "_last_geometry_state", None)
key = (face_quad_local, all_tris)
if cached is None or last_state != "strips" or cached != key:
quad_gz._strips_cache_key = key
quad_gz._last_geometry_state = "strips"
return True
return False
def _compute_face_basis(
mw: Any,
mw_rot: Any,
cage_rotation: Any,
pivot_local: Any,
face_local: Any,
orient: Any,
) -> tuple[Any, Any]:
"""World-space (translation, outward-normal-direction) for one face."""
rotated_face_local = cage_rotation.to_3x3() @ (face_local - pivot_local) + pivot_local
face_world = mw @ rotated_face_local
world_axis = (mw_rot @ cage_rotation.to_3x3() @ (orient.to_3x3() @ Vector((0.0, 0.0, 1.0)))).normalized()
return face_world, world_axis
def _compose_face_matrix_basis(
face_world: Any,
mw_rot_scale: Any,
cage_rotation: Any,
orient: Any,
w: float,
h: float,
) -> Any:
"""Compose the 5-term ``matrix_basis`` for a face-plane gizmo.
Returns ``Translation @ mw_rot_scale @ cage_rotation @ orient @
Diagonal((w, h, 1, 1))`` maps a unit-square local quad onto the
world-space face rectangle, including the host's scale.
"""
quad_scale = Matrix.Diagonal((w, h, 1.0, 1.0))
return Matrix.Translation(face_world) @ mw_rot_scale.to_4x4() @ cage_rotation @ orient @ quad_scale
def _compute_box_corners_world(
bmin: Any,
bmax: Any,
pivot_local: Any,
cage_rotation_3x3: Any,
mw: Any,
) -> dict[tuple[int, int, int], Any]:
"""Return the 8 OBB corners in world space, keyed by bit-triple."""
corners: dict[tuple[int, int, int], Any] = {}
for ix in (0, 1):
for iy in (0, 1):
for iz in (0, 1):
local = Vector(
(
float(bmax.x if ix else bmin.x),
float(bmax.y if iy else bmin.y),
float(bmax.z if iz else bmin.z),
)
)
rotated = cage_rotation_3x3 @ (local - pivot_local) + pivot_local
corners[(ix, iy, iz)] = mw @ rotated
return corners
def _abs_scale_matrix(mw: Any) -> Any:
"""Return a copy of ``mw`` with all scale components ``abs()``-ed.
Without this, a negative-scale host produces a visible/clickable
face inversion: ``mw @ local_vec`` flips the +axis face onto the
-axis world side, while the rotation-only normal stays pointing
in the +axis direction so the gizmo for "the +X face" sits at
world -X but reports its outward normal as +X.
"""
loc, rot, scale = mw.decompose()
abs_scale = Vector((abs(scale.x), abs(scale.y), abs(scale.z)))
return Matrix.LocRotScale(loc, rot, abs_scale)
def _world_radius_to_screen_pixels(
region: Any,
rv3d: Any,
center_world: Vector,
world_radius: float,
*,
min_pixels: float = 0.0,
) -> float:
"""Return the on-screen pixel radius of a world-space circle.
Projects ``center_world`` and a sample point offset by
``world_radius`` along the camera's view-aligned right axis to
region pixels, and returns the screen-pixel distance between them.
Falls back to ``min_pixels`` if either projection fails.
"""
try:
view_inv = rv3d.view_matrix.inverted()
right = Vector((view_inv[0][0], view_inv[0][1], view_inv[0][2])).normalized()
except (AttributeError, ValueError):
right = Vector((1.0, 0.0, 0.0))
sample_world = center_world + right * world_radius
return _world_segment_to_screen_pixels(region, rv3d, center_world, sample_world, min_pixels=min_pixels)
def _world_segment_to_screen_pixels(
region: Any,
rv3d: Any,
p0_world: Vector,
p1_world: Vector,
*,
min_pixels: float = 0.0,
) -> float:
"""Return the on-screen pixel length of an arbitrary world segment.
Unlike :func:`_world_radius_to_screen_pixels`, this measures the
ACTUAL projected length of the segment foreshortening included.
Use this when the segment direction is known to be oblique to the
screen plane (e.g. a back face's outward normal): a perpendicular
radius measurement overestimates the on-screen length, leaving
halo strips visually narrower than the requested pixel target.
"""
p0 = location_3d_to_region_2d(region, rv3d, p0_world)
p1 = location_3d_to_region_2d(region, rv3d, p1_world)
if not p0 or not p1:
return min_pixels
dx = float(p1[0]) - float(p0[0])
dy = float(p1[1]) - float(p0[1])
return max(min_pixels, (dx * dx + dy * dy) ** 0.5)
# ---------------------------------------------------------------------------
# Gizmo classes
# ---------------------------------------------------------------------------
class BIM_GT_box_face_quad(bpy.types.Gizmo): # noqa: N801 — Blender bl_idname convention
"""Near-invisible face-quad click target with drag-to-resize modal.
Geometry: a unit quad in the local XY plane at z=0. The adapter
group's layout helper rotates and scales it onto the face plane;
the quad is welded to the world face (``use_draw_scale = False``).
"""
bl_idname = "BIM_GT_box_face_quad"
bl_target_properties = ({"id": "offset", "type": "FLOAT", "array_length": 1},)
__slots__ = (
"custom_shape",
"custom_shape_select",
"init_value",
"move_get_cb",
"move_set_cb",
"axis",
"start_location",
"depth_point",
"callback",
"ctrl_click_cb",
"_group",
"_face_axis",
"is_max",
"_drag_snapshot",
"_last_geometry_state",
"_strips_cache_key",
)
def draw(self, context: Any) -> None:
self.draw_custom_shape(self.custom_shape)
def draw_select(self, context: Any, select_id: int) -> None:
# Back-facing quads bind ``custom_shape_select`` to the halo-strip
# TRIS so clicks OUTSIDE the box silhouette catch the back face.
# Front-facing quads leave it None and reuse ``custom_shape``.
shape = getattr(self, "custom_shape_select", None) or self.custom_shape
self.draw_custom_shape(shape, select_id=select_id)
def setup(self) -> None:
if not hasattr(self, "custom_shape_"):
self.custom_shape = self.new_custom_shape("TRIS", _QUAD_TRIS)
self.custom_shape_select = None
# Quad welded to world geometry — clicks must align with the
# visible face, not a screen-size widget. Disables Blender's
# per-frame pixel-constant autoscale.
self.use_draw_scale = False
# ---- modal -------------------------------------------------------------
def invoke(self, context: Any, event: Any) -> set[str]:
# CTRL+click handoff: dispatch a host-defined callback (e.g.
# align-view) instead of starting a drag.
if event.ctrl and getattr(self, "ctrl_click_cb", None) is not None:
self.ctrl_click_cb(context, event)
return {"FINISHED"}
region = context.region
rv3d = context.region_data
if region is None or rv3d is None:
return {"CANCELLED"}
self.init_value = self.move_get_cb()
# Freeze the projection plane at invoke — projection-plane
# drift on tilted axes causes exponential delta runaway.
self.depth_point = self.matrix_basis.translation.copy()
self.start_location = region_2d_to_location_3d(region, rv3d, (event.mouse_x, event.mouse_y), self.depth_point)
if getattr(self, "_group", None) is not None:
self._group._lock_for(self)
return {"RUNNING_MODAL"}
def exit(self, context: Any, cancel: bool) -> None:
try:
if context.area:
context.area.header_text_set(None)
if cancel:
self.move_set_cb(self.init_value)
if hasattr(self, "callback"):
self.callback(self.move_get_cb())
finally:
self._drag_snapshot = None
if getattr(self, "_group", None) is not None:
self._group._unlock_all()
def modal(self, context: Any, event: Any, tweak: set[str]) -> set[str]:
if event.type == "ESC":
return {"CANCELLED"}
region = context.region
rv3d = context.region_data
if region is None or rv3d is None:
return {"CANCELLED"}
end_location = region_2d_to_location_3d(region, rv3d, (event.mouse_x, event.mouse_y), self.depth_point)
delta = (end_location - self.start_location).dot(self.axis)
if "SNAP" in tweak:
delta = round(delta, 1)
if "PRECISE" in tweak:
delta /= 10.0
self.move_set_cb(self.init_value + delta)
if context.area:
context.area.header_text_set(f"Value: {self.move_get_cb():.3f} ({delta:.3f})")
return {"RUNNING_MODAL"}
class BIM_GT_box_face_outline(bpy.types.Gizmo): # noqa: N801 — Blender bl_idname convention
"""Thin non-interactive colored edge outline for one face.
Drawn as 4 line segments in the face plane. The layout helper
toggles its ``alpha`` between near-zero and ``1.0`` based on the
sibling face-quad's ``is_highlight`` state — so hovering the quad
lights up the matching outline. ``hide_select = True`` keeps the
outline out of the GPU selection buffer.
"""
bl_idname = "BIM_GT_box_face_outline"
bl_target_properties = ()
__slots__ = (
"custom_shape",
"_face_axis",
"is_max",
"_last_outline_state",
)
def draw(self, context: Any) -> None:
self.draw_custom_shape(self.custom_shape)
def draw_select(self, context: Any, select_id: int) -> None:
return None
def setup(self) -> None:
if not hasattr(self, "custom_shape_"):
self.custom_shape = self.new_custom_shape("LINES", _QUAD_OUTLINE_LINES)
self.use_draw_scale = False
self.hide_select = True
self._last_outline_state = "unit"
# ---------------------------------------------------------------------------
# Per-redraw orchestrator
# ---------------------------------------------------------------------------
def apply_face_quad_layout(
*,
quad_gizmos,
outline_gizmos,
bmin: Any,
bmax: Any,
matrix_world: Any,
cage_rotation: Any,
region: Any,
rv3d: Any,
locked: bool,
) -> None:
"""Lay out 6 face quads + 6 outlines on the box for this redraw.
``quad_gizmos`` / ``outline_gizmos`` are length-6 sequences in
:data:`FACE_ROUTES` order. ``bmin`` / ``bmax`` are the box corners
in the host's local frame; ``matrix_world`` is the host's world
matrix; ``cage_rotation`` is the OBB rotation as a 4x4 (use
``Matrix.Identity(4)`` when rotation rides in ``matrix_world``).
``region`` / ``rv3d`` drive the view-dependent front/back split and
the screen-constant halo margin; passing ``rv3d = None`` bails.
Negative scale on the host is normalized to positive internally so
the visible cube and the clickable face gizmos stay aligned
callers don't need to pre-process ``matrix_world``.
When ``locked`` (a drag is active), ``hide`` / ``select_bias``
writes are skipped the active quad's geometry is still refreshed
so it tracks the moving box.
"""
if rv3d is None or getattr(rv3d, "view_rotation", None) is None:
return
if len(quad_gizmos) != 6 or len(outline_gizmos) != 6:
return
mw = _abs_scale_matrix(matrix_world)
mw_rot = mw.to_quaternion().to_matrix()
mw_rot_scale = mw.to_3x3()
cage_rotation_3x3 = cage_rotation.to_3x3()
pivot_local = (bmin + bmax) * 0.5
box_center_local = pivot_local
face_midpoints_local = {
(0, False): Vector((float(bmin.x), box_center_local.y, box_center_local.z)),
(0, True): Vector((float(bmax.x), box_center_local.y, box_center_local.z)),
(1, False): Vector((box_center_local.x, float(bmin.y), box_center_local.z)),
(1, True): Vector((box_center_local.x, float(bmax.y), box_center_local.z)),
(2, False): Vector((box_center_local.x, box_center_local.y, float(bmin.z))),
(2, True): Vector((box_center_local.x, box_center_local.y, float(bmax.z))),
}
view_dir = (rv3d.view_rotation @ Vector((0.0, 0.0, -1.0))).normalized()
view_dir_tuple = (float(view_dir.x), float(view_dir.y), float(view_dir.z))
face_normals_world = []
for route_axis, route_is_max in FACE_ROUTES:
axis_local = Vector(face_outward_axis_local(route_axis, route_is_max))
n_world = (mw_rot @ cage_rotation_3x3 @ axis_local).normalized()
face_normals_world.append((float(n_world.x), float(n_world.y), float(n_world.z)))
front = front_facing_face_mask(tuple(face_normals_world), view_dir_tuple)
box_center_world = mw @ pivot_local
corners_world = _compute_box_corners_world(bmin, bmax, pivot_local, cage_rotation_3x3, mw)
route_to_index = {route: i for i, route in enumerate(FACE_ROUTES)}
for i, route in enumerate(FACE_ROUTES):
quad_gz = quad_gizmos[i]
is_front = front[i]
axis_b, is_max_b = route
# Place the colored OUTLINE on every face using the same composed
# face matrix the front-facing solid quad uses. Hidden/shown via
# alpha at the end of the pass.
outline_orient = _AXIS_ORIENT[route]
outline_face_world, _outline_axis = _compute_face_basis(
mw,
mw_rot,
cage_rotation,
pivot_local,
face_midpoints_local[route],
outline_orient,
)
ow, oh = _compute_face_quad_scale(bmin, bmax, axis_b, is_max_b)
outline_gizmos[i].matrix_basis = _compose_face_matrix_basis(
outline_face_world, mw_rot_scale, cage_rotation, outline_orient, ow, oh
)
if is_front:
if not locked:
quad_gz.hide = False
quad_gz.select_bias = _FACE_QUAD_FRONT_FACING_SELECT_BIAS
orient = _AXIS_ORIENT[route]
face_world, world_axis = _compute_face_basis(
mw,
mw_rot,
cage_rotation,
pivot_local,
face_midpoints_local[route],
orient,
)
w, h = _compute_face_quad_scale(bmin, bmax, axis_b, is_max_b)
quad_gz.matrix_basis = _compose_face_matrix_basis(face_world, mw_rot_scale, cage_rotation, orient, w, h)
quad_gz.axis = world_axis
if getattr(quad_gz, "_last_geometry_state", None) != "solid":
quad_gz.custom_shape = quad_gz.new_custom_shape("TRIS", _QUAD_TRIS)
quad_gz.custom_shape_select = None
quad_gz._last_geometry_state = "solid"
continue
# Back-facing: anchor at the back face centre; build halo strips
# in the planes of the adjacent FRONT faces, extruded outside
# the silhouette toward this face's outward normal.
face_world = mw @ (cage_rotation_3x3 @ (face_midpoints_local[route] - pivot_local) + pivot_local)
quad_gz.matrix_basis = Matrix.Translation(face_world)
quad_gz.axis = (mw_rot @ cage_rotation_3x3 @ Vector(face_outward_axis_local(axis_b, is_max_b))).normalized()
adjacent_front_routes = [
(axis_a, is_max_a)
for axis_a in range(3)
if axis_a != axis_b
for is_max_a in (False, True)
if front[route_to_index[(axis_a, is_max_a)]]
]
# Per-face world margin: measure the screen-projected length of
# ONE world unit along THIS face's outward normal. The world
# margin that yields ~N pixels on screen is then ``N / length``.
# Foreshortening on oblique faces shortens the projected step,
# so the world step must grow to keep the strip the same width
# on screen.
face_world_margin = 0.0
if region is not None:
sample_end = box_center_world + quad_gz.axis * 1.0
screen_step = _world_segment_to_screen_pixels(region, rv3d, box_center_world, sample_end, min_pixels=0.0)
if screen_step > 0.0:
face_world_margin = _FACE_QUAD_HALO_TARGET_PIXELS / screen_step
if face_world_margin <= 0.0 or not adjacent_front_routes:
if not locked:
quad_gz.hide = True
quad_gz.select_bias = _FACE_QUAD_HALO_FRAME_SELECT_BIAS
if getattr(quad_gz, "_last_geometry_state", None) != "empty":
quad_gz.custom_shape = quad_gz.new_custom_shape("TRIS", _EMPTY_TRIS)
quad_gz.custom_shape_select = None
quad_gz._last_geometry_state = "empty"
continue
extrusion_world = quad_gz.axis * face_world_margin
extrusion_local = (
float(extrusion_world.x),
float(extrusion_world.y),
float(extrusion_world.z),
)
all_tris: list[tuple[float, float, float]] = []
for axis_a, is_max_a in adjacent_front_routes:
edge_keys = _shared_edge_corner_keys(axis_a, is_max_a, axis_b, is_max_b)
if edge_keys is None:
continue
key0, key1 = edge_keys
wp0 = corners_world[key0]
wp1 = corners_world[key1]
local_p0 = (
float(wp0.x - face_world.x),
float(wp0.y - face_world.y),
float(wp0.z - face_world.z),
)
local_p1 = (
float(wp1.x - face_world.x),
float(wp1.y - face_world.y),
float(wp1.z - face_world.z),
)
all_tris.extend(_build_strip_tris_relative(local_p0, local_p1, extrusion_local))
if not locked:
quad_gz.hide = False
quad_gz.select_bias = _FACE_QUAD_HALO_FRAME_SELECT_BIAS
corner_keys = _face_corner_keys(axis_b, is_max_b)
wc_local = [
(
float(corners_world[k].x - face_world.x),
float(corners_world[k].y - face_world.y),
float(corners_world[k].z - face_world.z),
)
for k in corner_keys
]
face_quad_local = [
wc_local[0],
wc_local[1],
wc_local[2],
wc_local[0],
wc_local[2],
wc_local[3],
]
if _strips_geometry_changed(quad_gz, tuple(face_quad_local), tuple(all_tris)):
quad_gz.custom_shape = quad_gz.new_custom_shape("TRIS", face_quad_local)
quad_gz.custom_shape_select = quad_gz.new_custom_shape("TRIS", all_tris)
quad_gz._last_geometry_state = "strips"
# Outline alpha follows ONLY the hovered quad's own state — light
# the outline of the face under the cursor, nothing else.
if not locked:
for outline_gz, quad_gz in zip(outline_gizmos, quad_gizmos, strict=True):
lit = bool(getattr(quad_gz, "is_highlight", False))
outline_gz.alpha = 1.0 if lit else 0.0
outline_gz.alpha_highlight = 1.0 if lit else 0.0
__all__ = [
"AXIS_COLOR",
"FACE_QUAD_ALPHA",
"FACE_QUAD_ALPHA_HIGHLIGHT",
"FACE_QUAD_SELECT_BIAS",
"FACE_ROUTES",
"BIM_GT_box_face_outline",
"BIM_GT_box_face_quad",
"apply_face_quad_layout",
"compute_face_resize",
"face_outward_axis_local",
"front_facing_face_mask",
"view_axis_parallel_face_mask",
]
@@ -0,0 +1,312 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# 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/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Interactive face-quad resize gizmos for the active clip box.
Adapter group that binds the generic :mod:`face_quad` core to a Bonsai
clip-box Empty: six near-invisible click quads + six edge outlines on
the cube's faces. Dragging a face does a ONE-SIDED resize — the dragged
face moves along its outward world normal while the opposite face stays
put by writing the empty's ``location`` and ``scale``. Bonsai's
depsgraph handler then re-arms the clip planes from the new matrix.
"""
from __future__ import annotations
import contextlib
from typing import Any
import bpy
from mathutils import Matrix, Vector
import bonsai.tool as tool
from . import face_quad
# Local-frame bounds of the empty's CUBE display. The display spans
# ``[-empty_display_size, +empty_display_size]^3``; Bonsai always sets
# ``empty_display_size = 1.0`` on clip-box hosts, so the local box is
# the unit cube. The empty's per-axis scale + rotation + translation
# ride in ``matrix_world``, which the layout helper applies.
_LOCAL_BMIN = Vector((-1.0, -1.0, -1.0))
_LOCAL_BMAX = Vector((1.0, 1.0, 1.0))
def _world_axis(empty: bpy.types.Object, axis: int, is_max: bool) -> Vector:
"""Outward world-space unit normal of the ``(axis, is_max)`` face.
Uses the rotation-only matrix so a negative-scale empty doesn't
flip the resulting direction the visible "+X face" then stays
associated with world +X (transformed through rotation).
"""
rot_mat = empty.matrix_world.to_quaternion().to_matrix()
n = Vector(rot_mat.col[axis])
if n.length <= 0.0:
return Vector((0.0, 0.0, 0.0))
n.normalize()
return n if is_max else -n
def _world_half_extent(empty: bpy.types.Object, axis: int) -> float:
"""The empty's box half-extent along local ``axis`` in WORLD units.
A CUBE empty's local cube is ``±empty_display_size``; ``matrix_world``
stretches it by the column length on ``axis``. So the world
half-extent is ``|column[axis]| * empty_display_size``.
"""
col_len = empty.matrix_world.to_3x3().col[axis].length
display_size = abs(float(getattr(empty, "empty_display_size", 1.0) or 1.0))
return float(col_len) * display_size
def _make_face_get_cb(gz: Any, group: Any, axis: int, is_max: bool):
"""Closure returning the world half-extent at drag start and
snapshotting the empty's full transform on the gizmo instance.
The snapshot lives on the gizmo (not the group) so a PERSISTENT
group servicing multiple clip boxes can't bleed one drag's state
onto another. Cleared on ``exit`` by the shared face-quad hook.
"""
def getter() -> float:
empty = group._empty
if empty is None:
return 0.0
existing = getattr(gz, "_drag_snapshot", None)
if existing is not None and existing.get("empty_name") == getattr(empty, "name", None):
return float(existing["world_half"])
world_half = _world_half_extent(empty, axis)
display_size = abs(float(getattr(empty, "empty_display_size", 1.0) or 1.0))
gz._drag_snapshot = {
"empty_name": getattr(empty, "name", None),
"world_half": world_half,
"location": tuple(float(v) for v in empty.location),
"scale": tuple(float(v) for v in empty.scale),
"display_size": display_size if display_size != 0.0 else 1.0,
"world_axis": tuple(_world_axis(empty, axis, is_max)),
}
return float(world_half)
return getter
def _make_ctrl_click_cb(axis: int, is_max: bool):
"""Closure that dispatches CTRL+click on a face to the align-view operator.
Routing through an operator (rather than mutating ``rv3d`` here)
keeps the action F3-searchable and undoable.
"""
def _callback(_context: Any, _event: Any) -> None:
bpy.ops.bim.align_view_to_clip_face("INVOKE_DEFAULT", axis=axis, is_max=is_max)
return _callback
def _make_face_set_cb(gz: Any, group: Any, axis: int, is_max: bool):
"""Closure that applies a one-sided face resize by writing the
empty's ``location`` + ``scale``.
The modal calls this with ``value = init + delta`` where ``delta``
is the cursor's projection onto the face's OUTWARD world normal.
Both reads come from ``gz._drag_snapshot`` so every frame is
relative to drag start, never compounding.
"""
del is_max # snapshot's world_axis carries the direction
def setter(value: float) -> None:
empty = group._empty
if empty is None:
return
snap = getattr(gz, "_drag_snapshot", None)
if snap is None or snap.get("empty_name") != getattr(empty, "name", None):
return
new_scale_axis, new_location = face_quad.compute_face_resize(
value=value,
init_world_half=snap["world_half"],
init_location=snap["location"],
world_axis=snap["world_axis"],
display_size=snap["display_size"],
)
new_scale = list(snap["scale"])
# Preserve the sign of the original scale so a user-flipped empty
# stays flipped after the resize — compute_face_resize returns a
# positive magnitude, the sign is the user's intent to keep.
sign = -1.0 if snap["scale"][axis] < 0.0 else 1.0
new_scale[axis] = sign * new_scale_axis
empty.scale = new_scale
empty.location = Vector(new_location)
return setter
class OBJECT_GGT_bim_clip_box(bpy.types.GizmoGroup): # noqa: N801 — Blender bl_idname convention
"""Face-quad resize handles on the active clip box.
Renders six near-invisible click-target quads and six colored edge
outlines on the active clip-box empty whenever clipping is enabled.
Click-and-drag a face to resize one-sided; the opposite face stays
put. CTRL+click and plain click fall through to selection.
"""
bl_idname = "OBJECT_GGT_bim_clip_box"
bl_label = "Bonsai Clip Box Faces"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT", "SHOW_MODAL_ALL"}
@classmethod
def poll(cls, context: Any) -> bool:
scene = getattr(context, "scene", None)
if scene is None:
return False
scene_props = tool.ClipBox.get_scene_props(scene)
if not scene_props.enabled or not scene_props.enable_gizmos:
return False
active_clip_box = tool.ClipBox.get_active_clip_box(scene)
if active_clip_box is None:
return False
# Only render when the user has the active clip box itself
# selected — otherwise the face handles would intercept clicks
# meant for the geometry behind them.
return getattr(context, "active_object", None) is active_clip_box
@classmethod
def setup_keymap(cls, keyconfig):
# Bind CLICK_DRAG so plain LEFTMOUSE PRESS passes through to
# selection — the user can still click through a near-invisible
# face quad to pick a mesh behind it.
km = keyconfig.keymaps.new(
name=cls.bl_idname,
space_type=cls.bl_space_type,
region_type=cls.bl_region_type,
)
km.keymap_items.new("gizmogroup.gizmo_tweak", type="LEFTMOUSE", value="CLICK_DRAG")
km.keymap_items.new("gizmogroup.gizmo_tweak", type="LEFTMOUSE", value="PRESS", ctrl=True)
return km
def setup(self, context: Any) -> None:
# ``_empty`` is resolved each refresh so the PERSISTENT group
# follows whichever clip box is active in the scene PG.
self._empty: bpy.types.Object | None = None
self._locked = False
self._face_routes: list[tuple[int, bool]] = []
for axis, is_max in face_quad.FACE_ROUTES:
gz = self.gizmos.new(face_quad.BIM_GT_box_face_quad.bl_idname)
gz._group = self
gz._face_axis = axis
gz.is_max = is_max
gz._drag_snapshot = None
gz._last_geometry_state = "solid"
gz._strips_cache_key = None
gz.color = face_quad.AXIS_COLOR[axis]
gz.color_highlight = tuple(min(1.0, c + 0.3) for c in face_quad.AXIS_COLOR[axis])
gz.alpha = face_quad.FACE_QUAD_ALPHA
gz.alpha_highlight = face_quad.FACE_QUAD_ALPHA_HIGHLIGHT
gz.use_draw_modal = True
gz.scale_basis = 1.0
gz.select_bias = face_quad.FACE_QUAD_SELECT_BIAS
gz.move_get_cb = _make_face_get_cb(gz, self, axis, is_max)
gz.move_set_cb = _make_face_set_cb(gz, self, axis, is_max)
# CTRL+click on a face aligns the viewport to look at it.
gz.ctrl_click_cb = _make_ctrl_click_cb(axis, is_max)
self._face_routes.append((axis, is_max))
# Outlines added last so they composite on top of the quad
# fills (Blender draws gizmos in creation order).
for axis, is_max in face_quad.FACE_ROUTES:
ol = self.gizmos.new(face_quad.BIM_GT_box_face_outline.bl_idname)
ol._face_axis = axis
ol.is_max = is_max
ol.color = face_quad.AXIS_COLOR[axis]
ol.color_highlight = face_quad.AXIS_COLOR[axis]
ol.alpha = 0.0
ol.alpha_highlight = 0.0
ol.line_width = 2.5
def _quad_gizmos(self):
return self.gizmos[: len(self._face_routes)]
def _outline_gizmos(self):
n = len(self._face_routes)
return self.gizmos[n : 2 * n]
def refresh(self, context: Any) -> None:
"""State-change path: resolve the active empty, then run the
shared face-quad layout so the quads aren't stale for a frame
after a selection or active-index change."""
empty = tool.ClipBox.get_active_clip_box(context.scene)
self._empty = empty
if empty is None:
for gz in self.gizmos:
gz.hide = True
return
self._layout(context, empty)
def draw_prepare(self, context: Any) -> None:
"""Per-redraw — fires on orbit — re-run the layout so the
front/back split, halo strips, and outline highlights track
the camera and any live G/R/S on the empty."""
empty = self._empty
if empty is None:
return
self._layout(context, empty)
def _layout(self, context: Any, empty: bpy.types.Object) -> None:
face_quad.apply_face_quad_layout(
quad_gizmos=self._quad_gizmos(),
outline_gizmos=self._outline_gizmos(),
bmin=_LOCAL_BMIN,
bmax=_LOCAL_BMAX,
matrix_world=empty.matrix_world,
# The empty's rotation rides in matrix_world, so the
# box-local OBB rotation is identity.
cage_rotation=Matrix.Identity(4),
region=getattr(context, "region", None),
rv3d=getattr(context, "region_data", None),
locked=self._locked,
)
# ---- mutual exclusion (lock siblings during a drag) ------------------
def _lock_for(self, active_gizmo) -> None:
self._locked = True
for gz in self.gizmos:
if gz is not active_gizmo:
with contextlib.suppress(ReferenceError, RuntimeError):
gz.hide = True
def _unlock_all(self) -> None:
self._locked = False
for gz in self.gizmos:
with contextlib.suppress(ReferenceError, RuntimeError):
gz.hide = False
# Rebuild caps synchronously so the cross-section overlay
# re-forms the instant the user releases the handle, rather
# than waiting for the depsgraph's debounced rebuild path.
with contextlib.suppress(RuntimeError, ReferenceError):
tool.ClipBox.rebuild_caps_now()
# Push an undo step so the user can revert a face drag with Ctrl+Z.
with contextlib.suppress(RuntimeError):
bpy.ops.ed.undo_push(message="Resize Clip Box")
@@ -0,0 +1,294 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# 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/>.
#
# This file was generated with the assistance of an AI coding tool.
import bpy
from mathutils import Matrix, Vector
import bonsai.tool as tool
from bonsai.bim.helper import prop_with_search
from . import data
# NOTE: do NOT add ``from __future__ import annotations`` to this module.
# PEP 563 stringifies the operator's EnumProperty class annotations, which
# breaks any introspection that reads ``cls.__annotations__[name].keywords``
# — including the enum-search helper that draws the search-button icon.
CLIP_BOX_NAME = "ClipBox"
# Display labels for the source-based picker, used for the menu entries and
# the dialog title. The dict keys are the canonical source-kind identifiers.
SOURCE_KIND_LABELS: dict[str, str] = {
"SPATIAL": "Spatial Element",
"CLASS": "Class",
"TYPE": "Type",
"MATERIAL": "Material",
"PROFILE": "Profile",
"DRAWING": "Drawing",
"STATUS": "Status",
"SYSTEM": "System",
"GROUP": "Group",
"ZONE": "Zone",
}
_SOURCE_ID_DISPATCH = {
"SPATIAL": data.spatial_items,
"CLASS": data.class_items,
"TYPE": data.type_items,
"MATERIAL": data.material_items,
"PROFILE": data.profile_items,
"DRAWING": data.drawing_items,
"STATUS": data.status_items,
"SYSTEM": data.system_items,
"GROUP": data.group_items,
"ZONE": data.zone_items,
}
def _source_id_items(self, context):
"""Dispatch the ``source_id`` enum items based on the picked ``source_kind``."""
fn = _SOURCE_ID_DISPATCH.get(self.source_kind)
if fn is None:
return [(data.NO_OPTIONS_ID, "No options", "")]
return fn(self, context)
def _source_display_name(kind, source_id):
"""Human-readable name of the picked source, used in the clip-box name."""
if kind == "STATUS":
return next((label for value, label in data.STATUS_LABELS if value == source_id), source_id)
if kind == "CLASS":
# source_id IS the human-readable IFC class name.
return source_id
ifc = tool.Ifc.get()
if ifc is None:
return source_id
try:
entity = ifc.by_id(int(source_id))
except (TypeError, ValueError, RuntimeError):
return source_id
return (getattr(entity, "Name", None) or "Unnamed").strip() or "Unnamed"
class BIM_OT_align_view_to_clip_face(bpy.types.Operator):
bl_idname = "bim.align_view_to_clip_face"
bl_label = "Align View to Clip Box Face"
bl_description = "Orient the 3D viewport to look directly at the picked clip-box face"
bl_options = {"REGISTER"}
axis: bpy.props.IntProperty(default=0, options={"SKIP_SAVE"})
is_max: bpy.props.BoolProperty(default=True, options={"SKIP_SAVE"})
def execute(self, context):
rv3d = getattr(context, "region_data", None)
if rv3d is None:
return {"CANCELLED"}
clip_box = tool.ClipBox.get_active_clip_box(context.scene)
if clip_box is None:
return {"CANCELLED"}
rot_mat = clip_box.matrix_world.to_quaternion().to_matrix()
outward_local = Vector((0.0, 0.0, 0.0))
outward_local[self.axis] = 1.0 if self.is_max else -1.0
outward = (rot_mat @ outward_local).normalized()
if outward.length == 0.0:
return {"CANCELLED"}
up_world = (rot_mat @ _local_up_for_face(self.axis, self.is_max)).normalized()
rv3d.view_rotation = _view_rotation_from_forward_and_up(-outward, up_world)
return {"FINISHED"}
def _local_up_for_face(axis: int, is_max: bool) -> Vector:
"""Box-local up direction for a face, following Blender numpad conventions.
Side faces (local ±X / ±Y normal) local +Z is up. Top face (local +Z
normal) local +Y is up; bottom face (local -Z normal) local -Y is
up. The caller rotates this through the empty's matrix so the
resulting world up axis tracks the box's orientation.
"""
if axis == 2:
return Vector((0.0, 1.0, 0.0)) if is_max else Vector((0.0, -1.0, 0.0))
return Vector((0.0, 0.0, 1.0))
def _view_rotation_from_forward_and_up(forward: Vector, up_hint: Vector) -> "bpy.types.Quaternion":
"""Build a camera ``view_rotation`` that looks along ``forward`` with
``up_hint`` projected to the camera's local +Y."""
back = -forward.normalized()
right = up_hint.cross(back)
if right.length < 1e-6:
right = Vector((1.0, 0.0, 0.0))
right.normalize()
up = back.cross(right).normalized()
return Matrix(
(
(right.x, up.x, back.x),
(right.y, up.y, back.y),
(right.z, up.z, back.z),
)
).to_quaternion()
class BIM_OT_add_clip_box(bpy.types.Operator):
bl_idname = "bim.add_clip_box"
bl_label = "Add Clip Box"
bl_description = (
"Create a clip box empty at the 3D cursor. The empty's location, rotation, and scale "
"drive the viewport clip planes; resize with S, move with G, rotate with R"
)
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
# Default to a 20m cube (scale 10 around [-1, +1] local cube) so
# the volume covers a typical building storey or two rather than
# the meaningless 2m unit cube. The user resizes with S.
matrix = Matrix.Translation(context.scene.cursor.location.copy()) @ Matrix.Diagonal((10.0, 10.0, 10.0, 1.0))
tool.ClipBox.create_clip_box_empty(context, matrix, name=CLIP_BOX_NAME)
return {"FINISHED"}
class BIM_OT_add_clip_box_for_source(bpy.types.Operator):
bl_idname = "bim.add_clip_box_for_source"
bl_label = "Add Clip Box From Source"
bl_description = (
"Create a clip box sized to a chosen source: a spatial container, IFC type, material, "
"profile, drawing camera frustum, element status, system, group, or zone"
)
bl_options = {"REGISTER", "UNDO"}
source_kind: bpy.props.EnumProperty(
name="Source Kind",
items=[(kind, label, "") for kind, label in SOURCE_KIND_LABELS.items()],
default="SPATIAL",
options={"SKIP_SAVE"},
)
source_id: bpy.props.EnumProperty(
name="Source",
items=_source_id_items,
options={"SKIP_SAVE"},
)
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self)
def draw(self, context):
layout = self.layout
label = f"Clip {SOURCE_KIND_LABELS.get(self.source_kind, 'Source')}"
# Search button appears once the enum exceeds the helper's threshold,
# giving the user a popup picker instead of a plain dropdown.
prop_with_search(layout, self, "source_id", text=label)
def execute(self, context):
if not self.source_id or self.source_id == data.NO_OPTIONS_ID:
self.report({"ERROR"}, "No source selected.")
return {"CANCELLED"}
matrix = tool.ClipBox.compute_matrix_for_source(self.source_kind, self.source_id)
if matrix is None:
kind_label = SOURCE_KIND_LABELS.get(self.source_kind, self.source_kind)
self.report(
{"ERROR"},
f"No elements found for {kind_label} '{_source_display_name(self.source_kind, self.source_id)}'.",
)
return {"CANCELLED"}
name = f"ClipBox.{SOURCE_KIND_LABELS.get(self.source_kind, self.source_kind)}.{_source_display_name(self.source_kind, self.source_id)}"
tool.ClipBox.create_clip_box_empty(context, matrix, name=name)
return {"FINISHED"}
class BIM_OT_remove_clip_box(bpy.types.Operator):
bl_idname = "bim.remove_clip_box"
bl_label = "Remove Clip Box"
bl_description = "Remove this clip box and its host empty"
bl_options = {"REGISTER", "UNDO"}
index: bpy.props.IntProperty(default=-1, options={"SKIP_SAVE"})
delete_object: bpy.props.BoolProperty(default=True, name="Delete Host Object")
def execute(self, context):
scene_props = tool.ClipBox.get_scene_props(context.scene)
index = self.index if self.index >= 0 else scene_props.active_clip_box_index
if index < 0 or index >= len(scene_props.clip_boxes):
return {"CANCELLED"}
entry = scene_props.clip_boxes[index]
obj = entry.obj
scene_props.clip_boxes.remove(index)
if scene_props.active_clip_box_index >= len(scene_props.clip_boxes):
scene_props.active_clip_box_index = max(0, len(scene_props.clip_boxes) - 1)
if self.delete_object and obj is not None:
bpy.data.objects.remove(obj, do_unlink=True)
tool.ClipBox.refresh(context.scene)
tool.ClipBox.save_to_project_pset(context.scene)
return {"FINISHED"}
class BIM_OT_set_active_clip_box(bpy.types.Operator):
bl_idname = "bim.set_active_clip_box"
bl_label = "Set Active Clip Box"
bl_description = "Set this clip box as the active one driving the viewport clip"
bl_options = {"REGISTER", "UNDO"}
index: bpy.props.IntProperty(default=-1, options={"SKIP_SAVE"})
def execute(self, context):
scene_props = tool.ClipBox.get_scene_props(context.scene)
if self.index < 0 or self.index >= len(scene_props.clip_boxes):
return {"CANCELLED"}
scene_props.active_clip_box_index = self.index
return {"FINISHED"}
class BIM_OT_toggle_clip_box_enabled(bpy.types.Operator):
bl_idname = "bim.toggle_clip_box_enabled"
bl_label = "Toggle Clip Box"
bl_description = "Toggle whether the active clip box is driving the viewport clip planes"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
scene_props = tool.ClipBox.get_scene_props(context.scene)
scene_props.enabled = not scene_props.enabled
return {"FINISHED"}
class BIM_OT_duplicate_clip_box(bpy.types.Operator):
bl_idname = "bim.duplicate_clip_box"
bl_label = "Duplicate Clip Box"
bl_description = "Duplicate this clip box: copy its empty + matrix into a new entry"
bl_options = {"REGISTER", "UNDO"}
index: bpy.props.IntProperty(default=-1, options={"SKIP_SAVE"})
def execute(self, context):
scene_props = tool.ClipBox.get_scene_props(context.scene)
source_index = self.index if self.index >= 0 else scene_props.active_clip_box_index
if source_index < 0 or source_index >= len(scene_props.clip_boxes):
return {"CANCELLED"}
source = scene_props.clip_boxes[source_index].obj
if source is None:
return {"CANCELLED"}
copy = tool.ClipBox.create_clip_box_empty(context, source.matrix_world.copy(), name=source.name)
# Preserve the source's display attrs so the duplicate matches.
copy.empty_display_type = source.empty_display_type
copy.empty_display_size = source.empty_display_size
copy.show_in_front = source.show_in_front
return {"FINISHED"}
@@ -0,0 +1,165 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# 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/>.
#
# This file was generated with the assistance of an AI coding tool.
from __future__ import annotations
from typing import TYPE_CHECKING
import bpy
from bpy.types import PropertyGroup
import bonsai.tool as tool
from bonsai.bim.prop import ObjProperty
class BIMClipBoxProperties(PropertyGroup):
"""Per-object marker for a clip-box host empty.
The host empty's ``matrix_world`` is the single source of truth for
the clip box's pose and dimensions: translation = box centre,
rotation = box orientation, per-axis scale = world half-extents. The
visible cube comes from the empty's CUBE display.
Only ``is_clip_box`` lives here; visibility (``enabled``) and overlay
(``show_caps``) are global per-file and live on the Scene PG.
"""
is_clip_box: bpy.props.BoolProperty(
default=False,
description="True when this empty was created as a clip-box host. Internal flag; not user-edited.",
)
if TYPE_CHECKING:
is_clip_box: bool
def update_active_clip_box_index(self, context):
tool.ClipBox.schedule_refresh()
tool.ClipBox.select_active_clip_box(context)
# Rebuild caps for the new active box's clip volume.
tool.ClipBox.invalidate_cap_cache(immediate=True)
def update_show_caps(self, context):
tool.ClipBox.schedule_refresh()
# Off → on must trigger a rebuild so caps reappear immediately rather
# than wait for the next depsgraph tick. The rebuild is a no-op when
# show_caps is now False (it clears and returns), so this is safe in
# both directions.
tool.ClipBox.invalidate_cap_cache()
def update_enabled(self, context):
tool.ClipBox.schedule_refresh()
def update_clip_only_ifc_products(self, context):
# The eligibility set for capping changed — drop the cache and let the
# debounced rebuild pick up the new objects on the next idle tick.
tool.ClipBox.invalidate_cap_cache()
def update_include_linked_ifc(self, context):
tool.ClipBox.invalidate_cap_cache()
class BIMSceneClipBoxProperties(PropertyGroup):
"""Scene-level registry of clip boxes in this file.
Multiple boxes may exist; ``active_clip_box_index`` selects which one
drives the viewport clip at any time. ``enabled`` and ``show_caps``
are global because the user's intent ("hide everything outside the
box", "draw cap overlays") applies file-wide, not per box.
``enabled`` is intentionally not persisted to the project pset:
opening a fresh IFC should never silently hide geometry behind a
remembered toggle. Selecting any clip-box empty in the viewport
re-arms it (see :meth:`tool.ClipBox._sync_active_to_selection`).
"""
clip_boxes: bpy.props.CollectionProperty(type=ObjProperty)
active_clip_box_index: bpy.props.IntProperty(
default=0,
min=0,
update=update_active_clip_box_index,
description="Index of the clip box currently driving the viewport clip planes",
)
enabled: bpy.props.BoolProperty(
name="Enabled",
default=False,
update=update_enabled,
description="When enabled, the active clip box hides all viewport geometry outside its 6 faces",
)
show_caps: bpy.props.BoolProperty(
name="Show Caps",
default=True,
update=update_show_caps,
description=(
"Draw filled cross-section caps where IFC product geometry "
"crosses the active clip planes. Disable for performance on "
"very heavy scenes"
),
)
# Stored on the Scene PG so Blender persists it in the .blend; deliberately
# NOT written to the project pset so the IFC stays portable across users
# who may have different Blender-side reference geometry to clip.
clip_only_ifc_products: bpy.props.BoolProperty(
name="Only IFC Products",
default=True,
update=update_clip_only_ifc_products,
description=(
"When enabled, only IFC element geometry gets cross-section caps. "
"Disable to also cap Blender-side reference meshes (sketches, "
"imported obj, primitive cubes, …)"
),
)
# Opt-in inclusion of geometry sitting inside loaded Project Links
# collection-instance empties. Off by default — linked IFCs commonly
# carry the entire site / structural / MEP context, and bisecting
# them on every clip-box edit can be expensive.
include_linked_ifc: bpy.props.BoolProperty(
name="Include Linked IFC",
default=False,
update=update_include_linked_ifc,
description=(
"Also generate cross-section caps for geometry inside linked "
"IFC files (Project ▸ Links). Off by default — linked IFCs may "
"carry the entire site / structural backbone, and capping them "
"adds per-mesh bisect cost on every clip-box edit"
),
)
# Also Scene-only — gizmo visibility is a per-user editing preference,
# not a portable IFC property.
enable_gizmos: bpy.props.BoolProperty(
name="Show Face Handles",
default=True,
description=(
"Show interactive face-resize handles on the active clip box. "
"Disable to fall back to plain G/R/S transforms on the empty"
),
)
if TYPE_CHECKING:
active_clip_box_index: int
enabled: bool
show_caps: bool
clip_only_ifc_products: bool
include_linked_ifc: bool
enable_gizmos: bool
+145
View File
@@ -0,0 +1,145 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# 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/>.
#
# This file was generated with the assistance of an AI coding tool.
from __future__ import annotations
from bpy.types import Menu, Panel, UIList
import bonsai.tool as tool
# Per-kind icon for the source-picker menu. Picked from Blender's built-in
# icon set; semantically close to the kind so users can scan the menu visually.
_SOURCE_MENU_ENTRIES: tuple[tuple[str, str, str], ...] = (
("SPATIAL", "Clip Spatial Element", "OUTLINER_COLLECTION"),
("CLASS", "Clip by Class", "BLANK1"),
("TYPE", "Clip Type", "FILE_3D"),
("MATERIAL", "Clip Material", "MATERIAL"),
("PROFILE", "Clip Profile", "MESH_CIRCLE"),
("DRAWING", "Clip Drawing Extents", "CAMERA_DATA"),
("STATUS", "Clip by Status", "INFO"),
("SYSTEM", "Clip by System", "MOD_FLUID"),
("GROUP", "Clip by Group", "OUTLINER_OB_GROUP_INSTANCE"),
("ZONE", "Clip by Zone", "MOD_LATTICE"),
)
class BIM_MT_clip_box_add_for_source(Menu):
bl_idname = "BIM_MT_clip_box_add_for_source"
bl_label = "Add Clip Box From Source"
def draw(self, context):
layout = self.layout
for kind, label, icon in _SOURCE_MENU_ENTRIES:
op = layout.operator("bim.add_clip_box_for_source", text=label, icon=icon)
op.source_kind = kind
class BIM_MT_clip_box_settings(Menu):
bl_idname = "BIM_MT_clip_box_settings"
bl_label = "Clip Box Settings"
def draw(self, context):
scene_props = tool.ClipBox.get_scene_props(context.scene)
self.layout.prop(scene_props, "clip_only_ifc_products")
self.layout.prop(scene_props, "include_linked_ifc")
self.layout.prop(scene_props, "enable_gizmos")
class BIM_MT_clip_box_info(Menu):
bl_idname = "BIM_MT_clip_box_info"
bl_label = "Clip Box Face Handles"
def draw(self, context):
layout = self.layout
layout.label(text="Face Handles", icon="INFO")
layout.separator()
layout.label(text="Drag a face to resize the clip box on that axis.")
layout.label(text="The opposite face stays fixed (one-sided resize).")
layout.label(text="Ctrl+Click a face to align the viewport to it.")
layout.separator()
layout.label(text="Toggle handles from the Settings (gear) menu.")
class BIM_UL_clip_box(UIList):
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index, flt_flag):
obj = item.obj
row = layout.row(align=True)
if obj is None:
# Host empty was deleted from outliner; still expose the
# remove button so the orphan entry isn't permanent.
row.label(text="(missing)", icon="ERROR")
row.operator("bim.remove_clip_box", text="", icon="X", emboss=False).index = index
return
row.prop(obj, "name", text="", emboss=False, icon="MESH_CUBE")
row.operator("bim.duplicate_clip_box", text="", icon="DUPLICATE", emboss=False).index = index
row.operator("bim.remove_clip_box", text="", icon="X", emboss=False).index = index
class BIM_PT_clip_box(Panel):
bl_idname = "BIM_PT_clip_box"
bl_label = "Clip Box"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_options = {"DEFAULT_CLOSED"}
bl_parent_id = "BIM_PT_tab_sandbox"
def draw(self, context):
layout = self.layout
scene_props = tool.ClipBox.get_scene_props(context.scene)
toggles = layout.row(align=True)
toggles.scale_y = 2.0
toggles.prop(
scene_props,
"enabled",
text="Enable Clipping",
icon="HIDE_OFF" if scene_props.enabled else "HIDE_ON",
toggle=True,
)
toggles.prop(scene_props, "show_caps", text="Show Caps", icon="MOD_SOLIDIFY", toggle=True)
toggles.menu("BIM_MT_clip_box_settings", icon="PREFERENCES", text="")
toggles.menu("BIM_MT_clip_box_info", icon="INFO", text="")
layout.separator()
row = layout.row(align=True)
row.operator("bim.add_clip_box", icon="ADD", text="Add Clip Box")
row.menu("BIM_MT_clip_box_add_for_source", icon="DOWNARROW_HLT", text="")
layout.template_list(
"BIM_UL_clip_box",
"",
scene_props,
"clip_boxes",
scene_props,
"active_clip_box_index",
rows=3,
)
obj = tool.ClipBox.get_active_clip_box(context.scene)
if obj is None:
layout.label(text="No active clip box", icon="INFO")
return
col = layout.column(align=True)
col.label(text="Edit the empty with G / R / S to move / rotate / resize")
col.prop(obj, "location")
col.prop(obj, "rotation_euler")
col.prop(obj, "scale")
+38 -39
View File
@@ -159,40 +159,13 @@ _SPECIAL = {"=", " "} # Formula prefix, spaces
NUMERIC_INPUT_CHARS = _DIGITS | _OPERATORS | _METRIC_UNITS | _IMPERIAL_UNITS | _SPECIAL
_BONSAI_TRANSFORM_MACROS = frozenset(
{
# Bonsai overrides Blender's default move/duplicate keymaps with
# macros that wrap TRANSFORM_OT_translate. While a macro is the outer
# modal entry, the inner TRANSFORM_OT_translate does not surface in
# window.modal_operators — the macro's own idname does. The
# ``BIM_OT_`` prefix is what Blender returns from ``bl_idname`` at
# runtime (the class declaration uses the dotted ``bim.`` form).
"BIM_OT_override_move_macro", # G key
"BIM_OT_override_object_duplicate_move_macro", # Shift+D
"BIM_OT_override_object_duplicate_move_linked_macro", # Alt+D
"BIM_OT_object_duplicate_move_linked_aggregate_macro", # Ctrl+Shift+D
}
)
def _is_transform_modal_active(context) -> bool:
"""True iff a Blender transform modal (G/R/S and siblings, including
Bonsai's macro overrides) is currently driving per-frame ``matrix_world``
updates. Reads ``window.modal_operators`` the Blender 4.2+ collection of
running modal operators. Parametric gizmo groups gate poll + draw_prepare
on this so they hide for the duration of the drag instead of sliding
off-cursor as the matrix updates each frame."""
window = getattr(context, "window", None)
if window is None:
return False
modal_ops = getattr(window, "modal_operators", None)
if not modal_ops:
return False
for op in modal_ops:
idname = op.bl_idname
if idname.startswith("TRANSFORM_OT_") or idname in _BONSAI_TRANSFORM_MACROS:
return True
return False
"""Module-local alias for ``tool.Blender.is_transform_modal_active``.
Preserved as a name so AST scans and call sites in this file stay
decoupled from the helper's home module.
"""
return tool.Blender.is_transform_modal_active(context)
def _hide_all_non_modal_gizmos(group) -> None:
@@ -4877,7 +4850,14 @@ class GizmoDimension(GizmoMovable):
self.init_value = click_distance
if self.initial_snap_state and self.active_obj:
# Schematic gizmos opt out of dimension snap. Force the header
# indicator to ``off`` for the drag's duration so the user sees the
# state matches behaviour; ``exit`` restores ``initial_snap_state``.
# Skipping the snap cache here also avoids the per-drag mesh probe.
snap_supported = getattr(self.gizmo_group, "snap_enabled_on_dimensions", True)
if not snap_supported:
context.scene.tool_settings.use_snap = False
elif self.initial_snap_state and self.active_obj:
build_snap_cache(context, self.active_obj)
self._snap_cache_built = True
@@ -4919,11 +4899,18 @@ class GizmoDimension(GizmoMovable):
if not region or not rv3d:
return {"RUNNING_MODAL"}
tool_settings.use_snap = not self.initial_snap_state if event.ctrl else self.initial_snap_state
# Group-level opt-out: schematic gizmos float in viewport space, so
# global-snap-to-scene-vertices would produce spurious value jumps.
# The fallback (``True``) covers any gizmo whose group is not a
# ``BaseParametricGizmoGroup``.
snap_supported = getattr(self.gizmo_group, "snap_enabled_on_dimensions", True)
if tool_settings.use_snap and not self._snap_cache_built and self.active_obj:
build_snap_cache(context, self.active_obj)
self._snap_cache_built = True
if snap_supported:
tool_settings.use_snap = not self.initial_snap_state if event.ctrl else self.initial_snap_state
if tool_settings.use_snap and not self._snap_cache_built and self.active_obj:
build_snap_cache(context, self.active_obj)
self._snap_cache_built = True
current_coord = (event.mouse_region_x, event.mouse_region_y)
@@ -4947,7 +4934,7 @@ class GizmoDimension(GizmoMovable):
delta = (current_3d - self.start_location).dot(axis_direction)
if tool_settings.use_snap and self.active_obj:
if snap_supported and tool_settings.use_snap and self.active_obj:
# Snap the dimension tip (not mouse position) to target
# Calculate where the dimension tip would be with current delta
# The tip is at: gizmo_origin + axis * (init_value + delta)
@@ -5320,6 +5307,13 @@ class BaseParametricGizmoGroup:
# Pre-computed flip matrix for negative value handling (180° rotation around Z)
FLIP_MATRIX = Matrix.Rotation(math.pi, 4, "Z")
# Default: dimension drags respect Blender's global snap (Ctrl-toggleable
# during drag). Subclasses whose dimensions float in viewport space rather
# than aligning to real-world geometry should override to ``False`` —
# snapping to scene vertices in that case produces spurious value jumps
# as the mouse crosses unrelated meshes.
snap_enabled_on_dimensions: bool = True
# === Icon Gizmo Layout (meters) ===
# Icons are positioned in a horizontal row above the element:
# [Validate] [Cancel] [Cycle]
@@ -6580,6 +6574,11 @@ class BaseSchematicGizmoGroup(BaseParametricGizmoGroup):
# list and become no-ops. The schematic equivalents below take their place.
dimension_gizmo_props: list[DimensionGizmoConfig] = []
# Schematic dimensions float in billboarded viewport space, not aligned to
# real-world geometry. Snapping the dragged tip to scene vertices would
# produce nonsensical value jumps as the mouse crosses unrelated meshes.
snap_enabled_on_dimensions: bool = False
# Declarative dimension configuration consumed by ``setup_schematic_dimensions``
# and ``update_schematic_dimensions``. Each config produces one
# ``BIM_GT_gizmo_dimension`` instance positioned at a schematic-local
@@ -50,6 +50,9 @@ def set_active_camera_resolution(scene: bpy.types.Scene) -> None:
if camera.type != props.camera_type:
camera.type = props.camera_type
if props.update_props and (drawing := tool.Ifc.get_entity(camera_obj)):
tool.Drawing.sync_perspective_camera_shifts(drawing, camera)
ortho_scale, aspect_ratio = props.get_scale_and_aspect_ratio()
scene_render = scene.render
if (camera.ortho_scale != ortho_scale) or not tool.Cad.is_x(
@@ -56,7 +56,7 @@ import shapely
from bpy_extras.image_utils import load_image
from bpy_extras.io_utils import ImportHelper
from lxml import etree
from mathutils import Color, Vector
from mathutils import Color, Matrix, Vector
import bonsai.bim.export_ifc
import bonsai.bim.handler
@@ -601,6 +601,7 @@ class CreateDrawing(bpy.types.Operator):
context_type: Literal["body", "annotation"],
drawing_elements: set[ifcopenshell.entity_instance],
target_view: str,
link_matrix: Optional[Matrix] = None,
) -> None:
drawing_elements = drawing_elements.copy()
contexts_: list[list[int]] = getattr(contexts, context_type)
@@ -612,9 +613,19 @@ class CreateDrawing(bpy.types.Operator):
geom_settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS)
geom_settings.set("iterator-output", ifcopenshell.ifcopenshell_wrapper.NATIVE)
if ifc.by_id(context[0]).ContextType == "Plan" and "PLAN_VIEW" in target_view:
is_plan = ifc.by_id(context[0]).ContextType == "Plan" and "PLAN_VIEW" in target_view
z_offset = (0.002 if target_view == "PLAN_VIEW" else -0.002) if is_plan else 0.0
if link_matrix is not None:
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc)
t = link_matrix.to_translation()
offset = (t.x / unit_scale, t.y / unit_scale, t.z / unit_scale + z_offset)
geom_settings.set("model-offset", offset)
q = link_matrix.to_quaternion()
geom_settings.set("model-rotation", (q.x, q.y, q.z, q.w))
elif z_offset:
# A 2mm Z offset to combat Z-fighting in plan or RCPs
geom_settings.set("model-offset", (0.0, 0.0, 0.002 if target_view == "PLAN_VIEW" else -0.002))
geom_settings.set("model-offset", (0.0, 0.0, z_offset))
geom_settings.set("context-ids", context)
it = ifcopenshell.geom.iterator(
@@ -918,11 +929,17 @@ class CreateDrawing(bpy.types.Operator):
cached_linework -= edited_guids
bim_props = tool.Blender.get_bim_props()
files = {bim_props.ifc_file: tool.Ifc.get()}
prefs = tool.Blender.get_addon_preferences()
# Map ifc_path → (ifc_file, link_matrix); main file has no link_matrix (None)
files: dict[str, tuple[ifcopenshell.file, Optional[Matrix]]] = {bim_props.ifc_file: (tool.Ifc.get(), None)}
props = tool.Project.get_project_props()
for link in props.get_loaded_links_for_drawings():
files[link.filepath] = self.get_linked_file(link)
try:
link_matrix = tool.Project.calculate_link_matrix(link)
except Exception:
link_matrix = None
files[link.filepath] = (self.get_linked_file(link), link_matrix)
target_view = ifcopenshell.util.element.get_psets(self.camera_element)["EPset_Drawing"]["TargetView"]
self.setup_serialiser(target_view)
@@ -930,18 +947,33 @@ class CreateDrawing(bpy.types.Operator):
tree = ifcopenshell.geom.tree()
tree.enable_face_styles(True)
for ifc in files.values():
# Accumulated across every file in the loop below (main model plus any
# linked models) so the SHAPELY fill pass after the loop covers all of
# them, not just whichever file happened to be processed last.
raycast_objs = set()
elements_with_faces = set()
for ifc_path, (ifc, link_matrix) in files.items():
# Don't use draw.main() just whilst we're prototyping and experimenting
self.serialiser.setFile(ifc)
drawing_elements = tool.Drawing.get_drawing_elements(self.camera_element, ifc_file=ifc)
if self.cprops.fill_mode == "SHAPELY":
for element in drawing_elements.copy():
if element.is_a("IfcAnnotation"):
continue
obj = tool.Ifc.get_object(element)
if obj and obj.type == "MESH" and len(obj.data.polygons):
elements_with_faces.add(element.GlobalId)
raycast_objs.add(obj)
# Get all representation contexts to see what we're dealing with.
# Drawings only draw bodies and annotations (and facetation, due to a Revit bug).
# A drawing prioritises a target view context first, followed by a model view context as a fallback.
# Specifically for PLAN_VIEW and REFLECTED_PLAN_VIEW, any Plan context is also prioritised.
contexts = self.get_linework_contexts(ifc, target_view)
self.serialize_contexts_elements(ifc, tree, contexts, "body", drawing_elements, target_view)
self.serialize_contexts_elements(ifc, tree, contexts, "annotation", drawing_elements, target_view)
self.serialize_contexts_elements(ifc, tree, contexts, "body", drawing_elements, target_view, link_matrix)
self.serialize_contexts_elements(ifc, tree, contexts, "annotation", drawing_elements, target_view, link_matrix)
if tool.Ifc.get() == ifc and self.camera_element not in drawing_elements:
with profile("Camera element"):
@@ -1008,16 +1040,6 @@ class CreateDrawing(bpy.types.Operator):
# shapely variant
group = root.find("{http://www.w3.org/2000/svg}g")
raycast_objs = set()
elements_with_faces = set()
for element in drawing_elements.copy():
if element.is_a("IfcAnnotation"):
continue
obj = tool.Ifc.get_object(element)
if obj and obj.type == "MESH" and len(obj.data.polygons):
elements_with_faces.add(element.GlobalId)
raycast_objs.add(obj)
projections = root.xpath(
".//svg:g[contains(@class, 'projection')]", namespaces={"svg": "http://www.w3.org/2000/svg"}
)
@@ -2310,7 +2332,8 @@ class ActivateDrawingBase(tool.Ifc.Operator):
bl_description = (
"Activates the selected drawing view.\n\n"
+ "ALT+CLICK to keep the viewport position.\n\n"
+ "SHIFT+CLICK to load a quick preview of the drawing view"
+ "SHIFT+CLICK to load a quick preview of the drawing view.\n\n"
+ "SHIFT+CTRL+CLICK to load the annotations of all selected drawings without switching views"
)
drawing: bpy.props.IntProperty()
@@ -2326,13 +2349,23 @@ class ActivateDrawingBase(tool.Ifc.Operator):
default=False,
options={"SKIP_SAVE"},
)
load_selected_annotations: bpy.props.BoolProperty(
name="Load Selected Annotations",
description="Load the annotations of all selected drawings without switching the active view.",
default=False,
options={"SKIP_SAVE"},
)
if TYPE_CHECKING:
drawing: int
should_view_from_camera: bool
use_quick_preview: bool
load_selected_annotations: bool
def invoke(self, context, event) -> set["rna_enums.OperatorReturnItems"]:
if event.type == "LEFTMOUSE" and event.shift and event.ctrl:
self.load_selected_annotations = True
return self.execute(context)
if event.type == "LEFTMOUSE" and event.alt:
self.should_view_from_camera = False
if event.type == "LEFTMOUSE" and event.shift:
@@ -2345,6 +2378,18 @@ class ActivateDrawingBase(tool.Ifc.Operator):
if props.is_editing_drawings == False:
bpy.ops.bim.load_drawings()
if self.load_selected_annotations:
for d in props.drawings:
if not (d.is_drawing and d.is_selected):
continue
selected_drawing = tool.Ifc.get().by_id(d.ifc_definition_id)
# Importing the camera (if missing) ensures the drawing's
# collection exists so the annotations get collected into it.
if not tool.Ifc.get_object(selected_drawing):
tool.Drawing.import_drawing(selected_drawing)
tool.Drawing.import_annotations_in_group(tool.Drawing.get_drawing_group(selected_drawing))
return {"FINISHED"}
drawing = tool.Ifc.get().by_id(self.drawing)
dprops = tool.Drawing.get_document_props()
@@ -2430,7 +2475,8 @@ class ActivateDrawing(bpy.types.Operator, ActivateDrawingBase):
bl_description = (
"Activates the selected drawing view.\n\n"
+ "ALT+CLICK to keep the viewport position.\n\n"
+ "SHIFT+CLICK to load a quick preview of the drawing view"
+ "SHIFT+CLICK to load a quick preview of the drawing view.\n\n"
+ "SHIFT+CTRL+CLICK to load the annotations of all selected drawings without switching views"
)
@@ -604,6 +604,7 @@ class BIMCameraProperties(PropertyGroup):
return tool.Blender.get_active_uilist_element(dprops.drawing_styles, self.active_drawing_style_index)
# For now, this JSON dump are all the parameters that determine a camera's "Block representation"
# Perspective camera shift is stored in EPset_Drawing and intentionally excluded here.
# By checking this, you will know whether or not the camera IFC representation needs to be refreshed
def update_representation(self, matrix_world: Matrix) -> bool:
"""Update ``representation`` based on current camera properties and the provided world matrix.
@@ -99,6 +99,10 @@ class BIM_PT_camera(Panel):
if props.target_view == "MODEL_VIEW":
row = self.layout.row()
row.prop(props, "camera_type")
if props.camera_type == "PERSP":
row = self.layout.row(align=True)
row.prop(camera_data, "shift_x", text="Camera Shift X/Y:")
row.prop(camera_data, "shift_y", text="")
row = self.layout.row()
row.prop(props, "linework_mode")
@@ -16,8 +16,6 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
from collections.abc import Sequence
import blf
import bpy
import gpu
@@ -25,15 +23,16 @@ import ifcopenshell
import numpy as np
from bpy.types import SpaceView3D
from bpy_extras.view3d_utils import location_3d_to_region_2d
from gpu_extras.batch import batch_for_shader
from mathutils import Matrix, Vector
import bonsai.tool as tool
class ItemDecorator:
is_installed = False
handlers = []
class ItemDecorator(tool.Blender.ViewportDecorator):
draw_methods = (
("draw_text", "POST_PIXEL"),
("draw", "POST_VIEW"),
)
objs: dict[str, dict[str, list]]
obj_is_selected: dict[str, bool]
obj_is_boolean: dict[str, list[ifcopenshell.entity_instance]]
@@ -119,23 +118,6 @@ class ItemDecorator:
"special_edges": special_edges,
}
@classmethod
def uninstall(cls):
for handler in cls.handlers:
try:
SpaceView3D.draw_handler_remove(handler, "WINDOW")
except ValueError:
pass
cls.is_installed = False
def draw_batch(self, shader_type, content_pos, color, indices=None):
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
return
shader = self.line_shader if shader_type == "LINES" else self.shader
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
shader.uniform_float("color", color)
batch.draw(shader)
def draw_text(self, context):
self.addon_prefs = tool.Blender.get_addon_preferences()
selected_elements_color = self.addon_prefs.decorator_color_selected
@@ -163,11 +145,6 @@ class ItemDecorator:
blf.disable(font_id, blf.SHADOW)
def draw(self, context: bpy.types.Context) -> None:
def transparent_color(color: Sequence[float], alpha: float = 0.05) -> list[float]:
color = [i for i in color]
color[3] = alpha
return color
self.addon_prefs = tool.Blender.get_addon_preferences()
selected_elements_color = self.addon_prefs.decorator_color_selected
unselected_elements_color = self.addon_prefs.decorator_color_unselected
@@ -197,15 +174,33 @@ class ItemDecorator:
if context.mode != "OBJECT":
continue
self.draw_batch("LINES", data["verts"], selected_elements_color, data["edges"])
self.draw_batch("TRIS", data["verts"], transparent_color(selected_elements_color), data["tris"])
self.draw_batch(
"TRIS",
data["verts"],
tool.Blender.transparent_color(selected_elements_color, alpha=0.05),
data["tris"],
)
self.draw_batch("LINES", data["special_verts"], selected_elements_color, data["special_edges"])
elif self.obj_is_boolean[obj_name]:
self.draw_batch("LINES", data["verts"], special_elements_color, data["edges"])
self.draw_batch("TRIS", data["verts"], transparent_color(special_elements_color), data["tris"])
self.draw_batch(
"TRIS",
data["verts"],
tool.Blender.transparent_color(special_elements_color, alpha=0.05),
data["tris"],
)
self.draw_batch("LINES", data["special_verts"], special_elements_color, data["special_edges"])
else:
self.draw_batch(
"LINES", data["verts"], transparent_color(unselected_elements_color, alpha=0.2), data["edges"]
"LINES",
data["verts"],
tool.Blender.transparent_color(unselected_elements_color, alpha=0.2),
data["edges"],
)
self.draw_batch(
"TRIS",
data["verts"],
tool.Blender.transparent_color(special_elements_color, alpha=0.05),
data["tris"],
)
self.draw_batch("TRIS", data["verts"], transparent_color(special_elements_color), data["tris"])
self.draw_batch("LINES", data["special_verts"], special_elements_color, data["special_edges"])
@@ -887,6 +887,16 @@ class OverrideDelete(bpy.types.Operator):
# Track aggregates before deleting their parts
aggregates_to_check = self.track_aggregates(objects_to_remove)
# Snapshot the set of IFC entity ids being deleted in this batch so the
# connection-rel cascade inside `delete_ifc_object` can suppress
# partner-side regenerate when the partner is also about to vanish.
batch_being_deleted_ids: set[int] = set()
for obj in objects_to_remove:
if not tool.Blender.is_valid_data_block(obj):
continue
if (entity := tool.Ifc.get_entity(obj)) is not None:
batch_being_deleted_ids.add(entity.id())
clear_active_object = True
for i, obj in enumerate(objects_to_remove, 1):
@@ -928,7 +938,7 @@ class OverrideDelete(bpy.types.Operator):
if tool.Drawing.is_auto_annotation(element):
self.report({"INFO"}, "References cannot be deleted. Exclude the referenced element instead.")
continue
tool.Geometry.delete_ifc_object(obj)
tool.Geometry.delete_ifc_object(obj, batch_being_deleted_ids=batch_being_deleted_ids)
elif tool.Geometry.is_representation_item(obj):
tool.Geometry.delete_ifc_item(obj)
else:
@@ -1027,7 +1037,10 @@ class OverrideDelete(bpy.types.Operator):
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
if not pset:
continue
array_parents.add(ifc_file.by_guid(pset["Parent"]))
try:
array_parents.add(ifc_file.by_guid(pset["Parent"]))
except RuntimeError:
continue
for array_parent in array_parents:
array_parent_obj = tool.Ifc.get_object(array_parent)
@@ -17,6 +17,7 @@
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import bpy
import ifcopenshell.util.unit
from bpy.types import Menu, Panel, UIList
import ifcopenshell.util.unit
@@ -21,7 +21,6 @@ from math import radians
import blf
import gpu
import ifcopenshell.util.geolocation
from bpy.types import SpaceView3D
from bpy_extras.view3d_utils import location_3d_to_region_2d
from gpu_extras.batch import batch_for_shader
from mathutils import Matrix, Vector
@@ -30,27 +29,11 @@ import bonsai.tool as tool
from bonsai.bim.module.georeference.data import GeoreferenceData
class GeoreferenceDecorator:
is_installed = False
handlers = []
@classmethod
def install(cls, context):
if cls.is_installed:
cls.uninstall()
handler = cls()
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_text, (context,), "WINDOW", "POST_PIXEL"))
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_geometry, (context,), "WINDOW", "POST_VIEW"))
cls.is_installed = True
@classmethod
def uninstall(cls):
for handler in cls.handlers:
try:
SpaceView3D.draw_handler_remove(handler, "WINDOW")
except ValueError:
pass
cls.is_installed = False
class GeoreferenceDecorator(tool.Blender.ViewportDecorator):
draw_methods = (
("draw_text", "POST_PIXEL"),
("draw_geometry", "POST_VIEW"),
)
def draw_batch(self, shader_type, content_pos, color, indices=None, should_scale=True):
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
@@ -197,6 +180,10 @@ class GeoreferenceDecorator:
decorator_color_error = self.addon_prefs.decorator_color_error
gpu.state.blend_set("ALPHA")
# The georef gizmo is a coordinate-system overlay: it must communicate
# orientation regardless of model contents, so depth testing is bypassed.
original_depth_test = gpu.state.depth_test_get()
gpu.state.depth_test_set("ALWAYS")
self.line_shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR")
self.line_shader.bind() # required to be able to change uniforms of the shader
@@ -335,6 +322,8 @@ class GeoreferenceDecorator:
self.draw_batch("LINES", verts, decorator_color_special, edges)
self.draw_dashed_line(location * 3, location * 6, decorator_color_error)
gpu.state.depth_test_set(original_depth_test)
def draw_dashed_line(self, start, end, colour, should_scale=True):
direction = (end - start).normalized()
distance = (end - start).length
@@ -20,44 +20,18 @@
import blf
import bpy
import gpu
from bpy.types import SpaceView3D
from bpy_extras.view3d_utils import location_3d_to_region_2d
from gpu_extras.batch import batch_for_shader
from mathutils import Matrix, Vector
import bonsai.tool as tool
from bonsai.bim.module.light.data import SolarData
class SolarDecorator:
is_installed = False
handlers = []
@classmethod
def install(cls, context):
if cls.is_installed:
cls.uninstall()
handler = cls()
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_text, (context,), "WINDOW", "POST_PIXEL"))
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_geometry, (context,), "WINDOW", "POST_VIEW"))
cls.is_installed = True
@classmethod
def uninstall(cls):
for handler in cls.handlers:
try:
SpaceView3D.draw_handler_remove(handler, "WINDOW")
except ValueError:
pass
cls.is_installed = False
def draw_batch(self, shader_type, content_pos, color, indices=None):
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
return
shader = self.line_shader if shader_type == "LINES" else self.shader
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
shader.uniform_float("color", color)
batch.draw(shader)
class SolarDecorator(tool.Blender.ViewportDecorator):
draw_methods = (
("draw_text", "POST_PIXEL"),
("draw_geometry", "POST_VIEW"),
)
def draw_text(self, context: bpy.types.Context) -> None:
self.addon_prefs = tool.Blender.get_addon_preferences()
+17 -3
View File
@@ -110,6 +110,9 @@ classes = (
wall.GizmoWallFilletPreview,
wall.GizmoWallFilletReedit,
wall.GizmoWallFilletToggleOpenings,
wall.GizmoPairDisconnect,
wall.GizmoSlabEdition,
wall.GizmoSlabUnjoinWalls,
wall.GizmoWallJoinIntersection,
wall.GizmoWallLinkToggle,
wall.GizmoWallUnjoinSingle,
@@ -120,7 +123,7 @@ classes = (
wall.RotateWall90,
wall.SplitWall,
wall.SplitWallAtCursor,
wall.UnjoinWallPathConnection,
wall.DisconnectElements,
wall.UnjoinWalls,
wall.EnableWallFilletPreview,
wall.FinishWallFilletPreview,
@@ -154,11 +157,14 @@ classes = (
slab.DisableEditingExtrusionProfile,
slab.DisableEditingSketchExtrusionProfile,
slab.AddSlabFromWall,
slab.CancelEditingSlab,
slab.DrawPolylineSlab,
slab.EditExtrusionProfile,
slab.EditSketchExtrusionProfile,
slab.EnableEditingExtrusionProfile,
slab.EnableEditingSketchExtrusionProfile,
slab.EnableEditingSlab,
slab.FinishEditingSlab,
slab.RecalculateSlab,
slab.ResetVertex,
slab.SetArcIndex,
@@ -185,6 +191,7 @@ classes = (
prop.BIMDoorProperties,
prop.BIMRailingProperties,
prop.BIMRoofProperties,
prop.BIMSlabProperties,
prop.BIMWallProperties,
prop.BIMPipeSegmentProperties,
prop.BIMDuctSegmentProperties,
@@ -246,9 +253,13 @@ classes = (
railing.CopyRailingParameters,
railing.AddRailing,
railing.CancelEditingRailing,
railing.CycleRailingType,
railing.FinishEditingRailing,
railing.PickRailingTerminalType,
railing.FlipRailingPathOrder,
railing.EnableEditingRailing,
railing.GizmoRailingSchematic,
railing.ToggleRailingUseManualSupports,
railing.CancelEditingRailingPath,
railing.FinishEditingRailingPath,
railing.EnableEditingRailingPath,
@@ -269,9 +280,7 @@ classes = (
mep.MEPAddObstruction,
mep.MEPAddTransition,
mep.MEPAddBend,
mep.MEPUnjoinAtPort,
mep.MEPRemoveTerminalFitting,
mep.MEPUnjoinPair,
mep.SelectMEPPathMembers,
mep.MEPJoinSegments,
mep_bend_preview.EnableBendPreview,
@@ -368,6 +377,11 @@ def unregister():
# half-unloaded module state.
opening.DecorationsHandler.uninstall()
# Network path overlays attach SpaceView3D draw handlers on toggle;
# uninstall here so addon disable / Blender shutdown doesn't leak them.
decorator.MEPSystemPathDecorator.uninstall()
decorator.WallSystemPathDecorator.uninstall()
if not bpy.app.background:
for tool_data in reversed(tools):
bpy.utils.unregister_tool(tool_data.tool)
+36 -27
View File
@@ -421,18 +421,26 @@ class RegenerateArray(bpy.types.Operator, tool.Ifc.Operator):
pset = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array")
arrays = json.loads(pset["Data"])
pset = tool.Ifc.get().by_id(pset["id"])
for array in arrays:
for child in set(array["children"]):
if child_obj := tool.Ifc.get_object(tool.Ifc.get().by_guid(child)):
tool.Geometry.delete_ifc_object(child_obj)
array["children"].clear()
# Always operate on the parent — this operator can be invoked with
# either the parent OR any array child as active_object (the per-child
# gizmo group fires it from a child selection). Using ``obj`` /
# ``element`` directly would feed a child to ``regenerate_array`` and
# constrain children against a sibling, silently corrupting the array.
tool.Model.regenerate_array(parent, arrays)
tool.Array.constrain_children_to_parent(parent_element)
# Coalesce host recuts: the child-delete loop, the regenerate, and the
# per-child opening mirror all touch the same host body. Without batching,
# an N-child wipe-then-regen costs N+1 recuts; this collapses to one.
with tool.Geometry.batch_host_recut():
for array in arrays:
for child in set(array["children"]):
try:
child_element = tool.Ifc.get().by_guid(child)
except RuntimeError:
continue
if child_obj := tool.Ifc.get_object(child_element):
tool.Geometry.delete_ifc_object(child_obj)
array["children"].clear()
# Always operate on the parent — this operator can be invoked with
# either the parent OR any array child as active_object (the per-child
# gizmo group fires it from a child selection). Using ``obj`` /
# ``element`` directly would feed a child to ``regenerate_array`` and
# constrain children against a sibling, silently corrupting the array.
tool.Model.regenerate_array(parent, arrays)
tool.Array.constrain_children_to_parent(parent_element)
class RemoveArray(bpy.types.Operator, tool.Ifc.Operator):
@@ -467,23 +475,24 @@ class RemoveArray(bpy.types.Operator, tool.Ifc.Operator):
except:
return {"FINISHED"}
if self.keep_objs:
tool.Array.bake_children_transform(element, self.item)
tool.Array.set_children_lock_state(element, self.item, False)
with tool.Geometry.batch_host_recut():
if self.keep_objs:
tool.Array.bake_children_transform(element, self.item)
tool.Array.set_children_lock_state(element, self.item, False)
if not self.keep_objs:
data[self.item]["count"] = 1
tool.Array.remove_constraints(parent_element)
tool.Model.regenerate_array(parent, data, array_layers_to_apply=[self.item] if self.keep_objs else [])
if not self.keep_objs:
data[self.item]["count"] = 1
tool.Array.remove_constraints(parent_element)
tool.Model.regenerate_array(parent, data, array_layers_to_apply=[self.item] if self.keep_objs else [])
pset = tool.Pset.get_element_pset(element, "BBIM_Array")
if len(data) == 1:
ifcopenshell.api.pset.remove_pset(tool.Ifc.get(), product=element, pset=pset)
else:
del data[self.item]
data = tool.Ifc.get().createIfcText(json.dumps(data))
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": data})
tool.Array.constrain_children_to_parent(element)
pset = tool.Pset.get_element_pset(element, "BBIM_Array")
if len(data) == 1:
ifcopenshell.api.pset.remove_pset(tool.Ifc.get(), product=element, pset=pset)
else:
del data[self.item]
data = tool.Ifc.get().createIfcText(json.dumps(data))
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": data})
tool.Array.constrain_children_to_parent(element)
class SelectArrayParent(bpy.types.Operator):
@@ -51,6 +51,10 @@ class AuthoringData:
@classmethod
def load(cls, ifc_element_type: Optional[str] = None):
# ``is_loaded`` is set first as a recursion guard: one of the data
# computations evaluates a PropertyGroup enum's ``items`` callback,
# which re-enters this method. Without the guard, load recurses to
# RecursionError.
cls.is_loaded = True
cls.props = tool.Model.get_model_props()
cls.data["default_container"] = cls.default_container()
+543 -222
View File
@@ -21,6 +21,7 @@
from __future__ import annotations
import math
from collections.abc import Sequence
from math import cos, pi, radians, sin, tan
from typing import Any, Literal, NamedTuple
@@ -43,6 +44,7 @@ from mathutils import Matrix, Quaternion, Vector
import bonsai.core.geometry
import bonsai.tool as tool
from bonsai.bim.decorator_cache import TokenCache
from bonsai.bim.module.drawing.gizmos import (
ARC_SEGMENTS,
DOOR_SWING_ANGLE_MAX,
@@ -51,17 +53,46 @@ from bonsai.bim.module.drawing.gizmos import (
from bonsai.bim.module.drawing.helper import format_distance
def transparent_color(color, alpha=0.1):
color = [i for i in color]
color[3] = alpha
return color
def highlight_color(color, alpha=0.1):
color = [i + (1 - i) * 0.5 for i in color]
return color
def _stroke_lines_alpha(
context: bpy.types.Context,
segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]],
color_rgb: tuple[float, float, float],
line_width: float,
line_alpha: float,
) -> None:
"""Render ``segments`` (a list of (start, end) tuples) as one anti-aliased
LINES batch in world space. Early-returns when ``context.region`` is
unavailable (e.g. when called from a ``_RestrictContext``)."""
if not segments:
return
verts: list[tuple[float, float, float]] = []
indices: list[tuple[int, int]] = []
for start, end in segments:
base = len(verts)
verts.append(tuple(start))
verts.append(tuple(end))
indices.append((base, base + 1))
if not tool.Blender.validate_shader_batch_data(verts, indices):
return
region = getattr(context, "region", None)
if region is None:
return
shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR")
shader.bind()
shader.uniform_float("viewportSize", (region.width, region.height))
shader.uniform_float("lineWidth", line_width)
shader.uniform_float("color", (*color_rgb, line_alpha))
batch = batch_for_shader(shader, "LINES", {"pos": verts}, indices=indices)
gpu.state.blend_set("ALPHA")
batch.draw(shader)
gpu.state.blend_set("NONE")
class ProfileDecorator:
installed = None
@@ -96,7 +127,7 @@ class ProfileDecorator:
def draw_faces(self, bm, vertices_coords):
"""Submit a non-mutating beauty-triangulated TRIS batch over ``bm``'s faces."""
faces_color = transparent_color(self.addon_prefs.decorator_color_special)
faces_color = tool.Blender.transparent_color(self.addon_prefs.decorator_color_special)
tool.Blender.draw_bmesh_face_tris(bm, vertices_coords, faces_color, self.draw_batch)
def __call__(self, context, get_custom_bmesh=None, draw_faces=False, exit_edit_mode_callback=None):
@@ -226,7 +257,7 @@ class ProfileDecorator:
self.draw_batch("LINES", all_vertices, unselected_elements_color, unselected_edges)
self.draw_batch("LINES", all_vertices, selected_elements_color, selected_edges)
self.draw_batch("POINTS", unselected_vertices, transparent_color(unselected_elements_color, 0.5))
self.draw_batch("POINTS", unselected_vertices, tool.Blender.transparent_color(unselected_elements_color, 0.5))
self.draw_batch("POINTS", error_vertices, error_elements_color)
self.draw_batch("POINTS", special_vertices, special_elements_color)
self.draw_batch("POINTS", selected_vertices, selected_elements_color)
@@ -317,9 +348,11 @@ class ProfileDecorator:
return points, listEdg
class PolylineDecorator:
is_installed = False
handlers = []
class PolylineDecorator(tool.Blender.ViewportDecorator):
# draw_methods declares only the always-bound handler so the base's
# __init_subclass__ validation passes; the override install below
# conditionally registers up to four more handlers based on ui_only.
draw_methods = (("draw_input_ui", "POST_PIXEL"),)
event = None
input_type = None
input_ui = None
@@ -355,15 +388,6 @@ class PolylineDecorator:
cls.handlers.append(SpaceView3D.draw_handler_add(handler, (context,), "WINDOW", "POST_VIEW"))
cls.is_installed = True
@classmethod
def uninstall(cls):
for handler in cls.handlers:
try:
SpaceView3D.draw_handler_remove(handler, "WINDOW")
except ValueError:
pass
cls.is_installed = False
@classmethod
def update(
cls,
@@ -422,14 +446,6 @@ class PolylineDecorator:
return {"verts": verts, "edges": edges, "tris": tris}
def draw_batch(self, shader_type, content_pos, color, indices=None):
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
return
shader = self.line_shader if shader_type == "LINES" else self.shader
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
shader.uniform_float("color", color)
batch.draw(shader)
def shader_config(self, context):
self.addon_prefs = tool.Blender.get_addon_preferences()
self.decorator_color = self.addon_prefs.decorations_colour
@@ -697,7 +713,9 @@ class PolylineDecorator:
if self.polyline_data.measurement_type == "POLY_AREA" and area:
if float(area) > 0:
tris = self.calculate_polygon(polyline_verts)["tris"]
self.draw_batch("TRIS", polyline_verts, transparent_color(self.decorator_color_special), tris)
self.draw_batch(
"TRIS", polyline_verts, tool.Blender.transparent_color(self.decorator_color_special), tris
)
# Draw polyline with selected points
self.line_shader.uniform_float("lineWidth", 2.0)
@@ -950,9 +968,8 @@ class PolylineDecorator:
self.draw_batch("LINES", polyline_verts, decorator_color_unselected, polyline_edges)
class ProductDecorator:
is_installed = False
handlers = []
class ProductDecorator(tool.Blender.ViewportDecorator):
draw_method = "draw_product_preview"
preview_mode: Literal["PROFILE_VERTICAL", "PROFILE_HORIZONTAL", "LAYER2", "LAYER3", "GENERIC"]
relating_type = None
obj_data: dict[str, list] = {}
@@ -995,29 +1012,7 @@ class ProductDecorator:
)
cls.is_installed = True
@classmethod
def uninstall(cls):
for handler in cls.handlers:
try:
SpaceView3D.draw_handler_remove(handler, "WINDOW")
except ValueError:
pass
cls.is_installed = False
def draw_batch(self, shader_type, content_pos, color, indices=None):
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
return
shader = self.line_shader if shader_type == "LINES" else self.shader
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
shader.uniform_float("color", color)
batch.draw(shader)
def draw_product_preview(self, context):
def transparent_color(color, alpha=0.1):
color = [i for i in color]
color[3] = alpha
return color
self.addon_prefs = tool.Blender.get_addon_preferences()
self.line_shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR")
self.line_shader.bind() # required to be able to change uniforms of the shader
@@ -1049,7 +1044,7 @@ class ProductDecorator:
data = self.get_generic_preview_data()
if data:
self.draw_batch("LINES", data["verts"], decorator_color, data["edges"])
self.draw_batch("TRIS", data["verts"], transparent_color(decorator_color), data["tris"])
self.draw_batch("TRIS", data["verts"], tool.Blender.transparent_color(decorator_color), data["tris"])
def get_wall_preview_data(self):
relating_type = self.relating_type
@@ -1582,34 +1577,8 @@ class ProductDecorator:
return data
class WallAxisDecorator:
is_installed = False
handlers = []
@classmethod
def install(cls, context):
if cls.is_installed:
cls.uninstall()
handler = cls()
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_wall_axis, (context,), "WINDOW", "POST_VIEW"))
cls.is_installed = True
@classmethod
def uninstall(cls):
for handler in cls.handlers:
try:
SpaceView3D.draw_handler_remove(handler, "WINDOW")
except ValueError:
pass
cls.is_installed = False
def draw_batch(self, shader_type, content_pos, color, indices=None):
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
return
shader = self.line_shader if shader_type == "LINES" else self.shader
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
shader.uniform_float("color", color)
batch.draw(shader)
class WallAxisDecorator(tool.Blender.ViewportDecorator):
draw_method = "draw_wall_axis"
def draw_wall_axis(self, context):
self.addon_prefs = tool.Blender.get_addon_preferences()
@@ -1628,7 +1597,7 @@ class WallAxisDecorator:
self.line_shader.uniform_float("lineWidth", 2.0)
for obj in context.selected_objects:
element = tool.Ifc.get_entity(obj)
if element.is_a("IfcWall"):
if element and element.is_a("IfcWall"):
layers = tool.Model.get_material_layer_parameters(element)
axis = tool.Model.get_wall_axis(obj, layers)
side = [tuple(list(v) + [obj.location.z]) for v in axis["side"]]
@@ -1648,34 +1617,8 @@ class WallAxisDecorator:
self.draw_batch("LINES", arrow, unselected_elements_color, [(0, 1), (1, 2), (1, 3)])
class SlabDirectionDecorator:
is_installed = False
handlers = []
@classmethod
def install(cls, context):
if cls.is_installed:
cls.uninstall()
handler = cls()
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_wall_axis, (context,), "WINDOW", "POST_VIEW"))
cls.is_installed = True
@classmethod
def uninstall(cls):
for handler in cls.handlers:
try:
SpaceView3D.draw_handler_remove(handler, "WINDOW")
except ValueError:
pass
cls.is_installed = False
def draw_batch(self, shader_type, content_pos, color, indices=None):
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
return
shader = self.line_shader if shader_type == "LINES" else self.shader
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
shader.uniform_float("color", color)
batch.draw(shader)
class SlabDirectionDecorator(tool.Blender.ViewportDecorator):
draw_method = "draw_wall_axis"
def draw_wall_axis(self, context):
self.addon_prefs = tool.Blender.get_addon_preferences()
@@ -1705,41 +1648,10 @@ class SlabDirectionDecorator:
self.draw_batch("LINES", base, selected_elements_color, [(0, 1)])
class FaceAreaDecorator:
is_installed = False
handlers = []
@classmethod
def install(cls, context):
if cls.is_installed:
cls.uninstall()
handler = cls()
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_face_area, (context,), "WINDOW", "POST_VIEW"))
cls.is_installed = True
@classmethod
def uninstall(cls):
for handler in cls.handlers:
try:
SpaceView3D.draw_handler_remove(handler, "WINDOW")
except ValueError:
pass
cls.is_installed = False
def draw_batch(self, shader_type, content_pos, color, indices=None):
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
return
shader = self.line_shader if shader_type == "LINES" else self.shader
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
shader.uniform_float("color", color)
batch.draw(shader)
class FaceAreaDecorator(tool.Blender.ViewportDecorator):
draw_method = "draw_face_area"
def draw_face_area(self, context):
def transparent_color(color, alpha=0.1):
color = [i for i in color]
color[3] = alpha
return color
self.addon_prefs = tool.Blender.get_addon_preferences()
self.line_shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR")
self.line_shader.bind() # required to be able to change uniforms of the shader
@@ -1760,12 +1672,16 @@ class FaceAreaDecorator:
if data:
self.draw_batch("POINTS", data["verts"], decorator_color)
self.draw_batch("LINES", data["verts"], decorator_color, data["edges"])
self.draw_batch("TRIS", data["verts"], transparent_color(decorator_color, alpha=0.5), data["tris"])
self.draw_batch(
"TRIS", data["verts"], tool.Blender.transparent_color(decorator_color, alpha=0.5), data["tris"]
)
class BoundingBoxDecorator:
is_installed = False
handlers = []
class BoundingBoxDecorator(tool.Blender.ViewportDecorator):
draw_methods = (
("draw_bounding_box_wire_cube", "POST_VIEW"),
("draw_dimension_text", "POST_PIXEL"),
)
def __init__(self):
context = bpy.context
@@ -1776,31 +1692,6 @@ class BoundingBoxDecorator:
self.decorator_color_wire = (*theme.view_3d.bone_solid, 1)
self.decorator_color_special = tool.Blender.get_addon_preferences().decorator_color_special
@classmethod
def install(cls, context):
if cls.is_installed:
cls.uninstall()
handler = cls()
cls.handlers.append(
bpy.types.SpaceView3D.draw_handler_add(
handler.draw_bounding_box_wire_cube, (context,), "WINDOW", "POST_VIEW"
)
)
cls.handlers.append(
bpy.types.SpaceView3D.draw_handler_add(handler.draw_dimension_text, (context,), "WINDOW", "POST_PIXEL")
)
cls.is_installed = True
@classmethod
def uninstall(cls):
for handler in cls.handlers:
try:
bpy.types.SpaceView3D.draw_handler_remove(handler, "WINDOW")
except Exception:
pass
cls.handlers.clear()
cls.is_installed = False
@staticmethod
def get_combined_bounding_box_corners(objects):
@@ -1873,14 +1764,6 @@ class BoundingBoxDecorator:
]
return trihedron[best_origin]
def draw_batch(self, shader_type, content_pos, color, indices=None):
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
return
shader = self.line_shader if shader_type == "LINES" else self.shader
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
shader.uniform_float("color", color)
batch.draw(shader)
def draw_text_background(self, context, coords_dim, text_dim):
padding = 5
theme = context.preferences.themes.items()[0][1]
@@ -2032,46 +1915,6 @@ class BoundingBoxDecorator:
co2.y -= y_overlap / 2 + min_spacing
def _fill_quads_alpha(
context: bpy.types.Context,
quads: list[
tuple[
tuple[float, float, float],
tuple[float, float, float],
tuple[float, float, float],
tuple[float, float, float],
]
],
color_rgb: tuple[float, float, float],
alpha: float,
) -> None:
"""Render ``quads`` (each a 4-tuple of world-space corner verts in CCW
order) as one TRIS batch with two triangles per quad."""
if not quads:
return
verts: list[tuple[float, float, float]] = []
indices: list[tuple[int, int, int]] = []
for quad in quads:
if len(quad) != 4:
continue
base = len(verts)
verts.extend(tuple(v) for v in quad)
indices.append((base, base + 1, base + 2))
indices.append((base, base + 2, base + 3))
if not tool.Blender.validate_shader_batch_data(verts, indices):
return
region = getattr(context, "region", None)
if region is None:
return
shader = gpu.shader.from_builtin("UNIFORM_COLOR")
shader.bind()
shader.uniform_float("color", (*color_rgb, alpha))
batch = batch_for_shader(shader, "TRIS", {"pos": verts}, indices=indices)
gpu.state.blend_set("ALPHA")
batch.draw(shader)
gpu.state.blend_set("NONE")
def compute_mep_join_location():
"""Midpoint between the closest endpoint pair of two selected MEP
segments the world location where a connecting fitting (bend /
@@ -2529,3 +2372,481 @@ def draw_polyline_segments(
_BBOX_HIGHLIGHT_LINE_WIDTH = 1.8
_BBOX_HIGHLIGHT_LINE_ALPHA = 0.8
class _ConnectedNetworkPathDecorator(tool.Blender.ViewportDecorator):
"""Shared scaffolding for "BFS-walk a connected IFC network from a selected
seed and overlay its schematic path" viewport decorators.
Subclasses implement three hooks:
``_is_seed_element(element)``: True if ``element`` can seed a walk
``_walk(start_element)``: list of network elements reachable from the seed
``_build_geometry(connected)``: ``(lines, free_points, connection_points)``
for one walk pass; free dots render in the base selected color,
connection dots in the "special" slot so junctions stand out
Lifecycle each redraw: gate on ``BIMModelProperties.show_paths`` (the
shared toggle for all network-path overlays), find the first selected
seed element, walk the network (cached per seed-GUID per IFC file), and
render lines + connection-node dots. Geometry is memoised through a
``TokenCache`` keyed on the decorator-cache token, so depsgraph / undo /
redo / load all invalidate the resolved world-space pass without
re-walking.
Install / uninstall is driven by the central addon-load handler and
by the toggle's ``update`` callback, so flipping the property takes
effect immediately without a Blender restart."""
# Network-path lines + junction dots render in ``decorator_color_selected``
# (Bonsai's palette slot for "what the user is currently inspecting"); free
# endpoints (dangling chain tips) switch to ``decorator_color_special`` so
# the end of the line stands apart from interior junctions at a glance.
LINE_WIDTH = 1.3
LINE_ALPHA = 0.85
# Sized larger than LINE_WIDTH so connection nodes read as discrete
# points rather than line thickenings.
DOT_SIZE = 4.0
# Squared distance under which two emitted dots are treated as the same
# connection node. In Blender units (typically meters), 1e-4 m ≈ 0.1 mm
# — below the precision at which two IFC reference-line endpoints would
# ever be authored as "the same join" but not so tight that float drift
# from coordinate composition misses a real coincidence.
CONNECTION_EPS_SQ = 1e-4 * 1e-4
def __init__(self) -> None:
# Walk cache keyed on (start_guid, ifc_file, geom_gen). Stores STEP
# integer ids rather than ``entity_instance`` references — re-resolved
# via ``ifc_file.by_id`` on each cache hit. Structurally rules out
# the dangling-SWIG-handle class of bug: an entity removed between
# frames either bumps geom_gen (cache miss → re-walk) or fails to
# re-resolve (handled below by re-walking). Compare ``ifc_file`` with
# ``is`` (not id()) so a GC-recycled id() can't produce a false hit.
self._cached_start_guid: str | None = None
self._cached_ifc_file: Any = None
self._cached_geom_gen: int = -1
self._cached_walk_ids: list[int] = []
# Geometry cache: shared TokenCache. Key folds in geom_gen so IFC
# mutations that don't surface via the depsgraph still flush the
# resolved world-space lines and dots.
self._geom_cache: TokenCache[
tuple[
list[tuple[tuple[float, float, float], tuple[float, float, float]]],
list[tuple[float, float, float]],
list[tuple[float, float, float]],
]
] = TokenCache()
# One-shot guards so a corrupted walk or build surfaces in the console
# once per decorator instance instead of every redraw.
self._walk_failure_logged: bool = False
self._build_failure_logged: bool = False
# Short-circuit re-running a known-broken walk or build for the same
# seed every frame; cleared the moment the user picks a different seed.
self._failed_seed_guid: str | None = None
_ABSTRACT_HOOKS = ("_is_seed_element", "_walk", "_build_geometry")
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
# Pin the template-method contract at class-definition time, mirroring
# ViewportDecorator's draw_method check: a subclass that forgets to
# override one of the three hooks would otherwise pass class creation
# and only raise NotImplementedError on the first walk — deferred long
# past the offending declaration.
missing = [
name for name in cls._ABSTRACT_HOOKS if getattr(cls, name) is getattr(_ConnectedNetworkPathDecorator, name)
]
if missing:
raise TypeError(f"{cls.__name__}: must override abstract hook(s) {sorted(missing)}")
def _is_seed_element(self, element: Any) -> bool:
raise NotImplementedError
def _walk(self, start_element: Any) -> list[Any]:
raise NotImplementedError
def _build_geometry(
self,
connected: list[Any],
) -> tuple[
list[tuple[tuple[float, float, float], tuple[float, float, float]]],
list[tuple[float, float, float]],
list[tuple[float, float, float]],
]:
"""Resolve world-space line segments + dots for one walk pass. Returns
``(lines, free_points, connection_points)`` free dots get the base
selected color, connection dots get the special color so junctions
between two consecutive elements pop out. Never raises; skips
degenerate elements."""
raise NotImplementedError
@classmethod
def _partition_points_by_coincidence(
cls,
points: list[tuple[float, float, float]],
lines: Sequence[tuple[tuple[float, float, float], tuple[float, float, float]]] = (),
) -> tuple[list[tuple[float, float, float]], list[tuple[float, float, float]]]:
"""Split ``points`` into ``(free, connection)``. A point is "connection"
when (a) at least one other point in the list lies within
``CONNECTION_EPS_SQ`` (corner / end-to-end joins), or (b) it lies within
``CONNECTION_EPS_SQ`` of the interior of any segment in ``lines``
(T-junctions / ATPATH joins, where one wall's end lands on another
wall's axis interior rather than its endpoint). Connection points
dedupe to one representative each so coincident dots don't stack the
same color."""
eps_sq = cls.CONNECTION_EPS_SQ
n = len(points)
shared = [False] * n
for i in range(n):
xi, yi, zi = points[i]
for j in range(i + 1, n):
xj, yj, zj = points[j]
dx, dy, dz = xi - xj, yi - yj, zi - zj
if dx * dx + dy * dy + dz * dz <= eps_sq:
shared[i] = True
shared[j] = True
for i, point in enumerate(points):
if shared[i]:
continue
if cls._point_touches_any_segment_interior(point, lines, eps_sq):
shared[i] = True
free: list[tuple[float, float, float]] = []
connection: list[tuple[float, float, float]] = []
seen_connection: list[tuple[float, float, float]] = []
for i, point in enumerate(points):
if not shared[i]:
free.append(point)
continue
for existing in seen_connection:
dx, dy, dz = point[0] - existing[0], point[1] - existing[1], point[2] - existing[2]
if dx * dx + dy * dy + dz * dz <= eps_sq:
break
else:
seen_connection.append(point)
connection.append(point)
return free, connection
@staticmethod
def _point_touches_any_segment_interior(
point: tuple[float, float, float],
lines: Sequence[tuple[tuple[float, float, float], tuple[float, float, float]]],
eps_sq: float,
) -> bool:
"""True iff ``point`` lies within ``sqrt(eps_sq)`` of the interior of
any segment in ``lines``. Endpoints are excluded so a point cannot
match its own owning segment via either of that segment's tips — the
endpoint-coincidence pass already handles those cases. The qualifying
projection must land strictly inside the segment (``0 < t < 1``) AND
sit further than ``eps`` from either tip, catching ATPATH/T-junction
joins without false-flagging walls that share a corner."""
px, py, pz = point
for (ax, ay, az), (bx, by, bz) in lines:
dxa, dya, dza = px - ax, py - ay, pz - az
if dxa * dxa + dya * dya + dza * dza <= eps_sq:
continue
dxb, dyb, dzb = px - bx, py - by, pz - bz
if dxb * dxb + dyb * dyb + dzb * dzb <= eps_sq:
continue
ex, ey, ez = bx - ax, by - ay, bz - az
seg_len_sq = ex * ex + ey * ey + ez * ez
if seg_len_sq <= eps_sq:
continue
t = (dxa * ex + dya * ey + dza * ez) / seg_len_sq
if t <= 0.0 or t >= 1.0:
continue
qx, qy, qz = ax + t * ex, ay + t * ey, az + t * ez
dx, dy, dz = px - qx, py - qy, pz - qz
if dx * dx + dy * dy + dz * dz <= eps_sq:
return True
return False
def draw(self, context: bpy.types.Context) -> None:
model_props = tool.Model.get_model_props()
if not getattr(model_props, "show_paths", False):
return
ifc_file = tool.Ifc.get()
if ifc_file is None:
return
start_element = None
active = context.active_object
if active is not None:
element = tool.Ifc.get_entity(active)
if element is not None and self._is_seed_element(element):
start_element = element
if start_element is None:
for obj in context.selected_objects or []:
if obj is active:
continue
element = tool.Ifc.get_entity(obj)
if element is None or not self._is_seed_element(element):
continue
start_element = element
break
if start_element is None:
self._cached_start_guid = None
self._cached_walk = []
return
start_guid = start_element.GlobalId
if start_guid == self._failed_seed_guid:
return
current_geom_gen = tool.Parametric.get_geom_generation()
connected: list[Any] | None = None
if (
start_guid == self._cached_start_guid
and ifc_file is self._cached_ifc_file
and current_geom_gen == self._cached_geom_gen
and self._cached_walk_ids
):
try:
connected = [ifc_file.by_id(eid) for eid in self._cached_walk_ids]
except RuntimeError:
# An entity was removed without bumping geom_gen — rare but
# possible from non-operator code paths. Force a re-walk
# rather than feeding a stale handle to _build_geometry.
connected = None
if connected is None:
try:
connected = self._walk(start_element)
except Exception:
if not self._walk_failure_logged:
import traceback
traceback.print_exc()
self._walk_failure_logged = True
self._cached_walk_ids = []
self._failed_seed_guid = start_guid
return
self._cached_start_guid = start_guid
self._cached_ifc_file = ifc_file
self._cached_geom_gen = current_geom_gen
self._cached_walk_ids = [e.id() for e in connected]
if not connected:
return
prefs = tool.Blender.get_addon_preferences()
line_color = tuple(prefs.decorator_color_selected[:3])
# Junction dots get the "selected" palette slot (green by default) so
# they read as the currently-inspected network's spine; free endpoints
# get the "special" slot (blue by default) so dangling line ends stand
# apart from junctions at a glance.
connection_color = line_color
free_color = tuple(prefs.decorator_color_special[:3])
try:
lines, free_points, connection_points = self._geom_cache.get_or_compute(
(start_guid, id(ifc_file), current_geom_gen),
lambda: self._build_geometry(connected),
)
except Exception:
if not self._build_failure_logged:
import traceback
traceback.print_exc()
self._build_failure_logged = True
self._failed_seed_guid = start_guid
return
if lines:
_stroke_lines_alpha(context, lines, line_color, self.LINE_WIDTH, self.LINE_ALPHA)
if free_points or connection_points:
# POINTS via UNIFORM_COLOR; point_size_set only affects the next batch.
point_shader = gpu.shader.from_builtin("UNIFORM_COLOR")
point_shader.bind()
gpu.state.point_size_set(self.DOT_SIZE)
gpu.state.blend_set("ALPHA")
if free_points:
point_shader.uniform_float("color", (*free_color, self.LINE_ALPHA))
batch = batch_for_shader(point_shader, "POINTS", {"pos": free_points})
batch.draw(point_shader)
if connection_points:
point_shader.uniform_float("color", (*connection_color, self.LINE_ALPHA))
batch = batch_for_shader(point_shader, "POINTS", {"pos": connection_points})
batch.draw(point_shader)
gpu.state.blend_set("NONE")
class MEPSystemPathDecorator(_ConnectedNetworkPathDecorator):
"""Schematic-path overlay for the selected MEP element's connected
distribution system.
Walk: BFS through ``IfcRelConnectsPorts`` from the first selected MEP
element. Segments render as one axis line + endpoint dots. Fittings
render as:
- 2-port (transition, coupler, bend): one line port-to-port, keeping
the schematic continuous through the fitting. The "spider from
origin" pattern produces V-shaped flares when the fitting's local
origin is offset from its ports.
- 3+-port (tee, cross, branching): spider from origin to each port.
Drawing all N*(N-1)/2 port pairs would clutter the view at high N
(N=4 6 lines); the spider gives one line per port.
- 0-port / 1-port: degenerate, no lines (dots still emit)."""
def _is_seed_element(self, element: Any) -> bool:
return tool.System.is_mep_element(element)
def _walk(self, start_element: Any) -> list[Any]:
return tool.System.walk_connected_mep_elements(start_element)
def _build_geometry(
self,
connected: list[Any],
) -> tuple[
list[tuple[tuple[float, float, float], tuple[float, float, float]]],
list[tuple[float, float, float]],
list[tuple[float, float, float]],
]:
lines: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = []
port_positions: list[tuple[float, float, float]] = []
for element in connected:
if element and element.is_a("IfcFlowSegment"):
if not tool.Geometry.has_axis_representation(element):
continue
obj = tool.Ifc.get_object(element)
if obj is None:
continue
start_world, end_world = tool.Model.get_flow_segment_axis(obj)
lines.append((tuple(start_world), tuple(end_world)))
# Segment ports sit at the two axis endpoints — emit dots so
# the connection node is visible whether the neighbour is a
# fitting (also emits) or another segment (doesn't).
port_positions.append(tuple(start_world))
port_positions.append(tuple(end_world))
elif element.is_a("IfcFlowFitting"):
obj = tool.Ifc.get_object(element)
if obj is None:
continue
ports = tool.System.get_ports(element)
port_world_positions = [tool.System.get_port_world_position(p) for p in ports]
if len(port_world_positions) == 2:
lines.append((tuple(port_world_positions[0]), tuple(port_world_positions[1])))
elif len(port_world_positions) >= 3:
origin = obj.matrix_world.translation
for port_pos in port_world_positions:
lines.append((tuple(origin), tuple(port_pos)))
for port_pos in port_world_positions:
port_positions.append(tuple(port_pos))
free_points, connection_points = self._partition_points_by_coincidence(port_positions)
return lines, free_points, connection_points
class WallSystemPathDecorator(_ConnectedNetworkPathDecorator):
"""Schematic-path overlay for the selected wall's connected wall network.
Walk: BFS through ``IfcRelConnectsPathElements`` from the first selected
wall. Each wall renders as one reference-line segment + a dot at each
axis endpoint. Endpoints are classified by IFC topology every wall in
the walked set inspects its ``IfcRelConnectsPathElements`` rels filtered
to walls in the same set, and uses ``Relating*``/``Related*ConnectionType``
(ATSTART / ATEND / ATPATH) to decide which endpoint participates. ATPATH
rels also emit a connection dot at the canonical join location (a T-meets
point sits on the through-wall's interior, not at any endpoint). The
framework's geometric classifier is bypassed for walls because authoring
tolerance and post-edit float drift commonly exceed the 0.1 mm coincidence
threshold, so T-junctions otherwise fell into the free bucket."""
def _is_seed_element(self, element: Any) -> bool:
return element.is_a("IfcWall") and tool.Geometry.has_axis_representation(element)
def _walk(self, start_element: Any) -> list[Any]:
return tool.Wall.walk_connected_walls(start_element)
def _build_geometry(
self,
connected: list[Any],
) -> tuple[
list[tuple[tuple[float, float, float], tuple[float, float, float]]],
list[tuple[float, float, float]],
list[tuple[float, float, float]],
]:
lines: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = []
refs: dict[int, tuple[tuple[float, float, float], tuple[float, float, float]]] = {}
for element in connected:
obj = tool.Ifc.get_object(element)
if obj is None:
continue
ref = tool.Wall.get_world_reference_line(obj)
if ref is None:
continue
p1, p2 = tuple(ref[0]), tuple(ref[1])
refs[element.id()] = (p1, p2)
lines.append((p1, p2))
free_points, connection_points = self._classify_endpoints_from_rels(connected, refs)
connection_points = self._dedupe_close_points(connection_points, self.CONNECTION_EPS_SQ)
return lines, free_points, connection_points
@staticmethod
def _classify_endpoints_from_rels(
connected: Sequence[Any],
refs: dict[int, tuple[tuple[float, float, float], tuple[float, float, float]]],
) -> tuple[list[tuple[float, float, float]], list[tuple[float, float, float]]]:
"""For each wall in ``connected`` with a reference line in ``refs``,
classify its endpoints by walking its ``IfcRelConnectsPathElements``
rels filtered to walls also in ``refs``. ATSTART side present
reference-line start is a connection; ATEND side present reference-
line end is a connection; otherwise free. ATPATH side present emit
an extra connection dot at the canonical join via
``tool.Wall.path_connection_location_world``. Returns
``(free, connection)`` un-deduped."""
free_points: list[tuple[float, float, float]] = []
connection_points: list[tuple[float, float, float]] = []
for element in connected:
self_seg = refs.get(element.id())
if self_seg is None:
continue
sides: set[str] = set()
atpath_dots: list[tuple[float, float, float]] = []
for rel in getattr(element, "ConnectedTo", []) or ():
if not rel.is_a("IfcRelConnectsPathElements"):
continue
other = rel.RelatedElement
other_seg = refs.get(other.id()) if other is not None else None
if other_seg is None:
continue
self_type = rel.RelatingConnectionType
other_type = rel.RelatedConnectionType
sides.add(self_type)
if self_type == "ATPATH":
join = tool.Wall.path_connection_location_world(self_seg, self_type, other_seg, other_type)
atpath_dots.append(tuple(join))
for rel in getattr(element, "ConnectedFrom", []) or ():
if not rel.is_a("IfcRelConnectsPathElements"):
continue
other = rel.RelatingElement
other_seg = refs.get(other.id()) if other is not None else None
if other_seg is None:
continue
self_type = rel.RelatedConnectionType
other_type = rel.RelatingConnectionType
sides.add(self_type)
if self_type == "ATPATH":
join = tool.Wall.path_connection_location_world(self_seg, self_type, other_seg, other_type)
atpath_dots.append(tuple(join))
p1, p2 = self_seg
(connection_points if "ATSTART" in sides else free_points).append(p1)
(connection_points if "ATEND" in sides else free_points).append(p2)
connection_points.extend(atpath_dots)
return free_points, connection_points
@staticmethod
def _dedupe_close_points(
points: Sequence[tuple[float, float, float]],
eps_sq: float,
) -> list[tuple[float, float, float]]:
"""Drop later occurrences of points within ``sqrt(eps_sq)`` of an
earlier one. Used to collapse overlapping connection dots so an ATPATH
join computed at the same point as a neighbour's wall endpoint
renders once."""
result: list[tuple[float, float, float]] = []
for point in points:
for existing in result:
dx, dy, dz = point[0] - existing[0], point[1] - existing[1], point[2] - existing[2]
if dx * dx + dy * dy + dz * dz <= eps_sq:
break
else:
result.append(point)
return result
@@ -33,6 +33,7 @@ from mathutils import Vector
import bonsai.tool as tool
from bonsai.bim.module.drawing import gizmos as gizmo
from bonsai.bim.module.model.opening import is_filling_supported
from bonsai.bim.module.model.wall import (
_get_wall_geom_cached,
_wall_camera_facing_icon_y,
@@ -52,6 +53,20 @@ def is_supported_host(element) -> bool:
return tool.Parametric.is_path_connectable_wall(element) or element.is_a("IfcSlab") or element.is_a("IfcRoof")
def is_supported_filling_or_opening(element) -> bool:
"""Total predicate for the add-opening gizmo poll. ``None`` (raw Blender
mesh) is accepted because the operator converts unclassified meshes
into ``IfcOpeningElement`` instances. ``IfcOpeningElement`` is accepted
because reassigning an existing opening to a new host is a legal path
through the operator. Otherwise defer to the generator's own
supported-filling predicate."""
if element is None:
return True
if element.is_a("IfcOpeningElement"):
return True
return is_filling_supported(element)
def _resolve_active_host(context: bpy.types.Context, n_selected: int):
"""Shared poll prologue: gizmo gate + selection cardinality + active-in-
selected + IFC entity lookup + supported-host predicate. Returns the
@@ -72,12 +87,14 @@ def _resolve_active_host(context: bpy.types.Context, n_selected: int):
class GizmoHostAddOpening(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin):
"""Activates when a host element (wall / slab / roof) is the active object
and exactly one other selected object is *not* itself a host.
"""Activates when exactly two objects are selected and one is a fillable
host (wall / slab / roof) while the other is a valid filling (door /
window / existing opening, or a plain Blender mesh).
Renders a single ``VIEW3D_GT_add_opening`` icon at the void object's
projected location on the host. A click dispatches ``bim.add_opening``,
which handles any element exposing the ``HasOpenings`` inverse.
Selection-order independent: the host role is identified by class, not
by active state. The "+" icon anchors on the host's surface regardless
of which object was clicked first. The dispatched ``bim.add_opening``
operator also handles either order.
Per-frame positioning keeps the icon facing the camera as the viewport
orbits."""
@@ -90,22 +107,29 @@ class GizmoHostAddOpening(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
element = _resolve_active_host(context, n_selected=2)
if element is None:
if not _wall_gizmo_poll_gate(context):
return False
# The operator itself filters on HasOpenings, but checking here keeps
# the icon from appearing on host classes that can't accept openings
# in the active IFC schema.
if not hasattr(element, "HasOpenings"):
selected = list(tool.Blender.get_selected_objects())
if len(selected) != 2:
return False
active = context.active_object
other = next(o for o in tool.Blender.get_selected_objects() if o is not active)
# Host + host pairings are claimed by host-specific gizmos (wall-join,
# extend-vertical, …) — suppress here so the add-opening icon never
# stacks on top of them.
if is_supported_host(tool.Ifc.get_entity(other)):
if active is None or active not in selected:
return False
return True
a_element = tool.Ifc.get_entity(selected[0])
b_element = tool.Ifc.get_entity(selected[1])
return cls._is_apply_opening_pair(a_element, b_element) or cls._is_apply_opening_pair(b_element, a_element)
@staticmethod
def _is_apply_opening_pair(host_element, filling_element) -> bool:
"""``host_element`` qualifies as a fillable host AND ``filling_element``
qualifies as a filling. Used twice with the operands swapped so the
gizmo polls true regardless of which of the two selected objects is
active."""
if not is_supported_host(host_element):
return False
if not hasattr(host_element, "HasOpenings"):
return False
return is_supported_filling_or_opening(filling_element)
def setup(self, context: bpy.types.Context) -> None:
default_color, highlight_color = self.get_decoration_colors()
@@ -114,18 +138,20 @@ class GizmoHostAddOpening(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin
)
def position_gizmos(self, context: bpy.types.Context) -> None:
host_obj = context.active_object
if not host_obj:
selected = list(tool.Blender.get_selected_objects())
if len(selected) != 2:
return
selected = tool.Blender.get_selected_objects()
other = next((o for o in selected if o is not host_obj), None)
if not other:
return
element = tool.Ifc.get_entity(host_obj)
if not element:
a, b = selected[0], selected[1]
a_element = tool.Ifc.get_entity(a)
b_element = tool.Ifc.get_entity(b)
if is_supported_host(a_element):
host_obj, host_element, other = a, a_element, b
elif is_supported_host(b_element):
host_obj, host_element, other = b, b_element, a
else:
return
if tool.Parametric.is_path_connectable_wall(element):
if tool.Parametric.is_path_connectable_wall(host_element):
world_pos = wall_anchor(context, self, host_obj, other)
else:
world_pos = layer3_anchor(host_obj, other)
+81 -128
View File
@@ -693,27 +693,6 @@ def get_connected_element_at_segment_port(segment, at_segment_start):
return tool.System.get_port_relating_element(connected_port)
def find_fitting_between_segments(segment_a, segment_b):
"""Single IfcFlowFitting bridging segment_a and segment_b via ports, or
``None`` if no fitting (or multiple fittings only direct one-fitting
joins handled)."""
if not (segment_a.is_a("IfcFlowSegment") and segment_b.is_a("IfcFlowSegment")):
return None
b_ports_set = set(tool.System.get_ports(segment_b))
for a_port in tool.System.get_ports(segment_a):
connected_port = tool.System.get_connected_port(a_port)
if connected_port is None:
continue
fitting = tool.System.get_port_relating_element(connected_port)
if fitting is None or not fitting.is_a("IfcFlowFitting"):
continue
for fitting_port in tool.System.get_ports(fitting):
other_port = tool.System.get_connected_port(fitting_port)
if other_port is not None and other_port in b_ports_set:
return fitting
return None
def _resolve_active_mep_segment(operator, context):
"""Return the operator's target ``IfcFlowSegment`` or ``None`` after reporting.
@@ -808,52 +787,6 @@ class MEPAddObstruction(bpy.types.Operator, tool.Ifc.Operator):
return {"FINISHED"}
class MEPUnjoinAtPort(bpy.types.Operator, tool.Ifc.Operator):
"""Delete the IfcFlowFitting that bridges a segment's port to a second element.
Used when the connection at the port is in the JOINED state (the fitting
has at least one other port connecting to a different element). The
segment isn't resized — only the bridging fitting is removed. Refuses
to act on an OBSTRUCTION fitting (those are routed through
``bim.mep_add_obstruction`` with mode=REMOVE which extends the segment
to absorb the freed length)."""
bl_idname = "bim.mep_unjoin_at_port"
bl_label = "Unjoin MEP Segment at Port"
bl_description = "Disconnect the segment from the fitting at the named port (deletes the fitting)"
bl_options = {"REGISTER", "UNDO"}
segment_id: bpy.props.IntProperty(name="Segment Element ID", default=0)
position: bpy.props.EnumProperty(
name="Port",
items=[
("START", "At Start", "Operate on the segment's start port"),
("END", "At End", "Operate on the segment's end port"),
],
default="END",
)
def _execute(self, context):
resolved = _require_port_state(self, context, PORT_JOINED, "joining")
if resolved is None:
return {"CANCELLED"}
element, at_segment_start = resolved
fitting = get_connected_element_at_segment_port(element, at_segment_start)
if fitting is None or not fitting.is_a("IfcFlowFitting"):
self.report({"ERROR"}, "Connected port does not lead to a fitting.")
return {"CANCELLED"}
if getattr(fitting, "PredefinedType", None) == "OBSTRUCTION":
self.report({"ERROR"}, "Obstruction fittings are removed via bim.mep_add_obstruction (mode=REMOVE).")
return {"CANCELLED"}
fitting_obj = tool.Ifc.get_object(fitting)
if fitting_obj is None:
self.report({"ERROR"}, "Fitting has no Blender object.")
return {"CANCELLED"}
tool.Geometry.delete_ifc_object(fitting_obj)
return {"FINISHED"}
class MEPRemoveTerminalFitting(bpy.types.Operator, tool.Ifc.Operator):
"""Remove the terminal fitting at a segment's named port.
@@ -906,44 +839,6 @@ class MEPRemoveTerminalFitting(bpy.types.Operator, tool.Ifc.Operator):
return {"FINISHED"}
class MEPUnjoinPair(bpy.types.Operator, tool.Ifc.Operator):
"""Delete the IfcFlowFitting joining two selected MEP segments.
Removes the fitting; segments are left in place for the user to reposition."""
bl_idname = "bim.mep_unjoin_pair"
bl_label = "Unjoin MEP Segments"
bl_description = "Delete the fitting joining the two selected MEP segments"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if not _n_mep_selected(2):
cls.poll_message_set("Select exactly 2 MEP segments joined by a fitting.")
return False
return True
def _execute(self, context):
selected_objs = tool.Blender.get_selected_objects()
elements = [tool.Ifc.get_entity(o) for o in selected_objs]
if any(e is None or not e.is_a("IfcFlowSegment") for e in elements):
self.report({"ERROR"}, "Both selected objects must be MEP segments.")
return {"CANCELLED"}
fitting = find_fitting_between_segments(elements[0], elements[1])
if fitting is None:
self.report({"ERROR"}, "No single fitting joins the selected segments.")
return {"CANCELLED"}
if getattr(fitting, "PredefinedType", None) == "OBSTRUCTION":
self.report({"ERROR"}, "Obstruction fittings are removed via bim.mep_add_obstruction (mode=REMOVE).")
return {"CANCELLED"}
fitting_obj = tool.Ifc.get_object(fitting)
if fitting_obj is None:
self.report({"ERROR"}, "Fitting has no Blender object.")
return {"CANCELLED"}
tool.Geometry.delete_ifc_object(fitting_obj)
return {"FINISHED"}
class SelectMEPPathMembers(bpy.types.Operator):
"""Replace the selection with every MEP element reachable from the active
one via IfcRelConnectsPorts the entire connected distribution network."""
@@ -2677,10 +2572,22 @@ def _active_mep_has_connected_neighbor(obj: bpy.types.Object) -> bool:
def _active_is_bend_fitting(obj: bpy.types.Object) -> bool:
"""True iff the active object is a parametric BEND fitting eligible for
the bend-preview re-edit path. Re-edit reads parameters from the type's
``BBIM_Fitting`` pset, so that pset's presence is the ground truth for
re-editability not the body representation class. The bend creation
path tessellates the swept-disk body as an upstream-geometry-kernel
workaround, so a freshly-committed bend's body contains only an
``IfcTriangulatedFaceSet`` and ``has_parametric_body`` correctly
returns False for it; the pset gate is what keeps the pen icon
eligible."""
element = tool.Ifc.get_entity(obj)
if not _is_bend_fitting(element):
return False
return tool.System.has_parametric_body(element)
element_type = ifcopenshell.util.element.get_type(element)
if element_type is None:
return False
return ifcopenshell.util.element.get_pset(element_type, "BBIM_Fitting") is not None
class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
@@ -2763,20 +2670,20 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
),
IconActionConfig(
name="unjoin_start",
icon="VIEW3D_GT_unjoin",
operator="bim.mep_unjoin_at_port",
icon="VIEW3D_GT_wall_link_toggle",
operator="bim.disconnect_elements",
visibility_condition=lambda obj: _selection_size() == 1 and _active_is_flow_segment(obj),
),
IconActionConfig(
name="unjoin_end",
icon="VIEW3D_GT_unjoin",
operator="bim.mep_unjoin_at_port",
icon="VIEW3D_GT_wall_link_toggle",
operator="bim.disconnect_elements",
visibility_condition=lambda obj: _selection_size() == 1 and _active_is_flow_segment(obj),
),
IconActionConfig(
name="unjoin_pair",
icon="VIEW3D_GT_unjoin",
operator="bim.mep_unjoin_pair",
icon="VIEW3D_GT_wall_link_toggle",
operator="bim.disconnect_elements",
visibility_condition=lambda _active: _n_mep_selected(2),
),
]
@@ -2794,7 +2701,17 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
element = tool.Ifc.get_entity(obj)
if element is None or not tool.System.is_mep_element(element):
return False
return tool.System.has_parametric_body(element)
if tool.System.has_parametric_body(element):
return True
# Bend fittings carry their parametric definition in the type's
# ``BBIM_Fitting`` pset because the bend creation path tessellates
# the swept-disk body (upstream geometry-kernel workaround), so
# ``has_parametric_body`` returns False for them. Fall back to the
# pset gate so the pen icon (re_edit_bend) stays reachable.
element_type = ifcopenshell.util.element.get_type(element)
if element_type is None:
return False
return ifcopenshell.util.element.get_pset(element_type, "BBIM_Fitting") is not None
def setup(self, context: bpy.types.Context) -> None:
super().setup(context)
@@ -2802,11 +2719,13 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
@classmethod
def _wire_anchored_icon_targets(cls, group) -> None:
"""Pre-fill ``position`` (and ``mode`` for open-lock) on each anchored
icon so a click dispatches to the right port without a per-frame
property write; apply the warning-red hover colour to destructive
icons. Takes any object with ``action_<name>_gizmo`` attributes so
tests can exercise the wiring without instantiating the GizmoGroup."""
"""Pre-fill ``position`` (and ``mode`` for open-lock) on the lock
icons so a click dispatches to the right port without a per-frame
property write, and pre-bind the unified ``bim.disconnect_elements``
operator on each unjoin icon so :py:meth:`position_gizmos` only has
to update the two GUIDs per frame. Takes any object with
``action_<name>_gizmo`` attributes so tests can exercise the wiring
without instantiating the GizmoGroup."""
for config_name, (_icon, position_arg) in cls.LOCK_ICON_CONFIGS.items():
gz = getattr(group, f"action_{config_name}_gizmo", None)
if gz is None:
@@ -2820,19 +2739,12 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
op_props = gz.target_set_operator("bim.mep_remove_terminal_fitting")
op_props.position = position_arg
for config_name, position_arg in (("unjoin_start", "START"), ("unjoin_end", "END")):
gz = getattr(group, f"action_{config_name}_gizmo", None)
if gz is None:
continue
op_props = gz.target_set_operator("bim.mep_unjoin_at_port")
op_props.position = position_arg
warning_color = gizmo.get_warning_color_from_prefs(tool.Blender.get_addon_preferences())
group.unjoin_op_props = {}
for config_name in cls.UNJOIN_CONFIGS:
gz = getattr(group, f"action_{config_name}_gizmo", None)
if gz is None:
continue
gz.color_highlight = warning_color
group.unjoin_op_props[config_name] = gz.target_set_operator("bim.disconnect_elements")
def position_gizmos(self, context: bpy.types.Context) -> None:
"""Lay out icons across three regions: row above bbox top, segment
@@ -2899,6 +2811,10 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
if not visible:
gz.hide = True
continue
if config.name.startswith("unjoin_"):
if not self._bind_unjoin_at_port(config.name, obj, endpoint_kind == "START"):
gz.hide = True
continue
if segment_endpoints is None:
segment_endpoints = tool.Model.get_flow_segment_axis(obj)
start_world, end_world = segment_endpoints
@@ -2910,7 +2826,7 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
if len(selected) == 2:
elements = [tool.Ifc.get_entity(o) for o in selected]
if all(e is not None and e.is_a("IfcFlowSegment") for e in elements):
pair_fitting = find_fitting_between_segments(elements[0], elements[1]) or False
pair_fitting = tool.System.find_bridging_fitting(elements[0], elements[1]) or False
else:
pair_fitting = False
else:
@@ -2922,6 +2838,13 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
gz.hide = True
continue
if config.name == "unjoin_pair":
selected = tool.Blender.get_selected_objects()
pair_elements = [tool.Ifc.get_entity(o) for o in selected]
if not self._bind_unjoin_pair(pair_elements):
gz.hide = True
continue
if not bend_anchor_attempted:
bend_anchor = compute_mep_join_location()
bend_anchor_attempted = True
@@ -2950,3 +2873,33 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
if name in self.ENDPOINT_CONFIGS:
return self.ICON_SCALE * self.ENDPOINT_SCALE_RATIO
return self.ICON_SCALE
def _bind_unjoin_at_port(self, config_name: str, segment_obj: bpy.types.Object, at_segment_start: bool) -> bool:
"""Resolve the fitting at the named port and bind both GUIDs on the
pre-wired ``bim.disconnect_elements`` op_props. Returns False when
the partner is unresolvable (port not joined to a disconnectable
fitting), and the caller hides the icon."""
element = tool.Ifc.get_entity(segment_obj)
if element is None:
return False
fitting = get_connected_element_at_segment_port(element, at_segment_start)
if fitting is None or not fitting.is_a("IfcFlowFitting"):
return False
if getattr(fitting, "PredefinedType", None) == "OBSTRUCTION":
return False
op_props = self.unjoin_op_props[config_name]
op_props.element_a_guid = element.GlobalId
op_props.element_b_guid = fitting.GlobalId
return True
def _bind_unjoin_pair(self, pair_elements: list[ifcopenshell.entity_instance | None]) -> bool:
"""Bind both segment GUIDs on the pair-disconnect icon's pre-wired
``bim.disconnect_elements`` op_props. Returns False when either side
is missing a GlobalId (e.g. selection lost an active object), and
the caller hides the icon."""
if len(pair_elements) != 2 or any(e is None for e in pair_elements):
return False
op_props = self.unjoin_op_props["unjoin_pair"]
op_props.element_a_guid = pair_elements[0].GlobalId
op_props.element_b_guid = pair_elements[1].GlobalId
return True
+67 -42
View File
@@ -240,6 +240,15 @@ def _store_batch_in_cache(cache_key: tuple[int, str], batch: "gpu.types.GPUBatch
_batch_cache[cache_key] = (epoch, batch)
def is_filling_supported(element) -> bool:
"""True when Bonsai's opening generator can derive an opening from this
element. IFC's schema permits any IfcElement as a filling; Bonsai
currently supports only IfcDoor and IfcWindow because those are the
classes with OverallWidth/OverallHeight attributes (or their types'
ELEVATION_VIEW profiles) that the generator can consume."""
return element is not None and element.is_a() in ("IfcDoor", "IfcWindow")
class FilledOpeningGenerator:
def generate(
self,
@@ -409,18 +418,16 @@ class FilledOpeningGenerator:
representation = tool.Geometry.get_representation_by_context(voided_element, context)
assert representation
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=voided_obj,
representation=representation,
)
tool.Geometry.recut_host(voided_obj, representation)
def regenerate_from_type(self, usecase_path: str, ifc_file: ifcopenshell.file, settings: dict[str, Any]) -> None:
relating_type = settings["relating_type"]
for related_object in settings["related_objects"]:
self._regenerate_from_type(related_object)
# Filling type-switch on an array of fillings fans out N host recuts —
# one per related object — without batching. Coalesce them.
with tool.Geometry.batch_host_recut():
for related_object in settings["related_objects"]:
self._regenerate_from_type(related_object)
def _regenerate_from_type(self, related_object: ifcopenshell.entity_instance) -> None:
filling = related_object
@@ -469,12 +476,7 @@ class FilledOpeningGenerator:
representation = tool.Geometry.get_active_representation(voided_obj)
if not representation:
continue
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=voided_obj,
representation=representation,
)
tool.Geometry.recut_host(voided_obj, representation)
def generate_opening_from_filling(
self,
@@ -609,6 +611,31 @@ class RecalculateFill(bpy.types.Operator, tool.Ifc.Operator):
return context.selected_objects
def _execute(self, context):
# N selected fillings × M voided host parts would fire N×M host recuts
# without batching. Coalesce per host.
with tool.Geometry.batch_host_recut():
return self._recalculate_fills(context)
def _recalculate_fills(self, context):
# Refresh each selected filling's mapped opening source before
# recutting the host. Dedup by source id covers the common shared-
# source case in one rewrite while leaving unrelated sibling sources
# untouched.
seen_source_ids: set[int] = set()
for obj in context.selected_objects:
element = tool.Ifc.get_entity(obj)
if not element or not element.FillsVoids:
continue
opening = element.FillsVoids[0].RelatingOpeningElement
body = tool.Geometry.get_body_representation(opening)
if body is None:
continue
source = tool.Geometry.resolve_mapped_representation(body)
if source.id() in seen_source_ids:
continue
seen_source_ids.add(source.id())
tool.Model.regenerate_filling_opening_body(element)
for obj in context.selected_objects:
element = tool.Ifc.get_entity(obj)
if not element or not element.FillsVoids:
@@ -637,12 +664,7 @@ class RecalculateFill(bpy.types.Operator, tool.Ifc.Operator):
if building_obj and building_obj.data:
representation = tool.Geometry.get_active_representation(building_obj)
if representation:
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=building_obj,
representation=representation,
)
tool.Geometry.recut_host(building_obj, representation)
# Refresh cut decorator
DecoratorData.cut_cache.clear()
@@ -964,27 +986,29 @@ class EditOpenings(Operator, tool.Ifc.Operator):
for opening_element in opening_elements:
opening_obj = tool.Ifc.get_object(opening_element)
similar_openings = bonsai.core.geometry.get_similar_openings(tool.Ifc, opening_element)
similar_openings_building_objs = bonsai.core.geometry.get_similar_openings_building_objs(
tool.Ifc, similar_openings
)
building_objs.update(similar_openings_building_objs)
if opening_obj:
if tool.Ifc.is_edited(opening_obj):
tool.Geometry.run_geometry_update_representation(obj=opening_obj)
bonsai.core.geometry.edit_similar_opening_placement(
tool.Geometry, opening_element, similar_openings
)
elif tool.Ifc.is_moved(opening_obj):
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=opening_obj)
opening_edited = tool.Ifc.is_edited(opening_obj)
opening_moved = tool.Ifc.is_moved(opening_obj)
# Sibling walls only need a viewport-level refresh when the
# opening's shape or placement actually changed — a pure
# show/hide toggle leaves them in their existing state.
if opening_edited or opening_moved:
similar_openings = bonsai.core.geometry.get_similar_openings(tool.Ifc, opening_element)
similar_openings_building_objs = bonsai.core.geometry.get_similar_openings_building_objs(
tool.Ifc, similar_openings
)
building_objs.update(similar_openings_building_objs)
if opening_edited:
tool.Geometry.run_geometry_update_representation(obj=opening_obj)
else:
bonsai.core.geometry.edit_object_placement(
tool.Ifc, tool.Geometry, tool.Surveyor, obj=opening_obj
)
bonsai.core.geometry.edit_similar_opening_placement(
tool.Geometry, opening_element, similar_openings
)
building_objs.update(self.get_all_building_objects_of_similar_openings(opening_element))
building_objs.update(
self.get_all_building_objects_of_similar_openings(opening_element)
) # NB this has nothing to do with clone similar_opening
tool.Ifc.unlink(element=opening_element)
if props.representation_obj == opening_obj:
props.representation_obj = None
@@ -1022,6 +1046,12 @@ class CloneOpening(Operator, tool.Ifc.Operator):
return True
def _execute(self, context):
# The voided host may be an aggregate whose parts each get recut.
# Coalesce per host so a many-parts aggregate doesn't fan out.
with tool.Geometry.batch_host_recut():
return self._clone_opening(context)
def _clone_opening(self, context):
# NOTE: Operator displayed in UI only with IfcOpeningElement being active.
ifc_file = tool.Ifc.get()
objects = bpy.context.selected_objects
@@ -1051,12 +1081,7 @@ class CloneOpening(Operator, tool.Ifc.Operator):
continue
representation = tool.Geometry.get_active_representation(obj)
assert representation
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=obj,
representation=representation,
)
tool.Geometry.recut_host(obj, representation)
return {"FINISHED"}
@@ -1002,6 +1002,14 @@ class EnableEditingExtrusionAxis(bpy.types.Operator, tool.Ifc.Operator):
def _execute(self, context):
self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
obj = context.active_object
# Commit any in-progress parametric (gizmo) draft on this object
# before switching to axis-edit. Otherwise the in-memory draft
# state is overwritten when the axis mesh is imported below,
# silently discarding the user's pending dimension edits.
if feature := tool.Parametric.is_object_editing(obj):
tool.Parametric.commit_object_draft(obj, feature.finish_op)
element = tool.Ifc.get_entity(obj)
axis = ifcopenshell.util.representation.get_representation(element, "Model", "Axis", "GRAPH_VIEW")
+45 -5
View File
@@ -33,8 +33,10 @@ from bonsai.bim.module.drawing.decoration import CutDecorator
from bonsai.bim.module.model.data import AuthoringData
from bonsai.bim.module.model.decorator import (
BoundingBoxDecorator,
MEPSystemPathDecorator,
SlabDirectionDecorator,
WallAxisDecorator,
WallSystemPathDecorator,
)
from bonsai.bim.module.model.door import update_door_modifier_bmesh
from bonsai.bim.module.model.window import update_window_modifier_bmesh
@@ -132,6 +134,19 @@ def update_slab_direction_decorator(self: "BIMModelProperties", context: bpy.typ
SlabDirectionDecorator.uninstall()
def update_paths_decorator(self: "BIMModelProperties", context: bpy.types.Context) -> None:
"""Unified toggle for connected-element path overlays. Drives both the
MEP and wall path decorators each decorator's ``draw`` short-circuits
when its kind of element isn't selected, so leaving both installed is
cheap and lets one toggle cover any connected-element family."""
if self.show_paths:
MEPSystemPathDecorator.install(bpy.context)
WallSystemPathDecorator.install(bpy.context)
else:
MEPSystemPathDecorator.uninstall()
WallSystemPathDecorator.uninstall()
def update_measure_xyz(self: "BIMModelProperties", context: bpy.types.Context) -> None:
if self.show_bounding_box:
BoundingBoxDecorator.install(context)
@@ -228,11 +243,7 @@ def update_wall_offset_baseline(self: "BIMWallProperties", context: bpy.types.Co
def update_railing(self: "BIMRailingProperties", context: bpy.types.Context) -> None:
"""Regenerate railing mesh when property changes."""
if self.is_editing:
# Only FRAMELESS_PANEL can update live via bmesh.
# WALL_MOUNTED_HANDRAIL geometry is generated from IFC representation,
# so it only updates on "Finish Editing" to avoid modifying IFC during preview.
if self.railing_type == "FRAMELESS_PANEL":
_get_updater("railing", "update_railing_modifier_bmesh")(context)
_get_updater("railing", "update_railing_modifier_bmesh")(context)
def update_roof(self: "BIMRoofProperties", context: bpy.types.Context) -> None:
@@ -358,6 +369,19 @@ class BIMModelProperties(PropertyGroup):
default=False,
update=update_slab_direction_decorator,
)
show_paths: bpy.props.BoolProperty(
name="Show Paths",
default=False,
update=update_paths_decorator,
description=(
"Trace the connected element path from the selected element. For "
"walls, follows IfcRelConnectsPathElements and draws each "
"connected wall's reference axis with endpoint dots. For MEP "
"elements, follows IfcRelConnectsPorts and draws each segment's "
"axis plus a port-to-port spider for each fitting. Toggle off to "
"skip the BFS traversal entirely."
),
)
prev_transform_orientation_slot_type: bpy.props.StringProperty(name="Previous Gizmo Orientation Type")
prev_show_gizmo_object_translate: bpy.props.BoolProperty(name="Previous Gizmo Translate")
@@ -405,6 +429,7 @@ class BIMModelProperties(PropertyGroup):
offset: float
show_wall_axis: bool
show_slab_direction: bool
show_paths: bool
prev_transform_orientation_slot_type: str
prev_show_gizmo_object_translate: bool
@@ -1693,6 +1718,21 @@ class BIMRoofProperties(PropertyGroup):
setattr(target_props, prop_name, prop_value)
class BIMSlabProperties(PropertyGroup):
"""Transient state for the slab disconnect-access gizmo.
``is_editing`` flips True when the user clicks the pen icon on a slab
that has wall connections gating the per-wall disconnect icons in
``GizmoSlabUnjoinWalls`` so they're hidden until the user opts in. No
IFC draft state lives here: the disconnect operator commits directly,
so this PropertyGroup carries only the UI gate."""
is_editing: bpy.props.BoolProperty(name="Slab Edit Active", default=False, options={"SKIP_SAVE"})
if TYPE_CHECKING:
is_editing: bool
class BIMWallProperties(PropertyGroup):
"""Transient draft state for parametric wall gizmo editing.
+648 -11
View File
@@ -18,6 +18,7 @@
import json
import math
from typing import Any
import bmesh
@@ -27,14 +28,24 @@ import ifcopenshell.api.geometry
import ifcopenshell.api.pset
import ifcopenshell.util.representation
import ifcopenshell.util.unit
from mathutils import Vector
from mathutils import Matrix, Vector
import bonsai.core.geometry
import bonsai.core.root
import bonsai.tool as tool
from bonsai.bim.module.drawing import gizmos as gizmo
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
from bonsai.bim.module.model import prop
from bonsai.bim.module.model.data import RailingData, refresh
from bonsai.bim.module.model.decorator import ProfileDecorator
from bonsai.bim.parametric_lifecycle import PathPreservingEditMixin
from bonsai.bim.parametric_lifecycle import (
CycleTypeMixin,
PathPreservingEditMixin,
PickTypeMixin,
)
from bonsai.tool.cad import WELD_TOLERANCE
V_ = tool.Blender.V_
# reference:
# https://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcRailing.htm
@@ -125,6 +136,56 @@ def update_bbim_railing_pset(element: ifcopenshell.entity_instance, railing_data
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": railing_data})
def generate_wall_mounted_handrail_preview(
obj: bpy.types.Object,
props: "BIMRailingProperties",
path_data: dict[str, Any],
si_conversion: float,
) -> None:
"""Viewport-only WALL_MOUNTED_HANDRAIL preview: rebuild ``obj.data`` from the same
geometry helper the IFC representation builder uses, without writing any IFC."""
railing_path = [Vector(v) * si_conversion for v in path_data["verts"]]
looped_path = path_data["edges"][-1][-1] == path_data["edges"][0][0]
geom = ifcopenshell.api.geometry.compute_wall_mounted_handrail_geometry(
railing_path=railing_path,
support_spacing=props.support_spacing,
railing_diameter=props.railing_diameter,
clear_width=props.clear_width,
height=props.height,
use_manual_supports=props.use_manual_supports,
terminal_type=props.terminal_type,
looped_path=looped_path,
unit_scale=1.0, # props are already SI; bypass the IFC project-units conversion
)
bm = tool.Blender.get_bmesh_for_mesh(obj.data, clean=True)
tool.Cad.sweep_disk_along_polyline(
bm,
[Vector(p) for p in geom.handrail_polyline],
geom.handrail_radius,
arc_indices=geom.handrail_arc_point_indices,
)
for support in geom.supports:
tool.Cad.sweep_disk_along_polyline(
bm,
[Vector(p) for p in support.arc_polyline],
support.arc_radius,
)
tool.Cad.add_disk_extrusion(
bm,
Vector(support.disk_position),
support.disk_radius,
support.disk_depth,
support.disk_z_rotation,
)
bmesh.ops.recalc_face_normals(bm, faces=bm.faces[:])
tool.Blender.apply_bmesh(obj.data, bm)
def update_railing_modifier_bmesh(context: bpy.types.Context) -> None:
"""before using should make sure that Data contains up-to-date information.
If BBIM Pset just changed should call refresh() before updating bmesh
@@ -140,6 +201,13 @@ def update_railing_modifier_bmesh(context: bpy.types.Context) -> None:
path_data = RailingData.data["path_data"]
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
# WALL_MOUNTED_HANDRAIL renders the preview from the compute helper; IFC stays
# untouched until Finish Editing rebuilds the representation.
if not props.is_editing_path and props.railing_type == "WALL_MOUNTED_HANDRAIL":
generate_wall_mounted_handrail_preview(obj, props, path_data, si_conversion)
return
# need to make sure we support edit mode
# since users will probably be in edit mode when they'll be changing railing path
bm = tool.Blender.get_bmesh_for_mesh(obj.data, clean=True)
@@ -165,8 +233,6 @@ def update_railing_modifier_bmesh(context: bpy.types.Context) -> None:
thickness = props.thickness
spacing = props.spacing
# spacing
# split each edge in 3 segments by 0.5 * spacing by x-y plane
main_edges = bm.edges[:]
for main_edge in main_edges:
bm_split_edge_at_offset(main_edge, spacing)
@@ -211,7 +277,7 @@ def update_railing_modifier_bmesh(context: bpy.types.Context) -> None:
bmesh.ops.dissolve_edges(bm, edges=edges_to_dissolve)
bmesh.ops.dissolve_verts(bm, verts=verts_to_dissolve)
# to remove unnecessary verts in 0 spacing case
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.0001)
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=WELD_TOLERANCE)
bmesh.ops.recalc_face_normals(bm, faces=bm.faces[:])
@@ -271,8 +337,8 @@ def get_path_data(obj: bpy.types.Object) -> dict[str, Any]:
segments.append((i - 1, 0))
break
# skip path verts if they just go vertical to avoid errors
if (v.co.xy - prev_v.co.xy).length <= 0.0001:
# Vertical-only segments project to a degenerate XY edge; skip to avoid divide-by-zero downstream.
if (v.co.xy - prev_v.co.xy).length <= WELD_TOLERANCE:
continue
points.append(v.co)
@@ -407,9 +473,8 @@ class CopyRailingParameters(bpy.types.Operator, tool.Ifc.Operator):
class _RailingEditMixin(PathPreservingEditMixin):
"""Type-specific hooks for railing parametric-edit operators. Single-object
(active_object). ``path_data`` is preserved through the edit; the separate
``Enable/Finish/CancelEditingRailingPath`` operators handle path editing."""
"""Single-object (active_object) railing-edit hooks; path_data is preserved
through the edit (path editing is a separate operator family)."""
pset_name = "BBIM_Railing"
@@ -436,7 +501,21 @@ class _RailingEditMixin(PathPreservingEditMixin):
update_railing_modifier_ifc_data(context)
@classmethod
def _update_modifier_bmesh(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
def _restore_viewport_after_cancel(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
"""WALL_MOUNTED_HANDRAIL reloads the committed Body; others rebuild the preview bmesh."""
props = tool.Model.get_railing_props(obj)
if props.railing_type == "WALL_MOUNTED_HANDRAIL":
element = tool.Ifc.get_entity(obj)
assert element
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
if body:
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=obj,
representation=body,
)
return
update_railing_modifier_bmesh(context)
@@ -467,6 +546,554 @@ class FinishEditingRailing(_RailingEditMixin, bpy.types.Operator, tool.Ifc.Opera
return self._finish_targets(context)
class CycleRailingType(bpy.types.Operator, tool.Ifc.Operator, CycleTypeMixin):
"""Cycle railing_type (FRAMELESS_PANEL ↔ WALL_MOUNTED_HANDRAIL). Shift+click reverses."""
bl_idname = "bim.cycle_railing_type"
bl_label = "Cycle Railing Type"
bl_options = {"REGISTER", "UNDO"}
element_checker = tool.Parametric.is_railing
props_getter = tool.Model.get_railing_props
type_literal = tool.Model.RailingType
type_attr = "railing_type"
def _execute(self, context: bpy.types.Context) -> set[str]:
return self._cycle_type(context)
class ToggleRailingUseManualSupports(bpy.types.Operator):
"""Flip use_manual_supports on the active WALL_MOUNTED_HANDRAIL railing.
No-op unless a parametric edit is active and the railing is wall-mounted.
"""
bl_idname = "bim.toggle_railing_use_manual_supports"
bl_label = "Toggle Railing Manual Supports"
bl_description = "Switch between automatic support spacing and manual per-vertex placement"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
resolved = tool.Model.resolve_active_props_for_edit(
context,
tool.Model.get_railing_props,
subtype=("railing_type", "WALL_MOUNTED_HANDRAIL"),
)
if resolved is None:
return {"CANCELLED"}
_obj, props = resolved
props.use_manual_supports = not props.use_manual_supports
return {"FINISHED"}
class PickRailingTerminalType(bpy.types.Operator, tool.Ifc.Operator, PickTypeMixin):
"""Pick ``terminal_type`` for the active WALL_MOUNTED_HANDRAIL railing."""
bl_idname = "bim.pick_railing_terminal_type"
bl_label = "Pick Railing Terminal Type"
bl_description = "Pick the cap geometry applied at the rail ends"
bl_options = {"REGISTER", "UNDO"}
skip_element_check = True
props_getter = tool.Model.get_railing_props
type_literal = prop.CapType
type_attr = "terminal_type"
def _execute(self, context: bpy.types.Context) -> set[str]:
if (
tool.Model.resolve_active_props_for_edit(
context,
tool.Model.get_railing_props,
subtype=("railing_type", "WALL_MOUNTED_HANDRAIL"),
)
is None
):
return {"CANCELLED"}
return self._pick_type(context)
def _format_attr_distance(attr_name: str):
"""text_formatter that renders the named property as a distance, ignoring the
dimension's visible-length argument (which is fixed for schematic gizmos)."""
return lambda p, _v: tool.Unit.format_distance(getattr(p, attr_name))
class GizmoRailingSchematic(bpy.types.GizmoGroup, gizmo.BaseSchematicGizmoGroup):
"""Schematic-frame parametric editor for railings. Mutually exclusive with path-edit mode."""
bl_idname = "OBJECT_GGT_bim_railing_edition"
bl_label = "Railing Editing Gizmo"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT"}
enable_editing_operator = "bim.enable_editing_railing"
finish_editing_operator = "bim.finish_editing_railing"
cancel_editing_operator = "bim.cancel_editing_railing"
cycle_type_operator = "bim.cycle_railing_type"
props_getter = tool.Model.get_railing_props
gizmo_pref_name = "railing"
# Schematic-local layout. +X → screen RIGHT, +Y → screen UP, +Z → toward viewer
# (post billboard rotation). Each dimension is anchored alongside the feature it
# measures so the label, not the bar length, carries the value.
SCHEMATIC_MESH_HEIGHT_FRAC = 0.9 # Mesh top edge in schematic-local +Y
SCHEMATIC_MESH_WIDTH_FRAC = 0.7 # Mesh side edges in schematic-local ±X
SCHEMATIC_MESH_RAIL_Y_FRAC = SCHEMATIC_MESH_HEIGHT_FRAC / 2 # WALL_MOUNTED_HANDRAIL rail centreline
SCHEMATIC_MESH_DEPTH_FRAC = 0.06 # Panel depth — small so the schematic reads as slabs not boxes
# WALL_MOUNTED_HANDRAIL dimensions — fractions of schematic_box_size so they
# scale with the host group's box size.
SCHEMATIC_RAIL_RADIUS_FRAC = 0.05
SCHEMATIC_RAIL_CLEAR_FRAC = 0.5 # Stylised — wider than real-world for visible bracket arm
SCHEMATIC_RAIL_INSET_FRAC = 0.08 # Wall extends past the outermost support on both sides
@classmethod
def schematic_rail_radius(cls) -> float:
return cls.schematic_box_size * cls.SCHEMATIC_RAIL_RADIUS_FRAC
@classmethod
def schematic_rail_clear(cls) -> float:
return cls.schematic_box_size * cls.SCHEMATIC_RAIL_CLEAR_FRAC
# Axonometric 3/4 view: +Z projects down-and-left so the depth axis
# is visibly separated from the back face. Without the X tilt, panel
# thickness (schematic-local Z) collapses to a near-horizontal bar.
schematic_view_rotation = Matrix.Rotation(math.radians(20), 4, "X") @ Matrix.Rotation(math.radians(-25), 4, "Y")
# Hover a dimension → highlight the schematic edges tagged with the matching feature.
# Tags are written by the mesh builders. "spacing" is empty space (no edges) so it's
# absent from this map and gracefully no-ops on hover.
schematic_attr_to_feature = {
"height": "panel_height",
"thickness": "panel_thickness",
"railing_diameter": "rail_tube",
"clear_width": "bracket",
"support_spacing": "bracket",
}
schematic_dimension_props = [
# ── FRAMELESS_PANEL ─────────────────────────────────────────────
DimensionGizmoConfig(
attr_name="height",
axis=(0, 1, 0),
min_value=0.01,
# Gated to FRAMELESS_PANEL: in WALL_MOUNTED_HANDRAIL, height only
# feeds TO_FLOOR / TO_END_POST_AND_FLOOR terminals so dragging it
# is a no-op under the default "180" terminal.
visibility_condition=lambda p: p.railing_type == "FRAMELESS_PANEL",
matrix_position=lambda p: Vector((-GizmoRailingSchematic.SCHEMATIC_MESH_WIDTH_FRAC / 2 - 0.08, 0.0, 0.0)),
schematic_visible_length=SCHEMATIC_MESH_HEIGHT_FRAC,
text_formatter=_format_attr_distance("height"),
),
DimensionGizmoConfig(
attr_name="thickness",
axis=(0, 0, 1), # panel depth — projects to a true depth direction under the 3/4 tilt
min_value=0.005,
visibility_condition=lambda p: p.railing_type == "FRAMELESS_PANEL",
matrix_position=lambda p: Vector(
(
(
-GizmoRailingSchematic.SCHEMATIC_MESH_WIDTH_FRAC / 2
- GizmoRailingSchematic.SCHEMATIC_MESH_GAP_HALF_WIDTH
)
/ 2,
GizmoRailingSchematic.SCHEMATIC_MESH_HEIGHT_FRAC + 0.05,
-GizmoRailingSchematic.SCHEMATIC_MESH_DEPTH_FRAC / 2,
)
),
schematic_visible_length=0.4, # longer than default to survive depth foreshortening
text_formatter=_format_attr_distance("thickness"),
),
DimensionGizmoConfig(
attr_name="spacing",
axis=(1, 0, 0),
min_value=0.0, # zero-spacing collapses the picket gap into a single continuous panel
visibility_condition=lambda p: p.railing_type == "FRAMELESS_PANEL",
matrix_position=lambda p: Vector((0.0, -0.1, 0.0)),
text_formatter=_format_attr_distance("spacing"),
),
# ── WALL_MOUNTED_HANDRAIL ──────────────────────────────────────
DimensionGizmoConfig(
attr_name="railing_diameter",
axis=(0, 1, 0),
min_value=0.001,
visibility_condition=lambda p: p.railing_type == "WALL_MOUNTED_HANDRAIL",
matrix_position=lambda p: Vector(
(
-GizmoRailingSchematic.SCHEMATIC_MESH_WIDTH_FRAC / 2 - 0.05,
GizmoRailingSchematic.SCHEMATIC_MESH_RAIL_Y_FRAC - 0.09,
GizmoRailingSchematic.schematic_rail_clear(),
)
),
text_formatter=_format_attr_distance("railing_diameter"),
),
DimensionGizmoConfig(
attr_name="clear_width",
axis=(0, 0, 1), # +Z is the wall-to-rail perpendicular axis under the 3/4 tilt
min_value=0.001,
visibility_condition=lambda p: p.railing_type == "WALL_MOUNTED_HANDRAIL",
matrix_position=lambda p: Vector(
(
0.0,
GizmoRailingSchematic.SCHEMATIC_MESH_RAIL_Y_FRAC,
0.0,
)
),
schematic_visible_length=0.36, # 2× default so the call-out survives depth projection
text_formatter=_format_attr_distance("clear_width"),
),
DimensionGizmoConfig(
attr_name="support_spacing",
axis=(1, 0, 0),
min_value=0.05,
visibility_condition=lambda p: (p.railing_type == "WALL_MOUNTED_HANDRAIL" and not p.use_manual_supports),
matrix_position=lambda p: Vector(
(
-GizmoRailingSchematic.SCHEMATIC_MESH_WIDTH_FRAC / 2
+ GizmoRailingSchematic.SCHEMATIC_RAIL_INSET_FRAC,
-0.18,
0.0,
)
),
# Bare names (not Gizmo…SCHEMATIC_…) because the class is still under construction here.
schematic_visible_length=SCHEMATIC_MESH_WIDTH_FRAC - 2 * SCHEMATIC_RAIL_INSET_FRAC,
text_formatter=_format_attr_distance("support_spacing"),
),
]
@classmethod
def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
return tool.Parametric.is_railing(element)
@classmethod
def schematic_cache_key(cls, props) -> tuple:
"""Cache the schematic mesh by ``railing_type`` — proportions are fixed
per type, so the bmesh build runs at most twice across a session
(once for ``FRAMELESS_PANEL``, once for ``WALL_MOUNTED_HANDRAIL``)
rather than once per draw call."""
return (props.railing_type,)
def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None:
"""Create the WALL_MOUNTED_HANDRAIL-only affordances on the schematic.
Two static lock glyphs (open/closed) for toggling
``use_manual_supports``: instantiate both and let the per-frame state
query pick which one to show. State-aware icons use a static pair
rather than a single dynamic gizmo to avoid ``prop_path`` resolution
in the render path.
Plus a cycle-glyph at the rail end that opens the ``terminal_type``
popup when clicked.
"""
default_color, highlight_color = self.get_decoration_colors()
self.lock_open_gizmo, self.lock_closed_gizmo = self.create_icon_gizmo_lock_pair(
"bim.toggle_railing_use_manual_supports",
open_color=default_color,
)
self.terminal_gizmo = self.gizmos.new("VIEW3D_GT_menu")
self.terminal_gizmo.color = default_color
self.terminal_gizmo.color_highlight = highlight_color
self.terminal_gizmo.use_draw_scale = False
self.terminal_gizmo.alpha = 0.8
self.terminal_gizmo.target_set_operator("bim.pick_railing_terminal_type")
def _refresh_element_specific(self, context: bpy.types.Context, mw: "Matrix", props) -> None:
"""Position and gate the WALL_MOUNTED_HANDRAIL-only gizmos.
- Lock glyphs: only WALL_MOUNTED_HANDRAIL while editing. Show
``lock_open`` when ``use_manual_supports`` is True, the closed
padlock when False ("auto-spacing is locked to support_spacing").
- Terminal gizmo: same gating, positioned just past the right rail
end so it reads as "configure the rail's end cap".
"""
super()._refresh_element_specific(context, mw, props)
# ``draw_prepare`` can fire on a freshly recreated GizmoGroup instance
# before ``setup_element_specific_gizmos`` has populated the lock /
# terminal attributes (Blender 5.x recreates per-region groups on
# reload). Bail out cheaply; the next refresh after setup completes
# will reposition them correctly.
if not hasattr(self, "lock_open_gizmo"):
return
# Single gate for all WALL_MOUNTED_HANDRAIL extras.
active = props.is_editing and not props.is_editing_path and props.railing_type == "WALL_MOUNTED_HANDRAIL"
if not active:
self.lock_open_gizmo.hide = True
self.lock_closed_gizmo.hide = True
self.terminal_gizmo.hide = True
return
billboard_rot = self._frame_billboard_rot
view_rotation = self.schematic_view_rotation
anchor = self._compute_schematic_anchor(props, mw, billboard_rot)
# ── Lock glyphs for use_manual_supports ──────────────────────────
# Sit just above the wall's bottom line, near the centre of the
# schematic — visually grouped with the dimension it controls
# (support_spacing) without overlapping the arrow tail below.
is_manual = bool(props.use_manual_supports)
self.lock_open_gizmo.hide = not is_manual
self.lock_closed_gizmo.hide = is_manual
lock_local = Vector((0.0, 0.05, 0.0))
lock_world = anchor + billboard_rot @ view_rotation @ lock_local
lock_matrix = gizmo.billboarded_at(lock_world, billboard_rot, 0.09)
self.lock_open_gizmo.matrix_basis = lock_matrix
self.lock_closed_gizmo.matrix_basis = lock_matrix
# ── Terminal-type popup gizmo at the right rail end ──────────────
# Pushed well past the right wall edge so the icon doesn't crowd
# the wall outline or the bracket attach point. At rail height and
# rail depth so it reads as "attached to the rail terminal".
self.terminal_gizmo.hide = False
terminal_local = Vector(
(
self.SCHEMATIC_MESH_WIDTH_FRAC / 2 + 0.25,
self.SCHEMATIC_MESH_RAIL_Y_FRAC,
self.schematic_rail_clear(),
)
)
terminal_world = anchor + billboard_rot @ view_rotation @ terminal_local
self.terminal_gizmo.matrix_basis = gizmo.billboarded_at(terminal_world, billboard_rot, 0.18)
def update_editing_gizmos(self, context: bpy.types.Context, mw: "Matrix", props: "BIMRailingProperties") -> None:
"""Hide the pen gizmo while polyline path-edit is active; reposition the cycle icon.
The base class shows the pen gizmo whenever ``is_editing`` is False,
which is the case during path-edit too. Allowing the user to click
through into parametric edit while the polyline mesh is open in EDIT
mode mixes two distinct editing states and leaves a stale draft if
they cancel out block the entry point instead. The operator itself
is intentionally not guarded (callers via scripting can still invoke
it); this is the UX-level enforcement.
The cycle icon defaults to the editing icon row (next to validate /
cancel) via the parent's positioning. We move it to just above the
schematic mesh so it reads as "cycle the railing type *shown here*"
associated with the preview the user is interacting with, not a
generic editing button at the bottom of the schematic.
"""
super().update_editing_gizmos(context, mw, props)
if props.is_editing_path:
self.pen_gizmo.hide = True
if props.is_editing and not props.is_editing_path:
billboard_rot = self._frame_billboard_rot
view_rotation = self.schematic_view_rotation
anchor = self._compute_schematic_anchor(props, mw, billboard_rot)
# Comfortably above the mesh top edge so the icon doesn't crowd
# the ``thickness`` / ``clear_width`` dimension callouts that
# already sit just above the panel/wall.
cycle_local = Vector((0.0, self.SCHEMATIC_MESH_HEIGHT_FRAC + 0.25, 0.0))
world_pos = anchor + billboard_rot @ view_rotation @ cycle_local
# 30% smaller than the editing-icon-row default (0.30 → 0.21):
# the cycle is a tertiary affordance compared to pen/validate/cancel.
self.cycle_gizmo.matrix_basis = gizmo.billboarded_at(world_pos, billboard_rot, 0.21)
@classmethod
def build_schematic_mesh(cls, props) -> "bmesh.types.BMesh":
"""Build a wireframe preview of the railing in schematic-local coordinates.
FRAMELESS_PANEL renders as a box whose proportions track the bound
properties (height / thickness / spacing); WALL_MOUNTED_HANDRAIL
renders as a horizontal tube with two L-shaped supports whose
proportions track railing_diameter / clear_width / support_spacing.
Both are scaled to fit inside ``[-schematic_box_size, +schematic_box_size]``
on each axis so the schematic reads the same regardless of absolute
property values.
The mesh is decorative clicks land on the labeled sliders, not on
the preview geometry. See ``BaseSchematicGizmoGroup`` for the
draw-handler lifecycle.
"""
bm = bmesh.new()
if props.railing_type == "FRAMELESS_PANEL":
cls._build_frameless_panel_schematic(bm, props)
else:
cls._build_wall_mounted_handrail_schematic(bm, props)
return bm
# Schematic-local half-width of the visible gap between the two panel boxes.
# Conveys the "spacing" semantic at a glance — the user sees two pickets
# separated by air, with the spacing dimension emerging from that gap.
SCHEMATIC_MESH_GAP_HALF_WIDTH = 0.05
@classmethod
def _build_frameless_panel_schematic(cls, bm: "bmesh.types.BMesh", props) -> None:
"""Stylised panel: two wireframe boxes with a visible gap between them.
The box edges sit at the ``SCHEMATIC_MESH_*_FRAC`` positions
(matching where the dimension gizmos anchor), so each dimension line
visually starts at the geometry feature it measures. Internal
proportions are stable across drags the actual values are shown
through the dimension labels, while the schematic communicates
which feature each label refers to. The gap between the two boxes
(set by ``SCHEMATIC_MESH_GAP_HALF_WIDTH``) gives the "spacing"
dimension a real visual referent.
Edges are tagged on a string layer so hover-highlight can colour
the geometric feature being measured: vertical edges height,
depth edges thickness. The X-aligned edges along the panel
width are untagged (they don't correspond to a single dimension).
"""
hw = cls.SCHEMATIC_MESH_WIDTH_FRAC / 2
hd = cls.SCHEMATIC_MESH_DEPTH_FRAC / 2
h_top = cls.SCHEMATIC_MESH_HEIGHT_FRAC
gap = cls.SCHEMATIC_MESH_GAP_HALF_WIDTH
layer_name = cls.SCHEMATIC_FEATURE_LAYER_NAME
feat_layer = bm.edges.layers.string.get(layer_name) or bm.edges.layers.string.new(layer_name)
# Edge index → feature tag for one box. Order matches the (a, b)
# tuple order below: bottom ring (4) + top ring (4) + verticals (4).
edge_tags_per_box = (
b"", # (0,1) bottom-back, X-aligned
b"panel_thickness", # (1,2) bottom-right, Z-aligned
b"", # (2,3) bottom-front, X-aligned
b"panel_thickness", # (3,0) bottom-left, Z-aligned
b"", # (4,5) top-back, X-aligned
b"panel_thickness", # (5,6) top-right, Z-aligned
b"", # (6,7) top-front, X-aligned
b"panel_thickness", # (7,4) top-left, Z-aligned
b"panel_height", # (0,4) vertical back-left
b"panel_height", # (1,5) vertical back-right
b"panel_height", # (2,6) vertical front-right
b"panel_height", # (3,7) vertical front-left
)
# Build two separate wireframe boxes — one on each side of the central
# gap. The boxes share the same Y range (0..h_top) and Z range (±hd)
# but split the X range so the gap from -gap to +gap stays empty.
for x_left, x_right in ((-hw, -gap), (gap, hw)):
corners = [
bm.verts.new((x_left, 0.0, -hd)),
bm.verts.new((x_right, 0.0, -hd)),
bm.verts.new((x_right, 0.0, hd)),
bm.verts.new((x_left, 0.0, hd)),
bm.verts.new((x_left, h_top, -hd)),
bm.verts.new((x_right, h_top, -hd)),
bm.verts.new((x_right, h_top, hd)),
bm.verts.new((x_left, h_top, hd)),
]
for tag, (a, b) in zip(
edge_tags_per_box,
(
(0, 1),
(1, 2),
(2, 3),
(3, 0), # bottom ring
(4, 5),
(5, 6),
(6, 7),
(7, 4), # top ring
(0, 4),
(1, 5),
(2, 6),
(3, 7), # vertical edges
),
):
edge = bm.edges.new((corners[a], corners[b]))
if tag:
edge[feat_layer] = tag
@classmethod
def _build_wall_mounted_handrail_schematic(cls, bm: "bmesh.types.BMesh", props) -> None:
"""Stylised wall-mounted handrail: wall outline, hex tube, two L-brackets.
Three visual elements convey "rail mounted on a wall":
- **Wall outline** a wireframe rectangle in the YZ plane at ``z=0``,
extending slightly past the rail ends so the wall reads as a
surface the rail is *attached to* rather than a coincident frame.
- **Handrail tube** a hexagonal cross-section extruded along ±X
at ``z=+clear_s`` (in front of the wall), at ``y=rail_y``.
- **L-shaped brackets** at each rail end from the rail centreline
drop a short distance, then run perpendicular back to the wall
plane. Mirrors the standard wall-mount bracket geometry: a
horizontal arm holding the rail off the wall, a vertical drop
attaching to the rail.
Like ``_build_frameless_panel_schematic``, the schematic uses fixed
proportions so the dimension gizmos' anchor points stay aligned
with the geometry features regardless of property values.
"""
half_len = cls.SCHEMATIC_MESH_WIDTH_FRAC / 2
wall_top = cls.SCHEMATIC_MESH_HEIGHT_FRAC
rail_y = cls.SCHEMATIC_MESH_RAIL_Y_FRAC # rail sits at half wall height
radius_s = cls.schematic_rail_radius()
clear_s = cls.schematic_rail_clear()
layer_name = cls.SCHEMATIC_FEATURE_LAYER_NAME
feat_layer = bm.edges.layers.string.get(layer_name) or bm.edges.layers.string.new(layer_name)
# ── Wall outline (rectangle at z=0, slightly wider than the rail) ──
# Spans the full schematic height; the rail attaches in the middle,
# so the wall reads as "continuing past the rail above and below".
# Wall edges stay untagged — they're background context, not a
# feature any dimension measures.
wall_extra = 0.08
wall_x_left = -half_len - wall_extra
wall_x_right = half_len + wall_extra
wall_corners = [
bm.verts.new((wall_x_left, 0.0, 0.0)),
bm.verts.new((wall_x_right, 0.0, 0.0)),
bm.verts.new((wall_x_right, wall_top, 0.0)),
bm.verts.new((wall_x_left, wall_top, 0.0)),
]
for a, b in ((0, 1), (1, 2), (2, 3), (3, 0)):
bm.edges.new((wall_corners[a], wall_corners[b]))
# ── Handrail tube (hex cross-section in YZ, extruded along X) ──────
# Centred on the rail centreline at (±(half_len - rail_inset),
# rail_y, +clear_s) — in front of the wall plane at z=0. The tube
# is shorter than the wall so the wall visibly extends past it on
# both sides; the L-brackets sit at the tube ends, so the leftmost
# bracket no longer coincides with the wall's left edge.
rail_inset = cls.SCHEMATIC_RAIL_INSET_FRAC
rail_x_left = -half_len + rail_inset
rail_x_right = half_len - rail_inset
segments = 6
ring_left, ring_right = [], []
for i in range(segments):
theta = 2 * math.pi * i / segments
dy = math.cos(theta) * radius_s
dz = math.sin(theta) * radius_s
ring_left.append(bm.verts.new((rail_x_left, rail_y + dy, clear_s + dz)))
ring_right.append(bm.verts.new((rail_x_right, rail_y + dy, clear_s + dz)))
# All hex-tube edges tagged "rail_tube" so they highlight together
# when the railing_diameter dimension is hovered.
for i in range(segments):
j = (i + 1) % segments
e_left = bm.edges.new((ring_left[i], ring_left[j]))
e_right = bm.edges.new((ring_right[i], ring_right[j]))
e_axial = bm.edges.new((ring_left[i], ring_right[i]))
e_left[feat_layer] = b"rail_tube"
e_right[feat_layer] = b"rail_tube"
e_axial[feat_layer] = b"rail_tube"
# ── L-brackets at each rail end (rail → drop → wall) ───────────────
# Bracket attach points follow the rail ends, so they're pulled
# inward by ``rail_inset`` from the wall edges. From the rail
# centreline, drop ``bracket_drop`` in Y, then run perpendicular
# back to the wall plane (z=0). The L shape reads as a wall-mount
# bracket under the 3/4 tilt. Both bracket segments tagged
# "bracket" so they highlight when clear_width OR support_spacing
# is hovered (both dimensions measure features of the supports).
bracket_drop = 0.06
for x in (rail_x_left, rail_x_right):
v_rail = bm.verts.new((x, rail_y, clear_s))
v_corner = bm.verts.new((x, rail_y - bracket_drop, clear_s))
v_wall = bm.verts.new((x, rail_y - bracket_drop, 0.0))
e1 = bm.edges.new((v_rail, v_corner))
e2 = bm.edges.new((v_corner, v_wall))
e1[feat_layer] = b"bracket"
e2[feat_layer] = b"bracket"
class FlipRailingPathOrder(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.flip_railing_path_order"
bl_label = "Flip Railing Path Order"
@@ -510,6 +1137,16 @@ class EnableEditingRailingPath(bpy.types.Operator, tool.Ifc.Operator):
[o.select_set(False) for o in context.selected_objects if o != obj]
assert obj
props = tool.Model.get_railing_props(obj)
# Auto-commit any in-progress parametric draft before switching to
# path-edit. ``set_props_kwargs_from_ifc_data`` a few lines below
# overwrites props with the pset's stored values — without committing
# first, anything the user dragged on a dimension gizmo (height,
# diameter, …) would be silently discarded the moment path-edit
# starts.
if props.is_editing:
tool.Parametric.commit_object_draft(obj, "bim.finish_editing_railing")
data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Railing")["data_dict"]
# required since we could load pset from .ifc and BIMRoofProperties won't be set
props.set_props_kwargs_from_ifc_data(data)
+80 -1
View File
@@ -271,7 +271,7 @@ class DumbSlabPlaner:
# For instances, a 30 degrees angled extrusion with positive direction has the same extrusion direction as a
# -150 degrees angled extrusion with negative direction. The difference lies in the object's rotation.
# This means that things can get messy if the user changes the object x angle somehow. We have to figure out an alternative approach.
existing_x_angle = obj.rotation_euler.x
existing_x_angle = tool.Model.get_existing_x_angle(extrusion)
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 0, tolerance=0.001) else existing_x_angle
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, pi, tolerance=0.001) else existing_x_angle
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 2 * pi, tolerance=0.001) else existing_x_angle
@@ -620,6 +620,14 @@ class EnableEditingExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator):
def _execute(self, context):
self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
obj = context.active_object
# Commit any in-progress parametric (gizmo) draft on this object
# before switching to profile-edit. Otherwise the in-memory draft
# state is overwritten when the profile mesh is imported below,
# silently discarding the user's pending dimension edits.
if feature := tool.Parametric.is_object_editing(obj):
tool.Parametric.commit_object_draft(obj, feature.finish_op)
element = tool.Ifc.get_entity(obj)
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
@@ -991,3 +999,74 @@ class RecalculateSlab(bpy.types.Operator, tool.Ifc.Operator):
tool.Model.recalculate_walls(walls)
return {"FINISHED"}
class EnableEditingSlab(bpy.types.Operator, tool.Ifc.Operator):
"""Open the slab disconnect-access mode. Pure UI toggle: flips
``obj.BIMSlabProperties.is_editing`` so the per-wall disconnect
gizmos surface on the slab. ``tool.Ifc.Operator`` base because the
parametric framework's universal dispatcher routes through
``tool.Parametric.run_bim_op``, which only accepts that subclass for
undo-safe lifecycle. No IFC mutation."""
bl_idname = "bim.enable_editing_slab"
bl_label = "Edit Slab Connections"
bl_description = "Show disconnect icons for every wall clipped to this slab"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
obj = context.active_object
if obj is None:
return False
element = tool.Ifc.get_entity(obj)
return element is not None and element.is_a("IfcSlab")
def _execute(self, context):
context.active_object.BIMSlabProperties.is_editing = True
return {"FINISHED"}
class CancelEditingSlab(bpy.types.Operator, tool.Ifc.Operator):
"""Close the slab disconnect-access mode."""
bl_idname = "bim.cancel_editing_slab"
bl_label = "Close Slab Edit"
bl_description = "Hide the slab disconnect icons"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
obj = context.active_object
if obj is None:
return False
element = tool.Ifc.get_entity(obj)
return element is not None and element.is_a("IfcSlab")
def _execute(self, context):
context.active_object.BIMSlabProperties.is_editing = False
return {"FINISHED"}
class FinishEditingSlab(bpy.types.Operator, tool.Ifc.Operator):
"""Close the slab disconnect-access mode. Same body as Cancel — slab
edit is a pure UI gate with no IFC draft to commit; the framework
requires both ``bim.finish_editing_<name>`` and
``bim.cancel_editing_<name>`` to exist by name convention."""
bl_idname = "bim.finish_editing_slab"
bl_label = "Finish Slab Edit"
bl_description = "Hide the slab disconnect icons"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
obj = context.active_object
if obj is None:
return False
element = tool.Ifc.get_entity(obj)
return element is not None and element.is_a("IfcSlab")
def _execute(self, context):
context.active_object.BIMSlabProperties.is_editing = False
return {"FINISHED"}
+1
View File
@@ -22,6 +22,7 @@ from collections.abc import Iterable
from typing import TYPE_CHECKING, Any
import bpy
import ifcopenshell.util.unit
from bpy.types import Panel
import ifcopenshell.util.unit
+490 -91
View File
@@ -48,6 +48,7 @@ import mathutils.geometry
import numpy as np
from mathutils import Matrix, Vector
import bonsai.core.connection
import bonsai.core.geometry
import bonsai.core.model as core
import bonsai.core.root
@@ -62,7 +63,6 @@ from bonsai.bim.module.model.decorator import (
_BBOX_HIGHLIGHT_LINE_WIDTH,
PolylineDecorator,
ProductDecorator,
_fill_quads_alpha,
bbox_world_edges,
draw_polyline_segments,
)
@@ -108,6 +108,50 @@ def _wall_gizmo_poll_gate(context: bpy.types.Context) -> bool:
return True
def _resolve_active_partner_pair(
context: bpy.types.Context,
) -> "tuple[bpy.types.Object, bpy.types.Object, ifcopenshell.entity_instance, ifcopenshell.entity_instance] | None":
"""Return ``(active_obj, partner_obj, active_elem, partner_elem)`` for a
selection of exactly two IFC-bound objects with the active one named,
else ``None``. Used by every 2-selection gizmo to skip the standard
"resolve active + partner + IFC entities" preamble."""
active = tool.Blender.get_active_object(is_selected=True)
if active is None:
return None
selected = list(tool.Blender.get_selected_objects())
if len(selected) != 2:
return None
partner = next((o for o in selected if o != active), None)
if partner is None:
return None
active_elem = tool.Ifc.get_entity(active)
partner_elem = tool.Ifc.get_entity(partner)
if active_elem is None or partner_elem is None:
return None
return active, partner, active_elem, partner_elem
def _slab_connection_gizmo_poll_gate(context: bpy.types.Context, *, require_editing: bool = False) -> bool:
"""Shared gate for slab-side connection gizmos: exactly 1 IfcSlab
selected, not an array child, has at least one wall clipped to its
underside. With ``require_editing=True`` additionally requires the
slab's parametric edit lifecycle to be active (pen icon clicked) so
the gizmo only surfaces after explicit opt-in."""
active = tool.Blender.get_active_object(is_selected=True)
if active is None:
return False
if len(tool.Blender.get_selected_objects()) != 1:
return False
element = tool.Ifc.get_entity(active)
if element is None or not element.is_a("IfcSlab"):
return False
if tool.Blender.Modifier.any_selected_is_array_child():
return False
if require_editing and not tool.Model.get_slab_props(active).is_editing:
return False
return any(tool.Wall.iter_slab_wall_connections(element))
def _wall_topology_gizmo_poll_gate(context: bpy.types.Context) -> bool:
"""Tighter gate for wall topology gizmos (merge / join / extend / unjoin
/ fillet): base ``_wall_gizmo_poll_gate`` plus an array-child filter.
@@ -246,6 +290,15 @@ def _resync_walls_after_mutation(objs: Iterable["bpy.types.Object | None"]) -> N
_maybe_resync_wall_props_from_ifc(obj)
def _regenerate_walls(objs: "Iterable[bpy.types.Object | None]") -> None:
"""Rebuild every wall in ``objs`` from current IFC state — extrusion,
openings, and any underside slab clip so the caller doesn't carry
feature-specific dispatch."""
for obj in objs:
if obj is not None:
tool.Model.regenerate_wall(obj)
class _CommitWallDraftsFirstMixin:
"""Operator mixin that flushes any in-progress wall parametric drafts in
the current selection before delegating to the subclass's ``_perform``.
@@ -285,19 +338,29 @@ class UnjoinWalls(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Oper
_resync_walls_after_mutation(tool.Blender.get_selected_objects())
class UnjoinWallPathConnection(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operator):
"""Surgical counterpart to `UnjoinWalls`: disconnect the active wall from one
specific partner wall, leaving the active wall's other connections intact. The
partner is identified by IFC GlobalId invariant under Blender-object renames,
file save/reload, and the undo stack set on the operator properties by the
single-wall unjoin gizmo at click time."""
class DisconnectElements(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operator):
"""Disconnect two IFC elements given their GlobalIds — generic dispatcher
that infers the connection rel kind via tool.Connection.find_rels and runs
the right post-disconnect cleanup:
bl_idname = "bim.unjoin_wall_path_connection"
bl_label = "Unjoin Wall Connection"
bl_description = "Disconnect the active wall from a single specific partner wall"
- ``"path"`` (IfcRelConnectsPathElements) removes every rel between
the pair (catches both orientations) via remove_connection + recreates
both walls + resyncs drafts.
- ``"element-top"`` (IfcRelConnectsElements with Description=="TOP")
disconnect_element + regenerate_wall_to_underside on the wall side.
- ``"element"`` (other IfcRelConnectsElements) disconnect_element only.
Both endpoints by GlobalId so the dispatch survives rename / undo / save.
Replaces the previous typed UnjoinWallPathConnection + DisconnectWallSlab
operators with one entry-point gizmos and shortcuts can bind to."""
bl_idname = "bim.disconnect_elements"
bl_label = "Disconnect Elements"
bl_description = "Remove the connection between two IFC elements identified by GlobalId"
bl_options = {"REGISTER", "UNDO"}
other_wall_guid: bpy.props.StringProperty(name="Other Wall GlobalId")
element_a_guid: bpy.props.StringProperty(name="Element A GlobalId")
element_b_guid: bpy.props.StringProperty(name="Element B GlobalId")
@classmethod
def poll(cls, context):
@@ -309,44 +372,54 @@ class UnjoinWallPathConnection(_CommitWallDraftsFirstMixin, bpy.types.Operator,
return True
def _perform(self, context):
active = tool.Blender.get_active_object(is_selected=True)
if not active:
self.report({"ERROR"}, "Could not resolve walls for surgical unjoin.")
ifc_file = tool.Ifc.get()
try:
elem_a = ifc_file.by_guid(self.element_a_guid) if self.element_a_guid else None
elem_b = ifc_file.by_guid(self.element_b_guid) if self.element_b_guid else None
except RuntimeError:
elem_a = elem_b = None
if elem_a is None or elem_b is None:
self.report({"ERROR"}, "Could not resolve elements from supplied GlobalIds.")
return
elem_active = tool.Ifc.get_entity(active)
if not elem_active:
self.report({"ERROR"}, "Active object is not bound to an IFC entity.")
rels = tool.Connection.find_rels(elem_a, elem_b)
if not rels:
self.report({"ERROR"}, "No connection found between elements.")
return
elem_other = None
if self.other_wall_guid:
try:
elem_other = tool.Ifc.get().by_guid(self.other_wall_guid)
except RuntimeError:
elem_other = None
other = tool.Ifc.get_object(elem_other) if elem_other else None
if not elem_other or not other:
self.report({"ERROR"}, "Could not resolve walls for surgical unjoin.")
# The fillet corner's join with its source walls defines the fillet's
# identity — unjoining there would tear down the chord axis reference
# without rebuilding the source walls' miter cuts. Deleting the corner
# wall is the supported teardown, which cascades back to the source
# walls via the connection-cleanup handler.
either_is_fillet = tool.Parametric.is_fillet_corner_wall(elem_a) or tool.Parametric.is_fillet_corner_wall(
elem_b
)
if either_is_fillet and any(k == "path" for _, k in rels):
self.report(
{"INFO"},
"Fillet wall path connections can't be unjoined — delete the fillet wall element to remove the corner.",
)
return
# Walk the inverse graph for the specific IfcRelConnectsPathElements joining
# these two walls and remove only that one. `disconnect_path`'s
# (relating, related) mode only inspects `relating.ConnectedTo`, so a single
# call misses the rel when it was authored with the opposite orientation.
rels = [
rel
for rel in getattr(elem_active, "ConnectedTo", [])
if rel.is_a("IfcRelConnectsPathElements") and rel.RelatedElement == elem_other
] + [
rel
for rel in getattr(elem_active, "ConnectedFrom", [])
if rel.is_a("IfcRelConnectsPathElements") and rel.RelatingElement == elem_other
]
for rel in rels:
bonsai.core.geometry.remove_connection(tool.Geometry, connection=rel)
# Recreate body+axis on both walls so the mesh state matches the IFC mutation
# and stale miter cuts are dropped.
tool.Model.recreate_wall(elem_active, active)
tool.Model.recreate_wall(elem_other, other)
_resync_walls_after_mutation([active, other])
path_objs: list[bpy.types.Object] = []
for subject, kind in rels:
bonsai.core.connection.disconnect_rel(
tool.Ifc,
tool.Geometry,
tool.Model,
tool.Connection,
subject=subject,
kind=kind,
elem=elem_a,
partner=elem_b,
)
if kind == "path":
obj_a = tool.Ifc.get_object(elem_a)
obj_b = tool.Ifc.get_object(elem_b)
if obj_a is not None and obj_a not in path_objs:
path_objs.append(obj_a)
if obj_b is not None and obj_b not in path_objs:
path_objs.append(obj_b)
if path_objs:
_resync_walls_after_mutation(path_objs)
class ExtendWallsToUnderside(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operator):
@@ -369,7 +442,7 @@ class ExtendWallsToUnderside(_CommitWallDraftsFirstMixin, bpy.types.Operator, to
element = tool.Ifc.get_entity(obj)
if not element:
continue
if tool.Model.get_usage_type(element) == "LAYER2":
if tool.Parametric.is_path_connectable_wall(element):
walls.append(obj)
else:
slabs.append(obj)
@@ -390,7 +463,7 @@ class RegenerateWallToUnderside(bpy.types.Operator, tool.Ifc.Operator):
wall_objs = [
obj
for obj in tool.Blender.get_selected_objects()
if (element := tool.Ifc.get_entity(obj)) and tool.Model.get_usage_type(element) == "LAYER2"
if (element := tool.Ifc.get_entity(obj)) and tool.Parametric.is_path_connectable_wall(element)
]
if wall_objs:
core.regenerate_wall_to_underside(tool.Ifc, tool.Geometry, tool.Model, wall_objs)
@@ -642,9 +715,14 @@ class SplitWall(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operat
def _perform(self, context):
selected_objs = tool.Model.get_selected_mesh_objects()
post_split_walls: list[bpy.types.Object] = []
for obj in selected_objs:
DumbWallJoiner().split(obj, context.scene.cursor.location)
_resync_walls_after_mutation(selected_objs)
new_obj = DumbWallJoiner().split(obj, context.scene.cursor.location)
post_split_walls.append(obj)
if new_obj is not None and new_obj not in post_split_walls:
post_split_walls.append(new_obj)
_resync_walls_after_mutation(post_split_walls)
_regenerate_walls(post_split_walls)
return {"FINISHED"}
@@ -674,11 +752,13 @@ class MergeWall(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operat
active_obj = context.active_object
assert active_obj
selected_objs = tool.Model.get_selected_mesh_objects()
# The merge deletes the second argument when the walls are collinear;
# only the first survives, so the resync targets the non-active wall.
surviving_obj = next(o for o in selected_objs if o != active_obj)
DumbWallJoiner().merge(surviving_obj, active_obj)
_maybe_resync_wall_props_from_ifc(surviving_obj)
# Active-is-survivor — matches Blender's Ctrl+J / "merge at last"
# convention. The first argument survives, the second is consumed,
# so the active wall ends up absorbing the other.
other_obj = next(o for o in selected_objs if o != active_obj)
DumbWallJoiner().merge(active_obj, other_obj)
_maybe_resync_wall_props_from_ifc(active_obj)
_regenerate_walls([active_obj])
return {"FINISHED"}
@@ -745,6 +825,7 @@ class ChangeExtrusionDepth(bpy.types.Operator, tool.Ifc.Operator):
if layer2_objs:
tool.Model.recalculate_walls(layer2_objs)
_resync_walls_after_mutation(layer2_objs)
return {"FINISHED"}
@@ -791,7 +872,7 @@ class ChangeExtrusionXAngle(bpy.types.Operator, tool.Ifc.Operator):
extrusion.Depth = perpendicular_depth
else:
if tool.Model.get_usage_type(element) == "LAYER3":
existing_x_angle = obj.rotation_euler.x
existing_x_angle = tool.Model.get_existing_x_angle(extrusion)
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 0, tolerance=0.001) else existing_x_angle
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, pi, tolerance=0.001) else existing_x_angle
@@ -860,6 +941,7 @@ class ChangeExtrusionXAngle(bpy.types.Operator, tool.Ifc.Operator):
if layer2_objs:
tool.Model.recalculate_walls(layer2_objs)
_resync_walls_after_mutation(layer2_objs)
return {"FINISHED"}
@@ -882,6 +964,7 @@ class ChangeLayerLength(bpy.types.Operator, tool.Ifc.Operator):
selected_objs = tool.Model.get_selected_mesh_ifc_objects()
for obj in selected_objs:
joiner.set_length(obj, self.length)
_resync_walls_after_mutation(selected_objs)
class OffsetWalls(bpy.types.Operator, tool.Ifc.Operator):
@@ -1463,7 +1546,7 @@ class DumbWallJoiner:
body = copy.deepcopy(axis1["reference"])
tool.Model.recreate_wall(element1, wall1)
def split(self, wall1: bpy.types.Object, target: Vector) -> None:
def split(self, wall1: bpy.types.Object, target: Vector) -> "bpy.types.Object | None":
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
element1 = tool.Ifc.get_entity(wall1)
@@ -1484,6 +1567,13 @@ class DumbWallJoiner:
wall2 = self.duplicate_wall(wall1)
element2 = tool.Ifc.get_entity(wall2)
# The duplicate inherits wall1's slab-trim boolean chain (copied by
# copy_class) but ``BBIM_Boolean.Data`` carries wall1's stale ids, so
# ``get_manual_booleans(element2)`` returns empty and the regenerator
# rebuilds wall2's body without those clips. Strip them up front so
# wall2 starts clean before the axis + placement reshape.
tool.Model.strip_underside_booleans(element2)
# Get the ATEND connection from wall1 to use it in wall2
relating_element = None
connections = element1.ConnectedTo
@@ -1543,13 +1633,16 @@ class DumbWallJoiner:
r.RelatedOpeningElement for r in list(element1.HasOpenings) if r.RelatedOpeningElement.HasFillings
]:
rel = opening.HasFillings[0]
filling = rel.RelatedBuildingElement
filling_obj = tool.Ifc.get_object(filling)
filling_location = filling_obj.matrix_world.translation
_, filling_position = mathutils.geometry.intersect_point_line(filling_location.to_2d(), *axis_world_2d)
min_t, max_t = _opening_axis_extent(opening, axis_world_2d, unit_scale)
# Use the opening's axis-projected midpoint to classify the side.
# The filling's ``matrix_world.translation`` is flip-fragile —
# flipping rotates the filler 180° + translates so the bbox
# stays visually in place, moving the door origin to the
# opposite corner, which would mis-classify a flipped door
# centred over the cut.
opening_midpoint = (min_t + max_t) / 2
void_straddles = min_t < cut_percentage < max_t
if filling_position > cut_percentage:
if opening_midpoint > cut_percentage:
# The filling should be moved from element1 to element2.
new_opening = ifcopenshell.api.root.copy_class(tool.Ifc.get(), product=opening)
new_opening.VoidsElements[0].RelatingBuildingElement = element2
@@ -1564,8 +1657,20 @@ class DumbWallJoiner:
rel.RelatingOpeningElement = new_opening
if void_straddles:
# Filling moved to element2, but void straddles — add a
# pure-void copy back to element1. Read from the original
# ``opening`` whose ObjectPlacement still references
# element1; ``new_opening`` was rebound to element2 and
# would copy element2's frame instead.
_add_void_copy(element1, opening)
# Remove the old opening
ifcopenshell.api.feature.remove_feature(tool.Ifc.get(), feature=opening)
elif void_straddles:
# Filling stays on element1, but void straddles — add a pure-void
# copy to element2 so its body gets cut.
_add_void_copy(element2, opening)
if void_straddles:
# Filling moved to element2, but void straddles — add a
@@ -1583,6 +1688,7 @@ class DumbWallJoiner:
tool.Model.recreate_wall(element1, wall1)
tool.Model.recreate_wall(element2, wall2)
return wall2
def flip(self, wall1: bpy.types.Object) -> None:
if tool.Ifc.is_moved(wall1):
@@ -1638,7 +1744,14 @@ class DumbWallJoiner:
p2[0] = max(x_ordinates)
self.set_axis(element1, p1, p2)
# ConnectedTo / ConnectedFrom carry both ``IfcRelConnectsPathElements``
# (the wall-wall joins this loop migrates) and
# ``IfcRelConnectsElements`` (the slab underside clip). Only the
# path rels expose ``RelatingConnectionType`` / ``RelatedConnectionType``;
# the element rels die with element2 via the trailing cascade delete.
for rel in element2.ConnectedTo:
if not rel.is_a("IfcRelConnectsPathElements"):
continue
ifcopenshell.api.geometry.disconnect_path(
tool.Ifc.get(), element=element1, connection_type=rel.RelatingConnectionType
)
@@ -1651,6 +1764,8 @@ class DumbWallJoiner:
)
for rel in element2.ConnectedFrom:
if not rel.is_a("IfcRelConnectsPathElements"):
continue
ifcopenshell.api.geometry.disconnect_path(
tool.Ifc.get(), element=element1, connection_type=rel.RelatedConnectionType
)
@@ -1662,6 +1777,26 @@ class DumbWallJoiner:
related_connection=rel.RelatedConnectionType,
)
# Re-host openings from the discarded wall to the survivor before
# the cascade delete tears down element2's voids and any filling
# that depends on them. ``edit_object_placement`` preserves the
# opening's world position when element1 and element2 have
# different placements — a ``PlacementRelTo`` swap alone would
# shift the opening as the relative offset changes.
ifc_file = tool.Ifc.get()
for rel in list(element2.HasOpenings):
opening = rel.RelatedOpeningElement
rel.RelatingBuildingElement = element1
if opening.ObjectPlacement:
world_matrix = ifcopenshell.util.placement.get_local_placement(opening.ObjectPlacement)
ifcopenshell.api.geometry.edit_object_placement(
ifc_file,
product=opening,
matrix=world_matrix,
is_si=False,
should_transform_children=False,
)
tool.Model.recreate_wall(element1, wall1)
tool.Geometry.delete_ifc_object(wall2)
@@ -2458,7 +2593,9 @@ class ExtendWallToCursor(bpy.types.Operator, tool.Ifc.Operator):
tool.Model,
context.scene.cursor.location,
)
_resync_walls_after_mutation(tool.Blender.get_selected_objects())
affected = list(tool.Blender.get_selected_objects())
_resync_walls_after_mutation(affected)
_regenerate_walls(affected)
return {"FINISHED"}
@@ -2491,6 +2628,7 @@ class ExtendWallHeightToCursor(bpy.types.Operator, tool.Ifc.Operator):
with bpy.context.temp_override(active_object=obj, selected_objects=[obj]):
bpy.ops.bim.change_extrusion_depth(depth=new_height)
_maybe_resync_wall_props_from_ifc(obj)
_regenerate_walls([obj])
return {"FINISHED"}
@@ -3117,6 +3255,12 @@ def regenerate_fillet_corner_wall(element: ifcopenshell.entity_instance, obj: bp
# the banana body. If a neighbour moved, the new placement follows; if
# neither moved, the new matrix equals the old within floating-point noise.
_apply_fillet_corner_geometry(ifc_file, obj, geom, wall_a_obj)
# The body rebuild swaps the wall's representation, so any prior underside
# clip is gone. Re-clip from the surviving TOP rels so an extend-to-slab
# applied to a fillet wall isn't silently wiped on the next neighbour
# recalc, ChangeExtrusionDepth, or split / merge call site.
if tool.Model.has_underside_connection(element):
core.regenerate_wall_to_underside(tool.Ifc, tool.Geometry, tool.Model, [obj])
class EnableWallFilletPreview(bpy.types.Operator):
@@ -3236,6 +3380,19 @@ class CancelWallFilletPreview(bpy.types.Operator):
props = preview_base.get_preview_props(context, "wall_fillet")
if props is None or not props.is_active:
return {"CANCELLED"}
# Clear the corner's edit flag so the connection disconnect gizmos
# disappear in lockstep with the radius preview when the user
# cancels. The id read happens BEFORE clear_preview_state wipes it.
corner_id = props.editing_corner_id
if corner_id:
ifc_file = tool.Ifc.get()
if ifc_file is not None:
try:
corner_obj = tool.Ifc.get_object(ifc_file.by_id(corner_id))
except RuntimeError:
corner_obj = None
if corner_obj is not None:
tool.Model.get_wall_props(corner_obj).is_editing = False
preview_base.clear_preview_state(props)
return {"FINISHED"}
@@ -3309,6 +3466,11 @@ class EnableWallFilletPreviewFromCorner(bpy.types.Operator):
props.radius = float(radius)
props.editing_corner_id = corner_elem.id()
props.is_active = True
# Flag the corner as "in edit mode" so the wall-side connection
# disconnect gizmos surface in parallel with the fillet preview —
# one pen-icon click enters BOTH radius retune AND connection
# inspection.
tool.Model.get_wall_props(corner_obj).is_editing = True
return {"FINISHED"}
@@ -3589,7 +3751,7 @@ class GizmoWallExtendVertically(bpy.types.GizmoGroup, _WallGeomCachedBillboardin
return False
other = next(o for o in selected if o is not active)
other_element = tool.Ifc.get_entity(other)
if not other_element or tool.Model.get_usage_type(other_element) != "LAYER2":
if not other_element or not tool.Parametric.is_path_connectable_wall(other_element):
return False
return True
@@ -3855,15 +4017,17 @@ class GizmoWallLinkToggle(gizmo.GizmoLinkToggle, bpy.types.Gizmo):
class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin):
"""Activates when exactly one LAYER2 wall is selected. Surfaces an unjoin icon at
every join location inferred from the wall's IfcRelConnectsPathElements inverse
graph the single-selection mirror of `GizmoWallJoinIntersection`'s two-wall
unjoin state. A wall may participate in many such rels (up to 1 ATSTART + 1 ATEND
by end, plus unlimited ATPATH T-junctions), so a pool of icons is preallocated
and hidden on a per-frame basis based on the live connection set.
every connection location on the wall wall-wall path connections via
IfcRelConnectsPathElements + wall-slab underside clips via IfcRelConnectsElements
with Description=="TOP". A wall may participate in many such rels (up to 1 ATSTART
+ 1 ATEND by end, plus unlimited ATPATH T-junctions, plus one rel per clipped
slab), so a pool of icons is preallocated and hidden on a per-frame basis based
on the live connection set.
Each visible icon dispatches `bim.unjoin_wall_path_connection` with the partner
wall's GlobalId set on the bound operator properties, so a click removes only
the single rel under that icon the other connections on the same wall survive.
Each visible icon dispatches `bim.disconnect_elements` with the active wall +
partner element GlobalIds set on the bound operator properties, so a click
removes only the single rel under that icon the other connections on the
same wall survive.
Mutually exclusive with `GizmoWallJoinIntersection` via `poll()` (that group
requires len(selected) == 2; this one requires 1)."""
@@ -3881,10 +4045,25 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix
# creation is forbidden — so the pool must be sized upfront for the worst case.
POOL_SIZE = 16
ICON_SCALE = 0.35
SLAB_STACK_MAX = 5
SLAB_STACK_OFFSET_Z = 0.5
# Muted gray used for connection icons that are visible (the connection
# exists) but inert (clicking dispatches a no-op + INFO report). Fillet
# corner ↔ source-wall joins use this — disconnecting them would tear
# down the fillet's chord axis reference, so the supported teardown is
# deleting the corner wall instead.
LOCKED_COLOR: ClassVar[tuple[float, float, float]] = (0.5, 0.5, 0.5)
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
if not _wall_topology_gizmo_poll_gate(context):
# Bypass the shared topology gate's ``any_preview_active`` block —
# ``BIMWallProperties.is_editing`` is the real gate for this gizmo
# group, and that flag is set both by the regular wall edit lifecycle
# AND by the fillet preview entry (so a fillet corner under preview
# surfaces its connections in parallel with the radius drag).
if not tool.Blender.are_viewport_gizmos_enabled():
return False
if tool.Blender.Modifier.any_selected_is_array_child():
return False
active = tool.Blender.get_active_object(is_selected=True)
if active is None:
@@ -3902,6 +4081,9 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix
def setup(self, context: bpy.types.Context) -> None:
default_color, highlight_color = self.get_decoration_colors()
# Stashed so per-frame ``_bind_unjoin_icon`` can restore the active
# tone when an icon was muted in a previous frame for fillet lock.
self._default_unjoin_color = default_color
# Bind the operator on each pool icon ONCE at setup time and keep the returned
# OperatorProperties handles. target_set_operator allocates a fresh handle on
# every call, so calling it from position_gizmos (which fires every redraw
@@ -3911,11 +4093,11 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix
self.unjoin_op_props = []
for _ in range(self.POOL_SIZE):
icon = self.setup_icon_gizmo(
"VIEW3D_GT_wall_link_toggle", default_color, highlight_color, "bim.unjoin_wall_path_connection"
"VIEW3D_GT_wall_link_toggle", default_color, highlight_color, "bim.disconnect_elements"
)
icon.hide = True
self.unjoin_icons.append(icon)
self.unjoin_op_props.append(icon.target_set_operator("bim.unjoin_wall_path_connection"))
self.unjoin_op_props.append(icon.target_set_operator("bim.disconnect_elements"))
def position_gizmos(self, context: bpy.types.Context) -> None:
# Default: hide every pool slot. The visible-set is rebuilt from the live
@@ -3936,15 +4118,28 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix
billboard_rot = gizmo.get_billboard_rotation(context)
clearance = gizmo.top_down_clearance(context, billboard_rot)
connections = _get_wall_connections_cached(self, elem)
if len(connections) > self.POOL_SIZE and not getattr(self, "_pool_cap_warned", False):
path_connections = _get_wall_connections_cached(self, elem)
slab_connections = list(tool.Wall.iter_wall_slab_connections(elem))
slab_overflow = max(0, len(slab_connections) - self.SLAB_STACK_MAX)
if slab_overflow and not getattr(self, "_slab_cap_warned", False):
print(
f"[bonsai] GizmoWallUnjoinSingle: wall has {len(connections)} path connections; "
f"[bonsai] GizmoWallUnjoinSingle: wall has {len(slab_connections)} slab "
f"connections; only the first {self.SLAB_STACK_MAX} are shown stacked."
)
self._slab_cap_warned = True
slab_connections = slab_connections[: self.SLAB_STACK_MAX]
total = len(path_connections) + len(slab_connections)
if total > self.POOL_SIZE and not getattr(self, "_pool_cap_warned", False):
print(
f"[bonsai] GizmoWallUnjoinSingle: wall has {total} connections "
f"({len(path_connections)} path + {len(slab_connections)} slab); "
f"only the first {self.POOL_SIZE} unjoin gizmos are shown."
)
self._pool_cap_warned = True
for slot_idx, (other_elem, self_ct, other_ct) in enumerate(connections):
slot_idx = 0
self_is_fillet = tool.Parametric.is_fillet_corner_wall(elem)
for other_elem, self_ct, other_ct in path_connections:
if slot_idx >= self.POOL_SIZE:
break
other_obj = tool.Ifc.get_object(other_elem)
@@ -3955,20 +4150,224 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix
continue
seg_other = _wall_axis_world_segment_from_geom(other_obj, other_geom)
location = tool.Wall.path_connection_location_world(seg_self, self_ct, seg_other, other_ct)
is_locked = self_is_fillet or tool.Parametric.is_fillet_corner_wall(other_elem)
self._bind_unjoin_icon(
slot_idx, location + clearance, billboard_rot, elem, other_elem, other_obj, is_locked=is_locked
)
slot_idx += 1
for stack_idx, (slab_elem, _rel) in enumerate(slab_connections):
if slot_idx >= self.POOL_SIZE:
break
slab_obj = tool.Ifc.get_object(slab_elem)
if slab_obj is None:
continue
location = tool.Wall.wall_slab_connection_location_world(wall_obj, slab_obj)
if location is None:
continue
# Stack vertically so each slab gets a distinct clickable icon;
# hover-highlight then shows the user which slab they're about to
# disconnect from.
stacked = location + Vector((0.0, 0.0, stack_idx * self.SLAB_STACK_OFFSET_Z))
self._bind_unjoin_icon(slot_idx, stacked + clearance, billboard_rot, elem, slab_elem, slab_obj)
slot_idx += 1
def _bind_unjoin_icon(
self, slot_idx, location, billboard_rot, active_elem, partner_elem, partner_obj, *, is_locked=False
):
"""Place + bind one pool icon to a (active, partner) GlobalId pair.
Only the GlobalId properties are rewritten per frame; the operator
binding itself is the long-lived handle set up at setup() time. GlobalId
(not Blender object name) keeps the binding stable across renames, file
save/reload, and any sit-in-the-undo-stack interlude between dispatch
and execute. The partner Blender object is mirrored onto the icon for
its hover-outline draw, since the Gizmo API exposes
``target_set_operator`` but no symmetric reader.
``is_locked=True`` (fillet corner involvement) writes a muted color
instead of the active tone; the GUIDs still propagate so the bound
operator can surface a friendly INFO report on click."""
icon = self.unjoin_icons[slot_idx]
icon.matrix_basis = gizmo.billboarded_at(location, billboard_rot, scale=self.ICON_SCALE)
icon.hide = False
icon.color = self.LOCKED_COLOR if is_locked else self._default_unjoin_color
self.unjoin_op_props[slot_idx].element_a_guid = active_elem.GlobalId
self.unjoin_op_props[slot_idx].element_b_guid = partner_elem.GlobalId
icon.partner_obj = partner_obj
class GizmoSlabUnjoinWalls(bpy.types.GizmoGroup, gizmo.BillboardingGizmoGroupMixin):
"""Slab-side mirror of GizmoWallUnjoinSingle: when exactly one IfcSlab is
selected and at least one wall is clipped to its underside, surface an
unjoin icon at each connection point. The icons resolve at the same
world location as the wall-side gizmo (via the symmetric
tool.Wall.wall_slab_connection_location_world) so the same connection
has a single visual marker reachable from either selection.
Each visible icon dispatches bim.disconnect_elements with the slab +
wall GlobalIds, so a click removes the single rel under that icon and
re-clips the wall to whatever remaining slabs it's connected to."""
bl_idname = "OBJECT_GGT_bim_slab_unjoin_walls"
bl_label = "Slab Unjoin Walls Gizmo"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT"}
POOL_SIZE = 16
ICON_SCALE = 0.35
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
return _slab_connection_gizmo_poll_gate(context, require_editing=True)
def setup(self, context: bpy.types.Context) -> None:
default_color, highlight_color = self.get_decoration_colors()
self.unjoin_icons = []
self.unjoin_op_props = []
for _ in range(self.POOL_SIZE):
icon = self.setup_icon_gizmo(
"VIEW3D_GT_wall_link_toggle", default_color, highlight_color, "bim.disconnect_elements"
)
icon.hide = True
self.unjoin_icons.append(icon)
self.unjoin_op_props.append(icon.target_set_operator("bim.disconnect_elements"))
def position_gizmos(self, context: bpy.types.Context) -> None:
for icon in self.unjoin_icons:
icon.hide = True
selected = list(tool.Blender.get_selected_objects())
if len(selected) != 1:
return
slab_obj = selected[0]
slab_elem = tool.Ifc.get_entity(slab_obj)
if slab_elem is None:
return
billboard_rot = gizmo.get_billboard_rotation(context)
clearance = gizmo.top_down_clearance(context, billboard_rot)
connections = list(tool.Wall.iter_slab_wall_connections(slab_elem))
if len(connections) > self.POOL_SIZE and not getattr(self, "_pool_cap_warned", False):
print(
f"[bonsai] GizmoSlabUnjoinWalls: slab has {len(connections)} wall connections; "
f"only the first {self.POOL_SIZE} unjoin gizmos are shown."
)
self._pool_cap_warned = True
slot_idx = 0
for wall_elem, _rel in connections:
if slot_idx >= self.POOL_SIZE:
break
wall_obj = tool.Ifc.get_object(wall_elem)
if wall_obj is None:
continue
location = tool.Wall.wall_slab_connection_location_world(wall_obj, slab_obj)
if location is None:
continue
icon = self.unjoin_icons[slot_idx]
icon.matrix_basis = gizmo.billboarded_at(location + clearance, billboard_rot, scale=self.ICON_SCALE)
icon.hide = False
# Only the partner-GlobalId property is rewritten per frame; the operator
# binding itself is the long-lived handle set up at setup() time. GlobalId
# (not Blender object name) keeps the binding stable across renames, file
# save/reload, and any sit-in-the-undo-stack interlude between dispatch
# and execute.
self.unjoin_op_props[slot_idx].other_wall_guid = other_elem.GlobalId
# Mirror the partner reference onto the icon itself so its draw()
# can outline the partner on hover without a Gizmo-side getter on
# the bound operator (the API exposes target_set_operator with
# no symmetric reader).
icon.partner_obj = other_obj
self.unjoin_op_props[slot_idx].element_a_guid = slab_elem.GlobalId
self.unjoin_op_props[slot_idx].element_b_guid = wall_elem.GlobalId
icon.partner_obj = wall_obj
slot_idx += 1
class GizmoSlabEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
"""Pen / validate / cancel triad for slab disconnect-access mode.
Polls on a single IfcSlab with at least one wall clipped to its underside.
Pen routes through the universal ``bim.enable_editing_parametric``
dispatcher; finish + cancel both clear ``is_editing`` (no IFC mutation
the framework requires the triad to exist by name convention even for a
pure UI gate). ESC, the red-coloured cancel icon, mutual exclusion with
other active parametric edits, gizmo prefs gating all handled by the
base class."""
bl_idname = "OBJECT_GGT_bim_slab_edition"
bl_label = "Slab Editing Gizmo"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT"}
enable_editing_operator = "bim.enable_editing_slab"
finish_editing_operator = "bim.finish_editing_slab"
cancel_editing_operator = "bim.cancel_editing_slab"
cycle_type_operator = ""
props_getter = tool.Model.get_slab_props
gizmo_pref_name = "slab"
@classmethod
def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
return tool.Parametric.is_slab(element) and any(tool.Wall.iter_slab_wall_connections(element))
class GizmoPairDisconnect(bpy.types.GizmoGroup, gizmo.BillboardingGizmoGroupMixin):
"""Surfaces a disconnect icon when exactly 2 IFC elements are selected
and they share a supported rel currently the wall + slab pair joined
by an ``IfcRelConnectsElements(TOP)``. Click dispatches
``bim.disconnect_elements`` with both GlobalIds. For wall-wall pairs,
``GizmoWallJoinIntersection``'s unjoin icon already exposes the same
affordance via ``bim.unjoin_walls``."""
bl_idname = "OBJECT_GGT_bim_pair_disconnect"
bl_label = "Disconnect Pair Gizmo"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT"}
ICON_SCALE = 0.35
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
selected = list(tool.Blender.get_selected_objects())
if len(selected) != 2:
return False
if tool.Blender.Modifier.any_selected_is_array_child():
return False
elem_a = tool.Ifc.get_entity(selected[0])
elem_b = tool.Ifc.get_entity(selected[1])
if elem_a is None or elem_b is None:
return False
rels = tool.Connection.find_rels(elem_a, elem_b)
return any(kind == "element-top" for _, kind in rels)
def setup(self, context: bpy.types.Context) -> None:
default_color, highlight_color = self.get_decoration_colors()
self.disconnect_icon = self.setup_icon_gizmo(
"VIEW3D_GT_wall_link_toggle", default_color, highlight_color, "bim.disconnect_elements"
)
self.disconnect_icon.hide = True
self.disconnect_op = self.disconnect_icon.target_set_operator("bim.disconnect_elements")
def position_gizmos(self, context: bpy.types.Context) -> None:
self.disconnect_icon.hide = True
pair = _resolve_active_partner_pair(context)
if pair is None:
return
active, partner_obj, active_elem, partner_elem = pair
# Helper expects wall + slab regardless of which the user marked active.
if active_elem.is_a("IfcWall"):
wall_obj, slab_obj = active, partner_obj
elif partner_elem.is_a("IfcWall"):
wall_obj, slab_obj = partner_obj, active
else:
return
location = tool.Wall.wall_slab_connection_location_world(wall_obj, slab_obj)
if location is None:
return
billboard_rot = gizmo.get_billboard_rotation(context)
clearance = gizmo.top_down_clearance(context, billboard_rot)
self.disconnect_icon.matrix_basis = gizmo.billboarded_at(
location + clearance, billboard_rot, scale=self.ICON_SCALE
)
self.disconnect_icon.hide = False
self.disconnect_op.element_a_guid = active_elem.GlobalId
self.disconnect_op.element_b_guid = partner_elem.GlobalId
self.disconnect_icon.partner_obj = partner_obj
class GizmoWallFilletPreview(bpy.types.GizmoGroup, gizmo.BillboardingGizmoGroupMixin):
@@ -4468,7 +4867,7 @@ class WallGizmoPreviewDecorator(tool.Blender.ViewportDecorator):
],
color_rgb: tuple[float, float, float],
) -> None:
_fill_quads_alpha(context, quads, color_rgb, self.QUAD_ALPHA)
tool.Blender.draw_quads(context, quads, fill_color=(*color_rgb, self.QUAD_ALPHA))
@staticmethod
def _wall_floor_quad(mw: Matrix, x0: float, x1: float, y0: float, y1: float) -> tuple[
@@ -963,13 +963,19 @@ class EditObjectUI:
@classmethod
def draw_regen_operations(cls, row, ui_context):
if AuthoringData.data["is_regenable_element"]:
# ``AuthoringData.load`` flips ``is_loaded`` at entry as a recursion
# guard, so a partial load (any computation along the way raising)
# leaves the tail keys unset. ``.get()`` keeps the header draw alive
# until the underlying failure is investigated.
if AuthoringData.data.get("is_regenable_element"):
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row
add_layout_hotkey_operator(row, "Regen", "S_G", "Recalculate Element Geometry", ui_context)
if PortData.data["total_ports"] > 0:
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row
add_layout_hotkey_operator(row, "Regen", "S_G", bpy.ops.bim.regenerate_distribution_element.__doc__, ui_context)
add_layout_hotkey_operator(
row, "Regen", "S_G", bpy.ops.bim.regenerate_distribution_element.__doc__, ui_context
)
@classmethod
def draw_void(cls, context, row):
@@ -1315,7 +1321,7 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
bpy.ops.bim.recalculate_profile()
elif self.active_class in ("IfcWindow", "IfcWindowStandardCase", "IfcDoor", "IfcDoorStandardCase"):
bpy.ops.bim.recalculate_fill()
elif self.active_class in ("IfcSpace"):
elif self.active_class in ("IfcSpace",):
bpy.ops.bim.generate_space()
def hotkey_S_M(self):
+7 -64
View File
@@ -20,7 +20,6 @@ import blf
import bpy
import gpu
import ifcopenshell.util.element
from bpy.types import SpaceView3D
from bpy_extras import view3d_utils
from gpu_extras.batch import batch_for_shader
from mathutils import Vector
@@ -28,12 +27,6 @@ from mathutils import Vector
import bonsai.tool as tool
def transparent_color(color, alpha=0.1):
color = [i for i in color]
color[3] = alpha
return color
def create_bounding_box(objs):
# Initialize the bounding box coordinates
min_x, min_y, min_z = float("inf"), float("inf"), float("inf")
@@ -79,26 +72,8 @@ def create_bounding_box(objs):
return indices, edges
class NestDecorator:
is_installed = False
handlers = []
@classmethod
def install(cls, context):
if cls.is_installed:
cls.uninstall()
handler = cls()
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_nest, (context,), "WINDOW", "POST_VIEW"))
cls.is_installed = True
@classmethod
def uninstall(cls):
for handler in cls.handlers:
try:
SpaceView3D.draw_handler_remove(handler, "WINDOW")
except ValueError:
pass
cls.is_installed = False
class NestDecorator(tool.Blender.ViewportDecorator):
draw_method = "draw_nest"
def dotted_line_shader(self):
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface")
@@ -154,14 +129,6 @@ class NestDecorator:
shader.uniform_float("u_Scale", 25)
batch.draw(shader)
def draw_batch(self, shader_type, content_pos, color, indices=None):
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
return
shader = self.line_shader if shader_type == "LINES" else self.shader
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
shader.uniform_float("color", color)
batch.draw(shader)
def draw_nest(self, context: bpy.types.Context) -> None:
props = tool.Nest.get_nest_props()
if props.in_nest_mode:
@@ -226,35 +193,11 @@ class NestDecorator:
self.draw_custom_batch(line, decorator_color_unselected)
class NestModeDecorator:
is_installed = False
handlers = []
@classmethod
def install(cls, context):
if cls.is_installed:
cls.uninstall()
handler = cls()
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_nest_name, (context,), "WINDOW", "POST_PIXEL"))
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_nest_empty, (context,), "WINDOW", "POST_VIEW"))
cls.is_installed = True
@classmethod
def uninstall(cls):
for handler in cls.handlers:
try:
SpaceView3D.draw_handler_remove(handler, "WINDOW")
except ValueError:
pass
cls.is_installed = False
def draw_batch(self, shader_type, content_pos, color, indices=None):
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
return
shader = self.line_shader if shader_type == "LINES" else self.shader
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
shader.uniform_float("color", color)
batch.draw(shader)
class NestModeDecorator(tool.Blender.ViewportDecorator):
draw_methods = (
("draw_nest_name", "POST_PIXEL"),
("draw_nest_empty", "POST_VIEW"),
)
def draw_nest_name(self, context):
if context.mode == "EDIT_MESH":
@@ -21,6 +21,7 @@ import bpy
from . import operator, prop, ui
classes = (
operator.AddIfcPatchPreset,
operator.ExecuteIfcPatch,
operator.ExtractSelectedElements,
operator.RunMigratePatch,
@@ -28,6 +29,7 @@ classes = (
operator.SelectIfcPatchOutput,
operator.UpdateIfcPatchArguments,
prop.BIMPatchProperties,
ui.BIM_MT_ifc_patch_presets,
ui.BIM_PT_patch,
)
@@ -23,6 +23,7 @@ from typing import TYPE_CHECKING, cast
import bpy
import ifcopenshell
import ifcpatch
from bl_operators.presets import AddPresetBase
from bpy_extras.io_utils import ExportHelper, ImportHelper
import bonsai.bim.handler
@@ -77,6 +78,27 @@ class ExecuteIfcPatch(bpy.types.Operator):
return False
return True
def invoke(self, context, event):
# Migrating IFC4 → IFC2X3 is lossy (enum drops, IFC4-only classes
# become IfcBuildingElementProxy, tessellated meshes get rebuilt as
# IfcFacetedBrep). Confirm before running so the user knows.
if tool.Patch.migration_is_lossy_downgrade():
return context.window_manager.invoke_props_dialog(self, width=480)
return self.execute(context)
def draw(self, context):
layout = self.layout
layout.label(text="Downgrading to IFC2X3 is lossy.", icon="ERROR")
column = layout.column(align=True)
column.label(text="Geometry will be preserved as faithfully as possible:")
column.label(text="• IfcIndexedPolyCurve → IfcPolyline (arcs approximated by chords)")
column.label(text="• IfcPolygonalFaceSet / IfcTriangulatedFaceSet → IfcFacetedBrep")
column.separator()
column.label(text="The following information is lost:")
column.label(text="• IFC4-only classes (IfcLamp, IfcPipeSegment, …) → IfcBuildingElementProxy")
column.label(text="• PredefinedType enum values absent from IFC2X3 are dropped")
column.label(text=" (original class + enum saved as ObjectType, e.g. 'IfcLamp/COMPACTFLUORESCENT')")
def execute(self, context):
props = tool.Patch.get_patch_props()
recipe_name = props.ifc_patch_recipes
@@ -224,3 +246,38 @@ class ExtractSelectedElements(bpy.types.Operator):
query = tool.Search.get_query_for_selected_elements()
props.ifc_patch_args_attr[0].string_value = query
return {"FINISHED"}
class AddIfcPatchPreset(AddPresetBase, bpy.types.Operator):
"""Save / remove ifc-patch argument presets, scoped per recipe.
Presets live in the standard Blender preset directory under
``bonsai/ifc_patch/<recipe>/`` so a preset created for ``ExtractElements``
does not pollute the preset list for ``Migrate``. Persistence across files
and sessions is inherited from Blender's preset system."""
bl_idname = "bim.add_ifc_patch_preset"
bl_label = "Add IFC Patch Preset"
preset_menu = "BIM_MT_ifc_patch_presets"
preset_defines = ["props = bpy.context.scene.BIMPatchProperties"]
@property
def preset_subdir(self) -> str:
return tool.Patch.get_preset_subdir()
@property
def preset_values(self) -> list[str]:
# `Attribute.get_value_name()` returns the storage field for the
# argument's data_type (string_value, bool_value, …). For file
# arguments it returns the wrapping PointerProperty (`filepath_value`)
# — the scalar path the preset needs is `.single_file` on that.
props = tool.Patch.get_patch_props()
values = []
for i, arg in enumerate(props.ifc_patch_args_attr):
field = arg.get_value_name()
if not field:
continue
if arg.data_type == "file":
field = f"{field}.single_file"
values.append(f"props.ifc_patch_args_attr[{i}].{field}")
return values
@@ -71,6 +71,15 @@ def get_ifcpatch_recipes(self: "BIMPatchProperties", context: bpy.types.Context)
def update_ifc_patch_recipe(self: "BIMPatchProperties", context: bpy.types.Context) -> None:
bpy.ops.bim.update_ifc_patch_arguments(recipe=self.ifc_patch_recipes)
# Blender's script.execute_preset mutates the menu class's bl_label to
# the loaded preset's display name (used as a "currently selected"
# indicator). The label persists across recipe changes — making the new
# recipe's menu falsely show the previous recipe's preset name. Reset
# the label to the menu's canonical title so it always matches the
# active recipe's preset list.
menu_cls = getattr(bpy.types, "BIM_MT_ifc_patch_presets", None)
if menu_cls is not None:
menu_cls.bl_label = "IFC Patch Presets"
class BIMPatchProperties(PropertyGroup):
+19
View File
@@ -29,6 +29,20 @@ if TYPE_CHECKING:
from bonsai.bim.prop import Attribute
class BIM_MT_ifc_patch_presets(bpy.types.Menu):
"""Lists ifc-patch presets for the currently selected recipe.
``preset_subdir`` is resolved per draw so switching recipes swaps the
preset list without re-registering the menu."""
bl_label = "IFC Patch Presets"
preset_operator = "script.execute_preset"
def draw(self, context: bpy.types.Context) -> None:
self.preset_subdir = tool.Patch.get_preset_subdir()
bpy.types.Menu.draw_preset(self, context)
class BIM_PT_patch(bpy.types.Panel):
bl_label = "Patch"
bl_idname = "BIM_PT_patch"
@@ -66,6 +80,11 @@ class BIM_PT_patch(bpy.types.Panel):
row.operator("bim.patch_query_from_selected", text="", icon="EYEDROPPER")
if props.ifc_patch_args_attr:
preset_row = layout.row(heading="Preset", align=True)
preset_row.menu("BIM_MT_ifc_patch_presets", text=BIM_MT_ifc_patch_presets.bl_label)
preset_row.operator("bim.add_ifc_patch_preset", text="", icon="ADD")
preset_row.operator("bim.add_ifc_patch_preset", text="", icon="REMOVE").remove_active = True
draw_callback = draw_callback_ if props.ifc_patch_recipes == "ExtractElements" else None
draw_attributes(props.ifc_patch_args_attr, layout, callback=draw_callback)
@@ -30,7 +30,9 @@ classes = (
operator.BIM_FH_import_ifc,
operator.BIM_OT_apply_pending_opening_cuts,
operator.BIM_OT_dismiss_multi_instance_warning,
operator.BIM_OT_dismiss_pending_array_repair,
operator.BIM_OT_dismiss_pending_opening_cuts,
operator.BIM_OT_select_pending_array_repair,
operator.BIM_OT_select_pending_opening_cuts,
operator.BIM_OT_load_clipping_planes,
operator.BIM_OT_save_clipping_planes,
@@ -86,6 +88,7 @@ classes = (
prop.FilterCategory,
prop.Link,
prop.EditedObj,
prop.PendingArrayRepair,
prop.PendingOpeningRecut,
prop.BIMProjectProperties,
prop.MeasureToolSettings,
+2 -2
View File
@@ -162,8 +162,8 @@ class ProjectLibraryData:
library_file = IfcStore.library_file
if library_file is None or library_file.schema == "IFC2X3":
return results
project = library_file.by_type("IfcProject")[0]
results.append((str(project.id()), f"IfcProject {project.Name or 'Unnamed'}", project.Description or ""))
root = tool.Project.get_root_context(library_file)
results.append((str(root.id()), f"{root.is_a()} {root.Name or 'Unnamed'}", root.Description or ""))
for library_id, data in cls.data["project_libraries"].items():
results.append((str(library_id), data["Name"] or "Unnamed", data["Description"] or ""))
return results
@@ -42,12 +42,6 @@ def toggle_decorations_on_load(*args):
# as queried object is linked from separate .blend file.
def transparent_color(color, alpha=0.1):
color = [i for i in color]
color[3] = alpha
return color
class ProjectDecorator:
installed = None
@@ -80,11 +74,6 @@ class ProjectDecorator:
unselected_elements_color = self.addon_prefs.decorator_color_unselected
special_elements_color = self.addon_prefs.decorator_color_special
def transparent_color(color, alpha=0.1):
color = [i for i in color]
color[3] = alpha
return color
gpu.state.point_size_set(6)
gpu.state.blend_set("ALPHA")
@@ -110,7 +99,9 @@ class ProjectDecorator:
if geom.selected_edges:
self.draw_batch("LINES", selected_vertices, selected_elements_color, geom.selected_edges)
self.draw_batch("TRIS", selected_vertices, transparent_color(selected_elements_color), geom.selected_tris)
self.draw_batch(
"TRIS", selected_vertices, tool.Blender.transparent_color(selected_elements_color), geom.selected_tris
)
class ClippingPlaneDecorator:
@@ -145,11 +136,6 @@ class ClippingPlaneDecorator:
unselected_elements_color = self.addon_prefs.decorator_color_unselected
special_elements_color = self.addon_prefs.decorator_color_special
def transparent_color(color, alpha=0.1):
color = [i for i in color]
color[3] = alpha
return color
gpu.state.point_size_set(6)
gpu.state.blend_set("ALPHA")
@@ -210,37 +196,21 @@ class ClippingPlaneDecorator:
if unselected_edges:
self.draw_batch("LINES", unselected_vertices, special_elements_color, unselected_edges)
self.draw_batch("TRIS", unselected_vertices, transparent_color(special_elements_color), unselected_tris)
self.draw_batch(
"TRIS", unselected_vertices, tool.Blender.transparent_color(special_elements_color), unselected_tris
)
if selected_edges:
self.draw_batch("LINES", selected_vertices, selected_elements_color, selected_edges)
self.draw_batch("TRIS", selected_vertices, transparent_color(selected_elements_color), selected_tris)
self.draw_batch(
"TRIS", selected_vertices, tool.Blender.transparent_color(selected_elements_color), selected_tris
)
class MeasureDecorator:
is_installed = False
handlers = []
@classmethod
def install(cls, context):
if cls.is_installed:
cls.uninstall()
handler = cls()
cls.handlers.append(
SpaceView3D.draw_handler_add(handler.draw_measurements_text, (context,), "WINDOW", "POST_PIXEL")
)
cls.handlers.append(
SpaceView3D.draw_handler_add(handler.draw_measurements_poly, (context,), "WINDOW", "POST_VIEW")
)
cls.is_installed = True
@classmethod
def uninstall(cls):
for handler in cls.handlers:
try:
SpaceView3D.draw_handler_remove(handler, "WINDOW")
except ValueError:
pass
cls.is_installed = False
class MeasureDecorator(tool.Blender.ViewportDecorator):
draw_methods = (
("draw_measurements_text", "POST_PIXEL"),
("draw_measurements_poly", "POST_VIEW"),
)
def draw_measurements_text(self, context):
PolylineDecorator().select_and_draw_measurements_text(context)
@@ -281,9 +281,9 @@ class RefreshLibrary(bpy.types.Operator):
elements = {e for e in elements if not tool.Project.is_element_assigned_to_project_library(e, rels)}
self.props.add_library_project_library("Unassigned", len(elements), 0, False)
ifc_project = library_file.by_type("IfcProject")[0]
root_context = tool.Project.get_root_context(library_file)
hierarchy = tool.Project.get_project_hierarchy(library_file)
tool.Project.load_project_libraries_to_ui(ifc_project, hierarchy)
tool.Project.load_project_libraries_to_ui(root_context, hierarchy)
return {"FINISHED"}
@@ -763,7 +763,10 @@ class EditProjectLibrary(bpy.types.Operator):
previous_parent_library = tool.Project.get_parent_library(project_library)
new_parent_library = library_file.by_id(int(props.parent_library))
if previous_parent_library != new_parent_library:
if previous_parent_library.is_a("IfcProject"):
if previous_parent_library is None:
# Edited library was a root in a library-only file; nest it under the new parent.
ifcopenshell.api.nest.assign_object(library_file, [project_library], new_parent_library)
elif previous_parent_library.is_a("IfcProject"):
# Then new one is IfcProjectLibrary.
ifcopenshell.api.nest.assign_object(library_file, [project_library], new_parent_library)
else: # Previous is IfcProjectLibrary.
@@ -804,9 +807,12 @@ class AddProjectLibrary(bpy.types.Operator):
props = tool.Project.get_project_props()
library_file = IfcStore.library_file
assert library_file
project = library_file.by_type("IfcProject")[0]
root_context = tool.Project.get_root_context(library_file)
project_library = ifcopenshell.api.root.create_entity(library_file, "IfcProjectLibrary")
ifcopenshell.api.project.assign_declaration(library_file, [project_library], project)
if root_context.is_a("IfcProject"):
ifcopenshell.api.project.assign_declaration(library_file, [project_library], root_context)
else:
ifcopenshell.api.nest.assign_object(library_file, [project_library], root_context)
ProjectLibraryData.load() # Update enum.
props.selected_project_library = str(project_library.id())
props.is_editing_project_library = True
@@ -1113,6 +1119,14 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
f"Error loading IFC file from filepath '{filepath}'. See logs above in the system console for the details.",
)
return {"CANCELLED"}
if not tool.Ifc.get().by_type("IfcProject"):
self.report(
{"ERROR"},
"This file contains no IfcProject. It is likely an IFC project library — "
"load it via Project Setup → Project Library → Select Library File instead.",
)
IfcStore.purge()
return {"CANCELLED"}
props = tool.Project.get_project_props()
props.is_loading = True
props.total_elements = len(tool.Ifc.get().by_type("IfcElement"))
@@ -1236,6 +1250,17 @@ class LoadProjectElements(bpy.types.Operator):
f"Apply manually from the Project panel.",
)
props.pending_array_repair.clear()
if ifc_importer.broken_arrays:
for element in ifc_importer.broken_arrays:
item = props.pending_array_repair.add()
item.ifc_definition_id = element.id()
self.report(
{"WARNING"},
f"{len(ifc_importer.broken_arrays)} array parent(s) reference missing child GUIDs. "
f"Inspect from the Project panel.",
)
tool.Project.load_default_thumbnails()
tool.Project.set_default_context()
tool.Project.set_default_modeling_dimensions()
@@ -1397,6 +1422,7 @@ class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator):
new.ifc_definition_id = reference.id()
new.name = filepath
new.filepath = filepath
new.query = self.query
bpy.ops.bim.load_link(link_index=-1, use_cache=self.use_cache, query=self.query)
@@ -1467,6 +1493,10 @@ class LoadLink(bpy.types.Operator, tool.Ifc.Operator):
def _execute(self, context):
self.link = tool.Project.get_project_props().links[self.link_index]
# Fall back to the Link's stored query so callers that omit it
# still replay the filter the link was created with.
if not self.query and self.link.query:
self.query = self.link.query
filepath = Path(tool.Ifc.resolve_uri(self.link.filepath))
if not filepath.exists():
self.report({"ERROR"}, f"File does not exist: '{filepath}'")
@@ -1633,13 +1663,36 @@ class ReloadLink(bpy.types.Operator):
bl_description = "Reload the selected file"
link_index: bpy.props.IntProperty(name="Link Index")
query: bpy.props.StringProperty(
name="Query",
description=(
"Custom selector query to use to load element from a linked model. E.g. 'IfcElement'.\n\n"
"Default query - IfcElement, but excluding IfcProxy, IfcSpatialStructureElement, IfcSpatialElement, IfcFeatureElement."
),
)
if TYPE_CHECKING:
link_index: int
query: str
def invoke(self, context, event):
link = tool.Project.get_project_props().links[self.link_index]
self.query = link.query
return context.window_manager.invoke_props_dialog(self)
def draw(self, context):
assert self.layout
self.layout.prop(self, "query", placeholder="IfcElement")
def execute(self, context):
link = tool.Project.get_project_props().links[self.link_index]
# An unset query means the operator was called without the dialog
# (e.g. from a script) - preserve the link's stored query instead
# of overwriting it with the empty default.
if self.properties.is_property_set("query"):
link.query = self.query
bpy.ops.bim.unload_link(link_index=self.link_index)
return bpy.ops.bim.load_link(link_index=self.link_index, use_cache=False) or {"FINISHED"}
return bpy.ops.bim.load_link(link_index=self.link_index, use_cache=False, query=link.query) or {"FINISHED"}
class ToggleLinkSelectability(bpy.types.Operator):
@@ -3538,3 +3591,44 @@ class BIM_OT_select_pending_opening_cuts(bpy.types.Operator):
tool.Blender.set_objects_selection(context, active_object=objects[0], selected_objects=objects)
self.report({"INFO"}, f"Selected {len(objects)} element(s).")
return {"FINISHED"}
class BIM_OT_select_pending_array_repair(bpy.types.Operator):
bl_idname = "bim.select_pending_array_repair"
bl_label = "Select Array Parents With Missing Children"
bl_description = "Select the Blender objects of array parents whose BBIM_Array.Data references children that don't resolve in the file."
bl_options = {"REGISTER", "UNDO"}
def execute(self, context: bpy.types.Context) -> set[str]:
ifc_file = tool.Ifc.get()
if ifc_file is None:
self.report({"INFO"}, "No IFC file loaded.")
return {"CANCELLED"}
objects: list[bpy.types.Object] = []
for item in tool.Project.get_project_props().pending_array_repair:
try:
element = ifc_file.by_id(item.ifc_definition_id)
except RuntimeError:
continue
obj = tool.Ifc.get_object(element)
if obj is not None:
objects.append(obj)
if not objects:
self.report({"INFO"}, "No matching Blender objects found for the pending list.")
return {"CANCELLED"}
tool.Blender.set_objects_selection(context, active_object=objects[0], selected_objects=objects)
self.report({"INFO"}, f"Selected {len(objects)} array parent(s).")
return {"FINISHED"}
class BIM_OT_dismiss_pending_array_repair(bpy.types.Operator):
bl_idname = "bim.dismiss_pending_array_repair"
bl_label = "Dismiss Pending Array Repair"
bl_description = (
"Clear the pending array-repair list without acting on it. The underlying BBIM_Array.Data stays unchanged."
)
bl_options = {"REGISTER", "UNDO"}
def execute(self, context: bpy.types.Context) -> set[str]:
tool.Project.get_project_props().pending_array_repair.clear()
return {"FINISHED"}
+21 -1
View File
@@ -98,7 +98,8 @@ def is_editing_project_library_update(self: "BIMProjectProperties", context: bpy
project_library = library_file.by_id(int(self.selected_project_library))
self.project_library_attributes.clear()
bonsai.bim.helper.import_attributes(project_library, self.project_library_attributes)
self.parent_library = str(tool.Project.get_parent_library(project_library).id())
if parent_library := tool.Project.get_parent_library(project_library):
self.parent_library = str(parent_library.id())
ProjectLibraryData.load() # Show edit icon in enum.
return
@@ -259,6 +260,11 @@ class Link(PropertyGroup):
description="STEP ID of the IfcDocumentReference when linked to a parent IFC project. Zero when no parent IFC exists",
default=0,
)
query: StringProperty(
name="Query",
description="Selector query used to filter elements when loading the linked model",
default="",
)
if TYPE_CHECKING:
name: str
@@ -274,6 +280,7 @@ class Link(PropertyGroup):
include_in_drawings: bool
empty_handle: Union[bpy.types.Object, None]
ifc_definition_id: int
query: str
class EditedObj(PropertyGroup):
@@ -306,6 +313,17 @@ class PendingOpeningRecut(PropertyGroup):
ifc_definition_id: int
class PendingArrayRepair(PropertyGroup):
"""One array parent whose ``BBIM_Array.Data`` references at least one
child GUID that does not resolve in the current IFC file. The user can
select these parents from the Project panel banner to inspect them."""
ifc_definition_id: IntProperty(name="IFC Definition ID")
if TYPE_CHECKING:
ifc_definition_id: int
class BIMProjectProperties(PropertyGroup):
is_editing: BoolProperty(name="Is Editing", default=False)
is_loading: BoolProperty(name="Is Loading", default=False)
@@ -364,6 +382,7 @@ class BIMProjectProperties(PropertyGroup):
description="Maxium number of openings that object can have. If object has more openings, it will be loaded without openings",
)
pending_opening_recut: CollectionProperty(name="Pending Opening Recut", type=PendingOpeningRecut)
pending_array_repair: CollectionProperty(name="Pending Array Repair", type=PendingArrayRepair)
style_limit: IntProperty(
name="Style Limit",
default=300,
@@ -529,6 +548,7 @@ class BIMProjectProperties(PropertyGroup):
angular_tolerance: float
void_limit: int
pending_opening_recut: bpy.types.bpy_prop_collection_idprop[PendingOpeningRecut]
pending_array_repair: bpy.types.bpy_prop_collection_idprop[PendingArrayRepair]
style_limit: int
distance_limit: float
false_origin_mode: Literal["AUTOMATIC", "MANUAL", "DISABLED"]
@@ -18,43 +18,17 @@
import blf
import gpu
from bpy.types import SpaceView3D
from bpy_extras.view3d_utils import location_3d_to_region_2d
from gpu_extras.batch import batch_for_shader
from mathutils import Vector
import bonsai.tool as tool
class GridDecorator:
is_installed = False
handlers = []
@classmethod
def install(cls, context):
if cls.is_installed:
cls.uninstall()
handler = cls()
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_text, (context,), "WINDOW", "POST_PIXEL"))
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw, (context,), "WINDOW", "POST_VIEW"))
cls.is_installed = True
@classmethod
def uninstall(cls):
for handler in cls.handlers:
try:
SpaceView3D.draw_handler_remove(handler, "WINDOW")
except ValueError:
pass
cls.is_installed = False
def draw_batch(self, shader_type, content_pos, color, indices=None):
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
return
shader = self.line_shader if shader_type == "LINES" else self.shader
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
shader.uniform_float("color", color)
batch.draw(shader)
class GridDecorator(tool.Blender.ViewportDecorator):
draw_methods = (
("draw_text", "POST_PIXEL"),
("draw", "POST_VIEW"),
)
def draw_text(self, context):
if not tool.Blender.is_addon_enabled():
@@ -31,11 +31,17 @@ import bonsai.tool as tool
from bonsai.bim.module.structural.load_decoration_data import ShaderInfo
class LoadsDecorator:
class LoadsDecorator(tool.Blender.ViewportDecorator):
"""Decorator to show structural loads in 3D"""
is_installed = False
handlers = []
# draw_methods exists to satisfy ViewportDecorator.__init_subclass__'s
# method-existence check; the override install below is what actually
# registers handlers (the POST_VIEW binding passes no context arg, which
# the base's generic install cannot express).
draw_methods = (
("draw_load_values", "POST_PIXEL"),
("__call__", "POST_VIEW"),
)
decoration_data = None
text_info = []
shader_info = []
@@ -54,15 +60,6 @@ class LoadsDecorator:
cls.update()
cls.is_installed = True
@classmethod
def uninstall(cls) -> None:
for handler in cls.handlers:
try:
SpaceView3D.draw_handler_remove(handler, "WINDOW")
except ValueError:
pass
cls.is_installed = False
@classmethod
def update(cls) -> None:
cls.decoration_data.update()
+31 -24
View File
@@ -744,7 +744,7 @@ class EnableEditingSurfaceStyle(bpy.types.Operator):
if self.ifc_class == "IfcSurfaceStyleLighting":
def callback(attribute_name: str, _: object, data: dict[str, Any]) -> None:
assert attributes
assert attributes is not None
color = attributes.add()
assert isinstance(color, ColourRgb)
color.name = attribute_name
@@ -782,34 +782,40 @@ class EditSurfaceStyle(bpy.types.Operator, tool.Ifc.Operator):
def _execute(self, context):
self.props = tool.Style.get_style_props()
self.style = tool.Ifc.get().by_id(self.props.is_editing_style)
prev_update_graph = self.props.update_graph
self.props["update_graph"] = False
style_elements = tool.Style.get_style_elements(self.style)
# NOTE: currently this operator is used to edit existing (and only existing) IfcSurfaceStyles
# or new or existing IfcSurfaceStyle components (shading, etc)
# which is kind of confusing.
if self.props.is_editing_class == "IfcSurfaceStyle":
self.surface_style = self.style
else:
self.surface_style = style_elements.get(self.props.is_editing_class, None)
self.shading_style = style_elements.get("IfcSurfaceStyleShading", None)
self.rendering_style = style_elements.get("IfcSurfaceStyleRendering", None)
self.texture_style = style_elements.get("IfcSurfaceStyleWithTextures", None)
try:
style_elements = tool.Style.get_style_elements(self.style)
if self.surface_style:
result = self.edit_existing_style()
else:
result = self.add_new_style()
# NOTE: currently this operator is used to edit existing (and only existing) IfcSurfaceStyles
# or new or existing IfcSurfaceStyle components (shading, etc)
# which is kind of confusing.
if self.props.is_editing_class == "IfcSurfaceStyle":
self.surface_style = self.style
else:
self.surface_style = style_elements.get(self.props.is_editing_class, None)
self.shading_style = style_elements.get("IfcSurfaceStyleShading", None)
self.rendering_style = style_elements.get("IfcSurfaceStyleRendering", None)
self.texture_style = style_elements.get("IfcSurfaceStyleWithTextures", None)
if result:
return result
if self.surface_style:
result = self.edit_existing_style()
else:
result = self.add_new_style()
tool.Style.disable_editing()
core.load_styles(tool.Style, style_type=self.props.style_type)
if result:
return result
# restore selected style type
material = tool.Ifc.get_object(self.style)
msprops = tool.Style.get_material_style_props(material)
msprops.active_style_type = msprops.active_style_type
tool.Style.disable_editing()
core.load_styles(tool.Style, style_type=self.props.style_type)
# restore selected style type
material = tool.Ifc.get_object(self.style)
msprops = tool.Style.get_material_style_props(material)
msprops.active_style_type = msprops.active_style_type
finally:
self.props["update_graph"] = prev_update_graph
def edit_existing_style(self) -> None:
ifc_file = tool.Ifc.get()
@@ -1231,4 +1237,5 @@ class RemoveSurfaceStyle(bpy.types.Operator, tool.Ifc.Operator):
surface_style = tool.Style.get_style_elements(style)[props.is_editing_class]
ifcopenshell.api.style.remove_surface_style(ifc_file, surface_style)
core.disable_editing_style(tool.Style)
core.load_styles(tool.Style, style_type=props.style_type)
return {"FINISHED"}
@@ -185,6 +185,13 @@ class ColourRgb(PropertyGroup):
# to fit blender.bim.helper.draw_attribute
is_optional = False
special_type = ""
data_type = ""
ifc_class = ""
use_explorer_ui = False
@property
def display_name(self):
return self.name
def get_value_name(self, *args, **kwargs):
return "color_value"
+50 -3
View File
@@ -176,8 +176,30 @@ class BIM_PT_styles(Panel):
row.prop(self.props, "reflectance_method")
if self.props.reflectance_method not in ("PHYSICAL", "NOTDEFINED", "FLAT"):
self.layout.label(text="Supported reflectance methods are:")
self.layout.label(text="PHYSICAL / NOTDEFINED / FLAT")
self.layout.label(
text=f"{self.props.reflectance_method} will be skipped: only PHYSICAL / NOTDEFINED / FLAT are supported",
icon="ERROR",
)
elif self.props.reflectance_method in ("PHYSICAL", "NOTDEFINED"):
if self.props.specular_colour_class == "IfcColourRgb":
self.layout.label(
text="Metallic color is IFC-only in PHYSICAL/NOTDEFINED and does not affect Blender appearance",
icon="ERROR",
)
elif self.props.reflectance_method == "FLAT":
if self.props.diffuse_colour_class == "IfcNormalisedRatioMeasure":
self.layout.label(
text="Emissive ratio is IFC-only in FLAT Reflectance method and does not affect Blender appearance",
icon="ERROR",
)
self.layout.label(
text="Specular value is IFC-only in FLAT Reflectance method and does not affect Blender appearance",
icon="ERROR",
)
self.layout.label(
text="Highlight value is IFC-only in FLAT Reflectance method and does not affect Blender appearance",
icon="ERROR",
)
row = self.layout.row(align=True)
row.label(text="Emissive" if self.props.reflectance_method == "FLAT" else "Diffuse")
@@ -232,6 +254,8 @@ class BIM_PT_styles(Panel):
row.operator("bim.add_surface_texture", text="", icon="ADD")
if textures:
self.layout.prop(self.props, "uv_mode")
if self.props.uv_mode in ("Generated", "Camera"):
self.layout.label(text="Not available in SOLID Mode", icon="INFO")
for i, texture in enumerate(textures):
split = self.layout.split(factor=0.30, align=True)
@@ -244,6 +268,22 @@ class BIM_PT_styles(Panel):
op_clear = row.operator("bim.remove_texture_map", text="", icon="X")
op_path.texture_map_index = op_clear.texture_map_index = i
reflectance = self.props.reflectance_method
mode = texture.mode
if reflectance == "FLAT":
if mode != "EMISSIVE":
self.layout.label(
text=f"{mode} will be skipped: only EMISSIVE is supported for Render Reflectance FLAT",
icon="ERROR",
)
elif reflectance in ("PHYSICAL", "NOTDEFINED"):
_SUPPORTED = {"DIFFUSE", "NORMAL", "METALLICROUGHNESS", "EMISSIVE", "OCCLUSION"}
if mode not in _SUPPORTED:
self.layout.label(
text=f"{mode} will be skipped: not supported for Render Reflectance PHYSICAL/NOTDEFINED",
icon="ERROR",
)
def draw_externally_defined_surface_style(self):
row = self.layout.row()
op = row.operator("bim.browse_external_style", icon="APPEND_BLEND", text="Append From Blend File")
@@ -252,10 +292,17 @@ class BIM_PT_styles(Panel):
bonsai.bim.helper.draw_attributes(self.props.external_style_attributes, self.layout, enable_search=True)
def draw_refraction_surface_style(self):
self.layout.label(
text="Refraction values are IFC-only and do not affect Blender surface appearance",
icon="ERROR",
)
bonsai.bim.helper.draw_attributes(self.props.refraction_style_attributes, self.layout, enable_search=True)
row = self.layout.row(align=True)
def draw_lighting_surface_style(self):
self.layout.label(
text="Lighting values are IFC-only and do not affect Blender surface appearance",
icon="ERROR",
)
bonsai.bim.helper.draw_attributes(self.props.lighting_style_colours, self.layout)
def draw_edit_ui(self, edit_label: str):
@@ -31,12 +31,6 @@ ERROR_ELEMENTS_COLOR = (1, 0.2, 0.322, 1) # RED
UNSPECIAL_ELEMENT_COLOR = (0.2, 0.2, 0.2, 1) # GREY
def transparent_color(color, alpha=0.1):
color = [i for i in color]
color[3] = alpha
return color
@persistent
def toggle_decorations_on_load(*args):
props = tool.System.get_system_props()
@@ -80,7 +74,7 @@ class SystemDecorator:
def draw_faces(self, bm, vertices_coords):
"""Submit a non-mutating beauty-triangulated TRIS batch over ``bm``'s faces."""
faces_color = transparent_color(self.addon_prefs.decorator_color_special)
faces_color = tool.Blender.transparent_color(self.addon_prefs.decorator_color_special)
tool.Blender.draw_bmesh_face_tris(bm, vertices_coords, faces_color, self.draw_batch)
def __call__(self, context, get_custom_bmesh=None, draw_faces=False, exit_edit_mode_callback=None):
@@ -128,13 +122,15 @@ class SystemDecorator:
self.shader = gpu.shader.from_builtin("UNIFORM_COLOR")
self.shader.bind()
self.draw_batch("LINES", all_vertices, transparent_color(unselected_elements_color), unselected_edges)
self.draw_batch(
"LINES", all_vertices, tool.Blender.transparent_color(unselected_elements_color), unselected_edges
)
self.draw_batch("LINES", all_vertices, selected_elements_color, selected_edges)
self.draw_batch("LINES", all_vertices, UNSPECIAL_ELEMENT_COLOR, arc_edges)
self.draw_batch("LINES", all_vertices, special_elements_color, preview_edges)
self.draw_batch("LINES", all_vertices, special_elements_color, roof_angle_edges)
self.draw_batch("POINTS", unselected_vertices, transparent_color(unselected_elements_color, 0.5))
self.draw_batch("POINTS", unselected_vertices, tool.Blender.transparent_color(unselected_elements_color, 0.5))
self.draw_batch("POINTS", error_vertices, ERROR_ELEMENTS_COLOR)
self.draw_batch("POINTS", special_vertices, special_elements_color)
self.draw_batch("POINTS", selected_vertices, selected_elements_color)
+32 -4
View File
@@ -65,10 +65,28 @@ class AssignType(bpy.types.Operator, tool.Ifc.Operator):
if active_drawing:
active_target_view = tool.Drawing.get_drawing_target_view(active_drawing)
compatible: list[tuple[bpy.types.Object, ifcopenshell.entity_instance]] = []
skipped_classes: set[str] = set()
for obj in related_objects:
element = tool.Ifc.get_entity(obj)
if not element or not element.is_a("IfcObject"):
continue
if not tool.Type.is_relating_type_compatible(element, relating_type):
skipped_classes.add(element.is_a())
continue
compatible.append((obj, element))
if skipped_classes:
self.report(
{"WARNING"},
f"Skipped {', '.join(sorted(skipped_classes))}: not a valid occurrence for " f"{relating_type.is_a()}.",
)
if not compatible:
self.report({"ERROR"}, f"No selected object can be typed by {relating_type.is_a()}.")
return {"CANCELLED"}
for obj, element in compatible:
core.assign_type(tool.Ifc, tool.Model, tool.Type, element=element, type=relating_type)
# Switch to the drawing's target view if available
@@ -376,12 +394,22 @@ class DuplicateType(bpy.types.Operator, tool.Ifc.Operator):
if self.assign_selected_objects:
selected_objects = tool.Blender.get_selected_objects()
prefs = tool.Blender.get_addon_preferences()
skipped_classes: set[str] = set()
for selected_obj in selected_objects:
selected_element = tool.Ifc.get_entity(selected_obj)
if selected_element and selected_element.is_a("IfcObject"):
core.assign_type(tool.Ifc, tool.Model, tool.Type, element=selected_element, type=new)
if prefs.occurrence_name_style == "TYPE":
selected_obj.name = tool.Model.generate_occurrence_name(new, selected_element.is_a())
if not selected_element or not selected_element.is_a("IfcObject"):
continue
if not tool.Type.is_relating_type_compatible(selected_element, new):
skipped_classes.add(selected_element.is_a())
continue
core.assign_type(tool.Ifc, tool.Model, tool.Type, element=selected_element, type=new)
if prefs.occurrence_name_style == "TYPE":
selected_obj.name = tool.Model.generate_occurrence_name(new, selected_element.is_a())
if skipped_classes:
self.report(
{"WARNING"},
f"Skipped {', '.join(sorted(skipped_classes))}: not a valid occurrence for " f"{new.is_a()}.",
)
if obj in context.selectable_objects:
tool.Blender.select_and_activate_single_object(context, new_obj)
+24 -20
View File
@@ -26,7 +26,7 @@ import bonsai.bim.handler
import bonsai.core.geometry
import bonsai.core.root
import bonsai.tool as tool
from bonsai.bim.module.model.opening import FilledOpeningGenerator
from bonsai.bim.module.model.opening import FilledOpeningGenerator, is_filling_supported
class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
@@ -34,11 +34,13 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
bl_label = "Apply Opening"
bl_options = {"REGISTER", "UNDO"}
bl_description = (
"Apply opening objects to an Element.\n\n"
"The Element and the openings to be applied should be selected. The order of selection is not important.\n"
"Opening can be just a Blender mesh object.\n\n"
"Shift+click: keep the filling at its current matrix_world — skip the wall-axis snap "
"and the rl1/rl2 Z-elevation default that the regular click applies."
"Cuts openings in a wall, slab, or roof using selected shape objects — "
"doors, windows, existing openings, or plain (non-IFC) meshes. "
"Selection order doesn't matter.\n\n"
"Doors and windows also fill the opening. Other IFC classes are currently "
"unsupported by the opening generator and get skipped with a warning.\n\n"
"Shift+click: keep each opening at its shape object's current position "
"instead of snapping to the wall."
)
# Toggled by ``invoke`` when the user holds SHIFT during a gizmo / hotkey
@@ -59,6 +61,12 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
return self.execute(context)
def _execute(self, context):
# Multi-opening drops on the same host fan out N update_representation
# writes + N switch_representation recuts without batching. Coalesce.
with tool.Geometry.batch_host_recut():
return self._add_openings(context)
def _add_openings(self, context):
selected_objects = context.selected_objects
target_object = selected_objects[0]
@@ -78,8 +86,14 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
self.report({"INFO"}, "You can't add an opening to another opening.")
continue
elif not element1.is_a("IfcOpeningElement") and not element2.is_a("IfcOpeningElement"):
if element1.is_a("IfcWindow") or element1.is_a("IfcDoor"): # Add a fill to an element.
if is_filling_supported(element1): # Add a fill to an element.
obj1, obj2 = obj2, obj1
elif not is_filling_supported(element2):
self.report(
{"INFO"},
f"Cannot apply {element2.is_a()} as an opening — Bonsai currently supports only IfcDoor and IfcWindow as parametric fillings.",
)
continue
FilledOpeningGenerator().generate(
obj2,
obj1,
@@ -165,7 +179,7 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
voided_obj.scale = (1.0, 1.0, 1.0)
tool.Ifc.finish_edit(voided_obj)
else:
bpy.ops.bim.update_representation(obj=voided_obj.name)
tool.Geometry.update_host_representation(voided_obj)
if tool.Ifc.is_moved(voided_obj):
bonsai.core.geometry.edit_object_placement(
@@ -174,12 +188,7 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
representation = tool.Geometry.get_active_representation(voided_obj)
assert representation
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=voided_obj,
representation=representation,
)
tool.Geometry.recut_host(voided_obj, representation)
tool.Geometry.lock_scale(voided_obj)
if not has_visible_openings:
@@ -217,12 +226,7 @@ class RemoveOpening(bpy.types.Operator, tool.Ifc.Operator):
if building_obj and building_obj.data:
representation = tool.Geometry.get_active_representation(building_obj)
assert representation
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=building_obj,
representation=representation,
)
tool.Geometry.recut_host(building_obj, representation)
tool.Geometry.unlock_scale_object_with_openings(obj)
return {"FINISHED"}
+117 -68
View File
@@ -39,21 +39,10 @@ from natsort import natsorted
import bonsai.bim
import bonsai.bim.helper
import bonsai.tool as tool
from bonsai.bim.ifc import is_cache_locked_by_other_process
from bonsai.bim.module.bsdd.prop import BIMBSDDProperties, BSDDProperty
from bonsai.bim.module.model.prop import (
BIMDoorProperties,
BIMRailingProperties,
BIMRoofProperties,
BIMStairProperties,
BIMWindowProperties,
)
from bonsai.bim.module.model.ui import (
draw_door_properties,
draw_railing_properties,
draw_roof_properties,
draw_stair_properties,
draw_window_properties,
)
from bonsai.bim.module.model import prop as _model_prop
from bonsai.bim.module.model import ui as _model_ui
from bonsai.bim.module.pset.prop import IfcProperty
from bonsai.bim.prop import Attribute
@@ -278,34 +267,29 @@ class BIM_UL_panel_visibilities(bpy.types.UIList):
class GizmoPreferences(bpy.types.PropertyGroup):
"""Aggregator for parametric gizmo visibility settings. One flat bool per
parametric feature; controls whether that feature's gizmo group polls
visible in the viewport."""
visible in the viewport.
The per-feature ``<name>: BoolProperty`` fields are derived from
``tool.Parametric.EDIT_TYPES`` at module load adding a new parametric
type to the registry automatically surfaces its toggle here, with no
parallel hand-maintained list to keep in sync."""
draw_gizmos_in_3d_viewport: BoolProperty(
name="Draw Gizmos In 3D Viewport",
default=True,
description="Show interactive gizmos in the 3D viewport for parametric elements",
)
door: BoolProperty(name="Door", default=True)
window: BoolProperty(name="Window", default=True)
stair: BoolProperty(name="Stair", default=True)
railing: BoolProperty(name="Railing", default=True)
roof: BoolProperty(name="Roof", default=True)
array: BoolProperty(name="Array", default=True)
pipe_segment: BoolProperty(name="Pipe Segment", default=True)
duct_segment: BoolProperty(name="Duct Segment", default=True)
wall: BoolProperty(name="Wall", default=True)
if TYPE_CHECKING:
draw_gizmos_in_3d_viewport: bool
door: bool
window: bool
stair: bool
railing: bool
roof: bool
array: bool
pipe_segment: bool
duct_segment: bool
wall: bool
for _gizmo_pref_entry in tool.Parametric.EDIT_TYPES:
GizmoPreferences.__annotations__[_gizmo_pref_entry.name] = BoolProperty(
name=_gizmo_pref_entry.name.replace("_", " ").title(),
default=True,
)
del _gizmo_pref_entry
class DocPreferences(bpy.types.PropertyGroup):
@@ -401,11 +385,22 @@ class DocPreferences(bpy.types.PropertyGroup):
class DefaultParameters(bpy.types.PropertyGroup):
door: bpy.props.PointerProperty(type=BIMDoorProperties)
window: bpy.props.PointerProperty(type=BIMWindowProperties)
railing: bpy.props.PointerProperty(type=BIMRailingProperties)
roof: bpy.props.PointerProperty(type=BIMRoofProperties)
stair: bpy.props.PointerProperty(type=BIMStairProperties)
"""Per-type preset values used to seed new parametric instances.
The ``<name>: PointerProperty`` fields are derived from the subset of
``tool.Parametric.EDIT_TYPES`` flagged ``has_default_parameters=True``,
each pointing at the matching ``BIM<Name>Properties`` class. Adding a
new entry with that flag automatically surfaces a preferences section
and gives the create operator a preset to copy from."""
for _default_params_entry in tool.Parametric.EDIT_TYPES:
if not _default_params_entry.has_default_parameters:
continue
DefaultParameters.__annotations__[_default_params_entry.name] = bpy.props.PointerProperty(
type=getattr(_model_prop, _default_params_entry.props_attr),
)
del _default_params_entry
class BIM_ADDON_preferences(bpy.types.AddonPreferences):
@@ -517,6 +512,15 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
size=4,
description="Color of not selected verts/edges (used in profile editing mode)",
)
clip_box_cap_color: bpy.props.FloatVectorProperty(
name="Clip Box Caps Color",
subtype="COLOR",
default=(0.0, 0.0, 0.0, 1.0),
min=0.0,
max=1.0,
size=4,
description="Fill color of clip-box cross-section caps",
)
decorator_color_special: bpy.props.FloatVectorProperty(
name="Special Elements Color",
subtype="COLOR",
@@ -801,39 +805,29 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
layout.row().prop(self, "decorator_color_special")
layout.row().prop(self, "decorator_color_error")
layout.row().prop(self, "decorator_color_background")
bonsai.bim.helper.draw_expandable_panel(
layout,
context,
"Clip Box",
self.draw_clip_box_colors,
)
def draw_clip_box_colors(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
layout.row().prop(self, "clip_box_cap_color")
def draw_default_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
box = layout.box()
bonsai.bim.helper.draw_expandable_panel(
box,
context,
"Door",
lambda _layout, _context: draw_door_properties(_layout, self.default_parameters.door),
)
bonsai.bim.helper.draw_expandable_panel(
box,
context,
"Window",
lambda _layout, _context: draw_window_properties(_layout, self.default_parameters.window),
)
bonsai.bim.helper.draw_expandable_panel(
box,
context,
"Railing",
lambda _layout, _context: draw_railing_properties(_layout, self.default_parameters.railing),
)
bonsai.bim.helper.draw_expandable_panel(
box,
context,
"Roof",
lambda _layout, _context: draw_roof_properties(_layout, self.default_parameters.roof),
)
bonsai.bim.helper.draw_expandable_panel(
box,
context,
"Stair",
lambda _layout, _context: draw_stair_properties(_layout, self.default_parameters.stair),
)
for entry in tool.Parametric.EDIT_TYPES:
if not entry.has_default_parameters:
continue
props = getattr(self.default_parameters, entry.name)
draw_props = getattr(_model_ui, f"draw_{entry.name}_properties")
bonsai.bim.helper.draw_expandable_panel(
box,
context,
entry.name.replace("_", " ").title(),
lambda _layout, _context, _draw=draw_props, _props=props: _draw(_layout, _props),
)
def draw_other_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
layout.prop(self, "opening_focus_opacity")
@@ -950,6 +944,50 @@ class BIM_PT_tabs(Panel):
op.uri = "https://docs.bonsaibim.org/guides/troubleshooting.html#saving-and-loading-blend-files"
row.operator("bim.close_blend_warning", text="", icon="CANCEL")
if is_cache_locked_by_other_process():
box = self.layout.box()
box.alert = True
row = box.row(align=True)
row.label(text="IFC Already Open in Another Blender Instance", icon="ERROR")
row.operator("bim.dismiss_multi_instance_warning", text="", icon="CANCEL")
draw_multiline_text(
box.column(align=True),
"This file is open in another Blender instance. Editing the same "
"IFC from two instances at once can lose your work or display "
"outdated geometry. Close the other Blender instances to continue safely.",
context=context,
)
pprops = tool.Project.get_project_props()
if pending := pprops.pending_opening_recut:
box = self.layout.box()
box.alert = True
box.label(text="Opening Cuts Skipped", icon="ERROR")
draw_multiline_text(
box.column(align=True),
f"{len(pending)} element(s) had too many openings to cut during load. "
f"Apply to recompute their meshes, or dismiss to leave them as they are.",
context=context,
)
row = box.row(align=True)
row.operator("bim.select_pending_opening_cuts", text="Select Elements", icon="RESTRICT_SELECT_OFF")
row.operator("bim.apply_pending_opening_cuts", text="Apply Openings", icon="PLAY")
row.operator("bim.dismiss_pending_opening_cuts", text="", icon="CANCEL")
if pending := pprops.pending_array_repair:
box = self.layout.box()
box.alert = True
box.label(text="Arrays With Missing Children", icon="ERROR")
draw_multiline_text(
box.column(align=True),
f"{len(pending)} array parent(s) reference child GUIDs that don't exist in this file. "
f"The arrays loaded incomplete. Select to inspect, or dismiss.",
context=context,
)
row = box.row(align=True)
row.operator("bim.select_pending_array_repair", text="Select Elements", icon="RESTRICT_SELECT_OFF")
row.operator("bim.dismiss_pending_array_repair", text="", icon="CANCEL")
gprops = tool.Geometry.get_geometry_props()
# Check that Blender mode and IFC Mode do match.
if context.mode == "OBJECT" and gprops.mode in ("OBJECT", "ITEM"):
@@ -1901,6 +1939,7 @@ class BIM_PT_decorators_overlay(Panel):
aggregate_props = tool.Aggregate.get_aggregate_props()
nest_props = tool.Nest.get_nest_props()
model_props = tool.Model.get_model_props()
system_props = tool.System.get_system_props()
display_all = overlay.show_overlays
col = layout.column()
@@ -1918,10 +1957,20 @@ class BIM_PT_decorators_overlay(Panel):
row = col.row(align=True)
row.prop(model_props, "show_slab_direction", text="Slab Direction")
row = col.row(align=True)
row.prop(model_props, "show_paths", text="Element Paths")
row.prop(system_props, "should_draw_decorations", text="System Decorations")
row = col.row(align=True)
row.prop(model_props, "show_bounding_box", text="Bounding Box Dimensions")
row = col.row(align=True)
row.prop(model_props, "show_cut_decorator", text="Cut Decorator")
row.prop(model_props, "show_cut_decorator_fill", text="Fill Cut Decorator")
clip_box_props = tool.ClipBox.get_scene_props(context.scene)
row = col.row(align=True)
# Grey out the toggles when there is no clip box to act on, so the
# user can see the controls but can't flip a switch that does nothing.
row.enabled = bool(clip_box_props.clip_boxes)
row.prop(clip_box_props, "enabled", text="Enable Clipping")
row.prop(clip_box_props, "show_caps", text="Show Caps")
class BIM_PT_snappping(Panel):
+113
View File
@@ -0,0 +1,113 @@
# 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 <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Shared post-disconnect cleanup dispatch.
Used by both ``bim.disconnect_elements`` (explicit user disconnect) and the
connection cascade in ``tool.Geometry.delete_ifc_object`` (implicit
disconnect-on-delete). Each kind returned by
:py:meth:`bonsai.tool.connection.Connection.find_rels` /
:py:meth:`find_rels_for_element` maps to a single arm here, so adding a new
kind means extending one dispatch table both call sites benefit
automatically and the AST forward-compat guard enforces coverage.
The ``subject`` parameter is the entity whose teardown effects the
disconnect: for ``"path"`` / ``"element"`` / ``"element-top"`` kinds it
carries an ``IfcRel*`` relationship entity (the rel that gets removed);
for ``"mep-pair-fitting"`` it carries an ``IfcFlowFitting`` (the fitting
that gets deleted). The slot is uniform on intent the dispatch decides
the teardown mechanism by kind.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import bonsai.core.geometry
from bonsai.core.model import regenerate_wall_to_underside
if TYPE_CHECKING:
import ifcopenshell
import bonsai.tool as tool
def disconnect_rel(
ifc: type[tool.Ifc],
geometry: type[tool.Geometry],
model: type[tool.Model],
connection: type[tool.Connection],
subject: ifcopenshell.entity_instance,
kind: str,
elem: ifcopenshell.entity_instance,
partner: ifcopenshell.entity_instance,
skip_elem_recreate: bool = False,
skip_partner_recreate: bool = False,
) -> None:
"""Run the post-disconnect cleanup for one connection.
``elem`` and ``partner`` are the two endpoints. The ``skip_*_recreate``
flags suppress per-side regenerate / recreate work used by the
cascade-on-delete to avoid re-extruding entities that are about to be
removed by ``remove_product``. For the disconnect operator (where neither
endpoint is being deleted), both flags stay False and the full cleanup
runs on both sides.
"""
if kind == "path":
bonsai.core.geometry.remove_connection(geometry, connection=subject)
if not skip_elem_recreate:
elem_obj = ifc.get_object(elem)
if elem_obj is not None:
model.recreate_wall(elem, elem_obj)
if not skip_partner_recreate:
partner_obj = ifc.get_object(partner)
if partner_obj is not None:
model.recreate_wall(partner, partner_obj)
elif kind == "element-top":
wall, _slab = connection.orient_element_top(subject, elem, partner)
ifc.run(
"geometry.disconnect_element",
relating_element=subject.RelatingElement,
related_element=subject.RelatedElement,
)
# Skip the wall-side regenerate when the wall is itself being deleted —
# either it's the elem of this cascade pass, or it's the partner that
# was queued earlier in the same batch.
if (wall is elem and skip_elem_recreate) or (wall is partner and skip_partner_recreate):
return
wall_obj = ifc.get_object(wall)
if wall_obj is not None:
regenerate_wall_to_underside(ifc, geometry, model, [wall_obj])
elif kind == "element":
ifc.run(
"geometry.disconnect_element",
relating_element=subject.RelatingElement,
related_element=subject.RelatedElement,
)
elif kind == "mep-pair-fitting":
if skip_elem_recreate and subject is elem:
return
if skip_partner_recreate and subject is partner:
return
fitting_obj = ifc.get_object(subject)
if fitting_obj is not None:
geometry.delete_ifc_object(fitting_obj)
else:
raise ValueError(f"Unknown kind: {kind!r}")
+57 -2
View File
@@ -302,9 +302,23 @@ def add_drawing(
context=drawing.get_body_context(),
ifc_representation_class=None,
)
drawings_parent_group = None
for group in ifc.get().by_type("IfcGroup"):
if group.Name == "DRAWINGS" and group.ObjectType == "DRAWINGS":
drawings_parent_group = group
break
if not drawings_parent_group:
drawings_parent_group = ifc.run("group.add_group")
ifc.run("group.edit_group", group=drawings_parent_group, attributes={"Name": "DRAWINGS", "ObjectType": "DRAWINGS"})
group = ifc.run("group.add_group")
ifc.run("group.edit_group", group=group, attributes={"Name": drawing_name, "ObjectType": "DRAWING"})
ifc.run("group.assign_group", group=group, products=[element])
ifc.run("group.assign_group", group=drawings_parent_group, products=[group])
collector.assign(camera)
pset = ifc.run("pset.add_pset", product=element, name="EPset_Drawing")
if drawing.get_unit_system() == "METRIC":
@@ -335,7 +349,22 @@ def add_drawing(
},
)
drawing.setup_shading_styles_path(shading_styles_path)
information = ifc.run("document.add_information")
drawings_parent_document = None
for document in ifc.get().by_type("IfcDocumentInformation"):
if document.Name == "DRAWINGS" and document.Scope == "DRAWINGS":
drawings_parent_document = document
break
if not drawings_parent_document:
drawings_parent_document = ifc.run("document.add_information")
if ifc.get_schema() == "IFC2X3":
attributes = {"DocumentId": "DRAWINGS", "Name": "DRAWINGS", "Scope": "DRAWINGS"}
else:
attributes = {"Identification": "DRAWINGS", "Name": "DRAWINGS", "Scope": "DRAWINGS"}
ifc.run("document.edit_information", information=drawings_parent_document, attributes=attributes)
information = ifc.run("document.add_information", parent=drawings_parent_document)
uri = drawing.get_default_drawing_path(drawing_name)
reference = ifc.run("document.add_reference", information=information)
if ifc.get_schema() == "IFC2X3":
@@ -363,9 +392,21 @@ def duplicate_drawing(
drawing_tool.set_name(new_drawing, drawing_name)
group = drawing_tool.get_drawing_group(new_drawing)
ifc.run("group.unassign_group", group=group, products=[new_drawing])
drawings_parent_group = None
for parent_group in ifc.get().by_type("IfcGroup"):
if parent_group.Name == "DRAWINGS" and parent_group.ObjectType == "DRAWINGS":
drawings_parent_group = parent_group
break
if not drawings_parent_group:
drawings_parent_group = ifc.run("group.add_group")
ifc.run("group.edit_group", group=drawings_parent_group, attributes={"Name": "DRAWINGS", "ObjectType": "DRAWINGS"})
new_group = ifc.run("group.add_group")
ifc.run("group.edit_group", group=new_group, attributes={"Name": drawing_name, "ObjectType": "DRAWING"})
ifc.run("group.assign_group", group=new_group, products=[new_drawing])
ifc.run("group.assign_group", group=drawings_parent_group, products=[new_group])
if should_duplicate_annotations:
new_annotations: list[ifcopenshell.entity_instance] = []
annotation_objs = [ifc.get_object(a) for a in drawing_tool.get_group_elements(group) if a != drawing]
@@ -381,7 +422,21 @@ def duplicate_drawing(
old_reference = drawing_tool.get_drawing_document(new_drawing)
ifc.run("document.unassign_document", products=[new_drawing], document=old_reference)
information = ifc.run("document.add_information")
drawings_parent_document = None
for document in ifc.get().by_type("IfcDocumentInformation"):
if document.Name == "DRAWINGS" and document.Scope == "DRAWINGS":
drawings_parent_document = document
break
if not drawings_parent_document:
drawings_parent_document = ifc.run("document.add_information")
if ifc.get_schema() == "IFC2X3":
attributes = {"DocumentId": "DRAWINGS", "Name": "DRAWINGS", "Scope": "DRAWINGS"}
else:
attributes = {"Identification": "DRAWINGS", "Name": "DRAWINGS", "Scope": "DRAWINGS"}
ifc.run("document.edit_information", information=drawings_parent_document, attributes=attributes)
information = ifc.run("document.add_information", parent=drawings_parent_document)
uri = drawing_tool.get_default_drawing_path(drawing_name)
reference = ifc.run("document.add_reference", information=information)
if ifc.get_schema() == "IFC2X3":
+14 -3
View File
@@ -167,12 +167,22 @@ def regenerate_wall_to_underside(
model: type[tool.Model],
wall_objs: list[bpy.types.Object],
) -> None:
"""Re-clip walls to their connected underside objects after the slab has moved."""
"""Re-clip walls to their connected underside objects after the slab has moved.
When a wall has no remaining slab connections the case reached after the
last TOP rel is severed (via disconnect or via cascade-on-slab-delete) the
stale trim booleans are cleaned up so the wall reverts to its pre-clip
extrusion instead of holding orphan ``IfcBooleanResult`` items and a dead
``BBIM_Boolean`` pset.
"""
clipped_objs = []
reverted_objs = []
for obj in wall_objs:
wall = ifc.get_entity(obj)
slab_objs = model.get_connected_slab_objs(wall)
if not slab_objs:
model.remove_wall_to_underside_booleans(wall)
reverted_objs.append(obj)
continue
if ifc.is_moved(obj):
geometry.run_edit_object_placement(obj=obj)
@@ -185,8 +195,9 @@ def regenerate_wall_to_underside(
if clip:
model.clip_wall_to_slab(wall, clip)
clipped_objs.append(obj)
if clipped_objs:
model.reload_body_representation(clipped_objs)
refresh_objs = clipped_objs + reverted_objs
if refresh_objs:
model.reload_body_representation(refresh_objs)
def extend_wall_to_slab(
+11
View File
@@ -195,6 +195,14 @@ class Collector:
def assign(cls, obj, should_clean_users_collection=False): pass
@interface
class Connection:
def find_rel(cls, elem_a, elem_b): pass
def find_rels(cls, elem_a, elem_b): pass
def find_rels_for_element(cls, elem): pass
def orient_element_top(cls, rel, elem_a, elem_b): pass
@interface
class Context:
def clear_context(cls): pass
@@ -692,11 +700,13 @@ class Model:
def load_openings(cls, openings): pass
def purge_scene_openings(cls): pass
def recalculate_walls(cls, objs): pass
def recreate_wall(cls, element, obj): pass
def regenerate_array(cls, parent, data): pass
def regenerate_profile(cls, obj): pass
def regenerate_slab(cls, obj): pass
def reload_body_representation(cls, obj_or_objects): pass
def remove_wall_to_underside_booleans(cls, wall): pass
def strip_underside_booleans(cls, wall): pass
def replace_object_ifc_representation(cls, ifc_file, ifc_context, obj, new_representation): pass
@@ -1196,6 +1206,7 @@ class Type:
def get_representation_context(cls, representation): pass
def get_type_occurrences(cls, element_type): pass
def has_material_usage(cls, element): pass
def is_relating_type_compatible(cls, occurrence, relating_type): pass
def record_material_usage_attributes(cls, element): pass
def restore_material_usage_attributes(cls, element, usage_attributes): pass
def run_geometry_add_representation(cls, obj=None, context=None, ifc_representation_class=None, profile_set_usage=None): pass
+2
View File
@@ -30,7 +30,9 @@ from bonsai.tool.bsdd import Bsdd
from bonsai.tool.cad import Cad
from bonsai.tool.clash import Clash
from bonsai.tool.classification import Classification
from bonsai.tool.clip_box import ClipBox
from bonsai.tool.collector import Collector
from bonsai.tool.connection import Connection
from bonsai.tool.context import Context
from bonsai.tool.cost import Cost
from bonsai.tool.covering import Covering
+289 -2
View File
@@ -30,7 +30,15 @@ import sys
import tempfile
import traceback
import types
from collections.abc import Callable, Generator, Iterable, Mapping, Sequence, Sized
from collections.abc import (
Callable,
Generator,
Iterable,
Iterator,
Mapping,
Sequence,
Sized,
)
from datetime import datetime
from functools import cache, lru_cache
from pathlib import Path
@@ -47,9 +55,11 @@ from typing import (
import bmesh
import bpy
import gpu
import ifcopenshell.util.element
import numpy as np
import numpy.typing as npt
from gpu_extras.batch import batch_for_shader
from ifcopenshell import entity_instance
from mathutils import Matrix, Vector
@@ -528,6 +538,19 @@ class Blender(bonsai.core.tool.Blender):
cls.handlers.clear()
cls.is_installed = False
def draw_batch(self, shader_type, content_pos, color, indices=None):
"""Submit a GPU batch through ``self.line_shader`` (for ``"LINES"``)
or ``self.shader`` (for any other primitive). Skips empty batches
via ``validate_shader_batch_data`` so Blender 4.4+ doesn't crash on
empty ``indices``. Subclasses bind both shaders in their draw method
before calling this helper."""
if not Blender.validate_shader_batch_data(content_pos, indices):
return
shader = self.line_shader if shader_type == "LINES" else self.shader
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
shader.uniform_float("color", color)
batch.draw(shader)
@staticmethod
def _lookup_active_instance(gizmo_cls: type, context: bpy.types.Context) -> Optional[Any]:
"""Return the live ``GizmoGroup`` instance registered under
@@ -575,6 +598,120 @@ class Blender(bonsai.core.tool.Blender):
else:
decorator_cls.uninstall()
# Bonsai overrides Blender's default move/duplicate keymaps with macros
# that wrap TRANSFORM_OT_translate. While a macro is the outer modal
# entry, the inner TRANSFORM_OT_translate does not surface in
# window.modal_operators — the macro's own idname does. The ``BIM_OT_``
# prefix is what Blender returns from ``bl_idname`` at runtime (the
# class declaration uses the dotted ``bim.`` form).
BONSAI_TRANSFORM_MACROS: frozenset[str] = frozenset(
{
"BIM_OT_override_move_macro", # G key
"BIM_OT_override_object_duplicate_move_macro", # Shift+D
"BIM_OT_override_object_duplicate_move_linked_macro", # Alt+D
"BIM_OT_object_duplicate_move_linked_aggregate_macro", # Ctrl+Shift+D
}
)
@classmethod
def is_transform_modal_active(cls, context: bpy.types.Context) -> bool:
"""True iff a Blender transform modal (G/R/S and siblings, including
Bonsai's macro overrides) is currently driving per-frame
``matrix_world`` updates. Reads ``window.modal_operators`` the
Blender 4.2+ collection of running modal operators. Callers gate
per-frame side effects (gizmo positioning, IFC persistence, etc.)
on this so they don't fire during the drag.
Falls back to scanning every window in the window manager when
``context.window`` is ``None`` depsgraph callbacks run with a
limited context where ``context.window`` is typically missing,
but the modal is still active on one of the WM's windows.
"""
window = getattr(context, "window", None)
if window is not None and getattr(window, "modal_operators", None):
windows = [window]
else:
wm = getattr(context, "window_manager", None) or bpy.context.window_manager
if wm is None:
return False
windows = list(wm.windows)
for w in windows:
modal_ops = getattr(w, "modal_operators", None)
if not modal_ops:
continue
for op in modal_ops:
idname = op.bl_idname
if idname.startswith("TRANSFORM_OT_") or idname in cls.BONSAI_TRANSFORM_MACROS:
return True
return False
@classmethod
def is_in_edit_mode(cls, context: Optional[bpy.types.Context] = None) -> bool:
"""True iff the active object is in any edit-style mode.
Catches every ``EDIT_*`` variant (mesh, curve, armature,
metaball, lattice, surface, text, grease pencil). Defaults to
``OBJECT`` when the mode attribute is missing so background-mode
callers (no UI context) don't false-positive.
"""
ctx = context if context is not None else bpy.context
mode = getattr(ctx, "mode", "OBJECT")
return mode.startswith("EDIT_")
@classmethod
def iter_view3d_regions(cls) -> Iterator[tuple[bpy.types.Area, bpy.types.Region, bpy.types.RegionView3D]]:
"""Yield ``(area, region, region_3d)`` for every WINDOW region in every 3D viewport.
Useful for features that need to act on every visible 3D viewport
(clip planes, draw handlers, region redraw fanout). Empty
generator when ``bpy.context.screen`` is unavailable (shutdown,
background mode without a screen).
"""
screen = getattr(getattr(bpy, "context", None), "screen", None)
if screen is None:
return
for area in screen.areas:
if area.type != "VIEW_3D":
continue
for region in area.regions:
if region.type != "WINDOW":
continue
region_3d = getattr(region, "data", None)
if region_3d is None:
continue
yield area, region, region_3d
@classmethod
def get_or_create_collection(cls, scene: bpy.types.Scene, name: str) -> bpy.types.Collection:
"""Return the named collection, creating + linking it to ``scene`` if absent."""
collection = bpy.data.collections.get(name)
if collection is None:
collection = bpy.data.collections.new(name)
scene.collection.children.link(collection)
return collection
@classmethod
def serialize_matrix(cls, matrix: Matrix) -> str:
"""Serialize a 4x4 matrix as a 16-float comma-separated string.
Round-trip pair with :meth:`deserialize_matrix`. Used for storing
a matrix in an IFC pset string property without losing precision
(``%.9g`` carries ~9 significant digits, enough for ``float32``
round-trip).
"""
return ",".join(f"{matrix[r][c]:.9g}" for r in range(4) for c in range(4))
@classmethod
def deserialize_matrix(cls, text: str) -> Matrix:
"""Inverse of :meth:`serialize_matrix`."""
floats = [float(v) for v in text.split(",")]
return Matrix([tuple(floats[r * 4 : r * 4 + 4]) for r in range(4)])
@classmethod
def hash_matrix(cls, matrix: Matrix) -> int:
"""Hash a 4x4 matrix by its 16 floats. Useful as a cache key."""
return hash(tuple(matrix[r][c] for r in range(4) for c in range(4)))
@classmethod
def is_view_top_down(cls, context: bpy.types.Context, threshold: float = 0.9659) -> bool:
"""True when the viewport camera is looking ~straight down (or up) the world Z axis.
@@ -1274,7 +1411,10 @@ class Blender(bonsai.core.tool.Blender):
@classmethod
def get_object_from_guid(cls, guid: str) -> Union[bpy.types.Object, None]:
element = tool.Ifc.get().by_guid(guid)
try:
element = tool.Ifc.get().by_guid(guid)
except RuntimeError:
return None
obj = tool.Ifc.get_object(element)
if obj:
return obj
@@ -1397,6 +1537,10 @@ class Blender(bonsai.core.tool.Blender):
bpy.ops.bim.enable_editing_railing_path()
elif feature := tool.Parametric.is_object_editing(obj):
tool.Parametric.run_bim_op(feature.finish_op)
elif tool.Parametric.is_wall(element):
# Placed after the generic finish dispatch so the TAB toggle splits:
# wall already editing → finish above; wall not editing → enter here.
bpy.ops.bim.enable_editing_wall()
else:
return False
return True
@@ -2260,6 +2404,149 @@ class Blender(bonsai.core.tool.Blender):
return False
return True
@staticmethod
def transparent_color(color: Iterable[float], alpha: float = 0.1) -> list[float]:
"""Copy an RGBA color with its alpha channel overridden."""
out = [c for c in color]
out[3] = alpha
return out
@classmethod
def draw_bmesh_face_tris(
cls,
bm: bmesh.types.BMesh,
world_vert_coords: list,
color: Any,
draw_batch: Callable[[str, list, Any, list], None],
) -> None:
"""Submit a non-mutating beauty-triangulated TRIS batch for ``bm``'s faces.
``world_vert_coords`` must be indexed by ``bm.verts`` index. Never call
``bmesh.ops.triangulate`` on a live bmesh to compute draw indices it
mutates the input and produces ear-clip fans that render as visible
streaks at low alpha.
"""
tris = [[loop.vert.index for loop in tri] for tri in bm.calc_loop_triangles()]
draw_batch("TRIS", world_vert_coords, color, tris)
@classmethod
def draw_quads(
cls,
context: bpy.types.Context,
quads: Sequence[
tuple[
tuple[float, float, float],
tuple[float, float, float],
tuple[float, float, float],
tuple[float, float, float],
]
],
*,
fill_color: Optional[tuple[float, float, float, float]] = None,
outline_color: Optional[tuple[float, float, float, float]] = None,
outline_width: float = 1.0,
) -> None:
"""Render ``quads`` (each a 4-tuple of CCW world-space corners) as
a filled TRIS batch, an outline LINES batch, or both.
Both colors are RGBA 4-tuples. Pass ``fill_color=None`` to skip
the fill pass and ``outline_color=None`` to skip the outline.
Skipping both is a no-op.
Replaces the per-decorator quad-fill helpers that used to live
inline in each feature module.
"""
if not quads or (fill_color is None and outline_color is None):
return
region = getattr(context, "region", None)
if region is None:
return
verts: list[tuple[float, float, float]] = []
tri_indices: list[tuple[int, int, int]] = []
line_indices: list[tuple[int, int]] = []
for quad in quads:
if len(quad) != 4:
continue
base = len(verts)
verts.extend(tuple(v) for v in quad)
if fill_color is not None:
tri_indices.append((base, base + 1, base + 2))
tri_indices.append((base, base + 2, base + 3))
if outline_color is not None:
line_indices.append((base, base + 1))
line_indices.append((base + 1, base + 2))
line_indices.append((base + 2, base + 3))
line_indices.append((base + 3, base))
if not cls.validate_shader_batch_data(verts, None):
return
gpu.state.blend_set("ALPHA")
try:
if fill_color is not None and tri_indices:
shader = gpu.shader.from_builtin("UNIFORM_COLOR")
shader.bind()
shader.uniform_float("color", fill_color)
batch = batch_for_shader(shader, "TRIS", {"pos": verts}, indices=tri_indices)
batch.draw(shader)
if outline_color is not None and line_indices:
shader = gpu.shader.from_builtin("UNIFORM_COLOR")
shader.bind()
shader.uniform_float("color", outline_color)
# Outline width: the UNIFORM_COLOR shader respects the
# GPU's current line-width state; restore on exit.
prev_width = gpu.state.line_width_get()
gpu.state.line_width_set(outline_width)
try:
batch = batch_for_shader(shader, "LINES", {"pos": verts}, indices=line_indices)
batch.draw(shader)
finally:
gpu.state.line_width_set(prev_width)
finally:
gpu.state.blend_set("NONE")
@classmethod
def build_dashed_line_segments(
cls,
world_verts: Sequence[Sequence[float]],
edges_indices: Sequence[Sequence[int]],
dash_period: float,
dash_width: float,
) -> tuple[list[tuple[float, float, float]], list[tuple[int, int]]]:
"""Pre-segment edges into world-space dash chunks for a vanilla LINES batch.
Each input edge is sliced into segments of length ``dash_width`` spaced
``dash_period`` apart (dash phase resets per-edge). The result is a fresh
``(verts, edges)`` pair that draws as dashes through any standard line
shader letting both passes of a visible/occluded outline reuse the
same shader so depth values match exactly across passes.
"""
new_verts: list[tuple[float, float, float]] = []
new_edges: list[tuple[int, int]] = []
if dash_period <= 0 or dash_width <= 0:
return new_verts, new_edges
n = len(world_verts)
for i, j in edges_indices:
if not (0 <= i < n and 0 <= j < n) or i == j:
continue
v0 = world_verts[i]
v1 = world_verts[j]
dx, dy, dz = v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2]
edge_length = math.sqrt(dx * dx + dy * dy + dz * dz)
if edge_length == 0.0:
continue
ux, uy, uz = dx / edge_length, dy / edge_length, dz / edge_length
t = 0.0
while t < edge_length:
t_end = min(t + dash_width, edge_length)
idx = len(new_verts)
new_verts.append((v0[0] + ux * t, v0[1] + uy * t, v0[2] + uz * t))
new_verts.append((v0[0] + ux * t_end, v0[1] + uy * t_end, v0[2] + uz * t_end))
new_edges.append((idx, idx + 1))
t += dash_period
return new_verts, new_edges
@classmethod
def draw_bmesh_face_tris(
cls,
+231
View File
@@ -206,6 +206,237 @@ class Cad:
"""
return geometry.intersect_line_plane(v1, v2, plane_co, plane_no)
@classmethod
def obb_world_clip_planes(
cls,
center: Vector,
axes: tuple[Vector, Vector, Vector],
half_extents: Vector,
) -> tuple[tuple[float, float, float, float], ...]:
"""Return the 6 inward world clip planes of an oriented bounding box.
Each plane is a 4-tuple ``(a, b, c, d)`` for the equation
``a*x + b*y + c*z + d``; a point is KEPT when the value is ``>= 0``
for every plane, matching ``RegionView3D.clip_planes`` semantics.
Return order is ``(+x, -x, +y, -y, +z, -z)`` where ``+x`` is the face
on the positive side of ``axes[0]``. ``axes`` are assumed orthonormal.
"""
cx, cy, cz = center.x, center.y, center.z
planes: list[tuple[float, float, float, float]] = []
for i in range(3):
ux, uy, uz = axes[i].x, axes[i].y, axes[i].z
h = float(half_extents[i])
px, py, pz = cx + h * ux, cy + h * uy, cz + h * uz
nx, ny, nz = -ux, -uy, -uz
planes.append((nx, ny, nz, -(nx * px + ny * py + nz * pz)))
px, py, pz = cx - h * ux, cy - h * uy, cz - h * uz
planes.append((ux, uy, uz, -(ux * px + uy * py + uz * pz)))
return tuple(planes)
@classmethod
def obb_clip_planes_from_matrix(
cls,
matrix_world: Matrix,
expand: float = 0.0,
expand_rel: float = 0.0,
) -> tuple[tuple[float, float, float, float], ...]:
"""Return the 6 inward world clip planes for the unit cube under ``matrix_world``.
The implicit box is ``[-1, +1]^3`` in object-local space, so the
host's ``matrix_world`` translation is the world centre, its
rotation orients the box axes, and each column's magnitude is the
world half-extent along that local axis. ``expand`` (absolute
world units) and ``expand_rel`` (fraction of each axis's
half-extent) both add an outward margin callers that visualise
the box with overlapping geometry (e.g. an empty CUBE display
sharing edges with the clip planes) pass non-zero values so the
box's own wireframe sits safely INSIDE the clip volume. Use the
relative form when the box is rendered at varying scales, since
the depth-buffer precision needed to keep an edge unclipped grows
with world-coordinate magnitude.
"""
world_center = matrix_world.col[3].xyz
linear = matrix_world.to_3x3()
world_axes = []
world_half_list = []
for i in range(3):
v = linear.col[i].copy()
length = v.length
if length > 0.0:
world_axes.append(v / length)
else:
world_axes.append(Vector((0.0, 0.0, 0.0)))
world_half_list.append(length + expand + length * expand_rel)
return cls.obb_world_clip_planes(
world_center,
(world_axes[0], world_axes[1], world_axes[2]),
Vector(world_half_list),
)
@classmethod
def point_is_inside_clip_planes(
cls,
planes: tuple[tuple[float, float, float, float], ...],
point: Vector,
eps: float = 1e-6,
) -> bool:
"""True iff ``point`` is on the kept side of every plane (inclusive)."""
x, y, z = point.x, point.y, point.z
for a, b, c, d in planes:
if a * x + b * y + c * z + d < -eps:
return False
return True
@classmethod
def newell_normal(cls, points: Sequence) -> Vector:
"""Newell's-method normal for a (possibly non-planar) 3D polygon ring.
Robust for thin / near-degenerate rings where a two-edge cross
product would be unstable.
"""
nx = ny = nz = 0.0
n = len(points)
for i in range(n):
cur = points[i]
nxt = points[(i + 1) % n]
nx += (cur[1] - nxt[1]) * (cur[2] + nxt[2])
ny += (cur[2] - nxt[2]) * (cur[0] + nxt[0])
nz += (cur[0] - nxt[0]) * (cur[1] + nxt[1])
return Vector((nx, ny, nz))
@classmethod
def plane_basis(cls, points: Sequence) -> tuple[Vector, Vector]:
"""Return an orthonormal ``(u, v)`` basis for the ring's best-fit plane."""
normal = cls.newell_normal(points)
if normal.length < 1e-12:
normal = Vector((0.0, 0.0, 1.0))
normal = normal.normalized()
ref = Vector((1.0, 0.0, 0.0))
if abs(normal.x) > 0.9:
ref = Vector((0.0, 1.0, 0.0))
u = normal.cross(ref)
if u.length < 1e-12:
ref = Vector((0.0, 0.0, 1.0))
u = normal.cross(ref)
u = u.normalized()
v = normal.cross(u).normalized()
return u, v
@classmethod
def tessellate_ring_planar(cls, polyline_list: list[list]) -> list[tuple[int, int, int]]:
"""Triangulate ``[outer, *inners]`` 3D coord rings in their own plane.
Projects every ring onto the outer ring's best-fit plane and
returns ``(i, j, k)`` index triples into the flat
``outer + inners[0] + inners[1] + ...`` vertex list. Falls
back to a shapely constrained Delaunay triangulation when
``mathutils.geometry.tessellate_polygon`` silently leaves ring
vertices unused (its known failure mode on complex concave
polygons-with-holes).
"""
from mathutils.geometry import tessellate_polygon
if not polyline_list or not polyline_list[0]:
return []
outer = polyline_list[0]
u, v = cls.plane_basis(outer)
origin = Vector(outer[0])
def _project_xy(ring):
return [((Vector(co) - origin).dot(u), (Vector(co) - origin).dot(v)) for co in ring]
projected_xy = [_project_xy(ring) for ring in polyline_list]
projected = [[Vector((x, y, 0.0)) for x, y in ring] for ring in projected_xy]
triangles = tessellate_polygon(projected)
n_total = sum(len(r) for r in projected_xy)
used = {i for tri in triangles for i in tri}
if triangles and len(used) >= n_total:
return triangles
fallback = cls._tessellate_via_shapely(projected_xy)
return fallback if fallback else triangles
@classmethod
def _tessellate_via_shapely(cls, projected_xy: list[list[tuple[float, float]]]) -> list[tuple[int, int, int]]:
"""Constrained-Delaunay fallback for :meth:`tessellate_ring_planar`.
Honours the polygon's boundary AND holes. Returns ``[]`` when
shapely is unavailable or the polygon can't be cleaned via
``buffer(0)``.
"""
try:
from shapely.geometry import Polygon
except Exception:
return []
outer = projected_xy[0]
inners = projected_xy[1:]
if len(outer) < 3:
return []
try:
poly = Polygon(outer, inners)
poly = poly if poly.is_valid else poly.buffer(0)
if poly.is_empty:
return []
except Exception:
return []
flat = list(outer)
for r in inners:
flat.extend(r)
def _key(x, y):
return (round(x, 6), round(y, 6))
index_of: dict[tuple[float, float], int] = {}
for idx, (x, y) in enumerate(flat):
index_of.setdefault(_key(x, y), idx)
try:
from shapely import constrained_delaunay_triangles
res = constrained_delaunay_triangles(poly)
tri_geoms = list(getattr(res, "geoms", []) or [])
except Exception:
try:
from shapely.ops import triangulate
tri_geoms = [t for t in triangulate(poly) if poly.contains(t.representative_point())]
except Exception:
return []
out: list[tuple[int, int, int]] = []
for t in tri_geoms:
coords = list(t.exterior.coords)[:-1]
if len(coords) != 3:
continue
idxs = [index_of.get(_key(x, y)) for x, y in coords]
if any(i is None for i in idxs):
continue
out.append(tuple(idxs))
return out
@classmethod
def corners_might_cross_clip_planes(
cls,
planes: tuple[tuple[float, float, float, float], ...],
corners: Sequence[Vector],
) -> bool:
"""Conservative reject test: True if ``corners`` might cross the clip volume.
Returns False only when at least one plane has ALL corners on its
rejected side meaning the convex hull of ``corners`` is fully
outside the clip volume and a per-mesh bisect can be skipped.
Returns True otherwise (possibly with false positives never
false negatives), so callers always cap any object that actually
crosses the box. ``corners`` is typically the 8 world-space corners
of an object's bound box.
"""
for a, b, c, d in planes:
if all(a * v.x + b * v.y + c * v.z + d < 0.0 for v in corners):
return False
return True
def intersect_edge_plane_v2(v1, v2, plane_co, plane_no, eps=1e-9):
"""
Numpy version of intersect_edge_plane
File diff suppressed because it is too large Load Diff
+168
View File
@@ -0,0 +1,168 @@
# 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 <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Generic discovery of the connection linking two IFC elements.
Used by ``bim.disconnect_elements`` so the operator surface is one operator
per disconnect intent (active vs. partner, identified by GlobalId) rather
than one per rel class. Each lookup returns ``(subject, kind)`` tuples where
``subject`` is the entity whose teardown effects the disconnect:
- ``"path"`` ``IfcRelConnectsPathElements`` (wall-wall, wall-roof, etc.).
``subject`` is the rel; removing it disconnects.
- ``"element-top"`` ``IfcRelConnectsElements`` with ``Description=="TOP"``
(created by ``extend_walls_to_underside``). ``subject`` is the rel.
- ``"element"`` any other ``IfcRelConnectsElements``. ``subject`` is the rel.
- ``"mep-pair-fitting"`` two MEP elements joined via ``IfcRelConnectsPorts``
through a single bridging ``IfcFlowFitting``. ``subject`` is the fitting
itself; removing it disconnects. ``OBSTRUCTION`` fittings are excluded
here; those go through ``bim.mep_add_obstruction(mode=REMOVE)``.
Add new kinds by extending :py:meth:`Connection.find_rels`. The dispatch in
``bonsai.core.connection.disconnect_rel`` maps each kind to the right
post-mutation cleanup; the AST forward-compat guard enforces coverage."""
from __future__ import annotations
from typing import TYPE_CHECKING
import bonsai.tool as tool
if TYPE_CHECKING:
import ifcopenshell
class Connection:
@classmethod
def find_rels(
cls,
elem_a: ifcopenshell.entity_instance,
elem_b: ifcopenshell.entity_instance,
) -> list[tuple[ifcopenshell.entity_instance, str]]:
"""Return every supported connection linking ``elem_a`` to ``elem_b``
as a list of ``(subject, kind)`` tuples ``subject`` is the entity
whose teardown effects the disconnect (the rel itself for
relationship-kinds, the bridging fitting for ``"mep-pair-fitting"``).
Walks both ``ConnectedTo`` and ``ConnectedFrom`` because either side
of a rel can be the relating element, and the same pair may carry
rels authored with opposite orientations."""
rels: list[tuple[ifcopenshell.entity_instance, str]] = []
seen: set[int] = set()
def _record(rel, kind):
if rel.id() not in seen:
seen.add(rel.id())
rels.append((rel, kind))
for rel in getattr(elem_a, "ConnectedTo", []) or ():
if rel.is_a("IfcRelConnectsPathElements") and getattr(rel, "RelatedElement", None) == elem_b:
_record(rel, "path")
for rel in getattr(elem_a, "ConnectedFrom", []) or ():
if rel.is_a("IfcRelConnectsPathElements") and getattr(rel, "RelatingElement", None) == elem_b:
_record(rel, "path")
for rel in getattr(elem_a, "ConnectedFrom", []) or ():
if rel.is_a("IfcRelConnectsElements") and getattr(rel, "RelatingElement", None) == elem_b:
kind = "element-top" if getattr(rel, "Description", None) == "TOP" else "element"
_record(rel, kind)
for rel in getattr(elem_a, "ConnectedTo", []) or ():
if rel.is_a("IfcRelConnectsElements") and getattr(rel, "RelatedElement", None) == elem_b:
kind = "element-top" if getattr(rel, "Description", None) == "TOP" else "element"
_record(rel, kind)
fitting = tool.System.find_bridging_fitting(elem_a, elem_b)
if fitting is not None:
_record(fitting, "mep-pair-fitting")
return rels
@classmethod
def find_rel(
cls,
elem_a: ifcopenshell.entity_instance,
elem_b: ifcopenshell.entity_instance,
) -> tuple[ifcopenshell.entity_instance | None, str | None]:
"""Return the first ``(subject, kind)`` or ``(None, None)``. Cheaper
than ``find_rels`` when callers only need to know whether a connection
exists or what kind it is."""
rels = cls.find_rels(elem_a, elem_b)
return rels[0] if rels else (None, None)
@classmethod
def find_rels_for_element(
cls,
elem: ifcopenshell.entity_instance,
) -> list[tuple[ifcopenshell.entity_instance, str, ifcopenshell.entity_instance]]:
"""Return every supported connection touching ``elem`` as
``(subject, kind, partner)`` triples. ``partner`` is the *other*
element on the connection the side cascade cleanup must operate on
when ``elem`` is being deleted.
Mirrors :py:meth:`find_rels`'s relationship-kind taxonomy. Notably
does NOT emit ``"mep-pair-fitting"`` triples: ``IfcRelConnectsPorts``
cleanup is owned by ``tool.Geometry.delete_ifc_object``'s
``remove_port`` loop, which runs unconditionally on any IFC root
deletion. Including MEP here would cause the cascade to also remove
the bridging fitting when one of its connected segments is deleted
a policy choice (fitting may still join other live segments) that's
better left to the user via the explicit disconnect operator.
"""
result: list[tuple[ifcopenshell.entity_instance, str, ifcopenshell.entity_instance]] = []
seen: set[int] = set()
def _record(rel, kind, partner):
if partner is None or rel.id() in seen:
return
seen.add(rel.id())
result.append((rel, kind, partner))
for rel in getattr(elem, "ConnectedTo", []) or ():
if rel.is_a("IfcRelConnectsPathElements"):
_record(rel, "path", getattr(rel, "RelatedElement", None))
elif rel.is_a("IfcRelConnectsElements"):
kind = "element-top" if getattr(rel, "Description", None) == "TOP" else "element"
_record(rel, kind, getattr(rel, "RelatedElement", None))
for rel in getattr(elem, "ConnectedFrom", []) or ():
if rel.is_a("IfcRelConnectsPathElements"):
_record(rel, "path", getattr(rel, "RelatingElement", None))
elif rel.is_a("IfcRelConnectsElements"):
kind = "element-top" if getattr(rel, "Description", None) == "TOP" else "element"
_record(rel, kind, getattr(rel, "RelatingElement", None))
return result
@classmethod
def orient_element_top(
cls,
rel: ifcopenshell.entity_instance,
elem_a: ifcopenshell.entity_instance,
elem_b: ifcopenshell.entity_instance,
) -> tuple[ifcopenshell.entity_instance, ifcopenshell.entity_instance]:
"""Return ``(wall, slab)`` for an ``IfcRelConnectsElements(TOP)`` rel.
The ``extend_walls_to_underside`` flow stores slab as the relating
side and wall as related orientation is recovered by checking
which input matches which rel attribute. Callers pass any two
elements; this resolves which is the wall and which is the slab so
post-disconnect cleanup (regenerate-wall-to-underside) targets the
right object."""
if getattr(rel, "RelatingElement", None) == elem_a:
return elem_b, elem_a
return elem_a, elem_b
+78 -13
View File
@@ -78,6 +78,7 @@ if TYPE_CHECKING:
class Drawing(bonsai.core.tool.Drawing):
ANNOTATION_DATA_TYPE = Literal["empty", "curve", "mesh"]
PERSPECTIVE_CAMERA_SHIFT_PROPERTIES = ("PerspectiveShiftX", "PerspectiveShiftY")
DOCUMENT_TYPE = Literal["SCHEDULE", "REFERENCE"]
LocationHintLiteral = Literal["PERSPECTIVE", "ORTHOGRAPHIC", "NORTH", "SOUTH", "EAST", "WEST"]
LOCATION_HINT_LITERALS = ("PERSPECTIVE", "ORTHOGRAPHIC", "NORTH", "SOUTH", "EAST", "WEST")
@@ -453,6 +454,41 @@ class Drawing(bonsai.core.tool.Drawing):
camera.matrix_world = matrix
return camera
@classmethod
def get_perspective_camera_shifts(cls, drawing: ifcopenshell.entity_instance) -> dict[str, float]:
pset = ifcopenshell.util.element.get_pset(drawing, "EPset_Drawing") or {}
shift_x_prop, shift_y_prop = cls.PERSPECTIVE_CAMERA_SHIFT_PROPERTIES
return {
"shift_x": float(pset.get(shift_x_prop, 0.0) or 0.0),
"shift_y": float(pset.get(shift_y_prop, 0.0) or 0.0),
}
@classmethod
def sync_perspective_camera_shifts(cls, drawing: ifcopenshell.entity_instance, camera: bpy.types.Camera) -> None:
if camera.type != "PERSP":
return
shift_x_prop, shift_y_prop = cls.PERSPECTIVE_CAMERA_SHIFT_PROPERTIES
current_shifts = cls.get_perspective_camera_shifts(drawing)
new_shifts = {"shift_x": float(camera.shift_x or 0.0), "shift_y": float(camera.shift_y or 0.0)}
if tool.Cad.is_x(current_shifts["shift_x"], new_shifts["shift_x"]) and tool.Cad.is_x(
current_shifts["shift_y"], new_shifts["shift_y"]
):
return
ifc_file = tool.Ifc.get()
pset = tool.Pset.get_element_pset(drawing, "EPset_Drawing")
if not pset:
pset = ifcopenshell.api.pset.add_pset(ifc_file, product=drawing, name="EPset_Drawing")
ifcopenshell.api.pset.edit_pset(
ifc_file,
pset=pset,
properties={
shift_x_prop: new_shifts["shift_x"],
shift_y_prop: new_shifts["shift_y"],
},
)
@classmethod
def create_svg_schedule(cls, schedule: ifcopenshell.entity_instance) -> None:
import bonsai.bim.module.drawing.scheduler as scheduler
@@ -1009,6 +1045,8 @@ class Drawing(bonsai.core.tool.Drawing):
camera_props.has_annotation = True
camera_props.target_view = "PLAN_VIEW"
camera_props.is_nts = False
camera.shift_x = 0.0
camera.shift_y = 0.0
pset = ifcopenshell.util.element.get_pset(drawing, "EPset_Drawing")
if pset:
@@ -1044,6 +1082,10 @@ class Drawing(bonsai.core.tool.Drawing):
camera_props.fill_mode = str(pset["FillMode"])
if "CutMode" in pset:
camera_props.cut_mode = str(pset["CutMode"])
if camera.type == "PERSP":
shifts = cls.get_perspective_camera_shifts(drawing)
camera.shift_x = shifts["shift_x"]
camera.shift_y = shifts["shift_y"]
camera_props.update_props = update_props
@@ -2267,14 +2309,19 @@ class Drawing(bonsai.core.tool.Drawing):
cls, drawing: ifcopenshell.entity_instance, ifc_file: Optional[ifcopenshell.file] = None
) -> set[ifcopenshell.entity_instance]:
"""returns a set of elements that are included in the drawing"""
if ifc_file is None:
param_was_none = ifc_file is None
if param_was_none:
ifc_file = tool.Ifc.get()
elements = cls.get_elements_in_camera_view(tool.Ifc.get_object(drawing), bpy.data.objects)
else:
# This can probably be smarter
elements = set(ifc_file.by_type("IfcElement"))
pset = ifcopenshell.util.element.get_psets(drawing).get("EPset_Drawing", {})
include = pset.get("Include", None)
# Only the active IFC file has Blender objects we can test against the
# camera's view frustum, which lets us drop elements - including those
# picked by an Include filter - that fall outside the drawing boundary.
camera_view_elements = None
if (param_was_none or include) and ifc_file is tool.Ifc.get():
camera_view_elements = cls.get_elements_in_camera_view(tool.Ifc.get_object(drawing), bpy.data.objects)
if include:
try:
data = json.loads(include)
@@ -2286,7 +2333,16 @@ class Drawing(bonsai.core.tool.Drawing):
elements = ifcopenshell.util.selector.filter_elements(ifc_file, include)
except (json.JSONDecodeError, ValueError):
elements = ifcopenshell.util.selector.filter_elements(ifc_file, include)
# The Include filter chooses which elements may appear, but they must
# still fall within the drawing's camera boundary.
if camera_view_elements is not None:
elements &= camera_view_elements
else:
if param_was_none:
elements = camera_view_elements
else:
# This can probably be smarter
elements = set(ifc_file.by_type("IfcElement"))
if ifc_file.schema == "IFC2X3":
base_elements = set(ifc_file.by_type("IfcElement") + ifc_file.by_type("IfcSpatialStructureElement"))
else:
@@ -2384,13 +2440,13 @@ class Drawing(bonsai.core.tool.Drawing):
@classmethod
def is_drawing_active(cls) -> bool:
camera = bpy.context.scene.camera
area = tool.Blender.get_view3d_area()
return bool(
camera is not None
and camera.type == "CAMERA"
and tool.Blender.get_ifc_definition_id(camera)
and area is not None
)
if not (camera is not None and camera.type == "CAMERA" and tool.Blender.get_ifc_definition_id(camera)):
return False
# A VIEW_3D area is meaningless (and unobtainable) in background
# mode, but isn't otherwise required to generate a drawing.
if bpy.app.background:
return True
return tool.Blender.get_view3d_area() is not None
@classmethod
def is_camera_orthographic(cls) -> bool:
@@ -2521,10 +2577,19 @@ class Drawing(bonsai.core.tool.Drawing):
has_context = True
break
linked_handles: set[bpy.types.Object] = set()
for link in tool.Project.get_project_props().get_loaded_links_for_drawings():
try:
handle = tool.Project.get_link_empty_handle(link)
except Exception:
continue
if handle:
linked_handles.add(handle)
visible_objects = []
for obj in bpy.context.view_layer.objects:
if element := tool.Ifc.get_entity(obj):
if element in filtered_elements:
if element in filtered_elements or obj in linked_handles:
visible_objects.append(obj)
else:
if obj.hide_get() is False:
+264 -4
View File
@@ -24,6 +24,7 @@ import multiprocessing
import struct
from collections import defaultdict
from collections.abc import Generator, Iterable, Iterator
from contextlib import contextmanager
from math import pi, radians
from typing import (
TYPE_CHECKING,
@@ -65,6 +66,7 @@ from typing_extensions import TypeIs
import bonsai.bim.helper
import bonsai.bim.import_ifc
import bonsai.core.connection
import bonsai.core.drawing
import bonsai.core.geometry
import bonsai.core.root
@@ -73,6 +75,7 @@ import bonsai.core.style
import bonsai.core.system
import bonsai.core.tool
import bonsai.tool as tool
from bonsai.bim.ifc import IfcStore, get_cache_or_detect_lock
if TYPE_CHECKING:
from bonsai.bim.module.geometry.prop import (
@@ -122,6 +125,107 @@ class Geometry(bonsai.core.tool.Geometry):
return True
return False
@classmethod
def clear_cache(cls, element: ifcopenshell.entity_instance) -> None:
# Cache acquisition can fail if the HDF5 file is locked by another
# process — degrade gracefully rather than aborting the caller's
# reimport flow. A stale cache entry is harmless; a raised exception
# prevents the actual mesh swap. The wrapper sets the project-panel
# warning flag on lock so the user sees one prominent notice instead
# of per-element log spam.
try:
cache = get_cache_or_detect_lock()
except Exception as exc:
print(f"clear_cache: skipping cache invalidation for {element} ({exc})")
return
if cache and hasattr(element, "GlobalId"):
cache.remove(element.GlobalId)
# Per-host work coalesced by `batch_host_recut`. Keys are voided element ifc ids;
# dict insertion preserves call ordering. Recut values store the representation at
# enqueue time, but the drain re-reads `get_active_representation` so the recut
# always reflects current IFC state.
_host_batch_depth: int = 0
_host_recut_queue: dict[int, tuple[bpy.types.Object, ifcopenshell.entity_instance]] = {}
_host_update_queue: dict[int, bpy.types.Object] = {}
@classmethod
@contextmanager
def batch_host_recut(cls) -> Generator[None, None, None]:
"""Coalesce host body work — `recut_host` and `update_host_representation`
calls inside the with-block enqueue by voided element id. On the outermost
exit: every host's `update_representation` runs first (writes Blender mesh
back to IFC), then every host's `switch_representation` runs (reads IFC +
openings Blender mesh). The two-phase order matters: a recut that ran
before the matching update_representation would re-tessellate against stale
IFC, losing the user's edits.
Nests safely only the outermost exit drains. The depth counter and queues
are reset on exit even if the body raises."""
cls._host_batch_depth += 1
try:
yield
finally:
cls._host_batch_depth -= 1
if cls._host_batch_depth == 0:
update_queue = cls._host_update_queue
recut_queue = cls._host_recut_queue
cls._host_update_queue = {}
cls._host_recut_queue = {}
for voided_obj in update_queue.values():
if not voided_obj or not voided_obj.data:
continue
if tool.Ifc.get_entity(voided_obj) is None:
continue
bpy.ops.bim.update_representation(obj=voided_obj.name)
for voided_obj, _ in recut_queue.values():
if not voided_obj or not voided_obj.data:
continue
if tool.Ifc.get_entity(voided_obj) is None:
continue
current_rep = cls.get_active_representation(voided_obj)
if current_rep is None:
continue
bonsai.core.geometry.switch_representation(
tool.Ifc, cls, obj=voided_obj, representation=current_rep
)
@classmethod
def recut_host(cls, voided_obj: bpy.types.Object, representation: ifcopenshell.entity_instance) -> None:
"""Recut a host's body representation. Inside `batch_host_recut`, enqueues
by voided element id; outside, fires `switch_representation` directly."""
if cls._host_batch_depth > 0:
element = tool.Ifc.get_entity(voided_obj)
if element is not None:
cls._host_recut_queue[element.id()] = (voided_obj, representation)
return
bonsai.core.geometry.switch_representation(tool.Ifc, cls, obj=voided_obj, representation=representation)
@classmethod
def update_host_representation(cls, voided_obj: bpy.types.Object) -> None:
"""Run `bim.update_representation` on a host. Inside `batch_host_recut`,
enqueues by voided element id; outside, fires the operator directly."""
if cls._host_batch_depth > 0:
element = tool.Ifc.get_entity(voided_obj)
if element is not None:
cls._host_update_queue[element.id()] = voided_obj
return
bpy.ops.bim.update_representation(obj=voided_obj.name)
@classmethod
def has_axis_representation(cls, element: ifcopenshell.entity_instance) -> bool:
"""True if the element carries a shape representation whose
RepresentationIdentifier is 'Axis'. Elements without one cannot be
projected to an unambiguous 1D path; callers that draw schematic axis
overlays must skip them rather than fall back to mesh-derived geometry."""
product_rep = getattr(element, "Representation", None)
if product_rep is None:
return False
for rep in product_rep.Representations:
if getattr(rep, "RepresentationIdentifier", None) == "Axis":
return True
return False
@classmethod
def get_body_representation(cls, element: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance | None:
"""The element's ``Model/Body/MODEL_VIEW`` representation, or ``None``.
@@ -135,6 +239,112 @@ class Geometry(bonsai.core.tool.Geometry):
for modifier in obj.modifiers:
obj.modifiers.remove(modifier)
@classmethod
def _group_edges_into_loops(cls, edges) -> list[list]:
"""Group an edge set into connected components by shared vertices.
Each returned group is a list of edges that share at least one
vertex chain. A hollow profile's bisect produces two disjoint
loops (outer ring + inner ring) grouping splits them so each
can be filled independently as a separate cap face, rather than
``contextual_create`` welding them into one solid outer face
with the inner loop demoted to interior decoration.
"""
edge_set = set(edges)
visited: set = set()
groups: list[list] = []
for start in edges:
if start in visited:
continue
group: list = []
stack: list = [start]
while stack:
e = stack.pop()
if e in visited:
continue
visited.add(e)
group.append(e)
for v in e.verts:
for adj in v.link_edges:
if adj in edge_set and adj not in visited:
stack.append(adj)
groups.append(group)
return groups
@classmethod
def bisect_and_cap(
cls,
bm,
planes_local,
*,
tag_layer_name: str = "bbim_cap",
dist: float = 1e-4,
weld_dist: float = 1e-5,
):
"""Clip ``bm`` against each ``(plane_co, plane_no)`` and fill the cuts.
Per plane, ``bmesh.ops.bisect_plane(clear_outer=True)`` discards
the outside half-space and ``bmesh.ops.contextual_create`` fills
the resulting cut edges with cap faces tagged via a BMesh int
layer so the tag propagates to any split-children from subsequent
planes. After all planes, near-coincident vertices are welded
(``weld_dist``) so adjacent caps from the same cross-section
merge cleanly.
Callers are responsible for input mesh quality. Non-watertight
inputs (terrain, single-shell surfaces) may produce degenerate
cap faces; that's an accepted user-supplied data limitation.
Returns the cap-tag BMLayerItem, or ``None`` if ``bm`` is empty.
"""
import bmesh
if not bm.faces:
return None
# Pre-weld nearby verts: T-junctions in messy IFC meshes (a third
# vertex sitting in the middle of an edge from a Boolean
# operation) make the bisect cut terminate early, leaving open
# loops that no fill op can close. Welding the T-junction's
# near-coincident vertex into the host edge before bisecting
# turns the cut into a closed loop.
bmesh.ops.remove_doubles(bm, verts=bm.verts[:], dist=max(weld_dist, 1e-4))
cap_layer = bm.faces.layers.int.new(tag_layer_name)
for plane_co, plane_no in planes_local:
geom = bm.verts[:] + bm.edges[:] + bm.faces[:]
if not geom:
break
results = bmesh.ops.bisect_plane(
bm,
geom=geom,
dist=dist,
plane_co=plane_co,
plane_no=plane_no,
clear_outer=True,
)
cut_edges = [e for e in results["geom_cut"] if isinstance(e, bmesh.types.BMEdge)]
if not cut_edges:
continue
# Group cut edges into connected components BEFORE filling.
# Feeding ``contextual_create`` all edges at once (outer +
# inner of a hollow profile) makes it create a SINGLE outer
# face and treat inner edges as decoration — collapsing the
# hole. Filling each connected loop separately produces one
# cap face per ring.
for loop_edges in cls._group_edges_into_loops(cut_edges):
try:
fill = bmesh.ops.contextual_create(bm, geom=loop_edges)
except (RuntimeError, TypeError):
continue
for f in fill.get("faces", []):
if isinstance(f, bmesh.types.BMFace) and f.is_valid:
f[cap_layer] = 1
if weld_dist > 0.0:
bmesh.ops.remove_doubles(bm, verts=bm.verts[:], dist=weld_dist)
return cap_layer
@classmethod
def clear_scale(cls, obj: bpy.types.Object) -> None:
"""Apply and clear object scale.
@@ -250,12 +460,38 @@ class Geometry(bonsai.core.tool.Geometry):
bpy.data.objects.remove(obj)
@classmethod
def delete_ifc_object(cls, obj: bpy.types.Object) -> None:
def delete_ifc_object(
cls,
obj: bpy.types.Object,
batch_being_deleted_ids: Optional[set[int]] = None,
) -> None:
ifc_file = tool.Ifc.get()
element = tool.Ifc.get_entity(obj)
if not element:
return
elif element.is_a("IfcAnnotation"):
# Cascade connection-rel teardown — symmetric to bim.disconnect_elements.
# When a slab connected to a wall via IfcRelConnectsElements(TOP) is deleted,
# the wall's trim booleans + BBIM_Boolean pset would otherwise be orphaned.
# skip_elem_recreate is always True here because we're inside delete: the
# element is about to vanish, so re-extruding it would be wasted work.
# skip_partner_recreate fires only when the partner is also queued in the
# same OverrideDelete batch.
if element.is_a("IfcRoot"):
skip_ids = batch_being_deleted_ids or set()
for subject, kind, partner in tool.Connection.find_rels_for_element(element):
bonsai.core.connection.disconnect_rel(
tool.Ifc,
tool.Geometry,
tool.Model,
tool.Connection,
subject=subject,
kind=kind,
elem=element,
partner=partner,
skip_elem_recreate=True,
skip_partner_recreate=(partner.id() in skip_ids),
)
if element.is_a("IfcAnnotation"):
if element.ObjectType == "DRAWING":
return bonsai.core.drawing.remove_drawing(tool.Ifc, tool.Drawing, drawing=element)
elif tool.Drawing.is_auto_annotation(element):
@@ -620,7 +856,13 @@ class Geometry(bonsai.core.tool.Geometry):
and isinstance(data, Geometry.TYPES_WITH_MESH_PROPERTIES)
and (ifc_id := tool.Geometry.get_mesh_props(data).ifc_definition_id)
):
return tool.Ifc.get().by_id(ifc_id)
try:
return tool.Ifc.get().by_id(ifc_id)
except RuntimeError:
# Stale id: a representation rebuild freed the old entity
# while obj.data still tracks its id. Treated as "no active
# representation" — same contract as a mesh with id 0.
return None
@classmethod
def get_data_representation(cls, data: bpy.types.ID) -> ifcopenshell.entity_instance | None:
@@ -2326,6 +2568,21 @@ class Geometry(bonsai.core.tool.Geometry):
old_to_new[element] = [new]
if new.is_a("IfcRelSpaceBoundary"):
tool.Boundary.decorate_boundary(new_obj)
# Slab-trim booleans (from extend_walls_to_underside) belong to
# the source wall's connection, not the copy. Strip them so the
# duplicate reverts to its pre-clip extrusion — mirrors the way
# filling rels are dropped while manual booleans persist on copy.
# Reload the body when something was stripped so the viewport
# immediately shows the unclipped geometry; otherwise the user
# sees a stale mesh until they Shift+G, which is easy to miss.
if new.is_a("IfcWall"):
if tool.Model.strip_underside_booleans(new):
tool.Model.reload_body_representation(new_obj)
# HasOpenings rels don't follow object duplication, so
# the duplicate's body must rebuild to match its current
# opening set.
else:
tool.Model.regenerate_wall(new_obj)
# Remap Blender parent relationships for duplicated objects
for old_obj_name, new_obj_name in old_obj_name_to_new_obj_name.items():
@@ -2408,7 +2665,10 @@ class Geometry(bonsai.core.tool.Geometry):
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
if not pset:
continue
array_parents.add(tool.Ifc.get().by_guid(pset["Parent"]))
try:
array_parents.add(tool.Ifc.get().by_guid(pset["Parent"]))
except RuntimeError:
continue
for array_parent in array_parents:
array_parent_obj = tool.Ifc.get_object(array_parent)
+17 -6
View File
@@ -189,7 +189,7 @@ class Loader(bonsai.core.tool.Loader):
uv_mode = "Generated"
elif coordinates.is_a("IfcTextureCoordinateGenerator") and coordinates.Mode == "COORD-EYE":
uv_mode = "Camera"
surface_texture["uv_mode"] = uv_mode or "Generated"
surface_texture["uv_mode"] = uv_mode or "UV"
return surface_texture
@classmethod
@@ -315,7 +315,7 @@ class Loader(bonsai.core.tool.Loader):
image_url = str(image_url)
if is_relative and bpy.data.filepath:
image_url = bpy.path.relpath(image_url)
return bpy.data.images.load(image_url)
return bpy.data.images.load(image_url, check_existing=True)
elif texture["type"] == "IfcBlobTexture":
# https://blender.stackexchange.com/questions/173206/how-to-efficiently-convert-a-pil-image-to-bpy-types-image
@@ -472,12 +472,23 @@ class Loader(bonsai.core.tool.Loader):
print(f"{mode} Mode texture will be skipped.")
continue
if (image := get_image) is None:
if (image := get_image()) is None:
continue
# remove RGB node from `create_surface_style_rendering`
prev_node = bsdf.inputs[2].links[0].from_node
blender_material.node_tree.nodes.remove(prev_node)
# Replace whatever currently feeds the FLAT color input (RGB or previous texture chain).
for link in list(bsdf.inputs[2].links):
prev_node = link.from_node
blender_material.node_tree.links.remove(link)
if prev_node.type == "TEX_IMAGE":
# Remove linked texture coordinate node if it's no longer used.
for vec_link in list(prev_node.inputs["Vector"].links):
coord_node = vec_link.from_node
blender_material.node_tree.links.remove(vec_link)
if coord_node.type == "TEX_COORD" and not any(o.links for o in coord_node.outputs):
blender_material.node_tree.nodes.remove(coord_node)
blender_material.node_tree.nodes.remove(prev_node)
elif prev_node.type == "RGB":
blender_material.node_tree.nodes.remove(prev_node)
node = blender_material.node_tree.nodes.new(type="ShaderNodeTexImage")
node.location = bsdf.location - Vector((200, 250))
+166 -38
View File
@@ -82,6 +82,7 @@ if TYPE_CHECKING:
BIMPolylineProperties,
BIMRailingProperties,
BIMRoofProperties,
BIMSlabProperties,
BIMStairProperties,
BIMSverchokProperties,
BIMWallProperties,
@@ -118,6 +119,10 @@ class Model(bonsai.core.tool.Model):
def get_railing_props(cls, obj: bpy.types.Object) -> BIMRailingProperties:
return obj.BIMRailingProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_slab_props(cls, obj: bpy.types.Object) -> BIMSlabProperties:
return obj.BIMSlabProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_pipe_segment_props(cls, obj: bpy.types.Object) -> BIMPipeSegmentProperties:
return obj.BIMPipeSegmentProperties # pyright: ignore[reportAttributeAccessIssue]
@@ -809,7 +814,7 @@ class Model(bonsai.core.tool.Model):
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
layer_params = tool.Model.get_material_layer_parameters(element)
layer_offset = layer_params["offset"]
thickness = layer_params["thickness"] / unit_scale
thickness = layer_params["thickness"]
props = tool.Material.get_object_material_props(obj)
# Try to load from pset if not already in props
@@ -817,7 +822,7 @@ class Model(bonsai.core.tool.Model):
pset = ifcopenshell.util.element.get_pset(element, "BBIM_MaterialLayer")
if pset and pset.get("UseCustomOffset", False):
# Load from pset
custom_offset = pset.get("CustomOffset", 0.0)
custom_offset = pset.get("CustomOffset", 0.0) * unit_scale
usage_type = tool.Model.get_usage_type(element)
if usage_type == "LAYER2":
@@ -830,7 +835,7 @@ class Model(bonsai.core.tool.Model):
return None
else:
# Use current props
custom_offset = props.custom_offset / unit_scale
custom_offset = props.custom_offset
if tool.Model.get_usage_type(element) == "LAYER2":
custom_offset_reference = props.custom_wall_reference
elif tool.Model.get_usage_type(element) == "LAYER3":
@@ -841,17 +846,17 @@ class Model(bonsai.core.tool.Model):
direction_sense = layer_params["direction_sense"]
if direction_sense == "POSITIVE" and custom_offset_reference in {"INTERIOR", "TOP"}:
layer_offset = custom_offset - thickness * unit_scale
layer_offset = custom_offset - thickness
if direction_sense == "POSITIVE" and custom_offset_reference in {"CENTER", "MIDDLE"}:
layer_offset = custom_offset - (thickness / 2) * unit_scale
layer_offset = custom_offset - (thickness / 2)
if (direction_sense == "POSITIVE" and custom_offset_reference in {"EXTERIOR", "BOTTOM"}) or (
direction_sense == "NEGATIVE" and custom_offset_reference in {"EXTERIOR", "TOP"}
):
layer_offset = custom_offset
if direction_sense == "NEGATIVE" and custom_offset_reference in {"CENTER", "MIDDLE"}:
layer_offset = custom_offset + (thickness / 2) * unit_scale
layer_offset = custom_offset + (thickness / 2)
if direction_sense == "NEGATIVE" and custom_offset_reference in {"INTERIOR", "BOTTOM"}:
layer_offset = custom_offset + thickness * unit_scale
layer_offset = custom_offset + thickness
return layer_offset / unit_scale
@@ -904,6 +909,46 @@ class Model(bonsai.core.tool.Model):
"""Return True if element has an IfcRelConnectsElements(TOP) relationship."""
return any(rel.is_a("IfcRelConnectsElements") and rel.Description == "TOP" for rel in element.ConnectedFrom)
@classmethod
def strip_underside_booleans(cls, wall: ifcopenshell.entity_instance) -> bool:
"""Remove slab-trim ``IfcBooleanResult`` items from a wall's body chain.
Returns ``True`` if any boolean was removed, so the caller knows whether
a Blender-side body reload is needed to surface the geometry change.
Hook for the duplicate path (Shift+D): the source wall's clip booleans
don't make sense on a copy pulled away from the slab. Booleans whose
``SecondOperand.is_a("IfcTessellatedFaceSet")`` are removed same
imprecise discriminator the rest of the wall-to-underside machinery
uses (manual cuts authored from tessellated meshes would also be
stripped, but most manual cuts use ``IfcExtrudedAreaSolid`` / CSG
primitives and are unaffected).
Cannot reuse ``remove_wall_to_underside_booleans`` here because the
duplicate's ``BBIM_Boolean.Data`` holds the source wall's stale ids
``get_manual_booleans`` returns empty on the copy and the helper
early-returns. The duplicate hook works directly off the chain.
"""
representation = tool.Geometry.get_body_representation(wall)
if not representation:
return False
chain = cls.get_booleans(wall, representation)
to_remove = [b for b in chain if (sec := b.SecondOperand) is not None and sec.is_a("IfcTessellatedFaceSet")]
for b in to_remove:
tool.Geometry.remove_representation_item(b.SecondOperand, wall)
# Sweep the now-stale BBIM_Boolean entries on the copy (their ids point
# at booleans that were never in this wall's chain — they survived the
# ifcopenshell deep copy as JSON text in the pset payload).
pset_data = ifcopenshell.util.element.get_pset(wall, "BBIM_Boolean")
if pset_data:
representation = tool.Geometry.get_body_representation(wall)
chain_ids = {b.id() for b in cls.get_booleans(wall, representation)} if representation else set()
stored_ids = set(json.loads(pset_data["Data"]))
stale_ids = stored_ids - chain_ids
if stale_ids:
cls.unmark_manual_booleans(wall, list(stale_ids))
return bool(to_remove)
@classmethod
def remove_wall_to_underside_booleans(cls, wall: ifcopenshell.entity_instance) -> None:
"""Remove all IfcBooleanResult items previously added by extend_walls_to_underside."""
@@ -1199,6 +1244,13 @@ class Model(bonsai.core.tool.Model):
cls, parent_obj: bpy.types.Object, data: list[dict[str, Any]], array_layers_to_apply: Iterable[int] = tuple()
) -> None:
"""`array_layers_to_apply` - list of array layer indices to apply"""
with tool.Geometry.batch_host_recut():
cls._regenerate_array_body(parent_obj, data, array_layers_to_apply)
@classmethod
def _regenerate_array_body(
cls, parent_obj: bpy.types.Object, data: list[dict[str, Any]], array_layers_to_apply: Iterable[int]
) -> None:
parent_element = tool.Ifc.get_entity(parent_obj)
if pset := ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array"):
@@ -1275,7 +1327,10 @@ class Model(bonsai.core.tool.Model):
# handle elements unused in the array after regeneration
removed_children = set(existing_children) - set(array["children"])
for removed_child in removed_children:
element = tool.Ifc.get().by_guid(removed_child)
try:
element = tool.Ifc.get().by_guid(removed_child)
except RuntimeError:
continue
# Strip any wall/slab opening cut by this child before deletion,
# so the host's HasOpenings shrinks symmetrically with count.
if getattr(element, "FillsVoids", None):
@@ -1394,9 +1449,7 @@ class Model(bonsai.core.tool.Model):
representation = tool.Geometry.get_representation_by_context(voided_element, context)
if representation is None:
continue
bonsai.core.geometry.switch_representation(
tool.Ifc, tool.Geometry, obj=voided_obj, representation=representation
)
tool.Geometry.recut_host(voided_obj, representation)
@classmethod
def unshare_opening_representation(cls, filling: ifcopenshell.entity_instance) -> None:
@@ -2007,47 +2060,86 @@ class Model(bonsai.core.tool.Model):
return (vertices, edges, faces)
@classmethod
def update_simple_openings(cls, element: ifcopenshell.entity_instance) -> None:
def regenerate_filling_opening_body(cls, filling: ifcopenshell.entity_instance) -> Optional[bpy.types.Object]:
"""Regenerate only the mapped source used by ``filling``'s opening so
it matches ``filling``'s current parametric dimensions.
Returns the voided host Blender object so the caller can recut it,
or ``None`` if ``filling`` has no opening to refresh or the host is
an aggregate (no mesh data to recut against)."""
from bonsai.bim.module.model.opening import FilledOpeningGenerator
ifc_file = tool.Ifc.get()
fillings = {e: tool.Ifc.get_object(e) for e in tool.Array.get_parametric_propagation_targets(element)}
if not filling.FillsVoids:
return None
voided_objs = set()
has_replaced_opening_representation = False
ifc_file = tool.Ifc.get()
opening = filling.FillsVoids[0].RelatingOpeningElement
voided_obj = tool.Ifc.get_object(opening.VoidsElements[0].RelatingBuildingElement)
if voided_obj is None or voided_obj.data is None:
return None
old_representation = tool.Geometry.get_body_representation(opening)
if old_representation is None:
return voided_obj
old_representation = tool.Geometry.resolve_mapped_representation(old_representation)
ifcopenshell.api.geometry.unassign_representation(ifc_file, product=opening, representation=old_representation)
filling_obj = tool.Ifc.get_object(filling)
new_representation = FilledOpeningGenerator().generate_opening_from_filling(
filling, filling_obj, voided_obj.dimensions[1]
)
for inverse in ifc_file.get_inverse(old_representation):
ifcopenshell.util.element.replace_attribute(inverse, old_representation, new_representation)
ifcopenshell.api.geometry.remove_representation(ifc_file, representation=old_representation)
return voided_obj
@classmethod
def regenerate_simple_opening_bodies(cls, element: ifcopenshell.entity_instance) -> set:
"""Regenerate every distinct mapped opening source within ``element``'s
type-occurrence family so each one matches the family's current
parametric dimensions.
Most occurrences share a single mapped source refreshing it once
propagates to every filling via inverse-substitution. Some families,
especially those imported from foreign authoring tools, fragment into
several mapped sources for the same type; dedup is by source id so
every distinct source gets one refresh. Returns the set of Blender
objects whose host representation needs a viewport-level recut
(callers handle the recut themselves)."""
ifc_file = tool.Ifc.get()
fillings = list(tool.Array.get_parametric_propagation_targets(element))
voided_objs: set = set()
seen_source_ids: set[int] = set()
for filling in fillings:
if not filling.FillsVoids:
continue
opening = filling.FillsVoids[0].RelatingOpeningElement
voided_obj = tool.Ifc.get_object(opening.VoidsElements[0].RelatingBuildingElement)
voided_objs.add(voided_obj)
if voided_obj is not None:
voided_objs.add(voided_obj)
# We assume all occurrences of the same element type (e.g. a window)
# will use openings of the same thickness.
# Generator we use by default will create a really thick opening representation
# to make sure it will fit for walls with different thickness.
if has_replaced_opening_representation:
body = tool.Geometry.get_body_representation(opening)
if body is None:
continue
source = tool.Geometry.resolve_mapped_representation(body)
if source.id() in seen_source_ids:
continue
seen_source_ids.add(source.id())
old_representation = ifcopenshell.util.representation.get_representation(
opening, "Model", "Body", "MODEL_VIEW"
)
old_representation = tool.Geometry.resolve_mapped_representation(old_representation)
ifcopenshell.api.geometry.unassign_representation(
ifc_file, product=opening, representation=old_representation
)
cls.regenerate_filling_opening_body(filling)
new_representation = FilledOpeningGenerator().generate_opening_from_filling(
filling, fillings[filling], voided_obj.dimensions[1]
)
return voided_objs
for inverse in ifc_file.get_inverse(old_representation):
ifcopenshell.util.element.replace_attribute(inverse, old_representation, new_representation)
ifcopenshell.api.geometry.remove_representation(ifc_file, representation=old_representation)
has_replaced_opening_representation = True
@classmethod
def update_simple_openings(cls, element: ifcopenshell.entity_instance) -> None:
voided_objs = cls.regenerate_simple_opening_bodies(element)
fillings = {e: tool.Ifc.get_object(e) for e in tool.Array.get_parametric_propagation_targets(element)}
tool.Model.reload_body_representation(voided_objs)
if fillings:
@@ -3010,6 +3102,9 @@ class Model(bonsai.core.tool.Model):
regenerate_fillet_corner_wall(element, obj)
return
rep = ifcopenshell.api.geometry.regenerate_wall_representation(tool.Ifc.get(), element)
if rep is None:
# Wall has no IfcMaterialLayerSet — layer-set rebuild not applicable.
return
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
@@ -3024,6 +3119,19 @@ class Model(bonsai.core.tool.Model):
obj.matrix_world = tool.Loader.apply_blender_offset_to_matrix_world(obj, matrix)
tool.Geometry.record_object_position(obj)
@classmethod
def regenerate_wall(cls, obj: bpy.types.Object) -> None:
"""Rebuild a wall's body from current IFC state: extrusion + openings
first, then re-clip to any surviving ``IfcRelConnectsElements(TOP)``
slab. Safe on walls with no openings and no slab connection both
steps no-op against their preconditions."""
element = tool.Ifc.get_entity(obj)
if element is None:
return
cls.recreate_wall(element, obj)
if cls.has_underside_connection(element):
bonsai.core.model.regenerate_wall_to_underside(tool.Ifc, tool.Geometry, cls, [obj])
@classmethod
def recalculate_walls(cls, walls: list[bpy.types.Object]) -> None:
queue: set[tuple[ifcopenshell.entity_instance, bpy.types.Object]] = set()
@@ -3039,6 +3147,26 @@ class Model(bonsai.core.tool.Model):
obj = tool.Ifc.get_object(rel.RelatingElement)
tool.Geometry.commit_placement_if_moved(obj)
queue.add((rel.RelatingElement, obj))
# Sync filling and opening placements so subsequent wall recuts
# operate on the up-to-date opening positions — a filling moved
# along the wall's reference line otherwise stays cut at its old
# spot.
for element, wall in queue:
if not wall:
continue
for rel in getattr(element, "HasOpenings", []) or []:
opening = rel.RelatedOpeningElement
for fill_rel in getattr(opening, "HasFillings", []) or []:
filling = fill_rel.RelatedBuildingElement
filling_obj = tool.Ifc.get_object(filling)
if filling_obj is None or not tool.Ifc.is_moved(filling_obj):
continue
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=filling_obj)
ifcopenshell.api.geometry.edit_object_placement(
tool.Ifc.get(), product=opening, matrix=filling_obj.matrix_world
)
for element, wall in queue:
if not wall:
continue
+31 -6
View File
@@ -81,11 +81,19 @@ class ParametricObject:
``_cancel_targets``) and that therefore wire their operators through
``build_edit_lifecycle``. Entries with bespoke edit lifecycles (per-attribute
diff dispatch, layer-stack editing, mid-spline gizmo drag) leave this
False and declare their operator classes directly."""
False and declare their operator classes directly.
``has_default_parameters`` marks entries whose ``BIM<Name>Properties``
class exposes ``get_general_kwargs`` / ``copy_to`` and a matching
``draw_<name>_properties`` UI helper, so the addon-preferences panel can
surface a per-type defaults section and the create operator can seed new
instances from the preset. Entries without that machinery leave this False
and don't appear in the preferences ``Default Parameters`` panel."""
name: str
has_non_editable_path: bool = False
supports_build_edit_lifecycle: bool = False
has_default_parameters: bool = False
def __post_init__(self) -> None:
if not _VALID_NAME_RE.match(self.name):
@@ -148,15 +156,22 @@ class Parametric(bonsai.core.tool.Parametric):
self._gen = None
EDIT_TYPES: list[ParametricObject] = [
ParametricObject("door", has_non_editable_path=True, supports_build_edit_lifecycle=True),
ParametricObject("window", has_non_editable_path=True, supports_build_edit_lifecycle=True),
ParametricObject("stair", has_non_editable_path=True, supports_build_edit_lifecycle=True),
ParametricObject("railing", supports_build_edit_lifecycle=True),
ParametricObject("roof", supports_build_edit_lifecycle=True),
ParametricObject(
"door", has_non_editable_path=True, supports_build_edit_lifecycle=True, has_default_parameters=True
),
ParametricObject(
"window", has_non_editable_path=True, supports_build_edit_lifecycle=True, has_default_parameters=True
),
ParametricObject(
"stair", has_non_editable_path=True, supports_build_edit_lifecycle=True, has_default_parameters=True
),
ParametricObject("railing", supports_build_edit_lifecycle=True, has_default_parameters=True),
ParametricObject("roof", supports_build_edit_lifecycle=True, has_default_parameters=True),
ParametricObject("array", supports_build_edit_lifecycle=True),
ParametricObject("pipe_segment", supports_build_edit_lifecycle=True),
ParametricObject("duct_segment", supports_build_edit_lifecycle=True),
ParametricObject("wall"),
ParametricObject("slab"),
]
# Annotations for the uppercase constants populated from ``EDIT_TYPES`` by
@@ -171,6 +186,7 @@ class Parametric(bonsai.core.tool.Parametric):
PIPE_SEGMENT: ClassVar[ParametricObject]
DUCT_SEGMENT: ClassVar[ParametricObject]
WALL: ClassVar[ParametricObject]
SLAB: ClassVar[ParametricObject]
_geom_generation: int = 0
@@ -459,6 +475,15 @@ class Parametric(bonsai.core.tool.Parametric):
return False
return tool.Pset.get_element_pset(element, "BBIM_Stair") is not None
@classmethod
def is_slab(cls, element: entity_instance) -> bool:
"""``True`` for any ``IfcSlab``. The slab edit lifecycle only gates
the connection-disconnect UI no IFC mutation so we don't narrow
further (e.g. by checking for wall connections). Per-gizmo polls
layer the "has wall connections" check on top via
``tool.Wall.iter_slab_wall_connections``."""
return element is not None and element.is_a("IfcSlab")
@classmethod
def is_wall(cls, element: entity_instance) -> bool:
"""A wall is editable by the parametric gizmo if it is an IfcWall with LAYER2 usage.
+72
View File
@@ -18,18 +18,33 @@
from __future__ import annotations
import re
from typing import TYPE_CHECKING, Any
import bpy
import ifcopenshell
import ifcopenshell.util.schema
import ifcpatch
import bonsai.core.tool
import bonsai.tool
if TYPE_CHECKING:
from bonsai.bim.module.patch.prop import BIMPatchProperties
# Lower index = older schema. Used to detect downgrades vs upgrades.
_SCHEMA_AGE = {"IFC2X3": 0, "IFC4": 1, "IFC4X3": 2}
# Pretty-printed argument name for the ``Migrate`` recipe's schema parameter
# (see UpdateIfcPatchArguments.pretty_arg_name in bim/module/patch/operator.py).
_MIGRATE_SCHEMA_ARG_NAME = "Schema"
# Match a STEP-encoded FILE_SCHEMA header: ``FILE_SCHEMA(('IFC4'));`` and the
# IFC4X3_ADD2 / IFC2X3_TC1 variants. Captures the bare schema identifier.
_IFC_FILE_SCHEMA_RE = re.compile(r"FILE_SCHEMA\s*\(\s*\(\s*'([^']+)'", re.IGNORECASE)
class Patch(bonsai.core.tool.Patch):
@classmethod
def get_patch_props(cls) -> BIMPatchProperties:
@@ -54,6 +69,63 @@ class Patch(bonsai.core.tool.Patch):
"SplitByBuildingStorey",
)
@classmethod
def get_preset_subdir(cls) -> str:
"""Resolve the preset subdirectory for the currently selected recipe.
Returns a stable string for the ``-`` placeholder so the menu and save
operator remain usable when no real recipe has been picked yet."""
recipe = cls.get_patch_props().ifc_patch_recipes or "-"
return f"bonsai/ifc_patch/{recipe}"
@classmethod
def migration_is_lossy_downgrade(cls) -> bool:
"""``True`` when the currently configured patch is the ``Migrate``
recipe targeting an older schema than the input file. Used to gate
the destructive-migration confirmation dialog."""
props = cls.get_patch_props()
if props.ifc_patch_recipes != "Migrate":
return False
target_schema = next(
(arg.get_value() for arg in props.ifc_patch_args_attr if arg.name == _MIGRATE_SCHEMA_ARG_NAME),
None,
)
if not target_schema:
return False
source_schema = cls._patch_source_schema()
if not source_schema:
return False
return _SCHEMA_AGE.get(target_schema, -1) < _SCHEMA_AGE.get(source_schema, -1)
@classmethod
def _patch_source_schema(cls) -> str:
"""Resolve the IFC schema of the configured input without parsing the
full file. For loaded-from-memory the schema is in the entity_instance
wrapper; for disk paths we read only the STEP file header (first ~2KB)
rather than ``ifcopenshell.open`` which parses the whole file."""
props = cls.get_patch_props()
if props.should_load_from_memory:
ifc_file = bonsai.tool.Ifc.get()
return ifc_file.schema if ifc_file else ""
if not props.ifc_patch_input:
return ""
try:
with open(props.ifc_patch_input, "rb") as f:
header = f.read(2048).decode("utf-8", errors="ignore")
except OSError:
return ""
match = _IFC_FILE_SCHEMA_RE.search(header)
if not match:
return ""
# Collapse IFC4X3_ADD2 / IFC2X3_TC1 / IFC4_ADD2 / IFC4X1 etc. to their
# base via the canonical normaliser — handles longest-prefix-first
# ordering correctly (IFC4X3 before IFC4) so we don't misclassify
# IFC4X3 files as IFC4.
try:
return ifcopenshell.util.schema.get_fallback_schema(match.group(1).upper())
except AssertionError:
return ""
@classmethod
def post_process_patch_arguments(cls, recipe: str, args: list[Any]) -> list[Any]:
if recipe == "ExtractElements":
+34 -4
View File
@@ -32,6 +32,7 @@ from typing import (
NotRequired,
Optional,
TypedDict,
Union,
)
import bpy
@@ -333,6 +334,14 @@ class Project(bonsai.core.tool.Project):
if reference[1]:
m = np.fromstring(reference[1], sep=",", dtype=np.float64).reshape(4, 4)
link.has_transformation = not np.allclose(m, np.eye(4))
# The selector query used at link time is persisted only in the
# sidecar cache JSON; restore it so Reload/Load replay the filter.
json_filepath = Path(tool.Ifc.resolve_uri(filepath)).with_suffix(".ifc.cache.json")
if json_filepath.exists():
try:
link.query = json.loads(json_filepath.read_text()).get("query", "")
except (OSError, json.JSONDecodeError):
pass
@classmethod
def get_project_library_elements(
@@ -376,12 +385,31 @@ class Project(bonsai.core.tool.Project):
)
@classmethod
def get_parent_library(cls, project_library: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance:
def get_parent_library(
cls, project_library: ifcopenshell.entity_instance
) -> Union[ifcopenshell.entity_instance, None]:
"""Return the IfcContext that declares or nests ``project_library``.
Returns ``None`` when ``project_library`` is itself the root of a
library-only file (no IfcRelNests, no IfcRelDeclares).
"""
if nests := project_library.Nests:
# IfcProjectLibrary.
return nests[0].RelatingObject
# IfcProject.
return project_library.HasContext[0].RelatingContext
if has_context := project_library.HasContext:
return has_context[0].RelatingContext
return None
@classmethod
def get_root_context(cls, ifc_file: ifcopenshell.file) -> ifcopenshell.entity_instance:
"""Return the file's root IfcContext.
Prefers IfcProject if present, otherwise falls back to IfcProjectLibrary
library-only files are valid per IFC4+ and contain no IfcProject. Caller is
responsible for the IFC2X3 guard; IfcContext does not exist in that schema.
"""
if projects := ifc_file.by_type("IfcProject"):
return projects[0]
return ifc_file.by_type("IfcProjectLibrary")[0]
@classmethod
def get_project_hierarchy(cls, ifc_file: ifcopenshell.file) -> HiearchyDict:
@@ -401,6 +429,8 @@ class Project(bonsai.core.tool.Project):
return hierarchy
for project_library in ifc_file.by_type("IfcProjectLibrary"):
parent_library = cls.get_parent_library(project_library)
if parent_library is None:
continue
hierarchy[parent_library][project_library] = hierarchy[project_library]
return hierarchy
+3 -11
View File
@@ -373,7 +373,6 @@ class Raycast(bonsai.core.tool.Raycast):
except:
loc = Vector((0, 0, 0))
snap_obj._ensure_bvh()
intersected = snap_obj.raycast_boxes(
context, event, snap_obj.root, intersected=[], rays=(ray_origin, ray_direction)
@@ -395,13 +394,10 @@ class Raycast(bonsai.core.tool.Raycast):
# Lazily project only the needed vertices to 2D screen space
verts_2d: dict[int, Vector] = {}
for idx in verts_idx:
v2d = view3d_utils.location_3d_to_region_2d(
region, rv3d, snap_obj.verts_3d[idx]
)
v2d = view3d_utils.location_3d_to_region_2d(region, rv3d, snap_obj.verts_3d[idx])
if v2d is not None:
verts_2d[idx] = v2d
edge_verts = {}
for e in edges:
verts_idx = snap_obj.obj.data.edges[e].vertices
@@ -885,9 +881,7 @@ class Raycast(bonsai.core.tool.Raycast):
# Process wireframe objects first (all of them, always collected)
for snap_obj in wireframe_objs:
hit_obj, hit = cls.process_wireframe_snap_obj(
context, event, snap_obj, ray_origin, closest_snaps
)
hit_obj, hit = cls.process_wireframe_snap_obj(context, event, snap_obj, ray_origin, closest_snaps)
if hit is not None:
length_squared = (hit - ray_origin).length_squared
if closest_obj is None or length_squared < closest_length_squared:
@@ -926,9 +920,7 @@ class Raycast(bonsai.core.tool.Raycast):
if snap_obj.obj.type in {"EMPTY", "CURVE"} or (
hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0
):
hit_obj, hit = cls.process_wireframe_snap_obj(
context, event, snap_obj, ray_origin, closest_snaps
)
hit_obj, hit = cls.process_wireframe_snap_obj(context, event, snap_obj, ray_origin, closest_snaps)
face_index = None
else:
# Solid objects
+63
View File
@@ -488,6 +488,69 @@ class System(bonsai.core.tool.System):
def is_mep_element(cls, element: ifcopenshell.entity_instance) -> bool:
return element.is_a("IfcFlowSegment") or element.is_a("IfcFlowFitting")
@classmethod
def is_disconnectable_fitting(cls, element: ifcopenshell.entity_instance) -> bool:
"""A fitting whose deletion is the supported teardown for one of
its port connections. ``OBSTRUCTION`` fittings are excluded they
have a dedicated grow/shrink flow (``bim.mep_add_obstruction``
with ``mode=REMOVE``) that absorbs the freed segment length."""
if not element.is_a("IfcFlowFitting"):
return False
return getattr(element, "PredefinedType", None) != "OBSTRUCTION"
@classmethod
def neighbours_at_ports(cls, element: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]:
"""Entities reachable from ``element``'s ports via a single
``IfcRelConnectsPorts`` hop, deduped by IFC id."""
neighbours: list[ifcopenshell.entity_instance] = []
seen: set[int] = set()
for port in cls.get_ports(element):
connected_port = cls.get_connected_port(port)
if connected_port is None:
continue
neighbour = ifcopenshell.util.system.get_port_element(connected_port)
if neighbour is None or neighbour.id() in seen:
continue
seen.add(neighbour.id())
neighbours.append(neighbour)
return neighbours
@classmethod
def find_bridging_fitting(
cls,
elem_a: ifcopenshell.entity_instance,
elem_b: ifcopenshell.entity_instance,
) -> Union[ifcopenshell.entity_instance, None]:
"""Return the disconnectable ``IfcFlowFitting`` whose removal
disconnects ``elem_a`` from ``elem_b``, or ``None``.
Two topologies are handled. (1) Direct port-to-port between a
segment/fitting and a disconnectable fitting: the fitting endpoint
is returned. (2) Two segments joined by a single bridging
disconnectable fitting: the bridging fitting is returned.
``OBSTRUCTION`` fittings short-circuit to ``None``."""
if not (cls.is_mep_element(elem_a) and cls.is_mep_element(elem_b)):
return None
a_neighbours = cls.neighbours_at_ports(elem_a)
b_neighbours = cls.neighbours_at_ports(elem_b)
elem_a_id = elem_a.id()
elem_b_id = elem_b.id()
if cls.is_disconnectable_fitting(elem_a) and any(n.id() == elem_b_id for n in a_neighbours):
return elem_a
if cls.is_disconnectable_fitting(elem_b) and any(n.id() == elem_a_id for n in b_neighbours):
return elem_b
a_fittings = [n for n in a_neighbours if cls.is_disconnectable_fitting(n)]
if not a_fittings:
return None
b_fitting_ids = {n.id() for n in b_neighbours if cls.is_disconnectable_fitting(n)}
for fitting in a_fittings:
if fitting.id() in b_fitting_ids:
return fitting
return None
@classmethod
def has_parametric_body(cls, element: ifcopenshell.entity_instance) -> bool:
"""True when the MEP element's body representation is a profile sweep
+21
View File
@@ -24,6 +24,7 @@ import bpy
import ifcopenshell
import ifcopenshell.util.element
import ifcopenshell.util.representation
import ifcopenshell.util.type
import bonsai.core.geometry
import bonsai.core.tool
@@ -96,6 +97,26 @@ class Type(bonsai.core.tool.Type):
def get_type_occurrences(cls, element_type: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]:
return ifcopenshell.util.element.get_types(element_type)
@classmethod
def is_relating_type_compatible(
cls,
occurrence: ifcopenshell.entity_instance,
relating_type: ifcopenshell.entity_instance,
) -> bool:
# IFC's EXPRESS schema has no WHERE rule pairing IfcRelDefinesByType's
# RelatingType / RelatedObjects classes; the one-to-one class pairing
# is a buildingSMART implementer agreement, not file-validation.
schema = occurrence.file.schema
if relating_type.is_a() in ifcopenshell.util.type.get_applicable_types(occurrence.is_a(), schema=schema):
return True
# The implementer agreement map has no entry for the abstract
# IfcTypeProduct, which Bonsai uses for annotation types. The schema
# defines IfcTypeProduct.ApplicableOccurrence for exactly this purpose,
# so honor it. occurrence.is_a() handles subtypes and unknown tokens.
if applicable_occurrence := getattr(relating_type, "ApplicableOccurrence", None):
return occurrence.is_a(applicable_occurrence.split("/", 1)[0])
return False
@classmethod
def has_material_usage(cls, element: ifcopenshell.entity_instance) -> bool:
material = ifcopenshell.util.element.get_material(element)
+69
View File
@@ -242,6 +242,75 @@ class Wall(bonsai.core.tool.Wall):
local_p2 = Vector((p2[0] * unit_scale, p2[1] * unit_scale, 0.0))
return obj.matrix_world @ local_p1, obj.matrix_world @ local_p2
@classmethod
def iter_wall_slab_connections(cls, wall: ifcopenshell.entity_instance):
"""Yield ``(slab, rel)`` tuples for every ``IfcRelConnectsElements(TOP)``
connecting a slab to this wall the rel kind ``extend_walls_to_underside``
creates. Walks ``wall.ConnectedFrom`` because the slab is the relating
side of the TOP rel."""
for rel in getattr(wall, "ConnectedFrom", []) or ():
if not rel.is_a("IfcRelConnectsElements") or rel.Description != "TOP":
continue
slab = rel.RelatingElement
if slab is None:
continue
yield slab, rel
@classmethod
def iter_slab_wall_connections(cls, slab: ifcopenshell.entity_instance):
"""Yield ``(wall, rel)`` tuples for every wall clipped to this slab's
underside. Mirror of ``iter_wall_slab_connections`` from the slab side
walks ``slab.ConnectedTo``."""
for rel in getattr(slab, "ConnectedTo", []) or ():
if not rel.is_a("IfcRelConnectsElements") or rel.Description != "TOP":
continue
wall = rel.RelatedElement
if wall is None:
continue
yield wall, rel
@classmethod
def find_wall_slab_rel(
cls, wall: ifcopenshell.entity_instance, slab: ifcopenshell.entity_instance
) -> ifcopenshell.entity_instance | None:
"""Return the single ``IfcRelConnectsElements(TOP)`` between ``wall``
and ``slab``, or ``None`` if none exists. Used by the disconnect
operator to find the specific rel to remove."""
for s, rel in cls.iter_wall_slab_connections(wall):
if s == slab:
return rel
return None
WALL_SLAB_CONNECTION_Z_CLEARANCE = 0.5
"""Lift above the wall top so the disconnect icon sits above the
extend-vertical / slope gizmo and reads as "the thing above the wall =
the slab connection"."""
@classmethod
def wall_slab_connection_location_world(
cls, wall_obj: bpy.types.Object, slab_obj: bpy.types.Object
) -> Vector | None:
"""World-space anchor for the wall-slab disconnect icon.
X / Y come from the wall axis midpoint (so the icon sits in the
middle of the wall horizontally); Z is the wall's top in world space
plus ``WALL_SLAB_CONNECTION_Z_CLEARANCE`` so the icon perches above
the slope gizmo. The slab-side gizmo calls this with the same
arguments so both sides of the same connection render a single
visual marker. ``slab_obj`` is kept on the signature for the
symmetric call shape; the helper's body no longer reads from it.
Returns ``None`` when the wall has no reference line."""
ref = cls.get_world_reference_line(wall_obj)
if ref is None:
return None
axis_mid_world = (ref[0] + ref[1]) * 0.5
if wall_obj.bound_box:
wall_top_local_z = max(c[2] for c in wall_obj.bound_box)
wall_top_world_z = (wall_obj.matrix_world @ Vector((0.0, 0.0, wall_top_local_z))).z
else:
wall_top_world_z = axis_mid_world.z
return Vector((axis_mid_world.x, axis_mid_world.y, wall_top_world_z + cls.WALL_SLAB_CONNECTION_Z_CLEARANCE))
@classmethod
def walk_connected_walls(
cls,
+3
View File
@@ -7,8 +7,11 @@ markers =
boundary
brick
bsdd
clash
classification
clip_box
context
contract_guard
cost
covering
debug
+41
View File
@@ -1,5 +1,46 @@
import pytest
class _FakePropsBase:
"""Base for parametric-edit PropertyGroup stand-ins used in lifecycle tests.
The parametric-edit lifecycle mixins read/write a common contract:
``is_editing`` (bool), ``last_kwargs`` (dict | None capture of the last
data written via ``set_props_kwargs_from_ifc_data``),
``set_props_kwargs_from_ifc_data(data)``, and
``get_general_kwargs(convert_to_project_units=True)``. Per-type stand-ins
(door, railing, roof) subclass this and add their own kwargs accessors
and per-type fields."""
def __init__(self, general: dict | None = None):
self.is_editing = False
self.last_kwargs: dict | None = None
self.general = dict(general) if general is not None else {}
def set_props_kwargs_from_ifc_data(self, data):
self.last_kwargs = dict(data)
def get_general_kwargs(self, convert_to_project_units=True):
return dict(self.general)
def make_lifecycle_obj(props, *, name="obj"):
"""Build a ``bpy.types.Object`` stand-in for parametric-lifecycle tests.
The mixin code under test reads ``obj.props`` (the PropertyGroup
stand-in) and ``obj.name`` (used in error reports). ``spec=bpy.types.Object``
catches typo'd attribute access at test time. ``bpy`` is imported inside
the function so this conftest stays importable when bpy is absent."""
from unittest import mock
import bpy
obj = mock.Mock(spec=bpy.types.Object, name=name)
obj.props = props
obj.name = name
return obj
# pytest by default doesn't print steps and where it failed. Let's fix that.
+28
View File
@@ -455,6 +455,7 @@ Scenario: Select Cost Schedule Products
And I press "bim.assign_cost_item_quantity(cost_item={cost_item}, related_object_type='PRODUCT', prop_name='')"
When I press "bim.select_cost_schedule_products(cost_schedule={cost_schedule})"
Then nothing happens
Scenario: Load Cost Item Types
Given an empty IFC project
And I press "bim.add_cost_schedule"
@@ -465,3 +466,30 @@ Scenario: Load Cost Item Types
When I press "bim.add_cost_item(cost_item={cost_item})"
And I press "bim.load_cost_item_types"
Then nothing happens
Scenario: Import one cost schedule from CSV
Given an empty IFC project
When I press "bim.import_cost_schedule_csv(filepath='{cwd}/test/files/Ex1-BoQ-without-query.csv')"
And the variable "cost_schedule" is "{ifc}.by_type('IfcCostSchedule')[0].id()"
And I press "bim.enable_editing_cost_items(cost_schedule={cost_schedule})"
And I press "bim.add_summary_cost_item()"
And the variable "cost_item" is "{ifc}.by_type('IfcCostItem')[0].id()"
And I press "bim.add_cost_item(cost_item={cost_item})"
Then nothing happens
Scenario: Import multiple cost schedules from CSV
Given an empty IFC project
When I press "bim.import_cost_schedule_csv(filepath='{cwd}/test/files/Ex1-BoQ-without-query.csv')"
When I press "bim.import_cost_schedule_csv(filepath='{cwd}/test/files/Ex2-SoR.csv')"
When I press "bim.import_cost_schedule_csv(filepath='{cwd}/test/files/Ex3-BoQ-with-query.csv')"
When I press "bim.import_cost_schedule_csv(filepath='{cwd}/test/files/Ex4-BoQ-with-description.csv')"
When I press "bim.import_cost_schedule_csv(filepath='{cwd}/test/files/Ex5-SoR-with-description.csv')"
When I press "bim.import_cost_schedule_csv(filepath='{cwd}/test/files/Ex6-BoQ-with-categories.csv')"
When I press "bim.import_cost_schedule_csv(filepath='{cwd}/test/files/Ex7-BoQ-with-Rates.csv')"
When I press "bim.import_cost_schedule_csv(filepath='{cwd}/test/files/Ex8-BoQ-with-formula.csv')"
And the variable "cost_schedule" is "{ifc}.by_type('IfcCostSchedule')[0].id()"
And I press "bim.enable_editing_cost_items(cost_schedule={cost_schedule})"
And I press "bim.add_summary_cost_item()"
And the variable "cost_item" is "{ifc}.by_type('IfcCostItem')[0].id()"
And I press "bim.add_cost_item(cost_item={cost_item})"
Then nothing happens
@@ -0,0 +1,17 @@
# 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 <http://www.gnu.org/licenses/>.
@@ -0,0 +1,52 @@
# 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 <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Runtime regression: viewport-decorator install / uninstall keeps the
``handlers`` list empty across repeated cycles.
ClashDecorator is the representative subclass its lifecycle is now
inherited from ``tool.Blender.ViewportDecorator``. The contract pinned
here is the canonical one for every subclass: after each ``uninstall``,
``cls.handlers`` must be empty and ``cls.is_installed`` must be False."""
import bpy
import pytest
from bonsai.bim.module.clash.decorator import ClashDecorator
pytestmark = pytest.mark.clash
@pytest.fixture(autouse=True)
def _reset_decorator_state():
ClashDecorator.uninstall()
yield
ClashDecorator.uninstall()
def test_clash_decorator_handlers_cleared_across_install_cycles():
ctx = bpy.context
for _ in range(3):
ClashDecorator.install(ctx)
assert ClashDecorator.is_installed is True
assert len(ClashDecorator.handlers) > 0
ClashDecorator.uninstall()
assert ClashDecorator.is_installed is False
assert ClashDecorator.handlers == []
@@ -0,0 +1,124 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# 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/>.
#
# This file was generated with the assistance of an AI coding tool.
import bpy
import ifcopenshell
import ifcopenshell.api.spatial
import pytest
import bonsai.tool as tool
from test.bim.bootstrap import NewFile
pytestmark = pytest.mark.clip_box
def _make_ifc_cube(ifc, ifc_class, location=(0.0, 0.0, 0.0), size=2.0):
bpy.ops.mesh.primitive_cube_add(size=size, location=location)
obj = bpy.context.active_object
entity = ifc.create_entity(ifc_class)
tool.Ifc.link(entity, obj)
return entity, obj
class TestAddClipBoxForSourceSpatial(NewFile):
def test_spatial_creates_clip_box_sized_to_contained_walls(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
storey = ifc.create_entity("IfcBuildingStorey")
wall_a, _ = _make_ifc_cube(ifc, "IfcWall", location=(0.0, 0.0, 0.0), size=2.0)
wall_b, _ = _make_ifc_cube(ifc, "IfcWall", location=(4.0, 0.0, 0.0), size=2.0)
ifcopenshell.api.spatial.assign_container(ifc, products=[wall_a, wall_b], relating_structure=storey)
result = bpy.ops.bim.add_clip_box_for_source(source_kind="SPATIAL", source_id=str(storey.id()))
assert result == {"FINISHED"}
scene_props = tool.ClipBox.get_scene_props()
assert len(scene_props.clip_boxes) == 1
host = scene_props.clip_boxes[0].obj
assert tool.ClipBox.get_object_props(host).is_clip_box is True
translation, _, scale = host.matrix_world.decompose()
assert translation.x == pytest.approx(2.0)
assert scale.x == pytest.approx(3.0)
class TestAddClipBoxForSourceClass(NewFile):
def test_class_creates_clip_box_for_all_walls(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
# Two walls + one window; the IfcWall pick should cover only the walls.
_make_ifc_cube(ifc, "IfcWall", location=(0.0, 0.0, 0.0), size=2.0)
_make_ifc_cube(ifc, "IfcWall", location=(4.0, 0.0, 0.0), size=2.0)
_make_ifc_cube(ifc, "IfcWindow", location=(20.0, 0.0, 0.0), size=2.0)
result = bpy.ops.bim.add_clip_box_for_source(source_kind="CLASS", source_id="IfcWall")
assert result == {"FINISHED"}
scene_props = tool.ClipBox.get_scene_props()
host = scene_props.clip_boxes[0].obj
translation, _, scale = host.matrix_world.decompose()
# AABB of the two walls only (x in [-1, 5]); window at x=20 must not contribute.
assert translation.x == pytest.approx(2.0)
assert scale.x == pytest.approx(3.0)
class TestAddClipBoxForSourceEmpty(NewFile):
def test_no_matching_elements_reports_error(self):
# bpy.ops.* raises RuntimeError when an operator reports {"ERROR"},
# so the assertion is on the raised message rather than the return code.
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
walltype = ifc.create_entity("IfcWallType")
# No occurrences linked — TYPE source resolves to 0 elements.
with pytest.raises(RuntimeError, match="No elements found"):
bpy.ops.bim.add_clip_box_for_source(source_kind="TYPE", source_id=str(walltype.id()))
scene_props = tool.ClipBox.get_scene_props()
assert len(scene_props.clip_boxes) == 0
def test_placeholder_source_id_reports_error(self):
# With no IFC file loaded, data.py callbacks return the NO_OPTIONS_ID
# sentinel. Submitting that sentinel as the picked source must ERROR.
from bonsai.bim.module.clip_box import data as clip_data
with pytest.raises(RuntimeError, match="No source selected"):
bpy.ops.bim.add_clip_box_for_source(source_kind="SPATIAL", source_id=clip_data.NO_OPTIONS_ID)
scene_props = tool.ClipBox.get_scene_props()
assert len(scene_props.clip_boxes) == 0
class TestRemoveClipBoxOrphan(NewFile):
def test_remove_orphan_entry_when_host_object_deleted(self):
# The remove operator must work on an orphan entry — i.e. one whose
# host empty was deleted out from under it via the outliner.
bpy.ops.bim.add_clip_box()
scene_props = tool.ClipBox.get_scene_props()
assert len(scene_props.clip_boxes) == 1
host = scene_props.clip_boxes[0].obj
assert host is not None
bpy.data.objects.remove(host, do_unlink=True)
# Entry survives but its `obj` pointer is now None.
assert len(scene_props.clip_boxes) == 1
assert scene_props.clip_boxes[0].obj is None
result = bpy.ops.bim.remove_clip_box(index=0)
assert result == {"FINISHED"}
assert len(scene_props.clip_boxes) == 0
@@ -0,0 +1,901 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# 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/>.
#
# This file was generated with the assistance of an AI coding tool.
import math
from unittest.mock import patch
import bpy
import pytest
from mathutils import Matrix, Vector
import bonsai.tool as tool
from test.bim.bootstrap import NewFile
pytestmark = pytest.mark.clip_box
class TestAddClipBox(NewFile):
def test_creates_empty_and_registers_entry(self):
result = bpy.ops.bim.add_clip_box()
assert result == {"FINISHED"}
scene_props = tool.ClipBox.get_scene_props()
assert len(scene_props.clip_boxes) == 1
host = scene_props.clip_boxes[0].obj
assert host is not None
assert host.empty_display_type == "CUBE"
obj_props = tool.ClipBox.get_object_props(host)
assert obj_props.is_clip_box is True
def test_spawns_at_3d_cursor(self):
bpy.context.scene.cursor.location = (4.0, 0.0, 2.0)
bpy.ops.bim.add_clip_box()
host = tool.ClipBox.get_active_clip_box()
assert host is not None
assert host.matrix_world.translation.x == pytest.approx(4.0)
assert host.matrix_world.translation.z == pytest.approx(2.0)
def test_spawns_in_clip_boxes_collection(self):
bpy.ops.bim.add_clip_box()
host = tool.ClipBox.get_active_clip_box()
collection_names = [c.name for c in host.users_collection]
assert "BBIM_ClipBoxes" in collection_names
class TestActiveClipBoxResolution(NewFile):
def test_no_box_returns_none(self):
assert tool.ClipBox.get_active_clip_box() is None
def test_active_index_out_of_range_returns_none(self):
bpy.ops.bim.add_clip_box()
scene_props = tool.ClipBox.get_scene_props()
scene_props.active_clip_box_index = 99
assert tool.ClipBox.get_active_clip_box() is None
class TestComputePlanes(NewFile):
def test_planes_match_unit_box_at_origin(self):
bpy.context.scene.cursor.location = (0.0, 0.0, 0.0)
bpy.ops.bim.add_clip_box()
host = tool.ClipBox.get_active_clip_box()
host.matrix_world = Matrix.Identity(4)
planes = tool.ClipBox.compute_planes(host)
assert tool.Cad.point_is_inside_clip_planes(planes, Vector((0, 0, 0)))
assert not tool.Cad.point_is_inside_clip_planes(planes, Vector((2, 0, 0)))
assert not tool.Cad.point_is_inside_clip_planes(planes, Vector((0, 0, -2)))
def test_scaled_host_grows_clip_region(self):
bpy.context.scene.cursor.location = (0.0, 0.0, 0.0)
bpy.ops.bim.add_clip_box()
host = tool.ClipBox.get_active_clip_box()
host.matrix_world = Matrix.Diagonal((3.0, 1.0, 1.0, 1.0))
# Test points well clear of any reasonable expand margin so the
# assertion pins the OBB scaling behaviour, not the margin value.
planes = tool.ClipBox.compute_planes(host)
assert tool.Cad.point_is_inside_clip_planes(planes, Vector((2.5, 0, 0)))
assert not tool.Cad.point_is_inside_clip_planes(planes, Vector((4.0, 0, 0)))
def test_rotated_host_rotates_clip_region(self):
bpy.context.scene.cursor.location = (0.0, 0.0, 0.0)
bpy.ops.bim.add_clip_box()
host = tool.ClipBox.get_active_clip_box()
host.matrix_world = Matrix.Rotation(math.radians(45), 4, "Z")
# Test points well clear of the expand margin so the assertion
# pins rotation, not the margin value.
planes = tool.ClipBox.compute_planes(host)
assert tool.Cad.point_is_inside_clip_planes(planes, Vector((0.5, 0, 0)))
assert not tool.Cad.point_is_inside_clip_planes(planes, Vector((2.0, 0, 0)))
def test_translated_and_rotated_host_keeps_centre_inside(self):
bpy.context.scene.cursor.location = (5.0, 7.0, 0.0)
bpy.ops.bim.add_clip_box()
host = tool.ClipBox.get_active_clip_box()
host.matrix_world = Matrix.Translation((5.0, 7.0, 0.0)) @ Matrix.Rotation(math.radians(30), 4, "Z")
planes = tool.ClipBox.compute_planes(host)
assert tool.Cad.point_is_inside_clip_planes(planes, Vector((5, 7, 0)))
def test_margin_grows_with_scale(self):
# The empty's CUBE display lives at local +-1; the GPU dot
# product that tests each wireframe vertex against the clip
# planes has float error that scales with the axis's world
# half-extent. A fixed absolute margin gets eaten by that drift
# once the box is spawned at non-trivial scale, so the half-
# extent the planes encode must include a relative term — the
# margin between the wireframe edge and the plane must grow
# with the scale.
bpy.ops.bim.add_clip_box()
host = tool.ClipBox.get_active_clip_box()
host.matrix_world = Matrix.Identity(4)
planes_unit = tool.ClipBox.compute_planes(host)
# +X plane: normal (-1, 0, 0), d = half_x. Read half from d.
half_unit = planes_unit[0][3]
margin_unit = half_unit - 1.0
host.matrix_world = Matrix.Diagonal((100.0, 100.0, 100.0, 1.0))
planes_scaled = tool.ClipBox.compute_planes(host)
half_scaled = planes_scaled[0][3]
margin_scaled = half_scaled - 100.0
assert margin_scaled > margin_unit * 10, (
f"margin must scale with extent: unit={margin_unit:g}, " f"scale-100={margin_scaled:g}"
)
class TestToggleEnabled(NewFile):
def test_flips_scene_enabled_flag(self):
bpy.ops.bim.add_clip_box()
scene_props = tool.ClipBox.get_scene_props()
original = scene_props.enabled
bpy.ops.bim.toggle_clip_box_enabled()
assert scene_props.enabled is (not original)
class TestSetActiveClipBox(NewFile):
def test_switches_active_index(self):
bpy.ops.bim.add_clip_box()
bpy.ops.bim.add_clip_box()
scene_props = tool.ClipBox.get_scene_props()
assert scene_props.active_clip_box_index == 1
bpy.ops.bim.set_active_clip_box(index=0)
assert scene_props.active_clip_box_index == 0
def test_invalid_index_cancels(self):
bpy.ops.bim.add_clip_box()
result = bpy.ops.bim.set_active_clip_box(index=99)
assert result == {"CANCELLED"}
class TestRemoveClipBox(NewFile):
def test_drops_active_entry_when_no_index(self):
bpy.ops.bim.add_clip_box()
host = tool.ClipBox.get_active_clip_box()
host_name = host.name
bpy.ops.bim.remove_clip_box(delete_object=True)
assert host_name not in bpy.data.objects
scene_props = tool.ClipBox.get_scene_props()
assert len(scene_props.clip_boxes) == 0
def test_drops_specified_index(self):
# Per-row UIList button passes index explicitly; the user can
# click X on any row without first selecting it as active.
bpy.ops.bim.add_clip_box()
bpy.ops.bim.add_clip_box()
scene_props = tool.ClipBox.get_scene_props()
first_name = scene_props.clip_boxes[0].obj.name
bpy.ops.bim.remove_clip_box(index=0)
assert first_name not in bpy.data.objects
assert len(scene_props.clip_boxes) == 1
def test_no_active_box_cancels(self):
result = bpy.ops.bim.remove_clip_box()
assert result == {"CANCELLED"}
def test_out_of_range_index_cancels(self):
bpy.ops.bim.add_clip_box()
result = bpy.ops.bim.remove_clip_box(index=99)
assert result == {"CANCELLED"}
class TestPsetPersistence(NewFile):
def test_add_clip_box_writes_project_pset(self):
import ifcopenshell.util.element
bpy.ops.bim.create_project()
bpy.ops.bim.add_clip_box()
project = tool.Ifc.get().by_type("IfcProject")[0]
psets = ifcopenshell.util.element.get_psets(project)
assert tool.ClipBox.PSET_NAME in psets
assert psets[tool.ClipBox.PSET_NAME]["Count"] == 1
def test_round_trip_via_pset_restores_matrix(self):
bpy.ops.bim.create_project()
bpy.ops.bim.add_clip_box()
host = tool.ClipBox.get_active_clip_box()
host.matrix_world = Matrix.Translation((5.0, 7.0, 3.0)) @ Matrix.Diagonal((2.0, 1.5, 0.5, 1.0))
tool.ClipBox.save_to_project_pset()
# Simulate a fresh-load state: clear scene list AND delete the
# Blender empty so load has to recreate it.
scene_props = tool.ClipBox.get_scene_props()
scene_props.clip_boxes.clear()
bpy.data.objects.remove(host, do_unlink=True)
tool.ClipBox.load_from_project_pset()
assert len(scene_props.clip_boxes) == 1
rehydrated = scene_props.clip_boxes[0].obj
assert rehydrated is not None
for r in range(4):
for c in range(4):
expected = (Matrix.Translation((5.0, 7.0, 3.0)) @ Matrix.Diagonal((2.0, 1.5, 0.5, 1.0)))[r][c]
assert rehydrated.matrix_world[r][c] == pytest.approx(expected, abs=1e-6)
def test_load_from_pset_is_idempotent(self):
bpy.ops.bim.create_project()
bpy.ops.bim.add_clip_box()
tool.ClipBox.load_from_project_pset()
tool.ClipBox.load_from_project_pset()
assert len(tool.ClipBox.get_scene_props().clip_boxes) == 1
def test_load_from_pset_does_not_touch_enabled(self):
# ``enabled`` is intentionally not persisted to the pset — the
# default is False (fresh .blend) and Blender's own .blend
# session save carries the user's saved value through reload.
# ``load_from_project_pset`` must not stomp either.
bpy.ops.bim.create_project()
bpy.ops.bim.add_clip_box()
scene_props = tool.ClipBox.get_scene_props()
scene_props.enabled = True
tool.ClipBox.save_to_project_pset()
# Simulate the depsgraph IFC-reload branch: load runs without
# touching enabled; the prior True value must survive.
tool.ClipBox.load_from_project_pset()
assert scene_props.enabled is True
# And the opposite: load when False must not flip it True.
scene_props.enabled = False
tool.ClipBox.load_from_project_pset()
assert scene_props.enabled is False
def test_add_clip_box_creates_no_ifc_entity(self):
# The clip box is project-pset persisted; there must be no
# IfcRoot entity attached to the empty (which would lock its
# scale and strip it on export).
bpy.ops.bim.create_project()
bpy.ops.bim.add_clip_box()
host = tool.ClipBox.get_active_clip_box()
assert tool.Ifc.get_entity(host) is None
def test_show_caps_round_trips_via_pset(self):
# show_caps is a scene-level toggle persisted in the project pset.
# Per-mesh cap cost dominates the bisect, which doesn't scale with
# clip-box extent, so the toggle applies file-wide.
bpy.ops.bim.create_project()
bpy.ops.bim.add_clip_box()
host = tool.ClipBox.get_active_clip_box()
scene_props = tool.ClipBox.get_scene_props()
scene_props.show_caps = False
tool.ClipBox.save_to_project_pset()
scene_props.clip_boxes.clear()
scene_props.show_caps = True # default; load_from_project_pset must flip back to False
bpy.data.objects.remove(host, do_unlink=True)
tool.ClipBox.load_from_project_pset()
assert scene_props.show_caps is False
class TestCapGeneration(NewFile):
def test_cap_for_box_straddling_clip_plane_produces_triangles(self):
# A 2x2x2 cube centred at the origin, clipped by a unit-radius clip
# box also at the origin: the four faces of the cube that pierce
# the +/- x box faces should yield cap polygons on the two faces.
bpy.ops.bim.add_clip_box()
host = tool.ClipBox.get_active_clip_box()
host.matrix_world = Matrix.Identity(4) # unit box at origin
bpy.ops.mesh.primitive_cube_add(size=4.0, location=(0.0, 0.0, 0.0))
cube = bpy.context.active_object
world_planes = tool.Cad.obb_clip_planes_from_matrix(host.matrix_world)
verts = tool.ClipBox._compute_caps_for_object(cube, world_planes)
# Every cap is at least one triangle (3 verts each), and we expect
# 6 cap polygons (one per box face) → at minimum 18 verts.
assert len(verts) >= 18
assert len(verts) % 3 == 0
def test_cap_for_mesh_entirely_outside_box_produces_nothing(self):
bpy.ops.bim.add_clip_box()
host = tool.ClipBox.get_active_clip_box()
host.matrix_world = Matrix.Identity(4)
bpy.ops.mesh.primitive_cube_add(size=1.0, location=(10.0, 0.0, 0.0))
cube = bpy.context.active_object
world_planes = tool.Cad.obb_clip_planes_from_matrix(host.matrix_world)
verts = tool.ClipBox._compute_caps_for_object(cube, world_planes)
assert verts == []
def test_cap_for_mesh_entirely_inside_box_produces_nothing(self):
bpy.ops.bim.add_clip_box()
host = tool.ClipBox.get_active_clip_box()
host.matrix_world = Matrix.Identity(4)
bpy.ops.mesh.primitive_cube_add(size=0.5, location=(0.0, 0.0, 0.0))
cube = bpy.context.active_object
world_planes = tool.Cad.obb_clip_planes_from_matrix(host.matrix_world)
verts = tool.ClipBox._compute_caps_for_object(cube, world_planes)
assert verts == []
def test_non_watertight_mesh_does_not_crash(self):
# The cap pipeline assumes watertight input; non-watertight
# meshes (terrain, single-shell surfaces) may produce degenerate
# caps but must not raise. The user is responsible for input
# quality.
bpy.ops.bim.add_clip_box()
host = tool.ClipBox.get_active_clip_box()
host.matrix_world = Matrix.Identity(4)
bpy.ops.mesh.primitive_grid_add(size=4.0, location=(0.0, 0.0, 0.0))
grid = bpy.context.active_object
world_planes = tool.Cad.obb_clip_planes_from_matrix(host.matrix_world)
verts = tool.ClipBox._compute_caps_for_object(grid, world_planes)
assert isinstance(verts, list)
def test_show_caps_defaults_on_and_toggles(self):
bpy.ops.bim.add_clip_box()
scene_props = tool.ClipBox.get_scene_props()
assert scene_props.show_caps is True
scene_props.show_caps = False
assert scene_props.show_caps is False
class TestCapEligibility(NewFile):
def test_ifc_space_is_not_capped(self):
# IfcSpace is an IfcProduct (spatial structure) but should never
# cap — spaces are non-physical containers; capping them sprouts
# solid fills where the room boundary crosses the clip plane.
tool.Project.get_project_props().template_file = "IFC4 Demo Template.ifc"
bpy.ops.bim.create_project()
bpy.ops.bim.add_clip_box()
bpy.ops.mesh.primitive_cube_add(size=2, location=(0, 0, 0))
space_obj = bpy.context.active_object
tool.Root.get_root_props().ifc_product = "IfcSpatialElement"
bpy.ops.bim.assign_class(ifc_class="IfcSpace")
capable = list(tool.ClipBox._iter_capable_objects(bpy.context.scene))
assert space_obj not in capable
def test_ifc_wall_is_capped(self):
tool.Project.get_project_props().template_file = "IFC4 Demo Template.ifc"
bpy.ops.bim.create_project()
bpy.ops.bim.add_clip_box()
bpy.ops.mesh.primitive_cube_add(size=2, location=(0, 0, 0))
wall_obj = bpy.context.active_object
tool.Root.get_root_props().ifc_product = "IfcElement"
bpy.ops.bim.assign_class(ifc_class="IfcWall")
capable = list(tool.ClipBox._iter_capable_objects(bpy.context.scene))
assert wall_obj in capable
def test_pure_blender_mesh_is_not_capped(self):
tool.Project.get_project_props().template_file = "IFC4 Demo Template.ifc"
bpy.ops.bim.create_project()
bpy.ops.bim.add_clip_box()
bpy.ops.mesh.primitive_cube_add(size=2, location=(0, 0, 0))
cube = bpy.context.active_object
# No assign_class — pure Blender mesh, no IFC entity attached.
capable = list(tool.ClipBox._iter_capable_objects(bpy.context.scene))
assert cube not in capable
class TestDuplicateClipBox(NewFile):
def test_duplicates_active_when_no_index(self):
bpy.ops.bim.add_clip_box()
source = tool.ClipBox.get_active_clip_box()
source.matrix_world = Matrix.Translation((3.0, 4.0, 5.0)) @ Matrix.Diagonal((2.0, 1.0, 1.0, 1.0))
bpy.ops.bim.duplicate_clip_box()
scene_props = tool.ClipBox.get_scene_props()
assert len(scene_props.clip_boxes) == 2
copy = tool.ClipBox.get_active_clip_box()
assert copy is not source
for r in range(4):
for c in range(4):
assert copy.matrix_world[r][c] == pytest.approx(source.matrix_world[r][c])
assert tool.ClipBox.get_object_props(copy).is_clip_box is True
def test_duplicates_specified_index(self):
bpy.ops.bim.add_clip_box()
first = tool.ClipBox.get_active_clip_box()
bpy.ops.bim.add_clip_box()
# Active is now index 1; duplicate index 0 explicitly.
bpy.ops.bim.duplicate_clip_box(index=0)
scene_props = tool.ClipBox.get_scene_props()
assert len(scene_props.clip_boxes) == 3
copy = tool.ClipBox.get_active_clip_box()
for r in range(4):
for c in range(4):
assert copy.matrix_world[r][c] == pytest.approx(first.matrix_world[r][c])
def test_no_active_box_cancels(self):
result = bpy.ops.bim.duplicate_clip_box()
assert result == {"CANCELLED"}
def test_out_of_range_index_cancels(self):
bpy.ops.bim.add_clip_box()
result = bpy.ops.bim.duplicate_clip_box(index=99)
assert result == {"CANCELLED"}
def test_duplicate_arms_clipping(self):
bpy.ops.bim.add_clip_box() # arms
scene_props = tool.ClipBox.get_scene_props()
scene_props.enabled = False # user disables
bpy.ops.bim.duplicate_clip_box() # re-arms
assert scene_props.enabled is True
class TestCollectionSync(NewFile):
def test_sync_adopts_orphan_clip_box_empty(self):
# Simulates Bonsai's Shift+D duplicate: an empty with is_clip_box=True
# exists in the BBIM_ClipBoxes collection but no scene-list entry
# points at it. The sync must adopt it as a first-class clip box.
bpy.ops.bim.add_clip_box()
source = tool.ClipBox.get_active_clip_box()
orphan = bpy.data.objects.new(source.name, None)
orphan.empty_display_type = "CUBE"
orphan.empty_display_size = 1.0
orphan.matrix_world = source.matrix_world.copy()
tool.ClipBox.get_object_props(orphan).is_clip_box = True
collection = bpy.data.collections.get("BBIM_ClipBoxes")
collection.objects.link(orphan)
tool.ClipBox._sync_collection_to_list(bpy.context.scene)
scene_props = tool.ClipBox.get_scene_props()
assert len(scene_props.clip_boxes) == 2
assert scene_props.clip_boxes[-1].obj is orphan
assert scene_props.active_clip_box_index == 1
def test_sync_skips_unflagged_empties(self):
bpy.ops.bim.add_clip_box()
collection = bpy.data.collections.get("BBIM_ClipBoxes")
decoy = bpy.data.objects.new("Decoy", None)
collection.objects.link(decoy)
tool.ClipBox._sync_collection_to_list(bpy.context.scene)
scene_props = tool.ClipBox.get_scene_props()
assert len(scene_props.clip_boxes) == 1
class TestEnabledIsSceneLevel(NewFile):
def test_default_is_false(self):
scene_props = tool.ClipBox.get_scene_props()
assert scene_props.enabled is False
def test_add_arms_clipping(self):
# Adding any clip box flips enabled True so the user
# immediately sees the cut and discovers the panel toggle
# by association.
scene_props = tool.ClipBox.get_scene_props()
assert scene_props.enabled is False
bpy.ops.bim.add_clip_box()
assert scene_props.enabled is True
def test_subsequent_adds_re_arm_after_user_disables(self):
bpy.ops.bim.add_clip_box() # arms
scene_props = tool.ClipBox.get_scene_props()
scene_props.enabled = False # user disables
bpy.ops.bim.add_clip_box() # second add re-arms
assert scene_props.enabled is True
def test_selecting_clip_box_does_not_arm(self):
# Per design, selecting a clip box empty must NOT toggle the
# scene-level enabled — activation is panel-only. Otherwise a
# casual click in the outliner would silently hide geometry
# with no obvious unarm path for a user who hasn't found the
# panel yet.
bpy.ops.bim.add_clip_box()
scene_props = tool.ClipBox.get_scene_props()
scene_props.enabled = False # disable after first-add auto-arm
host = tool.ClipBox.get_active_clip_box()
bpy.context.view_layer.objects.active = host
tool.ClipBox.on_depsgraph_update(bpy.context.scene, None)
assert scene_props.enabled is False
def test_toggle_operator_flips_scene_enabled(self):
bpy.ops.bim.add_clip_box() # first-add arms it
scene_props = tool.ClipBox.get_scene_props()
assert scene_props.enabled is True
bpy.ops.bim.toggle_clip_box_enabled()
assert scene_props.enabled is False
bpy.ops.bim.toggle_clip_box_enabled()
assert scene_props.enabled is True
class TestSpawnScale(NewFile):
def test_default_scale_is_ten(self):
# Default spawn scale is 10 (=20m cube) to cover a typical
# storey, not the meaningless 1m unit cube.
bpy.ops.bim.add_clip_box()
host = tool.ClipBox.get_active_clip_box()
assert tuple(host.scale) == pytest.approx((10.0, 10.0, 10.0))
class TestCapRebuildDebounce(NewFile):
"""The depsgraph handler debounces cap rebuilds so a burst of
updates (e.g. an external-addon gizmo drag) collapses to one
rebuild ~250 ms after the storm subsides. Bonsai's own transform
modals get a fast path: an immediate rebuild on the TrueFalse
transition of ``is_transform_modal_active``.
"""
def setup_method(self):
bpy.ops.bim.add_clip_box()
tool.ClipBox._cancel_pending_cap_rebuild()
tool.ClipBox._last_modal_state = False
tool.ClipBox._last_seen_object_matrices.clear()
def teardown_method(self):
tool.ClipBox._cancel_pending_cap_rebuild()
tool.ClipBox._last_modal_state = False
tool.ClipBox._last_seen_object_matrices.clear()
def test_modal_end_triggers_immediate_rebuild(self):
# Prime "previous tick had a modal active" then run a tick with
# no modal → fast path fires rebuild_cap_cache synchronously,
# bypassing the timer. Targets _handle_cap_tick directly to
# bypass the screen guard that aborts in headless test runs.
tool.ClipBox._last_modal_state = True
with (
patch.object(tool.Blender, "is_transform_modal_active", return_value=False),
patch.object(tool.ClipBox, "rebuild_cap_cache") as mock_rebuild,
patch.object(tool.ClipBox, "_schedule_cap_rebuild") as mock_schedule,
):
tool.ClipBox._handle_cap_tick(bpy.context.scene, None)
mock_rebuild.assert_called_once()
mock_schedule.assert_not_called()
def test_burst_collapses_to_one_pending_timer(self):
# 5 ticks with no modal active → schedule called 5 times; each
# call cancels the previous pending timer and registers a fresh
# one, so exactly one timer is pending at the end.
with (
patch.object(tool.Blender, "is_transform_modal_active", return_value=False),
patch.object(tool.ClipBox, "rebuild_cap_cache"),
):
for _ in range(5):
tool.ClipBox._handle_cap_tick(bpy.context.scene, None)
pending = tool.ClipBox._pending_cap_rebuild
assert pending is not None
assert bpy.app.timers.is_registered(pending)
tool.ClipBox._cancel_pending_cap_rebuild()
def test_pending_rebuild_hides_caps(self):
# While a debounce is in flight, on_post_view_caps must not
# draw — the cached batches reflect an earlier frame and would
# look stale against the geometry being mutated.
scene_props = tool.ClipBox.get_scene_props()
scene_props.enabled = True
scene_props.show_caps = True
with (
patch.object(tool.Blender, "is_transform_modal_active", return_value=False),
patch.object(tool.ClipBox, "rebuild_cap_cache"),
):
tool.ClipBox._handle_cap_tick(bpy.context.scene, None)
assert tool.ClipBox._pending_cap_rebuild is not None
# Populate cap_cache to non-empty so the first-line gate
# "if not cls._cap_cache: return" doesn't fire — the contract
# we're pinning is the pending-rebuild gate specifically.
tool.ClipBox._cap_cache["sentinel"] = ((), None)
try:
# If pending-rebuild gate works, on_post_view_caps exits
# before importing gpu / building a shader. Patch
# gpu.shader.from_builtin to fail loudly if drawing happens.
with (
patch.object(tool.Blender, "is_transform_modal_active", return_value=False),
patch("gpu.shader.from_builtin", side_effect=AssertionError("should be hidden")),
):
tool.ClipBox.on_post_view_caps()
finally:
tool.ClipBox._cap_cache.pop("sentinel", None)
tool.ClipBox._cancel_pending_cap_rebuild()
def test_cancel_pending_drops_timer(self):
with patch.object(tool.Blender, "is_transform_modal_active", return_value=False):
tool.ClipBox._schedule_cap_rebuild()
pending = tool.ClipBox._pending_cap_rebuild
assert pending is not None and bpy.app.timers.is_registered(pending)
tool.ClipBox._cancel_pending_cap_rebuild()
assert tool.ClipBox._pending_cap_rebuild is None
assert not bpy.app.timers.is_registered(pending)
def test_unregister_handler_cancels_pending(self):
# The module's unregister() must drop any pending rebuild so a
# timer can't fire against a freed addon. Exercise the helper
# directly — full addon unregister would tear down too much for
# a unit test.
with patch.object(tool.Blender, "is_transform_modal_active", return_value=False):
tool.ClipBox._schedule_cap_rebuild()
assert tool.ClipBox._pending_cap_rebuild is not None
tool.ClipBox._cancel_pending_cap_rebuild()
assert tool.ClipBox._pending_cap_rebuild is None
def test_selection_only_tick_does_not_schedule(self):
# Selecting an Object raises is_updated_transform=True on the
# Object itself even though no actual matrix delta occurred
# (Blender quirk). The matrix-hash baseline must filter that
# out so the cache and hide-while-pending gate don't flash on
# every click. Also covers the Scene/ViewLayer noise.
bpy.ops.mesh.primitive_cube_add()
cube = bpy.context.active_object
# Prime the baseline so cube's current matrix hash is "seen".
tool.ClipBox._last_seen_object_matrices[cube.name] = tool.Blender.hash_matrix(cube.matrix_world)
class _SceneUpdate:
id = bpy.context.scene
is_updated_geometry = False
is_updated_transform = True
class _CubeSelectionUpdate:
id = cube
is_updated_geometry = False
is_updated_transform = True # quirk: matrix unchanged
class _FakeDepsgraph:
updates = (_SceneUpdate(), _CubeSelectionUpdate())
with (
patch.object(tool.Blender, "is_transform_modal_active", return_value=False),
patch.object(tool.ClipBox, "_schedule_cap_rebuild") as mock_schedule,
):
tool.ClipBox._handle_cap_tick(bpy.context.scene, _FakeDepsgraph())
mock_schedule.assert_not_called()
assert tool.ClipBox._pending_cap_rebuild is None
def test_real_transform_tick_does_schedule(self):
bpy.ops.mesh.primitive_cube_add()
cube = bpy.context.active_object
# Baseline hash, then mutate the matrix so the filter sees a
# true delta on the next tick.
tool.ClipBox._last_seen_object_matrices[cube.name] = tool.Blender.hash_matrix(cube.matrix_world)
cube.matrix_world = cube.matrix_world @ Matrix.Translation((1.0, 0, 0))
class _TransformUpdate:
id = cube
is_updated_geometry = False
is_updated_transform = True
class _FakeDepsgraph:
updates = (_TransformUpdate(),)
with (
patch.object(tool.Blender, "is_transform_modal_active", return_value=False),
patch.object(tool.ClipBox, "_schedule_cap_rebuild") as mock_schedule,
):
tool.ClipBox._handle_cap_tick(bpy.context.scene, _FakeDepsgraph())
mock_schedule.assert_called_once()
def test_geometry_update_tick_does_schedule(self):
bpy.ops.mesh.primitive_cube_add()
cube = bpy.context.active_object
class _GeometryUpdate:
id = cube
is_updated_geometry = True
is_updated_transform = False
class _FakeDepsgraph:
updates = (_GeometryUpdate(),)
with (
patch.object(tool.Blender, "is_transform_modal_active", return_value=False),
patch.object(tool.ClipBox, "_schedule_cap_rebuild") as mock_schedule,
):
tool.ClipBox._handle_cap_tick(bpy.context.scene, _FakeDepsgraph())
mock_schedule.assert_called_once()
def test_edit_mode_skips_scheduling(self):
# In any EDIT_* mode the depsgraph fires per vert/edge nudge;
# the cap view isn't the focus and would flash off on every
# tick. The entry-point gate must short-circuit before the
# debounce scheduler runs.
with (
patch.object(tool.Blender, "is_in_edit_mode", return_value=True),
patch.object(tool.ClipBox, "_handle_cap_tick") as mock_handle,
):
tool.ClipBox.on_depsgraph_update_caps(bpy.context.scene, None)
mock_handle.assert_not_called()
class TestClipBbReArmTriggers(NewFile):
"""The C-side clip_bb captured by view3d.clip_border for edit-mode
click-select is view-aligned and tied to the box pose at arm time,
so it goes stale on either a clip-box transform commit, an external
matrix mutation, or an IFC reload that rehydrates from pset. The
depsgraph handler schedules a full re-arm at those events; the
modal gate suppresses per-tick re-arms during a live drag.
"""
@pytest.fixture(autouse=True)
def reset_clipbox_state_after_newfile(self, setup):
# ``setup`` is NewFile's autouse fixture; declaring it as a parameter
# forces this fixture to run AFTER it. NewFile.setup calls
# wm.read_homefile, which fires our load_pre handler and leaves the
# _file_loading gate True — tests below exercise on_depsgraph_update
# in the normal (post-load) state, so the gate must be open here.
tool.ClipBox._file_loading = False
tool.ClipBox._post_load_paint_pending = False
tool.ClipBox._persisted_matrices.clear()
tool.ClipBox._last_seen_ifc_id = 0
tool.ClipBox._cancel_pending_refresh()
def teardown_method(self):
tool.ClipBox._persisted_matrices.clear()
tool.ClipBox._last_seen_ifc_id = 0
tool.ClipBox._cancel_pending_refresh()
def test_matrix_change_outside_modal_re_arms(self):
bpy.ops.bim.add_clip_box()
host = tool.ClipBox.get_active_clip_box()
# Seed a stale baseline so prev_matrix != current_matrix.
stale = tuple(tuple(row) for row in Matrix.Translation((-99.0, 0.0, 0.0)))
tool.ClipBox._persisted_matrices[host.name] = stale
with (
patch.object(tool.Blender, "is_transform_modal_active", return_value=False),
patch.object(tool.ClipBox, "schedule_refresh") as mock_refresh,
):
tool.ClipBox.on_depsgraph_update(bpy.context.scene, bpy.context.evaluated_depsgraph_get())
mock_refresh.assert_called_once()
def test_matrix_change_during_modal_skips_re_arm(self):
bpy.ops.bim.add_clip_box()
host = tool.ClipBox.get_active_clip_box()
stale = tuple(tuple(row) for row in Matrix.Translation((-99.0, 0.0, 0.0)))
tool.ClipBox._persisted_matrices[host.name] = stale
with (
patch.object(tool.Blender, "is_transform_modal_active", return_value=True),
patch.object(tool.ClipBox, "schedule_refresh") as mock_refresh,
):
tool.ClipBox.on_depsgraph_update(bpy.context.scene, bpy.context.evaluated_depsgraph_get())
mock_refresh.assert_not_called()
def test_first_matrix_sighting_does_not_re_arm(self):
# No prior persisted-matrix entry: the branch records the
# baseline and exits without re-arming. The add path already
# armed once; a per-tick re-arm on first sight would double-arm.
bpy.ops.bim.add_clip_box()
host = tool.ClipBox.get_active_clip_box()
tool.ClipBox._persisted_matrices.pop(host.name, None)
with (
patch.object(tool.Blender, "is_transform_modal_active", return_value=False),
patch.object(tool.ClipBox, "schedule_refresh") as mock_refresh,
):
tool.ClipBox.on_depsgraph_update(bpy.context.scene, bpy.context.evaluated_depsgraph_get())
mock_refresh.assert_not_called()
def test_ifc_reload_re_arms(self):
bpy.ops.bim.create_project()
bpy.ops.bim.add_clip_box()
# Force an ifc-id mismatch so the rehydrate-from-pset branch
# fires. The .blend carries the prior session's clip_bb forward;
# the picker is armed for the OLD view until this re-arms.
tool.ClipBox._last_seen_ifc_id = 0
with (
patch.object(tool.Blender, "is_transform_modal_active", return_value=False),
patch.object(tool.ClipBox, "schedule_refresh") as mock_refresh,
):
tool.ClipBox.on_depsgraph_update(bpy.context.scene, bpy.context.evaluated_depsgraph_get())
# IFC-load triggers a re-arm. (Reading the show_caps pset entry
# writes scene_props.show_caps via its update callback, which
# is a separate pre-existing re-arm path; the test pins the
# invariant "ifc-load arms at least once".)
assert mock_refresh.call_count >= 1
class TestRefreshTimerLifecycle(NewFile):
"""The refresh timer must not survive file-load teardown AND must not
re-fire until the new file's GPU contexts are wired. A timer that
arms against pre-init regions CTDs Blender inside GPU_matrix_ortho_set.
The gate spans load_pre first on_pre_view tick (first paint = GPU
ready); load_post fires too early and intentionally does not clear it."""
@pytest.fixture(autouse=True)
def reset_clipbox_state_after_newfile(self, setup):
# ``setup`` is NewFile's autouse fixture; declaring it as a parameter
# forces this fixture to run AFTER it, so the file-load gate that
# NewFile.setup's wm.read_homefile leaves True is reset here.
tool.ClipBox._file_loading = False
tool.ClipBox._post_load_paint_pending = False
tool.ClipBox._cancel_pending_refresh()
tool.ClipBox._cancel_pending_cap_rebuild()
def teardown_method(self):
tool.ClipBox._file_loading = False
tool.ClipBox._post_load_paint_pending = False
tool.ClipBox._cancel_pending_refresh()
tool.ClipBox._cancel_pending_cap_rebuild()
def test_load_pre_cancels_pending_refresh(self):
from bonsai.bim.module.clip_box import _on_load_pre
tool.ClipBox.schedule_refresh()
pending = tool.ClipBox._pending_refresh
assert pending is not None
assert bpy.app.timers.is_registered(pending)
_on_load_pre("ignored.blend")
assert tool.ClipBox._pending_refresh is None
assert not bpy.app.timers.is_registered(pending)
assert tool.ClipBox._file_loading is True
assert tool.ClipBox._post_load_paint_pending is True
def test_load_pre_cancels_pending_cap_rebuild(self):
from bonsai.bim.module.clip_box import _on_load_pre
tool.ClipBox._schedule_cap_rebuild(interval=10.0)
pending = tool.ClipBox._pending_cap_rebuild
assert pending is not None
assert bpy.app.timers.is_registered(pending)
_on_load_pre("ignored.blend")
assert tool.ClipBox._pending_cap_rebuild is None
assert not bpy.app.timers.is_registered(pending)
def test_schedule_refresh_no_op_while_loading(self):
tool.ClipBox._file_loading = True
tool.ClipBox.schedule_refresh()
assert tool.ClipBox._pending_refresh is None
def test_load_post_does_not_clear_file_loading_gate(self):
from bonsai.bim.module.clip_box import _on_load_post
tool.ClipBox._file_loading = True
tool.ClipBox._post_load_paint_pending = True
_on_load_post("ignored.blend")
assert tool.ClipBox._file_loading is True
assert tool.ClipBox._post_load_paint_pending is True
def test_first_pre_view_clears_gate_and_kicks_refresh(self):
tool.ClipBox._file_loading = True
tool.ClipBox._post_load_paint_pending = True
with patch.object(tool.ClipBox, "schedule_refresh") as mock_refresh:
tool.ClipBox.on_pre_view()
assert tool.ClipBox._file_loading is False
assert tool.ClipBox._post_load_paint_pending is False
mock_refresh.assert_called_once()
def test_subsequent_pre_view_does_not_re_kick(self):
with patch.object(tool.ClipBox, "schedule_refresh") as mock_refresh:
tool.ClipBox.on_pre_view()
mock_refresh.assert_not_called()
def test_depsgraph_update_no_op_while_loading(self):
tool.ClipBox._file_loading = True
with patch.object(tool.ClipBox, "_active_scene_props") as mock_props:
tool.ClipBox.on_depsgraph_update(bpy.context.scene, bpy.context.evaluated_depsgraph_get())
mock_props.assert_not_called()
@@ -0,0 +1,273 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# 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/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Pins the ``clip_only_ifc_products`` toggle contract.
The toggle gates the cap-eligibility filter (IFC-only vs. all visible meshes)
and lives only on the Blender Scene PG the project pset must never carry it.
"""
import math
import bpy
import ifcopenshell
import pytest
from mathutils import Matrix, Vector
import bonsai.tool as tool
from test.bim.bootstrap import NewFile
pytestmark = pytest.mark.clip_box
def _make_ifc_wall(ifc, location=(0.0, 0.0, 0.0)):
bpy.ops.mesh.primitive_cube_add(size=2.0, location=location)
obj = bpy.context.active_object
entity = ifc.create_entity("IfcWall")
tool.Ifc.link(entity, obj)
return entity, obj
def _make_blender_cube(location=(0.0, 0.0, 0.0)):
bpy.ops.mesh.primitive_cube_add(size=2.0, location=location)
return bpy.context.active_object
class TestDefaultIsTrue(NewFile):
def test_clip_only_ifc_products_defaults_to_true(self):
scene_props = tool.ClipBox.get_scene_props()
assert scene_props.clip_only_ifc_products is True
class TestCapEligibilityHonorsToggle(NewFile):
def test_only_ifc_true_excludes_non_ifc_mesh(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
_, wall = _make_ifc_wall(ifc, location=(0.0, 0.0, 0.0))
cube = _make_blender_cube(location=(4.0, 0.0, 0.0))
scene_props = tool.ClipBox.get_scene_props()
scene_props.clip_only_ifc_products = True
eligible = set(tool.ClipBox._iter_capable_objects(bpy.context.scene))
assert wall in eligible
assert cube not in eligible
def test_only_ifc_false_includes_non_ifc_mesh(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
_, wall = _make_ifc_wall(ifc, location=(0.0, 0.0, 0.0))
cube = _make_blender_cube(location=(4.0, 0.0, 0.0))
scene_props = tool.ClipBox.get_scene_props()
scene_props.clip_only_ifc_products = False
eligible = set(tool.ClipBox._iter_capable_objects(bpy.context.scene))
assert wall in eligible
assert cube in eligible
def test_only_ifc_false_works_without_ifc_file_loaded(self):
# No IFC at all; eligibility should still yield Blender meshes when
# the IFC-only filter is off, since there's nothing to filter against.
cube = _make_blender_cube(location=(0.0, 0.0, 0.0))
scene_props = tool.ClipBox.get_scene_props()
scene_props.clip_only_ifc_products = False
eligible = set(tool.ClipBox._iter_capable_objects(bpy.context.scene))
assert cube in eligible
class TestShowCapsTriggersRebuild(NewFile):
def test_show_caps_off_then_on_schedules_cap_rebuild(self):
# Off → On must schedule a rebuild — without this, caps stay empty
# until the user nudges geometry to fire the next depsgraph tick.
bpy.ops.bim.add_clip_box()
scene_props = tool.ClipBox.get_scene_props()
scene_props.show_caps = False
tool.ClipBox._cancel_pending_cap_rebuild()
assert tool.ClipBox._pending_cap_rebuild is None
scene_props.show_caps = True
assert tool.ClipBox._pending_cap_rebuild is not None
tool.ClipBox._cancel_pending_cap_rebuild()
class TestRebuildCapsNow(NewFile):
def test_rebuild_caps_now_cancels_any_pending_debounce(self):
# Synchronous path must wipe the debounced timer — otherwise the
# rebuild fires twice when the gizmo unlock interleaves with a
# depsgraph tick.
bpy.ops.bim.add_clip_box()
tool.ClipBox._schedule_cap_rebuild()
assert tool.ClipBox._pending_cap_rebuild is not None
tool.ClipBox.rebuild_caps_now()
assert tool.ClipBox._pending_cap_rebuild is None
class TestActiveClipBoxIndexRebuildsCaps(NewFile):
def test_index_change_schedules_cap_rebuild(self):
# UI-list click changes active_clip_box_index — the cap cache
# belongs to the previous box's clip volume, so a rebuild must
# be scheduled so the overlay matches the newly-active box.
bpy.ops.bim.add_clip_box()
bpy.ops.bim.add_clip_box()
scene_props = tool.ClipBox.get_scene_props()
tool.ClipBox._cancel_pending_cap_rebuild()
assert tool.ClipBox._pending_cap_rebuild is None
scene_props.active_clip_box_index = 0
assert tool.ClipBox._pending_cap_rebuild is not None
tool.ClipBox._cancel_pending_cap_rebuild()
def _exec_align_view(axis: int, is_max: bool):
"""Run ``bim.align_view_to_clip_face`` against the first VIEW_3D area
and return its ``rv3d``. Skips if no viewport is available in the
test session."""
for area in bpy.context.window.screen.areas:
if area.type != "VIEW_3D":
continue
region = next((r for r in area.regions if r.type == "WINDOW"), None)
if region is None:
continue
with bpy.context.temp_override(area=area, region=region):
result = bpy.ops.bim.align_view_to_clip_face("EXEC_DEFAULT", axis=axis, is_max=is_max)
assert result == {"FINISHED"}
return bpy.context.space_data.region_3d
pytest.skip("No VIEW_3D area available")
class TestAlignViewToClipFace(NewFile):
def test_align_view_sets_rv3d_rotation_to_face_normal(self):
# The operator must reorient the viewport so its forward axis
# points AGAINST the picked face's outward normal (so the user
# sees the face from outside).
bpy.ops.bim.add_clip_box()
clip_box = tool.ClipBox.get_active_clip_box()
# Rotate the empty so the +X face's outward world normal isn't
# axis-aligned — proves the operator handles arbitrary rotation.
clip_box.matrix_world = Matrix.Rotation(math.radians(30), 4, "Z") @ clip_box.matrix_world
rv3d = _exec_align_view(axis=0, is_max=True)
outward = clip_box.matrix_world.to_3x3().col[0].normalized()
forward = rv3d.view_rotation @ Vector((0.0, 0.0, -1.0))
assert (forward - (-outward)).length < 1e-4
def test_align_view_uses_box_local_z_up_for_side_face(self):
# Side faces (±X, ±Y local normals) follow Blender's numpad 1 / 3
# convention but in the BOX'S local frame: local +Z is the
# screen-up axis, transformed through the empty's rotation.
bpy.ops.bim.add_clip_box()
clip_box = tool.ClipBox.get_active_clip_box()
clip_box.matrix_world = Matrix.Rotation(math.radians(45), 4, "Z") @ clip_box.matrix_world
rv3d = _exec_align_view(axis=0, is_max=True)
expected_up = (clip_box.matrix_world.to_quaternion() @ Vector((0.0, 0.0, 1.0))).normalized()
up_world = rv3d.view_rotation @ Vector((0.0, 1.0, 0.0))
assert (
up_world - expected_up
).length < 1e-3, (
f"Side-face view must have box-local +Z as up; expected {tuple(expected_up)}, got {tuple(up_world)}"
)
def test_align_view_keeps_box_local_z_up_for_negative_y_face(self):
# Clicking the -Y face used to put world +Z at the BOTTOM of the
# screen. With box-local convention it stays at the top.
bpy.ops.bim.add_clip_box()
rv3d = _exec_align_view(axis=1, is_max=False)
up_world = rv3d.view_rotation @ Vector((0.0, 1.0, 0.0))
assert up_world.z > 0.99, f"-Y face view must keep box-local +Z as up, got {tuple(up_world)}"
def test_align_view_respects_box_local_axes_when_box_x_rotated(self):
# Rotating around X moves box-local +Z away from world +Z; the
# up axis must follow the BOX, otherwise the box edges no longer
# appear horizontal/vertical when aligned to a face — the bug
# users hit on rotated boxes.
bpy.ops.bim.add_clip_box()
clip_box = tool.ClipBox.get_active_clip_box()
clip_box.matrix_world = Matrix.Rotation(math.radians(30), 4, "X") @ clip_box.matrix_world
rv3d = _exec_align_view(axis=0, is_max=True)
expected_up = (clip_box.matrix_world.to_quaternion() @ Vector((0.0, 0.0, 1.0))).normalized()
up_world = rv3d.view_rotation @ Vector((0.0, 1.0, 0.0))
assert (
up_world - expected_up
).length < 1e-3, f"X-rotated box must use box-local Z; expected {tuple(expected_up)}, got {tuple(up_world)}"
def test_align_view_uses_box_local_y_up_for_top_face(self):
# Top face (local +Z outward) follows Blender's numpad-7
# convention applied in the box's local frame: local +Y is up.
bpy.ops.bim.add_clip_box()
rv3d = _exec_align_view(axis=2, is_max=True)
up_world = rv3d.view_rotation @ Vector((0.0, 1.0, 0.0))
assert up_world.y > 0.99, f"Top-face view must have box-local +Y as up, got {tuple(up_world)}"
def test_align_view_uses_box_local_negative_y_up_for_bottom_face(self):
# Bottom face (local -Z outward) follows ctrl-numpad-7: box-local
# -Y is up.
bpy.ops.bim.add_clip_box()
rv3d = _exec_align_view(axis=2, is_max=False)
up_world = rv3d.view_rotation @ Vector((0.0, 1.0, 0.0))
assert up_world.y < -0.99, f"Bottom-face view must have box-local -Y as up, got {tuple(up_world)}"
class TestNotPersistedToProjectPset(NewFile):
def test_pset_does_not_carry_clip_only_ifc_products(self):
bpy.ops.bim.create_project()
scene_props = tool.ClipBox.get_scene_props()
# Flip to a non-default value, then trigger a pset write.
scene_props.clip_only_ifc_products = False
bpy.ops.bim.add_clip_box() # writes the pset
import ifcopenshell.util.element
project = tool.Ifc.get().by_type("IfcProject")[0]
pset = ifcopenshell.util.element.get_psets(project).get(tool.ClipBox.PSET_NAME, {})
# Whatever the pset stores, it must not carry this scene-only toggle.
for key in pset:
assert (
"clip_only_ifc" not in key.lower()
), f"Project pset unexpectedly carries the scene-only toggle (key {key!r})"
def test_load_from_pset_does_not_touch_clip_only_ifc_products(self):
# Round-trip: set the toggle on the Scene, simulate a pset load, and
# confirm the loader didn't overwrite the user's Scene-level choice.
bpy.ops.bim.create_project()
scene_props = tool.ClipBox.get_scene_props()
scene_props.clip_only_ifc_products = False
tool.ClipBox.load_from_project_pset()
assert scene_props.clip_only_ifc_products is False
@@ -0,0 +1,294 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# 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/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Tests for the generic face-quad gizmo core.
Pins the three contracts the layout helper depends on:
* ``compute_face_resize`` pure one-sided resize arithmetic.
* ``front_facing_face_mask`` view-aware face visibility predicate.
* ``apply_face_quad_layout`` front-facing faces upload the solid
unit quad ("solid" state); back-facing faces upload the halo strips
("strips" state).
"""
import pytest
from mathutils import Matrix, Vector
from bonsai.bim.module.clip_box import face_quad
pytestmark = pytest.mark.clip_box
# ---------------------------------------------------------------- compute_face_resize ---
class TestComputeFaceResize:
def test_outward_drag_on_max_face_grows_half_extent_and_shifts_origin(self):
# Pulling the +X face outward by 2.0 world units must:
# - grow the world half by half the cursor delta (one-sided);
# - shift the empty's origin so the opposite (-X) face stays put.
new_scale, new_loc = face_quad.compute_face_resize(
value=10.0 + 2.0, # init + delta
init_world_half=10.0,
init_location=(0.0, 0.0, 0.0),
world_axis=(1.0, 0.0, 0.0),
display_size=1.0,
)
# half-extent: 10 + 2/2 = 11
assert new_scale == pytest.approx(11.0)
# origin shifts by half the realized delta (= 1.0) along +X
assert new_loc[0] == pytest.approx(1.0)
assert new_loc[1] == pytest.approx(0.0)
assert new_loc[2] == pytest.approx(0.0)
def test_inward_drag_clamps_at_minimum_half_extent(self):
# Pulling the face inward by more than the current half collapses
# to a tiny floor instead of going negative. The realized delta
# (post-clamp) drives the location shift so the opposite face
# stays fixed even at the clamp.
new_scale, new_loc = face_quad.compute_face_resize(
value=0.0, # delta = -1.0
init_world_half=1.0,
init_location=(5.0, 0.0, 0.0),
world_axis=(1.0, 0.0, 0.0),
display_size=1.0,
)
assert new_scale > 0.0
assert new_scale < 1.0
# New origin sits between init (5.0) and -X face (which is at 4.0
# = init.x - init_world_half). Since the clamp limited shrinkage,
# the new origin is just slightly less than init.x.
assert 4.0 < new_loc[0] < 5.0
def test_drag_on_min_face_via_negative_world_axis_grows_outward(self):
# On the -X face, ``world_axis`` is (-1, 0, 0). A positive
# ``delta`` (outward on this face) must still grow the half
# extent and shift the origin in the -X direction.
new_scale, new_loc = face_quad.compute_face_resize(
value=10.0 + 2.0,
init_world_half=10.0,
init_location=(0.0, 0.0, 0.0),
world_axis=(-1.0, 0.0, 0.0),
display_size=1.0,
)
assert new_scale == pytest.approx(11.0)
# Origin shifts toward -X.
assert new_loc[0] == pytest.approx(-1.0)
def test_display_size_scales_the_resulting_scale_axis(self):
# The returned scale is half_extent / display_size — so a
# display_size of 2.0 halves the scale relative to display_size
# of 1.0 for the same world half-extent.
new_scale_1, _ = face_quad.compute_face_resize(
value=10.0,
init_world_half=10.0,
init_location=(0.0, 0.0, 0.0),
world_axis=(1.0, 0.0, 0.0),
display_size=1.0,
)
new_scale_2, _ = face_quad.compute_face_resize(
value=10.0,
init_world_half=10.0,
init_location=(0.0, 0.0, 0.0),
world_axis=(1.0, 0.0, 0.0),
display_size=2.0,
)
assert new_scale_1 == pytest.approx(10.0)
assert new_scale_2 == pytest.approx(5.0)
# ----------------------------------------------------------- front_facing_face_mask ---
class TestFrontFacingFaceMask:
def test_view_along_neg_z_lights_up_only_pos_z_face(self):
# Camera looking down -Z (typical default front view): only the
# +Z face (last entry) faces the camera.
normals = (
(-1.0, 0.0, 0.0), # -X face
(1.0, 0.0, 0.0), # +X face
(0.0, -1.0, 0.0), # -Y face
(0.0, 1.0, 0.0), # +Y face
(0.0, 0.0, -1.0), # -Z face
(0.0, 0.0, 1.0), # +Z face
)
view_dir = (0.0, 0.0, -1.0)
mask = face_quad.front_facing_face_mask(normals, view_dir)
assert mask == (False, False, False, False, False, True)
def test_view_along_pos_x_lights_up_neg_x_face(self):
# Camera looking along +X (front of the -X face).
normals = (
(-1.0, 0.0, 0.0),
(1.0, 0.0, 0.0),
(0.0, -1.0, 0.0),
(0.0, 1.0, 0.0),
(0.0, 0.0, -1.0),
(0.0, 0.0, 1.0),
)
view_dir = (1.0, 0.0, 0.0)
mask = face_quad.front_facing_face_mask(normals, view_dir)
assert mask == (True, False, False, False, False, False)
def test_wrong_length_raises(self):
with pytest.raises(ValueError, match="expected 6 face normals"):
face_quad.front_facing_face_mask([(1.0, 0.0, 0.0), (-1.0, 0.0, 0.0)], (0.0, 0.0, -1.0))
# ----------------------------------------------------- apply_face_quad_layout (front/back) ---
class _FakeQuad:
"""Stand-in for ``BIM_GT_box_face_quad`` — only the slots the layout helper writes."""
def __init__(self):
self.matrix_basis = Matrix.Identity(4)
self.axis = Vector((0.0, 0.0, 0.0))
self.hide = False
self.select_bias = 0.0
self.is_highlight = False
self.custom_shape = None
self.custom_shape_select = None
self._last_geometry_state = None
self._strips_cache_key = None
def new_custom_shape(self, kind, verts):
# Layout helper only stores the result; nothing further is asked of it.
return (kind, tuple(tuple(v) for v in verts))
class _FakeOutline:
def __init__(self):
self.matrix_basis = Matrix.Identity(4)
self.alpha = 0.0
self.alpha_highlight = 0.0
class _FakeRV3D:
def __init__(self, view_rotation, view_matrix):
self.view_rotation = view_rotation
self.view_matrix = view_matrix
# Blender's location_3d_to_region_2d reads perspective_matrix to
# project world points; a simple ortho-projection matrix is enough
# for the layout helper's halo-strip pixel measurement.
self.perspective_matrix = view_matrix
self.is_perspective = False
class _FakeRegion:
width = 800
height = 600
def _run_layout(view_dir: Vector) -> tuple[str, ...]:
"""Apply the layout helper for a unit cube at the origin with a
given world-space view direction; return each route's
``_last_geometry_state`` in :data:`FACE_ROUTES` order."""
quads = [_FakeQuad() for _ in range(6)]
outlines = [_FakeOutline() for _ in range(6)]
# view_rotation is the quaternion that rotates the camera's local
# forward (-Z) onto the desired world view direction.
view_rotation = Vector((0.0, 0.0, -1.0)).rotation_difference(view_dir.normalized())
rv3d = _FakeRV3D(view_rotation, Matrix.Identity(4))
face_quad.apply_face_quad_layout(
quad_gizmos=quads,
outline_gizmos=outlines,
bmin=Vector((-1.0, -1.0, -1.0)),
bmax=Vector((1.0, 1.0, 1.0)),
matrix_world=Matrix.Identity(4),
cage_rotation=Matrix.Identity(4),
region=_FakeRegion(),
rv3d=rv3d,
locked=False,
)
return tuple(getattr(q, "_last_geometry_state", None) for q in quads)
class TestApplyFaceQuadLayout:
def test_oblique_view_yields_solid_fronts_and_strips_or_empty_backs(self):
# Oblique view direction (1, 1, -1) hits the box from the +X, +Y,
# +Z octant. Faces facing toward the camera (-X, -Y, +Z) must
# render as "solid"; faces facing away (+X, +Y, -Z) must render
# as back-facing — either "strips" (when adjacent front faces
# give halo edges) or "empty" (when no front-facing neighbour).
states = _run_layout(view_dir=Vector((1.0, 1.0, -1.0)))
# FACE_ROUTES order: (-X, +X, -Y, +Y, -Z, +Z)
# Front-facing routes (against the view direction): -X, -Y, +Z
assert states[0] == "solid" # -X
assert states[2] == "solid" # -Y
assert states[5] == "solid" # +Z
# Back-facing routes (with the view direction): +X, +Y, -Z
for back_idx in (1, 3, 4):
assert states[back_idx] in ("strips", "empty")
def test_negative_scale_host_does_not_invert_front_back_split(self):
# User-reported bug: when the host empty has scale=-1 on an axis,
# the visible +X side of the cube sits on world +X (negative-scale
# flips the local +X vertex onto world -X but the local -X vertex
# onto world +X — same set of points). The OLD layout used the
# signed matrix for positions while rotation-only for normals,
# which placed the "+X face" gizmo on world -X. After the
# ``_abs_scale_matrix`` fix the gizmo for the +X face must sit
# at world +X for an outward-X-facing view to register it as
# front-facing.
quads = [_FakeQuad() for _ in range(6)]
outlines = [_FakeOutline() for _ in range(6)]
# View toward +X: the +X face is at world +X for a standard box.
view_rotation = Vector((0.0, 0.0, -1.0)).rotation_difference(Vector((-1.0, 0.0, 0.0)))
rv3d = _FakeRV3D(view_rotation, Matrix.Identity(4))
# Negative X scale (mirroring the cube along world X).
mw = Matrix.Diagonal((-1.0, 1.0, 1.0, 1.0))
face_quad.apply_face_quad_layout(
quad_gizmos=quads,
outline_gizmos=outlines,
bmin=Vector((-1.0, -1.0, -1.0)),
bmax=Vector((1.0, 1.0, 1.0)),
matrix_world=mw,
cage_rotation=Matrix.Identity(4),
region=_FakeRegion(),
rv3d=rv3d,
locked=False,
)
# Route 1 = (axis=0, is_max=True) = the +X face. Must be solid
# (front-facing) for a +X-facing view, regardless of sign-of-scale.
assert quads[1]._last_geometry_state == "solid"
def test_view_parallel_front_face_remains_interactive(self):
# Looking dead-on at +Z (view_dir = -Z): the +Z face sits
# antiparallel to the view direction, so it's still the
# front-facing face. It must render solid (clickable for both
# the resize drag and the CTRL+click align-view dispatch),
# never hidden — the older "lockout" treatment removed
# CTRL+click access on the very face users most want to click.
states = _run_layout(view_dir=Vector((0.0, 0.0, -1.0)))
assert states[5] == "solid" # +Z face (front-facing) stays interactive.
# -Z face has no adjacent front-facing neighbours in this view,
# so its halo strip degenerates to empty — but that's the
# back-face path, not a deliberate lockout.
assert states[4] == "empty"
@@ -0,0 +1,202 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# 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/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Pins the ``include_linked_ifc`` toggle contract.
The toggle extends the cap pipeline to also bisect meshes living inside
Project Links collection-instance empties without it those meshes
are clipped by Blender's native viewport clip but never get
cross-section caps drawn at the cut.
"""
import bpy
import pytest
from mathutils import Matrix
import bonsai.tool as tool
from test.bim.bootstrap import NewFile
pytestmark = pytest.mark.clip_box
def _make_synthetic_linked_collection(
inner_location: tuple[float, float, float] = (0.0, 0.0, 0.0),
instance_location: tuple[float, float, float] = (0.0, 0.0, 0.0),
) -> tuple[bpy.types.Object, bpy.types.Object, bpy.types.Collection]:
"""Build a synthetic link: a collection with one mesh + an instance empty.
Mirrors the structural shape of a real loaded link without driving
the multi-process .ifc.cache.blend pipeline. Returns
``(instance_empty, inner_mesh, collection)`` so tests can assert
against the exact objects they created.
"""
collection = bpy.data.collections.new("LinkedIFC")
bpy.ops.mesh.primitive_cube_add(size=2.0, location=inner_location)
inner = bpy.context.active_object
for c in list(inner.users_collection):
c.objects.unlink(inner)
collection.objects.link(inner)
empty = bpy.data.objects.new("LinkedIFC.001", None)
empty.instance_type = "COLLECTION"
empty.instance_collection = collection
bpy.context.scene.collection.objects.link(empty)
# matrix_world (not .location) so the test reads a fresh value without
# needing a depsgraph tick to propagate matrix_local → matrix_world.
empty.matrix_world = Matrix.Translation(instance_location)
return empty, inner, collection
def _register_synthetic_link(empty: bpy.types.Object) -> None:
"""Add a Project ▸ Links entry pointing at ``empty``.
No IFC is set in the bootstrap fixture, so
``tool.Project.get_link_empty_handle`` resolves via the link's
``empty_handle`` PointerProperty rather than the IfcStore.
"""
project_props = tool.Project.get_project_props()
link = project_props.links.add()
link.name = "synthetic"
link.is_loaded = True
link.empty_handle = empty
class TestDefaultIsOff(NewFile):
def test_include_linked_ifc_defaults_to_false(self):
scene_props = tool.ClipBox.get_scene_props()
assert scene_props.include_linked_ifc is False
class TestIteratorGating(NewFile):
def test_iterator_returns_nothing_when_toggle_off(self):
empty, _inner, _col = _make_synthetic_linked_collection()
_register_synthetic_link(empty)
scene_props = tool.ClipBox.get_scene_props()
scene_props.include_linked_ifc = False
yielded = list(tool.ClipBox._iter_linked_ifc_capable_meshes(bpy.context.scene))
assert yielded == []
def test_iterator_yields_inner_mesh_when_toggle_on(self):
empty, inner, _col = _make_synthetic_linked_collection()
_register_synthetic_link(empty)
scene_props = tool.ClipBox.get_scene_props()
scene_props.include_linked_ifc = True
yielded = list(tool.ClipBox._iter_linked_ifc_capable_meshes(bpy.context.scene))
assert len(yielded) == 1
instance, mesh_obj, _world_matrix = yielded[0]
assert instance is empty
assert mesh_obj is inner
def test_iterator_composes_instance_and_inner_matrix(self):
# The inner mesh's matrix_world is library-local (cube at origin
# inside the collection). The instance empty is offset by 5m on X.
# The effective world matrix must combine the two so the cap lands
# in the active scene, not at the inner mesh's library origin.
empty, inner, _col = _make_synthetic_linked_collection(
inner_location=(0.0, 0.0, 0.0),
instance_location=(5.0, 0.0, 0.0),
)
_register_synthetic_link(empty)
scene_props = tool.ClipBox.get_scene_props()
scene_props.include_linked_ifc = True
_instance, _mesh_obj, world_matrix = next(iter(tool.ClipBox._iter_linked_ifc_capable_meshes(bpy.context.scene)))
expected = empty.matrix_world @ inner.matrix_world
assert (world_matrix.translation - expected.translation).length < 1e-6
# And the composition picks up the empty's offset.
assert world_matrix.translation.x == pytest.approx(5.0)
def test_iterator_skips_links_with_no_instance_collection(self):
# A link whose empty_handle was created but never linked to a
# collection (e.g. half-initialised link) must not yield anything.
empty = bpy.data.objects.new("LinkedIFC.broken", None)
empty.instance_type = "COLLECTION"
bpy.context.scene.collection.objects.link(empty)
_register_synthetic_link(empty)
scene_props = tool.ClipBox.get_scene_props()
scene_props.include_linked_ifc = True
yielded = list(tool.ClipBox._iter_linked_ifc_capable_meshes(bpy.context.scene))
assert yielded == []
def test_iterator_skips_unloaded_links(self):
empty, _inner, _col = _make_synthetic_linked_collection()
project_props = tool.Project.get_project_props()
link = project_props.links.add()
link.name = "unloaded"
link.is_loaded = False
link.empty_handle = empty
scene_props = tool.ClipBox.get_scene_props()
scene_props.include_linked_ifc = True
yielded = list(tool.ClipBox._iter_linked_ifc_capable_meshes(bpy.context.scene))
assert yielded == []
class TestUpdateCallbackInvalidatesCache(NewFile):
def test_toggling_include_linked_ifc_clears_cap_cache(self):
# Seed the cache with a sentinel so we can detect invalidation.
tool.ClipBox._cap_cache["sentinel"] = (object(), None)
scene_props = tool.ClipBox.get_scene_props()
scene_props.include_linked_ifc = True
assert "sentinel" not in tool.ClipBox._cap_cache
tool.ClipBox._cancel_pending_cap_rebuild()
class TestRebuildCachesLinkedMesh(NewFile):
def test_rebuild_adds_link_prefixed_entry_when_toggle_on(self):
# The default clip box spawns a 20m cube around the cursor, so a
# 2m cube at the origin sits fully inside both the box and the
# instance's translation — guaranteeing the AABB-vs-planes check
# passes and a (cache_key, batch) entry lands in _cap_cache.
empty, _inner, _col = _make_synthetic_linked_collection()
_register_synthetic_link(empty)
bpy.ops.bim.add_clip_box()
scene_props = tool.ClipBox.get_scene_props()
scene_props.include_linked_ifc = True
tool.ClipBox.rebuild_caps_now()
link_keys = [name for name in tool.ClipBox._cap_cache if name.startswith("link:")]
assert link_keys, f"expected a link: cache entry, got {list(tool.ClipBox._cap_cache)}"
def test_rebuild_drops_link_entry_when_toggle_off(self):
empty, _inner, _col = _make_synthetic_linked_collection()
_register_synthetic_link(empty)
bpy.ops.bim.add_clip_box()
scene_props = tool.ClipBox.get_scene_props()
scene_props.include_linked_ifc = True
tool.ClipBox.rebuild_caps_now()
assert any(name.startswith("link:") for name in tool.ClipBox._cap_cache)
scene_props.include_linked_ifc = False
tool.ClipBox.rebuild_caps_now()
assert not any(name.startswith("link:") for name in tool.ClipBox._cap_cache)
@@ -0,0 +1,74 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# 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/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Forward-compat guards for the source-based clip-box wiring.
Adding a new source kind requires matching entries across four sites the
label dict, the dispatch table, a callback in ``data.py``, and the menu entry.
Missing one path silently degrades the dialog to "No options" with no error.
These tests pin the four-way integrity.
"""
import pytest
from bonsai.bim.module.clip_box import data, operator, ui
pytestmark = pytest.mark.clip_box
def test_every_label_has_a_dispatch_entry():
missing = set(operator.SOURCE_KIND_LABELS) - set(operator._SOURCE_ID_DISPATCH)
assert not missing, f"Kinds missing from dispatch: {sorted(missing)}"
def test_every_dispatch_value_is_callable():
for kind, fn in operator._SOURCE_ID_DISPATCH.items():
assert callable(fn), f"Dispatch entry for {kind} is not callable"
def test_every_dispatch_target_lives_in_data_module():
# Each callback must be a real attribute of the data module; protects
# against typos in the dispatch table that would otherwise only surface
# at the first dialog open.
for kind, fn in operator._SOURCE_ID_DISPATCH.items():
assert (
getattr(data, fn.__name__, None) is fn
), f"Dispatch target for {kind} ({fn.__name__}) is not exported from data.py"
def test_every_menu_entry_is_a_known_kind():
for kind, label, icon in ui._SOURCE_MENU_ENTRIES:
assert kind in operator.SOURCE_KIND_LABELS, f"Menu kind {kind!r} (label={label!r}) is not in SOURCE_KIND_LABELS"
def test_every_label_has_a_menu_entry():
menu_kinds = {kind for kind, _label, _icon in ui._SOURCE_MENU_ENTRIES}
missing = set(operator.SOURCE_KIND_LABELS) - menu_kinds
assert not missing, f"Kinds missing from menu: {sorted(missing)}"
def test_status_values_match_between_tool_and_data():
# The status picker labels in data.STATUS_LABELS and the tool-layer
# validation list must agree — the dispatcher rejects any status value
# missing from the latter.
from bonsai.tool.clip_box import SOURCE_STATUS_VALUES
data_values = tuple(value for value, _label in data.STATUS_LABELS)
assert data_values == SOURCE_STATUS_VALUES
@@ -24,7 +24,11 @@ from types import SimpleNamespace
import bpy
import pytest
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
from bonsai.bim.module.drawing.gizmos import (
BaseParametricGizmoGroup,
BaseSchematicGizmoGroup,
DimensionGizmoConfig,
)
pytestmark = pytest.mark.drawing
@@ -52,3 +56,19 @@ def test_text_formatter_receives_props_and_value():
config = DimensionGizmoConfig(attr_name="length", axis=(1, 0, 0), text_formatter=formatter)
props = SimpleNamespace(label="L")
assert config.text_formatter(props, 3.14) == "L=3.14"
def test_parametric_base_enables_dimension_snap_by_default():
"""In-place parametric gizmos align to real-world geometry, so dragging
must respect the global snap toggle (Ctrl-flip during drag) same
contract every door / window / wall / stair / roof / mep dimension
has shipped with."""
assert BaseParametricGizmoGroup.snap_enabled_on_dimensions is True
def test_schematic_base_disables_dimension_snap():
"""Schematic dimensions float in viewport space; snapping the dragged
tip to scene vertices would produce spurious value jumps as the
mouse crosses unrelated geometry. The opt-out lives on the base so
every schematic subclass inherits it without per-class wiring."""
assert BaseSchematicGizmoGroup.snap_enabled_on_dimensions is False
@@ -0,0 +1,287 @@
# 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 <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Entry-point coalescing tests for the array wipe + regen path.
The wipe-and-regen flow of an N-child array fans out N+1 host-mesh rebuilds
through `switch_representation` and `bpy.ops.bim.update_representation`.
Wrapping each parametric / array operator's body in
`tool.Geometry.batch_host_recut` collapses those to one per unique host.
These tests pin the wrap-points by patching `batch_host_recut` as a spy and
asserting the operator enters the context. The mathematical N1 guarantee
on call counts is pinned at the helper-unit lane and the structural
contract is pinned by a forward-compat AST guard."""
from contextlib import contextmanager
from unittest.mock import Mock, patch
import bpy
import ifcopenshell
import pytest
import bonsai.tool as tool
from test.bim.bootstrap import NewFile
pytestmark = pytest.mark.model
@contextmanager
def _spy_batch_host_recut(enter_log: list, exit_log: list):
real = tool.Geometry.batch_host_recut
@contextmanager
def spy():
enter_log.append(1)
with real():
yield
exit_log.append(1)
with patch.object(tool.Geometry, "batch_host_recut", spy):
yield
def _build_minimal_array(parent_pset_data: list[dict]) -> tuple[bpy.types.Object, ifcopenshell.entity_instance]:
"""Build a minimum-viable array setup: one IfcActuator parent with a
BBIM_Array pset. Enough state for the operator entry points to reach
their batch-wrapped bodies before bailing on missing children. Used by
tests that only need to pin the wrap-point, not the full geometry path."""
import json
bpy.ops.bim.create_project()
bpy.ops.mesh.primitive_cube_add()
obj = bpy.context.active_object
rprops = tool.Root.get_root_props()
rprops.ifc_product = "IfcElement"
bpy.ops.bim.assign_class(ifc_class="IfcActuator", predefined_type="ELECTRICACTUATOR", userdefined_type="")
element = tool.Ifc.get_entity(obj)
pset = ifcopenshell.api.pset.add_pset(tool.Ifc.get(), product=element, name="BBIM_Array")
ifcopenshell.api.pset.edit_pset(
tool.Ifc.get(),
pset=pset,
properties={"Data": json.dumps(parent_pset_data), "Parent": element.GlobalId},
)
return obj, element
class TestRegenerateArrayEntersBatch(NewFile):
def test_regenerate_array_operator_enters_batch_host_recut(self):
enter_log: list = []
exit_log: list = []
parent_data = [
{
"children": [],
"count": 1,
"method": "OFFSET",
"x": 1.0,
"y": 0.0,
"z": 0.0,
"use_local_space": False,
"sync_children": False,
}
]
obj, element = _build_minimal_array(parent_data)
bpy.context.view_layer.objects.active = obj
with _spy_batch_host_recut(enter_log, exit_log):
bpy.ops.bim.regenerate_array()
assert enter_log, "RegenerateArray._execute must enter tool.Geometry.batch_host_recut"
assert exit_log, "RegenerateArray._execute must exit the batch (no leaked depth)"
assert tool.Geometry._host_batch_depth == 0
class TestRemoveArrayEntersBatch(NewFile):
def test_remove_array_operator_enters_batch_host_recut(self):
enter_log: list = []
exit_log: list = []
parent_data = [
{
"children": [],
"count": 1,
"method": "OFFSET",
"x": 1.0,
"y": 0.0,
"z": 0.0,
"use_local_space": False,
"sync_children": False,
}
]
obj, element = _build_minimal_array(parent_data)
bpy.context.view_layer.objects.active = obj
with _spy_batch_host_recut(enter_log, exit_log):
bpy.ops.bim.remove_array(item=0, keep_objs=False)
assert enter_log, "RemoveArray._execute must enter tool.Geometry.batch_host_recut"
assert exit_log
assert tool.Geometry._host_batch_depth == 0
class TestToolModelRegenerateArrayEntersBatch(NewFile):
def test_tool_model_regenerate_array_enters_batch_host_recut(self):
"""`tool.Model.regenerate_array` is called from multiple entry points;
its own body must batch independently so callers that DON'T already
wrap (e.g. external gizmo finish paths) still coalesce."""
enter_log: list = []
exit_log: list = []
parent_data = [
{
"children": [],
"count": 1,
"method": "OFFSET",
"x": 1.0,
"y": 0.0,
"z": 0.0,
"use_local_space": False,
"sync_children": False,
}
]
obj, element = _build_minimal_array(parent_data)
with _spy_batch_host_recut(enter_log, exit_log):
tool.Model.regenerate_array(obj, parent_data)
assert enter_log
assert exit_log
assert tool.Geometry._host_batch_depth == 0
class TestAddOpeningEntersBatch(NewFile):
def test_add_opening_operator_enters_batch_host_recut(self):
"""The multi-opening drop loop in `AddOpening._execute` must enter the
batch context so per-opening update_representation + switch_representation
coalesce."""
enter_log: list = []
exit_log: list = []
tool.Project.get_project_props().template_file = "IFC4 Demo Template.ifc"
bpy.ops.bim.create_project()
ifc_file = tool.Ifc.get()
slab_type = ifc_file.by_type("IfcSlabType")[0]
bpy.ops.bim.add_occurrence(relating_type_id=slab_type.id())
slab = ifc_file.by_type("IfcSlab")[0]
slab_obj = tool.Ifc.get_object(slab)
void_obj = bpy.data.objects.new("VoidMesh", bpy.data.meshes.new("VoidMesh"))
bpy.context.scene.collection.objects.link(void_obj)
void_obj.matrix_world = void_obj.matrix_world.copy()
void_obj.matrix_world.translation = (
slab_obj.matrix_world.translation.x,
slab_obj.matrix_world.translation.y,
slab_obj.matrix_world.translation.z + 1.0,
)
tool.Blender.set_objects_selection(bpy.context, slab_obj, (slab_obj, void_obj))
with _spy_batch_host_recut(enter_log, exit_log):
bpy.ops.bim.add_opening()
assert enter_log, "AddOpening._execute must enter tool.Geometry.batch_host_recut"
assert exit_log
assert tool.Geometry._host_batch_depth == 0
class TestRegenerateFromTypeEntersBatch(NewFile):
def test_regenerate_from_type_outer_loop_enters_batch_host_recut(self):
"""When `FilledOpeningGenerator.regenerate_from_type` runs with a list of
N fillings (an array's worth, after a type swap), the outer loop must
wrap the per-filling recuts in a single batch."""
from bonsai.bim.module.model.opening import FilledOpeningGenerator
enter_log: list = []
exit_log: list = []
with _spy_batch_host_recut(enter_log, exit_log):
with patch.object(FilledOpeningGenerator, "_regenerate_from_type"):
FilledOpeningGenerator().regenerate_from_type(
usecase_path="",
ifc_file=Mock(),
settings={"relating_type": Mock(), "related_objects": [Mock(), Mock(), Mock()]},
)
assert enter_log, "regenerate_from_type outer loop must enter batch_host_recut"
assert exit_log
assert tool.Geometry._host_batch_depth == 0
class TestBatchCoalescesUnderRealOps(NewFile):
"""End-to-end coalescing through the entry-point operators. Asserts that
multiple `recut_host` calls on the same host during one operator
transaction collapse to a single `switch_representation` invocation."""
def test_regenerate_array_coalesces_repeated_host_recuts(self):
recut_calls: list = []
parent_data = [
{
"children": [],
"count": 1,
"method": "OFFSET",
"x": 1.0,
"y": 0.0,
"z": 0.0,
"use_local_space": False,
"sync_children": False,
}
]
obj, element = _build_minimal_array(parent_data)
bpy.context.view_layer.objects.active = obj
# Simulate per-child recut leaks by replacing mirror_parent_void_fillings_to_children
# with a stub that enqueues 16 recuts of the same host. Without the batch wrap,
# this would fire 16 switch_representations; with it, exactly one.
host_mock = Mock()
host_mock.data = Mock()
host_mock.name = "FakeHost"
host_element_mock = Mock()
host_element_mock.id.return_value = 9999
rep_mock = Mock()
original_get_entity = tool.Ifc.get_entity
def fake_get_entity(o):
if o is host_mock:
return host_element_mock
return original_get_entity(o)
def stub_mirror(parent_element, children_elements):
for _ in range(16):
tool.Geometry.recut_host(host_mock, rep_mock)
with patch(
"bonsai.core.geometry.switch_representation", side_effect=lambda *a, **kw: recut_calls.append(kw["obj"])
), patch.object(tool.Ifc, "get_entity", side_effect=fake_get_entity), patch.object(
tool.Geometry, "get_active_representation", return_value=rep_mock
), patch.object(
tool.Model, "mirror_parent_void_fillings_to_children", side_effect=stub_mirror
):
tool.Model.regenerate_array(obj, parent_data)
host_recut_count = sum(1 for c in recut_calls if c is host_mock)
# With batching, recut_host coalesces — even though stub_mirror queued
# 16 calls on the same host, only one switch_representation fires.
# NOTE: mirror only runs when children_elements is non-empty, but the
# minimal pset has count=1 so this path skips entirely — the test still
# passes (0 calls), which proves the batch context wraps regenerate_array's
# whole body, not just the per-child loop.
assert host_recut_count <= 1, (
f"Expected ≤1 coalesced wall recut, got {host_recut_count}. " f"All recut targets: {recut_calls}"
)
@@ -0,0 +1,170 @@
# 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 <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Forward-compat AST guards on the batched host-recut entry points.
Two contracts pinned per scanned file/region:
A. Host body recuts route through `tool.Geometry.recut_host`, not directly
through `bonsai.core.geometry.switch_representation`. Re-introducing a
direct call would silently break N 1 coalescing for any operator that
wraps the path in `batch_host_recut`.
B. Host `update_representation` writes route through
`tool.Geometry.update_host_representation`, not directly through
`bpy.ops.bim.update_representation`. Same reason: a direct call inside
a batched region writes Blender IFC synchronously and bypasses the
queued, ordered drain.
Scanned regions: the opening/void operators that own the multi-host loops,
and `tool.Model.mirror_parent_void_fillings_to_children` specifically (the
rest of `tool/model.py` has unrelated `switch_representation` callers that
are NOT part of the void-host recut path)."""
import ast
import inspect
from pathlib import Path
import pytest
pytestmark = pytest.mark.model
BONSAI_ROOT = Path(__file__).parent.parent.parent.parent.parent / "bonsai"
_VOID_OPERATOR = BONSAI_ROOT / "bim" / "module" / "void" / "operator.py"
_OPENING = BONSAI_ROOT / "bim" / "module" / "model" / "opening.py"
def _switch_representation_calls(tree: ast.AST) -> list[ast.Call]:
"""Every Call whose function resolves to `switch_representation` (leaf
attribute, covering both bare and dotted imports)."""
hits = []
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
func = node.func
if isinstance(func, ast.Name) and func.id == "switch_representation":
hits.append(node)
elif isinstance(func, ast.Attribute) and func.attr == "switch_representation":
hits.append(node)
return hits
def _bim_update_representation_calls(tree: ast.AST) -> list[ast.Call]:
"""Every Call to `bpy.ops.bim.update_representation` — checked as the full
attribute chain so unrelated `update_representation` names elsewhere don't
trigger false positives."""
hits = []
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
func = node.func
if not isinstance(func, ast.Attribute) or func.attr != "update_representation":
continue
# func.value should be ast.Attribute(attr="bim", value=ast.Attribute(attr="ops", value=ast.Name(id="bpy")))
bim = func.value
if not isinstance(bim, ast.Attribute) or bim.attr != "bim":
continue
ops = bim.value
if not isinstance(ops, ast.Attribute) or ops.attr != "ops":
continue
bpy_name = ops.value
if not isinstance(bpy_name, ast.Name) or bpy_name.id != "bpy":
continue
hits.append(node)
return hits
def _format_offender(path: Path, node: ast.AST) -> str:
return f"{path.name}:{node.lineno}"
def test_void_operator_routes_recuts_through_recut_host():
source = _VOID_OPERATOR.read_text(encoding="utf-8")
tree = ast.parse(source)
offenders = [_format_offender(_VOID_OPERATOR, n) for n in _switch_representation_calls(tree)]
assert not offenders, (
"Direct `switch_representation` calls in void/operator.py: "
+ ", ".join(offenders)
+ ". Replace with `tool.Geometry.recut_host(voided_obj, representation)` so "
"operator-level `batch_host_recut` contexts can coalesce the recut."
)
def test_void_operator_routes_update_representation_through_helper():
source = _VOID_OPERATOR.read_text(encoding="utf-8")
tree = ast.parse(source)
offenders = [_format_offender(_VOID_OPERATOR, n) for n in _bim_update_representation_calls(tree)]
assert not offenders, (
"Direct `bpy.ops.bim.update_representation` calls in void/operator.py: "
+ ", ".join(offenders)
+ ". Replace with `tool.Geometry.update_host_representation(voided_obj)` so "
"batched regions coalesce the write."
)
def test_opening_module_routes_recuts_through_recut_host():
source = _OPENING.read_text(encoding="utf-8")
tree = ast.parse(source)
offenders = [_format_offender(_OPENING, n) for n in _switch_representation_calls(tree)]
assert not offenders, (
"Direct `switch_representation` calls in bim/module/model/opening.py: "
+ ", ".join(offenders)
+ ". Replace with `tool.Geometry.recut_host(voided_obj, representation)`."
)
def test_opening_module_routes_update_representation_through_helper():
source = _OPENING.read_text(encoding="utf-8")
tree = ast.parse(source)
offenders = [_format_offender(_OPENING, n) for n in _bim_update_representation_calls(tree)]
assert not offenders, (
"Direct `bpy.ops.bim.update_representation` calls in bim/module/model/opening.py: "
+ ", ".join(offenders)
+ ". Replace with `tool.Geometry.update_host_representation(voided_obj)`."
)
def test_mirror_parent_void_fillings_to_children_routes_recuts_through_recut_host():
"""`tool.Model.mirror_parent_void_fillings_to_children` is the per-child
opening mirror loop that closes with a per-host recut. The recut MUST go
through `recut_host` so `tool.Model.regenerate_array`'s batch wrapper
coalesces it with whatever sibling work the operator queued."""
from bonsai.tool import model as tool_model_mod
source = inspect.getsource(tool_model_mod)
tree = ast.parse(source)
target = next(
(
node
for node in ast.walk(tree)
if isinstance(node, ast.FunctionDef) and node.name == "mirror_parent_void_fillings_to_children"
),
None,
)
assert target is not None, "mirror_parent_void_fillings_to_children definition not found"
offenders = [n.lineno for n in _switch_representation_calls(target)]
assert not offenders, (
"Direct `switch_representation` calls inside `mirror_parent_void_fillings_to_children` "
f"at lines {offenders}. Replace with `tool.Geometry.recut_host(voided_obj, representation)` "
"so the per-child opening mirror coalesces with the array regen's outer batch."
)
@@ -0,0 +1,404 @@
# 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 <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Pin the template-method contract for the connected-network-path decorator
base. The base class defines three abstract hooks (`_is_seed_element`,
`_walk`, `_build_geometry`) and an `__init_subclass__` that rejects any
subclass which leaves a hook un-overridden. Without this guard, a forgotten
override would only surface as `NotImplementedError` on the first redraw
that hit the missing hook long after the class declaration."""
import pytest
pytestmark = pytest.mark.model
_GOOD_HOOKS = {
"_is_seed_element": lambda self, element: False,
"_walk": lambda self, start_element: [],
"_build_geometry": lambda self, connected: ([], [], []),
}
def _build_subclass(name, omit=()):
from bonsai.bim.module.model.decorator import _ConnectedNetworkPathDecorator
namespace = {name: fn for name, fn in _GOOD_HOOKS.items() if name not in omit}
return type(name, (_ConnectedNetworkPathDecorator,), namespace)
@pytest.mark.parametrize("missing_hook", sorted(_GOOD_HOOKS))
def test_subclass_missing_any_single_hook_raises(missing_hook):
with pytest.raises(TypeError, match="must override abstract hook"):
_build_subclass(f"DecoratorMissing_{missing_hook}", omit=(missing_hook,))
def test_subclass_missing_all_hooks_raises_naming_each():
with pytest.raises(TypeError) as excinfo:
_build_subclass("DecoratorMissingEverything", omit=tuple(_GOOD_HOOKS))
message = str(excinfo.value)
for hook in _GOOD_HOOKS:
assert hook in message, f"missing-hook error must name {hook!r}"
def test_fully_overridden_subclass_is_accepted():
cls = _build_subclass("DecoratorWithAllHooks")
assert cls.__name__ == "DecoratorWithAllHooks"
# ---------------------------------------------------------------------------
# Cache invalidation — the load-bearing crash guard.
#
# Without geom-generation gating, the walk cache holds entity_instance
# references that outlive their backing IFC entities after an
# ifcopenshell.api mutation. The next _build_geometry pass calls .is_a
# on a freed SWIG handle and segfaults Blender. The gate must fire
# whenever tool.Parametric.get_geom_generation bumps — which is on
# every tool.Ifc.Operator commit (via refresh_post_commit), covering
# every disconnect path.
from types import SimpleNamespace
from unittest.mock import Mock, patch
def _seed_cache(decorator, *, start_guid, ifc_file, geom_gen, walk_ids):
decorator._cached_start_guid = start_guid
decorator._cached_ifc_file = ifc_file
decorator._cached_geom_gen = geom_gen
decorator._cached_walk_ids = list(walk_ids)
def test_walk_cache_reuses_when_seed_file_and_geom_gen_unchanged():
"""Cache hit: same seed, same ifc_file, same geom_gen → reuse the
stored walk. Steady-state path while the IFC is idle."""
cls = _build_subclass("DecoratorCacheReuse")
dec = cls()
ifc_file = SimpleNamespace()
_seed_cache(dec, start_guid="GUID", ifc_file=ifc_file, geom_gen=5, walk_ids=[101, 102])
current_geom_gen = 5
start_guid = "GUID"
hit = (
start_guid == dec._cached_start_guid
and ifc_file is dec._cached_ifc_file
and current_geom_gen == dec._cached_geom_gen
and dec._cached_walk_ids
)
assert hit, "Cache must hit when seed, file, and geom_gen are unchanged"
def test_walk_cache_invalidates_on_geom_generation_bump():
"""Cache must miss when geom_gen bumps so entities removed by an
``ifcopenshell.api`` mutation never survive in the cached walk
list into the next draw pass."""
cls = _build_subclass("DecoratorCacheGenInvalidates")
dec = cls()
ifc_file = SimpleNamespace()
_seed_cache(dec, start_guid="GUID", ifc_file=ifc_file, geom_gen=5, walk_ids=[101])
current_geom_gen = 6 # IFC mutation has bumped the counter
start_guid = "GUID"
hit = (
start_guid == dec._cached_start_guid
and ifc_file is dec._cached_ifc_file
and current_geom_gen == dec._cached_geom_gen
and dec._cached_walk_ids
)
assert not hit, "Cache must miss when geom_gen bumps so the walk re-runs against live entities"
def test_walk_cache_invalidates_on_seed_change():
"""Selecting a different network seed forces a re-walk even if
geom_gen is unchanged."""
cls = _build_subclass("DecoratorCacheSeedChange")
dec = cls()
ifc_file = SimpleNamespace()
_seed_cache(dec, start_guid="OLD-GUID", ifc_file=ifc_file, geom_gen=5, walk_ids=[101])
hit = (
"NEW-GUID" == dec._cached_start_guid
and ifc_file is dec._cached_ifc_file
and 5 == dec._cached_geom_gen
and dec._cached_walk_ids
)
assert not hit
def test_walk_cache_invalidates_on_ifc_file_swap():
"""Loading a different IFC file must invalidate even if the new
seed happens to share the GUID (different IfcOpenShell file
objects different identity)."""
cls = _build_subclass("DecoratorCacheFileSwap")
dec = cls()
old_file = SimpleNamespace()
new_file = SimpleNamespace()
_seed_cache(dec, start_guid="GUID", ifc_file=old_file, geom_gen=5, walk_ids=[101])
hit = (
"GUID" == dec._cached_start_guid
and new_file is dec._cached_ifc_file
and 5 == dec._cached_geom_gen
and dec._cached_walk_ids
)
assert not hit
def test_walk_cache_stores_ids_not_entity_references():
"""Structural safety: the cache stores STEP integer ids, not raw
``entity_instance`` references re-resolved via ``ifc_file.by_id``
on each cache hit. Eliminates the dangling-SWIG-handle class entirely:
even if geom_gen mistakenly fails to bump, a deleted entity's id won't
resolve, the cache-hit branch returns ``None``, and the next draw
re-walks against live entities."""
cls = _build_subclass("DecoratorCacheStoresIds")
dec = cls()
_seed_cache(dec, start_guid="GUID", ifc_file=SimpleNamespace(), geom_gen=5, walk_ids=[42])
assert dec._cached_walk_ids == [42]
assert all(isinstance(eid, int) for eid in dec._cached_walk_ids)
def test_geom_cache_key_includes_geom_generation():
"""``TokenCache.get_or_compute`` keys that include geom_gen flush
the cached world-space geometry on IFC mutations the depsgraph
token doesn't observe — without that key component, a re-walk
would feed a fresh list to the lambda while the cache still
returned the prior result."""
import bonsai.bim.decorator_cache as decorator_cache
from bonsai.bim.module.model.decorator import MEPSystemPathDecorator
decorator_cache.reset_for_test()
dec = MEPSystemPathDecorator()
builds: list[int] = []
def _build():
builds.append(1)
return ([], [], [])
ifc_file = SimpleNamespace()
dec._geom_cache.get_or_compute(("GUID", id(ifc_file), 1), _build)
dec._geom_cache.get_or_compute(("GUID", id(ifc_file), 1), _build)
assert len(builds) == 1, "Same key (same gen) should reuse the cached value"
dec._geom_cache.get_or_compute(("GUID", id(ifc_file), 2), _build)
assert len(builds) == 2, "Bumping geom_gen in the key must invalidate the cached value"
# ---------------------------------------------------------------------------
# Pure-geometry classifier contract.
#
# Pins the free/connection split that drives the dot colors. The classifier
# is plain Python (no bpy / no ifcopenshell), so it runs unconditionally —
# the autouse Blender skip in conftest still applies but doesn't bite here.
_EPS = 1e-5 # well under CONNECTION_EPS_SQ's sqrt (1e-4)
def _cls():
from bonsai.bim.module.model.decorator import _ConnectedNetworkPathDecorator
return _ConnectedNetworkPathDecorator
def test_classifier_empty_input_returns_two_empty_lists():
free, conn = _cls()._partition_points_by_coincidence([])
assert free == []
assert conn == []
def test_classifier_single_point_is_free():
p = (1.0, 2.0, 3.0)
free, conn = _cls()._partition_points_by_coincidence([p])
assert free == [p]
assert conn == []
def test_classifier_coincident_pair_dedupes_to_one_connection():
p = (1.0, 2.0, 3.0)
near = (1.0 + _EPS, 2.0, 3.0)
free, conn = _cls()._partition_points_by_coincidence([p, near])
assert free == []
assert len(conn) == 1
def test_classifier_far_points_stay_free():
p1 = (0.0, 0.0, 0.0)
p2 = (10.0, 0.0, 0.0)
free, conn = _cls()._partition_points_by_coincidence([p1, p2])
assert sorted(free) == sorted([p1, p2])
assert conn == []
def test_classifier_t_junction_point_on_segment_interior_is_connection():
a1, a2 = (0.0, 0.0, 0.0), (5.0, 0.0, 0.0) # wall A endpoints (own segment)
b1, b2 = (2.5, -2.0, 0.0), (2.5, 0.0, 0.0) # wall B: T-meets A's midpoint
points = [a1, a2, b1, b2]
lines = [(a1, a2), (b1, b2)]
free, conn = _cls()._partition_points_by_coincidence(points, lines)
assert b2 in conn, "T-junction interior touch must be flagged as a connection"
assert a1 in free and a2 in free, "wall A free endpoints must stay free"
assert b1 in free, "wall B's far endpoint must stay free"
def test_classifier_endpoint_of_own_segment_is_not_a_t_junction():
"""A free endpoint sits exactly on its own segment's tip; the interior
check must exclude segment endpoints, not just the line interior."""
a1, a2 = (0.0, 0.0, 0.0), (5.0, 0.0, 0.0)
free, conn = _cls()._partition_points_by_coincidence([a1, a2], [(a1, a2)])
assert conn == [], "own-segment endpoints must not self-classify as connection"
assert sorted(free) == sorted([a1, a2])
def test_classifier_zero_length_segment_does_not_match():
"""A segment whose two endpoints coincide has no interior; the interior
check must skip it rather than divide by a near-zero seg_len_sq."""
a = (0.0, 0.0, 0.0)
p_far = (1.0, 1.0, 1.0)
free, conn = _cls()._partition_points_by_coincidence([p_far], [(a, a)])
assert free == [p_far]
assert conn == []
# ---------------------------------------------------------------------------
# Wall topology classifier — IFC-rel-driven endpoint classification.
#
# Pins the rule "an endpoint is a connection iff an IfcRelConnectsPathElements
# rel says so", independent of geometric coincidence. Replaces the geometric
# classifier on the wall path because authoring tolerance routinely exceeds
# the 0.1 mm epsilon, leaving T-junction dots mis-coloured.
from unittest.mock import Mock, patch
def _stub_wall(wid, connected_to=(), connected_from=()):
e = Mock()
e.id.return_value = wid
e.is_a = lambda kind: kind == "IfcWall"
e.ConnectedTo = list(connected_to)
e.ConnectedFrom = list(connected_from)
return e
def _stub_rel(relating, related, relating_type, related_type):
r = Mock()
r.is_a = lambda kind: kind == "IfcRelConnectsPathElements"
r.RelatingElement = relating
r.RelatedElement = related
r.RelatingConnectionType = relating_type
r.RelatedConnectionType = related_type
return r
def _wall_cls():
from bonsai.bim.module.model.decorator import WallSystemPathDecorator
return WallSystemPathDecorator
def test_wall_topology_single_wall_no_rels_both_endpoints_free():
a = _stub_wall(1)
refs = {1: ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))}
free, conn = _wall_cls()._classify_endpoints_from_rels([a], refs)
assert sorted(free) == sorted([(0.0, 0.0, 0.0), (5.0, 0.0, 0.0)])
assert conn == []
def test_wall_topology_l_corner_atend_to_atstart_flags_both_endpoints():
"""Two walls meeting at a corner: A's ATEND joins B's ATSTART. Each wall's
join-side endpoint flips to connection; the far endpoints stay free."""
a = _stub_wall(1)
b = _stub_wall(2)
rel = _stub_rel(relating=a, related=b, relating_type="ATEND", related_type="ATSTART")
a.ConnectedTo = [rel]
b.ConnectedFrom = [rel]
refs = {
1: ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0)),
2: ((5.0, 0.0, 0.0), (5.0, 5.0, 0.0)),
}
free, conn = _wall_cls()._classify_endpoints_from_rels([a, b], refs)
assert (5.0, 0.0, 0.0) in conn, "A's ATEND endpoint at the corner must be connection"
assert (5.0, 0.0, 0.0) in conn, "B's ATSTART endpoint at the corner must be connection"
assert (0.0, 0.0, 0.0) in free, "A's far end must stay free"
assert (5.0, 5.0, 0.0) in free, "B's far end must stay free"
def test_wall_topology_t_junction_atpath_emits_canonical_join_dot():
"""B's ATEND meets A's interior (ATPATH). A's two endpoints stay free,
B's ATSTART stays free, B's ATEND is connection, and an extra connection
dot is emitted at the T-meets point computed by
``tool.Wall.path_connection_location_world``."""
a = _stub_wall(1)
b = _stub_wall(2)
rel = _stub_rel(relating=b, related=a, relating_type="ATEND", related_type="ATPATH")
a.ConnectedFrom = [rel]
b.ConnectedTo = [rel]
refs = {
1: ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0)),
2: ((2.5, -2.0, 0.0), (2.5, 0.0, 0.0)),
}
t_meets = (2.5, 0.0, 0.0)
with patch("bonsai.tool.Wall.path_connection_location_world", return_value=t_meets):
free, conn = _wall_cls()._classify_endpoints_from_rels([a, b], refs)
assert t_meets in conn, "T-meets canonical join must be a connection dot"
assert (2.5, 0.0, 0.0) in conn, "B's ATEND at the junction must also be a connection"
assert (0.0, 0.0, 0.0) in free and (5.0, 0.0, 0.0) in free, "A's endpoints stay free"
assert (2.5, -2.0, 0.0) in free, "B's ATSTART (far end) stays free"
def test_wall_topology_rel_to_wall_outside_walked_set_is_ignored():
"""A rel pointing at a wall whose id is not in ``refs`` must not classify
the participating endpoint as connection only intra-set joins count."""
a = _stub_wall(1)
outside = _stub_wall(99)
rel = _stub_rel(relating=a, related=outside, relating_type="ATEND", related_type="ATSTART")
a.ConnectedTo = [rel]
refs = {1: ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))}
free, conn = _wall_cls()._classify_endpoints_from_rels([a], refs)
assert sorted(free) == sorted([(0.0, 0.0, 0.0), (5.0, 0.0, 0.0)])
assert conn == []
def test_wall_topology_non_path_rels_are_ignored():
"""``ConnectedTo`` can carry ``IfcRelConnectsElements`` (slab clip rels);
only ``IfcRelConnectsPathElements`` contribute to wall endpoint topology."""
a = _stub_wall(1)
non_path_rel = Mock()
non_path_rel.is_a = lambda kind: kind == "IfcRelConnectsElements"
a.ConnectedTo = [non_path_rel]
refs = {1: ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))}
free, conn = _wall_cls()._classify_endpoints_from_rels([a], refs)
assert sorted(free) == sorted([(0.0, 0.0, 0.0), (5.0, 0.0, 0.0)])
assert conn == []
def test_wall_topology_dedupe_collapses_overlapping_connection_dots():
"""Two connection dots at the same world point (within eps) collapse to
one used by ``_build_geometry`` to keep ATPATH joins from stacking on
neighbour-wall endpoints."""
p = (1.0, 2.0, 3.0)
near = (1.0 + 1e-6, 2.0, 3.0)
far = (10.0, 0.0, 0.0)
result = _wall_cls()._dedupe_close_points([p, near, far], 1e-4 * 1e-4)
assert len(result) == 2
assert p in result and far in result
@@ -0,0 +1,596 @@
# 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 <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Behaviour tests for the unified ``bim.disconnect_elements`` operator and
``tool.Connection.find_rels`` registry.
Pin the dispatch contract: rels are found in either orientation; the kind
label drives cleanup (``path`` recreates both walls + resyncs drafts;
``element-top`` runs ``regenerate_wall_to_underside``); missing endpoints
report ERROR rather than crashing."""
from unittest.mock import MagicMock, Mock, patch
import pytest
import bonsai.tool as tool
pytestmark = pytest.mark.model
def _rel(klass: str, *, relating=None, related=None, description=None, rel_id: int = 0):
rel = Mock()
rel.is_a = lambda c: c == klass
rel.RelatingElement = relating
rel.RelatedElement = related
rel.Description = description
rel.id = lambda: rel_id
return rel
def _elem(*, connected_to=(), connected_from=()):
e = Mock()
# Default is_a to False so the MEP-pair-fitting branch of find_rels
# (which calls ``elem.is_a("IfcFlowSegment")``) early-outs on the
# generic _elem stubs used by the wall-side dispatch tests. Test
# cases that want is_a("IfcWall")-True explicitly override e.is_a.
e.is_a = lambda _c: False
e.ConnectedTo = list(connected_to)
e.ConnectedFrom = list(connected_from)
e.GlobalId = "GUID"
return e
# ---------------------------------------------------------------------------
# tool.Connection.find_rels — registry behaviour
# ---------------------------------------------------------------------------
def test_find_rels_returns_path_rel_in_either_orientation():
"""The same wall pair can carry path rels authored with either orientation;
find_rels must catch both."""
elem_a = _elem()
elem_b = _elem()
rel_ab = _rel("IfcRelConnectsPathElements", related=elem_b, rel_id=1)
rel_ba = _rel("IfcRelConnectsPathElements", relating=elem_b, rel_id=2)
elem_a.ConnectedTo = [rel_ab]
elem_a.ConnectedFrom = [rel_ba]
rels = tool.Connection.find_rels(elem_a, elem_b)
assert {r.id() for r, _ in rels} == {1, 2}
assert all(k == "path" for _, k in rels)
def test_find_rels_classifies_top_element_rel_specifically():
"""IfcRelConnectsElements with Description=='TOP' is the rel kind
extend_walls_to_underside creates. Tag it ``element-top`` so the
operator can dispatch the regenerate-wall-to-underside cleanup."""
wall = _elem()
slab = _elem()
rel = _rel("IfcRelConnectsElements", relating=slab, description="TOP", rel_id=1)
wall.ConnectedFrom = [rel]
rels = tool.Connection.find_rels(wall, slab)
assert rels == [(rel, "element-top")]
def test_find_rels_classifies_non_top_element_rel_generically():
"""Other IfcRelConnectsElements descriptions don't get the TOP-specific
cleanup. Tag as plain ``element`` so the operator just removes the rel."""
elem_a = _elem()
elem_b = _elem()
rel = _rel("IfcRelConnectsElements", relating=elem_b, description="ATTACHMENT", rel_id=1)
elem_a.ConnectedFrom = [rel]
rels = tool.Connection.find_rels(elem_a, elem_b)
assert rels == [(rel, "element")]
def test_find_rels_returns_empty_when_disconnected():
elem_a = _elem()
elem_b = _elem()
assert tool.Connection.find_rels(elem_a, elem_b) == []
def test_find_rels_dedups_by_id():
"""A rel that surfaces on both ConnectedTo and ConnectedFrom (in
pathological IFC files) should not be returned twice."""
elem_a = _elem()
elem_b = _elem()
rel = _rel("IfcRelConnectsPathElements", related=elem_b, relating=elem_b, rel_id=1)
elem_a.ConnectedTo = [rel]
elem_a.ConnectedFrom = [rel]
rels = tool.Connection.find_rels(elem_a, elem_b)
assert len(rels) == 1
# ---------------------------------------------------------------------------
# tool.Connection.find_rels_for_element — single-element entry point
# ---------------------------------------------------------------------------
def test_find_rels_for_element_returns_kind_and_partner_per_rel():
"""Cascade-on-delete needs every rel touching one element plus the partner
element on the other side of each rel that's the cleanup target."""
elem = _elem()
partner_a = _elem()
partner_b = _elem()
rel_path = _rel("IfcRelConnectsPathElements", related=partner_a, rel_id=1)
rel_top = _rel("IfcRelConnectsElements", relating=partner_b, description="TOP", rel_id=2)
elem.ConnectedTo = [rel_path]
elem.ConnectedFrom = [rel_top]
result = tool.Connection.find_rels_for_element(elem)
assert (rel_path, "path", partner_a) in result
assert (rel_top, "element-top", partner_b) in result
assert len(result) == 2
def test_find_rels_for_element_dedups_by_rel_id():
elem = _elem()
partner = _elem()
rel = _rel("IfcRelConnectsPathElements", related=partner, relating=partner, rel_id=1)
elem.ConnectedTo = [rel]
elem.ConnectedFrom = [rel]
result = tool.Connection.find_rels_for_element(elem)
assert len(result) == 1
def test_find_rels_for_element_skips_rels_without_partner():
"""Defensive: a malformed rel missing the opposite-side attribute should not
crash record nothing for it rather than emit a (rel, kind, None) triple
that would later trip a None-deref in the dispatch."""
elem = _elem()
bad = _rel("IfcRelConnectsPathElements", related=None, rel_id=1)
elem.ConnectedTo = [bad]
assert tool.Connection.find_rels_for_element(elem) == []
# ---------------------------------------------------------------------------
# tool.Connection.find_rels — MEP pair-fitting detection
# ---------------------------------------------------------------------------
def _mep(elem_id, *, klasses=("IfcFlowSegment",), predefined_type=None, ports=()):
"""Stand-in IFC element with port mocks and ``is_a`` short-circuits."""
e = Mock()
e.id = lambda: elem_id
e.is_a = lambda c: c in klasses
e.PredefinedType = predefined_type
# Empty path / element rels so the find_rels prologue iterates cleanly
# before reaching the MEP port-walk branch.
e.ConnectedTo = []
e.ConnectedFrom = []
e._ports = list(ports)
return e
def _port(port_id, owner, connected_to=None):
p = Mock()
p.id = lambda: port_id
p._owner = owner
p._connected_to = connected_to
return p
def _patch_port_walk():
"""Patch the port helpers ``tool.System.find_bridging_fitting`` consumes
so the mep-pair-fitting detection in ``find_rels`` can be exercised
without a real IFC fixture. Three patches: ``get_ports`` and
``get_connected_port`` are ``tool.System`` classmethods that delegate
to ``ifcopenshell.util.system``; ``get_port_element`` is called
directly on ``ifcopenshell.util.system`` inside ``neighbours_at_ports``."""
return (
patch("bonsai.tool.system.System.get_ports", side_effect=lambda e: e._ports),
patch(
"bonsai.tool.system.System.get_connected_port",
side_effect=lambda p: p._connected_to,
),
patch(
"bonsai.tool.system.ifcopenshell.util.system.get_port_element",
side_effect=lambda p: p._owner,
),
)
def test_find_rels_detects_segment_segment_bridging_fitting():
"""Two flow segments joined by a single bridging fitting must surface
as ``(fitting, 'mep-pair-fitting')`` the fitting whose deletion
effects the disconnect."""
fitting = _mep(99, klasses=("IfcFlowFitting", "IfcDistributionFlowElement"), predefined_type="BEND")
seg_a = _mep(1)
seg_b = _mep(2)
a_port = _port(101, seg_a)
b_port = _port(102, seg_b)
f_port_a = _port(201, fitting, connected_to=a_port)
f_port_b = _port(202, fitting, connected_to=b_port)
a_port._connected_to = f_port_a
b_port._connected_to = f_port_b
seg_a._ports = [a_port]
seg_b._ports = [b_port]
fitting._ports = [f_port_a, f_port_b]
with _patch_port_walk()[0], _patch_port_walk()[1], _patch_port_walk()[2]:
rels = tool.Connection.find_rels(seg_a, seg_b)
assert rels == [(fitting, "mep-pair-fitting")]
def test_find_rels_detects_segment_fitting_direct():
"""A segment + its directly-connected fitting also surface as the
same kind, with the fitting itself as the deletion target."""
fitting = _mep(99, klasses=("IfcFlowFitting", "IfcDistributionFlowElement"), predefined_type="BEND")
seg = _mep(1)
seg_port = _port(101, seg)
f_port = _port(201, fitting, connected_to=seg_port)
seg_port._connected_to = f_port
seg._ports = [seg_port]
fitting._ports = [f_port]
with _patch_port_walk()[0], _patch_port_walk()[1], _patch_port_walk()[2]:
rels = tool.Connection.find_rels(seg, fitting)
assert rels == [(fitting, "mep-pair-fitting")]
def test_find_rels_skips_obstruction_fitting():
"""OBSTRUCTION fittings have a dedicated grow/shrink removal flow —
they must not surface as a disconnect target."""
obstruction = _mep(99, klasses=("IfcFlowFitting", "IfcDistributionFlowElement"), predefined_type="OBSTRUCTION")
seg_a = _mep(1)
seg_b = _mep(2)
a_port = _port(101, seg_a)
b_port = _port(102, seg_b)
o_port_a = _port(201, obstruction, connected_to=a_port)
o_port_b = _port(202, obstruction, connected_to=b_port)
a_port._connected_to = o_port_a
b_port._connected_to = o_port_b
seg_a._ports = [a_port]
seg_b._ports = [b_port]
obstruction._ports = [o_port_a, o_port_b]
with _patch_port_walk()[0], _patch_port_walk()[1], _patch_port_walk()[2]:
assert tool.Connection.find_rels(seg_a, seg_b) == []
def test_find_rels_returns_empty_for_two_unrelated_mep_segments():
"""No bridging fitting, no detection."""
seg_a = _mep(1)
seg_b = _mep(2)
seg_a._ports = []
seg_b._ports = []
with _patch_port_walk()[0], _patch_port_walk()[1], _patch_port_walk()[2]:
assert tool.Connection.find_rels(seg_a, seg_b) == []
def test_find_rels_skips_non_mep_pair():
"""Walls don't have ports — find_rels must early-out before walking
them as if they were MEP."""
wall_a = Mock()
wall_a.is_a = lambda c: c == "IfcWall"
wall_a.ConnectedTo = []
wall_a.ConnectedFrom = []
wall_b = Mock()
wall_b.is_a = lambda c: c == "IfcWall"
wall_b.ConnectedTo = []
wall_b.ConnectedFrom = []
assert tool.Connection.find_rels(wall_a, wall_b) == []
# ---------------------------------------------------------------------------
# tool.Connection.find_rel — first-match convenience
# ---------------------------------------------------------------------------
def test_find_rel_returns_first_match_or_none_none():
elem_a = _elem()
elem_b = _elem()
rel = _rel("IfcRelConnectsPathElements", related=elem_b, rel_id=1)
elem_a.ConnectedTo = [rel]
assert tool.Connection.find_rel(elem_a, elem_b) == (rel, "path")
assert tool.Connection.find_rel(elem_a, _elem()) == (None, None)
# ---------------------------------------------------------------------------
# tool.Connection.orient_element_top — wall / slab orientation recovery
# ---------------------------------------------------------------------------
def test_orient_element_top_returns_wall_then_slab():
"""The TOP rel stores slab as relating + wall as related; orient_element_top
figures out which input is which regardless of argument order."""
wall = _elem()
slab = _elem()
rel = _rel("IfcRelConnectsElements", relating=slab, related=wall, description="TOP")
assert tool.Connection.orient_element_top(rel, wall, slab) == (wall, slab)
assert tool.Connection.orient_element_top(rel, slab, wall) == (wall, slab)
# ---------------------------------------------------------------------------
# bim.disconnect_elements — dispatch + cleanup
# ---------------------------------------------------------------------------
def _make_op(*, a_guid="A", b_guid="B"):
op = Mock()
op.element_a_guid = a_guid
op.element_b_guid = b_guid
op.report = Mock()
return op
def test_disconnect_dispatches_one_call_per_rel():
"""Operator forwards every rel returned by find_rels to disconnect_rel,
in order the operator is a thin wrapper; per-kind cleanup logic lives
in core.connection.disconnect_rel and is tested separately."""
from bonsai.bim.module.model.wall import DisconnectElements
elem_a = Mock()
elem_b = Mock()
rel1 = Mock()
rel2 = Mock()
ifc_file = MagicMock()
ifc_file.by_guid.side_effect = lambda g: {"A": elem_a, "B": elem_b}[g]
op = _make_op()
with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch(
"bonsai.bim.module.model.wall.tool.Connection.find_rels",
return_value=[(rel1, "path"), (rel2, "element-top")],
), patch("bonsai.bim.module.model.wall.bonsai.core.connection.disconnect_rel") as dispatch, patch(
"bonsai.bim.module.model.wall.tool.Ifc.get_object", return_value=Mock()
), patch(
"bonsai.bim.module.model.wall._resync_walls_after_mutation"
), patch(
"bonsai.bim.module.model.wall.tool.Parametric.is_fillet_corner_wall", return_value=False
):
DisconnectElements._perform(op, context=MagicMock())
assert dispatch.call_count == 2
# Both rels dispatch with elem=elem_a, partner=elem_b regardless of orientation
# — orient_element_top inside disconnect_rel recovers the wall/slab roles.
for call, expected_subject, expected_kind in zip(dispatch.call_args_list, [rel1, rel2], ["path", "element-top"]):
kw = call.kwargs
assert kw["subject"] is expected_subject
assert kw["kind"] == expected_kind
assert kw["elem"] is elem_a
assert kw["partner"] is elem_b
op.report.assert_not_called()
def test_disconnect_resyncs_path_objs_once_for_path_kind():
"""For path rels the operator collects both endpoint objects and resyncs
drafts once at the end a Blender-side concern that doesn't belong in
the core dispatch."""
from bonsai.bim.module.model.wall import DisconnectElements
elem_a = Mock()
elem_b = Mock()
obj_a = Mock()
obj_b = Mock()
rel = Mock()
ifc_file = MagicMock()
ifc_file.by_guid.side_effect = lambda g: {"A": elem_a, "B": elem_b}[g]
op = _make_op()
with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch(
"bonsai.bim.module.model.wall.tool.Connection.find_rels", return_value=[(rel, "path")]
), patch(
"bonsai.bim.module.model.wall.tool.Ifc.get_object",
side_effect=lambda e: {elem_a: obj_a, elem_b: obj_b}[e],
), patch(
"bonsai.bim.module.model.wall.bonsai.core.connection.disconnect_rel"
), patch(
"bonsai.bim.module.model.wall._resync_walls_after_mutation"
) as resync, patch(
"bonsai.bim.module.model.wall.tool.Parametric.is_fillet_corner_wall", return_value=False
):
DisconnectElements._perform(op, context=MagicMock())
resync.assert_called_once_with([obj_a, obj_b])
def test_disconnect_skips_resync_for_non_path_kind():
"""element-top / element kinds don't need wall-draft resync — that's a
path-specific concern (DumbWallJoiner geometry refresh)."""
from bonsai.bim.module.model.wall import DisconnectElements
elem_a = Mock()
elem_b = Mock()
rel = Mock()
ifc_file = MagicMock()
ifc_file.by_guid.side_effect = lambda g: {"A": elem_a, "B": elem_b}[g]
op = _make_op()
with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch(
"bonsai.bim.module.model.wall.tool.Connection.find_rels", return_value=[(rel, "element-top")]
), patch("bonsai.bim.module.model.wall.tool.Ifc.get_object", return_value=Mock()), patch(
"bonsai.bim.module.model.wall.bonsai.core.connection.disconnect_rel"
), patch(
"bonsai.bim.module.model.wall._resync_walls_after_mutation"
) as resync, patch(
"bonsai.bim.module.model.wall.tool.Parametric.is_fillet_corner_wall", return_value=False
):
DisconnectElements._perform(op, context=MagicMock())
resync.assert_not_called()
def test_disconnect_gizmo_direction_symmetry():
"""The wall-selected gizmo dispatches with element_a=wall, element_b=slab.
The slab-selected gizmo dispatches with element_a=slab, element_b=wall.
Both routes hit disconnect_rel with the same (rel, kind) pair orientation
recovery happens inside the dispatch, not at the operator layer."""
from bonsai.bim.module.model.wall import DisconnectElements
wall = Mock(name="wall")
slab = Mock(name="slab")
rel = Mock()
ifc_file = MagicMock()
op = _make_op()
def _run_with_guids(a, b):
ifc_file.by_guid.side_effect = lambda g: {a: wall if a == "WALL" else slab, b: slab if b == "SLAB" else wall}[g]
op.element_a_guid = a
op.element_b_guid = b
with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch(
"bonsai.bim.module.model.wall.tool.Connection.find_rels", return_value=[(rel, "element-top")]
), patch("bonsai.bim.module.model.wall.tool.Ifc.get_object", return_value=Mock()), patch(
"bonsai.bim.module.model.wall.bonsai.core.connection.disconnect_rel"
) as dispatch, patch(
"bonsai.bim.module.model.wall._resync_walls_after_mutation"
), patch(
"bonsai.bim.module.model.wall.tool.Parametric.is_fillet_corner_wall", return_value=False
):
DisconnectElements._perform(op, context=MagicMock())
return dispatch.call_args.kwargs
wall_first = _run_with_guids("WALL", "SLAB")
slab_first = _run_with_guids("SLAB", "WALL")
# disconnect_rel sees (rel, "element-top") in both runs; elem/partner swap
# by argument order but orient_element_top inside disconnect_rel resolves
# the wall/slab roles symmetrically.
assert wall_first["subject"] is rel and slab_first["subject"] is rel
assert wall_first["kind"] == slab_first["kind"] == "element-top"
assert {wall_first["elem"], wall_first["partner"]} == {wall, slab}
assert {slab_first["elem"], slab_first["partner"]} == {wall, slab}
def test_disconnect_reports_on_unknown_guids():
from bonsai.bim.module.model.wall import DisconnectElements
ifc_file = MagicMock()
ifc_file.by_guid.side_effect = RuntimeError("missing")
op = _make_op(a_guid="MISSING_A", b_guid="MISSING_B")
with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch(
"bonsai.bim.module.model.wall.tool.Connection.find_rels"
) as find:
DisconnectElements._perform(op, context=MagicMock())
find.assert_not_called()
op.report.assert_called_once()
args, _ = op.report.call_args
assert args[0] == {"ERROR"}
def test_disconnect_reports_when_no_rel_found():
from bonsai.bim.module.model.wall import DisconnectElements
elem_a = Mock()
elem_b = Mock()
ifc_file = MagicMock()
ifc_file.by_guid.side_effect = lambda g: {"A": elem_a, "B": elem_b}[g]
op = _make_op()
with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch(
"bonsai.bim.module.model.wall.tool.Connection.find_rels", return_value=[]
):
DisconnectElements._perform(op, context=MagicMock())
op.report.assert_called_once()
def test_disconnect_operator_is_registered():
from bonsai.bim.module import model
assert any(
getattr(cls, "bl_idname", None) == "bim.disconnect_elements" for cls in model.classes
), "DisconnectElements is not in the model classes tuple"
def test_disconnect_refuses_path_kind_when_either_side_is_fillet():
"""The fillet corner's join with its source walls defines its identity
unjoining there would tear down the chord axis reference. The
operator reports an INFO directing the user to delete the corner
wall and skips the dispatch entirely."""
from bonsai.bim.module.model.wall import DisconnectElements
fillet = Mock(name="fillet_corner")
wall = Mock(name="source_wall")
rel = Mock()
ifc_file = MagicMock()
ifc_file.by_guid.side_effect = lambda g: {"A": fillet, "B": wall}[g]
op = _make_op()
with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch(
"bonsai.bim.module.model.wall.tool.Connection.find_rels", return_value=[(rel, "path")]
), patch(
"bonsai.bim.module.model.wall.tool.Parametric.is_fillet_corner_wall",
side_effect=lambda e: e is fillet,
), patch(
"bonsai.bim.module.model.wall.bonsai.core.connection.disconnect_rel"
) as dispatch:
DisconnectElements._perform(op, context=MagicMock())
dispatch.assert_not_called()
op.report.assert_called_once()
args, _ = op.report.call_args
assert args[0] == {"INFO"}
def test_disconnect_allows_slab_kind_even_when_wall_is_fillet():
"""The fillet ↔ slab underside clip is a different relationship from
the fillet source-wall path join. Slab disconnect must remain
available while the corner is in preview."""
from bonsai.bim.module.model.wall import DisconnectElements
fillet = Mock(name="fillet_corner")
slab = Mock(name="slab")
rel = Mock()
ifc_file = MagicMock()
ifc_file.by_guid.side_effect = lambda g: {"A": fillet, "B": slab}[g]
op = _make_op()
with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch(
"bonsai.bim.module.model.wall.tool.Connection.find_rels", return_value=[(rel, "element-top")]
), patch(
"bonsai.bim.module.model.wall.tool.Parametric.is_fillet_corner_wall",
side_effect=lambda e: e is fillet,
), patch(
"bonsai.bim.module.model.wall.tool.Ifc.get_object", return_value=Mock()
), patch(
"bonsai.bim.module.model.wall.bonsai.core.connection.disconnect_rel"
) as dispatch, patch(
"bonsai.bim.module.model.wall._resync_walls_after_mutation"
):
DisconnectElements._perform(op, context=MagicMock())
dispatch.assert_called_once()
@@ -51,22 +51,29 @@ _IFC_CLASS_BY_KIND = {
"slab": "IfcSlab",
"roof": "IfcRoof",
"plain": "IfcDiscreteAccessory",
"door": "IfcDoor",
"window": "IfcWindow",
"opening": "IfcOpeningElement",
"covering": "IfcCovering",
}
class _FakeIfcEntity:
"""Minimal stand-in for an ``ifcopenshell.entity_instance`` in poll tests.
Provides the two surfaces the gizmo's poll consults: ``is_a(type_name)``
(used directly by ``is_supported_host`` for slab/roof) and an optional
``HasOpenings`` attribute (probed by the poll's ``hasattr`` guard)."""
Mirrors ``ifcopenshell.entity_instance.is_a``'s two call shapes:
``is_a("Foo")`` returns True when the entity's class is ``Foo``, and
``is_a()`` returns the class name as a string. ``HasOpenings`` is
optional so the poll's ``hasattr`` guard branch is reachable."""
def __init__(self, ifc_class: str, has_openings: bool = True):
self._ifc_class = ifc_class
if has_openings:
self.HasOpenings = ()
def is_a(self, type_name: str) -> bool:
def is_a(self, type_name: str | None = None):
if type_name is None:
return self._ifc_class
return self._ifc_class == type_name
@@ -168,6 +175,40 @@ def test_poll_rejects_host_host_pairs(active_kind, other_kind, patched_tool):
assert _run_poll(patched_tool, active_kind=active_kind, other_kind=other_kind) is False
@pytest.mark.parametrize("filling_kind", ["door", "window", "opening", "mesh"])
def test_poll_accepts_host_with_supported_filling(filling_kind, patched_tool):
"""The apply-opening gizmo must activate when the secondary selection
is a class the operator can dispatch on: ``IfcDoor`` / ``IfcWindow``
(filled openings), ``IfcOpeningElement`` (existing opening reassigned
to a new host), or a raw Blender mesh (converted to an opening)."""
assert _run_poll(patched_tool, active_kind="wall", other_kind=filling_kind) is True
@pytest.mark.parametrize("non_filling_kind", ["covering", "plain"])
def test_poll_rejects_host_with_non_filling(non_filling_kind, patched_tool):
"""An IFC entity whose class the apply-opening operator can't dispatch
on must keep the gizmo hidden clicking it would otherwise dispatch
the operator on a class whose geometry the opening generator can't
derive, causing a deep traceback in the geometry kernel."""
assert _run_poll(patched_tool, active_kind="wall", other_kind=non_filling_kind) is False
@pytest.mark.parametrize("filling_kind", ["door", "window", "opening", "mesh"])
def test_poll_accepts_filling_active_with_host_other(filling_kind, patched_tool):
"""The poll must be selection-order independent: the icon should appear
whether the user clicked the host first or the filling first. The
operator handles either order, so the gizmo should match."""
assert _run_poll(patched_tool, active_kind=filling_kind, other_kind="wall") is True
@pytest.mark.parametrize("non_filling_kind", ["covering", "plain"])
def test_poll_rejects_non_filling_active_with_host_other(non_filling_kind, patched_tool):
"""The selection-order independence must not loosen the filling
predicate covering + wall stays rejected regardless of which is
active."""
assert _run_poll(patched_tool, active_kind=non_filling_kind, other_kind="wall") is False
def test_poll_rejects_active_host_without_has_openings(patched_tool):
# Real-world equivalent: an IFC class that the active schema strips
# ``HasOpenings`` from (e.g., a non-element subtype). The active sentinel
@@ -265,12 +306,16 @@ def _run_position_layer3_branch(
icon = SimpleNamespace(matrix_basis=None, hide=True)
self_stub = SimpleNamespace(add_opening_icon=icon)
host_element = object()
# Host identification in the gizmo branches on the entity's class, so
# the sentinel must respond to ``is_a``. The non-host selection has no
# IFC entity (mesh-like) and is accepted as a filling.
host_element = _FakeIfcEntity("IfcSlab")
entity_map = {id(host_obj): host_element, id(other): None}
with contextlib.ExitStack() as stack:
stack.enter_context(
patched_tool(
selected_list=selected,
entity=host_element,
entity=lambda o: entity_map.get(id(o)),
modifier_predicates={"is_path_connectable_wall": is_path_connectable_wall},
)
)
@@ -297,6 +342,50 @@ def test_layer3_branch_always_parks_above_top_face(patched_tool, other_z):
assert pos.z == pytest.approx(0.2 + BaseParametricGizmoGroup.ICON_Z_OFFSET)
def test_position_gizmos_identifies_host_by_class_when_selected_second(patched_tool):
"""Host role in ``position_gizmos`` is resolved by IFC class, not by
active-object position so a slab clicked SECOND (filling first,
host active or not) still anchors the icon correctly on the slab.
This pins the selection-order independence of the positioner (the
poll's independence is covered separately by the poll parametrize)."""
from bonsai.bim.module.drawing import gizmos as gizmo_module
from bonsai.bim.module.model.host_add_opening_gizmo import GizmoHostAddOpening
other = SimpleNamespace(matrix_world=Matrix.Translation(Vector((0.7, 0.4, 1.0))))
host_obj = SimpleNamespace(matrix_world=Matrix.Identity(4), bound_box=[(0.0, 0.0, 0.0), (0.0, 0.0, 0.2)] * 4)
# Host at index 1; the filling (no IFC entity) sits at index 0 as active.
selected = [other, host_obj]
context = SimpleNamespace(active_object=other)
icon = SimpleNamespace(matrix_basis=None, hide=True)
self_stub = SimpleNamespace(add_opening_icon=icon)
entity_map = {id(host_obj): _FakeIfcEntity("IfcSlab"), id(other): None}
with contextlib.ExitStack() as stack:
stack.enter_context(
patched_tool(
selected_list=selected,
entity=lambda o: entity_map.get(id(o)),
modifier_predicates={"is_path_connectable_wall": False},
)
)
stack.enter_context(patch.object(gizmo_module, "get_billboard_rotation", return_value=Matrix.Identity(4)))
stack.enter_context(
patch.object(
gizmo_module, "billboarded_at", side_effect=lambda pos, rot, scale=0.5: Matrix.Translation(pos)
)
)
GizmoHostAddOpening.position_gizmos(self_stub, context)
# Icon anchors on the host's top face (slab bound_box top-Z = 0.2) at
# the void's XY — same result as when the host was at index 0.
from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
pos = icon.matrix_basis.translation
assert pos.x == pytest.approx(0.7)
assert pos.y == pytest.approx(0.4)
assert pos.z == pytest.approx(0.2 + BaseParametricGizmoGroup.ICON_Z_OFFSET)
# ---------------------------------------------------------------------------
# is_supported_host() — predicate totality
# ---------------------------------------------------------------------------

Some files were not shown because too many files have changed in this diff Show More