mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-06 07:51:47 +00:00
Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d9d56664cc | |||
| 0b7e25a3ef | |||
| d16c283aef | |||
| a0f493b471 | |||
| e389939092 | |||
| 380675e214 | |||
| 3e55c5126c | |||
| 7e3d2f936d | |||
| 0d70812641 | |||
| dd9fa65629 | |||
| eb7324e7fc | |||
| 061bb90d50 | |||
| a8d0ef3437 | |||
| 438c0955f2 | |||
| b9deb9c63d | |||
| e14b3ec8a0 | |||
| c0d2c2ea24 | |||
| 0ce6e94352 | |||
| be55400ec6 | |||
| 6f1737bb58 | |||
| 256d5a63f1 | |||
| c4605f2a8f | |||
| 4a62ffe9ca | |||
| d5e890bccd | |||
| bba11aa619 | |||
| 9f848a73e1 | |||
| 4fb8af2278 | |||
| 78653a1708 | |||
| 216092150a | |||
| ade03b171a | |||
| b5c1b81ede | |||
| 47a20f0c7c | |||
| 52d894298e | |||
| 206cd6bbe1 | |||
| 9ae79b42dd | |||
| c01433cb6c | |||
| da50d22ed5 | |||
| 9191baf067 | |||
| c299f0b191 | |||
| 8f3a1d7412 | |||
| e0a1988044 | |||
| 644b92263d | |||
| 61642d2ba3 | |||
| 4776bd7639 | |||
| b2d58d0b81 | |||
| 7322263a5e | |||
| 49de7dbcb1 | |||
| bade0647e8 | |||
| 58cfab48e6 | |||
| fa597536e1 | |||
| ee2b357d74 | |||
| b1be7d92e6 | |||
| d2381ad6c6 | |||
| 5e539890f1 | |||
| 6b4c0194ff | |||
| 1614791775 | |||
| 20b68ce0a9 |
@@ -127,6 +127,7 @@ src/ifcopenshell-python/ifcopenshell/express/*.exp.cache.dat
|
||||
|
||||
# temp files from AI coding tools
|
||||
*.claude
|
||||
CLAUDE.local.md
|
||||
*.py.tmp*
|
||||
*.json.tmp*
|
||||
|
||||
|
||||
+18
-8
@@ -27,13 +27,14 @@ endif()
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON) # not necessary, but encouraged
|
||||
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
|
||||
|
||||
if(VERSION_OVERRIDE)
|
||||
file(READ "../VERSION" "RELEASE_VERSION_")
|
||||
string(STRIP "${RELEASE_VERSION_}" RELEASE_VERSION)
|
||||
message(STATUS "Detected version '${RELEASE_VERSION}'")
|
||||
else()
|
||||
set(RELEASE_VERSION "0.8.0")
|
||||
endif()
|
||||
# The VERSION file in the repository root is the single source of truth for the
|
||||
# release version. Read it unconditionally so a plain source build reports the
|
||||
# real version through buildinfo.cpp instead of the stale hardcoded 0.8.0
|
||||
# fallback (see #8164). VERSION_OVERRIDE still controls the branch name used
|
||||
# when ADD_COMMIT_SHA embeds a commit sha.
|
||||
file(READ "../VERSION" "RELEASE_VERSION_")
|
||||
string(STRIP "${RELEASE_VERSION_}" RELEASE_VERSION)
|
||||
message(STATUS "Detected version '${RELEASE_VERSION}'")
|
||||
|
||||
add_definitions(-D_DISABLE_CONSTEXPR_MUTEX_CONSTRUCTOR)
|
||||
|
||||
@@ -313,8 +314,12 @@ if(WASM_BUILD)
|
||||
else()
|
||||
# @todo review this, shouldn't this be all possible header-only now?
|
||||
# ... or rewritten using C++17 features?
|
||||
# Boost.System has been header-only since 1.69 and its compiled stub library
|
||||
# was dropped in newer Boost, so requesting it as a component makes
|
||||
# find_package fail on Boost 1.70 and up (for example Boost 1.90). It is
|
||||
# still pulled in transitively by thread / iostreams where needed, so do not
|
||||
# request it explicitly.
|
||||
set(BOOST_COMPONENTS
|
||||
system
|
||||
program_options
|
||||
regex
|
||||
thread
|
||||
@@ -660,6 +665,11 @@ if(ADD_COMMIT_SHA)
|
||||
endif()
|
||||
endif(ADD_COMMIT_SHA)
|
||||
|
||||
# Always expose the release version (from the VERSION file) to buildinfo.cpp so
|
||||
# that a build without commit-sha info reports the correct version instead of a
|
||||
# stale hardcoded fallback. See #8164.
|
||||
target_compile_definitions(IfcParse PRIVATE IFCOPENSHELL_VERSION_STRING=${RELEASE_VERSION})
|
||||
|
||||
if(MSVC)
|
||||
# @todo still needs to be understood better, but the cgal and cgal-simple kernel cause multiply defined boost lambda placeholders _1 ... _3
|
||||
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} /FORCE:MULTIPLE")
|
||||
|
||||
@@ -320,9 +320,11 @@ def loadIfcStore(scene: bpy.types.Scene) -> None:
|
||||
IfcStore.purge()
|
||||
refresh_ui_data()
|
||||
if not tool.Ifc.get():
|
||||
tool.Autosave.cancel_timer()
|
||||
return
|
||||
tool.Ifc.schema()
|
||||
IfcStore.relink_all_objects()
|
||||
tool.Autosave.reset_timer()
|
||||
|
||||
|
||||
@persistent
|
||||
|
||||
@@ -82,7 +82,15 @@ import math
|
||||
from collections.abc import Callable, Iterator
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, ClassVar, Literal, Optional, Protocol, runtime_checkable
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
ClassVar,
|
||||
Literal,
|
||||
Optional,
|
||||
Protocol,
|
||||
runtime_checkable,
|
||||
)
|
||||
|
||||
import blf
|
||||
import bpy
|
||||
@@ -105,6 +113,9 @@ from mathutils.kdtree import KDTree
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.module.drawing.shaders import ExtrusionGuidesShader
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import bmesh
|
||||
|
||||
SNAP_POINT_SIZE = 10.0
|
||||
SNAP_POINT_COLOR = (1.0, 0.5, 0.0, 1.0)
|
||||
SNAP_MAX_RADIUS = 50.0
|
||||
@@ -2035,7 +2046,9 @@ class TexturedQuadGizmoMixin(StaticTrisGizmoMixin):
|
||||
|
||||
def setup(self) -> None:
|
||||
super().setup()
|
||||
from bonsai.bim.module.drawing import gizmo_textures
|
||||
from bonsai.bim.module.drawing import (
|
||||
gizmo_textures, # ty: ignore[unresolved-import]
|
||||
)
|
||||
|
||||
self._quad_batch = batch_for_shader(
|
||||
gizmo_textures.get_shader(),
|
||||
@@ -2044,7 +2057,9 @@ class TexturedQuadGizmoMixin(StaticTrisGizmoMixin):
|
||||
)
|
||||
|
||||
def draw(self, context: bpy.types.Context) -> None:
|
||||
from bonsai.bim.module.drawing import gizmo_textures
|
||||
from bonsai.bim.module.drawing import (
|
||||
gizmo_textures, # ty: ignore[unresolved-import]
|
||||
)
|
||||
|
||||
texture = gizmo_textures.get_icon_texture(self.icon_name)
|
||||
if texture is None:
|
||||
|
||||
@@ -951,6 +951,12 @@ class CreateDrawing(bpy.types.Operator):
|
||||
tree = ifcopenshell.geom.tree()
|
||||
tree.enable_face_styles(True)
|
||||
|
||||
# Accumulated across every file in the loop below (main model plus any
|
||||
# linked models) so the SHAPELY fill pass after the loop covers all of
|
||||
# them, not just whichever file happened to be processed last.
|
||||
raycast_objs = set()
|
||||
elements_with_faces = set()
|
||||
|
||||
for ifc_path, (ifc, link_matrix) in files.items():
|
||||
# Don't use draw.main() just whilst we're prototyping and experimenting
|
||||
# TODO: hash paths are never used
|
||||
@@ -960,13 +966,24 @@ class CreateDrawing(bpy.types.Operator):
|
||||
self.serialiser.setFile(ifc)
|
||||
drawing_elements = tool.Drawing.get_drawing_elements(self.camera_element, ifc_file=ifc)
|
||||
|
||||
if self.cprops.fill_mode == "SHAPELY":
|
||||
for element in drawing_elements.copy():
|
||||
if element.is_a("IfcAnnotation"):
|
||||
continue
|
||||
obj = tool.Ifc.get_object(element)
|
||||
if obj and obj.type == "MESH" and len(obj.data.polygons):
|
||||
elements_with_faces.add(element.GlobalId)
|
||||
raycast_objs.add(obj)
|
||||
|
||||
# Get all representation contexts to see what we're dealing with.
|
||||
# Drawings only draw bodies and annotations (and facetation, due to a Revit bug).
|
||||
# A drawing prioritises a target view context first, followed by a model view context as a fallback.
|
||||
# Specifically for PLAN_VIEW and REFLECTED_PLAN_VIEW, any Plan context is also prioritised.
|
||||
contexts = self.get_linework_contexts(ifc, target_view)
|
||||
self.serialize_contexts_elements(ifc, tree, contexts, "body", drawing_elements, target_view, link_matrix)
|
||||
self.serialize_contexts_elements(ifc, tree, contexts, "annotation", drawing_elements, target_view, link_matrix)
|
||||
self.serialize_contexts_elements(
|
||||
ifc, tree, contexts, "annotation", drawing_elements, target_view, link_matrix
|
||||
)
|
||||
|
||||
if tool.Ifc.get() == ifc and self.camera_element not in drawing_elements:
|
||||
with profile("Camera element"):
|
||||
@@ -1033,16 +1050,6 @@ class CreateDrawing(bpy.types.Operator):
|
||||
# shapely variant
|
||||
group = root.find("{http://www.w3.org/2000/svg}g")
|
||||
|
||||
raycast_objs = set()
|
||||
elements_with_faces = set()
|
||||
for element in drawing_elements.copy():
|
||||
if element.is_a("IfcAnnotation"):
|
||||
continue
|
||||
obj = tool.Ifc.get_object(element)
|
||||
if obj and obj.type == "MESH" and len(obj.data.polygons):
|
||||
elements_with_faces.add(element.GlobalId)
|
||||
raycast_objs.add(obj)
|
||||
|
||||
projections = root.xpath(
|
||||
".//svg:g[contains(@class, 'projection')]", namespaces={"svg": "http://www.w3.org/2000/svg"}
|
||||
)
|
||||
@@ -2336,7 +2343,9 @@ class ActivateDrawingBase(tool.Ifc.Operator):
|
||||
"Activates the selected drawing view.\n\n"
|
||||
+ "ALT+CLICK to keep the viewport position.\n\n"
|
||||
+ "SHIFT+CLICK to load a quick preview of the drawing view.\n\n"
|
||||
+ "SHIFT+CTRL+CLICK to load the annotations of all selected drawings without switching views"
|
||||
+ "SHIFT+CTRL+CLICK to load the annotations of all selected drawings without switching views, "
|
||||
+ "then select their cameras (the first selected drawing's camera becomes active).\n\n"
|
||||
+ "SHIFT+CTRL+ALT+CLICK to do the same but also select the annotations, not just the cameras"
|
||||
)
|
||||
|
||||
drawing: bpy.props.IntProperty()
|
||||
@@ -2358,16 +2367,25 @@ class ActivateDrawingBase(tool.Ifc.Operator):
|
||||
default=False,
|
||||
options={"SKIP_SAVE"},
|
||||
)
|
||||
include_annotations_in_selection: bpy.props.BoolProperty(
|
||||
name="Include Annotations In Selection",
|
||||
description="Also select the loaded annotation objects, not just the drawing cameras.",
|
||||
default=False,
|
||||
options={"SKIP_SAVE"},
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
drawing: int
|
||||
should_view_from_camera: bool
|
||||
use_quick_preview: bool
|
||||
load_selected_annotations: bool
|
||||
include_annotations_in_selection: bool
|
||||
|
||||
def invoke(self, context, event) -> set["rna_enums.OperatorReturnItems"]:
|
||||
if event.type == "LEFTMOUSE" and event.shift and event.ctrl:
|
||||
self.load_selected_annotations = True
|
||||
if event.alt:
|
||||
self.include_annotations_in_selection = True
|
||||
return self.execute(context)
|
||||
if event.type == "LEFTMOUSE" and event.alt:
|
||||
self.should_view_from_camera = False
|
||||
@@ -2382,15 +2400,34 @@ class ActivateDrawingBase(tool.Ifc.Operator):
|
||||
bpy.ops.bim.load_drawings()
|
||||
|
||||
if self.load_selected_annotations:
|
||||
objs_to_select = []
|
||||
active_camera = None
|
||||
for d in props.drawings:
|
||||
if not (d.is_drawing and d.is_selected):
|
||||
continue
|
||||
selected_drawing = tool.Ifc.get().by_id(d.ifc_definition_id)
|
||||
# Importing the camera (if missing) ensures the drawing's
|
||||
# collection exists so the annotations get collected into it.
|
||||
if not tool.Ifc.get_object(selected_drawing):
|
||||
tool.Drawing.import_drawing(selected_drawing)
|
||||
tool.Drawing.import_annotations_in_group(tool.Drawing.get_drawing_group(selected_drawing))
|
||||
if not (camera := tool.Ifc.get_object(selected_drawing)):
|
||||
camera = tool.Drawing.import_drawing(selected_drawing)
|
||||
group = tool.Drawing.get_drawing_group(selected_drawing)
|
||||
tool.Drawing.import_annotations_in_group(group)
|
||||
|
||||
if active_camera is None:
|
||||
active_camera = camera
|
||||
objs_to_select.append(camera)
|
||||
if self.include_annotations_in_selection:
|
||||
for element in tool.Drawing.get_group_elements(group) or []:
|
||||
if element.is_a("IfcAnnotation") and element.ObjectType != "DRAWING":
|
||||
if annotation_obj := tool.Ifc.get_object(element):
|
||||
objs_to_select.append(annotation_obj)
|
||||
|
||||
# Select the checked drawings' objects, with the first drawing's camera as active.
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
for obj in objs_to_select:
|
||||
obj.select_set(True)
|
||||
if active_camera is not None:
|
||||
context.view_layer.objects.active = active_camera
|
||||
return {"FINISHED"}
|
||||
|
||||
drawing = tool.Ifc.get().by_id(self.drawing)
|
||||
@@ -2479,7 +2516,9 @@ class ActivateDrawing(bpy.types.Operator, ActivateDrawingBase):
|
||||
"Activates the selected drawing view.\n\n"
|
||||
+ "ALT+CLICK to keep the viewport position.\n\n"
|
||||
+ "SHIFT+CLICK to load a quick preview of the drawing view.\n\n"
|
||||
+ "SHIFT+CTRL+CLICK to load the annotations of all selected drawings without switching views"
|
||||
+ "SHIFT+CTRL+CLICK to load the annotations of all selected drawings without switching views, "
|
||||
+ "then select their cameras (the first selected drawing's camera becomes active).\n\n"
|
||||
+ "SHIFT+CTRL+ALT+CLICK to do the same but also select the annotations, not just the cameras"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import bonsai.tool as tool
|
||||
from . import (
|
||||
array,
|
||||
covering,
|
||||
decorator,
|
||||
door,
|
||||
external,
|
||||
grid,
|
||||
|
||||
@@ -329,6 +329,7 @@ class _ArrayEditMixin(ParametricEditMixinBase):
|
||||
# Unhide the (possibly newly-regenerated) children so the user sees
|
||||
# the committed result. Mirrors the hide in ``_enable_one``.
|
||||
cls._set_children_visibility(element, hidden=False)
|
||||
tool.Array.select_only_parent(obj, context)
|
||||
|
||||
@classmethod
|
||||
def _cancel_one(cls, obj: bpy.types.Object) -> None:
|
||||
@@ -421,9 +422,9 @@ class RegenerateArray(bpy.types.Operator, tool.Ifc.Operator):
|
||||
pset = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array")
|
||||
arrays = json.loads(pset["Data"])
|
||||
pset = tool.Ifc.get().by_id(pset["id"])
|
||||
# Coalesce host recuts: the child-delete loop, the regenerate, and the
|
||||
# per-child opening mirror all touch the same host body. Without batching,
|
||||
# an N-child wipe-then-regen costs N+1 recuts; this collapses to one.
|
||||
# Coalesce host recuts across the child-delete loop, the regenerate,
|
||||
# and the per-child opening mirror: each fans out its own host body
|
||||
# recut without the batch wrapper.
|
||||
with tool.Geometry.batch_host_recut():
|
||||
for array in arrays:
|
||||
for child in set(array["children"]):
|
||||
@@ -442,6 +443,8 @@ class RegenerateArray(bpy.types.Operator, tool.Ifc.Operator):
|
||||
tool.Model.regenerate_array(parent, arrays)
|
||||
tool.Array.constrain_children_to_parent(parent_element)
|
||||
|
||||
tool.Array.select_only_parent(parent, context)
|
||||
|
||||
|
||||
class RemoveArray(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.remove_array"
|
||||
|
||||
@@ -38,6 +38,7 @@ import numpy as np
|
||||
from ifcopenshell.util.shape_builder import ShapeBuilder
|
||||
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
|
||||
@@ -1677,6 +1678,11 @@ def _n_mep_selected(n: int) -> bool:
|
||||
element = tool.Ifc.get_entity(selected_obj)
|
||||
if element is None or not tool.System.is_mep_element(element):
|
||||
return False
|
||||
# Array children mirror their parent's port topology. Writable MEP
|
||||
# actions on a child get wiped by the next array regen, so gate the
|
||||
# icons out at the visibility layer.
|
||||
if tool.Array.is_array_child(element):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@@ -2555,6 +2561,8 @@ def _active_is_flow_segment(obj: bpy.types.Object) -> bool:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if element is None or not element.is_a("IfcFlowSegment"):
|
||||
return False
|
||||
if tool.Array.is_array_child(element):
|
||||
return False
|
||||
return tool.System.has_parametric_body(element)
|
||||
|
||||
|
||||
@@ -2584,6 +2592,8 @@ def _active_is_bend_fitting(obj: bpy.types.Object) -> bool:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not _is_bend_fitting(element):
|
||||
return False
|
||||
if tool.Array.is_array_child(element):
|
||||
return False
|
||||
element_type = ifcopenshell.util.element.get_type(element)
|
||||
if element_type is None:
|
||||
return False
|
||||
|
||||
@@ -138,7 +138,7 @@ def update_bbim_railing_pset(element: ifcopenshell.entity_instance, railing_data
|
||||
|
||||
def generate_wall_mounted_handrail_preview(
|
||||
obj: bpy.types.Object,
|
||||
props: "BIMRailingProperties",
|
||||
props: "prop.BIMRailingProperties",
|
||||
path_data: dict[str, Any],
|
||||
si_conversion: float,
|
||||
) -> None:
|
||||
@@ -860,7 +860,9 @@ class GizmoRailingSchematic(bpy.types.GizmoGroup, gizmo.BaseSchematicGizmoGroup)
|
||||
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:
|
||||
def update_editing_gizmos(
|
||||
self, context: bpy.types.Context, mw: "Matrix", props: "prop.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,
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
|
||||
import bpy
|
||||
|
||||
import bonsai.tool as tool
|
||||
|
||||
from . import decorator, gizmo, operator, prop, ui, workspace
|
||||
|
||||
classes = (
|
||||
@@ -58,6 +60,8 @@ classes = (
|
||||
operator.LinkIfc,
|
||||
operator.LoadBlendMetadataAndIFC,
|
||||
operator.LoadLink,
|
||||
operator.AutosavePrompt,
|
||||
operator.LoadAutosavedRecoveryPopup,
|
||||
operator.LoadLinkedProject,
|
||||
operator.LoadProject,
|
||||
operator.LoadProjectElements,
|
||||
@@ -136,6 +140,7 @@ def register():
|
||||
def unregister():
|
||||
if not bpy.app.background:
|
||||
bpy.utils.unregister_tool(workspace.ExploreTool)
|
||||
tool.Autosave.cancel_timer()
|
||||
del bpy.types.Scene.BIMProjectProperties
|
||||
del bpy.types.Scene.MeasureToolSettings
|
||||
bpy.app.handlers.load_post.remove(decorator.toggle_decorations_on_load)
|
||||
|
||||
@@ -985,8 +985,10 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
|
||||
),
|
||||
default=False,
|
||||
)
|
||||
skip_autosave_recovery: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"})
|
||||
use_detailed_tooltip: bpy.props.BoolProperty(default=False, options={"HIDDEN"})
|
||||
filename_ext = ".ifc"
|
||||
skip_recent: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"})
|
||||
|
||||
if TYPE_CHECKING:
|
||||
filepath: str
|
||||
@@ -995,6 +997,7 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
|
||||
use_relative_path: bool
|
||||
should_start_fresh_session: bool
|
||||
import_without_ifc_data: bool
|
||||
skip_autosave_recovery: bool
|
||||
use_detailed_tooltip: bool
|
||||
|
||||
@classmethod
|
||||
@@ -1041,7 +1044,26 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
|
||||
|
||||
return tooltip
|
||||
|
||||
def check_autosave_recovery(self, context: bpy.types.Context) -> set["rna_enums.OperatorReturnItems"] | None:
|
||||
if self.skip_autosave_recovery:
|
||||
return None
|
||||
autosaved_filepath = tool.Autosave.get_newer_autosaved_path(self.get_filepath_abs())
|
||||
if not autosaved_filepath:
|
||||
return None
|
||||
return bpy.ops.bim.load_autosaved_recovery_popup(
|
||||
"INVOKE_DEFAULT",
|
||||
original_filepath=str(self.get_filepath_abs()),
|
||||
autosaved_filepath=autosaved_filepath,
|
||||
is_advanced=self.is_advanced,
|
||||
use_relative_path=self.use_relative_path,
|
||||
should_start_fresh_session=self.should_start_fresh_session,
|
||||
import_without_ifc_data=self.import_without_ifc_data,
|
||||
)
|
||||
|
||||
def execute(self, context):
|
||||
if recovery := self.check_autosave_recovery(context):
|
||||
return recovery
|
||||
|
||||
if (
|
||||
tool.Blender.get_addon_preferences().save_metadata_blend_file
|
||||
and self.should_start_fresh_session
|
||||
@@ -1136,7 +1158,8 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
|
||||
props.should_save_metadata_for_this_file = metadata_doc is not None
|
||||
|
||||
tool.Blender.register_toolbar()
|
||||
tool.Project.add_recent_ifc_project(self.get_filepath_abs())
|
||||
if not self.skip_recent:
|
||||
tool.Project.add_recent_ifc_project(self.get_filepath_abs())
|
||||
|
||||
if self.is_advanced:
|
||||
pass
|
||||
@@ -1149,10 +1172,13 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
|
||||
except:
|
||||
bonsai.last_error = traceback.format_exc()
|
||||
raise
|
||||
tool.Autosave.reset_timer()
|
||||
return {"FINISHED"}
|
||||
|
||||
def invoke(self, context, event):
|
||||
if self.filepath:
|
||||
if recovery := self.check_autosave_recovery(context):
|
||||
return recovery
|
||||
return self.execute(context)
|
||||
return ImportHelper.invoke(self, context, event)
|
||||
|
||||
@@ -1294,6 +1320,11 @@ class LoadProjectElements(bpy.types.Operator):
|
||||
if element.IsDecomposedBy:
|
||||
for subelement in element.IsDecomposedBy[0].RelatedObjects:
|
||||
decomposed_elements.add(subelement)
|
||||
# IfcSurfaceFeature (e.g. road markings) adhere to a host element
|
||||
# via IfcRelAdheresToElement, a [1:1] hierarchical relationship in
|
||||
# the same family as aggregation, containment and nesting (IFC4.3).
|
||||
for rel in getattr(element, "HasSurfaceFeatures", ()):
|
||||
decomposed_elements.update(rel.RelatedSurfaceFeatures)
|
||||
if decomposed_elements:
|
||||
self.append_decomposed_elements(decomposed_elements)
|
||||
elements.update(decomposed_elements)
|
||||
@@ -1942,6 +1973,7 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
|
||||
json_compact: bpy.props.BoolProperty(name="Export Compact IFCJSON", default=False)
|
||||
should_save_as: bpy.props.BoolProperty(name="Should Save As", default=False, options={"HIDDEN"})
|
||||
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=False)
|
||||
skip_recent: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"})
|
||||
|
||||
if TYPE_CHECKING:
|
||||
filter_glob: str
|
||||
@@ -2002,6 +2034,18 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
|
||||
return {"FINISHED"}
|
||||
|
||||
def _execute(self, context):
|
||||
project_props = tool.Project.get_project_props()
|
||||
project_props.use_relative_project_path = self.use_relative_path
|
||||
|
||||
# Fallback if filepath is not set
|
||||
if not getattr(self, "filepath", None) or self.filepath.strip() in ("", ".ifc"):
|
||||
props = tool.Blender.get_bim_props()
|
||||
if props.ifc_file:
|
||||
self.filepath = str(tool.Blender.ensure_blender_path_is_abs(Path(props.ifc_file)))
|
||||
else:
|
||||
self.report({"ERROR"}, "No filepath available for saving.")
|
||||
return {"CANCELLED"}
|
||||
|
||||
committed, failed_commits = tool.Parametric.commit_pending_edits()
|
||||
# Previews are session-transient — discard rather than commit. Sibling
|
||||
# gizmo polls gate on each preview's is_active flag, and a stuck flag
|
||||
@@ -2064,7 +2108,8 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
|
||||
settings.logger.info("Export finished in {:.2f} seconds".format(time.time() - start))
|
||||
print("Export finished in {:.2f} seconds".format(time.time() - start))
|
||||
# New project created in Bonsai should be in recent projects too.
|
||||
tool.Project.add_recent_ifc_project(Path(output_file))
|
||||
if not self.skip_recent:
|
||||
tool.Project.add_recent_ifc_project(Path(output_file))
|
||||
props = tool.Project.get_project_props()
|
||||
if props.use_relative_project_path and bpy.data.is_saved:
|
||||
output_file = os.path.relpath(output_file, bpy.path.abspath("//"))
|
||||
@@ -2098,6 +2143,7 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
|
||||
)
|
||||
|
||||
bonsai.bim.handler.refresh_ui_data()
|
||||
tool.Autosave.reset_timer()
|
||||
|
||||
@classmethod
|
||||
def description(cls, context, properties):
|
||||
@@ -2106,6 +2152,97 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
|
||||
return "Save the IFC file. Will save both .IFC/.BLEND files if synced together"
|
||||
|
||||
|
||||
class LoadAutosavedRecoveryPopup(bpy.types.Operator):
|
||||
bl_idname = "bim.load_autosaved_recovery_popup"
|
||||
bl_label = "Recover Autosaved File"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
original_filepath: bpy.props.StringProperty(options={"SKIP_SAVE"})
|
||||
autosaved_filepath: bpy.props.StringProperty(options={"SKIP_SAVE"})
|
||||
is_advanced: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"})
|
||||
use_relative_path: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"})
|
||||
should_start_fresh_session: bpy.props.BoolProperty(default=True, options={"SKIP_SAVE"})
|
||||
import_without_ifc_data: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"})
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
layout.label(text="A newer autosaved copy was found:", icon="INFO")
|
||||
layout.label(text=os.path.basename(self.autosaved_filepath))
|
||||
layout.separator()
|
||||
layout.label(text="Do you want to load the autosaved version instead?")
|
||||
layout.label(text="(Cancel will load the original)")
|
||||
|
||||
def invoke(self, context, event):
|
||||
# invoke_props_dialog is modal - unlike invoke_popup/popup_menu, it
|
||||
# isn't dismissed by the mouse simply leaving its bounds. It always
|
||||
# renders both a fixed "Cancel" button and this confirm_text one, so
|
||||
# the question is framed as Yes/Cancel rather than adding separate
|
||||
# Load buttons on top.
|
||||
return context.window_manager.invoke_props_dialog(
|
||||
self, width=420, title="Recover Autosaved File", confirm_text="Yes"
|
||||
)
|
||||
|
||||
def _load(self, filepath: str, skip_recent: bool) -> set["rna_enums.OperatorReturnItems"]:
|
||||
return bpy.ops.bim.load_project(
|
||||
filepath=filepath,
|
||||
skip_autosave_recovery=True, # Prevent infinite loop
|
||||
is_advanced=self.is_advanced,
|
||||
use_relative_path=self.use_relative_path,
|
||||
should_start_fresh_session=self.should_start_fresh_session,
|
||||
import_without_ifc_data=self.import_without_ifc_data,
|
||||
skip_recent=skip_recent,
|
||||
)
|
||||
|
||||
def execute(self, context):
|
||||
result = self._load(self.autosaved_filepath, skip_recent=True)
|
||||
# Re-point tracking at the original path so future saves write back
|
||||
# to it, not "_autosaved.ifc".
|
||||
tool.Ifc.set_path(self.original_filepath)
|
||||
return result
|
||||
|
||||
def cancel(self, context):
|
||||
# Also reached via Escape or a click outside the dialog, not just Cancel.
|
||||
self._load(self.original_filepath, skip_recent=False)
|
||||
|
||||
|
||||
class AutosavePrompt(bpy.types.Operator):
|
||||
bl_idname = "bim.autosave_prompt"
|
||||
bl_label = "Autosave Reminder"
|
||||
bl_options = set()
|
||||
|
||||
def invoke(self, context, event):
|
||||
return context.window_manager.invoke_props_dialog(
|
||||
self, width=400, confirm_text="Save", title="Autosave Reminder"
|
||||
)
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
layout.label(text="The autosave timer has expired.", icon="INFO")
|
||||
layout.label(text="Would you like to save your IFC project now?")
|
||||
|
||||
def execute(self, context):
|
||||
# Get current IFC path
|
||||
props = tool.Blender.get_bim_props()
|
||||
current_ifc_path = props.ifc_file
|
||||
|
||||
if not current_ifc_path:
|
||||
self.report({"WARNING"}, "No IFC file path set. Please save manually.")
|
||||
tool.Autosave.reset_timer()
|
||||
return {"CANCELLED"}
|
||||
|
||||
# Call save_project with explicit filepath using EXEC_DEFAULT
|
||||
result = bpy.ops.bim.save_project(
|
||||
"EXEC_DEFAULT", filepath=current_ifc_path, should_save_as=False, skip_recent=True
|
||||
)
|
||||
|
||||
tool.Autosave.reset_timer()
|
||||
return result
|
||||
|
||||
def cancel(self, context):
|
||||
tool.Autosave.reset_timer()
|
||||
return {"CANCELLED"}
|
||||
|
||||
|
||||
class LoadLinkedProject(bpy.types.Operator, ImportHelper):
|
||||
bl_idname = "bim.load_linked_project"
|
||||
bl_label = "Load Project For Viewing Only"
|
||||
|
||||
@@ -23,6 +23,8 @@ from . import operator, prop, ui
|
||||
classes = (
|
||||
operator.AddContextDependentUnit,
|
||||
operator.AddConversionBasedUnit,
|
||||
operator.AddDerivedUnit,
|
||||
operator.AddDerivedUnitElement,
|
||||
operator.AddMonetaryUnit,
|
||||
operator.AddSIUnit,
|
||||
operator.AssignSceneUnits,
|
||||
@@ -32,9 +34,11 @@ classes = (
|
||||
operator.EditUnit,
|
||||
operator.EnableEditingUnit,
|
||||
operator.LoadUnits,
|
||||
operator.RemoveDerivedUnitElement,
|
||||
operator.RemoveUnit,
|
||||
operator.UnassignUnit,
|
||||
prop.Unit,
|
||||
prop.DerivedUnitElement,
|
||||
prop.BIMUnitProperties,
|
||||
ui.BIM_PT_units,
|
||||
ui.BIM_UL_units,
|
||||
|
||||
@@ -41,6 +41,8 @@ class UnitsData:
|
||||
"unit_classes": cls.unit_classes(),
|
||||
"named_unit_types": cls.named_unit_types(),
|
||||
"conversion_unit_types": cls.conversion_unit_types(),
|
||||
"derived_unit_types": cls.derived_unit_types(),
|
||||
"named_units": cls.named_units(),
|
||||
"total_units": cls.get_total_units(),
|
||||
}
|
||||
cls.is_loaded = True
|
||||
@@ -88,6 +90,21 @@ class UnitsData:
|
||||
def conversion_unit_types(cls):
|
||||
return [(u, u, "") for u in ifcopenshell.util.unit.si_conversions.keys()]
|
||||
|
||||
@classmethod
|
||||
def derived_unit_types(cls):
|
||||
assert (entity := tool.Ifc.schema().declaration_by_name("IfcDerivedUnit").as_entity())
|
||||
values = ifcopenshell.util.attribute.get_enum_items(entity.all_attributes()[1])
|
||||
# USERDEFINED is skipped as it additionally requires a UserDefinedType.
|
||||
return [(c, c, "") for c in sorted(values) if c != "USERDEFINED"]
|
||||
|
||||
@classmethod
|
||||
def named_units(cls):
|
||||
results = []
|
||||
for unit in sorted(tool.Ifc.get().by_type("IfcNamedUnit"), key=lambda u: u.id()):
|
||||
name = ifcopenshell.util.unit.get_full_unit_name(unit)
|
||||
results.append((str(unit.id()), f"{name} ({unit.UnitType})", ""))
|
||||
return results
|
||||
|
||||
@classmethod
|
||||
def get_total_units(cls):
|
||||
ifc = tool.Ifc.get()
|
||||
|
||||
@@ -132,6 +132,56 @@ class AddContextDependentUnit(bpy.types.Operator, tool.Ifc.Operator):
|
||||
core.add_context_dependent_unit(tool.Ifc, tool.Unit, unit_type=self.unit_type, name=self.name)
|
||||
|
||||
|
||||
class AddDerivedUnitElement(bpy.types.Operator):
|
||||
bl_idname = "bim.add_derived_unit_element"
|
||||
bl_label = "Add Derived Unit Element"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = "Add a new named unit and exponent to the derived unit definition"
|
||||
|
||||
def execute(self, context) -> set["rna_enums.OperatorReturnItems"]:
|
||||
props = tool.Unit.get_unit_props()
|
||||
props.derived_unit_elements.add()
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class RemoveDerivedUnitElement(bpy.types.Operator):
|
||||
bl_idname = "bim.remove_derived_unit_element"
|
||||
bl_label = "Remove Derived Unit Element"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = "Remove this named unit and exponent from the derived unit definition"
|
||||
index: bpy.props.IntProperty()
|
||||
|
||||
def execute(self, context) -> set["rna_enums.OperatorReturnItems"]:
|
||||
props = tool.Unit.get_unit_props()
|
||||
props.derived_unit_elements.remove(self.index)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class AddDerivedUnit(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.add_derived_unit"
|
||||
bl_label = "Add Derived Unit"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = "Add a derived unit built from the defined named units and exponents"
|
||||
|
||||
def _execute(self, context):
|
||||
props = tool.Unit.get_unit_props()
|
||||
elements = {}
|
||||
for element in props.derived_unit_elements:
|
||||
if not element.unit:
|
||||
self.report({"ERROR"}, "No named unit selected. Add a named unit (e.g. an SI unit) first.")
|
||||
return {"CANCELLED"}
|
||||
unit = tool.Ifc.get().by_id(int(element.unit))
|
||||
if unit in elements:
|
||||
self.report({"ERROR"}, f"Unit '{unit.Name}' is used in more than one element.")
|
||||
return {"CANCELLED"}
|
||||
elements[unit] = element.exponent
|
||||
if not elements:
|
||||
self.report({"ERROR"}, "A derived unit requires at least one element. Click 'Add Element' first.")
|
||||
return {"CANCELLED"}
|
||||
core.add_derived_unit(tool.Ifc, tool.Unit, unit_type=props.derived_unit_types, elements=elements)
|
||||
props.derived_unit_elements.clear()
|
||||
|
||||
|
||||
class EnableEditingUnit(bpy.types.Operator):
|
||||
bl_idname = "bim.enable_editing_unit"
|
||||
bl_label = "Enable Editing Unit"
|
||||
|
||||
@@ -50,6 +50,27 @@ def get_named_unit_types(self: "BIMUnitProperties", context: bpy.types.Context)
|
||||
return UnitsData.data["named_unit_types"]
|
||||
|
||||
|
||||
def get_derived_unit_types(self: "BIMUnitProperties", context: bpy.types.Context) -> list[tuple[str, str, str]]:
|
||||
if not UnitsData.is_loaded:
|
||||
UnitsData.load()
|
||||
return UnitsData.data["derived_unit_types"]
|
||||
|
||||
|
||||
def get_named_units(self: "DerivedUnitElement", context: bpy.types.Context) -> list[tuple[str, str, str]]:
|
||||
if not UnitsData.is_loaded:
|
||||
UnitsData.load()
|
||||
return UnitsData.data["named_units"]
|
||||
|
||||
|
||||
class DerivedUnitElement(PropertyGroup):
|
||||
unit: EnumProperty(items=get_named_units, name="Unit")
|
||||
exponent: IntProperty(name="Exponent", default=1)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
unit: str
|
||||
exponent: int
|
||||
|
||||
|
||||
class Unit(PropertyGroup):
|
||||
unit_type: StringProperty(name="Unit Type")
|
||||
is_assigned: BoolProperty(name="Is Assigned")
|
||||
@@ -71,6 +92,8 @@ class BIMUnitProperties(PropertyGroup):
|
||||
unit_classes: EnumProperty(items=get_unit_classes, name="Unit Classes")
|
||||
conversion_unit_types: EnumProperty(items=get_conversion_unit_types, name="Conversion Unit Types")
|
||||
named_unit_types: EnumProperty(items=get_named_unit_types, name="Named Unit Types")
|
||||
derived_unit_types: EnumProperty(items=get_derived_unit_types, name="Derived Unit Types")
|
||||
derived_unit_elements: CollectionProperty(name="Derived Unit Elements", type=DerivedUnitElement)
|
||||
unit_attributes: CollectionProperty(name="Unit Attributes", type=Attribute)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -81,4 +104,6 @@ class BIMUnitProperties(PropertyGroup):
|
||||
unit_classes: str
|
||||
conversion_unit_types: str
|
||||
named_unit_types: str
|
||||
derived_unit_types: str
|
||||
derived_unit_elements: bpy.types.bpy_prop_collection_idprop[DerivedUnitElement]
|
||||
unit_attributes: bpy.types.bpy_prop_collection_idprop[Attribute]
|
||||
|
||||
@@ -85,7 +85,16 @@ class BIM_PT_units(Panel):
|
||||
op = row.operator("bim.add_conversion_based_unit", text="", icon="ADD")
|
||||
op.name = self.props.conversion_unit_types
|
||||
elif self.props.unit_classes == "IfcDerivedUnit":
|
||||
pass # TODO
|
||||
prop_with_search(row, self.props, "derived_unit_types", text="")
|
||||
row.operator("bim.add_derived_unit", text="", icon="ADD")
|
||||
box = self.layout.box()
|
||||
for i, element in enumerate(self.props.derived_unit_elements):
|
||||
row = box.row(align=True)
|
||||
prop_with_search(row, element, "unit", text="")
|
||||
row.prop(element, "exponent", text="")
|
||||
row.operator("bim.remove_derived_unit_element", text="", icon="X").index = i
|
||||
row = box.row()
|
||||
row.operator("bim.add_derived_unit_element", text="Add Element", icon="ADD")
|
||||
elif self.props.unit_classes == "IfcSIUnit":
|
||||
prop_with_search(row, self.props, "named_unit_types", text="")
|
||||
op = row.operator("bim.add_si_unit", text="", icon="ADD")
|
||||
|
||||
@@ -577,6 +577,43 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
|
||||
should_disable_undo_on_save: BoolProperty(
|
||||
name="Disable Undo When Saving (Faster saves, no undo for you!)", default=False
|
||||
)
|
||||
|
||||
def update_autosave_settings(self, context: bpy.types.Context) -> None:
|
||||
if self.autosave_enabled:
|
||||
tool.Autosave.reset_timer()
|
||||
else:
|
||||
tool.Autosave.cancel_timer()
|
||||
|
||||
autosave_enabled: BoolProperty(
|
||||
name="Enable IFC Autosave Timer",
|
||||
description="Periodically remind you to save or automatically create a backup copy of the IFC file",
|
||||
default=False,
|
||||
update=update_autosave_settings,
|
||||
)
|
||||
autosave_interval_minutes: bpy.props.IntProperty(
|
||||
name="Autosave Interval (Minutes)",
|
||||
description="Time between autosave reminders or backups. The timer resets whenever you open or save a project",
|
||||
default=10,
|
||||
min=1,
|
||||
max=1440,
|
||||
update=update_autosave_settings,
|
||||
)
|
||||
autosave_mode: bpy.props.EnumProperty(
|
||||
name="Autosave Mode",
|
||||
items=[
|
||||
(
|
||||
"PROMPT",
|
||||
"Prompt to Save",
|
||||
"Show a dialog offering to save the IFC project when the timer expires",
|
||||
),
|
||||
(
|
||||
"BACKUP",
|
||||
"Automatic Backup",
|
||||
"Save a backup copy as filename_autosaved.ifc when the timer expires",
|
||||
),
|
||||
],
|
||||
default="PROMPT",
|
||||
)
|
||||
should_stream: BoolProperty(name="Stream Data From IFC-SPF (Only for advanced users)", default=False)
|
||||
should_always_cache: BoolProperty(
|
||||
name="Always Cache Geometry",
|
||||
@@ -689,6 +726,9 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
|
||||
bsdd_load_test_dictionaries: bool
|
||||
bsdd_baseurl: str
|
||||
should_disable_undo_on_save: bool
|
||||
autosave_enabled: bool
|
||||
autosave_interval_minutes: int
|
||||
autosave_mode: Literal["PROMPT", "BACKUP"]
|
||||
should_stream: bool
|
||||
should_always_cache: bool
|
||||
occurrence_name_style: Literal["CLASS", "TYPE", "CUSTOM"]
|
||||
@@ -837,6 +877,12 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
|
||||
def draw_other_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
|
||||
layout.prop(self, "opening_focus_opacity")
|
||||
layout.prop(self, "should_disable_undo_on_save")
|
||||
layout.separator()
|
||||
layout.label(text="Autosave:")
|
||||
layout.prop(self, "autosave_enabled")
|
||||
if self.autosave_enabled:
|
||||
layout.prop(self, "autosave_interval_minutes")
|
||||
layout.prop(self, "autosave_mode")
|
||||
layout.prop(self, "should_stream")
|
||||
layout.prop(self, "should_always_cache")
|
||||
layout.label(text="bSDD:")
|
||||
|
||||
@@ -302,9 +302,25 @@ def add_drawing(
|
||||
context=drawing.get_body_context(),
|
||||
ifc_representation_class=None,
|
||||
)
|
||||
|
||||
drawings_parent_group = None
|
||||
for group in ifc.get().by_type("IfcGroup"):
|
||||
if group.Name == "DRAWINGS" and group.ObjectType == "DRAWINGS":
|
||||
drawings_parent_group = group
|
||||
break
|
||||
|
||||
if not drawings_parent_group:
|
||||
drawings_parent_group = ifc.run("group.add_group")
|
||||
ifc.run(
|
||||
"group.edit_group", group=drawings_parent_group, attributes={"Name": "DRAWINGS", "ObjectType": "DRAWINGS"}
|
||||
)
|
||||
|
||||
group = ifc.run("group.add_group")
|
||||
ifc.run("group.edit_group", group=group, attributes={"Name": drawing_name, "ObjectType": "DRAWING"})
|
||||
ifc.run("group.assign_group", group=group, products=[element])
|
||||
|
||||
ifc.run("group.assign_group", group=drawings_parent_group, products=[group])
|
||||
|
||||
collector.assign(camera)
|
||||
pset = ifc.run("pset.add_pset", product=element, name="EPset_Drawing")
|
||||
if drawing.get_unit_system() == "METRIC":
|
||||
@@ -335,7 +351,22 @@ def add_drawing(
|
||||
},
|
||||
)
|
||||
drawing.setup_shading_styles_path(shading_styles_path)
|
||||
information = ifc.run("document.add_information")
|
||||
|
||||
drawings_parent_document = None
|
||||
for document in ifc.get().by_type("IfcDocumentInformation"):
|
||||
if document.Name == "DRAWINGS" and document.Scope == "DRAWINGS":
|
||||
drawings_parent_document = document
|
||||
break
|
||||
|
||||
if not drawings_parent_document:
|
||||
drawings_parent_document = ifc.run("document.add_information")
|
||||
if ifc.get_schema() == "IFC2X3":
|
||||
attributes = {"DocumentId": "DRAWINGS", "Name": "DRAWINGS", "Scope": "DRAWINGS"}
|
||||
else:
|
||||
attributes = {"Identification": "DRAWINGS", "Name": "DRAWINGS", "Scope": "DRAWINGS"}
|
||||
ifc.run("document.edit_information", information=drawings_parent_document, attributes=attributes)
|
||||
|
||||
information = ifc.run("document.add_information", parent=drawings_parent_document)
|
||||
uri = drawing.get_default_drawing_path(drawing_name)
|
||||
reference = ifc.run("document.add_reference", information=information)
|
||||
if ifc.get_schema() == "IFC2X3":
|
||||
@@ -363,9 +394,23 @@ def duplicate_drawing(
|
||||
drawing_tool.set_name(new_drawing, drawing_name)
|
||||
group = drawing_tool.get_drawing_group(new_drawing)
|
||||
ifc.run("group.unassign_group", group=group, products=[new_drawing])
|
||||
|
||||
drawings_parent_group = None
|
||||
for parent_group in ifc.get().by_type("IfcGroup"):
|
||||
if parent_group.Name == "DRAWINGS" and parent_group.ObjectType == "DRAWINGS":
|
||||
drawings_parent_group = parent_group
|
||||
break
|
||||
|
||||
if not drawings_parent_group:
|
||||
drawings_parent_group = ifc.run("group.add_group")
|
||||
ifc.run(
|
||||
"group.edit_group", group=drawings_parent_group, attributes={"Name": "DRAWINGS", "ObjectType": "DRAWINGS"}
|
||||
)
|
||||
|
||||
new_group = ifc.run("group.add_group")
|
||||
ifc.run("group.edit_group", group=new_group, attributes={"Name": drawing_name, "ObjectType": "DRAWING"})
|
||||
ifc.run("group.assign_group", group=new_group, products=[new_drawing])
|
||||
ifc.run("group.assign_group", group=drawings_parent_group, products=[new_group])
|
||||
if should_duplicate_annotations:
|
||||
new_annotations: list[ifcopenshell.entity_instance] = []
|
||||
annotation_objs = [ifc.get_object(a) for a in drawing_tool.get_group_elements(group) if a != drawing]
|
||||
@@ -381,7 +426,21 @@ def duplicate_drawing(
|
||||
old_reference = drawing_tool.get_drawing_document(new_drawing)
|
||||
ifc.run("document.unassign_document", products=[new_drawing], document=old_reference)
|
||||
|
||||
information = ifc.run("document.add_information")
|
||||
drawings_parent_document = None
|
||||
for document in ifc.get().by_type("IfcDocumentInformation"):
|
||||
if document.Name == "DRAWINGS" and document.Scope == "DRAWINGS":
|
||||
drawings_parent_document = document
|
||||
break
|
||||
|
||||
if not drawings_parent_document:
|
||||
drawings_parent_document = ifc.run("document.add_information")
|
||||
if ifc.get_schema() == "IFC2X3":
|
||||
attributes = {"DocumentId": "DRAWINGS", "Name": "DRAWINGS", "Scope": "DRAWINGS"}
|
||||
else:
|
||||
attributes = {"Identification": "DRAWINGS", "Name": "DRAWINGS", "Scope": "DRAWINGS"}
|
||||
ifc.run("document.edit_information", information=drawings_parent_document, attributes=attributes)
|
||||
|
||||
information = ifc.run("document.add_information", parent=drawings_parent_document)
|
||||
uri = drawing_tool.get_default_drawing_path(drawing_name)
|
||||
reference = ifc.run("document.add_reference", information=information)
|
||||
if ifc.get_schema() == "IFC2X3":
|
||||
|
||||
@@ -50,14 +50,15 @@ def copy_z_rotation_to_selected(
|
||||
flip: bool = False,
|
||||
) -> int:
|
||||
"""Apply ``active``'s Z-Euler rotation to each target."""
|
||||
source_z = surveyor.get_z_rotation(active)
|
||||
source_z = surveyor.get_z_rotation(active) # ty: ignore[missing-argument]
|
||||
if flip:
|
||||
source_z += math.pi
|
||||
rotated = 0
|
||||
for obj in targets:
|
||||
if abs(_z_rotation_diff(surveyor.get_z_rotation(obj), source_z)) < Z_ROTATION_ALIGNMENT_TOLERANCE:
|
||||
target_z = surveyor.get_z_rotation(obj) # ty: ignore[missing-argument]
|
||||
if abs(_z_rotation_diff(target_z, source_z)) < Z_ROTATION_ALIGNMENT_TOLERANCE:
|
||||
continue
|
||||
surveyor.set_z_rotation(obj, source_z)
|
||||
surveyor.set_z_rotation(obj, source_z) # ty: ignore[missing-argument]
|
||||
rotated += 1
|
||||
if ifc.get_entity(obj) is not None:
|
||||
bonsai.core.geometry.edit_object_placement(ifc, geometry, surveyor, obj=obj)
|
||||
|
||||
@@ -804,7 +804,7 @@ class Profile:
|
||||
|
||||
@interface
|
||||
class Parametric:
|
||||
def get_geom_generation(cls) -> int: pass
|
||||
def get_geom_generation(cls): pass
|
||||
def refresh_post_commit(cls, operator) -> None: pass
|
||||
|
||||
|
||||
|
||||
@@ -91,6 +91,17 @@ def add_conversion_based_unit(ifc: type[tool.Ifc], unit: type[tool.Unit], name:
|
||||
return result
|
||||
|
||||
|
||||
def add_derived_unit(
|
||||
ifc: type[tool.Ifc],
|
||||
unit: type[tool.Unit],
|
||||
unit_type: str,
|
||||
elements: dict[ifcopenshell.entity_instance, int],
|
||||
) -> ifcopenshell.entity_instance:
|
||||
result = ifc.run("unit.add_derived_unit", unit_type=unit_type, userdefinedtype=None, attributes=elements)
|
||||
unit.import_units()
|
||||
return result
|
||||
|
||||
|
||||
def enable_editing_unit(unit_tool: type[tool.Unit], unit: ifcopenshell.entity_instance) -> None:
|
||||
unit_tool.set_active_unit(unit)
|
||||
unit_tool.import_unit_attributes(unit)
|
||||
|
||||
@@ -80,3 +80,6 @@ from bonsai.tool.type import Type
|
||||
from bonsai.tool.unit import Unit
|
||||
from bonsai.tool.wall import Wall
|
||||
from bonsai.tool.web import Web
|
||||
|
||||
# Have to move after import of tool.drawing
|
||||
from bonsai.tool.autosave import Autosave # isort: skip
|
||||
|
||||
@@ -178,6 +178,25 @@ class Array(bonsai.core.tool.Array):
|
||||
element_root = cls.get_array_root_guid(element)
|
||||
return [o for o in occurrences if cls.get_array_root_guid(o) == element_root]
|
||||
|
||||
@classmethod
|
||||
def select_only_parent(cls, parent_obj: bpy.types.Object, context: bpy.types.Context) -> None:
|
||||
"""Post-condition for the user-facing regenerate and finish-edit paths:
|
||||
only ``parent_obj`` is selected + active. Grow and shrink otherwise
|
||||
diverge on which objects stay selected, surfacing an inconsistency."""
|
||||
tool.Blender.select_and_activate_single_object(context, parent_obj)
|
||||
|
||||
@classmethod
|
||||
def is_array_child(cls, element: entity_instance) -> bool:
|
||||
"""True when ``element`` is a child of a parametric array — has a
|
||||
BBIM_Array pset whose Parent GUID points to a different element.
|
||||
Lighter than ``get_child_layer_index`` (no ``by_guid`` lookup, no
|
||||
Data parse); suitable for per-element checks in draw handlers."""
|
||||
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
|
||||
if not pset:
|
||||
return False
|
||||
parent_guid = pset.get("Parent")
|
||||
return bool(parent_guid) and parent_guid != element.GlobalId
|
||||
|
||||
@classmethod
|
||||
def get_child_layer_index(cls, child_element: entity_instance) -> int | None:
|
||||
"""Index of the layer that produced ``child_element``, or ``None``
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
# 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.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
|
||||
import bpy
|
||||
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim import export_ifc
|
||||
from bonsai.bim.module.model import preview_base
|
||||
|
||||
AUTOSAVING_SUFFIX = "_autosaving.ifc"
|
||||
AUTOSAVED_SUFFIX = "_autosaved.ifc"
|
||||
|
||||
_timer_callback: Union[Callable[[], None], None] = None
|
||||
# See cleanup_stale_autosave() for why this is a cached plain string rather
|
||||
# than looked up live.
|
||||
_active_ifc_path_cache: Union[str, None] = None
|
||||
|
||||
|
||||
class Autosave:
|
||||
@classmethod
|
||||
def get_paths(cls, ifc_path: Union[str, Path]) -> tuple[Path, Path, Path]:
|
||||
path = Path(ifc_path)
|
||||
stem = path.stem if path.suffix.lower() == ".ifc" else path.name
|
||||
parent = path.parent
|
||||
main_path = path if path.suffix.lower() == ".ifc" else parent / f"{stem}.ifc"
|
||||
autosaving_path = parent / f"{stem}{AUTOSAVING_SUFFIX}"
|
||||
autosaved_path = parent / f"{stem}{AUTOSAVED_SUFFIX}"
|
||||
return main_path, autosaving_path, autosaved_path
|
||||
|
||||
@classmethod
|
||||
def get_active_ifc_path(cls) -> Union[Path, None]:
|
||||
props = tool.Blender.get_bim_props()
|
||||
if not props.ifc_file:
|
||||
return None
|
||||
path = tool.Blender.ensure_blender_path_is_abs(Path(props.ifc_file))
|
||||
if path.suffix.lower() != ".ifc":
|
||||
return None
|
||||
return path
|
||||
|
||||
@classmethod
|
||||
def _update_active_ifc_path_cache(cls) -> None:
|
||||
global _active_ifc_path_cache
|
||||
ifc_path = cls.get_active_ifc_path()
|
||||
_active_ifc_path_cache = ifc_path.as_posix() if ifc_path is not None else None
|
||||
|
||||
@classmethod
|
||||
def is_enabled(cls) -> bool:
|
||||
return bool(tool.Blender.get_addon_preferences().autosave_enabled)
|
||||
|
||||
@classmethod
|
||||
def get_interval_seconds(cls) -> float:
|
||||
minutes = tool.Blender.get_addon_preferences().autosave_interval_minutes
|
||||
return max(1.0, float(minutes) * 60.0)
|
||||
|
||||
@classmethod
|
||||
def is_eligible(cls) -> bool:
|
||||
return cls.is_enabled() and tool.Ifc.get() is not None and cls.get_active_ifc_path() is not None
|
||||
|
||||
@classmethod
|
||||
def cancel_timer(cls) -> None:
|
||||
global _timer_callback
|
||||
if _timer_callback is not None and bpy.app.timers.is_registered(_timer_callback):
|
||||
bpy.app.timers.unregister(_timer_callback)
|
||||
_timer_callback = None
|
||||
|
||||
@classmethod
|
||||
def reset_timer(cls) -> None:
|
||||
cls.cancel_timer()
|
||||
cls._update_active_ifc_path_cache()
|
||||
if not cls.is_eligible():
|
||||
return
|
||||
|
||||
def on_timer() -> None:
|
||||
cls._on_timer_expired()
|
||||
return None
|
||||
|
||||
global _timer_callback
|
||||
_timer_callback = on_timer
|
||||
bpy.app.timers.register(on_timer, first_interval=cls.get_interval_seconds())
|
||||
|
||||
@classmethod
|
||||
def _on_timer_expired(cls) -> None:
|
||||
if not cls.is_eligible():
|
||||
return
|
||||
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
bim_props = tool.Blender.get_bim_props()
|
||||
|
||||
if bim_props.is_dirty:
|
||||
if prefs.autosave_mode == "PROMPT":
|
||||
bpy.ops.bim.autosave_prompt("INVOKE_DEFAULT")
|
||||
elif prefs.autosave_mode == "BACKUP":
|
||||
try:
|
||||
cls.perform_backup(bpy.context)
|
||||
except Exception as error:
|
||||
print(f"Bonsai: autosave backup failed: {error}")
|
||||
cls.reset_timer()
|
||||
|
||||
@classmethod
|
||||
def perform_backup(cls, context: bpy.types.Context) -> None:
|
||||
ifc_path = cls.get_active_ifc_path()
|
||||
if ifc_path is None:
|
||||
return
|
||||
|
||||
_, autosaving_path, autosaved_path = cls.get_paths(ifc_path)
|
||||
autosaving_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
tool.Parametric.commit_pending_edits()
|
||||
preview_base.discard_pending_previews(context.scene)
|
||||
|
||||
logger = logging.getLogger("ExportIFC")
|
||||
output_file = autosaving_path.as_posix().replace("\\", "/")
|
||||
settings = export_ifc.IfcExportSettings.factory(context, output_file, logger)
|
||||
export_ifc.IfcExporter(settings).export()
|
||||
|
||||
try:
|
||||
os.replace(autosaving_path, autosaved_path)
|
||||
except OSError:
|
||||
if autosaving_path.is_file():
|
||||
autosaving_path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
@classmethod
|
||||
def get_newer_autosaved_path(cls, ifc_path: Union[str, Path]) -> Union[str, None]:
|
||||
path = Path(ifc_path)
|
||||
if path.suffix.lower() != ".ifc" or not path.is_file():
|
||||
return None
|
||||
|
||||
_, _, autosaved_path = cls.get_paths(path)
|
||||
if not autosaved_path.is_file():
|
||||
return None
|
||||
if autosaved_path.stat().st_mtime > path.stat().st_mtime:
|
||||
return autosaved_path.as_posix().replace("\\", "/")
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def cleanup_stale_autosave(cls) -> None:
|
||||
"""Remove the active IFC's autosave file(s) on a graceful shutdown.
|
||||
|
||||
Registered via `atexit`, which only runs on a normal interpreter
|
||||
shutdown - never on an actual crash. So a deliberate quit (whether
|
||||
the user saved or chose "don't save") clears the recovery file and
|
||||
won't prompt on next startup, while a genuine crash leaves it in
|
||||
place for recovery, since no atexit callbacks fire then.
|
||||
|
||||
Deliberately reads only `_active_ifc_path_cache` - a plain string
|
||||
kept up to date by `reset_timer()` - rather than touching `bpy` here.
|
||||
By the time `atexit` fires, Blender's own C++ side is torn down far
|
||||
enough that even reading `bpy.context.scene` aborts the process
|
||||
(std::bad_optional_access) instead of raising a catchable exception.
|
||||
"""
|
||||
if _active_ifc_path_cache is None:
|
||||
return
|
||||
try:
|
||||
_, autosaving_path, autosaved_path = cls.get_paths(_active_ifc_path_cache)
|
||||
autosaving_path.unlink(missing_ok=True)
|
||||
autosaved_path.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
atexit.register(Autosave.cleanup_stale_autosave)
|
||||
@@ -248,32 +248,30 @@ class Duplicate(bonsai.core.tool.Duplicate):
|
||||
old_to_new: dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]],
|
||||
) -> None:
|
||||
for element, data in relationship.items():
|
||||
try:
|
||||
new_relating_element = old_to_new.get(data.relating_element)[0]
|
||||
new_related_element = old_to_new.get(data.related_element)[0]
|
||||
except (KeyError, IndexError, TypeError):
|
||||
continue
|
||||
new_rel = tool.Ifc.run(
|
||||
"geometry.connect_path",
|
||||
relating_element=new_relating_element,
|
||||
related_element=new_related_element,
|
||||
relating_connection=data.relating_connection_type,
|
||||
related_connection=data.related_connection_type,
|
||||
)
|
||||
new_relating_elements = old_to_new.get(data.relating_element) or []
|
||||
new_related_elements = old_to_new.get(data.related_element) or []
|
||||
# connect_path hardcodes priorities to []; restore them post-hoc.
|
||||
priority_attrs: dict[str, Any] = {}
|
||||
if data.relating_priorities:
|
||||
priority_attrs["RelatingPriorities"] = data.relating_priorities
|
||||
if data.related_priorities:
|
||||
priority_attrs["RelatedPriorities"] = data.related_priorities
|
||||
if new_rel is not None and priority_attrs:
|
||||
try:
|
||||
tool.Ifc.run("attribute.edit_attributes", product=new_rel, attributes=priority_attrs)
|
||||
except (RuntimeError, ifcopenshell.Error) as e:
|
||||
cls._emit_warning(
|
||||
f"connection priority restore failed for {new_rel}; "
|
||||
f"duplicate has empty RelatingPriorities/RelatedPriorities: {e}"
|
||||
)
|
||||
for new_relating_element, new_related_element in zip(new_relating_elements, new_related_elements):
|
||||
new_rel = tool.Ifc.run(
|
||||
"geometry.connect_path",
|
||||
relating_element=new_relating_element,
|
||||
related_element=new_related_element,
|
||||
relating_connection=data.relating_connection_type,
|
||||
related_connection=data.related_connection_type,
|
||||
)
|
||||
if new_rel is not None and priority_attrs:
|
||||
try:
|
||||
tool.Ifc.run("attribute.edit_attributes", product=new_rel, attributes=priority_attrs)
|
||||
except (RuntimeError, ifcopenshell.Error) as e:
|
||||
cls._emit_warning(
|
||||
f"connection priority restore failed for {new_rel}; "
|
||||
f"duplicate has empty RelatingPriorities/RelatedPriorities: {e}"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def recreate_port_connections(
|
||||
@@ -283,46 +281,43 @@ class Duplicate(bonsai.core.tool.Duplicate):
|
||||
) -> None:
|
||||
"""Recreate ``IfcRelConnectsPorts`` between duplicates; skip records whose duplicate's port count diverges from the snapshot."""
|
||||
for relating_element, records in snapshot.by_element.items():
|
||||
new_relatings = old_to_new.get(relating_element) or []
|
||||
expected_relating = snapshot.port_counts.get(relating_element)
|
||||
for record in records:
|
||||
related_element = record.related_element
|
||||
try:
|
||||
new_relating = old_to_new[relating_element][0]
|
||||
new_related = old_to_new[related_element][0]
|
||||
except (KeyError, IndexError):
|
||||
continue
|
||||
|
||||
new_relating_ports = tool.System.get_ports(new_relating)
|
||||
new_related_ports = tool.System.get_ports(new_related)
|
||||
|
||||
expected_relating = snapshot.port_counts.get(relating_element)
|
||||
if expected_relating is not None and len(new_relating_ports) != expected_relating:
|
||||
cls._emit_warning(
|
||||
f"port reconnect skipped — duplicate has {len(new_relating_ports)} ports, "
|
||||
f"snapshot had {expected_relating}"
|
||||
)
|
||||
continue
|
||||
new_relateds = old_to_new.get(related_element) or []
|
||||
expected_related = snapshot.port_counts.get(related_element)
|
||||
if expected_related is not None and len(new_related_ports) != expected_related:
|
||||
cls._emit_warning(
|
||||
f"port reconnect skipped — duplicate has {len(new_related_ports)} ports, "
|
||||
f"snapshot had {expected_related}"
|
||||
)
|
||||
continue
|
||||
for new_relating, new_related in zip(new_relatings, new_relateds):
|
||||
new_relating_ports = tool.System.get_ports(new_relating)
|
||||
new_related_ports = tool.System.get_ports(new_related)
|
||||
|
||||
try:
|
||||
new_port_a = new_relating_ports[record.relating_port_index]
|
||||
new_port_b = new_related_ports[record.related_port_index]
|
||||
except IndexError:
|
||||
cls._emit_warning(
|
||||
f"port reconnect skipped — record references port index past the duplicate's port list"
|
||||
)
|
||||
continue
|
||||
try:
|
||||
tool.Ifc.run(
|
||||
"system.connect_port",
|
||||
port1=new_port_a,
|
||||
port2=new_port_b,
|
||||
direction=record.direction or "NOTDEFINED",
|
||||
)
|
||||
except (RuntimeError, ifcopenshell.Error) as e:
|
||||
cls._emit_warning(f"port reconnect failed between duplicates: {e}")
|
||||
if expected_relating is not None and len(new_relating_ports) != expected_relating:
|
||||
cls._emit_warning(
|
||||
f"port reconnect skipped — duplicate has {len(new_relating_ports)} ports, "
|
||||
f"snapshot had {expected_relating}"
|
||||
)
|
||||
continue
|
||||
if expected_related is not None and len(new_related_ports) != expected_related:
|
||||
cls._emit_warning(
|
||||
f"port reconnect skipped — duplicate has {len(new_related_ports)} ports, "
|
||||
f"snapshot had {expected_related}"
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
new_port_a = new_relating_ports[record.relating_port_index]
|
||||
new_port_b = new_related_ports[record.related_port_index]
|
||||
except IndexError:
|
||||
cls._emit_warning(
|
||||
f"port reconnect skipped — record references port index past the duplicate's port list"
|
||||
)
|
||||
continue
|
||||
try:
|
||||
tool.Ifc.run(
|
||||
"system.connect_port",
|
||||
port1=new_port_a,
|
||||
port2=new_port_b,
|
||||
direction=record.direction or "NOTDEFINED",
|
||||
)
|
||||
except (RuntimeError, ifcopenshell.Error) as e:
|
||||
cls._emit_warning(f"port reconnect failed between duplicates: {e}")
|
||||
|
||||
@@ -163,13 +163,21 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
cls._host_update_queue = {}
|
||||
cls._host_recut_queue = {}
|
||||
for voided_obj in update_queue.values():
|
||||
if not voided_obj or not voided_obj.data:
|
||||
try:
|
||||
if not voided_obj or not voided_obj.data:
|
||||
continue
|
||||
except ReferenceError:
|
||||
# Blender object was deleted while the batch was open
|
||||
# (e.g. user removed it via the outliner mid-op).
|
||||
continue
|
||||
if tool.Ifc.get_entity(voided_obj) is None:
|
||||
continue
|
||||
bpy.ops.bim.update_representation(obj=voided_obj.name)
|
||||
for voided_obj, _ in recut_queue.values():
|
||||
if not voided_obj or not voided_obj.data:
|
||||
try:
|
||||
if not voided_obj or not voided_obj.data:
|
||||
continue
|
||||
except ReferenceError:
|
||||
continue
|
||||
if tool.Ifc.get_entity(voided_obj) is None:
|
||||
continue
|
||||
@@ -2481,99 +2489,16 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
old_obj_name_to_new_obj_name: dict[str, str] = {}
|
||||
|
||||
for obj in objects_to_duplicate:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if element:
|
||||
if element.is_a("IfcAnnotation") and element.ObjectType == "DRAWING":
|
||||
tool.Blender.deselect_object(obj)
|
||||
continue # For now, don't copy drawings until we stabilise a bit more. It's tricky.
|
||||
elif tool.Geometry.is_locked(element):
|
||||
tool.Blender.deselect_object(obj)
|
||||
continue
|
||||
elif tool.Geometry.is_representation_item(obj):
|
||||
cls.duplicate_ifc_item(obj)
|
||||
continue
|
||||
|
||||
tracked_opening_type = tool.Model.get_tracked_opening_type(obj)
|
||||
is_tracked_opening = bool(tracked_opening_type)
|
||||
keep_data_linked = linked and not element and not is_tracked_opening
|
||||
|
||||
# Prior to duplicating, sync the object placement to make decomposition recreation more stable.
|
||||
cls.commit_placement_if_moved(obj, apply_scale=False)
|
||||
|
||||
new_obj = obj.copy()
|
||||
temp_data = None
|
||||
|
||||
# Currently for optimization we do not apply pending changes (scale or changed .data)
|
||||
# to the original and duplicated objects.
|
||||
# Keep new object edited if original is.
|
||||
if tool.Ifc.is_edited(obj, ignore_scale=True):
|
||||
tool.Ifc.edit(new_obj)
|
||||
|
||||
if obj.data and not keep_data_linked:
|
||||
# assure root.copy_class won't replace the previous mesh globally
|
||||
temp_data = obj.data.copy()
|
||||
new_obj.data = temp_data
|
||||
|
||||
# Unlink from previous boolean element
|
||||
# and keep object tracked for decorations.
|
||||
if is_tracked_opening:
|
||||
mprops = tool.Geometry.get_mesh_props(new_obj.data)
|
||||
mprops.ifc_boolean_id = 0
|
||||
tool.Root.add_tracked_opening(new_obj, tracked_opening_type)
|
||||
|
||||
if obj == active_object:
|
||||
new_active_obj = new_obj
|
||||
for collection in obj.users_collection:
|
||||
collection.objects.link(new_obj)
|
||||
obj.select_set(False)
|
||||
new_obj.select_set(True)
|
||||
old_obj_name_to_new_obj_name[obj.name] = new_obj.name
|
||||
|
||||
if not element:
|
||||
continue
|
||||
|
||||
# clear object's collection so it will be able to have it's own
|
||||
tool.Blender.get_object_bim_props(new_obj).collection = None
|
||||
# copy the actual class
|
||||
new = bonsai.core.root.copy_class(tool.Ifc, tool.Collector, tool.Geometry, tool.Root, obj=new_obj)
|
||||
|
||||
# clean up the orphaned mesh with ifc id of the original object to avoid confusion
|
||||
# IfcGridAxis keeps the same mesh data (it's pointing to ifc id 0, so it's not a problem)
|
||||
if new and temp_data and not new.is_a("IfcGridAxis"):
|
||||
if new.is_a("IfcRelSpaceBoundary"):
|
||||
surface = new.ConnectionGeometry.SurfaceOnRelatingElement
|
||||
temp_data.name = f"0/{surface.id()}"
|
||||
tool.Ifc.link(surface, temp_data)
|
||||
else:
|
||||
tool.Blender.remove_data_block(temp_data)
|
||||
|
||||
if new:
|
||||
# TODO: handle array data for other cases of duplication
|
||||
array_data = arrays_to_duplicate.get(obj, None)
|
||||
tool.Model.handle_array_on_copied_element(new, array_data)
|
||||
if array_data:
|
||||
for child in tool.Array.get_all_children_objects(new):
|
||||
child.select_set(True)
|
||||
|
||||
# TODO: add new array children to recreate their decomposition too
|
||||
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)
|
||||
new_active = cls._duplicate_ifc_object_once(
|
||||
obj,
|
||||
active_object,
|
||||
linked,
|
||||
arrays_to_duplicate,
|
||||
old_to_new,
|
||||
old_obj_name_to_new_obj_name,
|
||||
)
|
||||
if new_active is not None:
|
||||
new_active_obj = new_active
|
||||
|
||||
# Remap Blender parent relationships for duplicated objects
|
||||
for old_obj_name, new_obj_name in old_obj_name_to_new_obj_name.items():
|
||||
@@ -2601,10 +2526,211 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
# Recreate decompositions
|
||||
tool.Duplicate.recreate_decompositions(decomposition_relationships, old_to_new)
|
||||
cls.remove_linked_aggregate_data(old_to_new)
|
||||
|
||||
# In-loop regenerate_wall runs before recreate_connections, so any new
|
||||
# walls that just received an IfcRelConnectsPathElements have stale
|
||||
# junction geometry — recalculate them now that their connection graph
|
||||
# is complete.
|
||||
cls._recalculate_walls_with_new_connections(old_to_new)
|
||||
|
||||
bonsai.bim.handler.refresh_ui_data()
|
||||
tool.Root.reload_grid_decorator()
|
||||
return old_to_new, new_active_obj or active_object
|
||||
|
||||
@classmethod
|
||||
def duplicate_ifc_object_n_times(
|
||||
cls, source: bpy.types.Object, count: int
|
||||
) -> dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]]:
|
||||
"""N-way duplicate of a single source.
|
||||
|
||||
Same per-copy semantics as duplicate_ifc_objects (IFC class copy,
|
||||
decomposition + connection recreation, body regen for walls), but
|
||||
bypasses the set() dedupe and the arrays_to_duplicate pre-scan so
|
||||
callers building a fresh array don't pay per-call overhead N times.
|
||||
Returns the same old_to_new dict shape, with the source element
|
||||
mapping to the N new entities."""
|
||||
if count <= 0:
|
||||
return {}
|
||||
|
||||
sources = {source}
|
||||
decomposition_relationships = tool.Duplicate.get_decomposition_relationships(sources)
|
||||
connection_relationships = tool.Duplicate.get_connection_relationships(sources)
|
||||
port_connection_snapshot = tool.Duplicate.get_port_connection_relationships(sources)
|
||||
old_to_new: dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]] = {}
|
||||
old_obj_name_to_new_obj_name: dict[str, str] = {}
|
||||
|
||||
for _ in range(count):
|
||||
cls._duplicate_ifc_object_once(
|
||||
source,
|
||||
None,
|
||||
False,
|
||||
{},
|
||||
old_to_new,
|
||||
old_obj_name_to_new_obj_name,
|
||||
keep_source_selected=True,
|
||||
)
|
||||
|
||||
for old_obj_name, new_obj_name in old_obj_name_to_new_obj_name.items():
|
||||
new_obj = bpy.data.objects.get(new_obj_name)
|
||||
if new_obj and new_obj.parent and new_obj.parent.name in old_obj_name_to_new_obj_name:
|
||||
world_matrix = new_obj.matrix_world.copy()
|
||||
new_parent_name = old_obj_name_to_new_obj_name[new_obj.parent.name]
|
||||
new_parent = bpy.data.objects.get(new_parent_name)
|
||||
if new_parent:
|
||||
new_obj.parent = new_parent
|
||||
new_obj.matrix_world = world_matrix
|
||||
|
||||
for old in old_to_new.keys():
|
||||
if old.is_a("IfcElementAssembly"):
|
||||
tool.Root.recreate_aggregate(old_to_new)
|
||||
|
||||
cls.remove_old_connections(old_to_new)
|
||||
tool.Duplicate.recreate_connections(connection_relationships, old_to_new)
|
||||
tool.Duplicate.recreate_port_connections(port_connection_snapshot, old_to_new)
|
||||
tool.Duplicate.recreate_decompositions(decomposition_relationships, old_to_new)
|
||||
cls.remove_linked_aggregate_data(old_to_new)
|
||||
cls._recalculate_walls_with_new_connections(old_to_new)
|
||||
bonsai.bim.handler.refresh_ui_data()
|
||||
tool.Root.reload_grid_decorator()
|
||||
return old_to_new
|
||||
|
||||
@classmethod
|
||||
def _duplicate_ifc_object_once(
|
||||
cls,
|
||||
obj: bpy.types.Object,
|
||||
active_object: Optional[bpy.types.Object],
|
||||
linked: bool,
|
||||
arrays_to_duplicate: dict[bpy.types.Object, Any],
|
||||
old_to_new: dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]],
|
||||
old_obj_name_to_new_obj_name: dict[str, str],
|
||||
keep_source_selected: bool = False,
|
||||
) -> Optional[bpy.types.Object]:
|
||||
"""Per-source body of the duplicate flow. Mutates old_to_new and
|
||||
old_obj_name_to_new_obj_name in place. Returns new_obj when obj is
|
||||
the active_object, else None.
|
||||
|
||||
keep_source_selected: when True, skip the source deselect so batched
|
||||
callers can run N iterations without N×2 select flips and without
|
||||
needing a post-loop restore on the source."""
|
||||
new_active_obj: Optional[bpy.types.Object] = None
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if element:
|
||||
if element.is_a("IfcAnnotation") and element.ObjectType == "DRAWING":
|
||||
tool.Blender.deselect_object(obj)
|
||||
return None # For now, don't copy drawings until we stabilise a bit more. It's tricky.
|
||||
elif tool.Geometry.is_locked(element):
|
||||
tool.Blender.deselect_object(obj)
|
||||
return None
|
||||
elif tool.Geometry.is_representation_item(obj):
|
||||
cls.duplicate_ifc_item(obj)
|
||||
return None
|
||||
|
||||
tracked_opening_type = tool.Model.get_tracked_opening_type(obj)
|
||||
is_tracked_opening = bool(tracked_opening_type)
|
||||
keep_data_linked = linked and not element and not is_tracked_opening
|
||||
|
||||
# Prior to duplicating, sync the object placement to make decomposition recreation more stable.
|
||||
cls.commit_placement_if_moved(obj, apply_scale=False)
|
||||
|
||||
new_obj = obj.copy()
|
||||
temp_data = None
|
||||
|
||||
# Currently for optimization we do not apply pending changes (scale or changed .data)
|
||||
# to the original and duplicated objects.
|
||||
# Keep new object edited if original is.
|
||||
if tool.Ifc.is_edited(obj, ignore_scale=True):
|
||||
tool.Ifc.edit(new_obj)
|
||||
|
||||
if obj.data and not keep_data_linked:
|
||||
# assure root.copy_class won't replace the previous mesh globally
|
||||
temp_data = obj.data.copy()
|
||||
new_obj.data = temp_data
|
||||
|
||||
# Unlink from previous boolean element
|
||||
# and keep object tracked for decorations.
|
||||
if is_tracked_opening:
|
||||
mprops = tool.Geometry.get_mesh_props(new_obj.data)
|
||||
mprops.ifc_boolean_id = 0
|
||||
tool.Root.add_tracked_opening(new_obj, tracked_opening_type)
|
||||
|
||||
if obj == active_object:
|
||||
new_active_obj = new_obj
|
||||
for collection in obj.users_collection:
|
||||
collection.objects.link(new_obj)
|
||||
if not keep_source_selected:
|
||||
obj.select_set(False)
|
||||
new_obj.select_set(True)
|
||||
old_obj_name_to_new_obj_name[obj.name] = new_obj.name
|
||||
|
||||
if not element:
|
||||
return new_active_obj
|
||||
|
||||
# clear object's collection so it will be able to have it's own
|
||||
tool.Blender.get_object_bim_props(new_obj).collection = None
|
||||
# copy the actual class
|
||||
new = bonsai.core.root.copy_class(tool.Ifc, tool.Collector, tool.Geometry, tool.Root, obj=new_obj)
|
||||
|
||||
# clean up the orphaned mesh with ifc id of the original object to avoid confusion
|
||||
# IfcGridAxis keeps the same mesh data (it's pointing to ifc id 0, so it's not a problem)
|
||||
if new and temp_data and not new.is_a("IfcGridAxis"):
|
||||
if new.is_a("IfcRelSpaceBoundary"):
|
||||
surface = new.ConnectionGeometry.SurfaceOnRelatingElement
|
||||
temp_data.name = f"0/{surface.id()}"
|
||||
tool.Ifc.link(surface, temp_data)
|
||||
else:
|
||||
tool.Blender.remove_data_block(temp_data)
|
||||
|
||||
if new:
|
||||
# TODO: handle array data for other cases of duplication
|
||||
array_data = arrays_to_duplicate.get(obj, None)
|
||||
tool.Model.handle_array_on_copied_element(new, array_data)
|
||||
if array_data:
|
||||
for child in tool.Array.get_all_children_objects(new):
|
||||
child.select_set(True)
|
||||
|
||||
# TODO: add new array children to recreate their decomposition too
|
||||
old_to_new.setdefault(element, []).append(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)
|
||||
|
||||
return new_active_obj
|
||||
|
||||
@classmethod
|
||||
def _recalculate_walls_with_new_connections(
|
||||
cls, old_to_new: dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]]
|
||||
) -> None:
|
||||
"""Recalculate new IfcWall duplicates that just received an
|
||||
``IfcRelConnectsPathElements``. The in-loop ``regenerate_wall`` runs
|
||||
before ``recreate_connections``, so wall body geometry doesn't reflect
|
||||
the junction until this second pass."""
|
||||
walls_to_recalc: list[bpy.types.Object] = []
|
||||
for new_list in old_to_new.values():
|
||||
for new_entity in new_list:
|
||||
if not new_entity.is_a("IfcWall"):
|
||||
continue
|
||||
if not (getattr(new_entity, "ConnectedTo", None) or getattr(new_entity, "ConnectedFrom", None)):
|
||||
continue
|
||||
new_obj = tool.Ifc.get_object(new_entity)
|
||||
if new_obj is not None:
|
||||
walls_to_recalc.append(new_obj)
|
||||
if walls_to_recalc:
|
||||
tool.Model.recalculate_walls(walls_to_recalc)
|
||||
|
||||
@classmethod
|
||||
def duplicate_ifc_item(cls, obj: bpy.types.Object) -> None:
|
||||
props = tool.Geometry.get_geometry_props()
|
||||
|
||||
@@ -59,6 +59,7 @@ from ifcopenshell.util.shape_builder import ShapeBuilder, np_to_3d
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
import bonsai.core.geometry
|
||||
import bonsai.core.model
|
||||
import bonsai.core.tool
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim import import_ifc
|
||||
@@ -1247,6 +1248,35 @@ class Model(bonsai.core.tool.Model):
|
||||
with tool.Geometry.batch_host_recut():
|
||||
cls._regenerate_array_body(parent_obj, data, array_layers_to_apply)
|
||||
|
||||
@classmethod
|
||||
def _prune_orphan_array_children(cls, array: dict[str, Any]) -> None:
|
||||
"""Drop GUIDs from ``array['children']`` whose IFC entity or Blender
|
||||
object is no longer alive, and cascade-remove the orphan IFC entity
|
||||
if it still exists. Outliner / keyboard delete of a Bonsai-managed
|
||||
object bypasses ``bim.delete``'s cascade, leaving dangling opening
|
||||
and filling references that later confuse regen and crash the
|
||||
``batch_host_recut`` drain."""
|
||||
live_guids: list[str] = []
|
||||
ifc_file = tool.Ifc.get()
|
||||
for guid in array["children"]:
|
||||
try:
|
||||
element = ifc_file.by_guid(guid)
|
||||
except RuntimeError:
|
||||
continue
|
||||
obj = tool.Ifc.get_object(element)
|
||||
try:
|
||||
is_live = obj is not None and obj.data is not None
|
||||
except ReferenceError:
|
||||
is_live = False
|
||||
if is_live:
|
||||
live_guids.append(guid)
|
||||
continue
|
||||
try:
|
||||
ifcopenshell.api.root.remove_product(ifc_file, product=element)
|
||||
except (RuntimeError, ifcopenshell.Error):
|
||||
pass
|
||||
array["children"] = live_guids
|
||||
|
||||
@classmethod
|
||||
def _regenerate_array_body(
|
||||
cls, parent_obj: bpy.types.Object, data: list[dict[str, Any]], array_layers_to_apply: Iterable[int]
|
||||
@@ -1262,6 +1292,7 @@ class Model(bonsai.core.tool.Model):
|
||||
obj_stack = [parent_obj]
|
||||
|
||||
for array_i, array in enumerate(data):
|
||||
cls._prune_orphan_array_children(array)
|
||||
child_i = 0
|
||||
existing_children = set(array["children"])
|
||||
total_existing_children = len(array["children"])
|
||||
@@ -1275,6 +1306,14 @@ class Model(bonsai.core.tool.Model):
|
||||
else:
|
||||
base_offset = Vector([array["x"], array["y"], array["z"]]) * unit_scale
|
||||
|
||||
target_new_in_this_layer = (array["count"] - 1) * len(obj_stack)
|
||||
missing_count = max(0, target_new_in_this_layer - total_existing_children)
|
||||
new_entities_pool: list[ifcopenshell.entity_instance] = []
|
||||
if missing_count > 0:
|
||||
batch_old_to_new = tool.Geometry.duplicate_ifc_object_n_times(parent_obj, missing_count)
|
||||
new_entities_pool = batch_old_to_new.get(parent_element, [])
|
||||
new_entities_iter = iter(new_entities_pool)
|
||||
|
||||
for i in range(array["count"]):
|
||||
if i == 0:
|
||||
continue
|
||||
@@ -1292,8 +1331,13 @@ class Model(bonsai.core.tool.Model):
|
||||
child_obj = tool.Ifc.get_object(child_element)
|
||||
assert child_obj
|
||||
except (IndexError, RuntimeError, AssertionError):
|
||||
old_to_new, _ = tool.Geometry.duplicate_ifc_objects([parent_obj])
|
||||
child_element = next(iter(old_to_new.values()))[0]
|
||||
try:
|
||||
child_element = next(new_entities_iter)
|
||||
except StopIteration:
|
||||
# Stale-GUID mid-list left the pool exhausted; fall back
|
||||
# to a one-off duplicate so the layer can still complete.
|
||||
old_to_new, _ = tool.Geometry.duplicate_ifc_objects([parent_obj])
|
||||
child_element = next(iter(old_to_new.values()))[0]
|
||||
child_obj = tool.Ifc.get_object(child_element)
|
||||
|
||||
# add child pset
|
||||
@@ -1361,14 +1405,7 @@ class Model(bonsai.core.tool.Model):
|
||||
tool.Ifc.get(), pset=pset, properties={"Data": json_data, "Parent": parent_element.GlobalId}
|
||||
)
|
||||
|
||||
# Post-condition: parent is selected on return. duplicate_ifc_objects
|
||||
# deselects the source on every call inside the regen loop; without
|
||||
# this restore, callers get a deselected parent for arrays with N >= 2.
|
||||
# TODO: batch the per-child duplicate_ifc_objects([parent]) calls into
|
||||
# a single N-way duplicate — N depsgraph churns + N select/deselect
|
||||
# flips is wasteful, and a batched duplicate would also remove the
|
||||
# need for this restore.
|
||||
parent_obj.select_set(True)
|
||||
tool.Blender.set_object_selection(parent_obj, True)
|
||||
|
||||
@classmethod
|
||||
def mirror_parent_void_fillings_to_children(
|
||||
|
||||
@@ -373,35 +373,37 @@ class Root(bonsai.core.tool.Root):
|
||||
try:
|
||||
new_aggregate = old_to_new[old_aggregate]
|
||||
except:
|
||||
bonsai.core.aggregate.unassign_object(
|
||||
tool.Ifc,
|
||||
tool.Aggregate,
|
||||
tool.Collector,
|
||||
relating_obj=tool.Ifc.get_object(old_aggregate),
|
||||
related_obj=tool.Ifc.get_object(new[0]),
|
||||
)
|
||||
continue
|
||||
|
||||
bonsai.core.aggregate.assign_object(
|
||||
tool.Ifc,
|
||||
tool.Aggregate,
|
||||
tool.Collector,
|
||||
relating_obj=tool.Ifc.get_object(new_aggregate[0]),
|
||||
related_obj=tool.Ifc.get_object(new[0]),
|
||||
)
|
||||
|
||||
# Make sure that the array children also get reassigned to the correct aggregate
|
||||
pset = ifcopenshell.util.element.get_pset(new[0], "BBIM_Array")
|
||||
if pset:
|
||||
array_children = tool.Array.get_all_children_objects(new[0])
|
||||
for obj in array_children:
|
||||
bonsai.core.aggregate.assign_object(
|
||||
for new_entity in new:
|
||||
bonsai.core.aggregate.unassign_object(
|
||||
tool.Ifc,
|
||||
tool.Aggregate,
|
||||
tool.Collector,
|
||||
relating_obj=tool.Ifc.get_object(new_aggregate[0]),
|
||||
related_obj=tool.Ifc.get_object(tool.Ifc.get_entity(obj)),
|
||||
relating_obj=tool.Ifc.get_object(old_aggregate),
|
||||
related_obj=tool.Ifc.get_object(new_entity),
|
||||
)
|
||||
continue
|
||||
|
||||
for new_entity in new:
|
||||
bonsai.core.aggregate.assign_object(
|
||||
tool.Ifc,
|
||||
tool.Aggregate,
|
||||
tool.Collector,
|
||||
relating_obj=tool.Ifc.get_object(new_aggregate[0]),
|
||||
related_obj=tool.Ifc.get_object(new_entity),
|
||||
)
|
||||
|
||||
# Make sure that the array children also get reassigned to the correct aggregate
|
||||
pset = ifcopenshell.util.element.get_pset(new_entity, "BBIM_Array")
|
||||
if pset:
|
||||
array_children = tool.Array.get_all_children_objects(new_entity)
|
||||
for obj in array_children:
|
||||
bonsai.core.aggregate.assign_object(
|
||||
tool.Ifc,
|
||||
tool.Aggregate,
|
||||
tool.Collector,
|
||||
relating_obj=tool.Ifc.get_object(new_aggregate[0]),
|
||||
related_obj=tool.Ifc.get_object(tool.Ifc.get_entity(obj)),
|
||||
)
|
||||
|
||||
if new_aggregate is None:
|
||||
return
|
||||
|
||||
@@ -357,6 +357,13 @@ class System(bonsai.core.tool.System):
|
||||
if not cls.is_mep_element(element):
|
||||
continue
|
||||
|
||||
# Array children inherit port topology from their parent's IFC
|
||||
# entity, but their positions are derived — drawing ports on every
|
||||
# copy of an arrayed segment doubles up markers and misleads the
|
||||
# user into thinking each copy has its own port network.
|
||||
if tool.Array.is_array_child(element):
|
||||
continue
|
||||
|
||||
selected_element = element in connected_elements
|
||||
verts_pos = []
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ from unittest.mock import Mock, patch
|
||||
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.pset
|
||||
import pytest
|
||||
|
||||
import bonsai.tool as tool
|
||||
|
||||
@@ -0,0 +1,716 @@
|
||||
# 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.
|
||||
|
||||
"""Tests for the batched array-duplicate path.
|
||||
|
||||
`tool.Geometry.duplicate_ifc_object_n_times` lifts the per-call overhead of
|
||||
`duplicate_ifc_objects` (snapshot, UI refresh, decorator reload, select
|
||||
flips) out of the per-child loop in `_regenerate_array_body`. These tests
|
||||
pin three contracts:
|
||||
|
||||
1. N-way batched duplicate produces N distinct entities mapped from the
|
||||
source under `old_to_new[source_element]`, and the source object stays
|
||||
selected throughout (no per-iteration deselect).
|
||||
2. Per-layer batching collapses the N independent UI refreshes into one.
|
||||
3. End-to-end array regen still yields the same number and shape of
|
||||
children as the per-call baseline."""
|
||||
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.pset
|
||||
import ifcopenshell.util.element
|
||||
import pytest
|
||||
|
||||
import bonsai.tool as tool
|
||||
from test.bim.bootstrap import NewFile
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
def _build_actuator(name: str = "Actuator") -> tuple[bpy.types.Object, ifcopenshell.entity_instance]:
|
||||
"""Minimal IfcActuator + cube — matches the test_array_batch_recut.py shape."""
|
||||
bpy.ops.bim.create_project()
|
||||
bpy.ops.mesh.primitive_cube_add()
|
||||
obj = bpy.context.active_object
|
||||
obj.name = name
|
||||
rprops = tool.Root.get_root_props()
|
||||
rprops.ifc_product = "IfcElement"
|
||||
bpy.ops.bim.assign_class(ifc_class="IfcActuator", predefined_type="ELECTRICACTUATOR", userdefined_type="")
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
return obj, element
|
||||
|
||||
|
||||
def _build_actuator_with_array_pset(
|
||||
count: int, x: float = 1.0
|
||||
) -> tuple[bpy.types.Object, ifcopenshell.entity_instance, list[dict]]:
|
||||
obj, element = _build_actuator()
|
||||
parent_data = [
|
||||
{
|
||||
"children": [],
|
||||
"count": count,
|
||||
"method": "OFFSET",
|
||||
"x": x,
|
||||
"y": 0.0,
|
||||
"z": 0.0,
|
||||
"use_local_space": False,
|
||||
"sync_children": False,
|
||||
}
|
||||
]
|
||||
pset = ifcopenshell.api.pset.add_pset(tool.Ifc.get(), product=element, name="BBIM_Array")
|
||||
ifcopenshell.api.pset.edit_pset(
|
||||
tool.Ifc.get(),
|
||||
pset=pset,
|
||||
properties={"Data": json.dumps(parent_data), "Parent": element.GlobalId},
|
||||
)
|
||||
return obj, element, parent_data
|
||||
|
||||
|
||||
class TestDuplicateIfcObjectNTimes(NewFile):
|
||||
def test_returns_empty_dict_for_zero_count(self):
|
||||
obj, _ = _build_actuator()
|
||||
result = tool.Geometry.duplicate_ifc_object_n_times(obj, 0)
|
||||
assert result == {}
|
||||
|
||||
def test_returns_empty_dict_for_negative_count(self):
|
||||
obj, _ = _build_actuator()
|
||||
result = tool.Geometry.duplicate_ifc_object_n_times(obj, -3)
|
||||
assert result == {}
|
||||
|
||||
def test_produces_n_distinct_entities(self):
|
||||
obj, element = _build_actuator()
|
||||
result = tool.Geometry.duplicate_ifc_object_n_times(obj, 5)
|
||||
new_entities = result.get(element)
|
||||
assert new_entities is not None
|
||||
assert len(new_entities) == 5
|
||||
assert len({e.id() for e in new_entities}) == 5
|
||||
for new_entity in new_entities:
|
||||
assert new_entity.is_a("IfcActuator")
|
||||
assert new_entity.GlobalId != element.GlobalId
|
||||
|
||||
def test_source_stays_selected_after_batch(self):
|
||||
obj, _ = _build_actuator()
|
||||
obj.select_set(True)
|
||||
tool.Geometry.duplicate_ifc_object_n_times(obj, 4)
|
||||
assert obj in bpy.context.selected_objects, "source object must remain selected across batched duplicates"
|
||||
|
||||
def test_each_new_entity_has_blender_object(self):
|
||||
obj, element = _build_actuator()
|
||||
result = tool.Geometry.duplicate_ifc_object_n_times(obj, 3)
|
||||
for new_entity in result[element]:
|
||||
new_obj = tool.Ifc.get_object(new_entity)
|
||||
assert new_obj is not None
|
||||
assert new_obj is not obj
|
||||
|
||||
|
||||
class TestBatchedRefreshUIDataCallCount(NewFile):
|
||||
def test_n_times_calls_refresh_ui_data_once(self):
|
||||
obj, _ = _build_actuator()
|
||||
with patch("bonsai.bim.handler.refresh_ui_data") as refresh_mock:
|
||||
tool.Geometry.duplicate_ifc_object_n_times(obj, 8)
|
||||
assert (
|
||||
refresh_mock.call_count == 1
|
||||
), f"batched 8-way duplicate must call refresh_ui_data once, got {refresh_mock.call_count}"
|
||||
|
||||
def test_n_times_calls_reload_grid_decorator_once(self):
|
||||
obj, _ = _build_actuator()
|
||||
with patch.object(tool.Root, "reload_grid_decorator") as reload_mock:
|
||||
tool.Geometry.duplicate_ifc_object_n_times(obj, 8)
|
||||
assert reload_mock.call_count == 1
|
||||
|
||||
|
||||
class TestRegenerateArrayEndToEnd(NewFile):
|
||||
def test_regenerate_array_creates_expected_children(self):
|
||||
obj, element, parent_data = _build_actuator_with_array_pset(count=8)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
tool.Model.regenerate_array(obj, parent_data)
|
||||
|
||||
layer = parent_data[0]
|
||||
assert len(layer["children"]) == 7, "8-element array means 7 new children (parent + 7)"
|
||||
for child_guid in layer["children"]:
|
||||
child_element = tool.Ifc.get().by_guid(child_guid)
|
||||
assert child_element is not None
|
||||
assert child_element.is_a("IfcActuator")
|
||||
child_pset = ifcopenshell.util.element.get_pset(child_element, "BBIM_Array")
|
||||
assert child_pset is not None
|
||||
assert child_pset["Parent"] == element.GlobalId
|
||||
|
||||
def test_regenerate_array_parent_stays_selected(self):
|
||||
obj, element, parent_data = _build_actuator_with_array_pset(count=4)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
obj.select_set(True)
|
||||
tool.Model.regenerate_array(obj, parent_data)
|
||||
assert (
|
||||
obj in bpy.context.selected_objects
|
||||
), "regenerate_array must leave parent_obj selected on return (post-condition)"
|
||||
|
||||
def test_regen_operator_leaves_only_parent_selected_and_active(self):
|
||||
"""Post-condition parity between grow and shrink for the user-facing
|
||||
``bim.regenerate_array`` operator: only the parent is selected + active;
|
||||
every child is deselected. Pre-fix the grow path left new children
|
||||
selected, creating inconsistency with the shrink path.
|
||||
|
||||
Scoped to the operator, not the tool method — ``remove_array`` and
|
||||
``apply_array`` also invoke ``tool.Model.regenerate_array`` internally
|
||||
but expect a different post-selection state (children stay selected
|
||||
for user follow-up work)."""
|
||||
obj, element, parent_data = _build_actuator_with_array_pset(count=6)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
obj.select_set(True)
|
||||
bpy.ops.bim.regenerate_array()
|
||||
|
||||
assert obj in bpy.context.selected_objects
|
||||
assert bpy.context.view_layer.objects.active is obj
|
||||
parent_pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
|
||||
parent_data_after = json.loads(parent_pset["Data"])
|
||||
for child_guid in parent_data_after[0]["children"]:
|
||||
child_element = tool.Ifc.get().by_guid(child_guid)
|
||||
child_obj = tool.Ifc.get_object(child_element)
|
||||
assert (
|
||||
child_obj not in bpy.context.selected_objects
|
||||
), f"child {child_obj.name} must be deselected on regenerate_array return"
|
||||
|
||||
def test_regen_operator_after_shrink_still_leaves_only_parent_selected(self):
|
||||
obj, element, parent_data = _build_actuator_with_array_pset(count=6)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
bpy.ops.bim.regenerate_array()
|
||||
|
||||
parent_pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
|
||||
arrays = json.loads(parent_pset["Data"])
|
||||
arrays[0]["count"] = 3
|
||||
pset_entity = tool.Ifc.get().by_id(parent_pset["id"])
|
||||
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset_entity, properties={"Data": json.dumps(arrays)})
|
||||
bpy.ops.bim.regenerate_array()
|
||||
|
||||
assert obj in bpy.context.selected_objects
|
||||
assert bpy.context.view_layer.objects.active is obj
|
||||
parent_pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
|
||||
arrays_after = json.loads(parent_pset["Data"])
|
||||
for child_guid in arrays_after[0]["children"]:
|
||||
child_element = tool.Ifc.get().by_guid(child_guid)
|
||||
child_obj = tool.Ifc.get_object(child_element)
|
||||
assert child_obj not in bpy.context.selected_objects
|
||||
|
||||
def test_regenerate_array_child_positions_match_offset(self):
|
||||
obj, element, parent_data = _build_actuator_with_array_pset(count=4, x=2.5)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
parent_x = obj.matrix_world.translation.x
|
||||
tool.Model.regenerate_array(obj, parent_data)
|
||||
|
||||
layer = parent_data[0]
|
||||
for i, child_guid in enumerate(layer["children"], start=1):
|
||||
child_element = tool.Ifc.get().by_guid(child_guid)
|
||||
child_obj = tool.Ifc.get_object(child_element)
|
||||
expected_x = parent_x + 2.5 * i
|
||||
assert child_obj.matrix_world.translation.x == pytest.approx(
|
||||
expected_x
|
||||
), f"child {i}: expected x≈{expected_x}, got {child_obj.matrix_world.translation.x}"
|
||||
|
||||
|
||||
class TestRegenerateArrayUIRefreshCoalesces(NewFile):
|
||||
def test_n_children_grow_calls_refresh_ui_data_once_per_layer(self):
|
||||
obj, element, parent_data = _build_actuator_with_array_pset(count=8)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
with patch("bonsai.bim.handler.refresh_ui_data") as refresh_mock:
|
||||
tool.Model.regenerate_array(obj, parent_data)
|
||||
assert refresh_mock.call_count == 1, (
|
||||
"growing an array layer from 0 to 7 children must call refresh_ui_data once, "
|
||||
f"got {refresh_mock.call_count}"
|
||||
)
|
||||
|
||||
def test_n_children_grow_calls_reload_grid_decorator_once_per_layer(self):
|
||||
obj, element, parent_data = _build_actuator_with_array_pset(count=8)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
with patch.object(tool.Root, "reload_grid_decorator") as reload_mock:
|
||||
tool.Model.regenerate_array(obj, parent_data)
|
||||
assert reload_mock.call_count == 1
|
||||
|
||||
|
||||
class TestRecreateAggregateIteratesAllNew(NewFile):
|
||||
"""Pins the [0]-indexing sweep in tool/root.py recreate_aggregate. When the
|
||||
new-list has N>1 entries (the batched-duplicate shape), every entry must be
|
||||
aggregate-assigned, not just new[0]."""
|
||||
|
||||
def test_iterates_assign_object_per_new_entity_when_old_has_aggregate(self):
|
||||
from unittest.mock import Mock
|
||||
|
||||
old_assembly = Mock()
|
||||
old_assembly.is_a = lambda c: c == "IfcElementAssembly"
|
||||
old_parent_aggregate = Mock()
|
||||
old_parent_aggregate.is_a = lambda c: False
|
||||
|
||||
new_assemblies = [Mock(), Mock(), Mock()]
|
||||
new_parent_aggregate = [Mock()]
|
||||
|
||||
old_to_new = {old_assembly: new_assemblies, old_parent_aggregate: new_parent_aggregate}
|
||||
|
||||
with patch(
|
||||
"ifcopenshell.util.element.get_aggregate",
|
||||
side_effect=lambda e: old_parent_aggregate if e is old_assembly else None,
|
||||
), patch("bonsai.core.aggregate.assign_object") as assign_mock, patch(
|
||||
"ifcopenshell.util.element.get_pset", return_value=None
|
||||
), patch.object(
|
||||
tool.Ifc, "get_object", side_effect=lambda e: Mock(spec=bpy.types.Object)
|
||||
), patch.object(
|
||||
tool.Blender, "select_and_activate_single_object"
|
||||
):
|
||||
tool.Root.recreate_aggregate(old_to_new)
|
||||
|
||||
assert (
|
||||
assign_mock.call_count == 3
|
||||
), f"recreate_aggregate must assign each of N new entities (not just new[0]); got {assign_mock.call_count}"
|
||||
|
||||
def test_iterates_unassign_object_per_new_entity_when_aggregate_missing(self):
|
||||
from unittest.mock import Mock
|
||||
|
||||
old_assembly = Mock()
|
||||
old_assembly.is_a = lambda c: c == "IfcElementAssembly"
|
||||
old_parent_aggregate = Mock()
|
||||
|
||||
new_assemblies = [Mock(), Mock(), Mock()]
|
||||
old_to_new = {old_assembly: new_assemblies} # parent aggregate NOT in old_to_new
|
||||
|
||||
with patch(
|
||||
"ifcopenshell.util.element.get_aggregate",
|
||||
side_effect=lambda e: old_parent_aggregate if e is old_assembly else None,
|
||||
), patch("bonsai.core.aggregate.unassign_object") as unassign_mock, patch.object(
|
||||
tool.Ifc, "get_object", side_effect=lambda e: Mock(spec=bpy.types.Object)
|
||||
):
|
||||
tool.Root.recreate_aggregate(old_to_new)
|
||||
|
||||
assert unassign_mock.call_count == 3, (
|
||||
f"recreate_aggregate must unassign each of N new entities when parent aggregate is missing; "
|
||||
f"got {unassign_mock.call_count}"
|
||||
)
|
||||
|
||||
|
||||
class TestRecreateConnectionsZipsPairs(NewFile):
|
||||
"""Pins the [0]-indexing sweep in tool/duplicate.py recreate_connections. When
|
||||
both sides of a connection are duplicated N times, zip-pair the N new
|
||||
relating with N new related; when only one side is duplicated, skip."""
|
||||
|
||||
def _make_connection_data(self):
|
||||
from unittest.mock import Mock
|
||||
|
||||
from bonsai.tool.duplicate import ConnectionRecord
|
||||
|
||||
return ConnectionRecord(
|
||||
type="path",
|
||||
relating_element=Mock(),
|
||||
related_element=Mock(),
|
||||
relating_connection_type="ATSTART",
|
||||
related_connection_type="ATEND",
|
||||
relating_priorities=[],
|
||||
related_priorities=[],
|
||||
)
|
||||
|
||||
def test_zips_n_pairs_when_both_sides_duplicated(self):
|
||||
from unittest.mock import Mock
|
||||
|
||||
data = self._make_connection_data()
|
||||
old_to_new = {
|
||||
data.relating_element: [Mock(), Mock(), Mock()],
|
||||
data.related_element: [Mock(), Mock(), Mock()],
|
||||
}
|
||||
relationship = {Mock(): data}
|
||||
|
||||
with patch.object(tool.Ifc, "run", return_value=None) as run_mock:
|
||||
tool.Duplicate.recreate_connections(relationship, old_to_new)
|
||||
|
||||
connect_calls = [c for c in run_mock.call_args_list if c.args and c.args[0] == "geometry.connect_path"]
|
||||
assert (
|
||||
len(connect_calls) == 3
|
||||
), f"zip-pair must create 3 connect_path calls for 3-vs-3 batched duplicate; got {len(connect_calls)}"
|
||||
|
||||
def test_skips_when_other_side_not_duplicated(self):
|
||||
from unittest.mock import Mock
|
||||
|
||||
data = self._make_connection_data()
|
||||
# Only relating side is in old_to_new; related side was NOT duplicated.
|
||||
old_to_new = {data.relating_element: [Mock(), Mock(), Mock()]}
|
||||
relationship = {Mock(): data}
|
||||
|
||||
with patch.object(tool.Ifc, "run", return_value=None) as run_mock:
|
||||
tool.Duplicate.recreate_connections(relationship, old_to_new)
|
||||
|
||||
connect_calls = [c for c in run_mock.call_args_list if c.args and c.args[0] == "geometry.connect_path"]
|
||||
assert (
|
||||
connect_calls == []
|
||||
), "when only one side of a connection is in old_to_new, no connections should be recreated"
|
||||
|
||||
def test_single_pair_case_unchanged(self):
|
||||
"""Pre-sweep behavior (1 source -> 1 new) must still work — zip with two 1-element lists."""
|
||||
from unittest.mock import Mock
|
||||
|
||||
data = self._make_connection_data()
|
||||
old_to_new = {
|
||||
data.relating_element: [Mock()],
|
||||
data.related_element: [Mock()],
|
||||
}
|
||||
relationship = {Mock(): data}
|
||||
|
||||
with patch.object(tool.Ifc, "run", return_value=None) as run_mock:
|
||||
tool.Duplicate.recreate_connections(relationship, old_to_new)
|
||||
|
||||
connect_calls = [c for c in run_mock.call_args_list if c.args and c.args[0] == "geometry.connect_path"]
|
||||
assert len(connect_calls) == 1
|
||||
|
||||
|
||||
class TestRecalculateWallsWithNewConnections(NewFile):
|
||||
"""Pins the post-connection wall recalc: after ``recreate_connections``
|
||||
wires new IfcRelConnectsPathElements onto duplicated walls, the wall
|
||||
bodies must be re-recalculated because the in-loop ``regenerate_wall``
|
||||
fired before the connections existed. Otherwise the junction geometry
|
||||
stays stale and the user has to manually regen."""
|
||||
|
||||
def test_walls_with_new_connections_are_recalculated(self):
|
||||
from unittest.mock import Mock
|
||||
|
||||
wall_new = Mock()
|
||||
wall_new.is_a = lambda c: c == "IfcWall"
|
||||
wall_new.ConnectedTo = [Mock()]
|
||||
wall_new.ConnectedFrom = []
|
||||
|
||||
wall_obj = Mock(spec=bpy.types.Object)
|
||||
old_to_new = {Mock(): [wall_new]}
|
||||
|
||||
with patch.object(tool.Ifc, "get_object", return_value=wall_obj), patch.object(
|
||||
tool.Model, "recalculate_walls"
|
||||
) as recalc_mock:
|
||||
tool.Geometry._recalculate_walls_with_new_connections(old_to_new)
|
||||
|
||||
assert recalc_mock.call_count == 1
|
||||
assert recalc_mock.call_args.args[0] == [wall_obj]
|
||||
|
||||
def test_walls_without_connections_are_skipped(self):
|
||||
from unittest.mock import Mock
|
||||
|
||||
wall_new = Mock()
|
||||
wall_new.is_a = lambda c: c == "IfcWall"
|
||||
wall_new.ConnectedTo = []
|
||||
wall_new.ConnectedFrom = []
|
||||
|
||||
old_to_new = {Mock(): [wall_new]}
|
||||
|
||||
with patch.object(tool.Ifc, "get_object", return_value=Mock(spec=bpy.types.Object)), patch.object(
|
||||
tool.Model, "recalculate_walls"
|
||||
) as recalc_mock:
|
||||
tool.Geometry._recalculate_walls_with_new_connections(old_to_new)
|
||||
|
||||
assert recalc_mock.call_count == 0, "walls with no new connections must not trigger a recalc pass"
|
||||
|
||||
def test_non_wall_entities_are_skipped(self):
|
||||
from unittest.mock import Mock
|
||||
|
||||
actuator_new = Mock()
|
||||
actuator_new.is_a = lambda c: c == "IfcActuator"
|
||||
actuator_new.ConnectedTo = [Mock()]
|
||||
|
||||
old_to_new = {Mock(): [actuator_new]}
|
||||
|
||||
with patch.object(tool.Ifc, "get_object", return_value=Mock(spec=bpy.types.Object)), patch.object(
|
||||
tool.Model, "recalculate_walls"
|
||||
) as recalc_mock:
|
||||
tool.Geometry._recalculate_walls_with_new_connections(old_to_new)
|
||||
|
||||
assert recalc_mock.call_count == 0
|
||||
|
||||
def test_multiple_new_walls_collected_into_one_call(self):
|
||||
from unittest.mock import Mock
|
||||
|
||||
wall_a_new = Mock()
|
||||
wall_a_new.is_a = lambda c: c == "IfcWall"
|
||||
wall_a_new.ConnectedTo = [Mock()]
|
||||
wall_a_new.ConnectedFrom = []
|
||||
wall_b_new = Mock()
|
||||
wall_b_new.is_a = lambda c: c == "IfcWall"
|
||||
wall_b_new.ConnectedTo = []
|
||||
wall_b_new.ConnectedFrom = [Mock()]
|
||||
|
||||
objs = {wall_a_new: Mock(spec=bpy.types.Object), wall_b_new: Mock(spec=bpy.types.Object)}
|
||||
old_to_new = {Mock(): [wall_a_new], Mock(): [wall_b_new]}
|
||||
|
||||
with patch.object(tool.Ifc, "get_object", side_effect=lambda e: objs.get(e)), patch.object(
|
||||
tool.Model, "recalculate_walls"
|
||||
) as recalc_mock:
|
||||
tool.Geometry._recalculate_walls_with_new_connections(old_to_new)
|
||||
|
||||
assert recalc_mock.call_count == 1
|
||||
assert set(recalc_mock.call_args.args[0]) == {objs[wall_a_new], objs[wall_b_new]}
|
||||
|
||||
|
||||
class TestMEPActionGuardsAgainstArrayChildren(NewFile):
|
||||
"""Pins the array-child guards on the three MEP-action visibility helpers.
|
||||
Writable MEP actions (add fitting, remove terminal, join, re-edit bend)
|
||||
applied to an array child get wiped by the next regen — gating the icons
|
||||
at the visibility layer prevents that footgun."""
|
||||
|
||||
def test_active_is_flow_segment_returns_false_for_array_child(self):
|
||||
from unittest.mock import Mock
|
||||
|
||||
from bonsai.bim.module.model.mep import _active_is_flow_segment
|
||||
|
||||
obj = Mock(spec=bpy.types.Object)
|
||||
element = Mock()
|
||||
element.is_a = lambda c: c == "IfcFlowSegment"
|
||||
|
||||
with patch.object(tool.Ifc, "get_entity", return_value=element), patch.object(
|
||||
tool.Array, "is_array_child", return_value=True
|
||||
), patch.object(tool.System, "has_parametric_body", return_value=True):
|
||||
assert _active_is_flow_segment(obj) is False
|
||||
|
||||
def test_active_is_flow_segment_true_for_non_array_parent(self):
|
||||
from unittest.mock import Mock
|
||||
|
||||
from bonsai.bim.module.model.mep import _active_is_flow_segment
|
||||
|
||||
obj = Mock(spec=bpy.types.Object)
|
||||
element = Mock()
|
||||
element.is_a = lambda c: c == "IfcFlowSegment"
|
||||
|
||||
with patch.object(tool.Ifc, "get_entity", return_value=element), patch.object(
|
||||
tool.Array, "is_array_child", return_value=False
|
||||
), patch.object(tool.System, "has_parametric_body", return_value=True):
|
||||
assert _active_is_flow_segment(obj) is True
|
||||
|
||||
def test_active_is_bend_fitting_returns_false_for_array_child(self):
|
||||
from unittest.mock import Mock
|
||||
|
||||
from bonsai.bim.module.model.mep import _active_is_bend_fitting
|
||||
|
||||
obj = Mock(spec=bpy.types.Object)
|
||||
element = Mock()
|
||||
|
||||
with patch.object(tool.Ifc, "get_entity", return_value=element), patch(
|
||||
"bonsai.bim.module.model.mep._is_bend_fitting", return_value=True
|
||||
), patch.object(tool.Array, "is_array_child", return_value=True):
|
||||
assert _active_is_bend_fitting(obj) is False
|
||||
|
||||
def test_n_mep_selected_returns_false_when_any_selected_is_array_child(self):
|
||||
from unittest.mock import Mock
|
||||
|
||||
from bonsai.bim.module.model.mep import _n_mep_selected
|
||||
|
||||
obj_a = Mock(spec=bpy.types.Object)
|
||||
obj_b = Mock(spec=bpy.types.Object)
|
||||
element_a = Mock()
|
||||
element_b = Mock()
|
||||
|
||||
def is_array_child(el):
|
||||
return el is element_b
|
||||
|
||||
with patch.object(tool.Blender, "get_selected_objects", return_value=[obj_a, obj_b]), patch.object(
|
||||
tool.Ifc, "get_entity", side_effect=lambda o: element_a if o is obj_a else element_b
|
||||
), patch.object(tool.System, "is_mep_element", return_value=True), patch.object(
|
||||
tool.Array, "is_array_child", side_effect=is_array_child
|
||||
):
|
||||
assert _n_mep_selected(2) is False
|
||||
|
||||
|
||||
class TestSelectOnlyParent(NewFile):
|
||||
"""Pins ``tool.Array.select_only_parent`` — the shared helper wired into
|
||||
both ``bim.regenerate_array`` and ``bim.finish_editing_array`` so the
|
||||
grow / shrink / edit-commit paths converge on the same post-condition:
|
||||
only the parent is selected + active."""
|
||||
|
||||
def test_deselects_children_selects_and_activates_parent(self):
|
||||
obj, element, parent_data = _build_actuator_with_array_pset(count=4)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
obj.select_set(True)
|
||||
tool.Model.regenerate_array(obj, parent_data)
|
||||
for child_guid in parent_data[0]["children"]:
|
||||
child_element = tool.Ifc.get().by_guid(child_guid)
|
||||
child_obj = tool.Ifc.get_object(child_element)
|
||||
child_obj.select_set(True)
|
||||
|
||||
tool.Array.select_only_parent(obj, bpy.context)
|
||||
|
||||
assert obj in bpy.context.selected_objects
|
||||
assert bpy.context.view_layer.objects.active is obj
|
||||
for child_guid in parent_data[0]["children"]:
|
||||
child_element = tool.Ifc.get().by_guid(child_guid)
|
||||
child_obj = tool.Ifc.get_object(child_element)
|
||||
assert child_obj not in bpy.context.selected_objects
|
||||
|
||||
|
||||
class TestIsArrayChild(NewFile):
|
||||
"""Pins ``tool.Array.is_array_child`` — the light helper used by the port
|
||||
decorator (and any future per-element guard) to skip array children."""
|
||||
|
||||
def test_returns_false_when_no_bbim_array_pset(self):
|
||||
from unittest.mock import Mock
|
||||
|
||||
element = Mock()
|
||||
with patch("ifcopenshell.util.element.get_pset", return_value=None):
|
||||
assert tool.Array.is_array_child(element) is False
|
||||
|
||||
def test_returns_false_on_the_array_parent_itself(self):
|
||||
from unittest.mock import Mock
|
||||
|
||||
element = Mock()
|
||||
element.GlobalId = "PARENT_GUID"
|
||||
with patch("ifcopenshell.util.element.get_pset", return_value={"Parent": "PARENT_GUID"}):
|
||||
assert tool.Array.is_array_child(element) is False
|
||||
|
||||
def test_returns_true_when_parent_guid_points_elsewhere(self):
|
||||
from unittest.mock import Mock
|
||||
|
||||
element = Mock()
|
||||
element.GlobalId = "CHILD_GUID"
|
||||
with patch("ifcopenshell.util.element.get_pset", return_value={"Parent": "PARENT_GUID"}):
|
||||
assert tool.Array.is_array_child(element) is True
|
||||
|
||||
|
||||
class TestOrphanArrayChildPrune(NewFile):
|
||||
"""Outliner / keyboard delete of a Bonsai-managed array child bypasses
|
||||
``bim.delete``'s cascade, leaving the IFC entity and its opening / filling
|
||||
refs behind. Regen must prune these orphans before the main loop or the
|
||||
stale registry entry corrupts the ``batch_host_recut`` drain."""
|
||||
|
||||
def test_orphan_ifc_entity_pruned_from_children_list(self):
|
||||
obj, element, parent_data = _build_actuator_with_array_pset(count=4)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
tool.Model.regenerate_array(obj, parent_data)
|
||||
assert len(parent_data[0]["children"]) == 3
|
||||
|
||||
orphan_guid = parent_data[0]["children"][1]
|
||||
orphan_element = tool.Ifc.get().by_guid(orphan_guid)
|
||||
orphan_obj = tool.Ifc.get_object(orphan_element)
|
||||
assert orphan_obj is not None
|
||||
bpy.data.objects.remove(orphan_obj, do_unlink=True)
|
||||
|
||||
tool.Model.regenerate_array(obj, parent_data)
|
||||
|
||||
assert (
|
||||
orphan_guid not in parent_data[0]["children"]
|
||||
), "orphan GUID must be pruned from array['children'] once its Blender object is dead"
|
||||
try:
|
||||
still_there = tool.Ifc.get().by_guid(orphan_guid)
|
||||
except RuntimeError:
|
||||
still_there = None
|
||||
assert still_there is None, "orphan IFC entity must be cascade-removed, not left as a leak"
|
||||
|
||||
def test_regen_completes_when_child_deleted_outside_bim_cascade(self):
|
||||
obj, element, parent_data = _build_actuator_with_array_pset(count=6)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
tool.Model.regenerate_array(obj, parent_data)
|
||||
|
||||
victim_guid = parent_data[0]["children"][2]
|
||||
victim_element = tool.Ifc.get().by_guid(victim_guid)
|
||||
victim_obj = tool.Ifc.get_object(victim_element)
|
||||
bpy.data.objects.remove(victim_obj, do_unlink=True)
|
||||
|
||||
tool.Model.regenerate_array(obj, parent_data)
|
||||
|
||||
assert len(parent_data[0]["children"]) == 5, "regen must rebuild to the target count after pruning the orphan"
|
||||
for guid in parent_data[0]["children"]:
|
||||
child = tool.Ifc.get().by_guid(guid)
|
||||
child_obj = tool.Ifc.get_object(child)
|
||||
assert child_obj is not None, "every surviving child must have a live Blender object"
|
||||
|
||||
|
||||
class TestRecreatePortConnectionsZipsPairs(NewFile):
|
||||
"""Pins the [0]-indexing sweep in tool/duplicate.py recreate_port_connections.
|
||||
When both sides of a port-to-port connection are duplicated N times, the
|
||||
connection must be recreated on every pair of new siblings — not just the
|
||||
first. Matters for arrayed MEP segments (pipes / ducts / cables) where each
|
||||
child in the array should stay connected to its neighbour after regen."""
|
||||
|
||||
def _make_snapshot(self, relating_element, records, port_counts):
|
||||
from bonsai.tool.duplicate import PortConnectionSnapshot
|
||||
|
||||
return PortConnectionSnapshot(
|
||||
by_element={relating_element: records},
|
||||
port_counts=port_counts,
|
||||
)
|
||||
|
||||
def _make_record(self, related_element, relating_port_index=0, related_port_index=0, direction="SOURCE"):
|
||||
from bonsai.tool.duplicate import PortConnectionRecord
|
||||
|
||||
return PortConnectionRecord(
|
||||
relating_port_index=relating_port_index,
|
||||
related_element=related_element,
|
||||
related_port_index=related_port_index,
|
||||
direction=direction,
|
||||
)
|
||||
|
||||
def test_zips_n_pairs_when_both_sides_duplicated(self):
|
||||
from unittest.mock import Mock
|
||||
|
||||
relating_old = Mock()
|
||||
related_old = Mock()
|
||||
record = self._make_record(related_old)
|
||||
snapshot = self._make_snapshot(relating_old, [record], port_counts={})
|
||||
|
||||
old_to_new = {
|
||||
relating_old: [Mock(), Mock(), Mock()],
|
||||
related_old: [Mock(), Mock(), Mock()],
|
||||
}
|
||||
|
||||
fake_ports = [Mock(), Mock()]
|
||||
with patch.object(tool.System, "get_ports", return_value=fake_ports), patch.object(
|
||||
tool.Ifc, "run", return_value=None
|
||||
) as run_mock:
|
||||
tool.Duplicate.recreate_port_connections(snapshot, old_to_new)
|
||||
|
||||
connect_calls = [c for c in run_mock.call_args_list if c.args and c.args[0] == "system.connect_port"]
|
||||
assert (
|
||||
len(connect_calls) == 3
|
||||
), f"zip-pair must create 3 connect_port calls for 3-vs-3 batched MEP duplicate; got {len(connect_calls)}"
|
||||
|
||||
def test_skips_when_other_side_not_duplicated(self):
|
||||
from unittest.mock import Mock
|
||||
|
||||
relating_old = Mock()
|
||||
related_old = Mock()
|
||||
record = self._make_record(related_old)
|
||||
snapshot = self._make_snapshot(relating_old, [record], port_counts={})
|
||||
|
||||
# Only relating side is in old_to_new.
|
||||
old_to_new = {relating_old: [Mock(), Mock(), Mock()]}
|
||||
|
||||
with patch.object(tool.System, "get_ports", return_value=[Mock()]), patch.object(
|
||||
tool.Ifc, "run", return_value=None
|
||||
) as run_mock:
|
||||
tool.Duplicate.recreate_port_connections(snapshot, old_to_new)
|
||||
|
||||
connect_calls = [c for c in run_mock.call_args_list if c.args and c.args[0] == "system.connect_port"]
|
||||
assert connect_calls == [], "when only one side is in old_to_new, no port connections should be recreated"
|
||||
|
||||
def test_single_pair_case_unchanged(self):
|
||||
"""Pre-sweep behavior (1 source -> 1 new) must still work — zip with two 1-element lists."""
|
||||
from unittest.mock import Mock
|
||||
|
||||
relating_old = Mock()
|
||||
related_old = Mock()
|
||||
record = self._make_record(related_old)
|
||||
snapshot = self._make_snapshot(relating_old, [record], port_counts={})
|
||||
|
||||
old_to_new = {relating_old: [Mock()], related_old: [Mock()]}
|
||||
|
||||
with patch.object(tool.System, "get_ports", return_value=[Mock()]), patch.object(
|
||||
tool.Ifc, "run", return_value=None
|
||||
) as run_mock:
|
||||
tool.Duplicate.recreate_port_connections(snapshot, old_to_new)
|
||||
|
||||
connect_calls = [c for c in run_mock.call_args_list if c.args and c.args[0] == "system.connect_port"]
|
||||
assert len(connect_calls) == 1
|
||||
@@ -346,7 +346,9 @@ def test_active_is_flow_segment_classifies_segment_vs_fitting():
|
||||
fitting_elem.is_a = lambda c: c == "IfcFlowFitting"
|
||||
|
||||
plain = Mock()
|
||||
with patch("bonsai.bim.module.model.mep.tool.System.has_parametric_body", return_value=True):
|
||||
with patch("bonsai.bim.module.model.mep.tool.System.has_parametric_body", return_value=True), patch(
|
||||
"bonsai.bim.module.model.mep.tool.Array.is_array_child", return_value=False
|
||||
):
|
||||
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=segment_elem):
|
||||
assert _active_is_flow_segment(plain) is True
|
||||
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=fitting_elem):
|
||||
|
||||
@@ -146,7 +146,9 @@ def test_fit_flow_segments_with_single_segment_dispatches_obstruction():
|
||||
mep.tool.Model, "get_flow_segment_profile", return_value=segment_profile
|
||||
), patch.object(mep.MEPAddObstruction, "_execute", return_value=None) as obstruction, patch.object(
|
||||
mep.MEPAddBend, "_execute", return_value=None
|
||||
) as bend, patch.object(mep.MEPAddTransition, "_execute", return_value=None) as transition:
|
||||
) as bend, patch.object(
|
||||
mep.MEPAddTransition, "_execute", return_value=None
|
||||
) as transition:
|
||||
mep.FitFlowSegments._execute(op, context=context)
|
||||
|
||||
assert obstruction.call_count == 1
|
||||
@@ -178,7 +180,9 @@ def test_fit_flow_segments_refuses_mixed_pipe_and_duct():
|
||||
mep.tool.Model, "get_flow_segment_profile", return_value=profile
|
||||
), patch.object(mep.MEPAddObstruction, "_execute", return_value=None) as obstruction, patch.object(
|
||||
mep.MEPAddBend, "_execute", return_value=None
|
||||
) as bend, patch.object(mep.MEPAddTransition, "_execute", return_value=None) as transition:
|
||||
) as bend, patch.object(
|
||||
mep.MEPAddTransition, "_execute", return_value=None
|
||||
) as transition:
|
||||
mep.FitFlowSegments._execute(op, context=context)
|
||||
|
||||
obstruction.assert_not_called()
|
||||
|
||||
@@ -173,8 +173,9 @@ def test_gizmo_group_class_wiring(gizmo_cls_name, bl_idname, is_element_predicat
|
||||
predicate = getattr(tool.Parametric, is_element_predicate)
|
||||
fake_element = Mock()
|
||||
fake_element.is_a.return_value = True
|
||||
with patch.object(tool.Parametric, is_element_predicate, side_effect=predicate) as p, patch.object(
|
||||
tool.System, "has_parametric_body", return_value=True
|
||||
with (
|
||||
patch.object(tool.Parametric, is_element_predicate, side_effect=predicate) as p,
|
||||
patch.object(tool.System, "has_parametric_body", return_value=True),
|
||||
):
|
||||
cls.is_element_type(fake_element)
|
||||
assert p.called, f"{gizmo_cls_name}.is_element_type did not delegate to Parametric.{is_element_predicate}"
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# 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 os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from bonsai.tool.autosave import AUTOSAVED_SUFFIX, AUTOSAVING_SUFFIX, Autosave
|
||||
|
||||
pytestmark = pytest.mark.project
|
||||
|
||||
|
||||
class TestAutosavePaths:
|
||||
def test_get_paths_for_ifc_file(self):
|
||||
main_path, autosaving_path, autosaved_path = Autosave.get_paths("/tmp/myfile.ifc")
|
||||
assert main_path == Path("/tmp/myfile.ifc")
|
||||
assert autosaving_path == Path(f"/tmp/myfile{AUTOSAVING_SUFFIX}")
|
||||
assert autosaved_path == Path(f"/tmp/myfile{AUTOSAVED_SUFFIX}")
|
||||
|
||||
def test_get_newer_autosaved_path_when_missing(self, tmp_path):
|
||||
ifc_path = tmp_path / "myfile.ifc"
|
||||
ifc_path.write_text("ifc")
|
||||
assert Autosave.get_newer_autosaved_path(ifc_path) is None
|
||||
|
||||
def test_get_newer_autosaved_path_when_older(self, tmp_path):
|
||||
ifc_path = tmp_path / "myfile.ifc"
|
||||
autosaved_path = tmp_path / f"myfile{AUTOSAVED_SUFFIX}"
|
||||
ifc_path.write_text("ifc")
|
||||
autosaved_path.write_text("autosaved")
|
||||
past = time.time() - 10
|
||||
os.utime(ifc_path, (past, past))
|
||||
os.utime(autosaved_path, (time.time(), time.time()))
|
||||
assert Autosave.get_newer_autosaved_path(ifc_path) == autosaved_path.as_posix()
|
||||
|
||||
def test_get_newer_autosaved_path_when_not_newer(self, tmp_path):
|
||||
ifc_path = tmp_path / "myfile.ifc"
|
||||
autosaved_path = tmp_path / f"myfile{AUTOSAVED_SUFFIX}"
|
||||
ifc_path.write_text("ifc")
|
||||
autosaved_path.write_text("autosaved")
|
||||
now = time.time()
|
||||
os.utime(ifc_path, (now, now))
|
||||
past = now - 10
|
||||
os.utime(autosaved_path, (past, past))
|
||||
assert Autosave.get_newer_autosaved_path(ifc_path) is None
|
||||
|
||||
def test_get_newer_autosaved_path_ignores_non_ifc(self, tmp_path):
|
||||
path = tmp_path / "myfile.ifczip"
|
||||
path.write_text("zip")
|
||||
assert Autosave.get_newer_autosaved_path(path) is None
|
||||
@@ -139,6 +139,5 @@ def test_every_cancel_ops_entry_has_a_real_preview_propertygroup() -> None:
|
||||
orphaned = [attr for attr, _op in preview_base.PREVIEW_CANCEL_OPS if attr not in declared_attrs]
|
||||
assert not orphaned, (
|
||||
"PREVIEW_CANCEL_OPS contains entries whose PointerProperty child no longer "
|
||||
f"exists on {UMBRELLA_CLASS}. Drop the stale tuple(s):\n "
|
||||
+ "\n ".join(orphaned)
|
||||
f"exists on {UMBRELLA_CLASS}. Drop the stale tuple(s):\n " + "\n ".join(orphaned)
|
||||
)
|
||||
|
||||
@@ -174,6 +174,15 @@ class TestAddConversionBasedUnit:
|
||||
assert subject.add_conversion_based_unit(ifc, unit, name="name") == "unit"
|
||||
|
||||
|
||||
class TestAddDerivedUnit:
|
||||
def test_run(self, ifc, unit):
|
||||
ifc.run(
|
||||
"unit.add_derived_unit", unit_type="unit_type", userdefinedtype=None, attributes="elements"
|
||||
).should_be_called().will_return("unit")
|
||||
unit.import_units().should_be_called()
|
||||
assert subject.add_derived_unit(ifc, unit, unit_type="unit_type", elements="elements") == "unit"
|
||||
|
||||
|
||||
class TestEnableEditingUnit:
|
||||
def test_run(self, unit):
|
||||
unit.set_active_unit("unit").should_be_called()
|
||||
|
||||
@@ -24,6 +24,7 @@ import time
|
||||
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import ifcopenshell.util.element
|
||||
import pytest
|
||||
|
||||
from bonsai import tool as tool
|
||||
|
||||
@@ -164,6 +164,62 @@ def test_stale_element_skipped_at_drain():
|
||||
assert recut.call_count == 0
|
||||
|
||||
|
||||
class _DeadStructRNA:
|
||||
"""Simulates a Blender object whose StructRNA has been removed — every
|
||||
attribute access raises ReferenceError. Enqueue this as voided_obj to
|
||||
reproduce the outliner-mid-batch-delete crash."""
|
||||
|
||||
def __getattr__(self, name):
|
||||
raise ReferenceError("StructRNA of type Object has been removed")
|
||||
|
||||
def __bool__(self):
|
||||
raise ReferenceError("StructRNA of type Object has been removed")
|
||||
|
||||
|
||||
def test_dead_structrna_recut_skipped_at_drain():
|
||||
"""Blender object is deleted while the batch is open (outliner delete +
|
||||
manual DEL bypass the bim.delete cascade). The drain must skip it silently
|
||||
— not raise — so unrelated hosts in the same batch still get their recut."""
|
||||
from bonsai import tool
|
||||
|
||||
dead_obj = _DeadStructRNA()
|
||||
live_obj = _mock_voided_obj("LiveWall")
|
||||
rep = Mock()
|
||||
|
||||
def get_entity(obj):
|
||||
# Called only when the guard clears — for the dead ref, guard short-circuits first.
|
||||
return _mock_element(2)
|
||||
|
||||
with patch("bonsai.core.geometry.switch_representation") as recut, patch.object(
|
||||
tool.Ifc, "get_entity", side_effect=get_entity
|
||||
), patch.object(tool.Geometry, "get_active_representation", return_value=rep):
|
||||
with tool.Geometry.batch_host_recut():
|
||||
tool.Geometry._host_recut_queue[999] = (dead_obj, rep)
|
||||
tool.Geometry.recut_host(live_obj, rep)
|
||||
|
||||
assert recut.call_count == 1, "live host must still get its recut despite a dead sibling in the queue"
|
||||
drained_obj = recut.call_args.kwargs["obj"]
|
||||
assert drained_obj is live_obj
|
||||
|
||||
|
||||
def test_dead_structrna_update_skipped_at_drain():
|
||||
"""Same guarantee for update_representation drain path."""
|
||||
from bonsai import tool
|
||||
|
||||
dead_obj = _DeadStructRNA()
|
||||
live_obj = _mock_voided_obj("LiveWall")
|
||||
bpy_ops_mock = Mock()
|
||||
|
||||
with patch("bonsai.tool.geometry.bpy.ops", new=bpy_ops_mock), patch.object(
|
||||
tool.Ifc, "get_entity", return_value=_mock_element(42)
|
||||
), patch.object(tool.Geometry, "get_active_representation", return_value=Mock()):
|
||||
with tool.Geometry.batch_host_recut():
|
||||
tool.Geometry._host_update_queue[999] = dead_obj
|
||||
tool.Geometry.update_host_representation(live_obj)
|
||||
|
||||
assert bpy_ops_mock.bim.update_representation.call_count == 1
|
||||
|
||||
|
||||
def test_exception_inside_batch_still_resets_state():
|
||||
from bonsai import tool
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import bpy
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.geometry
|
||||
import ifcopenshell.api.material
|
||||
import ifcopenshell.api.pset
|
||||
import ifcopenshell.api.root
|
||||
import ifcopenshell.api.style
|
||||
import ifcopenshell.api.type
|
||||
@@ -630,15 +631,15 @@ class TestUsingArrays(NewFile):
|
||||
def test_remove_array_first_to_last(self):
|
||||
self.setup_array(add_second_layer=True)
|
||||
bpy.ops.bim.remove_array(item=0)
|
||||
assert len(bpy.context.selected_objects) == 3
|
||||
assert len(self._array_objects()) == 3
|
||||
bpy.ops.bim.remove_array(item=0)
|
||||
assert len(bpy.context.selected_objects) == 1
|
||||
assert len(self._array_objects()) == 1
|
||||
|
||||
def test_apply_array_1_layer(self):
|
||||
self.setup_array()
|
||||
bpy.ops.bim.apply_array()
|
||||
|
||||
objs = bpy.context.selected_objects
|
||||
objs = self._array_objects()
|
||||
assert len(objs) == 4
|
||||
# check BBIM_Array psets are removed
|
||||
for obj in objs:
|
||||
@@ -664,7 +665,7 @@ class TestUsingArrays(NewFile):
|
||||
self.setup_array(sync_children=True)
|
||||
bpy.ops.bim.apply_array()
|
||||
|
||||
objs = bpy.context.selected_objects
|
||||
objs = self._array_objects()
|
||||
assert len(objs) == 4
|
||||
# check BBIM_Array psets are removed
|
||||
for obj in objs:
|
||||
|
||||
@@ -57,7 +57,8 @@ class CsvHeader(TypedDict):
|
||||
|
||||
# Formula
|
||||
Formula: NotRequired[str]
|
||||
#QuantityClass: NotRequired[str]
|
||||
# QuantityClass: NotRequired[str]
|
||||
|
||||
|
||||
# Currently we assume that if column is not part of the main header,
|
||||
# then it is a cost value category. So here we list any additional column
|
||||
@@ -97,7 +98,8 @@ class CostItem(TypedDict):
|
||||
Query: Union[str, None]
|
||||
|
||||
Formula: Union[str, None]
|
||||
#QuantityClass: Union[str, None]
|
||||
# QuantityClass: Union[str, None]
|
||||
|
||||
|
||||
class Csv2Ifc:
|
||||
# Inputs.
|
||||
@@ -420,7 +422,7 @@ class Csv2Ifc:
|
||||
products=results,
|
||||
formula=cost_item["Formula"],
|
||||
ifc_class=ifc_quantity_class,
|
||||
)
|
||||
)
|
||||
|
||||
self.create_cost_items(cost_item["children"], cost_item["ifc"])
|
||||
|
||||
|
||||
@@ -252,6 +252,10 @@ int main(int argc, char** argv) {
|
||||
("stderr-progress", "output progress to stderr stream")
|
||||
("yes,y", "answer 'yes' automatically to possible confirmation queries (e.g. overwriting an existing output file)")
|
||||
("no-progress", "suppress possible progress bar type of prints that use carriage return")
|
||||
("fail-on-error", "return a non-zero exit code when one or more errors were logged during "
|
||||
"geometry conversion (e.g. an element failed to convert). By default IfcConvert exits "
|
||||
"successfully as long as an output file could be written, even if some elements were "
|
||||
"silently dropped. Enable this flag so scripts and CI can detect partial conversions.")
|
||||
("log-format", po::value<std::string>(&log_format), "log format: plain or json")
|
||||
("log-file", new po::typed_value<path_t, char_t>(&log_file), "redirect log output to file");
|
||||
|
||||
@@ -449,6 +453,7 @@ int main(int argc, char** argv) {
|
||||
|
||||
const bool mmap = vmap.count("mmap") != 0;
|
||||
const bool no_progress = vmap.count("no-progress") != 0;
|
||||
const bool fail_on_error = vmap.count("fail-on-error") != 0;
|
||||
const bool quiet = vmap.count("quiet") != 0;
|
||||
const bool stderr_progress = vmap.count("stderr-progress") != 0;
|
||||
|
||||
@@ -885,6 +890,7 @@ int main(int argc, char** argv) {
|
||||
}
|
||||
|
||||
if (!serializer->ready()) {
|
||||
logger.Error("SYS", 25, "Unable to open output file '" + IfcUtil::path::to_utf8(output_filename) + "' for writing; check that the directory exists and is writable");
|
||||
IfcUtil::path::delete_file(IfcUtil::path::to_utf8(output_temp_filename));
|
||||
write_log(!quiet);
|
||||
return EXIT_FAILURE;
|
||||
@@ -1220,6 +1226,11 @@ int main(int argc, char** argv) {
|
||||
successful = false;
|
||||
}
|
||||
|
||||
if (fail_on_error && logger.MaxSeverity() >= Logger::LOG_ERROR) {
|
||||
logger.Error("SYS", 26, "Errors encountered during processing, failing due to --fail-on-error.");
|
||||
successful = false;
|
||||
}
|
||||
|
||||
if (logger.Verbosity() == Logger::LOG_PERF) {
|
||||
logger.PrintPerformanceStats();
|
||||
}
|
||||
|
||||
@@ -361,8 +361,8 @@ namespace ifcopenshell {
|
||||
|
||||
struct CircleSegments : public SettingBase<CircleSegments, int> {
|
||||
static constexpr const char* const name = "circle-segments";
|
||||
static constexpr const char* const description = "Number of segments to approximate full circles in CGAL kernel.";
|
||||
static constexpr int defaultvalue = 16;
|
||||
static constexpr const char* const description = "Number of segments to approximate full circles in the CGAL kernel. When 0 (the default) the segment count is derived from mesher-linear-deflection instead, so curves stay within the deflection tolerance regardless of radius.";
|
||||
static constexpr int defaultvalue = 0;
|
||||
};
|
||||
|
||||
struct CgalSmoothAngleDegrees : public SettingBase<CgalSmoothAngleDegrees, double> {
|
||||
|
||||
@@ -391,6 +391,11 @@ namespace {
|
||||
}
|
||||
};
|
||||
|
||||
// Representative radius used to size the polygonal approximation of a conic.
|
||||
// For an ellipse the larger semi-axis is the conservative choice.
|
||||
inline double conic_radius(const taxonomy::circle::ptr& c) { return c->radius; }
|
||||
inline double conic_radius(const taxonomy::ellipse::ptr& e) { return e->radius > e->radius2 ? e->radius : e->radius2; }
|
||||
|
||||
struct cgal_curve_creation_visitor {
|
||||
Settings& settings_;
|
||||
parameter_range param;
|
||||
@@ -425,7 +430,36 @@ namespace {
|
||||
if (b <= a) {
|
||||
b += 2 * M_PI;
|
||||
}
|
||||
int num_segments = (int)std::ceil(std::fabs(a - b) / (2 * M_PI) * settings_.get<settings::CircleSegments>().get());
|
||||
const double span = std::fabs(a - b);
|
||||
// CircleSegments controls how conics (circles, ellipses, arcs) are approximated
|
||||
// in the CGAL kernel. Two modes, one or the other:
|
||||
// - CircleSegments == 0 (the default): the segment count is derived from
|
||||
// MesherLinearDeflection, so the chord deviation stays within the mesher's
|
||||
// linear deflection regardless of radius. This matches the deflection based
|
||||
// meshing the OpenCascade kernel already does and fixes issue #8051, where
|
||||
// large radius arcs (curved curtain wall mullions) collapsed to straight chords
|
||||
// because a fixed segment count is radius agnostic.
|
||||
// - CircleSegments > 0: it is used directly as the number of segments for a full
|
||||
// circle, giving deterministic, radius independent output.
|
||||
int num_segments;
|
||||
const int circle_segments = settings_.get<settings::CircleSegments>().get();
|
||||
if (circle_segments > 0) {
|
||||
num_segments = (int)std::ceil(span / (2 * M_PI) * circle_segments);
|
||||
} else {
|
||||
const double radius = conic_radius(t);
|
||||
const double deflection = settings_.get<settings::MesherLinearDeflection>().get();
|
||||
if (deflection > 0. && radius > deflection) {
|
||||
const double max_segment_angle = 2.0 * std::acos(1.0 - deflection / radius);
|
||||
num_segments = (int)std::ceil(span / max_segment_angle);
|
||||
} else {
|
||||
// Radius within the deflection tolerance (or no deflection set): a chord per
|
||||
// quarter turn already keeps the deviation within tolerance.
|
||||
num_segments = (int)std::ceil(span / (M_PI / 2.));
|
||||
}
|
||||
}
|
||||
if (num_segments < 1) {
|
||||
num_segments = 1;
|
||||
}
|
||||
double du = (b - a) / num_segments;
|
||||
taxonomy::point3 P;
|
||||
// @nb for loop is not inclusive of the both end points
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
#include <ShapeFix_Shape.hxx>
|
||||
#include <ShapeFix_ShapeTolerance.hxx>
|
||||
#include <BRep_Tool.hxx>
|
||||
#include <BRepExtrema_DistShapeShape.hxx>
|
||||
|
||||
#include <Standard_Macro.hxx>
|
||||
#include <TopoDS_Shape.hxx>
|
||||
@@ -356,6 +357,27 @@ bool OpenCascadeKernel::convert(const taxonomy::face::ptr face, TopoDS_Shape& re
|
||||
return false;
|
||||
}
|
||||
|
||||
// #527: A face whose inner boundary intersects the outer boundary (or
|
||||
// another inner boundary) is invalid per the schema. Open Cascade heals or
|
||||
// drops such a face silently, so the intended hole is lost with no
|
||||
// diagnostic. The distance between two non-intersecting loops is strictly
|
||||
// positive; a distance at (or below) the modelling precision means the
|
||||
// boundaries touch or cross. Emit a clear warning so the invalid input is
|
||||
// not silently lost. wires() is ordered outer-first, inner-bounds after.
|
||||
if (fd.wires().size() > 1) {
|
||||
const auto& fwires = fd.wires();
|
||||
bool reported = false;
|
||||
for (size_t i = 1; i < fwires.size() && !reported; ++i) {
|
||||
for (size_t j = 0; j < i && !reported; ++j) {
|
||||
BRepExtrema_DistShapeShape dss(fwires[i], fwires[j]);
|
||||
if (dss.IsDone() && dss.Value() < precision_) {
|
||||
logger().Warning("GEO", 402, "Face inner boundary intersects another face boundary", face->instance);
|
||||
reported = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fd.surface().IsNull()) {
|
||||
// Use the first wire to find a plane manually for polygonal wires
|
||||
const TopoDS_Wire& wire = fd.wires().front();
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// 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 "mapping.h"
|
||||
#define mapping POSTFIX_SCHEMA(mapping)
|
||||
using namespace ifcopenshell::geometry;
|
||||
|
||||
#include "../profile_helper.h"
|
||||
|
||||
// In IFC2X3 IfcAsymmetricIShapeProfileDef is a subtype of IfcIShapeProfileDef and is
|
||||
// therefore dispatched (and handled) by the IfcIShapeProfileDef mapping. From IFC4
|
||||
// onwards it is a standalone subtype of IfcParameterizedProfileDef with its own
|
||||
// Bottom*/Top* attributes, so nothing mapped it and the extrusion came out empty.
|
||||
// The presence of the standalone BottomFlangeWidth attribute is the discriminator:
|
||||
// it is only defined in the schemas where the type is standalone (IFC4 / IFC4X3).
|
||||
#ifdef SCHEMA_IfcAsymmetricIShapeProfileDef_HAS_BottomFlangeWidth
|
||||
|
||||
taxonomy::ptr mapping::map_impl(const IfcSchema::IfcAsymmetricIShapeProfileDef* inst) {
|
||||
// Bottom flange (half width), overall depth (half), web (half thickness).
|
||||
const double xb = inst->BottomFlangeWidth() / 2.0 * length_unit_;
|
||||
const double xt = inst->TopFlangeWidth() / 2.0 * length_unit_;
|
||||
const double y = inst->OverallDepth() / 2.0 * length_unit_;
|
||||
const double d1 = inst->WebThickness() / 2.0 * length_unit_;
|
||||
|
||||
// Bottom flange thickness; top flange thickness defaults to the bottom one.
|
||||
const double ftb = inst->BottomFlangeThickness() * length_unit_;
|
||||
const double ftt = inst->TopFlangeThickness().get_value_or(inst->BottomFlangeThickness()) * length_unit_;
|
||||
|
||||
// Optional fillet radii (web/flange transition) and flange edge radii.
|
||||
const double fb = inst->BottomFlangeFilletRadius().get_value_or(0.) * length_unit_;
|
||||
const double ft_top = inst->TopFlangeFilletRadius().get_value_or(0.) * length_unit_;
|
||||
const double feb = inst->BottomFlangeEdgeRadius().get_value_or(0.) * length_unit_;
|
||||
const double fet = inst->TopFlangeEdgeRadius().get_value_or(0.) * length_unit_;
|
||||
|
||||
// Optional flange slopes: the inner edge of the flange rises towards the web.
|
||||
const double bottomSlope = inst->BottomFlangeSlope().get_value_or(0.) * angle_unit_;
|
||||
const double topSlope = inst->TopFlangeSlope().get_value_or(0.) * angle_unit_;
|
||||
const double dyb = (xb - d1) * tan(bottomSlope);
|
||||
const double dyt = (xt - d1) * tan(topSlope);
|
||||
|
||||
const double tol = settings_.get<settings::Precision>().get();
|
||||
|
||||
if (xb < tol || xt < tol || y < tol || d1 < tol || ftb < tol || ftt < tol) {
|
||||
logger_.Message(Logger::LOG_NOTICE, "GEO", 264, "Skipping zero sized profile:", inst);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
taxonomy::matrix4::ptr m4;
|
||||
bool has_position = true;
|
||||
#ifdef SCHEMA_IfcParameterizedProfileDef_Position_IS_OPTIONAL
|
||||
has_position = !!inst->Position();
|
||||
#endif
|
||||
if (has_position) {
|
||||
m4 = taxonomy::cast<taxonomy::matrix4>(map(inst->Position()));
|
||||
}
|
||||
|
||||
// Twelve corner points, running counter-clockwise from the bottom-left, with the
|
||||
// bottom flange (xb) possibly wider than the top flange (xt). Fillet/edge radii are
|
||||
// attached to the corner they round, matching the symmetric IfcIShapeProfileDef.
|
||||
return profile_helper(m4, {
|
||||
{{-xb,-y}},
|
||||
{{xb,-y}},
|
||||
{{xb,-y + ftb}, {feb}},
|
||||
{{d1,-y + ftb + dyb},{fb} },
|
||||
{{d1,y - ftt - dyt},{ft_top} },
|
||||
{{xt,y - ftt}, {fet}},
|
||||
{{xt,y}},
|
||||
{{-xt,y}},
|
||||
{{-xt,y - ftt}, {fet}},
|
||||
{{-d1,y - ftt - dyt},{ft_top} },
|
||||
{{-d1,-y + ftb + dyb},{fb} },
|
||||
{{-xb,-y + ftb}, {feb}}
|
||||
});
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -39,8 +39,25 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcPolygonalFaceSet* inst) {
|
||||
|
||||
int max_index = (int)points.size();
|
||||
|
||||
// When the optional PnIndex is present, CoordIndex values do not index into
|
||||
// CoordList directly but into PnIndex, which in turn remaps to CoordList.
|
||||
// Both index levels are 1-based per the IFC specification.
|
||||
auto pn_index = inst->PnIndex();
|
||||
auto resolve = [&](int idx) -> const taxonomy::point3::ptr& {
|
||||
if (pn_index) {
|
||||
if (idx < 1 || idx > (int)pn_index->size()) {
|
||||
throw IfcParse::IfcException("IfcPolygonalFaceSet PnIndex out of bounds for index " + boost::lexical_cast<std::string>(idx));
|
||||
}
|
||||
idx = (*pn_index)[idx - 1];
|
||||
}
|
||||
if (idx < 1 || idx > max_index) {
|
||||
throw IfcParse::IfcException("IfcPolygonalFaceSet index out of bounds for index " + boost::lexical_cast<std::string>(idx));
|
||||
}
|
||||
return points[idx - 1];
|
||||
};
|
||||
|
||||
auto shell = taxonomy::make<taxonomy::shell>();
|
||||
|
||||
|
||||
for (auto& f : *polygonal_faces) {
|
||||
auto fa = taxonomy::make<taxonomy::face>();
|
||||
shell->children.push_back(fa);
|
||||
@@ -52,17 +69,14 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcPolygonalFaceSet* inst) {
|
||||
auto indices = f->CoordIndex();
|
||||
taxonomy::point3::ptr previous;
|
||||
for (std::vector<int>::const_iterator jt = indices.begin(); jt != indices.end(); ++jt) {
|
||||
if (*jt < 1 || *jt > max_index) {
|
||||
throw IfcParse::IfcException("IfcPolygonalFaceSet index out of bounds for index " + boost::lexical_cast<std::string>(*jt));
|
||||
}
|
||||
auto current = points[(*jt) - 1];
|
||||
auto current = resolve(*jt);
|
||||
if (jt != indices.begin()) {
|
||||
loop->children.push_back(taxonomy::make<taxonomy::edge>(previous, current));
|
||||
}
|
||||
previous = current;
|
||||
}
|
||||
if (!indices.empty()) {
|
||||
auto current = points[indices.front() - 1];
|
||||
auto current = resolve(indices.front());
|
||||
loop->children.push_back(taxonomy::make<taxonomy::edge>(previous, current));
|
||||
}
|
||||
}
|
||||
@@ -77,17 +91,14 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcPolygonalFaceSet* inst) {
|
||||
loop->external = false;
|
||||
|
||||
for (std::vector<int>::const_iterator jt = li.begin(); jt != li.end(); ++jt) {
|
||||
if (*jt < 1 || *jt > max_index) {
|
||||
throw IfcParse::IfcException("IfcPolygonalFaceSet index out of bounds for index " + boost::lexical_cast<std::string>(*jt));
|
||||
}
|
||||
auto current = points[(*jt) - 1];
|
||||
auto current = resolve(*jt);
|
||||
if (jt != li.begin()) {
|
||||
loop->children.push_back(taxonomy::make<taxonomy::edge>(previous, current));
|
||||
}
|
||||
previous = current;
|
||||
}
|
||||
if (!li.empty()) {
|
||||
auto current = points[li.front() - 1];
|
||||
auto current = resolve(li.front());
|
||||
loop->children.push_back(taxonomy::make<taxonomy::edge>(previous, current));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,23 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcTriangulatedFaceSet* inst) {
|
||||
|
||||
int max_index = (int)points.size();
|
||||
|
||||
// When the optional PnIndex is present, CoordIndex values do not index into
|
||||
// CoordList directly but into PnIndex, which in turn remaps to CoordList.
|
||||
// Both index levels are 1-based per the IFC specification.
|
||||
auto pn_index = inst->PnIndex();
|
||||
auto resolve = [&](int idx) -> const taxonomy::point3::ptr& {
|
||||
if (pn_index) {
|
||||
if (idx < 1 || idx > (int)pn_index->size()) {
|
||||
throw IfcParse::IfcException("IfcTriangulatedFaceSet PnIndex out of bounds for index " + boost::lexical_cast<std::string>(idx));
|
||||
}
|
||||
idx = (*pn_index)[idx - 1];
|
||||
}
|
||||
if (idx < 1 || idx > max_index) {
|
||||
throw IfcParse::IfcException("IfcTriangulatedFaceSet index out of bounds for index " + boost::lexical_cast<std::string>(idx));
|
||||
}
|
||||
return points[idx - 1];
|
||||
};
|
||||
|
||||
auto shell = taxonomy::make<taxonomy::shell>();
|
||||
|
||||
for (auto& indices : indices_list) {
|
||||
@@ -51,10 +68,7 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcTriangulatedFaceSet* inst) {
|
||||
loop->external = true;
|
||||
taxonomy::point3::ptr first, previous;
|
||||
for (std::vector<int>::const_iterator jt = indices.begin(); jt != indices.end(); ++jt) {
|
||||
if (*jt < 1 || *jt > max_index) {
|
||||
throw IfcParse::IfcException("IfcTriangulatedFaceSet index out of bounds for index " + boost::lexical_cast<std::string>(*jt));
|
||||
}
|
||||
const taxonomy::point3::ptr& current = points[(*jt) - 1];
|
||||
const taxonomy::point3::ptr& current = resolve(*jt);
|
||||
if (jt == indices.begin()) {
|
||||
first = current;
|
||||
} else {
|
||||
|
||||
@@ -89,7 +89,11 @@ BIND(IfcRectangleHollowProfileDef);
|
||||
BIND(IfcRectangleProfileDef);
|
||||
BIND(IfcTrapeziumProfileDef);
|
||||
BIND(IfcCShapeProfileDef);
|
||||
// IfcAsymmetricIShapeProfileDef included
|
||||
// In IFC2X3 IfcAsymmetricIShapeProfileDef is a subtype of IfcIShapeProfileDef and is
|
||||
// mapped by it; from IFC4 onwards it is a standalone type and needs its own binding.
|
||||
#ifdef SCHEMA_IfcAsymmetricIShapeProfileDef_HAS_BottomFlangeWidth
|
||||
BIND(IfcAsymmetricIShapeProfileDef);
|
||||
#endif
|
||||
BIND(IfcIShapeProfileDef);
|
||||
BIND(IfcLShapeProfileDef);
|
||||
BIND(IfcTShapeProfileDef);
|
||||
|
||||
@@ -311,8 +311,12 @@ CLI Manual
|
||||
output.
|
||||
--force-space-transparency arg Overrides transparency of spaces in
|
||||
geometry output.
|
||||
--circle-segments arg (= 16) Number of segments to approximate full
|
||||
circles in CGAL kernel.
|
||||
--circle-segments arg (= 0) Number of segments to approximate full
|
||||
circles in the CGAL kernel. When 0 (the
|
||||
default) the segment count is derived from
|
||||
mesher-linear-deflection instead, so curves
|
||||
stay within the deflection tolerance
|
||||
regardless of radius.
|
||||
--cgal-smooth-angle-degrees arg (= -1)
|
||||
Angle in degrees under which adjacent
|
||||
facets will have averaged vertex
|
||||
|
||||
@@ -72,6 +72,8 @@ Filtering is typically used to select any IFC element or type.
|
||||
|
||||
"``IfcPump, location=""Level 3""``", "Locations bubble up the hierarchy. So if a pump is in a space and that space is on Level 3, then you can say ""all pumps on level 3"" which will include that pump in the space."
|
||||
|
||||
"``IfcElement, query:""parent.Name""=""My Site""``", "Only elements *immediately* under ""My Site"" in the spatial hierarchy. Unlike the ``location`` and ``parent`` filters, which both match at any depth, the ``parent`` query key resolves the direct parent only, so nested storeys (and their contents) are excluded."
|
||||
|
||||
The filter elements syntax works by specifying one or more groups of filters
|
||||
separated by a ``+`` character. Each filter group will return a set of filtered
|
||||
elements, and these are unioned together.
|
||||
@@ -111,6 +113,15 @@ will search through all IfcTypeProducts and IfcProducts in the IFC project.
|
||||
"Parent", "Filter", "``parent{{=}}{{value}}``", "``parent=Foo`` specifies the criteria that elements must be a direct or indirect child in the spatial hierarchy to an element with a ``Name`` attribute with a value of ``Foo``."
|
||||
"Query", "Filter", "``query:{{keys}}{{=}}{{value}}``", "``query:types.count=0`` specifies the criteria that elements must have zero type occurrences. The query keys corresponds to the syntax used in the `Getting element values`_ section"
|
||||
|
||||
.. note::
|
||||
|
||||
The ``location`` and ``parent`` filters both match at **any depth** in the
|
||||
spatial hierarchy. To match only elements *immediately* contained in (or
|
||||
aggregated under) a spatial element, use the ``parent`` query key, which
|
||||
resolves the direct parent only. For example,
|
||||
``query:"parent.Name"="My Site"`` selects elements directly under ``My
|
||||
Site`` but excludes anything nested inside its sub-storeys or spaces.
|
||||
|
||||
When you specify a filter with a ``{{=}}`` check, you can choose from one of
|
||||
the following comparison checks:
|
||||
|
||||
@@ -191,7 +202,7 @@ Valid keys are:
|
||||
"``storey``", "Gets the first IfcBuildingStorey spatial element that an element is contained in."
|
||||
"``building``", "Gets the first IfcBuilding spatial element that an element is contained in."
|
||||
"``site``", "Gets the first IfcSite spatial element that an element is contained in."
|
||||
"``parent``", "Gets the parent element in the spatial hierarchy."
|
||||
"``parent``", "Gets the **immediate** parent element in the spatial hierarchy (the direct spatial container, or the direct aggregate/nest/fill/void parent). Combine with ``.Name`` in a query filter to match only immediate children, e.g. ``query:""parent.Name""=""My Site""``."
|
||||
"``classification``", "Gets the element's classification reference(s)"
|
||||
"``group``", "Gets the element's group(s)"
|
||||
"``system``", "Gets the element's system(s). This is a subset of group(s)."
|
||||
|
||||
@@ -228,10 +228,10 @@ circle-segments
|
||||
+------+-----------------------+---------+
|
||||
| Type | IfcConvert Option | Default |
|
||||
+======+=======================+=========+
|
||||
| INT | ``--circle-segments`` | 16 |
|
||||
| INT | ``--circle-segments`` | 0 |
|
||||
+------+-----------------------+---------+
|
||||
|
||||
Number of segments to approximate full circles in CGAL kernel.
|
||||
Number of segments to approximate full circles in the CGAL kernel. When 0 (the default) the segment count is derived from mesher-linear-deflection instead, so curves stay within the deflection tolerance regardless of radius.
|
||||
|
||||
context-identifiers
|
||||
^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
@@ -231,7 +231,7 @@ def open(
|
||||
kwargs = {"mmap": mmap}
|
||||
if logger is not None:
|
||||
kwargs["logger"] = logger
|
||||
f = ifcopenshell_wrapper.open(str(path.absolute()), **kwargs) # ty: ignore[unknown-argument]
|
||||
f = ifcopenshell_wrapper.open(str(path.absolute()), **kwargs)
|
||||
else:
|
||||
f = ifcopenshell_wrapper.open(str(path.absolute()), False, *((logger,) if logger is not None else ()))
|
||||
return file(f)
|
||||
|
||||
@@ -49,6 +49,7 @@ Future versions of this API may support:
|
||||
|
||||
from ._get_segment_start_point_label import register_referent_name_callback
|
||||
from .add_stationing_referent import add_stationing_referent
|
||||
from .add_positioning_referent import add_positioning_referent
|
||||
from .add_vertical_layout import add_vertical_layout
|
||||
from .add_zero_length_segment import add_zero_length_segment
|
||||
from .create import create
|
||||
@@ -94,6 +95,7 @@ from .util import *
|
||||
|
||||
__all__ = [
|
||||
"add_stationing_referent",
|
||||
"add_positioning_referent",
|
||||
"add_vertical_layout",
|
||||
"add_zero_length_segment",
|
||||
"create",
|
||||
|
||||
@@ -22,8 +22,6 @@ import numpy as np
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.alignment
|
||||
import ifcopenshell.geom
|
||||
import ifcopenshell.ifcopenshell_wrapper as ifcopenshell_wrapper
|
||||
import ifcopenshell.util.unit
|
||||
from ifcopenshell import entity_instance
|
||||
from ifcopenshell.api.alignment._get_segment_endpoint import _get_segment_endpoint
|
||||
from ifcopenshell.api.alignment._update_zero_length_segment_placement import _update_zero_length_segment_placement
|
||||
|
||||
@@ -22,28 +22,11 @@ import numpy as np
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.alignment
|
||||
from ifcopenshell.api.alignment import _map_alignment_cant_segment
|
||||
from ifcopenshell.api.alignment._update_zero_length_segment_placement import _update_zero_length_segment_placement
|
||||
import ifcopenshell.api.nest
|
||||
import ifcopenshell.api.pset
|
||||
import ifcopenshell.geom
|
||||
import ifcopenshell.util.alignment
|
||||
import ifcopenshell.util.unit
|
||||
from ifcopenshell import entity_instance, ifcopenshell_wrapper
|
||||
from ifcopenshell import entity_instance
|
||||
from ifcopenshell.api.alignment._add_segment_to_curve import _add_segment_to_curve
|
||||
from ifcopenshell.api.alignment._get_segment_endpoint import _get_segment_endpoint
|
||||
from ifcopenshell.api.alignment._get_segment_start_point_label import (
|
||||
_get_segment_start_point_label,
|
||||
)
|
||||
from ifcopenshell.api.alignment._map_alignment_cant_segment import (
|
||||
_map_alignment_cant_segment,
|
||||
)
|
||||
from ifcopenshell.api.alignment._map_alignment_horizontal_segment import (
|
||||
_map_alignment_horizontal_segment,
|
||||
)
|
||||
from ifcopenshell.api.alignment._map_alignment_vertical_segment import (
|
||||
_map_alignment_vertical_segment,
|
||||
)
|
||||
|
||||
|
||||
def _add_segment_to_layout(
|
||||
|
||||
@@ -18,11 +18,7 @@
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.alignment
|
||||
import ifcopenshell.util.alignment
|
||||
from ifcopenshell import entity_instance
|
||||
from ifcopenshell.api.alignment._get_segment_start_point_label import (
|
||||
_get_segment_start_point_label,
|
||||
)
|
||||
|
||||
|
||||
def _add_zero_length_segment(file: ifcopenshell.file, layout: entity_instance) -> None:
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
|
||||
import ifcopenshell.api.alignment
|
||||
import ifcopenshell.geom
|
||||
from ifcopenshell import entity_instance, ifcopenshell_wrapper
|
||||
from ifcopenshell.api.alignment._map_alignment_segment import _map_alignment_segment
|
||||
from typing import Union
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
# IfcOpenShell - IFC toolkit and geometry engine
|
||||
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
|
||||
#
|
||||
# This file is part of IfcOpenShell.
|
||||
#
|
||||
# IfcOpenShell is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 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
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.alignment
|
||||
from ifcopenshell.api.alignment.update_fallback_position import update_fallback_position
|
||||
import ifcopenshell.api.pset
|
||||
import ifcopenshell.guid
|
||||
from ifcopenshell import entity_instance
|
||||
|
||||
|
||||
def add_positioning_referent(
|
||||
file: ifcopenshell.file,
|
||||
name: str,
|
||||
alignment: entity_instance,
|
||||
distance_along: float,
|
||||
station: float,
|
||||
positioned_product: entity_instance,
|
||||
) -> entity_instance:
|
||||
"""
|
||||
Semantically defines the position of a product along an alignment by adding an IfcReferent to the alignment that defines the stationing system.
|
||||
|
||||
:param alignment: the alignment to receive the referent
|
||||
:param distance_along: distance along the alignment basis curve
|
||||
:param station: station value
|
||||
:param name: name to assign to IfcReferent.Name, typically a stringized version of the station value
|
||||
:param positioned_product: the product whose position is informed by the referent
|
||||
:return: referent
|
||||
|
||||
Example:
|
||||
|
||||
.. code:: python
|
||||
|
||||
alignment = model.by_type("IfcAlignment")[0]
|
||||
pier = model.by_type("IfcBridgePart")[0]
|
||||
ifcopenshell.api.alignment.add_positioning_referent(model,name="Pier 1 Sta 1+00",alignment=alignment,distance_along=0.0,station=100.0,positioned_product=pier)
|
||||
"""
|
||||
|
||||
curve = ifcopenshell.api.alignment.get_curve(alignment)
|
||||
|
||||
object_placement = None
|
||||
representation = None
|
||||
if curve and curve.is_a("IfcCompositeCurve") and 0 < len(curve.Segments):
|
||||
object_placement = file.createIfcLinearPlacement(
|
||||
RelativePlacement=file.createIfcAxis2PlacementLinear(
|
||||
Location=file.createIfcPointByDistanceExpression(
|
||||
DistanceAlong=file.createIfcLengthMeasure(distance_along),
|
||||
OffsetLateral=None,
|
||||
OffsetVertical=None,
|
||||
OffsetLongitudinal=None,
|
||||
BasisCurve=curve,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
update_fallback_position(file, object_placement)
|
||||
else:
|
||||
object_placement = file.createIfcLocalPlacement(
|
||||
PlacementRelTo=None,
|
||||
RelativePlacement=file.createIfcAxis2Placement2D(
|
||||
Location=file.createIfcCartesianPoint(alignment.ObjectPlacement.RelativePlacement.Location.Coordinates)
|
||||
),
|
||||
)
|
||||
|
||||
# this commented out code is what you would do to add a geometric representation of the referent
|
||||
# the example is a circle. a better way would be to pass a representation into the function
|
||||
# representation = file.create_entity(
|
||||
# name="IfcCircle",
|
||||
# position=file.createIfcAxis2Placement2D(Location=file.createIfcCartesianPoint(Coordinates=(0.0, 0.0)),
|
||||
# radius=1.0)
|
||||
# )
|
||||
|
||||
# create referent for the station
|
||||
referent = file.createIfcReferent(
|
||||
GlobalId=ifcopenshell.guid.new(),
|
||||
OwnerHistory=None,
|
||||
Name=name,
|
||||
Description=None,
|
||||
ObjectType=None,
|
||||
ObjectPlacement=object_placement,
|
||||
Representation=representation,
|
||||
PredefinedType="POSITION",
|
||||
)
|
||||
pset_stationing = ifcopenshell.api.pset.add_pset(file, product=referent, name="Pset_Stationing")
|
||||
ifcopenshell.api.pset.edit_pset(file, pset=pset_stationing, properties={"Station": station})
|
||||
|
||||
if len(referent.Positions) == 0:
|
||||
rel_positions = file.createIfcRelPositions(
|
||||
GlobalId=ifcopenshell.guid.new(),
|
||||
RelatingPositioningElement=referent,
|
||||
RelatedProducts=[
|
||||
positioned_product,
|
||||
],
|
||||
)
|
||||
else:
|
||||
referent.Positions[0].RelatedProducts += (positioned_product,)
|
||||
|
||||
return referent
|
||||
@@ -16,35 +16,35 @@
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import numpy as np
|
||||
from typing import Optional
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.alignment
|
||||
from ifcopenshell.api.alignment.update_fallback_position import update_fallback_position
|
||||
import ifcopenshell.api.pset
|
||||
import ifcopenshell.geom
|
||||
import ifcopenshell.guid
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.util.unit
|
||||
from ifcopenshell import entity_instance, ifcopenshell_wrapper
|
||||
from ifcopenshell import entity_instance
|
||||
|
||||
|
||||
def add_stationing_referent(
|
||||
file: ifcopenshell.file,
|
||||
name: str,
|
||||
alignment: entity_instance,
|
||||
distance_along: float,
|
||||
station: float,
|
||||
name: str,
|
||||
positioned_product: entity_instance,
|
||||
incoming_station: Optional[float] = None,
|
||||
on_basis_curve: Optional[bool] = None,
|
||||
) -> entity_instance:
|
||||
"""
|
||||
Adds an IfcReferent to the alignment with the Pset_Stationing property set.
|
||||
Adds an IfcReferent to the alignment that defines the stationing system.
|
||||
|
||||
:param name: name to assign to IfcReferent.Name, typically a stringized version of the station value
|
||||
:param alignment: the alignment to receive the referent
|
||||
:param distance_along: distance along the alignment basis curve
|
||||
:param station: station value
|
||||
:param name: name to assign to IfcReferent.Name, typically a stringized version of the station value
|
||||
:param positioned_product: the product whose position is informed by the referent
|
||||
:param incoming_station: station value of the incoming segment, only set to specify a station equation
|
||||
:param on_basis_curve: whether the referent is positioned on the basis curve or the alignment curve, if None the function will default to the basis curve
|
||||
:return: referent
|
||||
|
||||
Example:
|
||||
@@ -52,14 +52,21 @@ def add_stationing_referent(
|
||||
.. code:: python
|
||||
|
||||
alignment = model.by_type("IfcAlignment")[0]
|
||||
ifcopenshell.api.alignment.add_stationing_referent(model,alignment=alignment,distance_along=0.0,station=100.0)
|
||||
ifcopenshell.api.alignment.add_stationing_referent(model,name="1+00.0",alignment=alignment,distance_along=0.0,station=100.0)
|
||||
"""
|
||||
|
||||
basis_curve = ifcopenshell.api.alignment.get_basis_curve(alignment)
|
||||
if on_basis_curve is None:
|
||||
on_basis_curve = True
|
||||
|
||||
curve = (
|
||||
ifcopenshell.api.alignment.get_basis_curve(alignment)
|
||||
if on_basis_curve
|
||||
else ifcopenshell.api.alignment.get_curve(alignment)
|
||||
)
|
||||
|
||||
object_placement = None
|
||||
representation = None
|
||||
if basis_curve and basis_curve.is_a("IfcCompositeCurve") and 0 < len(basis_curve.Segments):
|
||||
if curve and curve.is_a("IfcCompositeCurve") and 0 < len(curve.Segments):
|
||||
object_placement = file.createIfcLinearPlacement(
|
||||
RelativePlacement=file.createIfcAxis2PlacementLinear(
|
||||
Location=file.createIfcPointByDistanceExpression(
|
||||
@@ -67,7 +74,7 @@ def add_stationing_referent(
|
||||
OffsetLateral=None,
|
||||
OffsetVertical=None,
|
||||
OffsetLongitudinal=None,
|
||||
BasisCurve=basis_curve,
|
||||
BasisCurve=curve,
|
||||
)
|
||||
),
|
||||
)
|
||||
@@ -100,8 +107,12 @@ def add_stationing_referent(
|
||||
Representation=representation,
|
||||
PredefinedType="STATION",
|
||||
)
|
||||
properties = {"Station": station}
|
||||
if incoming_station is not None:
|
||||
properties["IncomingStation"] = incoming_station
|
||||
|
||||
pset_stationing = ifcopenshell.api.pset.add_pset(file, product=referent, name="Pset_Stationing")
|
||||
ifcopenshell.api.pset.edit_pset(file, pset=pset_stationing, properties={"Station": station})
|
||||
ifcopenshell.api.pset.edit_pset(file, pset=pset_stationing, properties=properties)
|
||||
|
||||
nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
|
||||
if nest is None:
|
||||
@@ -115,15 +126,4 @@ def add_stationing_referent(
|
||||
nest.RelatedObjects, key=lambda x: ifcopenshell.util.element.get_pset(x, name="Pset_Stationing", prop="Station")
|
||||
)
|
||||
|
||||
if len(referent.Positions) == 0:
|
||||
rel_positions = file.createIfcRelPositions(
|
||||
GlobalId=ifcopenshell.guid.new(),
|
||||
RelatingPositioningElement=referent,
|
||||
RelatedProducts=[
|
||||
positioned_product,
|
||||
],
|
||||
)
|
||||
else:
|
||||
referent.Positions[0].RelatedProducts += (positioned_product,)
|
||||
|
||||
return referent
|
||||
|
||||
@@ -51,18 +51,6 @@ def _move_vertical_layout_to_child_alignment(
|
||||
# aggregate the child alignment to the parent alignment
|
||||
ifcopenshell.api.aggregate.assign_object(file, products=[child_alignment], relating_object=parent_alignment)
|
||||
|
||||
# move all referents positioning segments of the vertical layout to the referent nest of the child alignment
|
||||
child_referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, child_alignment)
|
||||
parent_referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, parent_alignment)
|
||||
for referent in parent_referent_nest.RelatedObjects:
|
||||
for product in referent.Positions[0].RelatedProducts:
|
||||
if product.is_a("IfcAlignmentSegment") and product.Nests[0].RelatingObject == vertical_layout:
|
||||
# ifcopenshell.api.nest.change_nest(file,referent,child_alignment) - this doesn't work because referent is assigned to child_alignment.IsNestedBy[0].RelatedObjects
|
||||
# and it needs to be assigned to child_alignment.IsNestedBy[1].RelatedObjects
|
||||
# move the referent manually - unassign it and add it to the child alignment's referent nest
|
||||
ifcopenshell.api.nest.unassign_object(file, [referent])
|
||||
child_referent_nest.RelatedObjects += (referent,)
|
||||
|
||||
# if the parent alignment has a representation, move the Axis/Curve3D represention to the child alignment
|
||||
base_curve = ifcopenshell.api.alignment.get_basis_curve(parent_alignment)
|
||||
if base_curve:
|
||||
|
||||
@@ -23,18 +23,8 @@ import ifcopenshell.api.alignment
|
||||
from ifcopenshell.api.alignment._get_segment_endpoint import _get_segment_endpoint
|
||||
from ifcopenshell.api.alignment._update_zero_length_segment_placement import _update_zero_length_segment_placement
|
||||
import ifcopenshell.api.nest
|
||||
import ifcopenshell.ifcopenshell_wrapper as wrapper
|
||||
import ifcopenshell.util.unit
|
||||
from ifcopenshell import entity_instance
|
||||
from ifcopenshell.api.alignment._get_segment_start_point_label import (
|
||||
_get_segment_start_point_label,
|
||||
)
|
||||
from ifcopenshell.api.alignment._map_alignment_horizontal_segment import (
|
||||
_map_alignment_horizontal_segment,
|
||||
)
|
||||
from ifcopenshell.api.alignment._map_alignment_vertical_segment import (
|
||||
_map_alignment_vertical_segment,
|
||||
)
|
||||
from ifcopenshell.api.alignment._update_curve_segment_transition_code import (
|
||||
_update_curve_segment_transition_code,
|
||||
)
|
||||
|
||||
@@ -87,9 +87,7 @@ def create(
|
||||
_create_geometric_representation(file, alignment)
|
||||
|
||||
referent_name = ifcopenshell.util.alignment.station_as_string(file, start_station)
|
||||
referent = ifcopenshell.api.alignment.add_stationing_referent(
|
||||
file, alignment, 0.0, start_station, referent_name, alignment
|
||||
)
|
||||
referent = ifcopenshell.api.alignment.add_stationing_referent(file, referent_name, alignment, 0.0, start_station)
|
||||
|
||||
for layout in alignment_layouts:
|
||||
_add_zero_length_segment(file, layout)
|
||||
|
||||
@@ -141,7 +141,7 @@ def create_as_polyline(
|
||||
|
||||
# define stationing
|
||||
name = ifcopenshell.util.alignment.station_as_string(file, start_station)
|
||||
referent = ifcopenshell.api.alignment.add_stationing_referent(file, alignment, 0.0, start_station, name, alignment)
|
||||
referent = ifcopenshell.api.alignment.add_stationing_referent(file, name, alignment, 0.0, start_station)
|
||||
|
||||
# IFC 4.1.4.1.1 Alignment Aggregation To Project
|
||||
project = file.by_type("IfcProject")[0]
|
||||
|
||||
@@ -21,9 +21,7 @@ from typing import Union
|
||||
import numpy as np
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.alignment
|
||||
import ifcopenshell.geom
|
||||
from ifcopenshell import entity_instance, ifcopenshell_wrapper
|
||||
from ifcopenshell import entity_instance
|
||||
from ifcopenshell.api.alignment._add_segment_to_layout import _add_segment_to_layout
|
||||
|
||||
|
||||
|
||||
@@ -16,23 +16,47 @@
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.alignment
|
||||
import ifcopenshell.util.element
|
||||
from ifcopenshell import entity_instance
|
||||
|
||||
|
||||
def distance_along_from_station(file: ifcopenshell.file, alignment: entity_instance, station: float) -> float:
|
||||
def _distance_along_of_referent(referent: entity_instance) -> float:
|
||||
placement = referent.ObjectPlacement
|
||||
if placement.is_a("IfcLinearPlacement"):
|
||||
return placement.RelativePlacement.Location.DistanceAlong.wrappedValue
|
||||
# IfcLocalPlacement fallback (e.g. semantic-only alignment, or the placement could not yet
|
||||
# be expressed relative to a basis curve) carries no DistanceAlong; it is only ever used for
|
||||
# the starting referent, at distance 0.0.
|
||||
return 0.0
|
||||
|
||||
|
||||
def distance_along_from_station(file: ifcopenshell.file, alignment: entity_instance, station: float) -> Optional[float]:
|
||||
"""
|
||||
Given a station, returns the distance along the horizontal alignment.
|
||||
|
||||
If the alignment does not have stationing defined with an IfcReferent, the start of the alignment is assumed
|
||||
to be at station 0.0. That is, the station is the distance along.
|
||||
|
||||
.. note:: The current implementation does not account for station equations and assumes stationing is increasing along the alignment.
|
||||
Station equations (where Pset_Stationing.IncomingStation is set on a referent) are taken into account.
|
||||
For each STATION referent nested to the alignment, DistanceAlong (D) and the outgoing station (S, i.e.
|
||||
Pset_Stationing.Station) are read off, sorted by DistanceAlong. The requested station is located within
|
||||
the segment defined by the last referent whose outgoing station is less than or equal to it, and the
|
||||
distance along is computed as D + (station - S) for that referent.
|
||||
|
||||
If the station falls within a gap introduced by a forward (gap) station equation - that is, it was skipped
|
||||
over by the equation - there is no distance along that corresponds to it, and None is returned.
|
||||
|
||||
Note that an overlap (backward) station equation causes a range of stations to correspond to two distinct
|
||||
distances along the alignment, one on either side of the equation. This implementation returns the distance
|
||||
along in the segment following the equation (i.e. the outgoing side).
|
||||
|
||||
:param alignment: the alignment
|
||||
:param station: station value
|
||||
:return: distance along the horizontal alignment
|
||||
:return: distance along the horizontal alignment, or None if the station falls inside a station equation gap
|
||||
|
||||
Example:
|
||||
|
||||
@@ -43,6 +67,36 @@ def distance_along_from_station(file: ifcopenshell.file, alignment: entity_insta
|
||||
print(dist_along) # 100.00
|
||||
"""
|
||||
|
||||
start_station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment)
|
||||
dist_along = station - start_station
|
||||
return dist_along
|
||||
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
|
||||
if referent_nest is None:
|
||||
start_station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment)
|
||||
return station - start_station
|
||||
|
||||
stations = [
|
||||
(
|
||||
_distance_along_of_referent(referent),
|
||||
ifcopenshell.util.element.get_pset(referent, name="Pset_Stationing", prop="Station"),
|
||||
)
|
||||
for referent in referent_nest.RelatedObjects
|
||||
]
|
||||
stations.sort(key=lambda entry: entry[0])
|
||||
|
||||
index = None
|
||||
for i, (distance_along, outgoing_station) in enumerate(stations):
|
||||
if outgoing_station <= station:
|
||||
index = i
|
||||
|
||||
if index is None:
|
||||
# station precedes the alignment's starting station; extrapolate from the first referent
|
||||
distance_along, outgoing_station = stations[0]
|
||||
return distance_along + (station - outgoing_station)
|
||||
|
||||
distance_along, outgoing_station = stations[index]
|
||||
|
||||
if index + 1 < len(stations):
|
||||
next_distance_along, _ = stations[index + 1]
|
||||
if station - outgoing_station > next_distance_along - distance_along:
|
||||
# the station was skipped over by a forward (gap) station equation
|
||||
return None
|
||||
|
||||
return distance_along + (station - outgoing_station)
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from ifcopenshell import entity_instance
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import numpy as np
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.alignment
|
||||
import ifcopenshell.util.placement
|
||||
from ifcopenshell import entity_instance
|
||||
|
||||
|
||||
@@ -16,8 +16,6 @@
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import numpy as np
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.util.placement
|
||||
from ifcopenshell import entity_instance
|
||||
@@ -36,7 +34,7 @@ def update_fallback_position(file: ifcopenshell.file, lp: entity_instance):
|
||||
if not lp.CartesianPosition:
|
||||
lp.CartesianPosition = file.createIfcAxis2Placement3D(Location=file.createIfcCartesianPoint((0.0, 0.0, 0.0)))
|
||||
|
||||
p = np.array(ifcopenshell.util.placement.get_axis2placement(lp.RelativePlacement))
|
||||
p = ifcopenshell.util.placement.get_local_placement(lp)
|
||||
|
||||
x = float(p[0, 3])
|
||||
y = float(p[1, 3])
|
||||
|
||||
@@ -117,7 +117,7 @@ def assign_cost_item_quantity(
|
||||
"products": products or [],
|
||||
"prop_name": prop_name,
|
||||
"formula": formula,
|
||||
"ifc_class" : ifc_class
|
||||
"ifc_class": ifc_class,
|
||||
}
|
||||
return usecase.execute()
|
||||
|
||||
@@ -134,7 +134,7 @@ class Usecase:
|
||||
continue
|
||||
self.assign_cost_control(related_object=product, cost_item=self.settings["cost_item"])
|
||||
if self.settings["formula"]:
|
||||
tree = ast.parse(self.settings["formula"], mode = "eval")
|
||||
tree = ast.parse(self.settings["formula"], mode="eval")
|
||||
collector = VariableExtractor()
|
||||
collector.visit(tree)
|
||||
variables = collector.variables
|
||||
@@ -144,10 +144,10 @@ class Usecase:
|
||||
value = getter(product, variable)
|
||||
|
||||
if value is None:
|
||||
print(
|
||||
f"WARNING: Variable '{variable}' in product '{product.Name}' "
|
||||
f"is missing (None). Check Pset/Qset or property name."
|
||||
)
|
||||
print(
|
||||
f"WARNING: Variable '{variable}' in product '{product.Name}' "
|
||||
f"is missing (None). Check Pset/Qset or property name."
|
||||
)
|
||||
elif value == 0:
|
||||
print(
|
||||
f"WARNING: Variable '{variable}' in product '{product.Name}' "
|
||||
@@ -159,7 +159,9 @@ class Usecase:
|
||||
|
||||
new_quantity = None
|
||||
for quantity in self.quantities:
|
||||
if quantity.Formula == self.settings["formula"] and len(self.settings["products"]) == 1: #Todo improve it
|
||||
if (
|
||||
quantity.Formula == self.settings["formula"] and len(self.settings["products"]) == 1
|
||||
): # Todo improve it
|
||||
new_quantity = quantity
|
||||
self.settings["ifc_class"] = quantity.is_a()
|
||||
continue
|
||||
@@ -184,23 +186,23 @@ class Usecase:
|
||||
self.update_cost_item_count()
|
||||
|
||||
def get_value_from_pset(
|
||||
self,
|
||||
product:ifcopenshell.entity_instance,
|
||||
v: str,
|
||||
self,
|
||||
product: ifcopenshell.entity_instance,
|
||||
v: str,
|
||||
) -> float:
|
||||
pset_name = v.split(".")[0]
|
||||
pset = ifcopenshell.util.element.get_pset(product, pset_name)
|
||||
pset_property_name = v.split(".")[1]
|
||||
return (pset or {}).get(pset_property_name,None)
|
||||
return (pset or {}).get(pset_property_name, None)
|
||||
|
||||
def get_value_from_qset(
|
||||
self,
|
||||
product:ifcopenshell.entity_instance,
|
||||
v: str,
|
||||
self,
|
||||
product: ifcopenshell.entity_instance,
|
||||
v: str,
|
||||
) -> float:
|
||||
qtos = ifcopenshell.util.element.get_psets(product, qtos_only = True)
|
||||
qtos = ifcopenshell.util.element.get_psets(product, qtos_only=True)
|
||||
quantities = next(iter(qtos.values()), {})
|
||||
return (quantities or {}).get(v,None)
|
||||
return (quantities or {}).get(v, None)
|
||||
|
||||
def assign_cost_control(
|
||||
self, related_object: ifcopenshell.entity_instance, cost_item: ifcopenshell.entity_instance
|
||||
@@ -243,6 +245,7 @@ class Usecase:
|
||||
count += 1
|
||||
quantity[3] = count
|
||||
|
||||
|
||||
OPERATORS = {
|
||||
ast.Add: operator.add,
|
||||
ast.Sub: operator.sub,
|
||||
@@ -252,18 +255,20 @@ OPERATORS = {
|
||||
ast.USub: operator.neg,
|
||||
}
|
||||
|
||||
|
||||
def build_full_name(node):
|
||||
#used for variables with dots
|
||||
# used for variables with dots
|
||||
parts = []
|
||||
while isinstance(node, ast.Attribute):
|
||||
parts.append(node.attr)
|
||||
node = node.value
|
||||
parts.append(node.attr)
|
||||
node = node.value
|
||||
|
||||
if isinstance(node, ast.Name):
|
||||
parts.append(node.id)
|
||||
|
||||
return ".".join(reversed(parts))
|
||||
|
||||
|
||||
class VariableExtractor(ast.NodeVisitor):
|
||||
def __init__(self):
|
||||
self.variables = set()
|
||||
@@ -274,6 +279,7 @@ class VariableExtractor(ast.NodeVisitor):
|
||||
def visit_Attribute(self, node):
|
||||
self.variables.add(build_full_name(node))
|
||||
|
||||
|
||||
class FormulaEvaluator(ast.NodeVisitor):
|
||||
def __init__(self, values):
|
||||
self.values = values
|
||||
@@ -281,7 +287,7 @@ class FormulaEvaluator(ast.NodeVisitor):
|
||||
def visit_BinOp(self, node):
|
||||
left = self.visit(node.left)
|
||||
right = self.visit(node.right)
|
||||
return OPERATORS[type(node.op)](left, right)
|
||||
return OPERATORS[type(node.op)](left, right) # ty: ignore[too-many-positional-arguments]
|
||||
|
||||
def visit_Name(self, node):
|
||||
return self.values[node.id]
|
||||
|
||||
@@ -642,16 +642,16 @@ class entity_instance:
|
||||
return_type: type[dict] = dict,
|
||||
ignore: Sequence[str] = (),
|
||||
) -> dict[str, Any]:
|
||||
"""More perfomant version of `.get_info()` but with limited arguments values.\n
|
||||
Method has exactly the same signature as `.get_info()` but it doesn't support getting information non-recursively.
|
||||
|
||||
Currently supported arguments values:
|
||||
* recursive: `True` (will fail with default `False` value from `.get_info()`)
|
||||
* return_type: `dict`
|
||||
* ignore: `()` (empty tuple)
|
||||
"""More perfomant version of `.get_info()`.\n
|
||||
Method has exactly the same signature as `.get_info()`, but the fast C++
|
||||
path only implements ``recursive=True``, ``return_type=dict`` and
|
||||
``ignore=()``. Any other combination falls back to the pure Python
|
||||
`.get_info()`, where no meaningful performance gain is possible anyway
|
||||
as the cost is dominated by the recursive traversal.
|
||||
"""
|
||||
|
||||
assert recursive
|
||||
assert return_type is dict
|
||||
assert len(ignore) == 0
|
||||
return ifcopenshell_wrapper.get_info_cpp(self.wrapped_data, include_identifier)
|
||||
if recursive and return_type is dict and not ignore:
|
||||
return ifcopenshell_wrapper.get_info_cpp(self.wrapped_data, include_identifier)
|
||||
return self.get_info(
|
||||
include_identifier=include_identifier, recursive=recursive, return_type=return_type, ignore=ignore
|
||||
)
|
||||
|
||||
@@ -221,8 +221,7 @@ 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
|
||||
@@ -261,6 +260,4 @@ if __name__ == "__main__":
|
||||
mdl = importlib.import_module(output)
|
||||
mdl.Generator(m).emit()
|
||||
sys.stdout.write(m.schema.name)
|
||||
"""
|
||||
% ("\n ".join(statements))
|
||||
)
|
||||
""" % ("\n ".join(statements)))
|
||||
|
||||
@@ -695,6 +695,7 @@ codegen_rule("MOD", lambda context: "%")
|
||||
codegen_rule("TRUE", lambda context: "True")
|
||||
codegen_rule("FALSE", lambda context: "False")
|
||||
|
||||
|
||||
def _dotted_name(node: ast.AST):
|
||||
"""Return dotted name for Name/Attribute chains, else None."""
|
||||
if isinstance(node, ast.Name):
|
||||
@@ -704,6 +705,7 @@ def _dotted_name(node: ast.AST):
|
||||
return f"{base}.{node.attr}" if base else node.attr
|
||||
return None
|
||||
|
||||
|
||||
class AttributeGetattrTransformer(ast.NodeTransformer):
|
||||
def visit_Attribute(self, node):
|
||||
parents = []
|
||||
@@ -720,7 +722,7 @@ class AttributeGetattrTransformer(ast.NodeTransformer):
|
||||
if isinstance(node.ctx, ast.Store):
|
||||
return node
|
||||
|
||||
if _dotted_name(node) in ('ifcopenshell.create_entity', 'str.lower'):
|
||||
if _dotted_name(node) in ("ifcopenshell.create_entity", "str.lower"):
|
||||
return node
|
||||
|
||||
if node.attr.startswith("__"):
|
||||
|
||||
@@ -363,24 +363,18 @@ 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,8 +145,7 @@ 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 #
|
||||
@@ -154,15 +153,13 @@ 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 #
|
||||
@@ -180,8 +177,7 @@ 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)
|
||||
|
||||
@@ -1697,10 +1697,16 @@ class uninitialized_tag: ...
|
||||
|
||||
def arrange_polygons(settings, polygons): ...
|
||||
def clear_schemas(): ...
|
||||
def construct_iterator(geometry_library, settings, file, num_threads): ...
|
||||
def construct_iterator_with_include_exclude(geometry_library, settings, file, elems, include, num_threads): ...
|
||||
def construct_iterator_with_include_exclude_globalid(geometry_library, settings, file, elems, include, num_threads): ...
|
||||
def construct_iterator_with_include_exclude_id(geometry_library, settings, file, elems, include, num_threads): ...
|
||||
def construct_iterator(geometry_library, settings, file, num_threads, logger=...): ...
|
||||
def construct_iterator_with_include_exclude(
|
||||
geometry_library, settings, file, elems, include, num_threads, logger=...
|
||||
): ...
|
||||
def construct_iterator_with_include_exclude_globalid(
|
||||
geometry_library, settings, file, elems, include, num_threads, logger=...
|
||||
): ...
|
||||
def construct_iterator_with_include_exclude_id(
|
||||
geometry_library, settings, file, elems, include, num_threads, logger=...
|
||||
): ...
|
||||
def convert_loop_to_function_item(loop): ...
|
||||
def create_box(*args): ...
|
||||
def create_epeck(*args): ...
|
||||
@@ -1717,8 +1723,8 @@ def line_segments_to_polygons(s, eps, segments): ...
|
||||
def map_shape(settings, instance): ...
|
||||
def nary_union(sequence): ...
|
||||
def new_IfcBaseClass(schema_identifier: str, name: str) -> entity_instance: ...
|
||||
def open(fn: str, readonly: bool = False) -> file: ...
|
||||
def parse_ifcxml(filename): ...
|
||||
def open(fn: str, readonly: bool = False, logger=...) -> file: ...
|
||||
def parse_ifcxml(filename, logger=...): ...
|
||||
def polygons_to_svg(*args): ...
|
||||
def read(data): ...
|
||||
def register_schema(arg1): ...
|
||||
|
||||
@@ -56,7 +56,7 @@ def append_zero_length_segments(file: ifcopenshell.file) -> ifcopenshell.file:
|
||||
for alignment in alignments:
|
||||
layouts = ifcopenshell.api.alignment.get_alignment_layouts(alignment)
|
||||
for layout in layouts:
|
||||
ifcopenshell.api.alignment.add_zero_length_segment(patched_file, layout, include_referent=False)
|
||||
ifcopenshell.api.alignment.add_zero_length_segment(patched_file, layout)
|
||||
curve = ifcopenshell.api.alignment.get_layout_curve(layout)
|
||||
if curve:
|
||||
ifcopenshell.api.alignment.add_zero_length_segment(patched_file, curve)
|
||||
|
||||
@@ -355,8 +355,7 @@ 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?
|
||||
@@ -393,8 +392,7 @@ class CostValueUnserialiser:
|
||||
NEWLINE: (CR? LF)+
|
||||
|
||||
%ignore WS // Disregard spaces in text
|
||||
"""
|
||||
)
|
||||
""")
|
||||
start = l.parse(formula)
|
||||
return self.get_formula(start.children[0])
|
||||
|
||||
|
||||
@@ -1125,7 +1125,8 @@ def get_decomposition(element: ifcopenshell.entity_instance, is_recursive=True)
|
||||
"""
|
||||
Retrieves all subelements of an element based on the spatial decomposition
|
||||
hierarchy. This includes all subspaces and elements contained in subspaces,
|
||||
parts of an aggregate, all openings, and all fills of any openings.
|
||||
parts of an aggregate, all openings, all fills of any openings, and any
|
||||
surface features adhering to an element (IFC4.3 and above).
|
||||
|
||||
:param element: The IFC element
|
||||
:return: The decomposition of the element
|
||||
@@ -1161,6 +1162,10 @@ def get_decomposition(element: ifcopenshell.entity_instance, is_recursive=True)
|
||||
related = rel.RelatedObjects
|
||||
queue.extend(related)
|
||||
results.update(related)
|
||||
for rel in getattr(element, "HasSurfaceFeatures", []):
|
||||
related = rel.RelatedSurfaceFeatures
|
||||
queue.extend(related)
|
||||
results.update(related)
|
||||
if not is_recursive:
|
||||
break
|
||||
return results
|
||||
@@ -1251,6 +1256,8 @@ def get_parent(
|
||||
- Nesting: components are attached to a host parent
|
||||
- Filling: the physical element fills an opening, such as a window filling a hole
|
||||
- Voiding: the opening voids another physical element, such as a hole in a wall
|
||||
- Adherence: a surface feature adheres to a host element, such as a road
|
||||
marking adhering to a road course (IFC4.3 and above)
|
||||
|
||||
:param element: Any physical or spatial element in the tree
|
||||
:param ifc_class: Optionally filter the type of parent you're after. For
|
||||
@@ -1270,6 +1277,7 @@ def get_parent(
|
||||
or get_nest(element)
|
||||
or get_filled_void(element)
|
||||
or get_voided_element(element)
|
||||
or get_adhered_element(element)
|
||||
)
|
||||
|
||||
if not ifc_class:
|
||||
@@ -1321,6 +1329,28 @@ def get_voided_element(element: ifcopenshell.entity_instance) -> Union[ifcopensh
|
||||
return rel[0].RelatingBuildingElement
|
||||
|
||||
|
||||
def get_adhered_element(element: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]:
|
||||
"""If the element is a surface feature, get the element it adheres to
|
||||
|
||||
In IFC4.3 an IfcSurfaceFeature (such as a road marking) adheres to a host
|
||||
element through the IfcRelAdheresToElement relationship. This is a [1:1]
|
||||
cardinality hierarchical relationship, in the same family as aggregation,
|
||||
containment and nesting.
|
||||
|
||||
:param element: The IfcSurfaceFeature
|
||||
:return: The host element that the surface feature adheres to
|
||||
|
||||
Example:
|
||||
|
||||
.. code:: python
|
||||
|
||||
marking = file.by_type("IfcSurfaceFeature")[0]
|
||||
host = ifcopenshell.util.element.get_adhered_element(marking)
|
||||
"""
|
||||
if rel := getattr(element, "AdheresToElement", None):
|
||||
return rel[0].RelatingElement
|
||||
|
||||
|
||||
def get_aggregate(element: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]:
|
||||
"""
|
||||
Retrieves the aggregate parent of an element.
|
||||
@@ -1415,6 +1445,29 @@ def get_contained(element: ifcopenshell.entity_instance) -> list[ifcopenshell.en
|
||||
return objects
|
||||
|
||||
|
||||
def get_surface_features(element: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]:
|
||||
"""Retrieves the surface features that adhere to an element.
|
||||
|
||||
In IFC4.3 an IfcSurfaceFeature (such as a road marking) adheres to a host
|
||||
element through the IfcRelAdheresToElement relationship.
|
||||
|
||||
:param element: The IFC element
|
||||
:return: The surface features adhering to the element
|
||||
|
||||
Example:
|
||||
|
||||
.. code:: python
|
||||
|
||||
element = file.by_type("IfcCourse")[0]
|
||||
markings = ifcopenshell.util.element.get_surface_features(element)
|
||||
"""
|
||||
objects: list[ifcopenshell.entity_instance] = []
|
||||
if has_surface_features := getattr(element, "HasSurfaceFeatures", ()):
|
||||
for rel in has_surface_features:
|
||||
objects.extend(rel.RelatedSurfaceFeatures)
|
||||
return objects
|
||||
|
||||
|
||||
def get_components(
|
||||
element: ifcopenshell.entity_instance, include_ports: bool = False
|
||||
) -> list[ifcopenshell.entity_instance]:
|
||||
|
||||
@@ -39,8 +39,7 @@ 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)*
|
||||
|
||||
@@ -111,11 +110,9 @@ filter_elements_grammar = lark.Lark(
|
||||
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
|
||||
@@ -130,11 +127,9 @@ get_element_grammar = lark.Lark(
|
||||
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
|
||||
@@ -193,8 +188,7 @@ format_grammar = lark.Lark(
|
||||
NEWLINE: (CR? LF)+
|
||||
|
||||
%ignore WS // Disregard spaces in text
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
class FormatTransformer(lark.Transformer):
|
||||
|
||||
@@ -912,6 +912,13 @@ def convert_file_length_units(ifc_file: ifcopenshell.file, target_units: str = "
|
||||
new_value = convert_value(val)
|
||||
setattr(element, attr.name(), new_value)
|
||||
|
||||
# IfcGeometricRepresentationContext.Precision is typed as a plain IfcReal
|
||||
# but is interpreted in the project length unit, so it must be scaled too.
|
||||
# Subcontexts derive Precision from their parent and cannot be set.
|
||||
for context in file_patched.by_type("IfcGeometricRepresentationContext", include_subtypes=False):
|
||||
if context.Precision is not None:
|
||||
context.Precision = convert_unit(context.Precision, old_length, new_length)
|
||||
|
||||
has_map_unit = False
|
||||
if (
|
||||
ifc_file.schema == "IFC2X3"
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# IfcOpenShell - IFC toolkit and geometry engine
|
||||
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
|
||||
#
|
||||
# This file is part of IfcOpenShell.
|
||||
#
|
||||
# IfcOpenShell is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 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
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import ifcopenshell.api.alignment
|
||||
import ifcopenshell.api.context
|
||||
import ifcopenshell.api.unit
|
||||
import ifcopenshell.util.element
|
||||
|
||||
|
||||
def test_add_positioning_referent():
|
||||
file = ifcopenshell.file(schema="IFC4X3")
|
||||
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
|
||||
length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT")
|
||||
ifcopenshell.api.unit.assign_unit(file, units=[length])
|
||||
geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model")
|
||||
axis_model_representation_subcontext = ifcopenshell.api.context.add_context(
|
||||
file,
|
||||
context_type="Model",
|
||||
context_identifier="Axis",
|
||||
target_view="MODEL_VIEW",
|
||||
parent=geometric_representation_context,
|
||||
)
|
||||
|
||||
alignment = ifcopenshell.api.alignment.create(file, "TestAlignment", start_station=2000.0)
|
||||
|
||||
horizontal_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
|
||||
segment = ifcopenshell.api.alignment.get_layout_segments(horizontal_layout)[0]
|
||||
|
||||
referent = ifcopenshell.api.alignment.add_positioning_referent(
|
||||
file, "P.C.", alignment, distance_along=0.0, station=2000.0, positioned_product=segment
|
||||
)
|
||||
|
||||
assert referent.is_a("IfcReferent")
|
||||
assert referent.PredefinedType == "POSITION"
|
||||
assert referent.Name == "P.C."
|
||||
assert ifcopenshell.util.element.get_pset(element=referent, name="Pset_Stationing")
|
||||
assert ifcopenshell.util.element.get_pset(element=referent, name="Pset_Stationing", prop="Station") == 2000.0
|
||||
assert referent.ObjectPlacement != None
|
||||
|
||||
assert len(referent.Positions) == 1
|
||||
rel_positions = referent.Positions[0]
|
||||
assert rel_positions.is_a("IfcRelPositions")
|
||||
assert rel_positions.RelatingPositioningElement == referent
|
||||
assert rel_positions.RelatedProducts == (segment,)
|
||||
|
||||
|
||||
def test_add_positioning_referent_creates_separate_referent_per_call():
|
||||
file = ifcopenshell.file(schema="IFC4X3")
|
||||
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
|
||||
length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT")
|
||||
ifcopenshell.api.unit.assign_unit(file, units=[length])
|
||||
geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model")
|
||||
axis_model_representation_subcontext = ifcopenshell.api.context.add_context(
|
||||
file,
|
||||
context_type="Model",
|
||||
context_identifier="Axis",
|
||||
target_view="MODEL_VIEW",
|
||||
parent=geometric_representation_context,
|
||||
)
|
||||
|
||||
alignment = ifcopenshell.api.alignment.create(file, "TestAlignment", start_station=2000.0)
|
||||
|
||||
horizontal_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
|
||||
segment = ifcopenshell.api.alignment.get_layout_segments(horizontal_layout)[0]
|
||||
|
||||
first_referent = ifcopenshell.api.alignment.add_positioning_referent(
|
||||
file, "P.C.", alignment, distance_along=0.0, station=2000.0, positioned_product=segment
|
||||
)
|
||||
|
||||
other_product = file.createIfcBuildingElementProxy(GlobalId=ifcopenshell.guid.new(), Name="Sign")
|
||||
second_referent = ifcopenshell.api.alignment.add_positioning_referent(
|
||||
file, "P.C.", alignment, distance_along=0.0, station=2000.0, positioned_product=other_product
|
||||
)
|
||||
|
||||
# each call creates its own IfcReferent, each with its own IfcRelPositions to the product passed in
|
||||
assert first_referent != second_referent
|
||||
assert len(first_referent.Positions) == 1
|
||||
assert first_referent.Positions[0].RelatedProducts == (segment,)
|
||||
assert len(second_referent.Positions) == 1
|
||||
assert second_referent.Positions[0].RelatedProducts == (other_product,)
|
||||
|
||||
|
||||
test_add_positioning_referent()
|
||||
test_add_positioning_referent_creates_separate_referent_per_call()
|
||||
@@ -0,0 +1,115 @@
|
||||
# IfcOpenShell - IFC toolkit and geometry engine
|
||||
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
|
||||
#
|
||||
# This file is part of IfcOpenShell.
|
||||
#
|
||||
# IfcOpenShell is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 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
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import ifcopenshell.api.alignment
|
||||
import ifcopenshell.api.context
|
||||
import ifcopenshell.api.unit
|
||||
import ifcopenshell.util.element
|
||||
|
||||
|
||||
def _create_test_file():
|
||||
file = ifcopenshell.file(schema="IFC4X3")
|
||||
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
|
||||
length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT")
|
||||
ifcopenshell.api.unit.assign_unit(file, units=[length])
|
||||
geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model")
|
||||
axis_model_representation_subcontext = ifcopenshell.api.context.add_context(
|
||||
file,
|
||||
context_type="Model",
|
||||
context_identifier="Axis",
|
||||
target_view="MODEL_VIEW",
|
||||
parent=geometric_representation_context,
|
||||
)
|
||||
return file
|
||||
|
||||
|
||||
def _create_test_alignment_with_vertical(file):
|
||||
# include_vertical=True so that get_curve() (IfcGradientCurve, on the "Axis" representation)
|
||||
# and get_basis_curve() (IfcCompositeCurve, on the "FootPrint" representation) are different
|
||||
# entities, letting the on_basis_curve option be observed.
|
||||
alignment = ifcopenshell.api.alignment.create(file, "TestAlignment", include_vertical=True, start_station=0.0)
|
||||
assert ifcopenshell.api.alignment.get_basis_curve(alignment).is_a("IfcCompositeCurve")
|
||||
assert ifcopenshell.api.alignment.get_curve(alignment).is_a("IfcGradientCurve")
|
||||
assert ifcopenshell.api.alignment.get_basis_curve(alignment) != ifcopenshell.api.alignment.get_curve(alignment)
|
||||
return alignment
|
||||
|
||||
|
||||
def _assert_common_referent_asserts(referent, name, station):
|
||||
assert referent.is_a("IfcReferent")
|
||||
assert referent.PredefinedType == "STATION"
|
||||
assert referent.Name == name
|
||||
assert ifcopenshell.util.element.get_pset(element=referent, name="Pset_Stationing")
|
||||
assert ifcopenshell.util.element.get_pset(element=referent, name="Pset_Stationing", prop="Station") == station
|
||||
assert referent.ObjectPlacement != None
|
||||
|
||||
|
||||
def test_add_stationing_referent_on_basis_curve_none_defaults_to_basis_curve():
|
||||
# on_basis_curve=None should behave the same as on_basis_curve=True
|
||||
file = _create_test_file()
|
||||
alignment = _create_test_alignment_with_vertical(file)
|
||||
|
||||
referent = ifcopenshell.api.alignment.add_stationing_referent(
|
||||
file, "1+00.000", alignment, distance_along=100.0, station=100.0, on_basis_curve=None
|
||||
)
|
||||
|
||||
_assert_common_referent_asserts(referent, "1+00.000", 100.0)
|
||||
|
||||
assert referent.ObjectPlacement.is_a("IfcLinearPlacement")
|
||||
assert referent.ObjectPlacement.RelativePlacement.Location.BasisCurve == ifcopenshell.api.alignment.get_basis_curve(
|
||||
alignment
|
||||
)
|
||||
|
||||
|
||||
def test_add_stationing_referent_on_basis_curve_true():
|
||||
file = _create_test_file()
|
||||
alignment = _create_test_alignment_with_vertical(file)
|
||||
|
||||
referent = ifcopenshell.api.alignment.add_stationing_referent(
|
||||
file, "1+00.000", alignment, distance_along=100.0, station=100.0, on_basis_curve=True
|
||||
)
|
||||
|
||||
_assert_common_referent_asserts(referent, "1+00.000", 100.0)
|
||||
|
||||
assert referent.ObjectPlacement.is_a("IfcLinearPlacement")
|
||||
assert referent.ObjectPlacement.RelativePlacement.Location.BasisCurve == ifcopenshell.api.alignment.get_basis_curve(
|
||||
alignment
|
||||
)
|
||||
|
||||
|
||||
def test_add_stationing_referent_on_basis_curve_false():
|
||||
# with a vertical layout present, on_basis_curve=False positions the referent on the
|
||||
# alignment curve (IfcGradientCurve) rather than on the basis curve (IfcCompositeCurve).
|
||||
file = _create_test_file()
|
||||
alignment = _create_test_alignment_with_vertical(file)
|
||||
|
||||
referent = ifcopenshell.api.alignment.add_stationing_referent(
|
||||
file, "1+00.000", alignment, distance_along=100.0, station=100.0, on_basis_curve=False
|
||||
)
|
||||
|
||||
_assert_common_referent_asserts(referent, "1+00.000", 100.0)
|
||||
|
||||
assert referent.ObjectPlacement.is_a("IfcLinearPlacement")
|
||||
basis_curve = referent.ObjectPlacement.RelativePlacement.Location.BasisCurve
|
||||
assert basis_curve == ifcopenshell.api.alignment.get_curve(alignment)
|
||||
assert basis_curve != ifcopenshell.api.alignment.get_basis_curve(alignment)
|
||||
|
||||
|
||||
test_add_stationing_referent_on_basis_curve_none_defaults_to_basis_curve()
|
||||
test_add_stationing_referent_on_basis_curve_true()
|
||||
test_add_stationing_referent_on_basis_curve_false()
|
||||
@@ -48,5 +48,26 @@ def test_add_stationing_to_alignment():
|
||||
assert ifcopenshell.util.element.get_pset(element=referent, name="Pset_Stationing", prop="Station") == 2000.0
|
||||
assert referent.ObjectPlacement != None
|
||||
|
||||
# add a station equation at 1000 distance along. this is station 3+000 in coming and 4+000 outgoing.
|
||||
# this is a gap equation.
|
||||
second_referent = ifcopenshell.api.alignment.add_stationing_referent(
|
||||
file, "4+000.000", alignment, distance_along=1000.0, station=4000.0, incoming_station=3000.0
|
||||
)
|
||||
|
||||
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
|
||||
assert len(referent_nest.RelatedObjects) == 2
|
||||
|
||||
assert second_referent == referent_nest.RelatedObjects[1]
|
||||
|
||||
assert second_referent.PredefinedType == "STATION"
|
||||
assert second_referent.Name == "4+000.000"
|
||||
assert ifcopenshell.util.element.get_pset(element=second_referent, name="Pset_Stationing")
|
||||
assert ifcopenshell.util.element.get_pset(element=second_referent, name="Pset_Stationing", prop="Station") == 4000.0
|
||||
assert (
|
||||
ifcopenshell.util.element.get_pset(element=second_referent, name="Pset_Stationing", prop="IncomingStation")
|
||||
== 3000.0
|
||||
)
|
||||
assert second_referent.ObjectPlacement != None
|
||||
|
||||
|
||||
test_add_stationing_to_alignment()
|
||||
|
||||
@@ -21,9 +21,12 @@ import math
|
||||
|
||||
import pytest
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.aggregate
|
||||
import ifcopenshell.api.alignment
|
||||
import ifcopenshell.api.context
|
||||
import ifcopenshell.api.spatial
|
||||
import ifcopenshell.api.unit
|
||||
import numpy as np
|
||||
import ifcopenshell.util.unit
|
||||
|
||||
|
||||
def test_create_representation():
|
||||
|
||||
@@ -53,4 +53,56 @@ def test_distance_along_from_station():
|
||||
assert ifcopenshell.api.alignment.distance_along_from_station(file, alignment, 17525.36) == pytest.approx(7525.36)
|
||||
|
||||
|
||||
def test_distance_along_from_station_with_station_equations():
|
||||
# Reproduces the worked example from the IFC Alignment Geometry Implementation Guide, chapter 9.2.6:
|
||||
# a gap equation (P3: incoming 14+00.00, outgoing 17+00.00) and an overlap equation
|
||||
# (P4: incoming 19+00.00, outgoing 18+50.00).
|
||||
file = ifcopenshell.file(schema="IFC4X3")
|
||||
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
|
||||
length = ifcopenshell.api.unit.add_conversion_based_unit(file, name="foot")
|
||||
ifcopenshell.api.unit.assign_unit(file, units=[length])
|
||||
geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model")
|
||||
axis_model_representation_subcontext = ifcopenshell.api.context.add_context(
|
||||
file,
|
||||
context_type="Model",
|
||||
context_identifier="Axis",
|
||||
target_view="MODEL_VIEW",
|
||||
parent=geometric_representation_context,
|
||||
)
|
||||
|
||||
coordinates = [(500.0, 2500.0), (3340.0, 660.0), (4340.0, 5000.0), (7600.0, 4560.0), (8480.0, 2010.0)]
|
||||
radii = [(1000.0), (1250.0), (950.0)]
|
||||
vpoints = [(0.0, 100.0), (2000.0, 135.0), (5000.0, 105.0), (7400.0, 153.0), (9800.0, 105.0), (12800.0, 90.0)]
|
||||
lengths = [(1600.0), (1200.0), (2000.0), (800.0)]
|
||||
|
||||
alignment = ifcopenshell.api.alignment.create_by_pi_method(
|
||||
file, "TestAlignment", coordinates, radii, vpoints, lengths, start_station=1000.0
|
||||
)
|
||||
|
||||
ifcopenshell.api.alignment.add_stationing_referent(
|
||||
file, "P3", alignment, distance_along=400.0, station=1700.0, incoming_station=1400.0
|
||||
)
|
||||
ifcopenshell.api.alignment.add_stationing_referent(
|
||||
file, "P4", alignment, distance_along=600.0, station=1850.0, incoming_station=1900.0
|
||||
)
|
||||
|
||||
distance_along_from_station = ifcopenshell.api.alignment.distance_along_from_station
|
||||
|
||||
# between P2 and P3: Sta. 13+00.00
|
||||
assert distance_along_from_station(file, alignment, 1300.0) == pytest.approx(300.0)
|
||||
|
||||
# between P3 and P4: Sta. 18+00.00
|
||||
assert distance_along_from_station(file, alignment, 1800.0) == pytest.approx(500.0)
|
||||
|
||||
# between P4 and P5: Sta. 19+25.00
|
||||
assert distance_along_from_station(file, alignment, 1925.0) == pytest.approx(675.0)
|
||||
|
||||
# Sta. 15+00.00 falls inside the gap opened by the equation at P3 and has no corresponding distance along
|
||||
assert distance_along_from_station(file, alignment, 1500.0) is None
|
||||
|
||||
# Sta. 18+75.00 falls inside the overlap zone at P4; the post-equation (outgoing) match is returned
|
||||
assert distance_along_from_station(file, alignment, 1875.0) == pytest.approx(625.0)
|
||||
|
||||
|
||||
test_distance_along_from_station()
|
||||
test_distance_along_from_station_with_station_equations()
|
||||
|
||||
@@ -64,3 +64,14 @@ class TestGetInfo2(test.bootstrap.IFC4):
|
||||
"Outer": {"CfsFaces": None, "type": "IfcClosedShell"},
|
||||
"type": "IfcFacetedBrep",
|
||||
}
|
||||
|
||||
def test_unsupported_arguments_fall_back_to_get_info(self):
|
||||
# Regression test for #4270: get_info_2 raised a bare AssertionError
|
||||
# when called with its own default arguments (recursive=False) or any
|
||||
# other combination the C++ fast path does not implement. It must
|
||||
# delegate to get_info instead of crashing.
|
||||
brep = self.file.create_entity("IfcFacetedBrep")
|
||||
shell = self.file.create_entity("IfcClosedShell")
|
||||
brep.Outer = shell
|
||||
assert brep.get_info_2() == brep.get_info()
|
||||
assert brep.get_info_2(recursive=True, ignore=("Outer",)) == brep.get_info(recursive=True, ignore=("Outer",))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import ifcopenshell
|
||||
|
||||
|
||||
def test_skip_over_non_entity_instance():
|
||||
data = """
|
||||
ISO-10303-21;
|
||||
|
||||
@@ -46,4 +46,4 @@ def test_file(filename):
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main(["-sx", __file__, '--import-mode=importlib'])
|
||||
pytest.main(["-sx", __file__, "--import-mode=importlib"])
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
from math import pi
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
import ifcopenshell.api.context
|
||||
import ifcopenshell.api.georeference
|
||||
@@ -258,6 +259,23 @@ class TestConvertFileLengthUnits(test.bootstrap.IFC2X3):
|
||||
assert max(i.id() for i in output) == len(output.wrapped_data.entity_names()) + 1
|
||||
assert subject.get_full_unit_name(subject.get_project_unit(output, "LENGTHUNIT")) == "METRE"
|
||||
|
||||
def test_precision_conversion(self):
|
||||
# Regression test for #6127: IfcGeometricRepresentationContext.Precision
|
||||
# is typed IfcReal but interpreted in the project length unit, so it must
|
||||
# be scaled along with the length measures.
|
||||
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
|
||||
unit = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix="MILLI")
|
||||
ifcopenshell.api.unit.assign_unit(self.file, units=[unit])
|
||||
context = ifcopenshell.api.context.add_context(self.file, context_type="Model")
|
||||
context.Precision = 0.01
|
||||
# Subcontexts derive Precision from the parent and must be left alone.
|
||||
ifcopenshell.api.context.add_context(
|
||||
self.file, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=context
|
||||
)
|
||||
output = subject.convert_file_length_units(self.file, target_units="METER")
|
||||
new_context = output.by_type("IfcGeometricRepresentationContext", include_subtypes=False)[0]
|
||||
assert new_context.Precision == pytest.approx(0.00001)
|
||||
|
||||
def test_attribute_conversion(self):
|
||||
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
|
||||
unit = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix="MILLI")
|
||||
|
||||
+25
-13
@@ -39,6 +39,7 @@
|
||||
#include <stdlib.h>
|
||||
#include <string>
|
||||
#include <iomanip>
|
||||
#include <charconv>
|
||||
|
||||
#ifdef USE_MMAP
|
||||
#include <boost/filesystem/path.hpp>
|
||||
@@ -449,7 +450,14 @@ const std::string& TokenFunc::asStringRef(const Token& token) {
|
||||
}
|
||||
std::string& str = token.lexer->GetTempString();
|
||||
token.lexer->TokenString(token.startPos, str);
|
||||
if ((isString(token) || isEnumeration(token) || isBinary(token)) && !str.empty()) {
|
||||
// A well-formed string/enumeration/binary token has both delimiters (e.g.
|
||||
// '...', .XXX., "...."), so at least two characters. Malformed input from a
|
||||
// fuzzer can produce a single-character token (e.g. a bare '.' left by
|
||||
// ".)" instead of ".PHYSICAL."); stripping both ends would then erase past
|
||||
// the end of an already-empty string, which is undefined behaviour and
|
||||
// aborts under hardened standard libraries (_GLIBCXX_ASSERTIONS). Require
|
||||
// two characters before stripping. See #5683.
|
||||
if ((isString(token) || isEnumeration(token) || isBinary(token)) && str.size() >= 2) {
|
||||
//remove start+end characters in-place
|
||||
str.erase(str.end() - 1);
|
||||
str.erase(str.begin());
|
||||
@@ -746,25 +754,29 @@ namespace {
|
||||
// the output of the C++ ostream formatting operation.
|
||||
// REAL = [ SIGN ] DIGIT { DIGIT } "." { DIGIT } [ "E" [ SIGN ] DIGIT { DIGIT } ] .
|
||||
static std::string format_double(const double& d) {
|
||||
std::ostringstream oss;
|
||||
oss.imbue(std::locale::classic());
|
||||
oss << std::setprecision(std::numeric_limits<double>::max_digits10) << d;
|
||||
const std::string str = oss.str();
|
||||
oss.str("");
|
||||
// Use the shortest representation that round-trips exactly (like
|
||||
// Python's repr) instead of max_digits10. max_digits10 padded clean
|
||||
// values with noise digits (0.0174532925199433 -> 0.017453292519943299),
|
||||
// which rewrote every REAL and produced huge diffs when a file was
|
||||
// re-saved. See #7696.
|
||||
// std::to_chars is locale-independent, so no ostringstream/imbue is
|
||||
// needed here.
|
||||
char buf[64];
|
||||
const auto res = std::to_chars(buf, buf + sizeof(buf), d);
|
||||
const std::string str(buf, res.ptr);
|
||||
std::string::size_type e = str.find('e');
|
||||
if (e == std::string::npos) {
|
||||
e = str.find('E');
|
||||
}
|
||||
const std::string mantissa = str.substr(0, e);
|
||||
oss << mantissa;
|
||||
if (mantissa.find('.') == std::string::npos) {
|
||||
oss << ".";
|
||||
std::string result = str.substr(0, e);
|
||||
if (result.find('.') == std::string::npos) {
|
||||
result += '.';
|
||||
}
|
||||
if (e != std::string::npos) {
|
||||
oss << "E";
|
||||
oss << str.substr(e + 1);
|
||||
result += 'E';
|
||||
result += str.substr(e + 1);
|
||||
}
|
||||
return oss.str();
|
||||
return result;
|
||||
}
|
||||
|
||||
static std::string format_binary(const boost::dynamic_bitset<>& b) {
|
||||
|
||||
@@ -187,6 +187,15 @@ void IfcUtil::sanitate_material_name(std::string& str) {
|
||||
}
|
||||
|
||||
void IfcUtil::escape_xml(std::string& str) {
|
||||
// Strip characters that are illegal in XML 1.0. Control characters other
|
||||
// than tab (0x09), newline (0x0A) and carriage return (0x0D) are not valid
|
||||
// XML 1.0 characters and cannot even be represented as numeric character
|
||||
// references, so they would otherwise make the serialized XML/SVG output
|
||||
// non-well-formed. Bytes belonging to a valid UTF-8 multibyte sequence are
|
||||
// always >= 0x80, so filtering on the low control range leaves them intact.
|
||||
str.erase(std::remove_if(str.begin(), str.end(), [](unsigned char c) {
|
||||
return c < 0x20 && c != '\t' && c != '\n' && c != '\r';
|
||||
}), str.end());
|
||||
boost::replace_all(str, "&", "&");
|
||||
boost::replace_all(str, "\"", """);
|
||||
boost::replace_all(str, "'", "'");
|
||||
@@ -353,6 +362,17 @@ IFC_PARSE_API bool IfcUtil::path::rename_file(const std::string& old_filename, c
|
||||
return success;
|
||||
}
|
||||
|
||||
IFC_PARSE_API bool IfcUtil::path::atomic_rename_file(const std::string& old_filename, const std::string& new_filename) {
|
||||
std::wstring old_filename_w = from_utf8(old_filename);
|
||||
std::wstring new_filename_w = from_utf8(new_filename);
|
||||
// MOVEFILE_REPLACE_EXISTING makes the replace atomic on NTFS (no unlink
|
||||
// of the destination first). MOVEFILE_WRITE_THROUGH waits until the move
|
||||
// is flushed to disk before returning.
|
||||
const bool success = !!MoveFileExW(old_filename_w.c_str(), new_filename_w.c_str(),
|
||||
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH);
|
||||
return success;
|
||||
}
|
||||
|
||||
IFC_PARSE_API bool IfcUtil::path::delete_file(const std::string& filename) {
|
||||
std::wstring filename_w = from_utf8(filename);
|
||||
const bool success = !!DeleteFileW(filename_w.c_str());
|
||||
@@ -368,6 +388,12 @@ IFC_PARSE_API bool IfcUtil::path::rename_file(const std::string& old_filename, c
|
||||
return std::rename(old_filename.c_str(), new_filename.c_str()) == 0;
|
||||
}
|
||||
|
||||
IFC_PARSE_API bool IfcUtil::path::atomic_rename_file(const std::string& old_filename, const std::string& new_filename) {
|
||||
// POSIX rename() atomically replaces an existing destination on the same
|
||||
// filesystem, so there is no window in which new_filename is missing.
|
||||
return std::rename(old_filename.c_str(), new_filename.c_str()) == 0;
|
||||
}
|
||||
|
||||
IFC_PARSE_API bool IfcUtil::path::delete_file(const std::string& filename) {
|
||||
return std::remove(filename.c_str()) != 0;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,10 @@
|
||||
|
||||
#if defined(IFCOPENSHELL_BRANCH) && defined(IFCOPENSHELL_COMMIT)
|
||||
const char *IFCOPENSHELL_VERSION = STRINGIFY(IFCOPENSHELL_BRANCH) "-" STRINGIFY(IFCOPENSHELL_COMMIT);
|
||||
#elif defined(IFCOPENSHELL_VERSION_STRING)
|
||||
// Set from CMake's RELEASE_VERSION (the repository VERSION file) so a release
|
||||
// build without commit-sha info still reports the correct version. See #8164.
|
||||
const char *IFCOPENSHELL_VERSION = STRINGIFY(IFCOPENSHELL_VERSION_STRING);
|
||||
#else
|
||||
const char *IFCOPENSHELL_VERSION = "0.8.0";
|
||||
#endif
|
||||
|
||||
@@ -37,6 +37,13 @@ namespace path {
|
||||
IFC_PARSE_API bool delete_file(const std::string& filename);
|
||||
IFC_PARSE_API bool rename_file(const std::string& old_filename, const std::string& new_filename);
|
||||
|
||||
/// Atomically renames old_filename onto new_filename, replacing an existing
|
||||
/// destination in a single filesystem operation. Unlike rename_file(), the
|
||||
/// destination is never unlinked before the rename, so an interruption can
|
||||
/// never leave the destination missing. This requires both paths to live on
|
||||
/// the same filesystem. Returns true on success.
|
||||
IFC_PARSE_API bool atomic_rename_file(const std::string& old_filename, const std::string& new_filename);
|
||||
|
||||
#if defined(_MSC_VER) && defined(_UNICODE)
|
||||
|
||||
/// Uses windows.h string conversion functions
|
||||
|
||||
@@ -110,8 +110,8 @@ class Patcher(ifcpatch.BasePatcher):
|
||||
pass
|
||||
if element.is_a("IfcProject"):
|
||||
proj = self.new.add(element)
|
||||
for ctx in element.RepresentationContexts:
|
||||
for coop in getattr(ctx, 'HasCoordinateOperation', ()):
|
||||
for ctx in element.RepresentationContexts or ():
|
||||
for coop in getattr(ctx, "HasCoordinateOperation", ()):
|
||||
self.new.add(coop)
|
||||
return proj
|
||||
return ifcopenshell.api.project.append_asset(
|
||||
|
||||
@@ -33,9 +33,7 @@ class TestDowngradeIndexedPolyCurve(test.bootstrap.IFC4):
|
||||
Points=point_list,
|
||||
Segments=segments,
|
||||
)
|
||||
self.file.create_entity(
|
||||
"IfcArbitraryClosedProfileDef", ProfileType="AREA", OuterCurve=curve
|
||||
)
|
||||
self.file.create_entity("IfcArbitraryClosedProfileDef", ProfileType="AREA", OuterCurve=curve)
|
||||
return curve
|
||||
|
||||
def test_run_without_segments(self):
|
||||
@@ -80,9 +78,7 @@ class TestDowngradeIndexedPolyCurve(test.bootstrap.IFC4):
|
||||
Points=point_list,
|
||||
Segments=[self.file.createIfcLineIndex((1, 2, 3, 4, 1))],
|
||||
)
|
||||
self.file.create_entity(
|
||||
"IfcArbitraryClosedProfileDef", ProfileType="AREA", OuterCurve=curve
|
||||
)
|
||||
self.file.create_entity("IfcArbitraryClosedProfileDef", ProfileType="AREA", OuterCurve=curve)
|
||||
ifcpatch.execute(
|
||||
{"input": "input.ifc", "file": self.file, "recipe": "DowngradeIndexedPolyCurve", "arguments": []}
|
||||
)
|
||||
@@ -110,9 +106,7 @@ class TestDowngradeIndexedPolyCurve(test.bootstrap.IFC4):
|
||||
self.file.createIfcLineIndex((3, 4)),
|
||||
],
|
||||
)
|
||||
self.file.create_entity(
|
||||
"IfcArbitraryClosedProfileDef", ProfileType="AREA", OuterCurve=curve
|
||||
)
|
||||
self.file.create_entity("IfcArbitraryClosedProfileDef", ProfileType="AREA", OuterCurve=curve)
|
||||
ifcpatch.execute(
|
||||
{"input": "input.ifc", "file": self.file, "recipe": "DowngradeIndexedPolyCurve", "arguments": []}
|
||||
)
|
||||
|
||||
@@ -21,10 +21,12 @@ import os
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.aggregate
|
||||
import ifcopenshell.api.context
|
||||
import ifcopenshell.api.geometry
|
||||
import ifcopenshell.api.georeference
|
||||
import ifcopenshell.api.root
|
||||
import ifcopenshell.api.spatial
|
||||
import ifcopenshell.util.element
|
||||
import numpy
|
||||
import pytest
|
||||
|
||||
import ifcpatch
|
||||
@@ -96,7 +98,10 @@ class TestExtractElements(test.bootstrap.IFC4):
|
||||
self.file,
|
||||
coordinate_operation={"Eastings": 100000.0, "Northings": 200000.0},
|
||||
)
|
||||
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
matrix = numpy.eye(4)
|
||||
matrix[:3, 3] = [5.0, 10.0, 2.0]
|
||||
ifcopenshell.api.geometry.edit_object_placement(self.file, product=wall, matrix=matrix)
|
||||
|
||||
output = ifcpatch.execute({"file": self.file, "recipe": "ExtractElements", "arguments": ["IfcWall"]})
|
||||
|
||||
@@ -105,6 +110,11 @@ class TestExtractElements(test.bootstrap.IFC4):
|
||||
conversion = output.by_type("IfcMapConversion")[0]
|
||||
assert conversion.Eastings == 100000.0
|
||||
assert conversion.Northings == 200000.0
|
||||
# Placements must be copied verbatim: extraction must not bake map
|
||||
# coordinates (or any other georeferencing transform) into the local
|
||||
# placements of the extracted elements.
|
||||
wall_new = output.by_type("IfcWall")[0]
|
||||
assert wall_new.ObjectPlacement.RelativePlacement.Location.Coordinates == (5.0, 10.0, 2.0)
|
||||
|
||||
@pytest.mark.skipif(
|
||||
"IFC4X3" not in ifcopenshell.ifcopenshell_wrapper.schema_names(),
|
||||
|
||||
@@ -977,12 +977,14 @@ struct ShapeRTTI : public boost::static_visitor<PyObject*>
|
||||
if (item == nullptr) {
|
||||
throw IfcParse::IfcException("Failed to convert placement");
|
||||
}
|
||||
/*
|
||||
if (st.get<ifcopenshell::geometry::settings::ConvertBackUnits>().get()) {
|
||||
// we pass the settings to the Transformation object, but access the data just offloads to the
|
||||
// generic cartesian_base<Matrix4> so there's no time to apply the settings to the translation part.
|
||||
item = ifcopenshell::geometry::taxonomy::matrix4::ptr(item->clone_());
|
||||
item->components().col(3).head<3>() /= kernel.settings().get<ifcopenshell::geometry::settings::LengthUnit>().get();
|
||||
}
|
||||
*/
|
||||
return new IfcGeom::Transformation(kernel.settings(), item);
|
||||
} else {
|
||||
if (!representation) {
|
||||
|
||||
@@ -117,10 +117,51 @@ PyObject* get_feature(const std::string& x) {
|
||||
|
||||
%{
|
||||
|
||||
#include <fstream>
|
||||
#include <random>
|
||||
|
||||
static const std::string& helper_fn_declaration_get_name(const IfcParse::declaration* decl) {
|
||||
return decl->name();
|
||||
}
|
||||
|
||||
// Atomic IFC/STEP write (issue #4797): serialize to a temporary file next to
|
||||
// the destination, then atomically rename it onto the destination. If the
|
||||
// process is interrupted mid-write, the destination is never truncated or
|
||||
// left with dangling STEP references; at most a stray temp file remains, which
|
||||
// the caller can safely ignore. Keeping the temp in the same directory means
|
||||
// the rename stays on a single filesystem and is therefore atomic. The temp
|
||||
// path never leaks into the FILE_NAME header, which is derived from the model
|
||||
// header, not the output path.
|
||||
template <typename T>
|
||||
static void helper_fn_atomic_write(T& file_obj, const std::string& fn) {
|
||||
std::random_device rd;
|
||||
const std::string temp_fn = fn + "." + std::to_string(rd()) + ".tmp";
|
||||
{
|
||||
// Same open mode as a plain write so the bytes are identical.
|
||||
std::ofstream f(IfcUtil::path::from_utf8(temp_fn).c_str());
|
||||
if (!f.good()) {
|
||||
// The temp file could not be created (e.g. directory not
|
||||
// writable). Nothing was touched; report as a normal write error.
|
||||
throw std::runtime_error("Failed to write to path: '" + fn + "', check folder and file permissions.");
|
||||
}
|
||||
f << file_obj;
|
||||
f.flush();
|
||||
if (!f.good()) {
|
||||
// Serialization failed (e.g. disk full). Clean up the partial temp
|
||||
// and abort. The existing destination is left intact.
|
||||
f.close();
|
||||
IfcUtil::path::delete_file(temp_fn);
|
||||
throw std::runtime_error("Failed to write to path: '" + fn + "', the file may be incomplete.");
|
||||
}
|
||||
// The ofstream destructor at the end of this scope closes the stream.
|
||||
// On Windows the file must be closed before it can be renamed.
|
||||
}
|
||||
if (!IfcUtil::path::atomic_rename_file(temp_fn, fn)) {
|
||||
IfcUtil::path::delete_file(temp_fn);
|
||||
throw std::runtime_error("Failed to write to path: '" + fn + "', could not replace the existing file.");
|
||||
}
|
||||
}
|
||||
|
||||
static IfcUtil::ArgumentType helper_fn_attribute_type(const IfcUtil::IfcBaseClass* inst, unsigned i) {
|
||||
const IfcParse::parameter_type* pt = 0;
|
||||
if (inst->declaration().as_entity()) {
|
||||
@@ -219,11 +260,10 @@ private:
|
||||
}
|
||||
|
||||
void write(const std::string& fn) {
|
||||
std::ofstream f(IfcUtil::path::from_utf8(fn).c_str());
|
||||
if (!f.good()) {
|
||||
throw std::runtime_error("Failed to write to path: '" + fn + "', check folder and file permissions.");
|
||||
}
|
||||
f << (*$self);
|
||||
// Atomic write: serialize to a temp file next to the target, then
|
||||
// atomically rename it into place, so an interrupted write can never
|
||||
// corrupt the destination (issue #4797).
|
||||
helper_fn_atomic_write(*$self, fn);
|
||||
}
|
||||
|
||||
std::string to_string() {
|
||||
|
||||
@@ -34,9 +34,19 @@
|
||||
if (PySequence_Size(aggregate) == -1) return false;
|
||||
for(Py_ssize_t i = 0; i < PySequence_Size(aggregate); ++i) {
|
||||
PyObject* element = PySequence_GetItem(aggregate, i);
|
||||
// This is equivalent to the PyFloat_CheckExact macro. This means
|
||||
// that direct instances of int, float, str, etc. need to be used.
|
||||
bool b = element->ob_type == type_obj;
|
||||
// Accept the exact type or, for the numeric types, a subclass such
|
||||
// as a numpy scalar (numpy.float64 subclasses float), so that numpy
|
||||
// arrays can be assigned. The REAL vs INTEGER distinction is kept: a
|
||||
// float is not accepted where an int is expected and vice versa, and
|
||||
// bool (a subclass of int) is still rejected for INTEGER. See #5873.
|
||||
bool b;
|
||||
if (type_obj == static_cast<void*>(&PyFloat_Type)) {
|
||||
b = PyFloat_Check(element);
|
||||
} else if (type_obj == static_cast<void*>(&PyLong_Type)) {
|
||||
b = PyLong_Check(element) && !PyBool_Check(element);
|
||||
} else {
|
||||
b = element->ob_type == type_obj;
|
||||
}
|
||||
Py_DECREF(element);
|
||||
if (!b) {
|
||||
return false;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user