mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-12 10:33:20 +00:00
Merge branch 'v0.8.0' into ifcmax/initial-refresh
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
name: ci-ifcwrap-standalone
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/ci-ifcwrap-standalone.yml"
|
||||
- "cmake/**"
|
||||
- "src/ifcwrap/**"
|
||||
- "src/ifcparse/**"
|
||||
- "src/ifcgeom/**"
|
||||
- "src/serializers/**"
|
||||
- "src/ifcconvert/**"
|
||||
- "src/ifcopenshell-python/**"
|
||||
- "src/svgfill/**"
|
||||
push:
|
||||
paths:
|
||||
- ".github/workflows/ci-ifcwrap-standalone.yml"
|
||||
- "cmake/**"
|
||||
- "src/ifcwrap/**"
|
||||
- "src/ifcparse/**"
|
||||
- "src/ifcgeom/**"
|
||||
- "src/serializers/**"
|
||||
- "src/ifcconvert/**"
|
||||
- "src/ifcopenshell-python/**"
|
||||
- "src/svgfill/**"
|
||||
|
||||
env:
|
||||
IFCOPENSHELL_PREFIX: ${{ github.workspace }}/ifcopenshell-install
|
||||
|
||||
jobs:
|
||||
build-ifcopenshell:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Install C++ dependencies
|
||||
run: |
|
||||
sudo apt update
|
||||
sudo apt-get install --no-install-recommends -y \
|
||||
cmake \
|
||||
gcc \
|
||||
g++ \
|
||||
libboost-date-time-dev \
|
||||
libboost-filesystem-dev \
|
||||
libboost-iostreams-dev \
|
||||
libboost-program-options-dev \
|
||||
libboost-regex-dev \
|
||||
libboost-system-dev \
|
||||
libboost-thread-dev \
|
||||
libeigen3-dev \
|
||||
libocct-data-exchange-dev \
|
||||
libocct-draw-dev \
|
||||
libocct-foundation-dev \
|
||||
libocct-modeling-algorithms-dev \
|
||||
libocct-modeling-data-dev \
|
||||
libocct-ocaf-dev \
|
||||
libocct-visualization-dev \
|
||||
libpcre3-dev \
|
||||
libtbb-dev \
|
||||
libxml2-dev \
|
||||
libxi-dev \
|
||||
occt-misc \
|
||||
tcl-dev \
|
||||
tk-dev \
|
||||
swig
|
||||
|
||||
- name: Configure minimal IfcOpenShell
|
||||
run: |
|
||||
cmake -S cmake -B build-ifcopenshell \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_INSTALL_PREFIX="${IFCOPENSHELL_PREFIX}" \
|
||||
-DCMAKE_PREFIX_PATH=/usr \
|
||||
-DCMAKE_SYSTEM_PREFIX_PATH=/usr \
|
||||
-DMINIMAL_BUILD=ON \
|
||||
-DBUILD_IFCPYTHON=OFF \
|
||||
"-DSCHEMA_VERSIONS=4x3_add2"
|
||||
|
||||
- name: Build and install minimal IfcOpenShell
|
||||
run: |
|
||||
cmake --build build-ifcopenshell --target install -j "$(nproc)"
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: 3.11
|
||||
|
||||
- name: Install Python import dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install numpy typing_extensions
|
||||
|
||||
- name: Configure standalone IfcPython
|
||||
run: |
|
||||
PYTHON_EXECUTABLE="$(python -c 'import sys; print(sys.executable)')"
|
||||
PYTHON_INCLUDE_DIR="$(python -c 'import sysconfig; print(sysconfig.get_path("include"))')"
|
||||
|
||||
cmake -S src/ifcwrap -B "build-ifcwrap-311" \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_PREFIX_PATH="${IFCOPENSHELL_PREFIX};/usr" \
|
||||
-DPython_EXECUTABLE:FILEPATH="${PYTHON_EXECUTABLE}" \
|
||||
-DPython_INCLUDE_DIR:PATH="${PYTHON_INCLUDE_DIR}" \
|
||||
-DPYTHON_EXECUTABLE:FILEPATH="${PYTHON_EXECUTABLE}" \
|
||||
-DPYTHON_INCLUDE_DIR:PATH="${PYTHON_INCLUDE_DIR}"
|
||||
|
||||
- name: Build and install standalone IfcPython
|
||||
run: |
|
||||
cmake --build "build-ifcwrap-311" --target install -j "$(nproc)"
|
||||
|
||||
- name: Import installed IfcPython
|
||||
run: |
|
||||
PYTHONPATH="${RUNNER_TEMP}/ifcopenshell-python" python - <<'PY'
|
||||
import ifcopenshell
|
||||
|
||||
print("IfcOpenShell import ok:", ifcopenshell.version)
|
||||
PY
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: 3.12
|
||||
|
||||
- name: Install Python import dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install numpy typing_extensions
|
||||
|
||||
- name: Configure standalone IfcPython
|
||||
run: |
|
||||
PYTHON_EXECUTABLE="$(python -c 'import sys; print(sys.executable)')"
|
||||
PYTHON_INCLUDE_DIR="$(python -c 'import sysconfig; print(sysconfig.get_path("include"))')"
|
||||
|
||||
cmake -S src/ifcwrap -B "build-ifcwrap-312" \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_PREFIX_PATH="${IFCOPENSHELL_PREFIX};/usr" \
|
||||
-DPython_EXECUTABLE:FILEPATH="${PYTHON_EXECUTABLE}" \
|
||||
-DPython_INCLUDE_DIR:PATH="${PYTHON_INCLUDE_DIR}" \
|
||||
-DPYTHON_EXECUTABLE:FILEPATH="${PYTHON_EXECUTABLE}" \
|
||||
-DPYTHON_INCLUDE_DIR:PATH="${PYTHON_INCLUDE_DIR}"
|
||||
|
||||
- name: Build and install standalone IfcPython
|
||||
run: |
|
||||
cmake --build "build-ifcwrap-312" --target install -j "$(nproc)"
|
||||
|
||||
- name: Import installed IfcPython
|
||||
run: |
|
||||
PYTHONPATH="${RUNNER_TEMP}/ifcopenshell-python" python - <<'PY'
|
||||
import ifcopenshell
|
||||
|
||||
print("IfcOpenShell import ok:", ifcopenshell.version)
|
||||
PY
|
||||
@@ -0,0 +1,131 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
################################################################################
|
||||
# #
|
||||
# This file is part of IfcOpenShell. #
|
||||
# #
|
||||
# IfcOpenShell is free software: you can redistribute it and/or modify #
|
||||
# it under the terms of the Lesser GNU General Public License as published by #
|
||||
# the Free Software Foundation, either version 3.0 of the License, or #
|
||||
# (at your option) any later version. #
|
||||
# #
|
||||
# IfcOpenShell 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 #
|
||||
# Lesser GNU General Public License for more details. #
|
||||
# #
|
||||
# You should have received a copy of the Lesser GNU General Public License #
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>. #
|
||||
# #
|
||||
################################################################################
|
||||
|
||||
include("${CMAKE_CURRENT_LIST_DIR}/utilities.cmake" OPTIONAL)
|
||||
|
||||
set(_IfcOpenShell_find_args)
|
||||
if(IfcOpenShell_FIND_VERSION)
|
||||
list(APPEND _IfcOpenShell_find_args "${IfcOpenShell_FIND_VERSION}")
|
||||
if(IfcOpenShell_FIND_VERSION_EXACT)
|
||||
list(APPEND _IfcOpenShell_find_args EXACT)
|
||||
endif()
|
||||
endif()
|
||||
list(APPEND _IfcOpenShell_find_args CONFIG QUIET)
|
||||
if(IfcOpenShell_FIND_COMPONENTS)
|
||||
list(APPEND _IfcOpenShell_find_args COMPONENTS ${IfcOpenShell_FIND_COMPONENTS})
|
||||
endif()
|
||||
|
||||
set(_IfcOpenShell_saved_module_path "${CMAKE_MODULE_PATH}")
|
||||
list(REMOVE_ITEM CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}")
|
||||
find_package(IfcOpenShell ${_IfcOpenShell_find_args})
|
||||
set(CMAKE_MODULE_PATH "${_IfcOpenShell_saved_module_path}")
|
||||
|
||||
if(NOT IfcOpenShell_FOUND)
|
||||
set(_IfcOpenShell_error "Could not find an IfcOpenShell CMake config package. Set IfcOpenShell_DIR or CMAKE_PREFIX_PATH.")
|
||||
if(IfcOpenShell_FIND_REQUIRED)
|
||||
message(FATAL_ERROR "${_IfcOpenShell_error}")
|
||||
elseif(NOT IfcOpenShell_FIND_QUIETLY)
|
||||
message(STATUS "${_IfcOpenShell_error}")
|
||||
endif()
|
||||
return()
|
||||
endif()
|
||||
|
||||
set(_IfcOpenShell_required_targets IfcOpenShell::IfcParse IfcOpenShell::IfcGeom)
|
||||
set(_IfcOpenShell_missing_targets "")
|
||||
foreach(_IfcOpenShell_target IN LISTS _IfcOpenShell_required_targets)
|
||||
if(NOT TARGET ${_IfcOpenShell_target})
|
||||
list(APPEND _IfcOpenShell_missing_targets ${_IfcOpenShell_target})
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
if(_IfcOpenShell_missing_targets)
|
||||
set(IfcOpenShell_FOUND FALSE)
|
||||
string(REPLACE ";" ", " _IfcOpenShell_missing_targets_text "${_IfcOpenShell_missing_targets}")
|
||||
set(_IfcOpenShell_error "IfcOpenShell config was found, but required targets are missing: ${_IfcOpenShell_missing_targets_text}.")
|
||||
if(IfcOpenShell_FIND_REQUIRED)
|
||||
message(FATAL_ERROR "${_IfcOpenShell_error}")
|
||||
elseif(NOT IfcOpenShell_FIND_QUIETLY)
|
||||
message(STATUS "${_IfcOpenShell_error}")
|
||||
endif()
|
||||
return()
|
||||
endif()
|
||||
|
||||
if(NOT DEFINED IFCOPENSHELL_WITH_OPENCASCADE)
|
||||
set(IFCOPENSHELL_WITH_OPENCASCADE OFF)
|
||||
if(TARGET IfcOpenShell::geometry_kernel_opencascade)
|
||||
set(IFCOPENSHELL_WITH_OPENCASCADE ON)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
if(NOT DEFINED IFCOPENSHELL_WITH_CGAL)
|
||||
set(IFCOPENSHELL_WITH_CGAL OFF)
|
||||
if(TARGET IfcOpenShell::IFCOPENSHELL_CGAL)
|
||||
set(IFCOPENSHELL_WITH_CGAL ON)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
if(NOT DEFINED IFCOPENSHELL_IFCXML)
|
||||
set(IFCOPENSHELL_IFCXML OFF)
|
||||
endif()
|
||||
|
||||
if(NOT DEFINED IFCOPENSHELL_WITH_ROCKSDB)
|
||||
set(IFCOPENSHELL_WITH_ROCKSDB OFF)
|
||||
endif()
|
||||
|
||||
set(IFCOPENSHELL_LIBRARIES IfcOpenShell::IfcParse)
|
||||
foreach(_IfcOpenShell_target IN ITEMS IfcOpenShell::geometry_serializer IfcOpenShell::Serializers)
|
||||
if(TARGET ${_IfcOpenShell_target})
|
||||
list(APPEND IFCOPENSHELL_LIBRARIES ${_IfcOpenShell_target})
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
set(IFCOPENSHELL_KERNEL_LIBRARIES "")
|
||||
foreach(_IfcOpenShell_target IN ITEMS
|
||||
IfcOpenShell::geometry_kernel_opencascade
|
||||
IfcOpenShell::geometry_kernel_cgal
|
||||
IfcOpenShell::geometry_kernel_cgal_simple
|
||||
)
|
||||
if(TARGET ${_IfcOpenShell_target})
|
||||
list(APPEND IFCOPENSHELL_KERNEL_LIBRARIES ${_IfcOpenShell_target})
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
set(IFCOPENSHELL_GEOMETRY_LIBRARIES IfcOpenShell::IfcGeom ${IFCOPENSHELL_KERNEL_LIBRARIES})
|
||||
|
||||
if(TARGET IfcOpenShell::OpenCASCADE_INTERFACE)
|
||||
set(OpenCASCADE_LIBRARIES IfcOpenShell::OpenCASCADE_INTERFACE)
|
||||
endif()
|
||||
|
||||
if(TARGET IfcOpenShell::IFCOPENSHELL_CGAL)
|
||||
set(CGAL_LIBRARIES IfcOpenShell::IFCOPENSHELL_CGAL)
|
||||
endif()
|
||||
|
||||
if(TARGET IfcOpenShell::svgfill)
|
||||
set(IFCOPENSHELL_SVGFILL_LIBRARY IfcOpenShell::svgfill)
|
||||
endif()
|
||||
|
||||
mark_as_advanced(IfcOpenShell_DIR)
|
||||
|
||||
unset(_IfcOpenShell_error)
|
||||
unset(_IfcOpenShell_find_args)
|
||||
unset(_IfcOpenShell_missing_targets)
|
||||
unset(_IfcOpenShell_missing_targets_text)
|
||||
unset(_IfcOpenShell_required_targets)
|
||||
unset(_IfcOpenShell_target)
|
||||
@@ -7,12 +7,26 @@ set(IFCOPENSHELL_WITH_OPENCASCADE @WITH_OPENCASCADE@)
|
||||
set(IFCOPENSHELL_WITH_CGAL @WITH_CGAL@)
|
||||
set(IFCOPENSHELL_IFCXML @IFCXML_SUPPORT@)
|
||||
set(IFCOPENSHELL_WITH_ROCKSDB @WITH_ROCKSDB@)
|
||||
set(IFCOPENSHELL_COLLADA_SUPPORT @COLLADA_SUPPORT@)
|
||||
set(IFCOPENSHELL_GLTF_SUPPORT @GLTF_SUPPORT@)
|
||||
set(IFCOPENSHELL_HDF5_SUPPORT @HDF5_SUPPORT@)
|
||||
set(IFCOPENSHELL_WITH_PROJ @WITH_PROJ@)
|
||||
set(IFCOPENSHELL_USD_SUPPORT @USD_SUPPORT@)
|
||||
|
||||
include(CMakeFindDependencyMacro)
|
||||
|
||||
set(Boost_USE_STATIC_LIBS ON)
|
||||
set(Boost_USE_STATIC_RUNTIME OFF)
|
||||
set(Boost_USE_MULTITHREADED ON)
|
||||
set(IFCOPENSHELL_BOOST_USE_STATIC_LIBS "@Boost_USE_STATIC_LIBS@")
|
||||
set(IFCOPENSHELL_BOOST_USE_STATIC_RUNTIME "@Boost_USE_STATIC_RUNTIME@")
|
||||
set(IFCOPENSHELL_BOOST_USE_MULTITHREADED "@Boost_USE_MULTITHREADED@")
|
||||
if(NOT "${IFCOPENSHELL_BOOST_USE_STATIC_LIBS}" STREQUAL "")
|
||||
set(Boost_USE_STATIC_LIBS ${IFCOPENSHELL_BOOST_USE_STATIC_LIBS})
|
||||
endif()
|
||||
if(NOT "${IFCOPENSHELL_BOOST_USE_STATIC_RUNTIME}" STREQUAL "")
|
||||
set(Boost_USE_STATIC_RUNTIME ${IFCOPENSHELL_BOOST_USE_STATIC_RUNTIME})
|
||||
endif()
|
||||
if(NOT "${IFCOPENSHELL_BOOST_USE_MULTITHREADED}" STREQUAL "")
|
||||
set(Boost_USE_MULTITHREADED ${IFCOPENSHELL_BOOST_USE_MULTITHREADED})
|
||||
endif()
|
||||
set(Boost_COMPONENTS
|
||||
system
|
||||
program_options
|
||||
@@ -43,13 +57,33 @@ if(IFCOPENSHELL_WITH_ROCKSDB)
|
||||
endif()
|
||||
|
||||
if(IFCOPENSHELL_IFCXML)
|
||||
find_dependency(LibXml2 CONFIG)
|
||||
find_dependency(LibXml2)
|
||||
endif()
|
||||
|
||||
if(IFCOPENSHELL_WITH_CGAL)
|
||||
find_dependency(CGAL CONFIG)
|
||||
endif()
|
||||
|
||||
if(IFCOPENSHELL_COLLADA_SUPPORT)
|
||||
find_dependency(OpenCOLLADA)
|
||||
endif()
|
||||
|
||||
if(IFCOPENSHELL_GLTF_SUPPORT)
|
||||
find_dependency(nlohmann_json CONFIG)
|
||||
endif()
|
||||
|
||||
if(IFCOPENSHELL_HDF5_SUPPORT)
|
||||
find_dependency(HDF5 COMPONENTS C CXX)
|
||||
endif()
|
||||
|
||||
if(IFCOPENSHELL_WITH_PROJ)
|
||||
find_dependency(PROJ)
|
||||
endif()
|
||||
|
||||
if(IFCOPENSHELL_USD_SUPPORT)
|
||||
find_dependency(USD)
|
||||
endif()
|
||||
|
||||
if(IFCOPENSHELL_WITH_OPENCASCADE)
|
||||
find_dependency(OpenCASCADE CONFIG)
|
||||
if(OpenCASCADE_VERSION VERSION_LESS "7.7.0")
|
||||
|
||||
+1
-1
@@ -106,7 +106,7 @@ endif
|
||||
endif # def PLATFORM
|
||||
|
||||
# Current build commit hash.
|
||||
OLD:=1c5b825
|
||||
OLD:=3e7b739
|
||||
.PHONY: bump
|
||||
bump:
|
||||
ifndef NEW
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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] = {}
|
||||
@@ -1220,7 +1221,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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# 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 operator, prop, ui
|
||||
|
||||
classes = (
|
||||
operator.BIM_OT_add_clip_box,
|
||||
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,
|
||||
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_post(filepath):
|
||||
# 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_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_depsgraph_update in bpy.app.handlers.depsgraph_update_post:
|
||||
bpy.app.handlers.depsgraph_update_post.remove(_on_depsgraph_update)
|
||||
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,170 @@
|
||||
# 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
|
||||
|
||||
import bpy
|
||||
|
||||
import bonsai.tool as tool
|
||||
|
||||
CLIP_BOX_NAME = "ClipBox"
|
||||
CLIP_BOX_COLLECTION = "BBIM_ClipBoxes"
|
||||
|
||||
|
||||
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):
|
||||
scene_props = tool.ClipBox.get_scene_props(context.scene)
|
||||
|
||||
obj = bpy.data.objects.new(CLIP_BOX_NAME, None)
|
||||
obj.empty_display_type = "CUBE"
|
||||
obj.empty_display_size = 1.0
|
||||
obj.location = context.scene.cursor.location.copy()
|
||||
# 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.
|
||||
obj.scale = (10.0, 10.0, 10.0)
|
||||
obj.show_in_front = True
|
||||
|
||||
collection = tool.Blender.get_or_create_collection(context.scene, CLIP_BOX_COLLECTION)
|
||||
collection.objects.link(obj)
|
||||
|
||||
obj_props = tool.ClipBox.get_object_props(obj)
|
||||
obj_props.is_clip_box = True
|
||||
|
||||
entry = scene_props.clip_boxes.add()
|
||||
entry.obj = obj
|
||||
scene_props.active_clip_box_index = len(scene_props.clip_boxes) - 1
|
||||
|
||||
# Adding a new clip box arms clipping so the user sees the cut
|
||||
# immediately. Without this they'd have to find the panel
|
||||
# toggle to discover the feature actually works.
|
||||
scene_props.enabled = True
|
||||
|
||||
tool.Blender.set_active_object(obj)
|
||||
tool.ClipBox.refresh(context.scene)
|
||||
# Persist to the project pset so the box round-trips through IFC
|
||||
# save/load. A project-level pset avoids the IfcRoot scale lock /
|
||||
# strip that a per-entity placement would trigger on export.
|
||||
tool.ClipBox.save_to_project_pset(context.scene)
|
||||
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 = bpy.data.objects.new(source.name, None)
|
||||
copy.empty_display_type = source.empty_display_type
|
||||
copy.empty_display_size = source.empty_display_size
|
||||
copy.show_in_front = source.show_in_front
|
||||
copy.matrix_world = source.matrix_world.copy()
|
||||
|
||||
collection = tool.Blender.get_or_create_collection(context.scene, CLIP_BOX_COLLECTION)
|
||||
collection.objects.link(copy)
|
||||
|
||||
tool.ClipBox.get_object_props(copy).is_clip_box = True
|
||||
|
||||
entry = scene_props.clip_boxes.add()
|
||||
entry.obj = copy
|
||||
scene_props.active_clip_box_index = len(scene_props.clip_boxes) - 1
|
||||
scene_props.enabled = True
|
||||
|
||||
tool.Blender.set_active_object(copy)
|
||||
tool.ClipBox.refresh(context.scene)
|
||||
tool.ClipBox.save_to_project_pset(context.scene)
|
||||
return {"FINISHED"}
|
||||
@@ -0,0 +1,107 @@
|
||||
# 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)
|
||||
|
||||
|
||||
def update_show_caps(self, context):
|
||||
tool.ClipBox.schedule_refresh()
|
||||
|
||||
|
||||
def update_enabled(self, context):
|
||||
tool.ClipBox.schedule_refresh()
|
||||
|
||||
|
||||
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"
|
||||
),
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
active_clip_box_index: int
|
||||
enabled: bool
|
||||
show_caps: bool
|
||||
@@ -0,0 +1,86 @@
|
||||
# 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 Panel, UIList
|
||||
|
||||
import bonsai.tool as tool
|
||||
|
||||
|
||||
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
|
||||
if obj is None:
|
||||
layout.label(text="(missing)", icon="ERROR")
|
||||
return
|
||||
row = layout.row(align=True)
|
||||
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)
|
||||
|
||||
layout.separator()
|
||||
layout.operator("bim.add_clip_box", icon="ADD", text="Add Clip Box")
|
||||
|
||||
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")
|
||||
@@ -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
|
||||
|
||||
@@ -890,6 +890,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):
|
||||
@@ -931,7 +941,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:
|
||||
@@ -1030,7 +1040,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)
|
||||
|
||||
@@ -110,6 +110,8 @@ classes = (
|
||||
wall.GizmoWallFilletPreview,
|
||||
wall.GizmoWallFilletReedit,
|
||||
wall.GizmoWallFilletToggleOpenings,
|
||||
wall.GizmoSlabEdition,
|
||||
wall.GizmoSlabUnjoinWalls,
|
||||
wall.GizmoWallJoinIntersection,
|
||||
wall.GizmoWallLinkToggle,
|
||||
wall.GizmoWallUnjoinSingle,
|
||||
@@ -120,7 +122,7 @@ classes = (
|
||||
wall.RotateWall90,
|
||||
wall.SplitWall,
|
||||
wall.SplitWallAtCursor,
|
||||
wall.UnjoinWallPathConnection,
|
||||
wall.DisconnectElements,
|
||||
wall.UnjoinWalls,
|
||||
wall.EnableWallFilletPreview,
|
||||
wall.FinishWallFilletPreview,
|
||||
@@ -154,11 +156,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 +190,7 @@ classes = (
|
||||
prop.BIMDoorProperties,
|
||||
prop.BIMRailingProperties,
|
||||
prop.BIMRoofProperties,
|
||||
prop.BIMSlabProperties,
|
||||
prop.BIMWallProperties,
|
||||
prop.BIMPipeSegmentProperties,
|
||||
prop.BIMDuctSegmentProperties,
|
||||
@@ -246,9 +252,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,
|
||||
|
||||
@@ -423,7 +423,11 @@ class RegenerateArray(bpy.types.Operator, tool.Ifc.Operator):
|
||||
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)):
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -228,11 +228,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:
|
||||
@@ -1693,6 +1689,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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -991,3 +991,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"}
|
||||
|
||||
@@ -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
|
||||
@@ -108,6 +109,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 +291,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 +339,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 +373,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 rel, kind in rels:
|
||||
bonsai.core.connection.disconnect_rel(
|
||||
tool.Ifc,
|
||||
tool.Geometry,
|
||||
tool.Model,
|
||||
tool.Connection,
|
||||
rel=rel,
|
||||
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 +443,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 +464,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 +716,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 +753,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 +826,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 +873,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 +942,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 +965,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 +1547,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 +1568,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 +1634,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,13 +1658,16 @@ class DumbWallJoiner:
|
||||
|
||||
rel.RelatingOpeningElement = new_opening
|
||||
|
||||
# Remove the old opening
|
||||
ifcopenshell.api.feature.remove_feature(tool.Ifc.get(), feature=opening)
|
||||
|
||||
if void_straddles:
|
||||
# Filling moved to element2, but void straddles — add a
|
||||
# pure-void copy back to element1 so its body still gets cut.
|
||||
_add_void_copy(element1, new_opening)
|
||||
# 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.
|
||||
@@ -1583,6 +1680,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 +1736,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 +1756,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 +1769,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 +2585,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 +2620,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 +3247,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 +3372,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 +3458,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 +3743,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 +4009,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 +4037,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 +4073,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 +4085,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 +4110,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 +4142,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):
|
||||
|
||||
@@ -963,7 +963,11 @@ 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)
|
||||
|
||||
@@ -1317,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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1236,6 +1236,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()
|
||||
@@ -3539,3 +3550,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"}
|
||||
|
||||
@@ -306,6 +306,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)
|
||||
@@ -372,6 +383,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,
|
||||
@@ -538,6 +550,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"]
|
||||
|
||||
@@ -205,6 +205,20 @@ class BIM_PT_project(Panel):
|
||||
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")
|
||||
|
||||
if props.ifc_file:
|
||||
self.draw_loaded_project_ui(context)
|
||||
else:
|
||||
|
||||
@@ -517,6 +517,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",
|
||||
@@ -806,6 +815,15 @@ 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()
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# 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 rel 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
|
||||
rel kind means extending one dispatch table — both call sites benefit
|
||||
automatically and the AST forward-compat guard enforces coverage.
|
||||
"""
|
||||
|
||||
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 bpy
|
||||
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]",
|
||||
rel: "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 rel.
|
||||
|
||||
``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=rel)
|
||||
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(rel, elem, partner)
|
||||
ifc.run(
|
||||
"geometry.disconnect_element",
|
||||
relating_element=rel.RelatingElement,
|
||||
related_element=rel.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=rel.RelatingElement,
|
||||
related_element=rel.RelatedElement,
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unknown rel kind: {kind!r}")
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -694,11 +702,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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -575,6 +583,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 +1396,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,991 @@
|
||||
# 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
|
||||
|
||||
import contextlib
|
||||
from collections.abc import Callable, Iterator
|
||||
from typing import TYPE_CHECKING, Any, Optional
|
||||
|
||||
import bpy
|
||||
|
||||
import bonsai.tool as tool
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bonsai.bim.module.clip_box.prop import (
|
||||
BIMClipBoxProperties,
|
||||
BIMSceneClipBoxProperties,
|
||||
)
|
||||
|
||||
|
||||
PlaneTuple = tuple[float, float, float, float]
|
||||
PlaneSet = tuple[PlaneTuple, PlaneTuple, PlaneTuple, PlaneTuple, PlaneTuple, PlaneTuple]
|
||||
|
||||
# Outward margin (world units) so the empty's CUBE display edges sit
|
||||
# safely INSIDE the clip volume. Absolute (not relative-to-scale)
|
||||
# because a relative multiplier balloons with scale and produces a
|
||||
# visibly-wrong gap between the wireframe and the clipped geometry.
|
||||
# Sub-mesh-precision value: visually invisible at any reasonable IFC
|
||||
# scale yet large enough to keep the empty's own wireframe edges off
|
||||
# the clip planes when float-precision accumulation pushes a corner
|
||||
# a fractional epsilon outward.
|
||||
_CLIP_EXPAND_ABS = 1e-6
|
||||
|
||||
|
||||
class ClipBox:
|
||||
"""Driver for the viewport clip-box feature.
|
||||
|
||||
Owns the bridge between ``BIMClipBoxProperties`` on a host empty and
|
||||
Blender's ``RegionView3D.clip_planes`` machinery. Plane math is in
|
||||
``Cad``; this class is the bpy adapter.
|
||||
|
||||
Region-ownership: ``_owned`` tracks which regions we have armed so
|
||||
a subsequent arm on the same region can skip the first-arm operator
|
||||
path and write planes directly. Keyed by ``region.as_pointer()``.
|
||||
"""
|
||||
|
||||
_owned: set[int] = set()
|
||||
_region_by_key: dict[int, tuple[Any, Any]] = {}
|
||||
_refresh_pending: bool = False
|
||||
_last_seen_ifc_id: int = 0
|
||||
# Tracks the last matrix we persisted to the pset, keyed by Blender
|
||||
# object name. Lets the depsgraph handler detect committed transform
|
||||
# changes on clip boxes rehydrated from the project pset on file load
|
||||
# (which have no modal poller watching them).
|
||||
_persisted_matrices: dict[str, tuple] = {}
|
||||
# Names of clip boxes whose matrix changed during a transform modal.
|
||||
# Flushed when the gate flips back to inactive — one save per dirty
|
||||
# box on commit, no writes during the drag.
|
||||
_dirty_for_save: set[str] = set()
|
||||
# Per-object cache of cross-section cap triangles in world space.
|
||||
# Key: obj.name. Value: (cache_key_tuple, gpu_batch). Invalidated when
|
||||
# the object's mesh data block, world matrix, or the clip box matrix
|
||||
# changes. Rebuild is skipped while any transform modal is dragging
|
||||
# matrix_world so a continuous G/R/S shows stale caps and rebuilds on
|
||||
# commit instead of re-bisecting every mesh per frame.
|
||||
_cap_cache: dict[str, tuple[tuple, Any]] = {}
|
||||
_last_cap_clip_box_hash: int = 0
|
||||
# Debounce window for cap rebuild from external (unknown-modal) drags:
|
||||
# each depsgraph tick reschedules a timer this far in the future, so
|
||||
# a burst of N ticks collapses to one rebuild after the storm.
|
||||
_CAP_REBUILD_DEBOUNCE_SECONDS: float = 1.0
|
||||
_last_modal_state: bool = False
|
||||
_pending_cap_rebuild: Optional[Callable[[], None]] = None
|
||||
# Per-object matrix hash baseline used to tell a real transform
|
||||
# change from Blender's "selection touched the flag" noise: when a
|
||||
# depsgraph tick reports is_updated_transform on an Object, the
|
||||
# relevance filter compares the live hash against this baseline.
|
||||
_last_seen_object_matrices: dict[str, int] = {}
|
||||
|
||||
@classmethod
|
||||
def get_scene_props(cls, scene: Optional[bpy.types.Scene] = None) -> BIMSceneClipBoxProperties:
|
||||
if scene is None:
|
||||
scene = bpy.context.scene
|
||||
return scene.BIMSceneClipBoxProperties
|
||||
|
||||
@classmethod
|
||||
def get_object_props(cls, obj: bpy.types.Object) -> BIMClipBoxProperties:
|
||||
return obj.BIMClipBoxProperties
|
||||
|
||||
@classmethod
|
||||
def select_active_clip_box(cls, context: bpy.types.Context) -> None:
|
||||
"""Deselect everything, then select + activate the active clip box's empty.
|
||||
|
||||
Wired into the panel UIList's ``active_clip_box_index`` update
|
||||
so clicking a row in the list does the standard outliner-style
|
||||
focus: the user can immediately G/R/S the box they just picked.
|
||||
|
||||
Short-circuits when the active object is already the target —
|
||||
keeps multi-selections intact when the index changed because the
|
||||
depsgraph sync detected the user clicking the empty directly.
|
||||
No-op when no active box is resolvable.
|
||||
"""
|
||||
obj = cls.get_active_clip_box(context.scene)
|
||||
if obj is None:
|
||||
return
|
||||
if getattr(context, "active_object", None) is obj:
|
||||
return
|
||||
tool.Blender.set_objects_selection(
|
||||
context, active_object=obj, selected_objects=[obj], clear_previous_selection=True
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_active_clip_box(cls, scene: Optional[bpy.types.Scene] = None) -> Optional[bpy.types.Object]:
|
||||
"""Return the host empty of the currently active clip box, or ``None``."""
|
||||
props = cls.get_scene_props(scene)
|
||||
index = props.active_clip_box_index
|
||||
if index < 0 or index >= len(props.clip_boxes):
|
||||
return None
|
||||
obj = props.clip_boxes[index].obj
|
||||
if obj is None:
|
||||
return None
|
||||
obj_props = cls.get_object_props(obj)
|
||||
if not obj_props.is_clip_box:
|
||||
return None
|
||||
return obj
|
||||
|
||||
@classmethod
|
||||
def compute_planes(cls, obj: bpy.types.Object) -> PlaneSet:
|
||||
"""Build the 6 inward world clip planes from the host empty's matrix_world.
|
||||
|
||||
The empty's CUBE display spans local ``[-1, +1]^3`` (with
|
||||
``empty_display_size = 1``); ``matrix_world`` carries translation,
|
||||
rotation, and per-axis scale, so the clip planes track the cube
|
||||
exactly as it looks in the viewport. A tiny outward margin
|
||||
prevents the cube's own wireframe from being clipped by its own
|
||||
planes.
|
||||
"""
|
||||
return tool.Cad.obb_clip_planes_from_matrix(obj.matrix_world, expand=_CLIP_EXPAND_ABS)
|
||||
|
||||
@classmethod
|
||||
def compute_planes_from_matrix(cls, matrix: Any) -> PlaneSet:
|
||||
"""Same as :meth:`compute_planes` but accepts a raw matrix.
|
||||
|
||||
Used by the depsgraph handler to read the *evaluated* matrix during
|
||||
a live G/R/S transform — that matrix reflects the in-progress
|
||||
transform offset, while ``obj.matrix_world`` stays at the
|
||||
pre-transform value until the operator commits on release.
|
||||
"""
|
||||
return tool.Cad.obb_clip_planes_from_matrix(matrix, expand=_CLIP_EXPAND_ABS)
|
||||
|
||||
@classmethod
|
||||
def apply_clip_planes(cls, planes: PlaneSet) -> None:
|
||||
"""Drive every open 3D viewport's clip planes to ``planes``.
|
||||
|
||||
Always calls ``view3d.clip_border`` to refresh the region's
|
||||
``clip_bb`` at the CURRENT view. Edit-mode click-select tests
|
||||
against ``clip_local`` derived from that bbox; if we don't keep
|
||||
``clip_bb`` fresh, the user can orbit the view (or transform
|
||||
the clip box) and find click-select rejecting verts that ARE
|
||||
visible because the test is using a stale view-frustum bbox
|
||||
captured the last time we armed. Re-arming on every commit
|
||||
keeps the bbox aligned with the view the user is actually at.
|
||||
"""
|
||||
for area, region, region_3d in tool.Blender.iter_view3d_regions():
|
||||
key = region.as_pointer()
|
||||
cls._owned.add(key)
|
||||
cls._region_by_key[key] = (area, region)
|
||||
cls._arm_region(area, region, region_3d, planes)
|
||||
|
||||
@classmethod
|
||||
def _arm_region(cls, area: Any, region: Any, region_3d: Any, planes: PlaneSet) -> None:
|
||||
"""Initialize the region's clip machinery and write ``planes``.
|
||||
|
||||
``view3d.clip_border`` with a FULL-REGION rect arms ``RV3D_CLIPPING``
|
||||
without leaving the C-side ``clipbb`` degenerate (which would break
|
||||
edit-mode click-select). Caller must guarantee a context in which
|
||||
operators are legal (not a draw handler / depsgraph callback).
|
||||
"""
|
||||
with bpy.context.temp_override(area=area, region=region):
|
||||
bpy.ops.view3d.clip_border(xmin=0, ymin=0, xmax=region.width, ymax=region.height)
|
||||
region_3d.clip_planes = planes
|
||||
region_3d.use_clip_planes = True
|
||||
region_3d.update()
|
||||
|
||||
@classmethod
|
||||
def clear_clip_planes(cls) -> None:
|
||||
"""Disable clip planes on every 3D viewport region.
|
||||
|
||||
Unchecking ``enabled`` or removing a clip box turns clipping
|
||||
off; any prior Alt+B clip is NOT restored. The ``_owned``
|
||||
ownership table is preserved across this clear so a later
|
||||
re-enable can skip the ``view3d.clip_border`` re-init (which
|
||||
would re-derive ``clip_bb`` at the current view and break
|
||||
edit-mode click-select alignment). The full ownership reset
|
||||
happens only on IFC reload or addon unregister.
|
||||
"""
|
||||
for area, region, region_3d in tool.Blender.iter_view3d_regions():
|
||||
with contextlib.suppress(ReferenceError, AttributeError, TypeError):
|
||||
region_3d.use_clip_planes = False
|
||||
region.tag_redraw()
|
||||
|
||||
@classmethod
|
||||
def _active_scene_props(cls, scene: Optional[bpy.types.Scene] = None) -> Optional[BIMSceneClipBoxProperties]:
|
||||
"""Scene PG iff the clipping pipeline should drive this tick, else ``None``.
|
||||
|
||||
Most sessions run with clipping disabled, so the cheap
|
||||
``enabled`` check fires before any active-box lookup or
|
||||
per-mesh work. Callers compose with their own further checks
|
||||
(e.g. ``show_caps`` for the cap pipeline) on the returned PG.
|
||||
"""
|
||||
if scene is None:
|
||||
scene = bpy.context.scene
|
||||
scene_props = cls.get_scene_props(scene)
|
||||
if not scene_props.enabled:
|
||||
return None
|
||||
return scene_props
|
||||
|
||||
@classmethod
|
||||
def refresh(cls, scene: Optional[bpy.types.Scene] = None) -> None:
|
||||
"""Re-arm or clear the viewport clip based on the active clip box state."""
|
||||
if cls._active_scene_props(scene) is None:
|
||||
cls.clear_clip_planes()
|
||||
return
|
||||
obj = cls.get_active_clip_box(scene)
|
||||
if obj is None:
|
||||
cls.clear_clip_planes()
|
||||
return
|
||||
cls.apply_clip_planes(cls.compute_planes(obj))
|
||||
|
||||
@classmethod
|
||||
def schedule_refresh(cls) -> None:
|
||||
"""Schedule a refresh on the next idle tick.
|
||||
|
||||
PropertyGroup ``update=`` callbacks must not call ``bpy.ops`` (which
|
||||
``apply_clip_planes`` may need for the first-time arm) — doing so
|
||||
from within a property write disrupts gizmo modal accounting and
|
||||
can leave the operator stack inconsistent. Deferring via a 0-delay
|
||||
timer hands the refresh to Blender's main loop, where operators are
|
||||
legal. Debounced: a flag suppresses repeats while one is pending.
|
||||
"""
|
||||
if cls._refresh_pending:
|
||||
return
|
||||
cls._refresh_pending = True
|
||||
|
||||
def _do_refresh():
|
||||
cls._refresh_pending = False
|
||||
cls.refresh()
|
||||
return None
|
||||
|
||||
bpy.app.timers.register(_do_refresh, first_interval=0.0)
|
||||
|
||||
@classmethod
|
||||
def reset_ownership(cls) -> None:
|
||||
"""Drop the ownership table without touching any region. Used on register/reload."""
|
||||
cls._owned.clear()
|
||||
cls._region_by_key.clear()
|
||||
|
||||
PSET_NAME = "BBIM_ClipBoxes"
|
||||
COLLECTION_NAME = "BBIM_ClipBoxes"
|
||||
|
||||
@classmethod
|
||||
def _get_project_pset_entity(cls, create: bool = False):
|
||||
"""Return the ``IfcPropertySet`` entity holding the clip-box state.
|
||||
|
||||
Stored on ``IfcProject`` because IFC's IfcRoot pipeline locks and
|
||||
strips object scale on export, which a clip box (whose size IS
|
||||
its scale) cannot tolerate. A project-level pset side-steps any
|
||||
per-entity placement sync.
|
||||
"""
|
||||
import ifcopenshell.util.element
|
||||
|
||||
ifc_file = tool.Ifc.get()
|
||||
if ifc_file is None:
|
||||
return None
|
||||
projects = ifc_file.by_type("IfcProject")
|
||||
if not projects:
|
||||
return None
|
||||
project = projects[0]
|
||||
existing = ifcopenshell.util.element.get_psets(project).get(cls.PSET_NAME)
|
||||
if existing is not None:
|
||||
return ifc_file.by_id(existing["id"])
|
||||
if not create:
|
||||
return None
|
||||
return tool.Ifc.run("pset.add_pset", product=project, name=cls.PSET_NAME)
|
||||
|
||||
@classmethod
|
||||
def mark_dirty_for_save(cls, obj_name: str) -> None:
|
||||
"""Note that ``obj_name`` has an unpersisted matrix change.
|
||||
|
||||
Accumulates dirty names during a transform drag without
|
||||
touching the IFC graph; the flush gate writes exactly one
|
||||
save per dirty box once no transform modal is active.
|
||||
"""
|
||||
cls._dirty_for_save.add(obj_name)
|
||||
|
||||
@classmethod
|
||||
def flush_pending_saves(cls, scene: Optional[bpy.types.Scene] = None) -> None:
|
||||
"""Write the pset iff there's pending dirt AND no transform modal.
|
||||
|
||||
Called from the depsgraph handler every tick. Reading
|
||||
``tool.Blender.is_transform_modal_active(bpy.context)`` checks
|
||||
``window.modal_operators`` against the known transform op names
|
||||
(Blender vanilla + Bonsai macro overrides — kept centrally in
|
||||
:attr:`tool.Blender.BONSAI_TRANSFORM_MACROS`), so this gate
|
||||
survives any Bonsai keymap override and any Python script that
|
||||
wraps the same operators.
|
||||
"""
|
||||
if not cls._dirty_for_save:
|
||||
return
|
||||
if tool.Ifc.get() is None:
|
||||
cls._dirty_for_save.clear()
|
||||
return
|
||||
if tool.Blender.is_transform_modal_active(bpy.context):
|
||||
return
|
||||
cls._dirty_for_save.clear()
|
||||
cls.save_to_project_pset(scene)
|
||||
|
||||
@classmethod
|
||||
def save_to_project_pset(cls, scene: Optional[bpy.types.Scene] = None) -> None:
|
||||
"""Snapshot the active clip-box state to ``IfcProject.BBIM_ClipBoxes``.
|
||||
|
||||
Each clip box contributes ``Box_<i>_Name`` and ``Box_<i>_Matrix``
|
||||
(a 16-float comma-separated string). ``Count`` is the canonical
|
||||
size. ``enabled`` is intentionally not persisted — opening a file
|
||||
should never silently hide geometry behind a remembered toggle.
|
||||
No-op when no IFC file is loaded.
|
||||
"""
|
||||
if tool.Ifc.get() is None:
|
||||
return
|
||||
if scene is None:
|
||||
scene = bpy.context.scene
|
||||
scene_props = cls.get_scene_props(scene)
|
||||
|
||||
pset = cls._get_project_pset_entity(create=True)
|
||||
if pset is None:
|
||||
return
|
||||
|
||||
properties: dict[str, str | int] = {
|
||||
"Count": len(scene_props.clip_boxes),
|
||||
"ShowCaps": int(scene_props.show_caps),
|
||||
}
|
||||
for i, entry in enumerate(scene_props.clip_boxes):
|
||||
obj = entry.obj
|
||||
if obj is None:
|
||||
continue
|
||||
properties[f"Box_{i}_Name"] = obj.name
|
||||
properties[f"Box_{i}_Matrix"] = tool.Blender.serialize_matrix(obj.matrix_world)
|
||||
tool.Ifc.run("pset.edit_pset", pset=pset, properties=properties)
|
||||
|
||||
@classmethod
|
||||
def load_from_project_pset(cls, scene: Optional[bpy.types.Scene] = None) -> None:
|
||||
"""Rehydrate clip boxes from ``IfcProject.BBIM_ClipBoxes``.
|
||||
|
||||
Idempotent: drops stale list entries (deleted hosts / un-flagged
|
||||
objects), then for each saved box, creates the empty if absent
|
||||
or updates its matrix if the .blend reload already restored it.
|
||||
|
||||
``scene_props.enabled`` is NOT touched: it defaults to ``False``
|
||||
(so a fresh IFC load over a fresh .blend never silently hides
|
||||
geometry), and Blender's normal .blend persistence carries the
|
||||
user's saved toggle through .blend reload.
|
||||
"""
|
||||
import ifcopenshell.util.element
|
||||
|
||||
if scene is None:
|
||||
scene = bpy.context.scene
|
||||
scene_props = cls.get_scene_props(scene)
|
||||
|
||||
for index in range(len(scene_props.clip_boxes) - 1, -1, -1):
|
||||
entry = scene_props.clip_boxes[index]
|
||||
obj = entry.obj
|
||||
if obj is None or not cls.get_object_props(obj).is_clip_box:
|
||||
scene_props.clip_boxes.remove(index)
|
||||
|
||||
ifc_file = tool.Ifc.get()
|
||||
if ifc_file is None:
|
||||
return
|
||||
projects = ifc_file.by_type("IfcProject")
|
||||
if not projects:
|
||||
return
|
||||
pset = ifcopenshell.util.element.get_psets(projects[0]).get(cls.PSET_NAME)
|
||||
if not pset:
|
||||
return
|
||||
|
||||
show_caps_raw = pset.get("ShowCaps")
|
||||
if show_caps_raw is not None:
|
||||
scene_props.show_caps = bool(int(show_caps_raw))
|
||||
# enabled is intentionally NOT read from the pset — see docstring.
|
||||
|
||||
existing_by_name = {entry.obj.name: entry.obj for entry in scene_props.clip_boxes if entry.obj}
|
||||
existing_objs = set(existing_by_name.values())
|
||||
count = int(pset.get("Count", 0) or 0)
|
||||
for i in range(count):
|
||||
name = pset.get(f"Box_{i}_Name") or f"ClipBox.{i:03d}"
|
||||
matrix_str = pset.get(f"Box_{i}_Matrix")
|
||||
if not matrix_str:
|
||||
continue
|
||||
matrix = tool.Blender.deserialize_matrix(matrix_str)
|
||||
existing_obj = bpy.data.objects.get(name)
|
||||
if existing_obj is None:
|
||||
# Fresh IFC load: no .blend backing, no viewport clip state.
|
||||
# Create the empty, place it, and force enabled=False so we
|
||||
# don't silently hide geometry behind a box the user forgot.
|
||||
obj = bpy.data.objects.new(name, None)
|
||||
obj.empty_display_type = "CUBE"
|
||||
obj.empty_display_size = 1.0
|
||||
obj.show_in_front = True
|
||||
collection = tool.Blender.get_or_create_collection(scene, cls.COLLECTION_NAME)
|
||||
collection.objects.link(obj)
|
||||
obj.matrix_world = matrix
|
||||
cls.get_object_props(obj).is_clip_box = True
|
||||
else:
|
||||
# .blend reload: the empty (and the scene-level enabled
|
||||
# toggle) survived Blender's own session save. Update the
|
||||
# matrix in case the pset diverged from the .blend snapshot.
|
||||
obj = existing_obj
|
||||
obj.matrix_world = matrix
|
||||
cls.get_object_props(obj).is_clip_box = True
|
||||
if obj not in existing_objs:
|
||||
entry = scene_props.clip_boxes.add()
|
||||
entry.obj = obj
|
||||
existing_objs.add(obj)
|
||||
|
||||
if scene_props.clip_boxes and scene_props.active_clip_box_index >= len(scene_props.clip_boxes):
|
||||
scene_props.active_clip_box_index = 0
|
||||
|
||||
@classmethod
|
||||
def apply_clip_planes_direct(cls, planes: PlaneSet) -> None:
|
||||
"""Direct-write variant for contexts where ``bpy.ops`` is illegal.
|
||||
|
||||
Skips the first-arm path (which needs ``view3d.clip_border``)
|
||||
and writes planes directly to every armed region.
|
||||
``region_3d.update()`` pushes the new clip planes to the GPU
|
||||
buffer the rasteriser samples — without it the planes sit in
|
||||
the data block and the next frame still uses the previous GPU
|
||||
state. ``tag_redraw`` requests that the region actually
|
||||
redraws this frame.
|
||||
"""
|
||||
for area, region, region_3d in tool.Blender.iter_view3d_regions():
|
||||
if not region_3d.use_clip_planes:
|
||||
continue
|
||||
key = region.as_pointer()
|
||||
cls._region_by_key[key] = (area, region)
|
||||
region_3d.clip_planes = planes
|
||||
region_3d.update()
|
||||
region.tag_redraw()
|
||||
|
||||
@classmethod
|
||||
def _sync_collection_to_list(cls, scene: bpy.types.Scene) -> None:
|
||||
"""Add any clip-box-flagged empties not yet in ``scene_props.clip_boxes``.
|
||||
|
||||
Bonsai's duplicate-move macros (Shift+D, Alt+D, Ctrl+Shift+D)
|
||||
deep-copy the source's ``BIMClipBoxProperties``, so the
|
||||
duplicated empty carries ``is_clip_box=True`` but no scene-list
|
||||
entry exists for it. This sync turns the duplicate into a
|
||||
first-class clip box matching the UIList duplicate button: a
|
||||
new entry, set active, persisted to the pset.
|
||||
|
||||
Scoped to the ``BBIM_ClipBoxes`` collection so the cost is O(N)
|
||||
in the number of clip boxes, not O(N) in the whole scene.
|
||||
"""
|
||||
scene_props = cls.get_scene_props(scene)
|
||||
known_objs = {entry.obj for entry in scene_props.clip_boxes if entry.obj}
|
||||
collection = bpy.data.collections.get(cls.COLLECTION_NAME)
|
||||
if collection is None:
|
||||
return
|
||||
appended = False
|
||||
for obj in collection.objects:
|
||||
if obj in known_objs:
|
||||
continue
|
||||
if obj.type != "EMPTY":
|
||||
continue
|
||||
obj_props = cls.get_object_props(obj)
|
||||
if not obj_props.is_clip_box:
|
||||
continue
|
||||
entry = scene_props.clip_boxes.add()
|
||||
entry.obj = obj
|
||||
scene_props.active_clip_box_index = len(scene_props.clip_boxes) - 1
|
||||
appended = True
|
||||
if appended and tool.Ifc.get() is not None:
|
||||
cls.save_to_project_pset(scene)
|
||||
|
||||
@classmethod
|
||||
def on_depsgraph_update(cls, scene, depsgraph) -> None:
|
||||
"""Safety-net re-arm, IFC-load rehydrate, sync + pset persistence.
|
||||
|
||||
- **Shutdown guard**: skips when ``bpy.context.screen`` is ``None``
|
||||
so the persistent handler can't fault against freed UI memory.
|
||||
- **IFC reload detection**: when ``id(tool.Ifc.get())`` changes,
|
||||
drop the now-stale ``_owned`` table (the regions from the old
|
||||
screen were freed) and rehydrate clip boxes from the new
|
||||
project's ``BBIM_ClipBoxes`` pset.
|
||||
- **Collection-to-list sync**: catches clip-box empties created
|
||||
outside ``bim.add_clip_box`` / ``bim.duplicate_clip_box`` —
|
||||
notably Bonsai's Shift+D / Alt+D / Ctrl+Shift+D macros, which
|
||||
deep-copy the source's ``BIMClipBoxProperties`` (including
|
||||
``is_clip_box=True``) but don't register the copy with us.
|
||||
Detection lives here so any future entry path is handled too.
|
||||
- **Live preview safety net**: re-applies the clip planes from
|
||||
the active box's evaluated matrix. ``on_pre_view`` is the
|
||||
primary live-preview path; this is what catches matrix changes
|
||||
outside any modal (Python set, undo, constraint update).
|
||||
- **Pset persistence**: when ``obj.matrix_world`` differs from
|
||||
the last persisted snapshot, write it to the project pset.
|
||||
Blender's G/R/S modal only commits ``matrix_world`` on release,
|
||||
so this branch fires once per commit — exactly the cadence the
|
||||
user expects for "save my latest transform".
|
||||
"""
|
||||
if getattr(bpy.context, "screen", None) is None:
|
||||
return
|
||||
if cls._active_scene_props(scene) is None:
|
||||
return
|
||||
ifc_file = tool.Ifc.get()
|
||||
ifc_id = id(ifc_file) if ifc_file is not None else 0
|
||||
if ifc_id != cls._last_seen_ifc_id:
|
||||
cls._last_seen_ifc_id = ifc_id
|
||||
cls._owned.clear()
|
||||
cls._region_by_key.clear()
|
||||
cls._persisted_matrices.clear()
|
||||
cls._last_seen_object_matrices.clear()
|
||||
if ifc_file is not None:
|
||||
cls.load_from_project_pset(scene)
|
||||
# Orphan-empty adoption is deferred while a transform modal is
|
||||
# dragging so the active-index change on adoption can't disrupt
|
||||
# the move.
|
||||
if not tool.Blender.is_transform_modal_active(bpy.context):
|
||||
cls._sync_collection_to_list(scene)
|
||||
obj = cls.get_active_clip_box(scene)
|
||||
if obj is None:
|
||||
return
|
||||
|
||||
current_matrix = tuple(tuple(row) for row in obj.matrix_world)
|
||||
prev_matrix = cls._persisted_matrices.get(obj.name)
|
||||
if prev_matrix != current_matrix:
|
||||
cls._persisted_matrices[obj.name] = current_matrix
|
||||
# Only persist when an IFC file is loaded; otherwise the box
|
||||
# is purely Blender-side and there's nothing to write to.
|
||||
# Mark dirty here, FLUSH below — the gate suppresses writes
|
||||
# while a transform modal is dragging so one drag produces
|
||||
# one save on release, not N saves per frame.
|
||||
if ifc_file is not None and prev_matrix is not None:
|
||||
cls.mark_dirty_for_save(obj.name)
|
||||
cls.flush_pending_saves(scene)
|
||||
|
||||
try:
|
||||
eval_obj = obj.evaluated_get(depsgraph)
|
||||
matrix = eval_obj.matrix_world
|
||||
except (AttributeError, RuntimeError, ReferenceError):
|
||||
return
|
||||
cls.apply_clip_planes_direct(cls.compute_planes_from_matrix(matrix))
|
||||
|
||||
@classmethod
|
||||
def on_pre_view(cls) -> None:
|
||||
"""Per-redraw live preview hook.
|
||||
|
||||
Installed as a ``SpaceView3D.draw_handler_add`` at ``PRE_VIEW``.
|
||||
Reads the active clip box's evaluated matrix and writes the
|
||||
clip planes to ``bpy.context.region_data`` — the region being
|
||||
rendered THIS frame, so no ``temp_override`` is needed.
|
||||
|
||||
IFC pset writes are NOT performed here; that's the depsgraph
|
||||
handler's job (it fires on transform commit and writes through
|
||||
the operator transaction path).
|
||||
"""
|
||||
if cls._active_scene_props() is None:
|
||||
return
|
||||
obj = cls.get_active_clip_box()
|
||||
if obj is None:
|
||||
return
|
||||
region_3d = getattr(bpy.context, "region_data", None)
|
||||
if region_3d is None or not region_3d.use_clip_planes:
|
||||
return
|
||||
try:
|
||||
depsgraph = bpy.context.evaluated_depsgraph_get()
|
||||
matrix = obj.evaluated_get(depsgraph).matrix_world
|
||||
except (AttributeError, RuntimeError, ReferenceError):
|
||||
return
|
||||
region_3d.clip_planes = cls.compute_planes_from_matrix(matrix)
|
||||
region_3d.update()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Cross-section caps
|
||||
#
|
||||
# When the clip box is enabled, each IfcProduct mesh that crosses
|
||||
# the box gets a "cap" polygon drawn where its geometry intersects
|
||||
# a clip plane — so cut surfaces appear filled instead of hollow.
|
||||
# The pipeline (``bmesh.ops.bisect_plane(clear_outer=True)`` per
|
||||
# plane, then ``bmesh.ops.contextual_create`` to fill cut edges)
|
||||
# runs on a temp BMesh per object so the source mesh is untouched.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def _compute_caps_for_object(
|
||||
cls,
|
||||
obj: bpy.types.Object,
|
||||
world_planes: PlaneSet,
|
||||
depsgraph: Optional[Any] = None,
|
||||
) -> list[tuple[float, float, float]]:
|
||||
"""Return triangle vertices for ``obj``'s cap polygons.
|
||||
|
||||
Flat list of ``(x, y, z)`` tuples in world space, ready for a
|
||||
``batch_for_shader("TRIS", ...)`` upload. Empty when the
|
||||
object's bound box doesn't cross any clip plane.
|
||||
|
||||
Uses the evaluated mesh (modifier stack applied) when a
|
||||
``depsgraph`` is passed, so caps match the rendered geometry of
|
||||
objects with subsurf / boolean / mirror modifiers. Falls back to
|
||||
``obj.data`` only for callers without a depsgraph (e.g. unit
|
||||
tests that fabricate a mesh outside any eval context).
|
||||
"""
|
||||
import bmesh
|
||||
from mathutils import Vector
|
||||
|
||||
bm = bmesh.new()
|
||||
eval_obj = None
|
||||
try:
|
||||
if depsgraph is not None:
|
||||
try:
|
||||
eval_obj = obj.evaluated_get(depsgraph)
|
||||
mesh = eval_obj.to_mesh()
|
||||
bm.from_mesh(mesh)
|
||||
except (RuntimeError, ReferenceError):
|
||||
return []
|
||||
else:
|
||||
try:
|
||||
bm.from_mesh(obj.data)
|
||||
except (RuntimeError, ReferenceError):
|
||||
return []
|
||||
|
||||
ws_to_ls = obj.matrix_world.inverted_safe()
|
||||
rot = ws_to_ls.to_quaternion()
|
||||
planes_local = []
|
||||
for plane in world_planes:
|
||||
inward_world = Vector(plane[:3])
|
||||
d = plane[3]
|
||||
point_on_plane_world = inward_world * -d
|
||||
plane_co_local = ws_to_ls @ point_on_plane_world
|
||||
# bisect_plane removes the +plane_no side when clear_outer=True;
|
||||
# our inward normal points INTO the box, so we negate to clear
|
||||
# the box's outside.
|
||||
plane_no_local = (rot @ -inward_world).normalized()
|
||||
planes_local.append((plane_co_local, plane_no_local))
|
||||
|
||||
cap_layer = tool.Geometry.bisect_and_cap(bm, planes_local)
|
||||
if cap_layer is None:
|
||||
return []
|
||||
|
||||
cap_faces = [f for f in bm.faces if f.is_valid and f[cap_layer]]
|
||||
if not cap_faces:
|
||||
return []
|
||||
|
||||
mw = obj.matrix_world
|
||||
return cls._triangulate_cap_faces(cap_faces, mw)
|
||||
finally:
|
||||
bm.free()
|
||||
if eval_obj is not None:
|
||||
with contextlib.suppress(RuntimeError, ReferenceError, AttributeError):
|
||||
eval_obj.to_mesh_clear()
|
||||
|
||||
@classmethod
|
||||
def _iter_capable_objects(cls, scene: bpy.types.Scene) -> Iterator[bpy.types.Object]:
|
||||
"""Yield mesh objects eligible for capping: visible ``IfcElement``s.
|
||||
|
||||
Limits to ``IfcElement`` (walls, slabs, doors, windows, …) so
|
||||
spatial structure (``IfcSpace``, ``IfcBuildingStorey``,
|
||||
``IfcSite``) and annotations / grids never get capped — they're
|
||||
non-physical containers / overlays that shouldn't sprout solid
|
||||
fill polygons at clip boundaries.
|
||||
"""
|
||||
ifc_file = tool.Ifc.get()
|
||||
if ifc_file is None:
|
||||
return
|
||||
for obj in scene.objects:
|
||||
if obj.type != "MESH" or obj.data is None:
|
||||
continue
|
||||
if not obj.visible_get():
|
||||
continue
|
||||
entity = tool.Ifc.get_entity(obj)
|
||||
if entity is None or not entity.is_a("IfcElement"):
|
||||
continue
|
||||
yield obj
|
||||
|
||||
@classmethod
|
||||
def rebuild_cap_cache(
|
||||
cls,
|
||||
scene: Optional[bpy.types.Scene] = None,
|
||||
depsgraph: Optional[Any] = None,
|
||||
) -> None:
|
||||
"""Recompute the per-object cap-vertex cache from the active clip box.
|
||||
|
||||
No-op while a transform modal is dragging ``matrix_world`` — the
|
||||
existing cache stays in place and the user sees stale caps until
|
||||
the drag commits. Per-object cache entries are reused when the
|
||||
object's mesh, world matrix, and the clip-box matrix all match
|
||||
the prior key. Stale entries (deleted objects, disabled box,
|
||||
unloaded IFC) are pruned.
|
||||
|
||||
When ``depsgraph`` is supplied (the typical handler path), per-mesh
|
||||
caps are computed from the evaluated mesh so modifier stacks are
|
||||
honoured; without it, raw source meshes are used.
|
||||
"""
|
||||
scene_props = cls._active_scene_props(scene)
|
||||
if scene_props is None or not scene_props.show_caps:
|
||||
cls._cap_cache.clear()
|
||||
cls._last_cap_clip_box_hash = 0
|
||||
return
|
||||
if scene is None:
|
||||
scene = bpy.context.scene
|
||||
active = cls.get_active_clip_box(scene)
|
||||
if active is None:
|
||||
cls._cap_cache.clear()
|
||||
cls._last_cap_clip_box_hash = 0
|
||||
return
|
||||
if tool.Blender.is_transform_modal_active(bpy.context):
|
||||
return
|
||||
|
||||
# Cap with the SAME expanded planes the viewport clips against
|
||||
# (cls.compute_planes applies the _CLIP_EXPAND_ABS margin), so the
|
||||
# cap face lines up with the visible cut. Using the un-expanded
|
||||
# planes would leave a visible margin-sized gap between the cut
|
||||
# mesh edge and the cap.
|
||||
world_planes = cls.compute_planes(active)
|
||||
clip_box_hash = hash(world_planes)
|
||||
cls._last_cap_clip_box_hash = clip_box_hash
|
||||
|
||||
from mathutils import Vector
|
||||
|
||||
live_names: set[str] = set()
|
||||
for obj in cls._iter_capable_objects(scene):
|
||||
live_names.add(obj.name)
|
||||
mesh = obj.data
|
||||
cache_key = (
|
||||
getattr(mesh, "session_uid", id(mesh)),
|
||||
tool.Blender.hash_matrix(obj.matrix_world),
|
||||
clip_box_hash,
|
||||
)
|
||||
cached = cls._cap_cache.get(obj.name)
|
||||
if cached is not None and cached[0] == cache_key:
|
||||
continue
|
||||
# Cheap AABB-vs-clip-box rejection before the expensive bisect.
|
||||
# bound_box has 8 corners in object-local space — transform to
|
||||
# world and check whether they're all on the outside of any
|
||||
# clip plane. If so, the mesh can't produce a cap from this
|
||||
# box and we skip the per-mesh bisect.
|
||||
mw = obj.matrix_world
|
||||
world_corners = [mw @ Vector(c) for c in obj.bound_box]
|
||||
if not tool.Cad.corners_might_cross_clip_planes(world_planes, world_corners):
|
||||
cls._cap_cache[obj.name] = (cache_key, None)
|
||||
continue
|
||||
verts = cls._compute_caps_for_object(obj, world_planes, depsgraph=depsgraph)
|
||||
batch = cls._build_cap_batch(verts) if verts else None
|
||||
cls._cap_cache[obj.name] = (cache_key, batch)
|
||||
|
||||
for name in list(cls._cap_cache):
|
||||
if name not in live_names:
|
||||
cls._cap_cache.pop(name)
|
||||
for name in list(cls._last_seen_object_matrices):
|
||||
if name not in live_names:
|
||||
cls._last_seen_object_matrices.pop(name)
|
||||
|
||||
@staticmethod
|
||||
def _build_cap_batch(verts: list[tuple[float, float, float]]):
|
||||
"""Bake ``verts`` into a GPU ``TRIS`` batch bound to ``UNIFORM_COLOR``."""
|
||||
import gpu
|
||||
from gpu_extras.batch import batch_for_shader
|
||||
|
||||
shader = gpu.shader.from_builtin("UNIFORM_COLOR")
|
||||
return batch_for_shader(shader, "TRIS", {"pos": verts})
|
||||
|
||||
@classmethod
|
||||
def _triangulate_cap_faces(cls, cap_faces, mw) -> list[tuple[float, float, float]]:
|
||||
"""Triangulate cap faces and return world-space triangle vertices.
|
||||
|
||||
Each cap face is tessellated as a single simple ring via
|
||||
:meth:`tool.Cad.tessellate_ring_planar`. Nested cap polygons
|
||||
(hollow profiles — annular columns, pipe walls) render as solid
|
||||
discs in v1; proper polygon-with-holes triangulation is a known
|
||||
limitation and a follow-up.
|
||||
"""
|
||||
verts: list[tuple[float, float, float]] = []
|
||||
for face in cap_faces:
|
||||
if not face.is_valid or len(face.verts) < 3:
|
||||
continue
|
||||
ring = [v.co.copy() for v in face.verts]
|
||||
try:
|
||||
tri_indices = tool.Cad.tessellate_ring_planar([ring])
|
||||
except Exception:
|
||||
continue
|
||||
for i, j, k in tri_indices:
|
||||
for idx in (i, j, k):
|
||||
w = mw @ ring[idx]
|
||||
verts.append((w.x, w.y, w.z))
|
||||
return verts
|
||||
|
||||
@classmethod
|
||||
def on_depsgraph_update_caps(cls, scene, depsgraph) -> None:
|
||||
"""Depsgraph entry-point — guard, then delegate to the
|
||||
modal-aware debounce in :meth:`_handle_cap_tick`."""
|
||||
if getattr(bpy.context, "screen", None) is None:
|
||||
return
|
||||
if cls._active_scene_props(scene) is None:
|
||||
return
|
||||
# Edit mode (mesh / curve / armature / …) fires depsgraph
|
||||
# constantly as the user manipulates verts/edges; the cap view
|
||||
# isn't the focus of that work, and the caps would flash off on
|
||||
# every nudge. Skip scheduling entirely while in any edit mode.
|
||||
if tool.Blender.is_in_edit_mode():
|
||||
return
|
||||
cls._handle_cap_tick(scene, depsgraph)
|
||||
|
||||
@classmethod
|
||||
def _handle_cap_tick(cls, scene, depsgraph) -> None:
|
||||
"""Schedule (or immediately fire) a cap-cache rebuild.
|
||||
|
||||
Strategy:
|
||||
- Default: debounce. Each depsgraph tick reschedules a
|
||||
``bpy.app.timers`` callback ``_CAP_REBUILD_DEBOUNCE_SECONDS``
|
||||
in the future, so a burst of ticks from an unknown-to-Bonsai
|
||||
drag (external-addon gizmo, scripted property updates) collapses
|
||||
to a single rebuild after the storm subsides. Drag is smooth,
|
||||
caps catch up shortly after release.
|
||||
- Fast path: when a *known* transform modal (Bonsai G/R/S) just
|
||||
finished — detected as a True→False transition on
|
||||
``is_transform_modal_active`` — cancel any pending timer and
|
||||
rebuild immediately, preserving the snappy on-release feel for
|
||||
Bonsai-internal drags.
|
||||
- Skip path: depsgraph ticks fire for selection-only changes,
|
||||
UI events, undo writes, etc. — none of which can move a cap.
|
||||
When no update in the tick carries ``is_updated_geometry`` or
|
||||
``is_updated_transform``, return without scheduling so the
|
||||
cache and its hide-while-pending gate don't churn for free.
|
||||
"""
|
||||
is_modal = tool.Blender.is_transform_modal_active(bpy.context)
|
||||
modal_just_ended = cls._last_modal_state and not is_modal
|
||||
cls._last_modal_state = is_modal
|
||||
|
||||
if modal_just_ended:
|
||||
cls._cancel_pending_cap_rebuild()
|
||||
cls.rebuild_cap_cache(scene, depsgraph=depsgraph)
|
||||
return
|
||||
|
||||
if depsgraph is not None and not cls._depsgraph_has_relevant_changes(depsgraph):
|
||||
return
|
||||
|
||||
cls._schedule_cap_rebuild()
|
||||
|
||||
@classmethod
|
||||
def _depsgraph_has_relevant_changes(cls, depsgraph) -> bool:
|
||||
"""True iff the tick carries an Object geometry change, or an
|
||||
Object transform update whose ``matrix_world`` actually moved.
|
||||
|
||||
Blender raises ``is_updated_transform`` on the selected Object
|
||||
itself even for plain selection changes (no matrix delta), and
|
||||
on Scene / ViewLayer IDs for the same. We'd schedule (and hide
|
||||
caps for) every click without this check. Comparing a matrix
|
||||
hash against a per-object baseline filters selection noise
|
||||
without requiring opt-in from external addons.
|
||||
|
||||
First time we see an Object the hash is recorded as baseline
|
||||
(no flag), so an addon-load-time selection burst doesn't fire
|
||||
a phantom rebuild; subsequent real moves are detected on the
|
||||
first tick the matrix actually differs.
|
||||
"""
|
||||
relevant = False
|
||||
for upd in depsgraph.updates:
|
||||
obj = upd.id
|
||||
if not isinstance(obj, bpy.types.Object):
|
||||
continue
|
||||
if upd.is_updated_geometry:
|
||||
relevant = True
|
||||
continue
|
||||
if not upd.is_updated_transform:
|
||||
continue
|
||||
new_hash = tool.Blender.hash_matrix(obj.matrix_world)
|
||||
old_hash = cls._last_seen_object_matrices.get(obj.name)
|
||||
cls._last_seen_object_matrices[obj.name] = new_hash
|
||||
if old_hash is not None and old_hash != new_hash:
|
||||
relevant = True
|
||||
return relevant
|
||||
|
||||
@classmethod
|
||||
def _schedule_cap_rebuild(cls) -> None:
|
||||
"""(Re)schedule the deferred cap rebuild.
|
||||
|
||||
Each call cancels any pending timer and registers a fresh one
|
||||
so a burst of updates collapses to a single rebuild once the
|
||||
debounce window of quiet elapses.
|
||||
"""
|
||||
cls._cancel_pending_cap_rebuild()
|
||||
|
||||
def _do_rebuild() -> None:
|
||||
cls._pending_cap_rebuild = None
|
||||
try:
|
||||
cls.rebuild_cap_cache()
|
||||
except Exception:
|
||||
# bpy.app.timers swallows exceptions silently, leaving
|
||||
# the user with stale caps + no diagnostic. Surface to
|
||||
# the console so future bisect / cap edge cases are
|
||||
# debuggable instead of mysteriously invisible.
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
# Timer fires from the main loop without an accompanying
|
||||
# depsgraph tick, so the viewport won't repaint on its own;
|
||||
# nudge every region so the freshly-baked cap batches show
|
||||
# up without the user having to wiggle the mouse.
|
||||
for _area, region, _region_3d in tool.Blender.iter_view3d_regions():
|
||||
region.tag_redraw()
|
||||
return None
|
||||
|
||||
bpy.app.timers.register(_do_rebuild, first_interval=cls._CAP_REBUILD_DEBOUNCE_SECONDS)
|
||||
cls._pending_cap_rebuild = _do_rebuild
|
||||
|
||||
@classmethod
|
||||
def _cancel_pending_cap_rebuild(cls) -> None:
|
||||
"""Cancel any pending debounced rebuild so the next event source
|
||||
gets a clean slate. Idempotent and safe to call when none is
|
||||
registered (e.g. on addon unregister)."""
|
||||
pending = cls._pending_cap_rebuild
|
||||
if pending is not None and bpy.app.timers.is_registered(pending):
|
||||
bpy.app.timers.unregister(pending)
|
||||
cls._pending_cap_rebuild = None
|
||||
|
||||
@classmethod
|
||||
def on_post_view_caps(cls) -> None:
|
||||
"""Draw cached cap batches over the clipped geometry.
|
||||
|
||||
Installed as a ``SpaceView3D.draw_handler_add`` at ``POST_VIEW``.
|
||||
Caps render with depth-test + depth-write enabled so any
|
||||
geometry in front of the cap occludes it — without this the
|
||||
``UNIFORM_COLOR`` shader defaults to no-depth and the caps
|
||||
would always paint on top of the scene. One ``batch.draw`` per
|
||||
object; batches are pre-baked.
|
||||
"""
|
||||
if not cls._cap_cache:
|
||||
return
|
||||
scene_props = cls._active_scene_props()
|
||||
if scene_props is None or not scene_props.show_caps:
|
||||
return
|
||||
# Hide caps while in edit mode — the user's focus is on
|
||||
# vert/edge/face manipulation, not the section view; the cache
|
||||
# is also frozen by the same gate in the depsgraph path.
|
||||
if tool.Blender.is_in_edit_mode():
|
||||
return
|
||||
# Hide caps for the duration of any G/R/S to suppress mid-drag
|
||||
# visual jitter; the cache is also frozen by the same gate so
|
||||
# anything drawn here would be stale relative to the live mesh.
|
||||
if tool.Blender.is_transform_modal_active(bpy.context):
|
||||
return
|
||||
# Hide caps while a debounced rebuild is in flight (typical
|
||||
# cause: external-addon gizmo drag). The cache may reflect a
|
||||
# frame from earlier in the drag; drawing it would look stale
|
||||
# against the geometry the user is currently mutating.
|
||||
if cls._pending_cap_rebuild is not None:
|
||||
return
|
||||
import gpu
|
||||
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
cap_color = tuple(prefs.clip_box_cap_color)
|
||||
shader = gpu.shader.from_builtin("UNIFORM_COLOR")
|
||||
shader.bind()
|
||||
shader.uniform_float("color", cap_color)
|
||||
prev_depth_test = gpu.state.depth_test_get()
|
||||
prev_depth_mask = gpu.state.depth_mask_get()
|
||||
gpu.state.depth_test_set("LESS_EQUAL")
|
||||
gpu.state.depth_mask_set(True)
|
||||
try:
|
||||
for _key, batch in cls._cap_cache.values():
|
||||
if batch is None:
|
||||
continue
|
||||
batch.draw(shader)
|
||||
finally:
|
||||
gpu.state.depth_mask_set(prev_depth_mask)
|
||||
gpu.state.depth_test_set(prev_depth_test)
|
||||
@@ -0,0 +1,150 @@
|
||||
# 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 relation 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. The kind label returned alongside the rel lets the
|
||||
operator dispatch the right post-disconnect cleanup:
|
||||
|
||||
- ``"path"`` for ``IfcRelConnectsPathElements`` (wall-wall, wall-roof, etc.)
|
||||
- ``"element-top"`` for ``IfcRelConnectsElements`` with ``Description=="TOP"``
|
||||
(the rel kind ``extend_walls_to_underside`` creates)
|
||||
- ``"element"`` for any other ``IfcRelConnectsElements``
|
||||
|
||||
Add new rel kinds by extending :py:meth:`Connection.find_rel`. The disconnect
|
||||
operator's cleanup switch maps each kind to the right post-mutation calls."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
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 rel linking ``elem_a`` to ``elem_b`` as a
|
||||
list of ``(rel, kind)`` tuples. Walks both ``ConnectedTo`` and
|
||||
``ConnectedFrom`` because either side of the rel can be the relating
|
||||
element, and the same pair may carry rels authored with opposite
|
||||
orientations (``disconnect_path``'s ``(relating, related)`` mode only
|
||||
inspects ``relating.ConnectedTo``, so a single call would miss the
|
||||
opposite-orientation rel)."""
|
||||
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)
|
||||
|
||||
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 ``(rel, 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 rel touching ``elem`` as ``(rel, kind, partner)``
|
||||
triples. ``partner`` is the *other* element on the rel — the side cascade
|
||||
cleanup must operate on when ``elem`` is being deleted.
|
||||
|
||||
Mirrors :py:meth:`find_rels`'s kind taxonomy. The single-element entry
|
||||
point lets the cascade-on-delete in ``tool.Geometry.delete_ifc_object``
|
||||
enumerate everything the disconnect operator would handle pairwise.
|
||||
"""
|
||||
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
|
||||
@@ -65,6 +65,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
|
||||
@@ -156,6 +157,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.
|
||||
@@ -271,12 +378,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 rel, kind, partner in tool.Connection.find_rels_for_element(element):
|
||||
bonsai.core.connection.disconnect_rel(
|
||||
tool.Ifc,
|
||||
tool.Geometry,
|
||||
tool.Model,
|
||||
tool.Connection,
|
||||
rel=rel,
|
||||
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):
|
||||
@@ -641,7 +774,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:
|
||||
@@ -2348,6 +2487,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():
|
||||
@@ -2430,7 +2584,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)
|
||||
|
||||
@@ -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."""
|
||||
@@ -1275,7 +1320,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):
|
||||
@@ -3024,6 +3072,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()
|
||||
|
||||
@@ -157,6 +157,7 @@ class Parametric(bonsai.core.tool.Parametric):
|
||||
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 +172,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 +461,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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -8,6 +8,7 @@ markers =
|
||||
brick
|
||||
bsdd
|
||||
classification
|
||||
clip_box
|
||||
context
|
||||
cost
|
||||
covering
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,692 @@
|
||||
# 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)))
|
||||
|
||||
|
||||
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 True→False
|
||||
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()
|
||||
@@ -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,457 @@
|
||||
# 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()
|
||||
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_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_rel, expected_kind in zip(dispatch.call_args_list, [rel1, rel2], ["path", "element-top"]):
|
||||
kw = call.kwargs
|
||||
assert kw["rel"] is expected_rel
|
||||
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["rel"] is rel and slab_first["rel"] 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()
|
||||
@@ -0,0 +1,87 @@
|
||||
# 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.
|
||||
|
||||
"""Pins the active-is-survivor merge convention.
|
||||
|
||||
``bim.merge_wall`` must consume the non-active selection into the active
|
||||
one — matching Blender's ``OBJECT_OT_join`` / ``MESH_OT_merge`` "at
|
||||
last" convention. Users following Ctrl+J muscle-memory click the
|
||||
surviving wall last; the operator must align with that expectation."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.wall
|
||||
|
||||
|
||||
def _run_perform(active, other):
|
||||
"""Invoke ``MergeWall._perform`` as an unbound function with the
|
||||
two wall stubs in the selection, patching the heavy IFC / Blender
|
||||
side effects. Returns the ``(merger_arg_1, merger_arg_2)`` actually
|
||||
passed to ``DumbWallJoiner.merge``."""
|
||||
from bonsai.bim.module.model.wall import MergeWall
|
||||
|
||||
context = SimpleNamespace(active_object=active)
|
||||
captured_call = {}
|
||||
|
||||
def _capture_merge(self, a, b):
|
||||
captured_call["wall1"] = a
|
||||
captured_call["wall2"] = b
|
||||
|
||||
fake_self = SimpleNamespace()
|
||||
|
||||
with (
|
||||
patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=MagicMock(name="ifc_file")),
|
||||
patch("bonsai.bim.module.model.wall.tool.Model.get_selected_mesh_objects", return_value=[active, other]),
|
||||
patch("bonsai.bim.module.model.wall.DumbWallJoiner.__init__", return_value=None),
|
||||
patch("bonsai.bim.module.model.wall.DumbWallJoiner.merge", new=_capture_merge),
|
||||
patch("bonsai.bim.module.model.wall._maybe_resync_wall_props_from_ifc"),
|
||||
patch("bonsai.bim.module.model.wall._regenerate_walls") as regen_walls,
|
||||
):
|
||||
result = MergeWall._perform(fake_self, context)
|
||||
|
||||
return captured_call, regen_walls, result
|
||||
|
||||
|
||||
def test_active_wall_is_passed_as_survivor_to_merge():
|
||||
"""The first argument to ``DumbWallJoiner.merge`` is the survivor;
|
||||
the active object must occupy that slot so the wall the user clicked
|
||||
last absorbs the other."""
|
||||
active = SimpleNamespace(name="active")
|
||||
other = SimpleNamespace(name="other")
|
||||
|
||||
captured, _regen, _ = _run_perform(active, other)
|
||||
|
||||
assert captured["wall1"] is active
|
||||
assert captured["wall2"] is other
|
||||
|
||||
|
||||
def test_post_merge_resync_targets_active_not_consumed():
|
||||
"""After the merge ``_regenerate_walls`` rebuilds the survivor's
|
||||
body. Targeting the consumed wall would crash on a freed ``bpy_struct``;
|
||||
the survivor (active) is the only valid target."""
|
||||
active = SimpleNamespace(name="active")
|
||||
other = SimpleNamespace(name="other")
|
||||
|
||||
_, regen_walls, _ = _run_perform(active, other)
|
||||
|
||||
regen_walls.assert_called_once_with([active])
|
||||
@@ -0,0 +1,300 @@
|
||||
# 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.
|
||||
|
||||
"""Unit coverage for the ``_RailingEditMixin`` overrides and the lifecycle
|
||||
behaviour railing inherits from ``PathPreservingEditMixin``.
|
||||
|
||||
The parent short-circuit (skip the IFC commit / viewport rebuild when the
|
||||
draft is identical to the stored pset) lives in
|
||||
``PathPreservingEditMixin``; the tests below verify railing's subclass
|
||||
honours that contract by inheritance, then pin the railing-specific
|
||||
viewport-restore dispatch:
|
||||
|
||||
- Finish / Cancel no-op short-circuit: inherited from the parent — verified
|
||||
here because railing was the original consumer that motivated the
|
||||
optimisation.
|
||||
- ``_RailingEditMixin._restore_viewport_after_cancel`` dispatch: WALL_MOUNTED_HANDRAIL
|
||||
reloads the high-poly Body representation via ``switch_representation``;
|
||||
FRAMELESS_PANEL rebuilds the bmesh preview via
|
||||
``update_railing_modifier_bmesh``. This is the per-type branch that used
|
||||
to live in ``_cancel_one`` and now lives in the viewport-restore hook the
|
||||
parent's ``_cancel_one`` calls.
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from test.bim.conftest import _FakePropsBase
|
||||
from test.bim.conftest import make_lifecycle_obj as _make_obj
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeRailingProps(_FakePropsBase):
|
||||
"""Stand-in for ``BIMRailingProperties`` — adds ``railing_type`` on top of
|
||||
the shared parametric-edit contract. Starts in ``is_editing=True`` because
|
||||
the railing-specific overrides under test only fire on Finish / Cancel,
|
||||
not on Enable."""
|
||||
|
||||
def __init__(self, railing_type: str = "WALL_MOUNTED_HANDRAIL", general: dict | None = None):
|
||||
super().__init__(general=general if general is not None else {"railing_type": railing_type, "height": 1.0})
|
||||
self.railing_type = railing_type
|
||||
self.is_editing = True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_railing():
|
||||
"""Patch the railing module's external references for unit testing.
|
||||
|
||||
``_RailingEditMixin`` and the parent lifecycle reach for
|
||||
``tool.Model.get_modeling_bbim_pset_data``, ``tool.Ifc.get_entity``,
|
||||
``ifcopenshell.util.representation.get_representation``,
|
||||
``bonsai.core.geometry.switch_representation``, and the module-level
|
||||
``update_railing_modifier_bmesh`` — each looked up through the railing
|
||||
module's own bindings, so we patch them there.
|
||||
|
||||
``parametric_lifecycle.tool`` is patched separately so the parent's
|
||||
``_resolve`` and ``_cancel_one`` can read ``tool.Model.get_modeling_bbim_pset_data``
|
||||
without falling through to the real Blender bindings.
|
||||
|
||||
Uses ``mock.patch.object`` with a direct module reference rather than
|
||||
the dotted-string form: ``mock.patch("bonsai.bim.module.model.railing.bonsai")``
|
||||
needs ``pkgutil.resolve_name`` to traverse ``bonsai → bim → module → …``,
|
||||
which fails at the ``bonsai.bim`` step until that subpackage has been
|
||||
imported elsewhere. The direct-object form sidesteps the resolution.
|
||||
|
||||
Returns a dict for tests to seed return values and assert call sites.
|
||||
"""
|
||||
from bonsai.bim import parametric_lifecycle
|
||||
from bonsai.bim.module.model import railing
|
||||
|
||||
with (
|
||||
mock.patch.object(railing, "tool") as mock_tool,
|
||||
mock.patch.object(railing, "ifcopenshell") as mock_ifc,
|
||||
mock.patch.object(railing, "bonsai") as mock_bonsai,
|
||||
mock.patch.object(railing, "update_railing_modifier_bmesh") as mock_update_bmesh,
|
||||
mock.patch.object(parametric_lifecycle, "tool") as mock_pl_tool,
|
||||
):
|
||||
# _resolve will be overridden on the test subclass below so the
|
||||
# parametric_lifecycle.tool patch isn't needed for that path, but the
|
||||
# parent's _cancel_one / _finish_one still call
|
||||
# tool.Model.get_modeling_bbim_pset_data and would otherwise miss.
|
||||
mock_tool.Ifc.get_entity.return_value = mock.Mock(name="entity")
|
||||
yield {
|
||||
"tool": mock_tool,
|
||||
"ifcopenshell": mock_ifc,
|
||||
"bonsai": mock_bonsai,
|
||||
"update_bmesh": mock_update_bmesh,
|
||||
"pl_tool": mock_pl_tool,
|
||||
}
|
||||
|
||||
|
||||
def _railing_test_subclass(props):
|
||||
"""Build a ``_RailingEditMixin`` subclass that bypasses ``_resolve``.
|
||||
|
||||
The base ``_resolve`` reads ``tool.Ifc.get_entity`` from
|
||||
``parametric_lifecycle.tool`` (a separate import from the railing
|
||||
module's ``tool``). Overriding it here keeps the test patches local
|
||||
to the railing module and the hook closures local to the test."""
|
||||
from bonsai.bim.module.model.railing import _RailingEditMixin
|
||||
|
||||
test_element = mock.Mock(name="ifc_element")
|
||||
|
||||
class _TestRailingMixin(_RailingEditMixin):
|
||||
pset_updates: mock.MagicMock = mock.MagicMock(name="_update_pset")
|
||||
ifc_data_updates: mock.MagicMock = mock.MagicMock(name="_update_modifier_ifc_data")
|
||||
bmesh_updates: mock.MagicMock = mock.MagicMock(name="_restore_viewport_after_cancel")
|
||||
|
||||
@classmethod
|
||||
def _resolve(cls, obj):
|
||||
return test_element, props
|
||||
|
||||
@classmethod
|
||||
def _update_pset(cls, element, data):
|
||||
cls.pset_updates(element, data)
|
||||
|
||||
@classmethod
|
||||
def _update_modifier_ifc_data(cls, obj, context):
|
||||
cls.ifc_data_updates(obj, context)
|
||||
|
||||
@classmethod
|
||||
def _restore_viewport_after_cancel(cls, obj, context):
|
||||
cls.bmesh_updates(obj, context)
|
||||
|
||||
# The base _post_load_data JSON-serialises path_data; bypass that
|
||||
# here so the round-trip stays a plain dict and tests can compare
|
||||
# by reference / equality without re-parsing.
|
||||
@classmethod
|
||||
def _post_load_data(cls, data):
|
||||
return dict(data)
|
||||
|
||||
return _TestRailingMixin, test_element
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _RailingEditMixin._finish_one
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_finish_one_short_circuits_when_draft_matches_stored(patched_railing):
|
||||
"""Enable → Finish without any property edit must NOT write to IFC.
|
||||
|
||||
Without this, every "open Edit, click Validate immediately" cycle
|
||||
would create a fresh ``IfcShapeRepresentation``, pollute the file's
|
||||
representation list, and burn an undo entry — the user-visible
|
||||
regression that motivated the short-circuit.
|
||||
|
||||
Behaviour now inherited from ``PathPreservingEditMixin``; railing keeps
|
||||
the coverage as the original consumer of the contract.
|
||||
"""
|
||||
stored = {"railing_type": "WALL_MOUNTED_HANDRAIL", "height": 1.0}
|
||||
props = _FakeRailingProps(general=dict(stored))
|
||||
obj = _make_obj(props)
|
||||
patched_railing["pl_tool"].Model.get_modeling_bbim_pset_data.return_value = {
|
||||
"data_dict": {**stored, "path_data": {"verts": [], "edges": []}},
|
||||
}
|
||||
|
||||
cls, _element = _railing_test_subclass(props)
|
||||
cls._finish_one(obj, mock.Mock(name="context"))
|
||||
|
||||
assert props.is_editing is False, "is_editing must still flip even on no-op"
|
||||
cls.pset_updates.assert_not_called()
|
||||
cls.ifc_data_updates.assert_not_called()
|
||||
|
||||
|
||||
def test_finish_one_writes_when_draft_differs(patched_railing):
|
||||
"""The complement of the short-circuit: a real property change must
|
||||
flow through to ``_update_pset`` + ``_update_modifier_ifc_data``."""
|
||||
stored = {"railing_type": "WALL_MOUNTED_HANDRAIL", "height": 1.0}
|
||||
# Draft height differs: simulating a user edit.
|
||||
props = _FakeRailingProps(general={"railing_type": "WALL_MOUNTED_HANDRAIL", "height": 1.5})
|
||||
obj = _make_obj(props)
|
||||
patched_railing["pl_tool"].Model.get_modeling_bbim_pset_data.return_value = {
|
||||
"data_dict": {**stored, "path_data": {"verts": [], "edges": []}},
|
||||
}
|
||||
|
||||
cls, element = _railing_test_subclass(props)
|
||||
cls._finish_one(obj, mock.Mock(name="context"))
|
||||
|
||||
assert props.is_editing is False
|
||||
cls.pset_updates.assert_called_once()
|
||||
# The pset must receive the DRAFT data, not the stored data — that's the
|
||||
# whole point of Finish committing the user's edits.
|
||||
written = cls.pset_updates.call_args[0][1]
|
||||
assert written["height"] == 1.5
|
||||
cls.ifc_data_updates.assert_called_once_with(obj, mock.ANY)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _RailingEditMixin._cancel_one
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_cancel_one_short_circuits_when_draft_matches_stored(patched_railing):
|
||||
"""Cancel-without-changes is asymmetrically expensive without this guard:
|
||||
``switch_representation`` re-tessellates the IfcSweptDiskSolid and is
|
||||
visibly slow on a long handrail. When nothing changed, the mesh on
|
||||
screen is still the committed IFC representation (the preview only
|
||||
builds on a property change) — skip the reload entirely.
|
||||
|
||||
Behaviour now inherited from ``PathPreservingEditMixin``; railing keeps
|
||||
the coverage as the original consumer of the contract.
|
||||
"""
|
||||
stored = {"railing_type": "WALL_MOUNTED_HANDRAIL", "height": 1.0}
|
||||
props = _FakeRailingProps(general=dict(stored))
|
||||
obj = _make_obj(props)
|
||||
patched_railing["pl_tool"].Model.get_modeling_bbim_pset_data.return_value = {
|
||||
"data_dict": {**stored, "path_data": {"verts": [], "edges": []}},
|
||||
}
|
||||
|
||||
cls, _element = _railing_test_subclass(props)
|
||||
cls._cancel_one(obj, mock.Mock(name="context"))
|
||||
|
||||
assert props.is_editing is False
|
||||
patched_railing["bonsai"].core.geometry.switch_representation.assert_not_called()
|
||||
patched_railing["update_bmesh"].assert_not_called()
|
||||
cls.bmesh_updates.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _RailingEditMixin._restore_viewport_after_cancel — per-type viewport-restore dispatch
|
||||
#
|
||||
# The parent's _cancel_one calls cls._restore_viewport_after_cancel whenever
|
||||
# the draft differs from the stored pset. Railing's override branches on
|
||||
# railing_type so WALL_MOUNTED_HANDRAIL reloads the high-poly Body
|
||||
# representation rather than rebuilding the low-poly cylinder-segment preview.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_restore_viewport_wall_mounted_handrail_switches_representation(patched_railing):
|
||||
"""WALL_MOUNTED_HANDRAIL restore must call ``switch_representation`` with
|
||||
the Body representation — the preview is viewport-only (low-poly cylinder)
|
||||
and would persist visibly without the reload."""
|
||||
from bonsai.bim.module.model.railing import _RailingEditMixin
|
||||
|
||||
props = _FakeRailingProps(railing_type="WALL_MOUNTED_HANDRAIL")
|
||||
obj = _make_obj(props)
|
||||
patched_railing["tool"].Model.get_railing_props.return_value = props
|
||||
body_repr = mock.Mock(name="body_representation")
|
||||
patched_railing["ifcopenshell"].util.representation.get_representation.return_value = body_repr
|
||||
|
||||
_RailingEditMixin._restore_viewport_after_cancel(obj, mock.Mock(name="context"))
|
||||
|
||||
patched_railing["bonsai"].core.geometry.switch_representation.assert_called_once()
|
||||
kwargs = patched_railing["bonsai"].core.geometry.switch_representation.call_args.kwargs
|
||||
assert kwargs["obj"] is obj
|
||||
assert kwargs["representation"] is body_repr
|
||||
# Must NOT fall through to the FRAMELESS bmesh-rebuild path.
|
||||
patched_railing["update_bmesh"].assert_not_called()
|
||||
|
||||
|
||||
def test_restore_viewport_frameless_panel_calls_module_bmesh_rebuild(patched_railing):
|
||||
"""FRAMELESS_PANEL's bmesh IS the canonical mesh — there's no IFC
|
||||
swept-disk solid to reload. The restore must delegate to the module-level
|
||||
``update_railing_modifier_bmesh`` rebuilder rather than swap representations."""
|
||||
from bonsai.bim.module.model.railing import _RailingEditMixin
|
||||
|
||||
props = _FakeRailingProps(railing_type="FRAMELESS_PANEL")
|
||||
obj = _make_obj(props)
|
||||
patched_railing["tool"].Model.get_railing_props.return_value = props
|
||||
ctx = mock.Mock(name="context")
|
||||
|
||||
_RailingEditMixin._restore_viewport_after_cancel(obj, ctx)
|
||||
|
||||
patched_railing["update_bmesh"].assert_called_once_with(ctx)
|
||||
patched_railing["bonsai"].core.geometry.switch_representation.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _get_railing_path_anchor: tests removed.
|
||||
#
|
||||
# The schematic-redesign branch replaced ``GizmoRailingEdition`` with
|
||||
# ``GizmoRailingSchematic``, which anchors via the schematic frame rather
|
||||
# than the polyline's first vertex. ``_get_railing_path_anchor`` was the
|
||||
# helper for the old anchor strategy and has been deleted along with the
|
||||
# old gizmo group. If schematic-mode gains a similar path-derived helper,
|
||||
# new tests should land here.
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -0,0 +1,270 @@
|
||||
# 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.
|
||||
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
from bonsai import tool
|
||||
from bonsai.bim.module.drawing.gizmos import (
|
||||
BaseSchematicGizmoGroup,
|
||||
DimensionGizmoConfig,
|
||||
)
|
||||
from bonsai.bim.module.model.railing import GizmoRailingSchematic
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _require_real_bpy():
|
||||
"""Skip the file when ``bpy`` is mocked or absent.
|
||||
|
||||
Without this guard, mis-routed test runs (e.g. ``pytest test/bim/...``
|
||||
invoked outside Blender) crash at module-collection time on the chain of
|
||||
``bonsai.tool`` imports below, instead of producing a clean ``skipped``.
|
||||
"""
|
||||
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
|
||||
pytest.skip("requires real Blender (bpy is mocked or absent)")
|
||||
|
||||
|
||||
# ── Class shape ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_railing_schematic_inherits_base():
|
||||
"""GizmoRailingSchematic plugs into the schematic framework, not the
|
||||
in-place dimension framework. If a future refactor breaks this lineage
|
||||
the schematic-specific machinery (sliders, draw handler) silently goes
|
||||
dormant."""
|
||||
assert issubclass(GizmoRailingSchematic, BaseSchematicGizmoGroup)
|
||||
|
||||
|
||||
def test_railing_schematic_bl_idname_preserved():
|
||||
"""``OBJECT_GGT_bim_railing_edition`` is the user-facing identifier and
|
||||
is referenced by keymaps and persistence. Preserve it across the class
|
||||
rename — see the migration note in the class docstring."""
|
||||
assert GizmoRailingSchematic.bl_idname == "OBJECT_GGT_bim_railing_edition"
|
||||
|
||||
|
||||
def test_railing_schematic_props_getter_pairing():
|
||||
"""``gizmo_pref_name = "railing"`` and ``props_getter = tool.Model.get_railing_props``
|
||||
are the pairing test_parametric_registry depends on. If either drifts,
|
||||
the addon-preferences gizmo toggle silently stops controlling this group."""
|
||||
assert GizmoRailingSchematic.gizmo_pref_name == "railing"
|
||||
assert GizmoRailingSchematic.props_getter == tool.Model.get_railing_props
|
||||
|
||||
|
||||
def test_railing_schematic_disables_in_place_dimension_props():
|
||||
"""The schematic owns the value-input surface — no in-place dimensions on the actual geometry."""
|
||||
assert GizmoRailingSchematic.dimension_gizmo_props == []
|
||||
|
||||
|
||||
# ── Dimension configuration ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_railing_schematic_has_six_dimensions():
|
||||
"""One dimension per parametric property — three for each railing_type."""
|
||||
assert len(GizmoRailingSchematic.schematic_dimension_props) == 6
|
||||
|
||||
|
||||
def test_railing_schematic_dimension_attr_names_complete():
|
||||
"""The six bound attributes match the parametric properties that
|
||||
``update_railing_modifier_bmesh`` reads when regenerating the live preview."""
|
||||
attr_names = {c.attr_name for c in GizmoRailingSchematic.schematic_dimension_props}
|
||||
assert attr_names == {
|
||||
"height",
|
||||
"thickness",
|
||||
"spacing",
|
||||
"railing_diameter",
|
||||
"clear_width",
|
||||
"support_spacing",
|
||||
}
|
||||
|
||||
|
||||
def test_railing_schematic_dimensions_are_dimension_configs():
|
||||
"""The dimension-line aesthetic depends on ``DimensionGizmoConfig`` (with
|
||||
arrows + label), not the abstract slider widget."""
|
||||
for config in GizmoRailingSchematic.schematic_dimension_props:
|
||||
assert isinstance(config, DimensionGizmoConfig)
|
||||
|
||||
|
||||
def test_railing_schematic_dimensions_have_text_formatters():
|
||||
"""Each dimension must format the label from the actual property value,
|
||||
not from the visually-scaled value the gizmo's getter returns. Without a
|
||||
formatter the label would show the schematic-scaled length, which is
|
||||
meaningless to the user."""
|
||||
for config in GizmoRailingSchematic.schematic_dimension_props:
|
||||
assert config.text_formatter is not None, f"{config.attr_name} missing text_formatter"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"attr_name,railing_type,expected",
|
||||
[
|
||||
("height", "FRAMELESS_PANEL", True),
|
||||
("height", "WALL_MOUNTED_HANDRAIL", False),
|
||||
("thickness", "FRAMELESS_PANEL", True),
|
||||
("spacing", "FRAMELESS_PANEL", True),
|
||||
("railing_diameter", "WALL_MOUNTED_HANDRAIL", True),
|
||||
("railing_diameter", "FRAMELESS_PANEL", False),
|
||||
("clear_width", "WALL_MOUNTED_HANDRAIL", True),
|
||||
],
|
||||
)
|
||||
def test_railing_schematic_dimension_visibility_gated_by_railing_type(attr_name, railing_type, expected):
|
||||
"""The two railing types are mutually exclusive — height/thickness/spacing
|
||||
belong to FRAMELESS_PANEL; railing_diameter/clear_width/support_spacing
|
||||
belong to WALL_MOUNTED_HANDRAIL. The visibility lambdas enforce that."""
|
||||
config = next(c for c in GizmoRailingSchematic.schematic_dimension_props if c.attr_name == attr_name)
|
||||
props = SimpleNamespace(railing_type=railing_type, use_manual_supports=False)
|
||||
assert config.visibility_condition(props) is expected
|
||||
|
||||
|
||||
def test_railing_schematic_support_spacing_hidden_for_manual_supports():
|
||||
"""``support_spacing`` only drives auto-positioned supports — when the
|
||||
user has switched to manual supports the dimension should disappear."""
|
||||
config = next(c for c in GizmoRailingSchematic.schematic_dimension_props if c.attr_name == "support_spacing")
|
||||
auto = SimpleNamespace(railing_type="WALL_MOUNTED_HANDRAIL", use_manual_supports=False)
|
||||
manual = SimpleNamespace(railing_type="WALL_MOUNTED_HANDRAIL", use_manual_supports=True)
|
||||
assert config.visibility_condition(auto) is True
|
||||
assert config.visibility_condition(manual) is False
|
||||
|
||||
|
||||
# ── Fixed-length tag rendering ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_schematic_dim_visible_length_is_constant():
|
||||
"""Every schematic dimension tag renders at the same width — the bar is a
|
||||
UI affordance, not a proportional measurement. The constant ratio keeps
|
||||
tiny (5 mm thickness) and huge (5 m height) values equally clickable; the
|
||||
real value lives in the dimension label.
|
||||
|
||||
Regression guard: if value-proportional scaling is reintroduced, this
|
||||
contract breaks silently — small dimensions start collapsing into stacked
|
||||
arrows again.
|
||||
"""
|
||||
cls = GizmoRailingSchematic
|
||||
ratio = cls.SCHEMATIC_DIM_VISIBLE_LENGTH_RATIO
|
||||
assert ratio > 0
|
||||
assert ratio <= 1.0 # bar must fit within the schematic box
|
||||
|
||||
|
||||
def test_schematic_no_compute_schematic_scale_override():
|
||||
"""The constant-length schematic must not reintroduce scale-based
|
||||
proportional sizing via a ``_compute_schematic_scale`` override."""
|
||||
assert "_compute_schematic_scale" not in GizmoRailingSchematic.__dict__
|
||||
|
||||
|
||||
# ── Path-edit guard ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_update_editing_gizmos_override_defined_on_subclass():
|
||||
"""``GizmoRailingSchematic`` must own the override that hides the pen
|
||||
icon during path-edit. The parent's version shows the pen whenever
|
||||
``is_editing`` is False, which includes path-edit; that would let the
|
||||
user open two editing modes at once."""
|
||||
assert "update_editing_gizmos" in GizmoRailingSchematic.__dict__
|
||||
|
||||
|
||||
# ── Schematic mesh building ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_build_schematic_mesh_frameless_panel_returns_bmesh_with_edges():
|
||||
"""FRAMELESS_PANEL renders as two separated wireframe boxes — 8 corners
|
||||
per box × 2 = 16 verts; 12 edges per box × 2 = 24 edges. The visible
|
||||
gap between the two boxes is the "spacing" semantic made literal.
|
||||
|
||||
The mesh proportions are fixed (independent of property values) so the
|
||||
dimension gizmos can anchor to known feature positions; the property
|
||||
values are shown through dimension labels, not the mesh size."""
|
||||
props = SimpleNamespace(
|
||||
railing_type="FRAMELESS_PANEL",
|
||||
height=1.0,
|
||||
thickness=0.05,
|
||||
spacing=0.5,
|
||||
)
|
||||
bm = GizmoRailingSchematic.build_schematic_mesh(props)
|
||||
try:
|
||||
assert isinstance(bm, bmesh.types.BMesh)
|
||||
assert len(bm.verts) == 16
|
||||
assert len(bm.edges) == 24
|
||||
finally:
|
||||
bm.free()
|
||||
|
||||
|
||||
def test_build_schematic_mesh_wall_mounted_handrail_returns_bmesh_with_edges():
|
||||
"""WALL_MOUNTED_HANDRAIL renders as three visual elements:
|
||||
|
||||
- **Wall outline** — 4 corner verts, 4 edges (rectangle at z=0).
|
||||
- **Hex tube** — 12 verts (6 per ring × 2 ends), 18 edges
|
||||
(6 left ring + 6 right ring + 6 axial).
|
||||
- **L-brackets** at each rail end — 3 verts per bracket (rail centre,
|
||||
corner, wall attach) × 2 brackets = 6 verts; 2 edges per bracket
|
||||
(rail→corner, corner→wall) × 2 = 4 edges.
|
||||
|
||||
Total: 22 verts, 26 edges.
|
||||
"""
|
||||
props = SimpleNamespace(
|
||||
railing_type="WALL_MOUNTED_HANDRAIL",
|
||||
railing_diameter=0.05,
|
||||
clear_width=0.04,
|
||||
support_spacing=1.0,
|
||||
)
|
||||
bm = GizmoRailingSchematic.build_schematic_mesh(props)
|
||||
try:
|
||||
assert isinstance(bm, bmesh.types.BMesh)
|
||||
assert len(bm.verts) == 22
|
||||
assert len(bm.edges) == 26
|
||||
finally:
|
||||
bm.free()
|
||||
|
||||
|
||||
def test_build_schematic_mesh_proportions_independent_of_props():
|
||||
"""The mesh uses fixed proportions so dimension gizmo anchor points stay
|
||||
aligned with the geometry — extreme prop ratios don't change the mesh."""
|
||||
small = SimpleNamespace(railing_type="FRAMELESS_PANEL", height=0.01, thickness=0.005, spacing=0.05)
|
||||
large = SimpleNamespace(railing_type="FRAMELESS_PANEL", height=10.0, thickness=0.5, spacing=2.0)
|
||||
bm_small = GizmoRailingSchematic.build_schematic_mesh(small)
|
||||
bm_large = GizmoRailingSchematic.build_schematic_mesh(large)
|
||||
try:
|
||||
# Same vert count regardless of prop magnitude.
|
||||
assert len(bm_small.verts) == len(bm_large.verts)
|
||||
# Same bounding box in each axis (within floating-point noise).
|
||||
for axis in range(3):
|
||||
small_coords = [v.co[axis] for v in bm_small.verts]
|
||||
large_coords = [v.co[axis] for v in bm_large.verts]
|
||||
assert min(small_coords) == pytest.approx(min(large_coords))
|
||||
assert max(small_coords) == pytest.approx(max(large_coords))
|
||||
finally:
|
||||
bm_small.free()
|
||||
bm_large.free()
|
||||
|
||||
|
||||
def test_build_schematic_mesh_panel_top_matches_height_frac():
|
||||
"""The panel's top edge sits at exactly ``SCHEMATIC_MESH_HEIGHT_FRAC``,
|
||||
which is also where the ``thickness`` dimension anchors above the box.
|
||||
If this drifts, the dimension labels float disconnected from the mesh."""
|
||||
props = SimpleNamespace(railing_type="FRAMELESS_PANEL", height=1.0, thickness=0.05, spacing=0.3)
|
||||
bm = GizmoRailingSchematic.build_schematic_mesh(props)
|
||||
try:
|
||||
max_y = max(v.co.y for v in bm.verts)
|
||||
assert max_y == pytest.approx(GizmoRailingSchematic.SCHEMATIC_MESH_HEIGHT_FRAC)
|
||||
finally:
|
||||
bm.free()
|
||||
@@ -0,0 +1,85 @@
|
||||
# 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.
|
||||
|
||||
"""Pins the branching contract of ``tool.Model.regenerate_wall``.
|
||||
|
||||
The body rebuild always runs (extrusion + openings); the slab re-clip only
|
||||
runs when an ``IfcRelConnectsElements(TOP)`` rel survives. A wall without
|
||||
either feature still completes without crashing."""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import bonsai.tool as tool
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
def test_regenerate_wall_rebuilds_body_and_reclips_when_connected():
|
||||
"""Wall with a TOP connection: body rebuilt first, then re-clipped."""
|
||||
element = Mock()
|
||||
obj = Mock()
|
||||
|
||||
with patch("bonsai.tool.model.tool.Ifc.get_entity", return_value=element), patch.object(
|
||||
tool.Model, "recreate_wall"
|
||||
) as recreate, patch.object(tool.Model, "has_underside_connection", return_value=True), patch(
|
||||
"bonsai.tool.model.bonsai.core.model.regenerate_wall_to_underside"
|
||||
) as regen:
|
||||
tool.Model.regenerate_wall(obj)
|
||||
|
||||
recreate.assert_called_once_with(element, obj)
|
||||
regen.assert_called_once()
|
||||
args, _ = regen.call_args
|
||||
assert args[3] == [obj]
|
||||
|
||||
|
||||
def test_regenerate_wall_skips_reclip_when_no_top_rel():
|
||||
"""Wall without a TOP connection: body rebuilt; re-clip skipped."""
|
||||
element = Mock()
|
||||
obj = Mock()
|
||||
|
||||
with patch("bonsai.tool.model.tool.Ifc.get_entity", return_value=element), patch.object(
|
||||
tool.Model, "recreate_wall"
|
||||
) as recreate, patch.object(tool.Model, "has_underside_connection", return_value=False), patch(
|
||||
"bonsai.tool.model.bonsai.core.model.regenerate_wall_to_underside"
|
||||
) as regen:
|
||||
tool.Model.regenerate_wall(obj)
|
||||
|
||||
recreate.assert_called_once_with(element, obj)
|
||||
regen.assert_not_called()
|
||||
|
||||
|
||||
def test_regenerate_wall_noops_when_obj_has_no_ifc_entity():
|
||||
"""Non-IFC objects (e.g. a freshly created Blender mesh before
|
||||
`tool.Ifc.run("root.create_entity")` runs) return None from get_entity;
|
||||
the helper must return without touching the body or any rels."""
|
||||
obj = Mock()
|
||||
|
||||
with patch("bonsai.tool.model.tool.Ifc.get_entity", return_value=None), patch.object(
|
||||
tool.Model, "recreate_wall"
|
||||
) as recreate, patch.object(tool.Model, "has_underside_connection") as has_top, patch(
|
||||
"bonsai.tool.model.bonsai.core.model.regenerate_wall_to_underside"
|
||||
) as regen:
|
||||
tool.Model.regenerate_wall(obj)
|
||||
|
||||
recreate.assert_not_called()
|
||||
has_top.assert_not_called()
|
||||
regen.assert_not_called()
|
||||
@@ -26,6 +26,8 @@ Allow-list (gizmos intentionally outside the rule):
|
||||
|
||||
- ``GizmoWallEdition`` — single-object parametric edit gizmo. Its base
|
||||
parametric poll already filters array children.
|
||||
- ``GizmoSlabEdition`` — same as ``GizmoWallEdition`` (inherits
|
||||
``BaseParametricGizmoGroup`` whose poll filters array children).
|
||||
- ``GizmoWallFilletPreview`` — the preview-owner whose poll must fire
|
||||
WHILE its own preview is active; routing it through the topology gate
|
||||
would self-block it.
|
||||
@@ -47,9 +49,11 @@ pytestmark = pytest.mark.model
|
||||
|
||||
# Wall gizmo groups intentionally outside the rule. Add a new entry only
|
||||
# with the in-code reasoning above.
|
||||
_ALLOWLIST = frozenset({"GizmoWallEdition", "GizmoWallFilletPreview"})
|
||||
_ALLOWLIST = frozenset({"GizmoSlabEdition", "GizmoWallEdition", "GizmoWallFilletPreview"})
|
||||
|
||||
_REQUIRED_CALLEES = frozenset({"_wall_topology_gizmo_poll_gate", "any_selected_is_array_child"})
|
||||
_REQUIRED_CALLEES = frozenset(
|
||||
{"_wall_topology_gizmo_poll_gate", "_slab_connection_gizmo_poll_gate", "any_selected_is_array_child"}
|
||||
)
|
||||
|
||||
|
||||
def _wall_module_source():
|
||||
|
||||
@@ -50,12 +50,15 @@ def _make_context(active, selected):
|
||||
return SimpleNamespace(active_object=active, selected_objects=list(selected))
|
||||
|
||||
|
||||
def _patch_tools(prefs_on, selected, active_element, other_element, active_usage, other_usage):
|
||||
def _patch_tools(
|
||||
prefs_on, selected, active_element, other_element, active_usage, other_usage, other_is_path_connectable=None
|
||||
):
|
||||
"""Return a stack of patches that simulate one selection / IFC state for poll().
|
||||
|
||||
``prefs.gizmos.draw_gizmos_in_3d_viewport`` is the top-level toggle. The
|
||||
selection set, the IFC entity lookup, and the usage-type lookup are stubbed
|
||||
so the test only depends on the predicate ordering in poll()."""
|
||||
selection set, the IFC entity lookup, the usage-type lookup, and the
|
||||
path-connectable-wall predicate are stubbed so the test only depends on
|
||||
the predicate ordering in poll()."""
|
||||
prefs = SimpleNamespace(gizmos=SimpleNamespace(draw_gizmos_in_3d_viewport=prefs_on))
|
||||
|
||||
entity_map = {}
|
||||
@@ -67,12 +70,18 @@ def _patch_tools(prefs_on, selected, active_element, other_element, active_usage
|
||||
usage_map[id(active_element)] = active_usage
|
||||
usage_map[id(other_element)] = other_usage
|
||||
|
||||
if other_is_path_connectable is None:
|
||||
other_is_path_connectable = other_usage == "LAYER2"
|
||||
|
||||
def get_entity(obj):
|
||||
return entity_map.get(id(obj))
|
||||
|
||||
def get_usage_type(element):
|
||||
return usage_map.get(id(element))
|
||||
|
||||
def is_path_connectable_wall(element):
|
||||
return element is other_element and other_is_path_connectable
|
||||
|
||||
from bonsai import tool
|
||||
|
||||
return [
|
||||
@@ -80,6 +89,7 @@ def _patch_tools(prefs_on, selected, active_element, other_element, active_usage
|
||||
patch.object(tool.Blender, "get_selected_objects", return_value=set(selected)),
|
||||
patch.object(tool.Ifc, "get_entity", side_effect=get_entity),
|
||||
patch.object(tool.Model, "get_usage_type", side_effect=get_usage_type),
|
||||
patch.object(tool.Parametric, "is_path_connectable_wall", side_effect=is_path_connectable_wall),
|
||||
# The array-child filter is pinned by its own test file; stub it here
|
||||
# so these poll tests stay focused on the count / layer-usage gates
|
||||
# and don't have to scaffold the memoization cache key.
|
||||
@@ -87,7 +97,15 @@ def _patch_tools(prefs_on, selected, active_element, other_element, active_usage
|
||||
]
|
||||
|
||||
|
||||
def _run_poll(prefs_on, active_is_in_selected, len_override, active_usage, other_usage, active_has_entity=True):
|
||||
def _run_poll(
|
||||
prefs_on,
|
||||
active_is_in_selected,
|
||||
len_override,
|
||||
active_usage,
|
||||
other_usage,
|
||||
active_has_entity=True,
|
||||
other_is_path_connectable=None,
|
||||
):
|
||||
from bonsai.bim.module.model.wall import GizmoWallExtendVertically
|
||||
|
||||
slab_obj = _Obj("slab")
|
||||
@@ -103,7 +121,15 @@ def _run_poll(prefs_on, active_is_in_selected, len_override, active_usage, other
|
||||
slab_element = object() if active_has_entity else None
|
||||
wall_element = object()
|
||||
|
||||
patches = _patch_tools(prefs_on, selected, slab_element, wall_element, active_usage, other_usage)
|
||||
patches = _patch_tools(
|
||||
prefs_on,
|
||||
selected,
|
||||
slab_element,
|
||||
wall_element,
|
||||
active_usage,
|
||||
other_usage,
|
||||
other_is_path_connectable=other_is_path_connectable,
|
||||
)
|
||||
for p in patches:
|
||||
p.start()
|
||||
try:
|
||||
@@ -189,6 +215,23 @@ def test_poll_rejects_when_other_is_not_layer2_wall():
|
||||
)
|
||||
|
||||
|
||||
def test_poll_accepts_fillet_corner_wall_partner():
|
||||
# Fillet-corner walls carry no LAYER2 usage by spec but the extend-to-
|
||||
# underside operator handles them just like a parametric LAYER2 wall —
|
||||
# the gizmo must surface for the slab + fillet-corner selection too.
|
||||
assert (
|
||||
_run_poll(
|
||||
prefs_on=True,
|
||||
active_is_in_selected=True,
|
||||
len_override=None,
|
||||
active_usage="LAYER3",
|
||||
other_usage=None,
|
||||
other_is_path_connectable=True,
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# _iter_path_connections — IfcRelConnectsPathElements inverse-graph walk
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
# 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.
|
||||
|
||||
"""Pins the contract that ``DumbWallJoiner.merge`` re-hosts openings from
|
||||
the discarded wall to the survivor before the cascade delete tears down
|
||||
``element2.HasOpenings`` and any filling that references them.
|
||||
|
||||
``edit_object_placement`` preserves the opening's world position when the
|
||||
two walls have different placements — a ``PlacementRelTo`` swap alone
|
||||
would shift the opening as the relative offset changes."""
|
||||
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.wall
|
||||
|
||||
|
||||
def _opening_rel(opening_id: int, placement_matrix: np.ndarray):
|
||||
"""Build a stub ``IfcRelVoidsElement`` carrying an opening with a known
|
||||
placement. ``RelatingBuildingElement`` is settable so the test can
|
||||
observe the re-host."""
|
||||
opening = Mock(name=f"opening_{opening_id}")
|
||||
opening.id.return_value = opening_id
|
||||
opening.ObjectPlacement = Mock(name=f"opening_placement_{opening_id}")
|
||||
rel = Mock(name=f"voids_rel_{opening_id}")
|
||||
rel.RelatedOpeningElement = opening
|
||||
rel.RelatingBuildingElement = None
|
||||
return rel, opening, placement_matrix
|
||||
|
||||
|
||||
def _merge_inputs(*, has_openings):
|
||||
"""Stage the minimum wall1 + wall2 + element1 + element2 surface that
|
||||
``DumbWallJoiner.merge`` reads. The reference lines and placements are
|
||||
rigged so the collinearity guard passes and execution reaches the
|
||||
opening-migration loop."""
|
||||
wall1 = Mock(name="wall1")
|
||||
wall2 = Mock(name="wall2")
|
||||
element1 = Mock(name="element1")
|
||||
element2 = Mock(name="element2")
|
||||
element1.ObjectPlacement = Mock(name="elem1_placement")
|
||||
element2.ObjectPlacement = Mock(name="elem2_placement")
|
||||
element1.ConnectedTo = []
|
||||
element1.ConnectedFrom = []
|
||||
element2.ConnectedTo = []
|
||||
element2.ConnectedFrom = []
|
||||
element2.HasOpenings = list(has_openings)
|
||||
return wall1, wall2, element1, element2
|
||||
|
||||
|
||||
def _run_merge(wall1, wall2, element1, element2, opening_matrices, captured_edit_calls):
|
||||
"""Invoke ``DumbWallJoiner().merge`` against the staged inputs with
|
||||
every heavy IFC / Blender side effect patched out. ``opening_matrices``
|
||||
maps an opening id to its captured world matrix; ``captured_edit_calls``
|
||||
is appended to whenever ``edit_object_placement`` fires."""
|
||||
from bonsai.bim.module.model.wall import DumbWallJoiner
|
||||
|
||||
def fake_get_local_placement(placement):
|
||||
for rel in element2.HasOpenings:
|
||||
if rel.RelatedOpeningElement.ObjectPlacement is placement:
|
||||
return opening_matrices[rel.RelatedOpeningElement.id()]
|
||||
return np.eye(4)
|
||||
|
||||
def fake_get_entity(obj):
|
||||
return {wall1: element1, wall2: element2}[obj]
|
||||
|
||||
def fake_edit_object_placement(ifc_file, *, product, matrix, is_si, should_transform_children):
|
||||
captured_edit_calls.append(
|
||||
{
|
||||
"product": product,
|
||||
"matrix": matrix,
|
||||
"is_si": is_si,
|
||||
"should_transform_children": should_transform_children,
|
||||
}
|
||||
)
|
||||
|
||||
p1 = np.array([0.0, 0.0])
|
||||
p2 = np.array([5.0, 0.0])
|
||||
p3 = np.array([5.0, 0.0])
|
||||
p4 = np.array([10.0, 0.0])
|
||||
|
||||
with (
|
||||
patch("bonsai.bim.module.model.wall.tool.Ifc.is_moved", return_value=False),
|
||||
patch("bonsai.bim.module.model.wall.tool.Ifc.get_entity", side_effect=fake_get_entity),
|
||||
patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=MagicMock(name="ifc_file")),
|
||||
patch(
|
||||
"bonsai.bim.module.model.wall.ifcopenshell.util.representation.get_reference_line",
|
||||
side_effect=lambda elem: (p1, p2) if elem is element1 else (p3, p4),
|
||||
),
|
||||
patch(
|
||||
"bonsai.bim.module.model.wall.ifcopenshell.util.placement.get_local_placement",
|
||||
side_effect=fake_get_local_placement,
|
||||
),
|
||||
patch(
|
||||
"bonsai.bim.module.model.wall.ifcopenshell.api.geometry.edit_object_placement",
|
||||
side_effect=fake_edit_object_placement,
|
||||
),
|
||||
patch("bonsai.bim.module.model.wall.tool.Model.recreate_wall"),
|
||||
patch("bonsai.bim.module.model.wall.tool.Geometry.delete_ifc_object") as delete_ifc_object,
|
||||
patch("bonsai.bim.module.model.wall.DumbWallJoiner.set_axis"),
|
||||
):
|
||||
DumbWallJoiner().merge(wall1, wall2)
|
||||
return delete_ifc_object
|
||||
|
||||
|
||||
def test_merge_rehosts_each_opening_to_survivor():
|
||||
"""Every void rel on the discarded wall is rebound to the survivor so
|
||||
the cascade delete doesn't take them down with element2."""
|
||||
matrix_a = np.eye(4)
|
||||
matrix_a[0, 3] = 1.0
|
||||
matrix_b = np.eye(4)
|
||||
matrix_b[0, 3] = 3.0
|
||||
rel_a, opening_a, _ = _opening_rel(opening_id=101, placement_matrix=matrix_a)
|
||||
rel_b, opening_b, _ = _opening_rel(opening_id=102, placement_matrix=matrix_b)
|
||||
wall1, wall2, element1, element2 = _merge_inputs(has_openings=[rel_a, rel_b])
|
||||
|
||||
_run_merge(
|
||||
wall1,
|
||||
wall2,
|
||||
element1,
|
||||
element2,
|
||||
opening_matrices={101: matrix_a, 102: matrix_b},
|
||||
captured_edit_calls=[],
|
||||
)
|
||||
|
||||
assert rel_a.RelatingBuildingElement is element1
|
||||
assert rel_b.RelatingBuildingElement is element1
|
||||
|
||||
|
||||
def test_merge_preserves_opening_world_placement():
|
||||
"""``edit_object_placement`` re-applies the opening's pre-merge world
|
||||
matrix so the void doesn't drift when the two walls have different
|
||||
placements — the regression a ``PlacementRelTo`` swap alone would
|
||||
fail."""
|
||||
matrix = np.eye(4)
|
||||
matrix[:3, 3] = (2.5, 0.0, 0.0)
|
||||
rel, opening, _ = _opening_rel(opening_id=42, placement_matrix=matrix)
|
||||
wall1, wall2, element1, element2 = _merge_inputs(has_openings=[rel])
|
||||
captured: list[dict] = []
|
||||
|
||||
_run_merge(
|
||||
wall1,
|
||||
wall2,
|
||||
element1,
|
||||
element2,
|
||||
opening_matrices={42: matrix},
|
||||
captured_edit_calls=captured,
|
||||
)
|
||||
|
||||
edit_calls_for_opening = [call for call in captured if call["product"] is opening]
|
||||
assert len(edit_calls_for_opening) == 1
|
||||
np.testing.assert_allclose(edit_calls_for_opening[0]["matrix"], matrix, atol=1e-9)
|
||||
assert edit_calls_for_opening[0]["should_transform_children"] is False
|
||||
|
||||
|
||||
def test_merge_rehosts_before_delete():
|
||||
"""Order matters: ``delete_ifc_object`` cascades through
|
||||
``element2.HasOpenings`` and would destroy the void if it ran before
|
||||
the re-host. Assert the survivor was rebound before delete fires."""
|
||||
matrix = np.eye(4)
|
||||
rel, opening, _ = _opening_rel(opening_id=7, placement_matrix=matrix)
|
||||
wall1, wall2, element1, element2 = _merge_inputs(has_openings=[rel])
|
||||
|
||||
delete_ifc_object = _run_merge(
|
||||
wall1,
|
||||
wall2,
|
||||
element1,
|
||||
element2,
|
||||
opening_matrices={7: matrix},
|
||||
captured_edit_calls=[],
|
||||
)
|
||||
|
||||
assert rel.RelatingBuildingElement is element1
|
||||
delete_ifc_object.assert_called_once_with(wall2)
|
||||
|
||||
|
||||
def test_merge_skips_non_path_connection_rels():
|
||||
"""``ConnectedTo`` / ``ConnectedFrom`` carry both
|
||||
``IfcRelConnectsPathElements`` (wall-wall joins) AND
|
||||
``IfcRelConnectsElements`` (slab underside clips). Only the path rels
|
||||
expose ``RelatingConnectionType`` / ``RelatedConnectionType``;
|
||||
accessing those attributes on an element rel raises ``AttributeError``.
|
||||
The migration loop must filter on the rel class so a wall with a slab
|
||||
clip can still be merged."""
|
||||
from bonsai.bim.module.model.wall import DumbWallJoiner
|
||||
|
||||
wall1, wall2, element1, element2 = _merge_inputs(has_openings=[])
|
||||
|
||||
path_rel = Mock(name="path_rel")
|
||||
path_rel.is_a = lambda c: c == "IfcRelConnectsPathElements"
|
||||
path_rel.RelatingElement = Mock(name="rel_relating")
|
||||
path_rel.RelatedElement = Mock(name="rel_related")
|
||||
path_rel.RelatingConnectionType = "ATSTART"
|
||||
path_rel.RelatedConnectionType = "ATEND"
|
||||
|
||||
slab_rel = Mock(name="slab_rel")
|
||||
slab_rel.is_a = lambda c: c == "IfcRelConnectsElements"
|
||||
slab_rel.Description = "TOP"
|
||||
# ``RelatedConnectionType`` is what the merge loop reads from
|
||||
# ``ConnectedFrom``; the real ``IfcRelConnectsElements`` schema has
|
||||
# no such attribute, so wire the stub to raise like ifcopenshell does.
|
||||
type(slab_rel).RelatedConnectionType = property(
|
||||
lambda self: (_ for _ in ()).throw(AttributeError("RelatedConnectionType"))
|
||||
)
|
||||
type(slab_rel).RelatingConnectionType = property(
|
||||
lambda self: (_ for _ in ()).throw(AttributeError("RelatingConnectionType"))
|
||||
)
|
||||
element2.ConnectedFrom = [slab_rel, path_rel]
|
||||
|
||||
captured_disconnects = []
|
||||
captured_connects = []
|
||||
|
||||
def fake_disconnect_path(*args, **kwargs):
|
||||
captured_disconnects.append(kwargs)
|
||||
|
||||
def fake_connect_path(*args, **kwargs):
|
||||
captured_connects.append(kwargs)
|
||||
|
||||
p1 = np.array([0.0, 0.0])
|
||||
p2 = np.array([5.0, 0.0])
|
||||
p3 = np.array([5.0, 0.0])
|
||||
p4 = np.array([10.0, 0.0])
|
||||
|
||||
def fake_get_entity(obj):
|
||||
return {wall1: element1, wall2: element2}[obj]
|
||||
|
||||
with (
|
||||
patch("bonsai.bim.module.model.wall.tool.Ifc.is_moved", return_value=False),
|
||||
patch("bonsai.bim.module.model.wall.tool.Ifc.get_entity", side_effect=fake_get_entity),
|
||||
patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=MagicMock(name="ifc_file")),
|
||||
patch(
|
||||
"bonsai.bim.module.model.wall.ifcopenshell.util.representation.get_reference_line",
|
||||
side_effect=lambda elem: (p1, p2) if elem is element1 else (p3, p4),
|
||||
),
|
||||
patch(
|
||||
"bonsai.bim.module.model.wall.ifcopenshell.util.placement.get_local_placement",
|
||||
return_value=np.eye(4),
|
||||
),
|
||||
patch(
|
||||
"bonsai.bim.module.model.wall.ifcopenshell.api.geometry.disconnect_path",
|
||||
side_effect=fake_disconnect_path,
|
||||
),
|
||||
patch(
|
||||
"bonsai.bim.module.model.wall.ifcopenshell.api.geometry.connect_path",
|
||||
side_effect=fake_connect_path,
|
||||
),
|
||||
patch("bonsai.bim.module.model.wall.tool.Model.recreate_wall"),
|
||||
patch("bonsai.bim.module.model.wall.tool.Geometry.delete_ifc_object"),
|
||||
patch("bonsai.bim.module.model.wall.DumbWallJoiner.set_axis"),
|
||||
):
|
||||
# The bug pre-fix: the slab rel's ``RelatedConnectionType`` access
|
||||
# raised AttributeError and crashed merge. With the filter, this
|
||||
# call must complete cleanly.
|
||||
DumbWallJoiner().merge(wall1, wall2)
|
||||
|
||||
assert len(captured_disconnects) == 1
|
||||
assert len(captured_connects) == 1
|
||||
assert captured_disconnects[0]["connection_type"] == "ATEND"
|
||||
@@ -0,0 +1,64 @@
|
||||
# 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.
|
||||
|
||||
"""Pins the contract that the three dimension-mutating wall operators —
|
||||
``bim.change_extrusion_depth``, ``bim.change_extrusion_x_angle``,
|
||||
``bim.change_layer_length`` — re-prime ``BIMWallProperties`` from the
|
||||
post-mutation IFC at the end of ``_execute``.
|
||||
|
||||
Without the resync, ``props.height`` / ``props.length`` / ``props.x_angle``
|
||||
stay at their pre-mutation values; gizmo icons that position from
|
||||
``props.height`` then sit at the old elevation even though the wall mesh
|
||||
shows the new one."""
|
||||
|
||||
import inspect
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.wall
|
||||
|
||||
|
||||
def _execute_source(operator_cls):
|
||||
return inspect.getsource(operator_cls._execute)
|
||||
|
||||
|
||||
def test_change_extrusion_depth_resyncs_wall_props():
|
||||
"""Height mutation must re-prime ``BIMWallProperties.height`` so
|
||||
gizmo icons positioned from ``props.height`` track the post-mutation
|
||||
wall top in the same redraw."""
|
||||
from bonsai.bim.module.model.wall import ChangeExtrusionDepth
|
||||
|
||||
assert "_resync_walls_after_mutation" in _execute_source(ChangeExtrusionDepth)
|
||||
|
||||
|
||||
def test_change_extrusion_x_angle_resyncs_wall_props():
|
||||
"""Slope mutation must re-prime ``BIMWallProperties.x_angle`` so
|
||||
slope-driven gizmo positions track the new angle."""
|
||||
from bonsai.bim.module.model.wall import ChangeExtrusionXAngle
|
||||
|
||||
assert "_resync_walls_after_mutation" in _execute_source(ChangeExtrusionXAngle)
|
||||
|
||||
|
||||
def test_change_layer_length_resyncs_wall_props():
|
||||
"""Length mutation must re-prime ``BIMWallProperties.length`` so
|
||||
horizontal gizmo X positions track the new axis extent."""
|
||||
from bonsai.bim.module.model.wall import ChangeLayerLength
|
||||
|
||||
assert "_resync_walls_after_mutation" in _execute_source(ChangeLayerLength)
|
||||
@@ -0,0 +1,213 @@
|
||||
# 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 wall-slab connection helpers on tool.Wall.
|
||||
|
||||
Pins the rel-shape contract (IfcRelConnectsElements with Description=="TOP")
|
||||
the underside-extension feature creates, and the icon placement contract the
|
||||
new wall-slab connection gizmo group reads."""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
import bonsai.tool as tool
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
def _rel(klass: str = "IfcRelConnectsElements", description: str = "TOP", relating=None, related=None):
|
||||
rel = Mock()
|
||||
rel.is_a = lambda c: c == klass
|
||||
rel.Description = description
|
||||
rel.RelatingElement = relating
|
||||
rel.RelatedElement = related
|
||||
return rel
|
||||
|
||||
|
||||
def _wall_with_rels(*rels) -> Mock:
|
||||
wall = Mock()
|
||||
wall.ConnectedFrom = list(rels)
|
||||
return wall
|
||||
|
||||
|
||||
def _slab_with_rels(*rels) -> Mock:
|
||||
slab = Mock()
|
||||
slab.ConnectedTo = list(rels)
|
||||
return slab
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# iter_wall_slab_connections — yields (slab, rel) for TOP rels
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_iter_wall_slab_connections_yields_top_rels():
|
||||
slab_a = Mock(name="slab_a")
|
||||
slab_b = Mock(name="slab_b")
|
||||
wall = _wall_with_rels(
|
||||
_rel(relating=slab_a),
|
||||
_rel(relating=slab_b),
|
||||
)
|
||||
|
||||
result = list(tool.Wall.iter_wall_slab_connections(wall))
|
||||
|
||||
assert result == [(slab_a, wall.ConnectedFrom[0]), (slab_b, wall.ConnectedFrom[1])]
|
||||
|
||||
|
||||
def test_iter_wall_slab_connections_skips_non_top_description():
|
||||
"""Only TOP-described rels count; BOTTOM / SIDE / arbitrary strings are
|
||||
skipped so other RelConnectsElements semantics aren't confused with the
|
||||
underside-extension contract."""
|
||||
slab = Mock()
|
||||
wall = _wall_with_rels(
|
||||
_rel(description="BOTTOM", relating=slab),
|
||||
_rel(description="TOP", relating=slab),
|
||||
)
|
||||
|
||||
result = list(tool.Wall.iter_wall_slab_connections(wall))
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0][0] is slab
|
||||
|
||||
|
||||
def test_iter_wall_slab_connections_skips_non_connectselements_rels():
|
||||
"""Path-connections to other walls show up on ConnectedFrom too — the
|
||||
helper must filter on rel class, not just presence."""
|
||||
slab = Mock()
|
||||
wall = _wall_with_rels(
|
||||
_rel(klass="IfcRelConnectsPathElements", relating=slab),
|
||||
_rel(klass="IfcRelConnectsElements", relating=slab),
|
||||
)
|
||||
|
||||
result = list(tool.Wall.iter_wall_slab_connections(wall))
|
||||
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
def test_iter_wall_slab_connections_handles_none_relating():
|
||||
"""A malformed rel with RelatingElement=None is skipped rather than
|
||||
raising — defensive against partially-loaded IFC files."""
|
||||
wall = _wall_with_rels(_rel(relating=None))
|
||||
|
||||
result = list(tool.Wall.iter_wall_slab_connections(wall))
|
||||
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_iter_wall_slab_connections_empty_when_no_connectedfrom():
|
||||
wall = Mock()
|
||||
wall.ConnectedFrom = []
|
||||
|
||||
assert list(tool.Wall.iter_wall_slab_connections(wall)) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# iter_slab_wall_connections — mirror, walks slab.ConnectedTo
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_iter_slab_wall_connections_yields_top_rels():
|
||||
wall_a = Mock()
|
||||
wall_b = Mock()
|
||||
slab = _slab_with_rels(
|
||||
_rel(related=wall_a),
|
||||
_rel(related=wall_b),
|
||||
)
|
||||
|
||||
result = list(tool.Wall.iter_slab_wall_connections(slab))
|
||||
|
||||
assert [w for w, _ in result] == [wall_a, wall_b]
|
||||
|
||||
|
||||
def test_iter_slab_wall_connections_skips_non_top():
|
||||
wall = Mock()
|
||||
slab = _slab_with_rels(
|
||||
_rel(description="BOTTOM", related=wall),
|
||||
_rel(description="TOP", related=wall),
|
||||
)
|
||||
|
||||
result = list(tool.Wall.iter_slab_wall_connections(slab))
|
||||
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# find_wall_slab_rel — locate specific rel between wall + slab
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_find_wall_slab_rel_returns_match():
|
||||
slab_a = Mock(name="slab_a")
|
||||
slab_b = Mock(name="slab_b")
|
||||
rel_a = _rel(relating=slab_a)
|
||||
rel_b = _rel(relating=slab_b)
|
||||
wall = _wall_with_rels(rel_a, rel_b)
|
||||
|
||||
assert tool.Wall.find_wall_slab_rel(wall, slab_b) is rel_b
|
||||
|
||||
|
||||
def test_find_wall_slab_rel_returns_none_when_unconnected():
|
||||
slab_a = Mock(name="slab_a")
|
||||
other_slab = Mock(name="other_slab")
|
||||
wall = _wall_with_rels(_rel(relating=slab_a))
|
||||
|
||||
assert tool.Wall.find_wall_slab_rel(wall, other_slab) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# wall_slab_connection_location_world — icon anchor point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_wall_slab_connection_location_perches_above_wall_top():
|
||||
"""Icon X/Y comes from the wall axis midpoint; Z from the wall's mesh
|
||||
bbox top in world space plus WALL_SLAB_CONNECTION_Z_CLEARANCE so the
|
||||
icon sits above the extend-vertical / slope gizmo at the wall top."""
|
||||
wall_obj = Mock()
|
||||
wall_obj.matrix_world = Matrix.Identity(4)
|
||||
wall_obj.bound_box = [
|
||||
(-0.1, -0.1, 0.0),
|
||||
(0.1, -0.1, 0.0),
|
||||
(-0.1, 0.1, 0.0),
|
||||
(0.1, 0.1, 0.0),
|
||||
(-0.1, -0.1, 3.0),
|
||||
(0.1, -0.1, 3.0),
|
||||
(-0.1, 0.1, 3.0),
|
||||
(0.1, 0.1, 3.0),
|
||||
]
|
||||
slab_obj = Mock()
|
||||
|
||||
ref_line = (Vector((1.0, 0.0, 0.0)), Vector((3.0, 0.0, 0.0)))
|
||||
with patch.object(tool.Wall, "get_world_reference_line", return_value=ref_line):
|
||||
loc = tool.Wall.wall_slab_connection_location_world(wall_obj, slab_obj)
|
||||
|
||||
expected_z = 3.0 + tool.Wall.WALL_SLAB_CONNECTION_Z_CLEARANCE
|
||||
assert loc == Vector((2.0, 0.0, expected_z))
|
||||
|
||||
|
||||
def test_wall_slab_connection_location_returns_none_for_axisless_wall():
|
||||
"""A wall without an IFC Axis representation has no reference line; the
|
||||
helper returns None so callers can skip rather than guess a location."""
|
||||
wall_obj = Mock()
|
||||
slab_obj = Mock()
|
||||
with patch.object(tool.Wall, "get_world_reference_line", return_value=None):
|
||||
assert tool.Wall.wall_slab_connection_location_world(wall_obj, slab_obj) is None
|
||||
@@ -0,0 +1,67 @@
|
||||
# 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.
|
||||
|
||||
"""Pins two contracts in ``DumbWallJoiner.split``'s filled-opening branch:
|
||||
|
||||
1. Side classification reads the opening's axis-projected midpoint, not
|
||||
the filling's ``matrix_world.translation``. The filling origin is
|
||||
flip-fragile — flipping rotates the filler 180° + translates so the
|
||||
bbox stays visually in place, which would mis-classify a flipped door
|
||||
centred over the cut.
|
||||
2. When the void straddles the cut and the filling moves to element2,
|
||||
the void copy for element1 is taken from the ORIGINAL opening (whose
|
||||
``ObjectPlacement`` still references element1), not the rebound
|
||||
``new_opening`` (whose ``PlacementRelTo`` was swapped to element2)."""
|
||||
|
||||
import inspect
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.wall
|
||||
|
||||
|
||||
def _split_source():
|
||||
from bonsai.bim.module.model.wall import DumbWallJoiner
|
||||
|
||||
return inspect.getsource(DumbWallJoiner.split)
|
||||
|
||||
|
||||
def test_side_classification_uses_opening_midpoint_not_filling_origin():
|
||||
"""Side classification must read the opening's axis-projected
|
||||
midpoint, not the filling's world translation — the latter shifts
|
||||
under flipping and would mis-classify a flipped door centred over
|
||||
the cut."""
|
||||
source = _split_source()
|
||||
assert "opening_midpoint" in source
|
||||
assert "filling_obj.matrix_world.translation" not in source
|
||||
|
||||
|
||||
def test_void_copy_reads_from_original_opening_before_remove():
|
||||
"""When the filling moves to element2 and the void straddles the
|
||||
cut, element1's pure-void copy must come from the original opening
|
||||
BEFORE the cleanup that destroys it — the rebound ``new_opening``
|
||||
references element2's frame and would shift the void to element1's
|
||||
origin in element2's local coords."""
|
||||
source = _split_source()
|
||||
branch_start = source.index("if opening_midpoint > cut_percentage:")
|
||||
branch = source[branch_start:]
|
||||
add_idx = branch.index("_add_void_copy(element1, opening)")
|
||||
remove_idx = branch.index("feature.remove_feature(tool.Ifc.get(), feature=opening)")
|
||||
assert add_idx < remove_idx
|
||||
@@ -60,6 +60,13 @@ def collector():
|
||||
prophet.verify()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def connection():
|
||||
prophet = Prophecy(bonsai.core.tool.Connection)
|
||||
yield prophet
|
||||
prophet.verify()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def context():
|
||||
prophet = Prophecy(bonsai.core.tool.Context)
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
# 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.
|
||||
|
||||
"""Dispatch tests for ``core.connection.disconnect_rel``.
|
||||
|
||||
The dispatch is the single source of truth for per-kind cleanup shared by the
|
||||
explicit ``bim.disconnect_elements`` operator and the implicit cascade in
|
||||
``tool.Geometry.delete_ifc_object``. Each kind has one test that pins which
|
||||
helpers must be called; the AST forward-compat guard in
|
||||
``test_connection_forward_compat.py`` then asserts the dispatch table covers
|
||||
every kind ``Connection.find_rels`` can emit.
|
||||
|
||||
Uses ``unittest.mock`` directly (rather than the Prophecy fixtures) because
|
||||
the dispatch passes IFC rel entities with attribute access (``rel.RelatingElement``)
|
||||
that Prophecy's JSON call recorder can't serialize.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import bonsai.core.connection as subject
|
||||
|
||||
|
||||
def _rel(relating="slab", related="wall"):
|
||||
return SimpleNamespace(RelatingElement=relating, RelatedElement=related)
|
||||
|
||||
|
||||
def _ifc_with_objects(mapping):
|
||||
ifc = Mock()
|
||||
ifc.get_object.side_effect = lambda e: mapping.get(e)
|
||||
ifc.run = Mock()
|
||||
return ifc
|
||||
|
||||
|
||||
class TestDisconnectRelPath:
|
||||
def test_removes_connection_and_recreates_both_walls(self):
|
||||
ifc = _ifc_with_objects({"elem_a": "obj_a", "elem_b": "obj_b"})
|
||||
geometry = Mock()
|
||||
model = Mock()
|
||||
connection = Mock()
|
||||
|
||||
with patch("bonsai.core.connection.bonsai.core.geometry.remove_connection") as remove:
|
||||
subject.disconnect_rel(
|
||||
ifc, geometry, model, connection,
|
||||
rel="rel", kind="path", elem="elem_a", partner="elem_b",
|
||||
)
|
||||
|
||||
remove.assert_called_once_with(geometry, connection="rel")
|
||||
model.recreate_wall.assert_any_call("elem_a", "obj_a")
|
||||
model.recreate_wall.assert_any_call("elem_b", "obj_b")
|
||||
assert model.recreate_wall.call_count == 2
|
||||
|
||||
def test_skip_elem_recreate_suppresses_elem_side(self):
|
||||
"""Cascade case: elem is being deleted — don't recreate it."""
|
||||
ifc = _ifc_with_objects({"elem": "elem_obj", "partner": "partner_obj"})
|
||||
geometry = Mock()
|
||||
model = Mock()
|
||||
connection = Mock()
|
||||
|
||||
with patch("bonsai.core.connection.bonsai.core.geometry.remove_connection"):
|
||||
subject.disconnect_rel(
|
||||
ifc, geometry, model, connection,
|
||||
rel="rel", kind="path", elem="elem", partner="partner",
|
||||
skip_elem_recreate=True,
|
||||
)
|
||||
|
||||
model.recreate_wall.assert_called_once_with("partner", "partner_obj")
|
||||
|
||||
def test_skip_partner_recreate_suppresses_partner_side(self):
|
||||
ifc = _ifc_with_objects({"elem": "elem_obj", "partner": "partner_obj"})
|
||||
geometry = Mock()
|
||||
model = Mock()
|
||||
connection = Mock()
|
||||
|
||||
with patch("bonsai.core.connection.bonsai.core.geometry.remove_connection"):
|
||||
subject.disconnect_rel(
|
||||
ifc, geometry, model, connection,
|
||||
rel="rel", kind="path", elem="elem", partner="partner",
|
||||
skip_partner_recreate=True,
|
||||
)
|
||||
|
||||
model.recreate_wall.assert_called_once_with("elem", "elem_obj")
|
||||
|
||||
def test_both_skips_means_only_remove_rel(self):
|
||||
ifc = _ifc_with_objects({})
|
||||
geometry = Mock()
|
||||
model = Mock()
|
||||
connection = Mock()
|
||||
|
||||
with patch("bonsai.core.connection.bonsai.core.geometry.remove_connection") as remove:
|
||||
subject.disconnect_rel(
|
||||
ifc, geometry, model, connection,
|
||||
rel="rel", kind="path", elem="elem", partner="partner",
|
||||
skip_elem_recreate=True,
|
||||
skip_partner_recreate=True,
|
||||
)
|
||||
|
||||
remove.assert_called_once()
|
||||
model.recreate_wall.assert_not_called()
|
||||
|
||||
|
||||
class TestDisconnectRelElementTop:
|
||||
def test_disconnects_then_regenerates_wall(self):
|
||||
"""Operator case (no skip flags): both sides survive, so the wall gets
|
||||
re-clipped against currently-connected slabs."""
|
||||
rel = _rel()
|
||||
ifc = _ifc_with_objects({"wall": "wall_obj"})
|
||||
geometry = Mock()
|
||||
model = Mock()
|
||||
connection = Mock()
|
||||
connection.orient_element_top.return_value = ("wall", "slab")
|
||||
|
||||
with patch("bonsai.core.connection.regenerate_wall_to_underside") as regen:
|
||||
subject.disconnect_rel(
|
||||
ifc, geometry, model, connection,
|
||||
rel=rel, kind="element-top", elem="elem", partner="partner",
|
||||
)
|
||||
|
||||
ifc.run.assert_called_once_with(
|
||||
"geometry.disconnect_element", relating_element="slab", related_element="wall"
|
||||
)
|
||||
regen.assert_called_once_with(ifc, geometry, model, ["wall_obj"])
|
||||
|
||||
def test_slab_delete_cascade_still_regenerates_wall(self):
|
||||
"""When slab is being deleted (elem=slab), wall survives and must
|
||||
re-clip against remaining connections — the cascade's main purpose."""
|
||||
rel = _rel()
|
||||
ifc = _ifc_with_objects({"wall": "wall_obj"})
|
||||
connection = Mock()
|
||||
connection.orient_element_top.return_value = ("wall", "slab")
|
||||
|
||||
with patch("bonsai.core.connection.regenerate_wall_to_underside") as regen:
|
||||
subject.disconnect_rel(
|
||||
ifc, Mock(), Mock(), connection,
|
||||
rel=rel, kind="element-top", elem="slab", partner="wall",
|
||||
skip_elem_recreate=True, # slab is being deleted
|
||||
)
|
||||
|
||||
regen.assert_called_once()
|
||||
|
||||
def test_wall_delete_cascade_skips_wall_regen(self):
|
||||
"""When the wall itself is being deleted, regenerating its body moments
|
||||
before remove_product wipes it is wasted work — skip."""
|
||||
rel = _rel()
|
||||
ifc = _ifc_with_objects({"wall": "wall_obj"})
|
||||
connection = Mock()
|
||||
connection.orient_element_top.return_value = ("wall", "slab")
|
||||
|
||||
with patch("bonsai.core.connection.regenerate_wall_to_underside") as regen:
|
||||
subject.disconnect_rel(
|
||||
ifc, Mock(), Mock(), connection,
|
||||
rel=rel, kind="element-top", elem="wall", partner="slab",
|
||||
skip_elem_recreate=True, # wall is being deleted
|
||||
)
|
||||
|
||||
regen.assert_not_called()
|
||||
ifc.run.assert_called_once() # rel still removed
|
||||
|
||||
def test_both_in_batch_skips_wall_regen(self):
|
||||
"""Batch delete of both endpoints, processing slab first: partner (wall)
|
||||
also queued for deletion → skip wall regen."""
|
||||
rel = _rel()
|
||||
ifc = _ifc_with_objects({"wall": "wall_obj"})
|
||||
connection = Mock()
|
||||
connection.orient_element_top.return_value = ("wall", "slab")
|
||||
|
||||
with patch("bonsai.core.connection.regenerate_wall_to_underside") as regen:
|
||||
subject.disconnect_rel(
|
||||
ifc, Mock(), Mock(), connection,
|
||||
rel=rel, kind="element-top", elem="slab", partner="wall",
|
||||
skip_elem_recreate=True,
|
||||
skip_partner_recreate=True, # wall also in batch
|
||||
)
|
||||
|
||||
regen.assert_not_called()
|
||||
|
||||
|
||||
class TestDisconnectRelElement:
|
||||
def test_just_removes_the_rel(self):
|
||||
rel = _rel(relating="A", related="B")
|
||||
ifc = Mock()
|
||||
|
||||
subject.disconnect_rel(
|
||||
ifc, Mock(), Mock(), Mock(),
|
||||
rel=rel, kind="element", elem="elem_a", partner="elem_b",
|
||||
)
|
||||
|
||||
ifc.run.assert_called_once_with(
|
||||
"geometry.disconnect_element", relating_element="A", related_element="B"
|
||||
)
|
||||
|
||||
|
||||
class TestDisconnectRelUnknownKind:
|
||||
def test_raises_value_error(self):
|
||||
with pytest.raises(ValueError, match="Unknown rel kind"):
|
||||
subject.disconnect_rel(
|
||||
Mock(), Mock(), Mock(), Mock(),
|
||||
rel="rel", kind="bogus", elem="a", partner="b",
|
||||
)
|
||||
@@ -183,3 +183,16 @@ class TestNpFrombufferLegacy(NewFile):
|
||||
result = subject.np_frombuffer_legacy(data, n)
|
||||
assert result.shape == (n,)
|
||||
np.testing.assert_allclose(result, np.arange(n))
|
||||
|
||||
|
||||
class TestGetObjectFromGuidMissing(NewFile):
|
||||
"""``get_object_from_guid`` must honour its ``Optional[Object]`` return
|
||||
contract: a GUID that does not resolve in the current IFC file yields
|
||||
``None``, not a ``RuntimeError``. Callers iterate stored GUID lists
|
||||
(array children, library refs, …) and rely on the falsy return to
|
||||
skip stale entries."""
|
||||
|
||||
def test_returns_none_when_guid_not_in_file(self):
|
||||
bpy.ops.bim.create_project()
|
||||
assert tool.Ifc.get() is not None
|
||||
assert subject.get_object_from_guid("3iyt7r$Hf4_hQYNhBIDJI4") is None
|
||||
|
||||
@@ -16,7 +16,9 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from mathutils import Vector
|
||||
import math
|
||||
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
from bonsai.tool.cad import Cad as subject
|
||||
from test.bim.bootstrap import NewFile
|
||||
@@ -88,3 +90,87 @@ class TestClosestPoints(NewFile):
|
||||
edge1 = (V(0, 0, 0), V(0, 0, 0))
|
||||
edge2 = (V(1, 0, 1), V(2, 0, 2))
|
||||
assert subject.closest_points(edge1, edge2)[0] == (edge1[0], edge2[0])
|
||||
|
||||
|
||||
class TestObbWorldClipPlanes(NewFile):
|
||||
def test_unit_box_at_origin_returns_axis_aligned_planes(self):
|
||||
planes = subject.obb_world_clip_planes(
|
||||
V(0, 0, 0),
|
||||
(V(1, 0, 0), V(0, 1, 0), V(0, 0, 1)),
|
||||
V(1, 1, 1),
|
||||
)
|
||||
assert planes[0] == (-1.0, 0.0, 0.0, 1.0)
|
||||
assert planes[1] == (1.0, 0.0, 0.0, 1.0)
|
||||
assert planes[2] == (0.0, -1.0, 0.0, 1.0)
|
||||
assert planes[3] == (0.0, 1.0, 0.0, 1.0)
|
||||
assert planes[4] == (0.0, 0.0, -1.0, 1.0)
|
||||
assert planes[5] == (0.0, 0.0, 1.0, 1.0)
|
||||
|
||||
def test_center_is_inside_all_planes(self):
|
||||
center = V(5, -3, 2)
|
||||
planes = subject.obb_world_clip_planes(
|
||||
center,
|
||||
(V(1, 0, 0), V(0, 1, 0), V(0, 0, 1)),
|
||||
V(2, 1, 0.5),
|
||||
)
|
||||
assert subject.point_is_inside_clip_planes(planes, center)
|
||||
|
||||
def test_point_just_outside_positive_x_face_rejected(self):
|
||||
planes = subject.obb_world_clip_planes(
|
||||
V(0, 0, 0),
|
||||
(V(1, 0, 0), V(0, 1, 0), V(0, 0, 1)),
|
||||
V(1, 1, 1),
|
||||
)
|
||||
assert subject.point_is_inside_clip_planes(planes, V(0.5, 0, 0))
|
||||
assert not subject.point_is_inside_clip_planes(planes, V(1.5, 0, 0))
|
||||
|
||||
def test_rotated_obb_clips_along_rotated_axes(self):
|
||||
s = math.sin(math.radians(45))
|
||||
planes = subject.obb_world_clip_planes(
|
||||
V(0, 0, 0),
|
||||
(V(s, s, 0), V(-s, s, 0), V(0, 0, 1)),
|
||||
V(1, 1, 1),
|
||||
)
|
||||
assert subject.point_is_inside_clip_planes(planes, V(1.2, 0, 0))
|
||||
assert not subject.point_is_inside_clip_planes(planes, V(1.42, 0, 0))
|
||||
|
||||
def test_zero_extent_axis_does_not_raise(self):
|
||||
planes = subject.obb_world_clip_planes(
|
||||
V(0, 0, 0),
|
||||
(V(1, 0, 0), V(0, 1, 0), V(0, 0, 1)),
|
||||
V(1, 1, 0),
|
||||
)
|
||||
assert subject.point_is_inside_clip_planes(planes, V(0, 0, 0))
|
||||
|
||||
|
||||
class TestObbClipPlanesFromMatrix(NewFile):
|
||||
def test_identity_matches_unit_box(self):
|
||||
planes = subject.obb_clip_planes_from_matrix(Matrix.Identity(4))
|
||||
assert subject.point_is_inside_clip_planes(planes, V(0, 0, 0))
|
||||
assert not subject.point_is_inside_clip_planes(planes, V(2, 0, 0))
|
||||
assert not subject.point_is_inside_clip_planes(planes, V(0, -2, 0))
|
||||
|
||||
def test_translated_host_shifts_clip_region(self):
|
||||
translated = Matrix.Translation(V(10, 0, 0))
|
||||
planes = subject.obb_clip_planes_from_matrix(translated)
|
||||
assert not subject.point_is_inside_clip_planes(planes, V(0, 0, 0))
|
||||
assert subject.point_is_inside_clip_planes(planes, V(10, 0, 0))
|
||||
|
||||
def test_z_rotation_rotates_box(self):
|
||||
rot = Matrix.Rotation(math.radians(45), 4, "Z")
|
||||
planes = subject.obb_clip_planes_from_matrix(rot)
|
||||
assert subject.point_is_inside_clip_planes(planes, V(1.2, 0, 0))
|
||||
assert not subject.point_is_inside_clip_planes(planes, V(1.42, 0, 0))
|
||||
|
||||
def test_host_scale_scales_box_extents(self):
|
||||
scaled = Matrix.Diagonal((2.0, 2.0, 2.0, 1.0))
|
||||
planes = subject.obb_clip_planes_from_matrix(scaled)
|
||||
assert subject.point_is_inside_clip_planes(planes, V(1.9, 0, 0))
|
||||
assert not subject.point_is_inside_clip_planes(planes, V(2.1, 0, 0))
|
||||
|
||||
def test_non_uniform_scale_axis_independent(self):
|
||||
scaled = Matrix.Diagonal((3.0, 1.0, 1.0, 1.0))
|
||||
planes = subject.obb_clip_planes_from_matrix(scaled)
|
||||
assert subject.point_is_inside_clip_planes(planes, V(2.9, 0, 0))
|
||||
assert not subject.point_is_inside_clip_planes(planes, V(3.1, 0, 0))
|
||||
assert not subject.point_is_inside_clip_planes(planes, V(0, 1.1, 0))
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
# 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 contract: ``core.connection.disconnect_rel`` must have a
|
||||
branch for every rel ``kind`` emitted by ``tool.connection.Connection`` lookups.
|
||||
|
||||
Adding a new rel kind (e.g. ``"void"``, ``"fill"``, ``"interferes"``) to
|
||||
``find_rels`` / ``find_rels_for_element`` without extending ``disconnect_rel``
|
||||
would silently regress the disconnect operator and the cascade-on-delete: a new
|
||||
kind would reach the dispatch, hit the ``raise ValueError("Unknown rel kind")``
|
||||
fallback, and either crash the operator or leave the cascade half-done. This
|
||||
guard makes the symmetry mandatory at test time."""
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
BONSAI_ROOT = Path(__file__).parent.parent.parent / "bonsai"
|
||||
TOOL_CONNECTION = BONSAI_ROOT / "tool" / "connection.py"
|
||||
CORE_CONNECTION = BONSAI_ROOT / "core" / "connection.py"
|
||||
|
||||
|
||||
def _find_function(tree: ast.Module, name: str) -> ast.FunctionDef:
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.FunctionDef) and node.name == name:
|
||||
return node
|
||||
raise AssertionError(f"Function {name!r} not found")
|
||||
|
||||
|
||||
def _find_method(tree: ast.Module, class_name: str, method_name: str) -> ast.FunctionDef:
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ClassDef) and node.name == class_name:
|
||||
for child in node.body:
|
||||
if isinstance(child, ast.FunctionDef) and child.name == method_name:
|
||||
return child
|
||||
raise AssertionError(f"Method {class_name}.{method_name} not found")
|
||||
|
||||
|
||||
def _kinds_emitted_by(method: ast.FunctionDef) -> set[str]:
|
||||
"""Extract every kind label this method emits.
|
||||
|
||||
Looks at exactly two narrow patterns to avoid false positives from
|
||||
docstrings or type-annotation strings:
|
||||
|
||||
- ``_record(rel, "<kind>", …)`` — positional string at index 1, the
|
||||
conventional emit shape in ``find_rels`` / ``find_rels_for_element``.
|
||||
- ``kind = "<a>" if … else "<b>"`` and chained variants — string
|
||||
literals on either branch of an ``ast.IfExp`` assigned to ``kind``.
|
||||
"""
|
||||
kinds: set[str] = set()
|
||||
for node in ast.walk(method):
|
||||
if isinstance(node, ast.Call):
|
||||
func = node.func
|
||||
if isinstance(func, ast.Name) and func.id == "_record" and len(node.args) >= 2:
|
||||
arg = node.args[1]
|
||||
if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
|
||||
kinds.add(arg.value)
|
||||
elif isinstance(arg, ast.IfExp):
|
||||
for branch in (arg.body, arg.orelse):
|
||||
if isinstance(branch, ast.Constant) and isinstance(branch.value, str):
|
||||
kinds.add(branch.value)
|
||||
elif isinstance(node, ast.Assign):
|
||||
targets = [t for t in node.targets if isinstance(t, ast.Name) and t.id == "kind"]
|
||||
if not targets or not isinstance(node.value, ast.IfExp):
|
||||
continue
|
||||
for branch in (node.value.body, node.value.orelse):
|
||||
if isinstance(branch, ast.Constant) and isinstance(branch.value, str):
|
||||
kinds.add(branch.value)
|
||||
return kinds
|
||||
|
||||
|
||||
def _kind_branches_in_disconnect_rel(tree: ast.Module) -> set[str]:
|
||||
"""Return every kind matched by ``disconnect_rel``'s ``kind == "…"`` branches."""
|
||||
fn = _find_function(tree, "disconnect_rel")
|
||||
kinds: set[str] = set()
|
||||
for node in ast.walk(fn):
|
||||
if isinstance(node, ast.Compare) and len(node.ops) == 1 and isinstance(node.ops[0], ast.Eq):
|
||||
left = node.left
|
||||
right = node.comparators[0]
|
||||
if isinstance(left, ast.Name) and left.id == "kind":
|
||||
if isinstance(right, ast.Constant) and isinstance(right.value, str):
|
||||
kinds.add(right.value)
|
||||
return kinds
|
||||
|
||||
|
||||
def test_disconnect_rel_handles_every_kind_emitted_by_connection_lookups() -> None:
|
||||
tool_tree = ast.parse(TOOL_CONNECTION.read_text(encoding="utf-8"))
|
||||
core_tree = ast.parse(CORE_CONNECTION.read_text(encoding="utf-8"))
|
||||
|
||||
emitted = _kinds_emitted_by(_find_method(tool_tree, "Connection", "find_rels")) | _kinds_emitted_by(
|
||||
_find_method(tool_tree, "Connection", "find_rels_for_element")
|
||||
)
|
||||
handled = _kind_branches_in_disconnect_rel(core_tree)
|
||||
|
||||
assert emitted, "Sanity check: no kinds extracted — emit pattern may have changed"
|
||||
|
||||
missing = emitted - handled
|
||||
assert not missing, (
|
||||
f"core.connection.disconnect_rel is missing branches for kinds {missing}. "
|
||||
f"Every kind returned by Connection.find_rels / find_rels_for_element "
|
||||
f"must have a matching if/elif branch in the dispatch."
|
||||
)
|
||||
@@ -135,6 +135,37 @@ class TestGetRepresentationData(NewFile):
|
||||
assert subject.get_representation_data(representation) == data
|
||||
|
||||
|
||||
class TestGetActiveRepresentation(NewFile):
|
||||
def test_returns_representation_for_live_id(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
representation = ifc.createIfcShapeRepresentation()
|
||||
mesh = bpy.data.meshes.new("Mesh")
|
||||
obj = bpy.data.objects.new("Object", mesh)
|
||||
tool.Geometry.get_mesh_props(mesh).ifc_definition_id = representation.id()
|
||||
assert subject.get_active_representation(obj) == representation
|
||||
|
||||
def test_returns_none_when_mesh_has_no_id(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
obj = bpy.data.objects.new("Object", bpy.data.meshes.new("Mesh"))
|
||||
assert subject.get_active_representation(obj) is None
|
||||
|
||||
def test_returns_none_when_id_is_stale(self):
|
||||
"""A representation rebuild can free the old entity while obj.data
|
||||
still tracks its id. Returning ``None`` keeps every UI redraw alive
|
||||
instead of spamming ``RuntimeError`` from the by_id lookup."""
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
representation = ifc.createIfcShapeRepresentation()
|
||||
mesh = bpy.data.meshes.new("Mesh")
|
||||
obj = bpy.data.objects.new("Object", mesh)
|
||||
stale_id = representation.id()
|
||||
tool.Geometry.get_mesh_props(mesh).ifc_definition_id = stale_id
|
||||
ifc.remove(representation)
|
||||
assert subject.get_active_representation(obj) is None
|
||||
|
||||
|
||||
class TestGetRepresentationId(NewFile):
|
||||
def test_run(self):
|
||||
ifc = ifcopenshell.file()
|
||||
|
||||
@@ -672,6 +672,30 @@ class TestUsingArrays(NewFile):
|
||||
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
|
||||
assert pset is None, (obj, pset)
|
||||
|
||||
def test_remove_array_tolerates_stale_child_guid(self):
|
||||
"""``bim.remove_array`` and the underlying ``regenerate_array`` must
|
||||
survive a child GUID in ``BBIM_Array.Data`` that no longer resolves
|
||||
in the file. Real-world IFC files can carry dangling array refs
|
||||
from external edits — the remove path is meant to delete those
|
||||
children, so an already-missing entity is the desired terminal
|
||||
state, not a fatal error."""
|
||||
self.setup_array()
|
||||
parent_obj = bpy.context.active_object
|
||||
parent_element = tool.Ifc.get_entity(parent_obj)
|
||||
ifc_file = tool.Ifc.get()
|
||||
|
||||
pset = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array")
|
||||
data = json.loads(pset["Data"])
|
||||
data[0]["children"].append("3iyt7r$Hf4_hQYNhBIDJI4")
|
||||
ifcopenshell.api.pset.edit_pset(
|
||||
ifc_file,
|
||||
pset=ifc_file.by_id(pset["id"]),
|
||||
properties={"Data": json.dumps(data)},
|
||||
)
|
||||
|
||||
bpy.ops.bim.remove_array(item=0)
|
||||
assert ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array") is None
|
||||
|
||||
|
||||
class TestApplyIfcMaterialChanges(NewFile):
|
||||
def get_used_styles(self, obj: bpy.types.Object) -> set[ifcopenshell.entity_instance]:
|
||||
|
||||
@@ -25,8 +25,6 @@
|
||||
|
||||
#include "ifcparse/IfcFile.h"
|
||||
#include "ifcparse/IfcLogger.h"
|
||||
#include INCLUDE_SCHEMA(ifcparse, IfcSchema)
|
||||
#include INCLUDE_SCHEMA_DEFINITIONS(ifcparse, IfcSchema)
|
||||
|
||||
#include <boost/preprocessor/stringize.hpp>
|
||||
#include <boost/preprocessor/seq/for_each.hpp>
|
||||
@@ -43,6 +41,30 @@
|
||||
static_assert(false, "A boost preprocessor sequence of schema identifiers is needed for this file to compile.");
|
||||
#endif
|
||||
|
||||
// A macro cannot expand to an include directive, so unroll enough includes for
|
||||
// the maximum number of schemas supported by the build configuration.
|
||||
#define INCLUDE_SCHEMA_N(n) \
|
||||
BOOST_PP_IIF(BOOST_PP_GREATER(BOOST_PP_SEQ_SIZE(SCHEMA_SEQ), n), \
|
||||
BOOST_PP_STRINGIZE(ifcparse/BOOST_PP_CAT(Ifc, BOOST_PP_SEQ_ELEM(BOOST_PP_MIN(n, BOOST_PP_SEQ_SIZE(BOOST_PP_SEQ_POP_BACK(SCHEMA_SEQ))), SCHEMA_SEQ)).h), \
|
||||
"ifcgeom/empty.h")
|
||||
|
||||
#include INCLUDE_SCHEMA_N(0)
|
||||
#include INCLUDE_SCHEMA_N(1)
|
||||
#include INCLUDE_SCHEMA_N(2)
|
||||
#include INCLUDE_SCHEMA_N(3)
|
||||
#include INCLUDE_SCHEMA_N(4)
|
||||
#include INCLUDE_SCHEMA_N(5)
|
||||
#include INCLUDE_SCHEMA_N(6)
|
||||
#include INCLUDE_SCHEMA_N(7)
|
||||
#include INCLUDE_SCHEMA_N(8)
|
||||
#include INCLUDE_SCHEMA_N(9)
|
||||
#include INCLUDE_SCHEMA_N(10)
|
||||
#include INCLUDE_SCHEMA_N(11)
|
||||
#include INCLUDE_SCHEMA_N(12)
|
||||
#include INCLUDE_SCHEMA_N(13)
|
||||
#include INCLUDE_SCHEMA_N(14)
|
||||
#include INCLUDE_SCHEMA_N(15)
|
||||
|
||||
#include <iomanip>
|
||||
|
||||
#if USE_VLD
|
||||
@@ -58,9 +80,9 @@ struct is_ifc4_or_higher<T, std::void_t<decltype(T::IfcMaterialDefinition)>> : s
|
||||
typedef std::map<std::string, std::map<std::string, std::string>> element_properties;
|
||||
|
||||
#ifdef SCHEMA_HAS_IfcBuildingElement
|
||||
typedef IfcSchema::IfcBuildingElement element_t
|
||||
typedef IfcSchema::IfcBuildingElement element_t;
|
||||
#else
|
||||
typedef IfcSchema::IfcBuiltElement element_t
|
||||
typedef IfcSchema::IfcBuiltElement element_t;
|
||||
#endif
|
||||
|
||||
std::string format_string(const AttributeValue& argument) {
|
||||
@@ -242,7 +264,7 @@ int main(int argc, char** argv) {
|
||||
// we need to cast them to IfcWindows. Since these properties
|
||||
// are optional we need to make sure the properties are
|
||||
// defined for the window in question before accessing them.
|
||||
example_element_type::list::ptr elements = file.instances_by_type<example_element_type>();
|
||||
auto elements = file.instances_by_type<element_t>();
|
||||
|
||||
std::cout << "Found " << elements->size() << " elements in " << argv[1] << ":" << std::endl;
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
@@ -256,26 +257,31 @@ class IfcDataGetter:
|
||||
return ""
|
||||
if cost_item.CostQuantities is None:
|
||||
return ""
|
||||
string = "["
|
||||
result = []
|
||||
for quantity in cost_item.CostQuantities:
|
||||
string += '["'
|
||||
prefix = ""
|
||||
for rel in file.get_inverse(quantity):
|
||||
if rel.is_a("IfcPropertySet") or rel.is_a("IfcElementQuantity"):
|
||||
prop_set = rel
|
||||
# Find elements that have this property set
|
||||
for prop_rel in file.get_inverse(prop_set):
|
||||
for prop_rel in file.get_inverse(rel):
|
||||
if prop_rel.is_a("IfcRelDefinesByProperties"):
|
||||
for obj in prop_rel.RelatedObjects:
|
||||
if obj.is_a("IfcElement"):
|
||||
string += obj.Name + " - "
|
||||
string += quantity.Name
|
||||
prefix += (obj.Name or "") + " - "
|
||||
name = prefix + (quantity.Name or "")
|
||||
# Formula is an optional IfcLabel on IfcQuantity* in IFC4+; absent in
|
||||
# IFC2X3, hence the schema-safe getattr.
|
||||
formula = getattr(quantity, "Formula", None) or ""
|
||||
if quantity.is_a("IfcPhysicalSimpleQuantity"):
|
||||
string += '", ' + str(quantity[3]) + "],"
|
||||
value = quantity[3]
|
||||
try:
|
||||
value = float(value) if value is not None else 0.0
|
||||
except (TypeError, ValueError):
|
||||
value = 0.0
|
||||
result.append([name, value, formula])
|
||||
else:
|
||||
string += ' ERROR: Only IfcPhysicalSimpleQuantity is supported", 0.0],'
|
||||
string = string.removesuffix(",")
|
||||
string += "]"
|
||||
return string
|
||||
result.append([name + " ERROR: Only IfcPhysicalSimpleQuantity is supported", 0.0, formula])
|
||||
return json.dumps(result, ensure_ascii=False)
|
||||
|
||||
|
||||
class SheetData(TypedDict):
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import csv
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
@@ -118,3 +119,44 @@ class TestCsv2Ifc:
|
||||
writer.write()
|
||||
assert len(list(Path(temp_csv_dir).glob("*.ods"))) == 1
|
||||
assert len(list(Path(temp_csv_dir).glob("*.xlsx"))) == 1
|
||||
|
||||
|
||||
class TestSerialiseCostQuantities:
|
||||
def test_quantity_name_with_special_characters_round_trips_as_json(self):
|
||||
ifc_file = ifcopenshell.file()
|
||||
name = 'Prospetto est "Np=256,667-23"'
|
||||
quantity = ifc_file.create_entity("IfcQuantityArea", Name=name, AreaValue=12.5)
|
||||
cost_item = ifc_file.create_entity("IfcCostItem", CostQuantities=[quantity])
|
||||
|
||||
result = ifc5d.ifc5Dspreadsheet.IfcDataGetter.serialise_cost_quantities(ifc_file, cost_item)
|
||||
|
||||
assert json.loads(result) == [[name, 12.5, ""]]
|
||||
|
||||
def test_unset_name_does_not_crash(self):
|
||||
ifc_file = ifcopenshell.file()
|
||||
# Name left unset so quantity.Name resolves to None at access time.
|
||||
quantity = ifc_file.create_entity("IfcQuantityArea", AreaValue=3.0)
|
||||
cost_item = ifc_file.create_entity("IfcCostItem", CostQuantities=[quantity])
|
||||
|
||||
result = ifc5d.ifc5Dspreadsheet.IfcDataGetter.serialise_cost_quantities(ifc_file, cost_item)
|
||||
|
||||
assert json.loads(result) == [["", 3.0, ""]]
|
||||
|
||||
def test_formula_is_included_when_present(self):
|
||||
ifc_file = ifcopenshell.file()
|
||||
quantity = ifc_file.create_entity("IfcQuantityArea", Name="Area", AreaValue=12.5, Formula="Length * Width")
|
||||
cost_item = ifc_file.create_entity("IfcCostItem", CostQuantities=[quantity])
|
||||
|
||||
result = ifc5d.ifc5Dspreadsheet.IfcDataGetter.serialise_cost_quantities(ifc_file, cost_item)
|
||||
|
||||
assert json.loads(result) == [["Area", 12.5, "Length * Width"]]
|
||||
|
||||
def test_quantity_without_formula_attribute_does_not_crash(self):
|
||||
# IfcPhysicalComplexQuantity has no Formula attribute and is unsupported.
|
||||
ifc_file = ifcopenshell.file()
|
||||
quantity = ifc_file.create_entity("IfcPhysicalComplexQuantity", Name="Complex", Discrimination="layer")
|
||||
cost_item = ifc_file.create_entity("IfcCostItem", CostQuantities=[quantity])
|
||||
|
||||
result = ifc5d.ifc5Dspreadsheet.IfcDataGetter.serialise_cost_quantities(ifc_file, cost_item)
|
||||
|
||||
assert json.loads(result) == [["Complex ERROR: Only IfcPhysicalSimpleQuantity is supported", 0.0, ""]]
|
||||
|
||||
@@ -45,6 +45,11 @@ ifcopenshell::geometry::abstract_mapping* ifcopenshell::geometry::impl::MappingF
|
||||
throw IfcParse::IfcException("No geometry mapping registered for " + schema_name_lower);
|
||||
}
|
||||
auto new_mapping = it->second(file, s, logger);
|
||||
new_mapping->initialize_settings();
|
||||
try {
|
||||
new_mapping->initialize_settings();
|
||||
} catch (const std::exception& e) {
|
||||
logger.Error("GEO", 400, e);
|
||||
logger.Error("GEO", 401, "Unable to initialize conversion settings");
|
||||
}
|
||||
return new_mapping;
|
||||
}
|
||||
|
||||
@@ -54,8 +54,8 @@ ifeq ($(PLATFORM), win64)
|
||||
PLATFORMTAG:=win_amd64
|
||||
endif
|
||||
|
||||
BINARY_VERSION:=0.8.5
|
||||
BUILD_COMMIT:=1c5b825
|
||||
BINARY_VERSION:=0.8.6
|
||||
BUILD_COMMIT:=3e7b739
|
||||
IOS_URL:=https://s3.amazonaws.com/ifcopenshell-builds/ifcopenshell-python-$(PYNUMBER)-v$(BINARY_VERSION)-$(BUILD_COMMIT)-$(PLATFORM).zip
|
||||
IFCCONVERT_URL:=https://s3.amazonaws.com/ifcopenshell-builds/IfcConvert-v$(BINARY_VERSION)-$(BUILD_COMMIT)-$(PLATFORM).zip
|
||||
|
||||
|
||||
@@ -95,7 +95,9 @@ from .entity_instance import entity_instance, register_schema_attributes
|
||||
from .file import file, rocksdb_lazy_instance
|
||||
from .file import file as _file
|
||||
from .sql import sqlite, sqlite_entity
|
||||
from .ifcopenshell_wrapper import get_log, logger
|
||||
|
||||
get_log = ifcopenshell_wrapper.get_log
|
||||
logger = getattr(ifcopenshell_wrapper, "logger", None)
|
||||
|
||||
# explicitly specify available imported symbols
|
||||
# (it's a requirement for a typed library)
|
||||
@@ -132,10 +134,20 @@ class SchemaError(Error):
|
||||
|
||||
@overload
|
||||
def open(
|
||||
path: Union[os.PathLike, str], format: SupportedFormat = None, *, should_stream: Literal[False] = False
|
||||
path: Union[os.PathLike, str],
|
||||
format: SupportedFormat = None,
|
||||
*,
|
||||
should_stream: Literal[False] = False,
|
||||
logger: Optional[logger] = None,
|
||||
) -> Union[_file, sqlite]: ...
|
||||
@overload
|
||||
def open(path: Union[os.PathLike, str], format: SupportedFormat = None, *, should_stream: Literal[True]) -> _stream: ...
|
||||
def open(
|
||||
path: Union[os.PathLike, str],
|
||||
format: SupportedFormat = None,
|
||||
*,
|
||||
should_stream: Literal[True],
|
||||
logger: Optional[logger] = None,
|
||||
) -> _stream: ...
|
||||
@overload
|
||||
def open(
|
||||
path: Union[os.PathLike, str],
|
||||
@@ -143,6 +155,7 @@ def open(
|
||||
*,
|
||||
should_stream: bool = False,
|
||||
readonly: bool = False,
|
||||
logger: Optional[logger] = None,
|
||||
) -> Union[_file, sqlite, _stream]: ...
|
||||
def open(
|
||||
path: Union[os.PathLike, str],
|
||||
@@ -151,11 +164,13 @@ def open(
|
||||
readonly: bool = False,
|
||||
mmap: bool = False,
|
||||
bypass_types: Optional[Sequence[str]] = None,
|
||||
logger: Optional[logger] = None,
|
||||
) -> Union[_file, sqlite, _stream]:
|
||||
"""Loads an IFC dataset from a filepath
|
||||
|
||||
:param should_stream: Whether to open the file in streaming mode. Could be useful
|
||||
for reading large files.
|
||||
:param logger: Logger that receives native parser messages.
|
||||
|
||||
You can specify a file format. If no format is given, it is guessed from
|
||||
its extension.
|
||||
@@ -179,8 +194,10 @@ def open(
|
||||
raise FileNotFoundError(f"Path does not exist: '{path}'.")
|
||||
if format is None:
|
||||
format = guess_format(path)
|
||||
if logger is None and (logger_type := getattr(ifcopenshell_wrapper, "logger", None)):
|
||||
logger = logger_type.Root()
|
||||
if format == ".ifcXML":
|
||||
f = ifcopenshell_wrapper.parse_ifcxml(str(path.absolute()))
|
||||
f = ifcopenshell_wrapper.parse_ifcxml(str(path.absolute()), *((logger,) if logger is not None else ()))
|
||||
if f:
|
||||
return file(f)
|
||||
raise OSError(f"Failed to parse .ifcXML file from {path}")
|
||||
@@ -189,7 +206,7 @@ def open(
|
||||
with zipfile.ZipFile(path) as zf:
|
||||
for name in zf.namelist():
|
||||
if Path(name).suffix.lower() in (".ifc", ".ifcxml"):
|
||||
return open(zf.extract(name, unzipped_path))
|
||||
return open(zf.extract(name, unzipped_path), logger=logger)
|
||||
else:
|
||||
raise LookupError(f"No .ifc or .ifcXML file found in {path}")
|
||||
if format == ".ifcSQLite":
|
||||
@@ -197,9 +214,11 @@ def open(
|
||||
if should_stream:
|
||||
return stream(path)
|
||||
if readonly: # Temporary conditional see #7131. Remove once newer builds don't segfault on Linux.
|
||||
f = ifcopenshell_wrapper.open(str(path.absolute()), readonly=readonly)
|
||||
f = ifcopenshell_wrapper.open(str(path.absolute()), readonly, *((logger,) if logger is not None else ()))
|
||||
elif bypass_types:
|
||||
f = ifcopenshell_wrapper.file(ifcopenshell_wrapper.uninitialized_tag())
|
||||
f = ifcopenshell_wrapper.file(
|
||||
ifcopenshell_wrapper.uninitialized_tag(), *((logger,) if logger is not None else ())
|
||||
)
|
||||
for ty in bypass_types:
|
||||
f.bypass_type(ty)
|
||||
if mmap:
|
||||
@@ -209,9 +228,12 @@ def open(
|
||||
f.initialize(str(path.absolute()))
|
||||
elif mmap:
|
||||
# mmap parameter is only available for builds with USE_MMAP, not used in our main builds
|
||||
f = ifcopenshell_wrapper.open(str(path.absolute()), mmap=mmap) # ty: ignore[unknown-argument]
|
||||
kwargs = {"mmap": mmap}
|
||||
if logger is not None:
|
||||
kwargs["logger"] = logger
|
||||
f = ifcopenshell_wrapper.open(str(path.absolute()), **kwargs) # ty: ignore[unknown-argument]
|
||||
else:
|
||||
f = ifcopenshell_wrapper.open(str(path.absolute()))
|
||||
f = ifcopenshell_wrapper.open(str(path.absolute()), False, *((logger,) if logger is not None else ()))
|
||||
return file(f)
|
||||
|
||||
|
||||
|
||||
@@ -105,7 +105,10 @@ def main(
|
||||
iterators: Sequence[ifcopenshell.geom.iterator] = (),
|
||||
merge_projection: bool = True,
|
||||
progress_function: Callable = DO_NOTHING,
|
||||
logger=None,
|
||||
):
|
||||
if logger is None and ifcopenshell.logger is not None:
|
||||
logger = ifcopenshell.logger.Root()
|
||||
|
||||
def by_guid(g):
|
||||
for f in files:
|
||||
@@ -147,7 +150,7 @@ def main(
|
||||
iterator_kwargs["include"] = list(
|
||||
filter(has_selected_parent, sum((f.by_type(x) for x in iterator_kwargs["include"]), []))
|
||||
)
|
||||
return ifcopenshell.geom.iterator(geom_settings, f, **iterator_kwargs)
|
||||
return ifcopenshell.geom.iterator(geom_settings, f, logger=logger, **iterator_kwargs)
|
||||
|
||||
# We have to keep the iterator in memory because otherwise
|
||||
# the styles are cleared up.
|
||||
@@ -458,7 +461,6 @@ def main(
|
||||
g1.appendChild(g2)
|
||||
|
||||
if settings.arrange_spaces or settings.arrange_zones:
|
||||
|
||||
if settings.storey_filter:
|
||||
# delete storey groups not selected by filter
|
||||
# sometimes happens in case of elements protruding multiple stories
|
||||
@@ -541,6 +543,7 @@ def main(
|
||||
arranged = W.arrange_polygons(
|
||||
*filter(None, (ARRANGE_POLYGON_SETTINGS,)),
|
||||
polies, # ty: ignore[too-many-positional-arguments]
|
||||
*((logger,) if logger is not None else ()),
|
||||
)
|
||||
svg_data_3 = W.polygons_to_svg(arranged, False)
|
||||
dom3 = parseString(svg_data_3)
|
||||
|
||||
@@ -217,7 +217,8 @@ for id in to_emit:
|
||||
statements.append("%s << %s" % (id, stmt))
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(r"""
|
||||
print(
|
||||
r"""
|
||||
# This file is generated by IfcOpenShell ifcexpressparser bootstrap.py
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -256,4 +257,6 @@ if __name__ == "__main__":
|
||||
mdl = importlib.import_module(output)
|
||||
mdl.Generator(m).emit()
|
||||
sys.stdout.write(m.schema.name)
|
||||
""" % ("\n ".join(statements)))
|
||||
"""
|
||||
% ("\n ".join(statements))
|
||||
)
|
||||
|
||||
@@ -363,18 +363,24 @@ class EarlyBoundCodeWriter:
|
||||
)
|
||||
)
|
||||
|
||||
self.statements[self.statements.index("{factory_placeholder}")] = """
|
||||
self.statements[self.statements.index("{factory_placeholder}")] = (
|
||||
"""
|
||||
class %(schema_name)s_instance_factory : public IfcParse::instance_factory {
|
||||
virtual IfcUtil::IfcBaseClass* operator()(const IfcParse::declaration* decl, IfcEntityInstanceData&& data) const {
|
||||
%(instance_mapping)s
|
||||
}
|
||||
};
|
||||
""" % locals()
|
||||
"""
|
||||
% locals()
|
||||
)
|
||||
|
||||
""
|
||||
self.statements[self.statements.index("{string_pool_placeholder}")] = """
|
||||
self.statements[self.statements.index("{string_pool_placeholder}")] = (
|
||||
"""
|
||||
const std::string strings[] = {%s};
|
||||
""" % ",".join(map(lambda s: '"%s"s' % s, self.strings))
|
||||
"""
|
||||
% ",".join(map(lambda s: '"%s"s' % s, self.strings))
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return "\n".join(self.statements)
|
||||
|
||||
@@ -145,7 +145,8 @@ class configuration:
|
||||
config.set(
|
||||
"snippets",
|
||||
"print all wall ids",
|
||||
self.config_encode("""
|
||||
self.config_encode(
|
||||
"""
|
||||
###########################################################################
|
||||
# A simple script that iterates over all walls in the current model #
|
||||
# and prints their Globally unique IDs (GUIDS) to the console window #
|
||||
@@ -153,13 +154,15 @@ class configuration:
|
||||
|
||||
for wall in model.by_type("IfcWall"):
|
||||
print ("wall with global id: "+str(wall.GlobalId))
|
||||
""".lstrip()),
|
||||
""".lstrip()
|
||||
),
|
||||
)
|
||||
|
||||
config.set(
|
||||
"snippets",
|
||||
"print properties of current selection",
|
||||
self.config_encode("""
|
||||
self.config_encode(
|
||||
"""
|
||||
###########################################################################
|
||||
# A simple script that iterates over all IfcPropertySets of the currently #
|
||||
# selected object and prints them to the console #
|
||||
@@ -177,7 +180,8 @@ if selection:
|
||||
for prop in relDefinesByProperties.RelatingPropertyDefinition.HasProperties:
|
||||
print ("{:<20} :{}".format(prop.Name,prop.NominalValue.wrappedValue))
|
||||
print ("\\n")
|
||||
""".lstrip()),
|
||||
""".lstrip()
|
||||
),
|
||||
)
|
||||
with open(conf_file, "w") as configfile:
|
||||
config.write(configfile)
|
||||
|
||||
@@ -299,13 +299,16 @@ class iterator(ifcopenshell_wrapper.Iterator):
|
||||
include: Optional[Union[list[entity_instance], list[str]]] = None,
|
||||
exclude: Optional[Union[list[entity_instance], list[str]]] = None,
|
||||
geometry_library: GEOMETRY_LIBRARY = "opencascade",
|
||||
logger=None,
|
||||
):
|
||||
self.settings = settings
|
||||
if logger is None and (logger_type := getattr(ifcopenshell_wrapper, "logger", None)):
|
||||
logger = logger_type.Root()
|
||||
if isinstance(file_or_filename, file):
|
||||
self.file = file
|
||||
file_or_filename = file_or_filename.wrapped_data
|
||||
else:
|
||||
file_or_filename = self.file = open(file_or_filename)
|
||||
file_or_filename = self.file = open(file_or_filename, logger=logger)
|
||||
|
||||
if include is not None and exclude is not None:
|
||||
raise ValueError("include and exclude cannot be specified simultaneously")
|
||||
@@ -333,13 +336,18 @@ class iterator(ifcopenshell_wrapper.Iterator):
|
||||
else:
|
||||
initializer = ifcopenshell_wrapper.construct_iterator_with_include_exclude
|
||||
|
||||
self.this = initializer(
|
||||
geometry_library, self.settings, file_or_filename, include_or_exclude, include is not None, num_threads
|
||||
args = (
|
||||
geometry_library,
|
||||
self.settings,
|
||||
file_or_filename,
|
||||
include_or_exclude,
|
||||
include is not None,
|
||||
num_threads,
|
||||
)
|
||||
self.this = initializer(*args, *((logger,) if logger is not None else ()))
|
||||
else:
|
||||
self.this = ifcopenshell_wrapper.construct_iterator(
|
||||
geometry_library, self.settings, file_or_filename, num_threads
|
||||
)
|
||||
args = (geometry_library, self.settings, file_or_filename, num_threads)
|
||||
self.this = ifcopenshell_wrapper.construct_iterator(*args, *((logger,) if logger is not None else ()))
|
||||
|
||||
if has_occ:
|
||||
|
||||
@@ -508,7 +516,11 @@ def create_shape(
|
||||
return wrap_shape_creation(
|
||||
settings,
|
||||
ifcopenshell_wrapper.create_shape(
|
||||
settings, inst.wrapped_data, repr.wrapped_data if repr is not None else None, geometry_library, *(filter(None, (logger,)))
|
||||
settings,
|
||||
inst.wrapped_data,
|
||||
repr.wrapped_data if repr is not None else None,
|
||||
geometry_library,
|
||||
*((logger,) if logger is not None else ()),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -564,6 +576,7 @@ def iterate(
|
||||
cache: Optional[str] = None,
|
||||
serializer_settings: Optional[serializer_settings] = None,
|
||||
geometry_library: GEOMETRY_LIBRARY = "opencascade",
|
||||
logger=None,
|
||||
) -> Generator[IteratorOutput, None, None]: ...
|
||||
@overload
|
||||
def iterate(
|
||||
@@ -577,6 +590,7 @@ def iterate(
|
||||
cache: Optional[str] = None,
|
||||
serializer_settings: Optional[serializer_settings] = None,
|
||||
geometry_library: GEOMETRY_LIBRARY = "opencascade",
|
||||
logger=None,
|
||||
) -> Generator[tuple[int, IteratorOutput], None, None]: ...
|
||||
@overload
|
||||
def iterate(
|
||||
@@ -590,6 +604,7 @@ def iterate(
|
||||
cache: Optional[str] = None,
|
||||
serializer_settings: Optional[serializer_settings] = None,
|
||||
geometry_library: GEOMETRY_LIBRARY = "opencascade",
|
||||
logger=None,
|
||||
) -> Generator[Union[IteratorOutput, tuple[int, IteratorOutput]], None, None]: ...
|
||||
def iterate(
|
||||
settings: settings,
|
||||
@@ -602,13 +617,14 @@ def iterate(
|
||||
cache: Optional[str] = None,
|
||||
serializer_settings: Optional[serializer_settings] = None,
|
||||
geometry_library: GEOMETRY_LIBRARY = "opencascade",
|
||||
logger=None,
|
||||
) -> Generator[Union[IteratorOutput, tuple[int, IteratorOutput]], None, None]:
|
||||
"""Get a geometry iterator for the provided file.
|
||||
|
||||
:param cache: .h5 cache filepath (might not exist, will be created).
|
||||
:param serializer_settings: Settings for cache serializer. Required if `cache` is provided.
|
||||
"""
|
||||
it = iterator(settings, file_or_filename, num_threads, include, exclude, geometry_library)
|
||||
it = iterator(settings, file_or_filename, num_threads, include, exclude, geometry_library, logger)
|
||||
if cache:
|
||||
assert serializer_settings, "`serializer_settings` argument is not optional if `cache` is provided."
|
||||
hdf5_cache = serializers.hdf5(cache, settings, serializer_settings)
|
||||
|
||||
@@ -355,7 +355,8 @@ def get_cost_rate(
|
||||
|
||||
class CostValueUnserialiser:
|
||||
def parse(self, formula: str):
|
||||
l = lark.Lark("""start: formula
|
||||
l = lark.Lark(
|
||||
"""start: formula
|
||||
formula: operand (operator operand)*
|
||||
operand: value | category "(" formula ")"
|
||||
value: NUMBER?
|
||||
@@ -392,7 +393,8 @@ class CostValueUnserialiser:
|
||||
NEWLINE: (CR? LF)+
|
||||
|
||||
%ignore WS // Disregard spaces in text
|
||||
""")
|
||||
"""
|
||||
)
|
||||
start = l.parse(formula)
|
||||
return self.get_formula(start.children[0])
|
||||
|
||||
|
||||
@@ -39,7 +39,8 @@ import ifcopenshell.util.shape
|
||||
import ifcopenshell.util.system
|
||||
import ifcopenshell.util.unit
|
||||
|
||||
filter_elements_grammar = lark.Lark("""start: filter_group
|
||||
filter_elements_grammar = lark.Lark(
|
||||
"""start: filter_group
|
||||
filter_group: facet_list ("+" facet_list)*
|
||||
facet_list: facet ("," facet)*
|
||||
|
||||
@@ -110,9 +111,11 @@ filter_elements_grammar = lark.Lark("""start: filter_group
|
||||
NEWLINE: (CR? LF)+
|
||||
|
||||
%ignore WS // Disregard spaces in text
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
get_element_grammar = lark.Lark("""start: keys
|
||||
get_element_grammar = lark.Lark(
|
||||
"""start: keys
|
||||
|
||||
keys: key ("." key)*
|
||||
key: quoted_string | regex_string | unquoted_string
|
||||
@@ -127,9 +130,11 @@ get_element_grammar = lark.Lark("""start: keys
|
||||
WS: /[ \\t\\f\\r\\n]/+
|
||||
|
||||
%ignore WS // Disregard spaces in text
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
format_grammar = lark.Lark("""start: expression
|
||||
format_grammar = lark.Lark(
|
||||
"""start: expression
|
||||
|
||||
?expression: add_sub
|
||||
?add_sub: mul_div
|
||||
@@ -188,7 +193,8 @@ format_grammar = lark.Lark("""start: expression
|
||||
NEWLINE: (CR? LF)+
|
||||
|
||||
%ignore WS // Disregard spaces in text
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
class FormatTransformer(lark.Transformer):
|
||||
|
||||
@@ -34,7 +34,22 @@ namespace {
|
||||
inline T dispatch_get_(AttributeValue::pointer_type array_, uint8_t storage_model_, size_t instance_name_, const IfcParse::declaration* entity_or_type, uint8_t index_)
|
||||
{
|
||||
if (storage_model_ == 0) {
|
||||
return array_.storage_ptr->get<T>(index_);
|
||||
try {
|
||||
return array_.storage_ptr->get<T>(index_);
|
||||
} catch (const impl::storage_type_mismatch& e) {
|
||||
throw IfcParse::IfcException(
|
||||
// entity_or_type not passed, but in v0.9 this is beginning to make sense
|
||||
(entity_or_type
|
||||
? std::string("On instance #" + std::to_string(instance_name_) + " of " + entity_or_type->name() + ": ")
|
||||
: std::string("")) +
|
||||
"Requested type <" + e.requested() + "> does not match actual type <" + e.actual() + "> at index " + std::to_string(index_));
|
||||
} catch (const std::out_of_range& e) {
|
||||
throw IfcParse::IfcException(
|
||||
(entity_or_type
|
||||
? std::string("On instance #" + std::to_string(instance_name_) + " of " + entity_or_type->name() + ": ")
|
||||
: std::string("")) +
|
||||
e.what());
|
||||
}
|
||||
}
|
||||
#ifdef IFOPSH_WITH_ROCKSDB
|
||||
else {
|
||||
|
||||
@@ -72,6 +72,83 @@ class IFC_PARSE_API Derived {};
|
||||
class IFC_PARSE_API empty_aggregate_t {};
|
||||
class IFC_PARSE_API empty_aggregate_of_aggregate_t {};
|
||||
|
||||
namespace impl {
|
||||
template <>
|
||||
struct VariantTypeName<Blank> {
|
||||
static std::string get() { return "null"; }
|
||||
};
|
||||
|
||||
template <>
|
||||
struct VariantTypeName<Derived> {
|
||||
static std::string get() { return "derived"; }
|
||||
};
|
||||
|
||||
template <>
|
||||
struct VariantTypeName<int> {
|
||||
static std::string get() { return "int"; }
|
||||
};
|
||||
|
||||
template <>
|
||||
struct VariantTypeName<bool> {
|
||||
static std::string get() { return "bool"; }
|
||||
};
|
||||
|
||||
template <>
|
||||
struct VariantTypeName<boost::logic::tribool> {
|
||||
static std::string get() { return "logical"; }
|
||||
};
|
||||
|
||||
template <>
|
||||
struct VariantTypeName<double> {
|
||||
static std::string get() { return "real"; }
|
||||
};
|
||||
|
||||
template <>
|
||||
struct VariantTypeName<std::string> {
|
||||
static std::string get() { return "string"; }
|
||||
};
|
||||
|
||||
template <>
|
||||
struct VariantTypeName<boost::dynamic_bitset<>> {
|
||||
static std::string get() { return "binary"; }
|
||||
};
|
||||
|
||||
template <>
|
||||
struct VariantTypeName<EnumerationReference> {
|
||||
static std::string get() { return "enumeration"; }
|
||||
};
|
||||
|
||||
template <>
|
||||
struct VariantTypeName<IfcUtil::IfcBaseClass*> {
|
||||
static std::string get() { return "instance"; }
|
||||
};
|
||||
|
||||
template <>
|
||||
struct VariantTypeName<empty_aggregate_t> {
|
||||
static std::string get() { return "aggregate"; }
|
||||
};
|
||||
|
||||
template <typename T, typename Allocator>
|
||||
struct VariantTypeName<std::vector<T, Allocator>> {
|
||||
static std::string get() { return "aggregate of " + VariantTypeName<T>::get(); }
|
||||
};
|
||||
|
||||
template <>
|
||||
struct VariantTypeName<aggregate_of_instance::ptr> {
|
||||
static std::string get() { return "aggregate of instance"; }
|
||||
};
|
||||
|
||||
template <>
|
||||
struct VariantTypeName<empty_aggregate_of_aggregate_t> {
|
||||
static std::string get() { return "aggregate of aggregate"; }
|
||||
};
|
||||
|
||||
template <>
|
||||
struct VariantTypeName<aggregate_of_aggregate_of_instance::ptr> {
|
||||
static std::string get() { return "aggregate of aggregate of instance"; }
|
||||
};
|
||||
}
|
||||
|
||||
template<typename... Args>
|
||||
struct parameter_pack {
|
||||
static constexpr size_t size = sizeof...(Args);
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
#include <boost/property_tree/ptree.hpp>
|
||||
#include <boost/version.hpp>
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <ctime>
|
||||
#include <iomanip>
|
||||
#include <iostream>
|
||||
@@ -131,6 +132,31 @@ void json_message(T& out, const IfcUtil::IfcBaseClass* current_product, Logger::
|
||||
}
|
||||
} // namespace
|
||||
|
||||
log_message::log_message(
|
||||
int severity,
|
||||
const char (&code_prefix)[4],
|
||||
uint16_t code_number,
|
||||
const std::string& timestamp,
|
||||
const std::string& message,
|
||||
const IfcUtil::IfcBaseInterface* inst,
|
||||
const IfcUtil::IfcBaseClass* current_product)
|
||||
: severity(severity)
|
||||
, timestamp(timestamp)
|
||||
, message(message)
|
||||
{
|
||||
snprintf(code, 7, "%s%03u", code_prefix, code_number);
|
||||
if (inst) {
|
||||
std::ostringstream oss;
|
||||
inst->as<IfcUtil::IfcBaseClass>()->toString(oss);
|
||||
instance = oss.str();
|
||||
}
|
||||
if (current_product) {
|
||||
std::ostringstream oss;
|
||||
current_product->toString(oss);
|
||||
product = oss.str();
|
||||
}
|
||||
}
|
||||
|
||||
Logger& Logger::Root() {
|
||||
static Logger logger;
|
||||
return logger;
|
||||
@@ -199,7 +225,7 @@ void Logger::Message(Logger::Severity type, const char (&code_prefix)[4], uint16
|
||||
}
|
||||
|
||||
if (format_ == FMT_INMEMORY) {
|
||||
log_messages_.emplace_back(type, code_prefix, code_number, message, instance, current_product());
|
||||
log_messages_.emplace_back(type, code_prefix, code_number, get_time(), message, instance, current_product());
|
||||
} else if (((log2_ != nullptr) || (wlog2_ != nullptr))) {
|
||||
if (format_ == FMT_PLAIN) {
|
||||
if (log2_ != nullptr) {
|
||||
|
||||
@@ -37,24 +37,16 @@ class IFC_PARSE_API log_message {
|
||||
public:
|
||||
char code[7];
|
||||
int severity;
|
||||
std::string message, instance, product;
|
||||
std::string timestamp, message, instance, product;
|
||||
|
||||
log_message(int severity, const char (&code_prefix)[4], uint16_t code_number, const std::string& message, const IfcUtil::IfcBaseInterface* inst = 0, const IfcUtil::IfcBaseClass* current_product = 0)
|
||||
: severity(severity)
|
||||
, message(message)
|
||||
{
|
||||
snprintf(code, 7, "%s%03u", code_prefix, code_number);
|
||||
if (inst) {
|
||||
std::ostringstream oss;
|
||||
inst->data().toString(nullptr, nullptr, 0, oss, true);
|
||||
instance = oss.str();
|
||||
}
|
||||
if (current_product) {
|
||||
std::ostringstream oss;
|
||||
current_product->toString(oss);
|
||||
product = oss.str();
|
||||
}
|
||||
}
|
||||
log_message(
|
||||
int severity,
|
||||
const char (&code_prefix)[4],
|
||||
uint16_t code_number,
|
||||
const std::string& timestamp,
|
||||
const std::string& message,
|
||||
const IfcUtil::IfcBaseInterface* inst = 0,
|
||||
const IfcUtil::IfcBaseClass* current_product = 0);
|
||||
};
|
||||
|
||||
class IFC_PARSE_API Logger {
|
||||
|
||||
+34
-18
@@ -37,10 +37,28 @@ variant - which is the maximum size of its constituents - is reduced.
|
||||
#include <cstring>
|
||||
#include <cstddef>
|
||||
#include <limits>
|
||||
|
||||
#include "IfcException.h"
|
||||
#include <exception>
|
||||
|
||||
namespace impl {
|
||||
class storage_type_mismatch : public std::exception {
|
||||
private:
|
||||
std::string requested_, actual__, message_;
|
||||
|
||||
public:
|
||||
storage_type_mismatch(const std::string& requested, const std::string& actual)
|
||||
: requested_(requested), actual__(actual), message_("Requested type " + requested_ + " does not match actual type " + actual__) {}
|
||||
|
||||
const char* what() const noexcept override {
|
||||
return message_.c_str();
|
||||
}
|
||||
|
||||
const std::string& requested() const { return requested_; }
|
||||
const std::string& actual() const { return actual__; }
|
||||
};
|
||||
|
||||
template <typename T>
|
||||
struct VariantTypeName;
|
||||
|
||||
// Trait to detect unique_ptr
|
||||
template <typename...> struct is_unique_ptr : std::false_type {};
|
||||
template<class T, typename... Args>
|
||||
@@ -166,14 +184,13 @@ public:
|
||||
using U = std::decay_t<T>;
|
||||
static_assert(::impl::TypeIndex_v<U, Types...> < sizeof...(Types), "Type not supported by variant");
|
||||
if (index >= size()) {
|
||||
throw std::out_of_range("Index out of range");
|
||||
throw std::out_of_range("Index " + std::to_string(index) + " is out of range for storage of size " + std::to_string(size()));
|
||||
}
|
||||
|
||||
destroy_at_index(index);
|
||||
|
||||
size_and_indices_[index + 1] = ::impl::TypeIndex_v<U, Types...>;
|
||||
using V = typename std::tuple_element<::impl::TypeIndex_v<U, Types...>, ::impl::MapTypes_t<Types... >>::type;
|
||||
// std::wcout << "setting " << index << " to " << typeid(V).name() << " (" << ::impl::TypeIndex_v<U, Types...> << ")" << std::endl;
|
||||
if constexpr (::impl::is_unique_ptr<V>::value) {
|
||||
new(&storage_[index]) V(new U(value));
|
||||
} else {
|
||||
@@ -187,8 +204,8 @@ public:
|
||||
|
||||
std::size_t index(std::size_t index) const {
|
||||
if (index >= size()) {
|
||||
throw IfcParse::IfcException(
|
||||
"Index " + std::to_string(index) + " is out of range for variant of size " + std::to_string(size())
|
||||
throw std::out_of_range(
|
||||
"Index " + std::to_string(index) + " is out of range for storage of size " + std::to_string(size())
|
||||
);
|
||||
}
|
||||
return size_and_indices_[index + 1];
|
||||
@@ -197,8 +214,8 @@ public:
|
||||
template<typename T>
|
||||
T& get(std::size_t index) {
|
||||
if (index >= size()) {
|
||||
throw IfcParse::IfcException(
|
||||
"Index " + std::to_string(index) + " is out of range for variant of size " + std::to_string(size())
|
||||
throw std::out_of_range(
|
||||
"Index " + std::to_string(index) + " is out of range for storage of size " + std::to_string(size())
|
||||
);
|
||||
}
|
||||
if (!has<T>(index)) {
|
||||
@@ -220,17 +237,16 @@ public:
|
||||
template<typename T>
|
||||
const T& get(std::size_t index) const {
|
||||
if (index >= size()) {
|
||||
throw IfcParse::IfcException(
|
||||
"Index " + std::to_string(index) + " is out of range for variant of size " + std::to_string(size())
|
||||
throw std::out_of_range(
|
||||
"Index " + std::to_string(index) + " is out of range for storage of size " + std::to_string(size())
|
||||
);
|
||||
}
|
||||
if (size_and_indices_[index + 1] != ::impl::TypeIndex<T, Types...>::value) {
|
||||
// @todo this IfcException is silly. Figure out what
|
||||
// to do, but at the moment it is specifically caught
|
||||
// in various places.
|
||||
throw IfcParse::IfcException(
|
||||
"Type held at index " + std::to_string(index) + " is " +
|
||||
get_type_name(size_and_indices_[index + 1]) + " and not " + typeid(T).name()
|
||||
throw impl::storage_type_mismatch(
|
||||
::impl::VariantTypeName<T>::get(), get_type_name(size_and_indices_[index + 1])
|
||||
);
|
||||
}
|
||||
using V = typename std::tuple_element<::impl::TypeIndex_v<T, Types...>, ::impl::MapTypes_t<Types... >>::type;
|
||||
@@ -244,8 +260,8 @@ public:
|
||||
template<typename Visitor>
|
||||
auto apply_visitor(Visitor&& visitor, std::size_t index) const {
|
||||
if (index >= size()) {
|
||||
throw IfcParse::IfcException(
|
||||
"Index " + std::to_string(index) + " is out of range for variant of size " + std::to_string(size())
|
||||
throw std::out_of_range(
|
||||
"Index " + std::to_string(index) + " is out of range for storage of size " + std::to_string(size())
|
||||
);
|
||||
}
|
||||
return apply_visitor_impl(std::forward<Visitor>(visitor), index, std::integral_constant<std::size_t, sizeof...(Types)>{});
|
||||
@@ -312,19 +328,19 @@ private:
|
||||
}
|
||||
|
||||
template <size_t I>
|
||||
const char* get_type_name_impl(size_t i) const {
|
||||
std::string get_type_name_impl(size_t i) const {
|
||||
if constexpr (I == 0) {
|
||||
return "";
|
||||
} else {
|
||||
if (i == I - 1) {
|
||||
return typeid(std::tuple_element_t<I - 1, std::tuple<Types...>>).name();
|
||||
return ::impl::VariantTypeName<std::tuple_element_t<I - 1, std::tuple<Types...>>>::get();
|
||||
} else {
|
||||
return get_type_name_impl<I - 1>(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const char* get_type_name(size_t i) const {
|
||||
std::string get_type_name(size_t i) const {
|
||||
return get_type_name_impl<sizeof...(Types)>(i);
|
||||
}
|
||||
};
|
||||
|
||||
Generated
+3
-3
@@ -3177,9 +3177,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "7.5.11",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.11.tgz",
|
||||
"integrity": "sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==",
|
||||
"version": "7.5.16",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz",
|
||||
"integrity": "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
|
||||
@@ -17,6 +17,23 @@
|
||||
# #
|
||||
################################################################################
|
||||
|
||||
cmake_minimum_required(VERSION 3.21)
|
||||
|
||||
if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
|
||||
project(IfcOpenShellPython LANGUAGES CXX)
|
||||
set(IFCOPENSHELL_IFCWRAP_STANDALONE ON)
|
||||
else()
|
||||
set(IFCOPENSHELL_IFCWRAP_STANDALONE OFF)
|
||||
endif()
|
||||
|
||||
if(NOT DEFINED CMAKE_CXX_STANDARD)
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
endif()
|
||||
if(CMAKE_CXX_STANDARD LESS 17)
|
||||
message(FATAL_ERROR "C++17 or newer is required.")
|
||||
endif()
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
|
||||
if(POLICY CMP0148) # 3.27
|
||||
cmake_policy(SET CMP0148 OLD)
|
||||
endif()
|
||||
@@ -24,6 +41,35 @@ if(POLICY CMP0177) # 3.31
|
||||
cmake_policy(SET CMP0177 OLD)
|
||||
endif()
|
||||
|
||||
set(_ifcwrap_feature_definitions "")
|
||||
if(IFCOPENSHELL_IFCWRAP_STANDALONE)
|
||||
get_filename_component(_ifcwrap_repo_root "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE)
|
||||
list(PREPEND CMAKE_MODULE_PATH "${_ifcwrap_repo_root}/cmake")
|
||||
|
||||
find_package(IfcOpenShell REQUIRED)
|
||||
|
||||
set(WITH_OPENCASCADE "${IFCOPENSHELL_WITH_OPENCASCADE}")
|
||||
set(WITH_CGAL "${IFCOPENSHELL_WITH_CGAL}")
|
||||
set(SWIG_DEFINES "")
|
||||
|
||||
if(IFCOPENSHELL_WITH_OPENCASCADE)
|
||||
list(APPEND SWIG_DEFINES -DIFOPSH_WITH_OPENCASCADE)
|
||||
list(APPEND _ifcwrap_feature_definitions IFOPSH_WITH_OPENCASCADE)
|
||||
endif()
|
||||
if(IFCOPENSHELL_WITH_CGAL)
|
||||
list(APPEND SWIG_DEFINES -DIFOPSH_WITH_CGAL)
|
||||
list(APPEND _ifcwrap_feature_definitions IFOPSH_WITH_CGAL)
|
||||
endif()
|
||||
if(IFCOPENSHELL_IFCXML)
|
||||
list(APPEND SWIG_DEFINES -DWITH_IFCXML)
|
||||
list(APPEND _ifcwrap_feature_definitions WITH_IFCXML)
|
||||
endif()
|
||||
if(IFCOPENSHELL_WITH_ROCKSDB)
|
||||
list(APPEND SWIG_DEFINES -DIFOPSH_WITH_ROCKSDB)
|
||||
list(APPEND _ifcwrap_feature_definitions IFOPSH_WITH_ROCKSDB)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
find_package(SWIG)
|
||||
if(NOT SWIG_FOUND)
|
||||
message(
|
||||
@@ -68,7 +114,15 @@ include_directories(BEFORE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
set(CMAKE_SWIG_FLAGS ${SWIG_DEFINES})
|
||||
|
||||
if(WITH_CGAL)
|
||||
set(LIBSVGFILL svgfill)
|
||||
if(IFCOPENSHELL_IFCWRAP_STANDALONE)
|
||||
if(TARGET IfcOpenShell::svgfill)
|
||||
set(LIBSVGFILL IfcOpenShell::svgfill)
|
||||
else()
|
||||
message(FATAL_ERROR "IfcOpenShell was built with CGAL, but the exported svgfill target was not found.")
|
||||
endif()
|
||||
else()
|
||||
set(LIBSVGFILL svgfill)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
set_source_files_properties(IfcPython.i PROPERTIES CPLUSPLUS ON)
|
||||
@@ -113,7 +167,21 @@ else()
|
||||
endif()
|
||||
|
||||
target_link_libraries(ifcopenshell_wrapper PRIVATE Python::Module)
|
||||
set_property(TARGET ifcopenshell_wrapper PROPERTY SWIG_DEPENDS ${IFCOPENSHELL_LIBRARIES})
|
||||
if(_ifcwrap_feature_definitions)
|
||||
target_compile_definitions(ifcopenshell_wrapper PRIVATE ${_ifcwrap_feature_definitions})
|
||||
endif()
|
||||
if(IFCOPENSHELL_IFCWRAP_STANDALONE)
|
||||
set(_ifcwrap_ifcopenshell_libraries ${IFCOPENSHELL_LIBRARIES})
|
||||
set(_ifcwrap_geometry_libraries ${IFCOPENSHELL_GEOMETRY_LIBRARIES})
|
||||
set(_ifcwrap_cgal_libraries "")
|
||||
set(_ifcwrap_swig_depends "")
|
||||
else()
|
||||
set(_ifcwrap_ifcopenshell_libraries ${IFCOPENSHELL_LIBRARIES})
|
||||
set(_ifcwrap_geometry_libraries IfcGeom ${kernel_libraries})
|
||||
set(_ifcwrap_cgal_libraries ${CGAL_LIBRARIES})
|
||||
set(_ifcwrap_swig_depends ${IFCOPENSHELL_LIBRARIES})
|
||||
endif()
|
||||
set_property(TARGET ifcopenshell_wrapper PROPERTY SWIG_DEPENDS ${_ifcwrap_swig_depends})
|
||||
if(WASM_BUILD)
|
||||
# SIDE_MODULE=1 - add to .so all symbols from linked archives (default used by pyodide).
|
||||
# Since currently libIfcGeom.a seems to be linked twice it results in duplicated symbols and compilation errors.
|
||||
@@ -134,14 +202,14 @@ if("$ENV{LDFLAGS}" MATCHES ".undefined.suppress")
|
||||
# On osx there is some state in the python dylib. With `-Wl,undefined,suppress` we can ignore the missing symbols at compile time.
|
||||
target_link_libraries(
|
||||
ifcopenshell_wrapper
|
||||
PRIVATE ${IFCOPENSHELL_LIBRARIES} ${OpenCASCADE_LIBRARIES} ${Boost_LIBRARIES} ${LIBSVGFILL}
|
||||
PRIVATE ${_ifcwrap_ifcopenshell_libraries} ${OpenCASCADE_LIBRARIES} ${Boost_LIBRARIES} ${LIBSVGFILL}
|
||||
)
|
||||
else()
|
||||
target_link_libraries(ifcopenshell_wrapper PRIVATE ${IFCOPENSHELL_LIBRARIES} ${LIBSVGFILL})
|
||||
target_link_libraries(ifcopenshell_wrapper PRIVATE ${_ifcwrap_ifcopenshell_libraries} ${LIBSVGFILL})
|
||||
endif()
|
||||
target_link_libraries(ifcopenshell_wrapper PRIVATE ${CGAL_LIBRARIES})
|
||||
target_link_libraries(ifcopenshell_wrapper PRIVATE IfcGeom ${kernel_libraries})
|
||||
if((NOT WIN32) AND BUILD_SHARED_LIBS)
|
||||
target_link_libraries(ifcopenshell_wrapper PRIVATE ${_ifcwrap_cgal_libraries})
|
||||
target_link_libraries(ifcopenshell_wrapper PRIVATE ${_ifcwrap_geometry_libraries})
|
||||
if((NOT WIN32) AND BUILD_SHARED_LIBS AND COMMAND SET_INSTALL_RPATHS)
|
||||
SET_INSTALL_RPATHS(ifcopenshell_wrapper "${IFCDIRS};${OCC_LIBRARY_DIR}")
|
||||
endif()
|
||||
|
||||
@@ -194,7 +262,7 @@ if(Python_Interpreter_FOUND OR PYTHON_MODULE_INSTALL_DIR)
|
||||
endif()
|
||||
endforeach()
|
||||
install(
|
||||
FILES "${CMAKE_BINARY_DIR}/ifcwrap/ifcopenshell_wrapper.py"
|
||||
FILES "${CMAKE_CURRENT_BINARY_DIR}/ifcopenshell_wrapper.py"
|
||||
DESTINATION "${python_package_dir}/ifcopenshell"
|
||||
)
|
||||
install(TARGETS ifcopenshell_wrapper DESTINATION "${python_package_dir}/ifcopenshell")
|
||||
|
||||
@@ -637,31 +637,31 @@ struct ShapeRTTI : public boost::static_visitor<PyObject*>
|
||||
// I couldn't get the vector<string> typemap to be applied when %extending Iterator constructor.
|
||||
// anyway it does not matter as SWIG generates C code without actual constructors
|
||||
%inline %{
|
||||
IfcGeom::Iterator* construct_iterator(const std::string& geometry_library, ifcopenshell::geometry::Settings settings, IfcParse::IfcFile* file, int num_threads) {
|
||||
return new IfcGeom::Iterator(ifcopenshell::geometry::kernels::construct(file, geometry_library, settings), settings, file, num_threads);
|
||||
}
|
||||
|
||||
IfcGeom::Iterator* construct_iterator_with_include_exclude(const std::string& geometry_library, ifcopenshell::geometry::Settings settings, IfcParse::IfcFile* file, std::vector<std::string> elems, bool include, int num_threads) {
|
||||
std::set<std::string> elems_set(elems.begin(), elems.end());
|
||||
IfcGeom::entity_filter ef{ include, false, elems_set };
|
||||
return new IfcGeom::Iterator(ifcopenshell::geometry::kernels::construct(file, geometry_library, settings), settings, file, {ef}, num_threads);
|
||||
}
|
||||
|
||||
IfcGeom::Iterator* construct_iterator_with_include_exclude_globalid(const std::string& geometry_library, ifcopenshell::geometry::Settings settings, IfcParse::IfcFile* file, std::vector<std::string> elems, bool include, int num_threads) {
|
||||
std::set<std::string> elems_set(elems.begin(), elems.end());
|
||||
IfcGeom::attribute_filter af;
|
||||
af.attribute_name = "GlobalId";
|
||||
af.populate(elems_set);
|
||||
af.include = include;
|
||||
return new IfcGeom::Iterator(ifcopenshell::geometry::kernels::construct(file, geometry_library, settings), settings, file, {af}, num_threads);
|
||||
}
|
||||
|
||||
IfcGeom::Iterator* construct_iterator_with_include_exclude_id(const std::string& geometry_library, ifcopenshell::geometry::Settings settings, IfcParse::IfcFile* file, std::vector<int> elems, bool include, int num_threads) {
|
||||
std::set<int> elems_set(elems.begin(), elems.end());
|
||||
IfcGeom::instance_id_filter af(include, false, elems_set);
|
||||
return new IfcGeom::Iterator(ifcopenshell::geometry::kernels::construct(file, geometry_library, settings), settings, file, {af}, num_threads);
|
||||
}
|
||||
%}
|
||||
IfcGeom::Iterator* construct_iterator(const std::string& geometry_library, ifcopenshell::geometry::Settings settings, IfcParse::IfcFile* file, int num_threads, Logger& logger = Logger::Root()) {
|
||||
return new IfcGeom::Iterator(ifcopenshell::geometry::kernels::construct(file, geometry_library, settings, logger), settings, file, num_threads, logger);
|
||||
}
|
||||
|
||||
IfcGeom::Iterator* construct_iterator_with_include_exclude(const std::string& geometry_library, ifcopenshell::geometry::Settings settings, IfcParse::IfcFile* file, std::vector<std::string> elems, bool include, int num_threads, Logger& logger = Logger::Root()) {
|
||||
std::set<std::string> elems_set(elems.begin(), elems.end());
|
||||
IfcGeom::entity_filter ef{ include, false, elems_set };
|
||||
return new IfcGeom::Iterator(ifcopenshell::geometry::kernels::construct(file, geometry_library, settings, logger), settings, file, {ef}, num_threads, logger);
|
||||
}
|
||||
|
||||
IfcGeom::Iterator* construct_iterator_with_include_exclude_globalid(const std::string& geometry_library, ifcopenshell::geometry::Settings settings, IfcParse::IfcFile* file, std::vector<std::string> elems, bool include, int num_threads, Logger& logger = Logger::Root()) {
|
||||
std::set<std::string> elems_set(elems.begin(), elems.end());
|
||||
IfcGeom::attribute_filter af;
|
||||
af.attribute_name = "GlobalId";
|
||||
af.populate(elems_set);
|
||||
af.include = include;
|
||||
return new IfcGeom::Iterator(ifcopenshell::geometry::kernels::construct(file, geometry_library, settings, logger), settings, file, {af}, num_threads, logger);
|
||||
}
|
||||
|
||||
IfcGeom::Iterator* construct_iterator_with_include_exclude_id(const std::string& geometry_library, ifcopenshell::geometry::Settings settings, IfcParse::IfcFile* file, std::vector<int> elems, bool include, int num_threads, Logger& logger = Logger::Root()) {
|
||||
std::set<int> elems_set(elems.begin(), elems.end());
|
||||
IfcGeom::instance_id_filter af(include, false, elems_set);
|
||||
return new IfcGeom::Iterator(ifcopenshell::geometry::kernels::construct(file, geometry_library, settings, logger), settings, file, {af}, num_threads, logger);
|
||||
}
|
||||
%}
|
||||
|
||||
%extend IfcGeom::Representation::Triangulation {
|
||||
|
||||
@@ -1288,11 +1288,11 @@ ifcopenshell::geometry::taxonomy::item::ptr try_upcast(PyObject* obj0, swig_type
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<svgfill::polygon_2> arrange_polygons(svgfill::arrange_polygon_settings settings, const std::vector<svgfill::polygon_2>& polygons) {
|
||||
std::vector<svgfill::polygon_2> r;
|
||||
if (svgfill::arrange_polygons(settings, polygons, r)) {
|
||||
return r;
|
||||
} else {
|
||||
std::vector<svgfill::polygon_2> arrange_polygons(svgfill::arrange_polygon_settings settings, const std::vector<svgfill::polygon_2>& polygons, Logger& logger = Logger::Root()) {
|
||||
std::vector<svgfill::polygon_2> r;
|
||||
if (svgfill::arrange_polygons(settings, polygons, r, logger)) {
|
||||
return r;
|
||||
} else {
|
||||
throw std::runtime_error("Failed to arrange polygons");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -712,10 +712,10 @@ private:
|
||||
%newobject stream_from_string;
|
||||
|
||||
%inline %{
|
||||
IfcParse::IfcFile* open(const std::string& fn, bool readonly=false) {
|
||||
IfcParse::IfcFile* open(const std::string& fn, bool readonly=false, Logger& logger=Logger::Root()) {
|
||||
IfcParse::IfcFile* f;
|
||||
Py_BEGIN_ALLOW_THREADS;
|
||||
f = new IfcParse::IfcFile(fn, IfcParse::FT_AUTODETECT, readonly);
|
||||
f = new IfcParse::IfcFile(fn, IfcParse::FT_AUTODETECT, readonly, logger);
|
||||
Py_END_ALLOW_THREADS;
|
||||
return f;
|
||||
}
|
||||
@@ -1238,8 +1238,11 @@ private:
|
||||
}
|
||||
%pythoncode %{
|
||||
severity_string = property(severity_string)
|
||||
def to_dict(self):
|
||||
keys = ("timestamp", "severity", "code", "message", "instance", "product")
|
||||
return dict(zip(keys, self.to_tuple()))
|
||||
def to_tuple(self):
|
||||
return self.severity_string, self.code, self.message, self.instance
|
||||
return self.timestamp, self.severity_string, self.code, self.message, self.instance, self.product
|
||||
def __eq__(self, other):
|
||||
return type(self) == type(other) and self.to_tuple() == other.to_tuple()
|
||||
def __hash__(self):
|
||||
|
||||
@@ -36,7 +36,7 @@ target_link_libraries(
|
||||
IfcParse
|
||||
)
|
||||
|
||||
install(TARGETS Serializers)
|
||||
install(TARGETS Serializers EXPORT ${IFCOPENSHELL_EXPORT_TARGETS})
|
||||
|
||||
# Can't use `PUBLIC_HEADER` since we need two folders.
|
||||
install(FILES ${SERIALIZERS_H_FILES} DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}/serializers/")
|
||||
|
||||
@@ -13,6 +13,7 @@ foreach(schema ${SCHEMA_VERSIONS})
|
||||
if(NOT WASM_BUILD)
|
||||
target_link_libraries(Serializers_ifc${schema} ${OpenCASCADE_LIBRARIES})
|
||||
endif()
|
||||
install(TARGETS Serializers_ifc${schema} EXPORT ${IFCOPENSHELL_EXPORT_TARGETS})
|
||||
endforeach()
|
||||
|
||||
set(SERIALIZER_SCHEMA_LIBRARIES ${SERIALIZER_SCHEMA_LIBRARIES} PARENT_SCOPE)
|
||||
|
||||
@@ -49,7 +49,7 @@ file(GLOB LIB_H_FILES src/*.h)
|
||||
file(GLOB LIB_CPP_FILES src/svgfill.cpp src/arrange_polygons.cpp)
|
||||
set(LIB_SRC_FILES ${LIB_H_FILES} ${LIB_CPP_FILES})
|
||||
add_library(svgfill ${LIB_SRC_FILES})
|
||||
target_link_libraries(svgfill ${Boost_LIBRARIES} ${BCRYPT_LIBRARIES} LibXml2::LibXml2 IFCOPENSHELL_CGAL)
|
||||
target_link_libraries(svgfill ${Boost_LIBRARIES} ${BCRYPT_LIBRARIES} LibXml2::LibXml2 IFCOPENSHELL_CGAL IfcParse)
|
||||
set_target_properties(svgfill PROPERTIES PUBLIC_HEADER "${LIB_H_FILES}")
|
||||
|
||||
add_executable(svgfill_exe src/main.cpp)
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
#include "svgfill.h"
|
||||
#endif
|
||||
|
||||
#include "../../ifcparse/IfcLogger.h"
|
||||
|
||||
#include <CGAL/Exact_predicates_exact_constructions_kernel.h>
|
||||
#include <CGAL/Boolean_set_operations_2.h>
|
||||
#include <CGAL/Polygon_2.h>
|
||||
@@ -1602,7 +1604,8 @@ std::map<Point_2, std::vector<Point_2>> snap_points_to_box_axes(
|
||||
DebugWriter& debug,
|
||||
const CenterLineGraphData& graph,
|
||||
const std::vector<MergedBoxRecord>& boxes,
|
||||
const K::FT& max_projection_distance) {
|
||||
const K::FT& max_projection_distance,
|
||||
Logger& logger) {
|
||||
std::vector<Point_2> snapped_points(graph.points.size());
|
||||
|
||||
for (size_t i = 0; i < graph.points.size(); ++i) {
|
||||
@@ -1685,7 +1688,11 @@ std::map<Point_2, std::vector<Point_2>> snap_points_to_box_axes(
|
||||
debug.write_segment(graph.points[i], best.projection, "snap_candidate_4");
|
||||
} else {
|
||||
snapped_points[i] = graph.points[i];
|
||||
std::cout << "Warning: snapping distance exceeding distance: " << std::sqrt(CGAL::to_double((snapped_points[i] - best.projection).squared_length())) << " > " << max_projection_distance << std::endl;
|
||||
std::ostringstream message;
|
||||
message << "Snapping distance exceeds maximum distance: "
|
||||
<< std::sqrt(CGAL::to_double((snapped_points[i] - best.projection).squared_length()))
|
||||
<< " > " << max_projection_distance;
|
||||
logger.Message(Logger::LOG_WARNING, "ARR", 1, message.str());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1711,7 +1718,8 @@ Graph2D<K> join_segment_runs(
|
||||
DebugWriter& debug,
|
||||
const std::map<Point_2, std::vector<Point_2>>& line_graph,
|
||||
const std::map<Point_2, std::pair<Point_2, Point_2>>& midpoint_to_segment,
|
||||
const K::FT& max_projection_distance) {
|
||||
const K::FT& max_projection_distance,
|
||||
Logger& logger) {
|
||||
auto graph = make_center_line_graph_data(line_graph, midpoint_to_segment);
|
||||
auto runs = runs_from_graph(graph);
|
||||
runs.erase(std::remove_if(runs.begin(), runs.end(), [](const LineRun& run) {
|
||||
@@ -1742,7 +1750,7 @@ Graph2D<K> join_segment_runs(
|
||||
}
|
||||
debug.write_polygons(run_polygons, "merged_boxes");
|
||||
|
||||
auto snapped_graph = snap_points_to_box_axes(debug, graph, boxes, max_projection_distance);
|
||||
auto snapped_graph = snap_points_to_box_axes(debug, graph, boxes, max_projection_distance, logger);
|
||||
return Graph2D<K>(snapped_graph);
|
||||
}
|
||||
|
||||
@@ -2239,7 +2247,9 @@ extend_end_vertices_based_on_input_simple(
|
||||
DebugWriter& debug_output,
|
||||
const Graph2D<K>& G,
|
||||
const Polygon_list& outer_perimiter,
|
||||
const K::FT& max_projection_distance, int pass)
|
||||
const K::FT& max_projection_distance,
|
||||
int pass,
|
||||
Logger& logger)
|
||||
{
|
||||
auto max_intersection_distance = max_projection_distance / 4;
|
||||
|
||||
@@ -2389,9 +2399,9 @@ extend_end_vertices_based_on_input_simple(
|
||||
}
|
||||
}
|
||||
if (within_any_perimeter) {
|
||||
std::cout << "Within boundary but still no solution given" << std::endl;
|
||||
logger.Message(Logger::LOG_WARNING, "ARR", 2, "Within boundary but no projection or intersection solution was found");
|
||||
} else {
|
||||
std::cout << "Outside of all boundaries" << std::endl;
|
||||
logger.Message(Logger::LOG_WARNING, "ARR", 3, "Point is outside all boundaries");
|
||||
}
|
||||
return boost::optional<Point_2>{};
|
||||
};
|
||||
@@ -2404,13 +2414,18 @@ extend_end_vertices_based_on_input_simple(
|
||||
auto& M = it->first;
|
||||
if (auto result = process_point(M, *it->second.begin())) {
|
||||
if (*result == M) {
|
||||
std::cout << "Point already on perimeter (" << M.x() << " " << M.y() << ")" << std::endl;
|
||||
std::ostringstream message;
|
||||
message << "Point is already on perimeter (" << M.x() << " " << M.y() << ")";
|
||||
logger.Message(Logger::LOG_NOTICE, "ARR", 4, message.str());
|
||||
continue;
|
||||
}
|
||||
auto d = (M - *result).squared_length();
|
||||
solutions.emplace_back(d, M, *it->second.begin());
|
||||
} else {
|
||||
std::cout << "Unable to find projection or intersection point for interior boundary pass " << pass << " [round 1] (" << M.x() << " " << M.y() << ")" << std::endl;
|
||||
std::ostringstream message;
|
||||
message << "Unable to find projection or intersection point for interior boundary pass "
|
||||
<< pass << " [round 1] (" << M.x() << " " << M.y() << ")";
|
||||
logger.Message(Logger::LOG_WARNING, "ARR", 5, message.str());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2424,12 +2439,17 @@ extend_end_vertices_based_on_input_simple(
|
||||
debug_output.write_segment(point, *result, "exterior_constructed_segment");
|
||||
|
||||
auto d = CGAL::squared_distance(point, *result);
|
||||
std::cout << "Distance: " << std::sqrt(CGAL::to_double(d)) << std::endl;
|
||||
std::ostringstream message;
|
||||
message << "Projection or intersection distance: " << std::sqrt(CGAL::to_double(d));
|
||||
logger.Message(Logger::LOG_DEBUG, "ARR", 6, message.str());
|
||||
validation_segments.emplace_back(to_3d(point), to_3d(*result));
|
||||
auto inserted_it = std::prev(validation_segments.end());
|
||||
validation_tree.insert(inserted_it, validation_segments.end());
|
||||
} else {
|
||||
std::cout << "Unable to find projection or intersection point for interior boundary pass " << pass << " [round 2] (" << point.x() << " " << point.y() << ")" << std::endl;
|
||||
std::ostringstream message;
|
||||
message << "Unable to find projection or intersection point for interior boundary pass "
|
||||
<< pass << " [round 2] (" << point.x() << " " << point.y() << ")";
|
||||
logger.Message(Logger::LOG_WARNING, "ARR", 7, message.str());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2528,7 +2548,7 @@ class Segment_2_less {
|
||||
}
|
||||
};
|
||||
|
||||
std::vector<K::FT> arrangement_cell_iou(DebugWriter& debug_output, Arrangement_2& left, Arrangement_2& right) {
|
||||
std::vector<K::FT> arrangement_cell_iou(DebugWriter& debug_output, Arrangement_2& left, Arrangement_2& right, Logger& logger) {
|
||||
|
||||
using Walk_pl = CGAL::Arr_walk_along_line_point_location<Arrangement_2>;
|
||||
Walk_pl walk_pl(right);
|
||||
@@ -2610,7 +2630,7 @@ std::vector<K::FT> arrangement_cell_iou(DebugWriter& debug_output, Arrangement_2
|
||||
if (visited_faces_on_right.count(*v) > 0) {
|
||||
// Maybe we should be more permissive, try some other points etc.
|
||||
return_values.push_back(0);
|
||||
std::cout << "Already visited face on right, skipping point\n";
|
||||
logger.Message(Logger::LOG_WARNING, "ARR", 8, "Already visited face on right; skipping point");
|
||||
} else {
|
||||
// convert arr facet to polygon with holes
|
||||
auto polygon_exterior = circ_to_poly((*v)->outer_ccb());
|
||||
@@ -2648,7 +2668,7 @@ std::vector<K::FT> arrangement_cell_iou(DebugWriter& debug_output, Arrangement_2
|
||||
max_deviation_poly_pair = {pwh.outer_boundary(), pwh_right.outer_boundary()};
|
||||
}
|
||||
} else {
|
||||
std::cout << "No intersection, skipping point\n";
|
||||
logger.Message(Logger::LOG_WARNING, "ARR", 9, "No intersection; skipping point");
|
||||
return_values.push_back(0);
|
||||
}
|
||||
}
|
||||
@@ -2670,7 +2690,7 @@ std::vector<K::FT> arrangement_cell_iou(DebugWriter& debug_output, Arrangement_2
|
||||
return return_values;
|
||||
}
|
||||
|
||||
void clean_noisy_paths(DebugWriter& debug_output, Arrangement_2& arr, SegmentLookup& segment_lookup, double& threshold) {
|
||||
void clean_noisy_paths(DebugWriter& debug_output, Arrangement_2& arr, SegmentLookup& segment_lookup, double& threshold, Logger& logger) {
|
||||
using SK = CGAL::Simple_cartesian<double>;
|
||||
CGAL::Cartesian_converter<K, SK> C{};
|
||||
|
||||
@@ -2886,7 +2906,7 @@ void clean_noisy_paths(DebugWriter& debug_output, Arrangement_2& arr, SegmentLoo
|
||||
}
|
||||
}
|
||||
if (!removed) {
|
||||
std::cerr << "Warning: unable to locate edge for removal, skipping" << std::endl;
|
||||
logger.Message(Logger::LOG_WARNING, "ARR", 10, "Unable to locate edge for removal; skipping");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2971,7 +2991,7 @@ void clean_noisy_paths(DebugWriter& debug_output, Arrangement_2& arr, SegmentLoo
|
||||
#else
|
||||
auto arr_copy = arr;
|
||||
process_modifications(arr_copy, to_remove_this_path, to_insert_this_path);
|
||||
auto ious = arrangement_cell_iou(arr, arr_copy);
|
||||
auto ious = arrangement_cell_iou(debug_output, arr, arr_copy, logger);
|
||||
for (auto& iou : ious) {
|
||||
std::cerr << " - cell iou: " << CGAL::to_double(iou) << std::endl;
|
||||
}
|
||||
@@ -3303,28 +3323,36 @@ class timer {
|
||||
public:
|
||||
class entry {
|
||||
public:
|
||||
entry() {}
|
||||
entry() : logger_(nullptr) {}
|
||||
|
||||
entry(std::map<std::string, std::chrono::high_resolution_clock::time_point>::const_iterator start_it)
|
||||
: start_it(start_it) {}
|
||||
entry(
|
||||
std::map<std::string, std::chrono::high_resolution_clock::time_point>::const_iterator start_it,
|
||||
Logger& logger)
|
||||
: start_it(start_it)
|
||||
, logger_(&logger) {}
|
||||
|
||||
void stop() {
|
||||
if (start_it) {
|
||||
if (start_it && logger_) {
|
||||
auto end = std::chrono::high_resolution_clock::now();
|
||||
auto duration = std::chrono::duration<double, std::milli>(end - start_it.value()->second).count();
|
||||
std::cerr << "Timing for " << start_it.value()->first << ": " << duration << " ms" << std::endl;
|
||||
std::ostringstream message;
|
||||
message << "Timing for " << start_it.value()->first << ": " << duration << " ms";
|
||||
logger_->Message(Logger::LOG_PERF, "ARR", 11, message.str());
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
std::optional<std::map<std::string, std::chrono::high_resolution_clock::time_point>::const_iterator> start_it;
|
||||
Logger* logger_;
|
||||
};
|
||||
|
||||
timer(bool enabled = true) : enabled_(enabled) {}
|
||||
timer(Logger& logger, bool enabled = true)
|
||||
: logger_(logger)
|
||||
, enabled_(enabled) {}
|
||||
|
||||
entry start(const std::string& name) {
|
||||
if (enabled_) {
|
||||
return entry(timings_.insert({name, std::chrono::high_resolution_clock::now()}).first);
|
||||
return entry(timings_.insert({name, std::chrono::high_resolution_clock::now()}).first, logger_);
|
||||
} else {
|
||||
return entry();
|
||||
}
|
||||
@@ -3336,6 +3364,7 @@ class timer {
|
||||
std::chrono::high_resolution_clock::time_point>
|
||||
timings_;
|
||||
|
||||
Logger& logger_;
|
||||
bool enabled_;
|
||||
};
|
||||
|
||||
@@ -3351,7 +3380,12 @@ size_t delete_same_facet_edge_pairs(Arrangement_2& arr) {
|
||||
return n_deleted;
|
||||
}
|
||||
|
||||
void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std::vector<Polygon_2>& input_polygons_, std::vector<Polygon_2>& output_polygons, double polygon_offset_distance = -1.) {
|
||||
void arrange_cgal_polygons(
|
||||
svgfill::arrange_polygon_settings settings,
|
||||
const std::vector<Polygon_2>& input_polygons_,
|
||||
std::vector<Polygon_2>& output_polygons,
|
||||
Logger& logger,
|
||||
double polygon_offset_distance = -1.) {
|
||||
|
||||
static const double OVERLAP_RESOLUTION_DISTANCE = 1.e-1;
|
||||
// even larger amount of inset so that outer perimeter is safely within all input polygons even when overlap resolution is applied
|
||||
@@ -3371,7 +3405,7 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std
|
||||
debug_output = DebugWriter(false, "");
|
||||
}
|
||||
|
||||
timer timer(settings.debug_output);
|
||||
timer timer(logger, settings.debug_output);
|
||||
|
||||
auto t0 = timer.start("input");
|
||||
|
||||
@@ -3578,7 +3612,7 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std
|
||||
for (int i = 0; i < 2; ++i) {
|
||||
auto it = line_graph.find(e.first);
|
||||
if (it == line_graph.end()) {
|
||||
std::cerr << "Warning: unable to locate vertex for elimination, skipping" << std::endl;
|
||||
logger.Message(Logger::LOG_WARNING, "ARR", 12, "Unable to locate vertex for elimination; skipping");
|
||||
continue;
|
||||
}
|
||||
auto& neighbours = it->second;
|
||||
@@ -3607,7 +3641,7 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std
|
||||
};
|
||||
|
||||
if (settings.line_cleaning_algo == 0) {
|
||||
G = join_segment_runs(debug_output, line_graph, midpoint_to_segment, subdivision_length * 4);
|
||||
G = join_segment_runs(debug_output, line_graph, midpoint_to_segment, subdivision_length * 4, logger);
|
||||
Arrangement_2 arr;
|
||||
G.to_arrangement(arr);
|
||||
Graph2D<K> G2;
|
||||
@@ -3629,8 +3663,8 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std
|
||||
bool fallback_to_line_cleaning_algo_1 = false;
|
||||
|
||||
if (settings.line_cleaning_algo == 0) {
|
||||
segments1 = extend_end_vertices_based_on_input_simple(debug_output, G, outer_perimiter, subdivision_length * 16, 0);
|
||||
segments2 = extend_end_vertices_based_on_input_simple(debug_output, G_orig, outer_perimiter, subdivision_length * 16, 1);
|
||||
segments1 = extend_end_vertices_based_on_input_simple(debug_output, G, outer_perimiter, subdivision_length * 16, 0, logger);
|
||||
segments2 = extend_end_vertices_based_on_input_simple(debug_output, G_orig, outer_perimiter, subdivision_length * 16, 1, logger);
|
||||
|
||||
Arrangement_2 arr_clean;
|
||||
G.to_arrangement(arr_clean);
|
||||
@@ -3668,7 +3702,7 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std
|
||||
debug_output.write_polygons(arr_clean, "iou_left");
|
||||
debug_output.write_polygons(arr_orig, "iou_right");
|
||||
|
||||
auto ious = arrangement_cell_iou(debug_output, arr_clean, arr_orig);
|
||||
auto ious = arrangement_cell_iou(debug_output, arr_clean, arr_orig, logger);
|
||||
/*
|
||||
for (auto& iou : ious) {
|
||||
std::cout << " " << CGAL::to_double(iou - 1);
|
||||
@@ -3679,7 +3713,10 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std
|
||||
auto it = std::min_element(ious.begin(), ious.end());
|
||||
|
||||
if (it != ious.end() && (*it < 0.45)) {
|
||||
std::cerr << "Significant difference between cleaned and original arrangement, using original for topology reconstruction: " << *it << std::endl;
|
||||
std::ostringstream message;
|
||||
message << "Significant difference between cleaned and original arrangement; using original for topology reconstruction: "
|
||||
<< *it;
|
||||
logger.Message(Logger::LOG_WARNING, "ARR", 13, message.str());
|
||||
fallback_to_line_cleaning_algo_1 = true;
|
||||
apply_line_cleaning_algo_1();
|
||||
} else {
|
||||
@@ -3754,7 +3791,7 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std
|
||||
if (settings.perform_cleanup && settings.line_cleaning_algo != 0) {
|
||||
remove_colinear_vertices(arr);
|
||||
double threshold;
|
||||
clean_noisy_paths(debug_output, arr, segment_lookup, threshold);
|
||||
clean_noisy_paths(debug_output, arr, segment_lookup, threshold, logger);
|
||||
remove_colinear_vertices(arr);
|
||||
// clean_noisy_bounds(debug_output, arr, segment_lookup, threshold);
|
||||
}
|
||||
@@ -3773,7 +3810,11 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std
|
||||
|
||||
#ifndef SVGFILL_MAIN
|
||||
|
||||
bool svgfill::arrange_polygons(arrange_polygon_settings settings, const std::vector<svgfill::polygon_2>& polygons, std::vector<svgfill::polygon_2>& arranged) {
|
||||
bool svgfill::arrange_polygons(
|
||||
arrange_polygon_settings settings,
|
||||
const std::vector<svgfill::polygon_2>& polygons,
|
||||
std::vector<svgfill::polygon_2>& arranged,
|
||||
Logger& logger) {
|
||||
std::vector<Polygon_2> cgal_polygons, cgal_polygons_out;
|
||||
std::transform(polygons.begin(), polygons.end(), std::back_inserter(cgal_polygons), [](auto& poly) {
|
||||
Polygon_2 result;
|
||||
@@ -3782,7 +3823,7 @@ bool svgfill::arrange_polygons(arrange_polygon_settings settings, const std::vec
|
||||
});
|
||||
return result;
|
||||
});
|
||||
arrange_cgal_polygons(settings, cgal_polygons, cgal_polygons_out);
|
||||
arrange_cgal_polygons(settings, cgal_polygons, cgal_polygons_out, logger);
|
||||
std::transform(cgal_polygons_out.begin(), cgal_polygons_out.end(), std::back_inserter(arranged), [](auto& poly) {
|
||||
svgfill::polygon_2 result;
|
||||
std::transform(poly.begin(), poly.end(), std::back_inserter(result.boundary), [](auto& pt) {
|
||||
@@ -3812,6 +3853,9 @@ Polygon_2 create_rectangle(T x_min, T y_min, T x_max, T y_max) {
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
std::vector<Polygon_2> input_polygons, output;
|
||||
Logger logger;
|
||||
logger.SetOutput(&std::cout, &std::cerr);
|
||||
logger.Verbosity(Logger::LOG_PERF);
|
||||
|
||||
if (argc == 2) {
|
||||
using json = nlohmann::json;
|
||||
@@ -3820,7 +3864,7 @@ int main(int argc, char** argv) {
|
||||
file >> jsonData;
|
||||
size_t i = 0;
|
||||
for (const auto& item : jsonData.items()) {
|
||||
std::cout << "i " << i << std::endl;
|
||||
logger.Message(Logger::LOG_NOTICE, "ARR", 14, "Processing arrangement " + std::to_string(i));
|
||||
i++;
|
||||
input_polygons.clear();
|
||||
const auto& polygonsData = item.value();
|
||||
@@ -3832,7 +3876,7 @@ int main(int argc, char** argv) {
|
||||
input_polygons.back().push_back(CGAL::Point_2<K>(x, y));
|
||||
}
|
||||
}
|
||||
arrange_cgal_polygons(arrange_polygon_settings{}, input_polygons, output);
|
||||
arrange_cgal_polygons(arrange_polygon_settings{}, input_polygons, output, logger);
|
||||
break;
|
||||
}
|
||||
return 0;
|
||||
@@ -3845,7 +3889,7 @@ int main(int argc, char** argv) {
|
||||
|
||||
input_polygons = { rect1, rect2, rect3, rect4, rect5 };
|
||||
}
|
||||
arrange_cgal_polygons(arrange_polygon_settings{}, input_polygons, output);
|
||||
arrange_cgal_polygons(arrange_polygon_settings{}, input_polygons, output, logger);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@
|
||||
#include <array>
|
||||
#include <vector>
|
||||
|
||||
class Logger;
|
||||
|
||||
namespace svgfill {
|
||||
typedef std::array<double, 2> point_2;
|
||||
typedef std::array<point_2, 2> line_segment_2;
|
||||
@@ -133,7 +135,7 @@ namespace svgfill {
|
||||
double subdivision_factor = 16.;
|
||||
};
|
||||
|
||||
SVGFILL_API bool arrange_polygons(arrange_polygon_settings settings, const std::vector<polygon_2>& polygons, std::vector<polygon_2>& arranged);
|
||||
SVGFILL_API bool arrange_polygons(arrange_polygon_settings settings, const std::vector<polygon_2>& polygons, std::vector<polygon_2>& arranged, Logger& logger);
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
Reference in New Issue
Block a user