mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-30 08:33:10 +00:00
e0aa39068d
The regular bim.add_opening click on the host-add-opening gizmo (wall + door/window co-selected) routes through FilledOpeningGenerator.generate, which snaps the filling to the wall's reference-line axis, optionally rotates 180° when the filling sits on the opposite side, and re-applies an rl1 / rl2 Z-elevation default. That is the right default for "drag a fresh door onto a wall and let the model place it for me", but defeats the workflow where the user has already positioned the filling precisely (e.g. snapped to a window in an adjacent wall, copy- pasted at an exact Z, aligned to a reference object). Holding SHIFT while clicking the gizmo now opts into a "preserve placement" mode: the filling stays at its current matrix_world and the opening is created at the filling's existing position. The opening / filling rels and representation work are unchanged — only the snap-to-axis branch is skipped, so the IFC graph is identical to the regular click; only the spatial position of the filling differs (user-chosen vs auto-snapped). Implementation: * bim/module/void/operator.py: AddOpening gains a hidden preserve_placement BoolProperty + an invoke() that sets it from event.shift. The call into FilledOpeningGenerator.generate forwards the flag. bl_description documents the SHIFT modifier so it surfaces in F3 search / hover tooltip. * bim/module/model/opening.py: FilledOpeningGenerator.generate accepts preserve_placement (default False — backwards-compatible with the other caller, tool.Model.add_filled_opening). The voided_obj.data-gated snap block (raycast + axis projection + rl-Z default + filling_obj.matrix_world write) skips entirely when the flag is True. The opening's matrix_world reads from filling_obj.matrix_world below the gate, so the opening lands at the filling's preserved position automatically. Generated with the assistance of an AI coding tool.
1309 lines
56 KiB
Python
1309 lines
56 KiB
Python
# Bonsai - OpenBIM Blender Add-on
|
|
# Copyright (C) 2020, 2021 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/>.
|
|
|
|
from collections.abc import Sequence
|
|
from math import radians
|
|
from typing import Any, Optional, Union
|
|
|
|
import bmesh
|
|
import bpy
|
|
import gpu
|
|
import ifcopenshell
|
|
import ifcopenshell.api.feature
|
|
import ifcopenshell.api.geometry
|
|
import ifcopenshell.api.root
|
|
import ifcopenshell.geom
|
|
import ifcopenshell.util.element
|
|
import ifcopenshell.util.representation
|
|
import ifcopenshell.util.shape
|
|
import ifcopenshell.util.shape_builder
|
|
import ifcopenshell.util.unit
|
|
import numpy as np
|
|
import shapely
|
|
from bpy.types import Operator, SpaceView3D
|
|
from gpu_extras.batch import batch_for_shader
|
|
from mathutils import Matrix, Vector
|
|
|
|
import bonsai.core.geometry
|
|
import bonsai.tool as tool
|
|
from bonsai.bim import decorator_cache
|
|
from bonsai.bim.module.drawing.decoration import DecoratorData
|
|
|
|
# Multi-entry cache for the opening preview's dissolved-edges fallback.
|
|
# Single-entry wouldn't fit: the draw handler iterates every active opening
|
|
# per frame, each with its own mesh. Bumped wholesale on the shared
|
|
# decorator-cache token (depsgraph / undo / redo / load), one slot per
|
|
# (mesh.session_uid, angle_limit). Outlier vs. the per-object caches below —
|
|
# consulted only on world-draw-data miss, so the global wipe rarely fires in
|
|
# steady state and the simpler invalidation is enough.
|
|
_dissolved_edges_cache: dict[
|
|
tuple[int, float],
|
|
tuple[list[Vector], list[tuple[int, int]]],
|
|
] = {}
|
|
_dissolved_edges_cache_token: int = -1
|
|
|
|
|
|
def _get_cached_dissolved_edges(
|
|
mesh: bpy.types.Mesh,
|
|
angle_limit: float = radians(1.0),
|
|
) -> tuple[list[Vector], list[tuple[int, int]]]:
|
|
global _dissolved_edges_cache_token
|
|
token = decorator_cache.get_decorator_cache_token()
|
|
if token != _dissolved_edges_cache_token:
|
|
_dissolved_edges_cache.clear()
|
|
_dissolved_edges_cache_token = token
|
|
key = (mesh.session_uid, angle_limit)
|
|
cached = _dissolved_edges_cache.get(key)
|
|
if cached is not None:
|
|
return cached
|
|
result = tool.Geometry.get_dissolved_edges(mesh, angle_limit=angle_limit)
|
|
_dissolved_edges_cache[key] = result
|
|
return result
|
|
|
|
|
|
# Per-object epoch: bumped only when this specific object's transform or geometry
|
|
# updates land in the depsgraph delta. Invalidation work scales with the number
|
|
# of changed objects, not total scene size — moving one object leaves every
|
|
# other entry valid. Bumped by the depsgraph handler below; cleared on
|
|
# undo/redo/load alongside the cache dicts.
|
|
_object_epochs: dict[int, int] = {}
|
|
|
|
|
|
@bpy.app.handlers.persistent
|
|
def _bump_object_epochs_for_decoration(*args) -> None:
|
|
# depsgraph_update_post is called as (scene, depsgraph) in 4.x but the
|
|
# *args signature follows decorator_cache's defensive idiom.
|
|
depsgraph = args[1] if len(args) >= 2 else None
|
|
if depsgraph is None or not hasattr(depsgraph, "updates"):
|
|
return
|
|
for u in depsgraph.updates:
|
|
if not isinstance(u.id, bpy.types.Object):
|
|
continue
|
|
if not (u.is_updated_geometry or u.is_updated_transform):
|
|
continue
|
|
# u.id is the evaluated COW copy; the cache keys are written from the
|
|
# original Object (read by the draw handler), and session_uid can
|
|
# differ across the COW boundary. Resolve to the original before keying.
|
|
original = getattr(u.id, "original", u.id)
|
|
if original is None:
|
|
continue
|
|
uid = original.session_uid
|
|
_object_epochs[uid] = _object_epochs.get(uid, 0) + 1
|
|
|
|
|
|
@bpy.app.handlers.persistent
|
|
def _clear_decoration_caches_globally(*args) -> None:
|
|
# Undo/redo/load: depsgraph deltas can't be trusted to describe the
|
|
# transition, so wipe every per-object cache state.
|
|
_object_epochs.clear()
|
|
_world_draw_data_cache.clear()
|
|
_batch_cache.clear()
|
|
|
|
|
|
def _decoration_invalidation_hooks() -> tuple:
|
|
return (
|
|
bpy.app.handlers.undo_post,
|
|
bpy.app.handlers.redo_post,
|
|
bpy.app.handlers.load_post,
|
|
)
|
|
|
|
|
|
def install_decoration_cache_handlers() -> None:
|
|
if _bump_object_epochs_for_decoration not in bpy.app.handlers.depsgraph_update_post:
|
|
bpy.app.handlers.depsgraph_update_post.append(_bump_object_epochs_for_decoration)
|
|
for hook in _decoration_invalidation_hooks():
|
|
if _clear_decoration_caches_globally not in hook:
|
|
hook.append(_clear_decoration_caches_globally)
|
|
|
|
|
|
def uninstall_decoration_cache_handlers() -> None:
|
|
try:
|
|
bpy.app.handlers.depsgraph_update_post.remove(_bump_object_epochs_for_decoration)
|
|
except ValueError:
|
|
pass
|
|
for hook in _decoration_invalidation_hooks():
|
|
try:
|
|
hook.remove(_clear_decoration_caches_globally)
|
|
except ValueError:
|
|
pass
|
|
|
|
|
|
# Per-object world-space draw payload: line_verts (dissolved or ios_edges-filtered),
|
|
# verts (full mesh, indexed by loop_triangles), edges_indices, tris. Entries are
|
|
# (epoch, payload) tuples; lookup compares epoch to _object_epochs[uid], so a
|
|
# stale entry for an object that didn't change since the last build still hits.
|
|
_world_draw_data_cache: dict[
|
|
int,
|
|
tuple[
|
|
int,
|
|
tuple[
|
|
list[tuple[float, float, float]],
|
|
list[tuple[float, float, float]],
|
|
list[tuple[int, int]],
|
|
list[tuple[int, ...]],
|
|
],
|
|
],
|
|
] = {}
|
|
|
|
|
|
def _get_cached_world_draw_data(
|
|
obj: bpy.types.Object,
|
|
) -> tuple[
|
|
list[tuple[float, float, float]],
|
|
list[tuple[float, float, float]],
|
|
list[tuple[int, int]],
|
|
list[tuple[int, ...]],
|
|
]:
|
|
uid = obj.session_uid
|
|
epoch = _object_epochs.get(uid, 0)
|
|
entry = _world_draw_data_cache.get(uid)
|
|
if entry is not None and entry[0] == epoch:
|
|
return entry[1]
|
|
|
|
mw = obj.matrix_world
|
|
verts = [tuple(mw @ v.co) for v in obj.data.vertices]
|
|
obj.data.calc_loop_triangles()
|
|
tris = [tuple(t.vertices) for t in obj.data.loop_triangles]
|
|
|
|
ios_edges_attribute = obj.data.attributes.get("ios_edges")
|
|
if ios_edges_attribute:
|
|
# Loader-curated edges: read the attribute aligned with bm.edges order.
|
|
bm = bmesh.new()
|
|
bm.from_mesh(obj.data)
|
|
edges_indices = [
|
|
tuple(v.index for v in e.verts) for i, e in enumerate(bm.edges) if ios_edges_attribute.data[i].value
|
|
]
|
|
bm.free()
|
|
line_verts = verts
|
|
else:
|
|
dissolved, edges_indices = _get_cached_dissolved_edges(obj.data)
|
|
line_verts = [tuple(mw @ v) for v in dissolved]
|
|
|
|
result = (line_verts, verts, edges_indices, tris)
|
|
_world_draw_data_cache[uid] = (epoch, result)
|
|
return result
|
|
|
|
|
|
# GPUBatch cache: skip per-frame batch_for_shader. Entries are (epoch, batch);
|
|
# lookup compares epoch to _object_epochs[uid] so other objects' batches stay
|
|
# alive when one object's depsgraph delta bumps only its own epoch. The cached
|
|
# batches reference GPU-side buffers tied to Blender's built-in shaders, which
|
|
# are themselves cached by name (gpu.shader.from_builtin returns the same
|
|
# handle each call), so they stay drawable across frames.
|
|
_batch_cache: dict[tuple[int, str], tuple[int, "gpu.types.GPUBatch"]] = {}
|
|
|
|
|
|
def _get_cached_batch_or_none(cache_key: tuple[int, str]) -> "gpu.types.GPUBatch | None":
|
|
uid = cache_key[0]
|
|
epoch = _object_epochs.get(uid, 0)
|
|
entry = _batch_cache.get(cache_key)
|
|
if entry is not None and entry[0] == epoch:
|
|
return entry[1]
|
|
return None
|
|
|
|
|
|
def _store_batch_in_cache(cache_key: tuple[int, str], batch: "gpu.types.GPUBatch") -> None:
|
|
uid = cache_key[0]
|
|
epoch = _object_epochs.get(uid, 0)
|
|
_batch_cache[cache_key] = (epoch, batch)
|
|
|
|
|
|
class FilledOpeningGenerator:
|
|
def generate(
|
|
self,
|
|
filling_obj: bpy.types.Object,
|
|
voided_obj: bpy.types.Object,
|
|
target: Optional[Vector] = None,
|
|
preserve_placement: bool = False,
|
|
) -> Union[None, str]:
|
|
"""
|
|
:param target: Target opening position. If ommited, cursor position is used.
|
|
:param preserve_placement: If True, keep ``filling_obj.matrix_world`` as-is
|
|
and skip the snap-to-wall-axis / rl1-rl2 Z-default logic. The opening
|
|
is still created at the filling's current world position. Useful
|
|
when the caller (e.g. the SHIFT-add-opening gizmo flow) has
|
|
already positioned the filling intentionally.
|
|
:return: None if there was no errors, otherwise returns a string with error message.
|
|
"""
|
|
props = tool.Model.get_model_props()
|
|
opening_thickness_si = 0.0
|
|
|
|
filling = tool.Ifc.get_entity(filling_obj)
|
|
element = tool.Ifc.get_entity(voided_obj)
|
|
|
|
assert filling and element
|
|
if filling.FillsVoids:
|
|
ifcopenshell.api.feature.remove_feature(
|
|
tool.Ifc.get(), feature=filling.FillsVoids[0].RelatingOpeningElement
|
|
)
|
|
|
|
if target is None:
|
|
should_set_z_level = True
|
|
target = bpy.context.scene.cursor.location.copy()
|
|
else:
|
|
should_set_z_level = False
|
|
|
|
# Sometimes, the voided_obj may be an aggregate, which won't have any representation.
|
|
if not preserve_placement and voided_obj.data:
|
|
raycast = voided_obj.closest_point_on_mesh(voided_obj.matrix_world.inverted() @ target, distance=0.01)
|
|
if not raycast[0]:
|
|
target = filling_obj.matrix_world.translation.copy()
|
|
raycast = voided_obj.closest_point_on_mesh(voided_obj.matrix_world.inverted() @ target, distance=0.5)
|
|
if not raycast[0]:
|
|
return "TARGET is too far away from the voided object's mesh."
|
|
|
|
# In this prototype, we assume openings are only added to axis-based elements
|
|
layers = tool.Model.get_material_layer_parameters(element)
|
|
if layers["layer_set_direction"] == "AXIS2":
|
|
opening_thickness_si = layers["thickness"] * 2
|
|
axes = tool.Model.get_wall_axis(voided_obj, layers=layers)
|
|
axis_base = axes["base"]
|
|
axis_side = axes["side"]
|
|
new_matrix = voided_obj.matrix_world.copy()
|
|
point_on_base_axis = tool.Cad.point_on_edge(target, axis_base)
|
|
point_on_side_axis = tool.Cad.point_on_edge(target, axis_side)
|
|
if (point_on_base_axis - target).length <= (point_on_side_axis - target).length:
|
|
new_matrix.translation.x = point_on_base_axis.x
|
|
new_matrix.translation.y = point_on_base_axis.y
|
|
else:
|
|
new_matrix.translation.x = point_on_side_axis.x
|
|
new_matrix.translation.y = point_on_side_axis.y
|
|
new_matrix = new_matrix @ Matrix.Rotation(radians(180.0), 4, "Z")
|
|
|
|
if should_set_z_level:
|
|
if filling.is_a("IfcDoor"):
|
|
new_matrix.translation.z = voided_obj.matrix_world.translation.z + props.rl1
|
|
else:
|
|
new_matrix.translation.z = voided_obj.matrix_world.translation.z + props.rl2
|
|
else:
|
|
new_matrix.translation.z = filling_obj.matrix_world.copy().translation.z
|
|
elif layers["layer_set_direction"] == "AXIS3":
|
|
new_matrix = voided_obj.matrix_world.copy()
|
|
local_position_on_voided_obj = raycast[1]
|
|
# Equivalent to "side Z" for a wall axis, so that stuff like skylights appear on the top.
|
|
local_position_on_voided_obj.z = layers["offset"] + layers["thickness"]
|
|
new_matrix.translation.xyz = voided_obj.matrix_world @ local_position_on_voided_obj
|
|
rotation_matrix = Matrix.Rotation(radians(-90), 4, "X")
|
|
new_matrix @= rotation_matrix
|
|
else:
|
|
assert False, f"Unexpected layer set direction: {layers['layer_set_direction']}"
|
|
|
|
filling_obj.matrix_world = new_matrix
|
|
bpy.context.view_layer.update()
|
|
|
|
if tool.Ifc.is_moved(voided_obj):
|
|
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=voided_obj)
|
|
|
|
existing_opening_occurrence = self.get_existing_opening_occurrence_if_any(filling)
|
|
|
|
# CREATE THE OPENING FIRST
|
|
opening = ifcopenshell.api.root.create_entity(
|
|
tool.Ifc.get(),
|
|
ifc_class="IfcOpeningElement",
|
|
predefined_type="OPENING",
|
|
name="Opening",
|
|
)
|
|
ifcopenshell.api.geometry.edit_object_placement(
|
|
tool.Ifc.get(),
|
|
product=opening,
|
|
matrix=np.array(filling_obj.matrix_world),
|
|
is_si=True,
|
|
)
|
|
|
|
# NOW HANDLE REPRESENTATION
|
|
# Variables to track if we should reuse a mapped representation
|
|
reuse_mapped_representation = False
|
|
existing_mapping_source = None
|
|
representation = None
|
|
|
|
if existing_opening_occurrence:
|
|
representation = ifcopenshell.util.representation.get_representation(
|
|
existing_opening_occurrence, "Model", "Body", "MODEL_VIEW"
|
|
)
|
|
assert representation
|
|
representation = ifcopenshell.util.representation.resolve_representation(representation)
|
|
else:
|
|
representation = self.generate_opening_from_filling(
|
|
filling, filling_obj, opening_thickness_si=opening_thickness_si
|
|
)
|
|
|
|
# Create mapped representation
|
|
if reuse_mapped_representation:
|
|
# Reuse the existing RepresentationMap - don't create a new one!
|
|
context = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body", "MODEL_VIEW")
|
|
new_mapped_item = tool.Ifc.get().create_entity(
|
|
"IfcMappedItem",
|
|
MappingSource=existing_mapping_source,
|
|
MappingTarget=tool.Ifc.get().create_entity(
|
|
"IfcCartesianTransformationOperator3D",
|
|
Axis1=tool.Ifc.get().create_entity("IfcDirection", DirectionRatios=(1.0, 0.0, 0.0)),
|
|
Axis2=tool.Ifc.get().create_entity("IfcDirection", DirectionRatios=(0.0, 1.0, 0.0)),
|
|
LocalOrigin=tool.Ifc.get().create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0)),
|
|
Scale=1.0,
|
|
Axis3=tool.Ifc.get().create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0)),
|
|
),
|
|
)
|
|
mapped_representation = tool.Ifc.get().create_entity(
|
|
"IfcShapeRepresentation",
|
|
ContextOfItems=context,
|
|
RepresentationIdentifier="Body",
|
|
RepresentationType="MappedRepresentation",
|
|
Items=[new_mapped_item],
|
|
)
|
|
else:
|
|
mapped_representation = ifcopenshell.api.geometry.map_representation(
|
|
tool.Ifc.get(), representation=representation
|
|
)
|
|
|
|
ifcopenshell.api.geometry.assign_representation(
|
|
tool.Ifc.get(), product=opening, representation=mapped_representation
|
|
)
|
|
|
|
ifcopenshell.api.feature.add_feature(tool.Ifc.get(), feature=opening, element=element)
|
|
ifcopenshell.api.feature.add_filling(tool.Ifc.get(), opening=opening, element=filling)
|
|
|
|
voided_objs = [voided_obj]
|
|
# Openings affect all subelements of an aggregate
|
|
for subelement in tool.Aggregate.get_parts_recursively(element):
|
|
subobj = tool.Ifc.get_object(subelement)
|
|
if subobj:
|
|
voided_objs.append(subobj)
|
|
|
|
for voided_obj in voided_objs:
|
|
if voided_obj.data:
|
|
voided_element = tool.Ifc.get_entity(voided_obj)
|
|
assert voided_element
|
|
context = tool.Geometry.get_active_representation_context(voided_obj)
|
|
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,
|
|
)
|
|
|
|
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)
|
|
|
|
def _regenerate_from_type(self, related_object: ifcopenshell.entity_instance) -> None:
|
|
filling = related_object
|
|
if not getattr(filling, "FillsVoids", None):
|
|
return
|
|
|
|
opening = filling.FillsVoids[0].RelatingOpeningElement
|
|
voided_element = opening.VoidsElements[0].RelatingBuildingElement
|
|
|
|
opening_rep = ifcopenshell.util.representation.get_representation(opening, "Model", "Body", "MODEL_VIEW")
|
|
ifcopenshell.api.geometry.unassign_representation(tool.Ifc.get(), product=opening, representation=opening_rep)
|
|
ifcopenshell.api.geometry.remove_representation(tool.Ifc.get(), representation=opening_rep)
|
|
|
|
existing_opening_occurrence = self.get_existing_opening_occurrence_if_any(filling)
|
|
|
|
if existing_opening_occurrence:
|
|
representation = ifcopenshell.util.representation.get_representation(
|
|
existing_opening_occurrence, "Model", "Body", "MODEL_VIEW"
|
|
)
|
|
representation = ifcopenshell.util.representation.resolve_representation(representation)
|
|
mapped_representation = ifcopenshell.api.geometry.map_representation(
|
|
tool.Ifc.get(), representation=representation
|
|
)
|
|
ifcopenshell.api.geometry.assign_representation(
|
|
tool.Ifc.get(), product=opening, representation=mapped_representation
|
|
)
|
|
else:
|
|
opening_obj = tool.Ifc.get_object(opening)
|
|
if opening_obj:
|
|
tool.Ifc.unlink(element=opening)
|
|
tool.Blender.remove_data_blocks([opening_obj], remove_unused_data=True)
|
|
|
|
filling_obj = tool.Ifc.get_object(filling)
|
|
representation = self.generate_opening_from_filling(filling, filling_obj)
|
|
mapped_representation = ifcopenshell.api.geometry.map_representation(
|
|
tool.Ifc.get(), representation=representation
|
|
)
|
|
ifcopenshell.api.geometry.assign_representation(
|
|
tool.Ifc.get(), product=opening, representation=mapped_representation
|
|
)
|
|
|
|
# update voided object representation or all it's parts if it's an aggregate
|
|
voided_elements = ifcopenshell.util.element.get_parts(voided_element) or [voided_element]
|
|
for voided_element in voided_elements:
|
|
voided_obj = tool.Ifc.get_object(voided_element)
|
|
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,
|
|
)
|
|
|
|
def generate_opening_from_filling(
|
|
self,
|
|
filling: ifcopenshell.entity_instance,
|
|
filling_obj: bpy.types.Object,
|
|
opening_thickness_si: float = 0.0,
|
|
) -> ifcopenshell.entity_instance:
|
|
# Since openings are reused later, we give a default thickness of 1.2m
|
|
# which should cover the majority of curved, or super thick walls.
|
|
thickness = max(1.2, opening_thickness_si)
|
|
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
|
shape_builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get())
|
|
|
|
profile = None
|
|
filling_type = ifcopenshell.util.element.get_type(filling)
|
|
if filling_type:
|
|
profile = ifcopenshell.util.representation.get_representation(
|
|
filling_type, "Model", "Profile", "ELEVATION_VIEW"
|
|
)
|
|
filling_obj = tool.Ifc.get_object(filling_type)
|
|
context = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body", "MODEL_VIEW")
|
|
assert context
|
|
|
|
if profile:
|
|
profile = ifcopenshell.util.representation.resolve_representation(profile)
|
|
|
|
def get_curve_2d_from_3d(profile: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance:
|
|
if len(profile.Items) == 1:
|
|
curve_3d = profile.Items[0]
|
|
if tool.Ifc.get_schema() == "IFC2X3":
|
|
coords = [Vector(p).xz for p in shape_builder.get_polyline_coords(curve_3d)]
|
|
return shape_builder.polyline(coords, closed=True)
|
|
# using different algorithm to keep arc segments possible in the future
|
|
ifc_segments = [shape_builder.deep_copy(s) for s in curve_3d.Segments]
|
|
ifc_points = tool.Ifc.get().createIfcCartesianPointList2D(
|
|
[Vector(p).xz for p in curve_3d.Points.CoordList]
|
|
)
|
|
return tool.Ifc.get().createIfcIndexedPolyCurve(Points=ifc_points, Segments=ifc_segments)
|
|
|
|
settings = ifcopenshell.geom.settings()
|
|
settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS)
|
|
geometry = ifcopenshell.geom.create_shape(settings, profile)
|
|
verts = ifcopenshell.util.shape.get_vertices(geometry)
|
|
# [0, 2] represents X and Z ordinates
|
|
verts = [(np.around(v[[0, 2]], decimals=3) / unit_scale).tolist() for v in verts]
|
|
edges = ifcopenshell.util.shape.get_edges(geometry)
|
|
|
|
boundary_lines = [shapely.LineString([verts[v] for v in e]) for e in edges]
|
|
unioned_boundaries = shapely.union_all(shapely.GeometryCollection(boundary_lines))
|
|
closed_polygons = shapely.polygonize(boundary_lines)
|
|
polygon = max(closed_polygons.geoms, key=lambda polygon: polygon.area)
|
|
return shape_builder.polyline(list(polygon.exterior.coords))
|
|
|
|
extrusion = shape_builder.extrude(
|
|
get_curve_2d_from_3d(profile),
|
|
magnitude=thickness / unit_scale,
|
|
position=Vector([0.0, -thickness * 0.5 / unit_scale, 0.0]),
|
|
**shape_builder.extrude_kwargs("Y"),
|
|
)
|
|
return shape_builder.get_representation(context, [extrusion])
|
|
|
|
if (
|
|
filling_rep := tool.Geometry.get_active_representation(filling_obj)
|
|
) and filling_rep.ContextOfItems == context:
|
|
x, y, z = filling_obj.dimensions
|
|
else:
|
|
# The filling_obj's mesh data is not the body geometry.
|
|
settings = ifcopenshell.geom.settings()
|
|
filling_element = tool.Ifc.get_entity(filling_obj)
|
|
filling_body = ifcopenshell.util.representation.get_representation(filling_element, context)
|
|
filling_geometry = ifcopenshell.geom.create_shape(settings, filling_body)
|
|
x = ifcopenshell.util.shape.get_x(filling_geometry)
|
|
y = ifcopenshell.util.shape.get_y(filling_geometry)
|
|
z = ifcopenshell.util.shape.get_z(filling_geometry)
|
|
opening_position = Vector([0.0, -thickness * 0.5 / unit_scale, 0.0])
|
|
opening_size = Vector([x, z]) / unit_scale
|
|
|
|
# Windows and doors can have a casing that overlaps the wall
|
|
# but shouldn't affect the size of the opening.
|
|
# So we shouldn't use object dimensions in that case. More: #2784
|
|
# Just keeping it for windows and doors for now to be safe
|
|
has_width_attribute, has_height_attribute = False, False
|
|
if filling.is_a() in ["IfcWindow", "IfcDoor"]:
|
|
if filling.OverallWidth:
|
|
opening_size.x = filling.OverallWidth
|
|
has_width_attribute = True
|
|
if filling.OverallHeight:
|
|
opening_size.y = filling.OverallHeight
|
|
has_height_attribute = True
|
|
|
|
# making sure if min_x or min_z != 0 to shift the opening accordingly
|
|
# to prevent something like #2784
|
|
if not has_width_attribute:
|
|
opening_position.x = min(v[0] for v in filling_obj.bound_box)
|
|
|
|
if not has_height_attribute:
|
|
opening_position.z = min(v[2] for v in filling_obj.bound_box)
|
|
|
|
extrusion = shape_builder.extrude(
|
|
shape_builder.rectangle(size=opening_size),
|
|
magnitude=thickness / unit_scale,
|
|
position=opening_position,
|
|
**shape_builder.extrude_kwargs("Y"),
|
|
)
|
|
|
|
return shape_builder.get_representation(context, [extrusion])
|
|
|
|
def has_visible_openings(self, element):
|
|
for opening in [r.RelatedOpeningElement for r in tool.Geometry.get_openings(element)]:
|
|
if tool.Ifc.get_object(opening):
|
|
return True
|
|
return False
|
|
|
|
def get_existing_opening_occurrence_if_any(
|
|
self, filling: ifcopenshell.entity_instance
|
|
) -> Union[ifcopenshell.entity_instance, None]:
|
|
filling_type = ifcopenshell.util.element.get_type(filling)
|
|
if filling_type:
|
|
filling_occurrences = ifcopenshell.util.element.get_types(filling_type)
|
|
for filling_occurrence in filling_occurrences:
|
|
if filling_occurrence != filling and filling_occurrence.FillsVoids:
|
|
return filling_occurrence.FillsVoids[0].RelatingOpeningElement
|
|
|
|
|
|
class RecalculateFill(bpy.types.Operator, tool.Ifc.Operator):
|
|
bl_idname = "bim.recalculate_fill"
|
|
bl_label = "Recalculate Fill"
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
|
|
@classmethod
|
|
def poll(cls, context):
|
|
return context.selected_objects
|
|
|
|
def _execute(self, context):
|
|
for obj in context.selected_objects:
|
|
element = tool.Ifc.get_entity(obj)
|
|
if not element or not element.FillsVoids:
|
|
continue
|
|
openings = [r.RelatingOpeningElement for r in element.FillsVoids or []]
|
|
building_elements = []
|
|
for opening in openings:
|
|
building_elements.extend([r.RelatingBuildingElement for r in opening.VoidsElements or []])
|
|
for building_element in building_elements:
|
|
building_obj = tool.Ifc.get_object(building_element)
|
|
if tool.Ifc.is_moved(building_obj):
|
|
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=building_obj)
|
|
for opening in openings:
|
|
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
|
|
ifcopenshell.api.geometry.edit_object_placement(
|
|
tool.Ifc.get(), product=opening, matrix=obj.matrix_world
|
|
)
|
|
|
|
decomposed_building_elements = set()
|
|
for building_element in building_elements:
|
|
decomposed_building_elements.add(building_element)
|
|
decomposed_building_elements.update(ifcopenshell.util.element.get_decomposition(building_element))
|
|
|
|
for building_element in decomposed_building_elements:
|
|
building_obj = tool.Ifc.get_object(building_element)
|
|
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,
|
|
)
|
|
|
|
# Refresh cut decorator
|
|
DecoratorData.cut_cache.clear()
|
|
DecoratorData.fill_cache.clear()
|
|
DecoratorData.slice_cache.clear()
|
|
|
|
return {"FINISHED"}
|
|
|
|
|
|
class FlipFill(bpy.types.Operator, tool.Ifc.Operator):
|
|
bl_idname = "bim.flip_fill"
|
|
bl_label = "Flip Fill"
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
|
|
@classmethod
|
|
def poll(cls, context):
|
|
return context.selected_objects
|
|
|
|
def _execute(self, context):
|
|
for obj in context.selected_objects:
|
|
element = tool.Ifc.get_entity(obj)
|
|
if not element or not element.FillsVoids:
|
|
continue
|
|
|
|
filled_opening = element.FillsVoids[0].RelatingOpeningElement
|
|
filled_element = filled_opening.VoidsElements[0].RelatingBuildingElement
|
|
filled_object = tool.Ifc.get_object(filled_element)
|
|
|
|
if filled_element.is_a() in ["IfcWall", "IfcWallStandardCase"]:
|
|
# if the filled element is a wall, move the filling in such a way
|
|
# that it will have the same relative position, but to the other
|
|
# side of the wall
|
|
#
|
|
# For example, if a door frame protudes 1cm out of the wall,
|
|
# it will produde 1cm out of the other side of the wall.
|
|
|
|
layers = tool.Model.get_material_layer_parameters(filled_element)
|
|
axes = tool.Model.get_wall_axis(filled_object, layers=layers)
|
|
|
|
center_axis = [(axes["base"][0] + axes["side"][0]) * 0.5, (axes["base"][1] + axes["side"][1]) * 0.5]
|
|
|
|
original_pos = obj.matrix_world.translation
|
|
bb = tool.Blender.get_object_bounding_box(obj)
|
|
min_y = min(bb["min_y"], 0)
|
|
max_y = max(bb["max_y"], 0)
|
|
|
|
point_on_center_axis = tool.Cad.point_on_edge(original_pos, center_axis)
|
|
offset_to_center_axis = point_on_center_axis - original_pos
|
|
offset_to_center_axis.z = 0
|
|
depth_offset = max_y + min_y
|
|
depth_correction_vec = offset_to_center_axis.normalized() * depth_offset
|
|
|
|
mirrored_point = original_pos + offset_to_center_axis * 2.0 - depth_correction_vec
|
|
|
|
obj.matrix_world.translation = mirrored_point
|
|
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
|
|
|
|
tool.Geometry.flip_object(obj, "XY")
|
|
ifcopenshell.api.geometry.edit_object_placement(tool.Ifc.get(), filled_opening, obj.matrix_world)
|
|
tool.Geometry.reload_representation(filled_object)
|
|
|
|
return {"FINISHED"}
|
|
|
|
|
|
class AddBoolean(Operator, tool.Ifc.Operator):
|
|
bl_idname = "bim.add_boolean"
|
|
bl_label = "Add Boolean"
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
bl_description = "Applies a boolean to the selected IFC object using the other selected blender object as a void"
|
|
|
|
@classmethod
|
|
def poll(cls, context):
|
|
if not len(context.selected_objects) >= 2:
|
|
cls.poll_message_set("At least 2 objects need to be selected.")
|
|
return False
|
|
return True
|
|
|
|
def _execute(self, context):
|
|
first_obj = tool.Blender.get_active_object()
|
|
if not first_obj or not tool.Geometry.is_boolean_operand(first_obj):
|
|
self.report({"INFO"}, "At least two valid objects must be selected to add a boolean.")
|
|
return {"CANCELLED"}
|
|
second_objs = [
|
|
o for o in tool.Blender.get_selected_objects() if o != first_obj and tool.Geometry.is_boolean_operand(o)
|
|
]
|
|
if not second_objs:
|
|
self.report({"INFO"}, "At least two representation items must be selected to add a boolean.")
|
|
return {"CANCELLED"}
|
|
|
|
props = tool.Feature.get_boolean_props()
|
|
|
|
first_item = tool.Geometry.get_active_representation(first_obj)
|
|
assert first_item
|
|
second_items = [
|
|
representation for o in second_objs if (representation := tool.Geometry.get_active_representation(o))
|
|
]
|
|
booleans = ifcopenshell.api.geometry.add_boolean(tool.Ifc.get(), first_item, second_items, props.operator)
|
|
|
|
rep_obj = tool.Geometry.get_geometry_props().representation_obj
|
|
if booleans:
|
|
# Users typically select two top-level items and expect the
|
|
# operand to be absorbed into the boolean, not remain as a
|
|
# standalone item alongside it.
|
|
representation = tool.Geometry.get_active_representation(rep_obj)
|
|
representation = ifcopenshell.util.representation.resolve_representation(representation)
|
|
second_items_set = set(second_items)
|
|
new_items = [i for i in representation.Items if i not in second_items_set]
|
|
if new_items:
|
|
representation.Items = new_items
|
|
rep_element = tool.Ifc.get_entity(rep_obj)
|
|
tool.Model.mark_manual_booleans(rep_element, booleans)
|
|
tool.Geometry.reload_representation(rep_obj)
|
|
if props.is_editing:
|
|
bpy.ops.bim.enable_editing_booleans()
|
|
tool.Root.reload_item_decorator()
|
|
|
|
|
|
class ToggleHostOpenings(Operator, tool.Ifc.Operator):
|
|
bl_idname = "bim.toggle_host_openings"
|
|
bl_label = "Toggle Openings"
|
|
bl_description = "Show or hide opening fills (doors and windows) in the viewport\n\nHotkey: Alt+O"
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
|
|
@classmethod
|
|
def poll(cls, context):
|
|
if not tool.Model.has_selected_ifc_objects():
|
|
cls.poll_message_set("No IFC objects selected.")
|
|
return False
|
|
return True
|
|
|
|
def _execute(self, context: bpy.types.Context) -> set[str]:
|
|
# Opening visibility is independent of host geometry — don't commit any
|
|
# active parametric edit; the user can keep editing the host.
|
|
if tool.Model.get_model_props().openings:
|
|
bpy.ops.bim.edit_openings(apply_all=True)
|
|
else:
|
|
bpy.ops.bim.show_openings()
|
|
return {"FINISHED"}
|
|
|
|
|
|
class ShowOpenings(Operator, tool.Ifc.Operator):
|
|
bl_idname = "bim.show_openings"
|
|
bl_label = "Show Openings"
|
|
bl_description = "Show openings for selected objects"
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
|
|
def _execute(self, context):
|
|
for obj in tool.Blender.get_selected_objects():
|
|
element = tool.Ifc.get_entity(obj)
|
|
if element is None:
|
|
continue
|
|
self.show_object_openings(obj, element)
|
|
DecorationsHandler.install(bpy.context)
|
|
bpy.ops.bim.update_openings_focus()
|
|
return {"FINISHED"}
|
|
|
|
def show_object_openings(self, obj: bpy.types.Object, element: ifcopenshell.entity_instance) -> None:
|
|
openings_elements = [rel.RelatedOpeningElement for rel in tool.Geometry.get_openings(element)]
|
|
if not openings_elements:
|
|
return
|
|
if tool.Ifc.is_moved(obj):
|
|
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
|
|
openings_elements_to_load = [o for o in openings_elements if not tool.Ifc.get_object(o)]
|
|
openings_objects = tool.Model.load_openings(openings_elements_to_load)
|
|
for obj in openings_objects:
|
|
tool.Root.add_tracked_opening(obj, "OPENING")
|
|
|
|
|
|
class UpdateOpeningsFocus(Operator):
|
|
bl_idname = "bim.update_openings_focus"
|
|
bl_label = "Update Openings Focus"
|
|
bl_description = "Show objects that are not part of the object or its openings as transparent"
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
|
|
def execute(self, context):
|
|
preferences = tool.Blender.get_addon_preferences()
|
|
if preferences.opening_focus_opacity == 100:
|
|
return {"FINISHED"}
|
|
openings: set[bpy.types.Object] = set()
|
|
building_objects: set[bpy.types.Object] = set()
|
|
props = tool.Model.get_model_props()
|
|
for opening in props.openings:
|
|
if opening.obj:
|
|
openings.add(opening.obj)
|
|
opening_element = tool.Ifc.get_entity(opening.obj)
|
|
assert opening_element
|
|
building_element = opening_element.VoidsElements[0].RelatingBuildingElement
|
|
building_obj = tool.Ifc.get_object(building_element)
|
|
assert isinstance(building_obj, bpy.types.Object)
|
|
building_objects.add(building_obj)
|
|
|
|
for obj in context.scene.objects:
|
|
obj.color = [
|
|
obj.color[0],
|
|
obj.color[1],
|
|
obj.color[2],
|
|
(
|
|
1
|
|
if not props.openings
|
|
or not building_objects
|
|
or obj in openings
|
|
or obj in building_objects
|
|
or obj in context.selected_objects
|
|
else preferences.opening_focus_opacity / 100
|
|
),
|
|
]
|
|
return {"FINISHED"}
|
|
|
|
|
|
def hide_openings(context: bpy.types.Context, objects: Sequence[bpy.types.Object]) -> None:
|
|
objects_to_remove = set()
|
|
props = tool.Model.get_model_props()
|
|
for opening_prop in props.openings:
|
|
opening_obj = opening_prop.obj
|
|
if not opening_obj:
|
|
continue
|
|
opening_element = tool.Ifc.get_entity(opening_obj)
|
|
if opening_element:
|
|
if not opening_element.is_a("IfcOpeningElement"):
|
|
# This opening has been assigned to another ifc class. Remove it from the openings pool. See #3854
|
|
opening_prop.obj = None
|
|
continue
|
|
building_element = opening_element.VoidsElements[0].RelatingBuildingElement
|
|
if building_element:
|
|
building_obj = tool.Ifc.get_object(building_element)
|
|
if building_obj in objects:
|
|
tool.Ifc.unlink(element=opening_element)
|
|
objects_to_remove.add(opening_obj)
|
|
if opening_obj in objects:
|
|
objects_to_remove.add(opening_obj)
|
|
|
|
tool.Blender.remove_data_blocks(objects_to_remove, remove_unused_data=True)
|
|
tool.Model.purge_scene_openings()
|
|
bpy.ops.bim.update_openings_focus()
|
|
|
|
|
|
class HideAllOpenings(Operator, tool.Ifc.Operator):
|
|
bl_idname = "bim.hide_all_openings"
|
|
bl_label = "Hide All Openings"
|
|
bl_description = "Hide every single opening"
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
|
|
def _execute(self, context):
|
|
hide_openings(context, context.scene.objects[:])
|
|
return {"FINISHED"}
|
|
|
|
|
|
class HideOpenings(Operator, tool.Ifc.Operator):
|
|
bl_idname = "bim.hide_openings"
|
|
bl_label = "Hide"
|
|
bl_description = "Hide Openings"
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
|
|
def _execute(self, context):
|
|
hide_openings(context, context.selected_objects[:])
|
|
return {"FINISHED"}
|
|
|
|
|
|
class EditOpenings(Operator, tool.Ifc.Operator):
|
|
bl_idname = "bim.edit_openings"
|
|
bl_label = "Edit"
|
|
bl_description = "Edit Openings"
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
apply_all: bpy.props.BoolProperty(default=False)
|
|
|
|
def _execute(self, context):
|
|
building_objs, opening_elements = self.get_buildings_and_openings(context)
|
|
self.edit_openings(building_objs, opening_elements)
|
|
|
|
tool.Model.purge_scene_openings()
|
|
tool.Model.reload_body_representation(building_objs)
|
|
bpy.ops.bim.update_openings_focus()
|
|
return {"FINISHED"}
|
|
|
|
def get_buildings_and_openings(
|
|
self, context: bpy.types.Context
|
|
) -> tuple[set[bpy.types.Object], set[ifcopenshell.entity_instance]]:
|
|
props = tool.Model.get_model_props()
|
|
building_objs: set[bpy.types.Object] = set()
|
|
opening_elements: set[ifcopenshell.entity_instance] = set()
|
|
objects_to_remove = set()
|
|
if self.apply_all:
|
|
for opening_prop in props.openings:
|
|
opening_obj = opening_prop.obj
|
|
if opening_obj is None:
|
|
continue
|
|
opening_element = tool.Ifc.get_entity(opening_obj)
|
|
if opening_element is None:
|
|
objects_to_remove.add(opening_obj)
|
|
continue
|
|
opening_elements.add(opening_element)
|
|
building_element = opening_element.VoidsElements[0].RelatingBuildingElement
|
|
building_objs.add(tool.Ifc.get_object(building_element))
|
|
else:
|
|
for obj in context.selected_objects:
|
|
element = tool.Ifc.get_entity(obj)
|
|
if element.is_a("IfcOpeningElement"):
|
|
opening_element = element
|
|
opening_elements.add(opening_element)
|
|
if opening_element.VoidsElements:
|
|
building_element = opening_element.VoidsElements[0].RelatingBuildingElement
|
|
building_obj = tool.Ifc.get_object(building_element)
|
|
if building_obj:
|
|
building_objs.add(building_obj)
|
|
else:
|
|
for relation in tool.Geometry.get_openings(element):
|
|
opening_element = relation.RelatedOpeningElement
|
|
if tool.Ifc.get_object(opening_element):
|
|
opening_elements.add(opening_element)
|
|
building_objs.add(obj)
|
|
tool.Blender.remove_data_blocks(objects_to_remove, remove_unused_data=True)
|
|
return building_objs, opening_elements
|
|
|
|
def edit_openings(
|
|
self, building_objs: set[bpy.types.Object], opening_elements: set[ifcopenshell.entity_instance]
|
|
) -> None:
|
|
props = tool.Geometry.get_geometry_props()
|
|
objects_to_remove: set[bpy.types.Object] = set()
|
|
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)
|
|
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)
|
|
) # 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
|
|
objects_to_remove.add(opening_obj)
|
|
tool.Blender.remove_data_blocks(objects_to_remove, remove_unused_data=True)
|
|
|
|
def get_all_building_objects_of_similar_openings(self, opening):
|
|
if not opening.is_a("IfcOpeningElement") or not opening.HasFillings:
|
|
return []
|
|
results = set()
|
|
for rel in opening.HasFillings:
|
|
filling_type = ifcopenshell.util.element.get_type(rel.RelatedBuildingElement)
|
|
if not filling_type:
|
|
continue
|
|
for occurrence in ifcopenshell.util.element.get_types(filling_type):
|
|
for rel2 in occurrence.FillsVoids:
|
|
for rel3 in rel2.RelatingOpeningElement.VoidsElements:
|
|
obj = tool.Ifc.get_object(rel3.RelatingBuildingElement)
|
|
if obj:
|
|
results.add(obj)
|
|
return results
|
|
|
|
|
|
class CloneOpening(Operator, tool.Ifc.Operator):
|
|
bl_idname = "bim.clone_opening"
|
|
bl_label = "Clone Opening"
|
|
bl_description = "Clone the active Opening object and assign to the selected Element"
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
|
|
@classmethod
|
|
def poll(cls, context):
|
|
if len(context.selected_objects) != 2:
|
|
cls.poll_message_set("Exactly 2 objects must be selected.")
|
|
return False
|
|
return True
|
|
|
|
def _execute(self, context):
|
|
# NOTE: Operator displayed in UI only with IfcOpeningElement being active.
|
|
ifc_file = tool.Ifc.get()
|
|
objects = bpy.context.selected_objects
|
|
opening_obj = context.active_object
|
|
assert opening_obj
|
|
opening = tool.Ifc.get_entity(opening_obj)
|
|
assert opening and opening.is_a("IfcOpeningElement")
|
|
|
|
voided_obj = next(o for o in objects if o != opening_obj)
|
|
voided_element = tool.Ifc.get_entity(voided_obj)
|
|
assert voided_element
|
|
|
|
opening_placement = opening.ObjectPlacement
|
|
opening_representation = opening.Representation
|
|
|
|
new_opening = ifcopenshell.api.root.create_entity(tool.Ifc.get(), ifc_class="IfcOpeningElement")
|
|
new_opening.Representation = opening_representation
|
|
|
|
ifcopenshell.api.feature.add_feature(ifc_file, feature=new_opening, element=voided_element)
|
|
new_opening.ObjectPlacement = opening_placement
|
|
|
|
# Update affected representations.
|
|
elements_to_update = tool.Aggregate.get_parts_recursively(voided_element)
|
|
for element in elements_to_update:
|
|
obj = tool.Ifc.get_object(element)
|
|
if not isinstance(obj, bpy.types.Object) or not isinstance(obj.data, bpy.types.Mesh):
|
|
continue
|
|
representation = tool.Geometry.get_active_representation(obj)
|
|
assert representation
|
|
bonsai.core.geometry.switch_representation(
|
|
tool.Ifc,
|
|
tool.Geometry,
|
|
obj=obj,
|
|
representation=representation,
|
|
)
|
|
|
|
return {"FINISHED"}
|
|
|
|
|
|
class PurgeUnusedOpenings(Operator, tool.Ifc.Operator):
|
|
bl_idname = "bim.purge_unused_openings"
|
|
bl_label = "Purge Unused Openings"
|
|
bl_description = "Purge Openings that do not intersect with their related building element"
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
|
|
@classmethod
|
|
def poll(cls, context):
|
|
poll = any(
|
|
tool.Geometry.has_openings(element)
|
|
for element in [tool.Ifc.get_entity(obj) for obj in context.selected_objects]
|
|
if element
|
|
)
|
|
if not poll:
|
|
cls.poll_message_set("No objects with openings selected.")
|
|
return False
|
|
return True
|
|
|
|
def _execute(self, context):
|
|
bpy.ops.bim.show_openings()
|
|
objects = context.selected_objects[:]
|
|
[o.select_set(False) for o in objects]
|
|
active_object = context.active_object
|
|
purged = 0
|
|
for obj in objects:
|
|
element = tool.Ifc.get_entity(obj)
|
|
if not element or not tool.Geometry.has_openings(element):
|
|
continue
|
|
obj_bvh_tree = tool.Geometry.get_bvh_tree(obj)
|
|
for opening_rel in tool.Geometry.get_openings(element):
|
|
opening_elt = opening_rel.RelatedOpeningElement
|
|
opening_obj = tool.Ifc.get_object(opening_elt)
|
|
opening_bvh_tree = tool.Geometry.get_bvh_tree(opening_obj)
|
|
if not opening_bvh_tree.overlap(obj_bvh_tree):
|
|
opening_obj.select_set(True)
|
|
purged += 1
|
|
if context.selected_objects:
|
|
bpy.ops.bim.override_object_delete(is_batch=False)
|
|
bpy.ops.bim.edit_openings(apply_all=True)
|
|
[o.select_set(True) for o in objects]
|
|
context.view_layer.objects.active = active_object
|
|
self.report({"INFO"}, f"{purged} unused openings were purged.")
|
|
return {"FINISHED"}
|
|
|
|
|
|
class RemoveBoolean(Operator, tool.Ifc.Operator):
|
|
bl_idname = "bim.remove_boolean"
|
|
bl_label = "Remove Boolean"
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
bl_description = "Removes the actively selected boolean"
|
|
|
|
@classmethod
|
|
def poll(cls, context):
|
|
props = tool.Feature.get_boolean_props()
|
|
return props.active_boolean
|
|
|
|
def _execute(self, context):
|
|
props = tool.Feature.get_boolean_props()
|
|
ifcopenshell.api.geometry.remove_boolean(
|
|
tool.Ifc.get(), tool.Ifc.get().by_id(props.active_boolean.ifc_definition_id)
|
|
)
|
|
bpy.ops.bim.enable_editing_booleans()
|
|
rep_obj = tool.Geometry.get_geometry_props().representation_obj
|
|
assert rep_obj
|
|
tool.Geometry.reload_representation(rep_obj)
|
|
tool.Root.reload_item_decorator()
|
|
|
|
|
|
class SelectBoolean(Operator):
|
|
bl_idname = "bim.select_boolean"
|
|
bl_label = "Select Boolean"
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
bl_description = "Selects operands of the active boolean\nSHIFT-CLICK to select all operands recursively"
|
|
is_recursive: bpy.props.BoolProperty(name="Is Recursive", default=False, options={"SKIP_SAVE"})
|
|
|
|
@classmethod
|
|
def poll(cls, context):
|
|
props = tool.Feature.get_boolean_props()
|
|
return props.active_boolean
|
|
|
|
def invoke(self, context, event):
|
|
if event.type == "LEFTMOUSE" and event.shift:
|
|
self.is_recursive = True
|
|
return self.execute(context)
|
|
|
|
def execute(self, context):
|
|
props = tool.Feature.get_boolean_props()
|
|
queue = [tool.Ifc.get().by_id(props.active_boolean.ifc_definition_id)]
|
|
items = {i.ifc_definition_id: i.obj for i in tool.Geometry.get_geometry_props().item_objs}
|
|
while queue:
|
|
item = queue.pop()
|
|
if item.is_a("IfcBooleanResult"):
|
|
if self.is_recursive:
|
|
queue.append(item.FirstOperand)
|
|
queue.append(item.SecondOperand)
|
|
else:
|
|
if obj := items.get(item.FirstOperand.id()):
|
|
tool.Blender.select_object(obj)
|
|
if obj := items.get(item.SecondOperand.id()):
|
|
tool.Blender.select_object(obj)
|
|
elif obj := items.get(item.id()):
|
|
tool.Blender.select_object(obj)
|
|
return {"FINISHED"}
|
|
|
|
|
|
class DecorationsHandler:
|
|
installed = None
|
|
|
|
@classmethod
|
|
def install(cls, context):
|
|
if cls.installed:
|
|
cls.uninstall()
|
|
handler = cls()
|
|
cls.installed = SpaceView3D.draw_handler_add(handler, (context,), "WINDOW", "POST_VIEW")
|
|
install_decoration_cache_handlers()
|
|
|
|
@classmethod
|
|
def uninstall(cls):
|
|
try:
|
|
SpaceView3D.draw_handler_remove(cls.installed, "WINDOW")
|
|
except ValueError:
|
|
pass
|
|
cls.installed = None
|
|
uninstall_decoration_cache_handlers()
|
|
|
|
def _get_or_build_batch(self, shader, shader_type, content_pos, indices=None, cache_key=None):
|
|
if cache_key is not None:
|
|
cached = _get_cached_batch_or_none(cache_key)
|
|
if cached is not None:
|
|
return cached
|
|
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
|
|
return None
|
|
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
|
|
if cache_key is not None:
|
|
_store_batch_in_cache(cache_key, batch)
|
|
return batch
|
|
|
|
def draw_batch(self, shader_type, content_pos, color, indices=None, cache_key=None):
|
|
shader = self.line_shader if shader_type == "LINES" else self.shader
|
|
batch = self._get_or_build_batch(shader, shader_type, content_pos, indices, cache_key=cache_key)
|
|
if batch is None:
|
|
return
|
|
shader.uniform_float("color", color)
|
|
batch.draw(shader)
|
|
|
|
def _draw_lines_with_occlusion(self, verts, color, edges_indices, occluded_alpha: float = 0.25, cache_key=None):
|
|
# One batch, two draws: front pass at full color, occluded pass at
|
|
# `occluded_alpha`. Save/restore depth_test matches the pattern in
|
|
# bim/module/structural/decorator.py so callers' state survives.
|
|
batch = self._get_or_build_batch(self.line_shader, "LINES", verts, edges_indices, cache_key=cache_key)
|
|
if batch is None:
|
|
return
|
|
original_depth_test = gpu.state.depth_test_get()
|
|
gpu.state.depth_test_set("LESS_EQUAL")
|
|
self.line_shader.uniform_float("color", color)
|
|
batch.draw(self.line_shader)
|
|
gpu.state.depth_test_set("GREATER")
|
|
dimmed = list(color)
|
|
dimmed[3] = occluded_alpha
|
|
self.line_shader.uniform_float("color", dimmed)
|
|
batch.draw(self.line_shader)
|
|
gpu.state.depth_test_set(original_depth_test)
|
|
|
|
def __call__(self, context):
|
|
props = tool.Model.get_model_props()
|
|
if not props.openings:
|
|
return
|
|
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
|
|
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")
|
|
|
|
gprops = tool.Geometry.get_geometry_props()
|
|
for opening in props.openings:
|
|
obj = opening.obj
|
|
if gprops.representation_obj == obj:
|
|
# We are editing the representation of the opening :
|
|
for item in gprops.item_objs:
|
|
if item.obj.mode == "EDIT":
|
|
obj = item.obj
|
|
break
|
|
if not obj:
|
|
continue
|
|
|
|
self.line_shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR")
|
|
self.line_shader.bind() # required to be able to change uniforms of the shader
|
|
# POLYLINE_UNIFORM_COLOR specific uniforms
|
|
self.line_shader.uniform_float("viewportSize", (context.region.width, context.region.height))
|
|
self.line_shader.uniform_float("lineWidth", 2.0)
|
|
|
|
# general shader
|
|
self.shader = gpu.shader.from_builtin("UNIFORM_COLOR")
|
|
|
|
verts = []
|
|
selected_edges = []
|
|
unselected_edges = []
|
|
selected_vertices = []
|
|
unselected_vertices = []
|
|
|
|
if obj.mode == "EDIT":
|
|
bm = bmesh.from_edit_mesh(obj.data)
|
|
|
|
for vertex in bm.verts:
|
|
co = tuple(obj.matrix_world @ vertex.co)
|
|
verts.append(co)
|
|
if vertex.hide:
|
|
continue
|
|
|
|
if vertex.select:
|
|
selected_vertices.append(co)
|
|
else:
|
|
unselected_vertices.append(co)
|
|
|
|
for edge in bm.edges:
|
|
edge_indices = [v.index for v in edge.verts]
|
|
if edge.hide:
|
|
continue
|
|
if edge.select:
|
|
selected_edges.append(edge_indices)
|
|
else:
|
|
unselected_edges.append(edge_indices)
|
|
|
|
self.draw_batch("LINES", verts, transparent_color(unselected_elements_color, 0.5), unselected_edges)
|
|
self.draw_batch("LINES", verts, selected_elements_color, selected_edges)
|
|
self.draw_batch("POINTS", unselected_vertices, unselected_elements_color)
|
|
self.draw_batch("POINTS", selected_vertices, selected_elements_color)
|
|
obj.data.calc_loop_triangles()
|
|
tris = [tuple(t.vertices) for t in obj.data.loop_triangles]
|
|
self.draw_batch("TRIS", verts, transparent_color(special_elements_color), tris)
|
|
else:
|
|
line_verts, verts, edges_indices, tris = _get_cached_world_draw_data(obj)
|
|
color = selected_elements_color if obj in context.selected_objects else special_elements_color
|
|
self._draw_lines_with_occlusion(line_verts, color, edges_indices, cache_key=(obj.session_uid, "lines"))
|
|
self.draw_batch(
|
|
"TRIS",
|
|
verts,
|
|
transparent_color(special_elements_color),
|
|
tris,
|
|
cache_key=(obj.session_uid, "tris"),
|
|
)
|
|
|
|
if "HalfSpaceSolid" in obj.name:
|
|
# Arrow shape
|
|
verts = [
|
|
tuple(obj.matrix_world @ Vector((0, 0, 0))),
|
|
tuple(obj.matrix_world @ Vector((0, 0, 0.5))),
|
|
tuple(obj.matrix_world @ Vector((0.05, 0, 0.45))),
|
|
tuple(obj.matrix_world @ Vector((-0.05, 0, 0.45))),
|
|
tuple(obj.matrix_world @ Vector((0, 0.05, 0.45))),
|
|
tuple(obj.matrix_world @ Vector((0, -0.05, 0.45))),
|
|
]
|
|
edges = [(0, 1), (1, 2), (1, 3), (1, 4), (1, 5)]
|
|
color = selected_elements_color if obj in context.selected_objects else special_elements_color
|
|
self._draw_lines_with_occlusion(verts, color, edges, cache_key=(obj.session_uid, "arrow"))
|