mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-08 17:01:40 +00:00
Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d39a21bf4 | |||
| 671217d494 | |||
| dcebf23af8 | |||
| 4d22a3fdb9 | |||
| 3136c74c2f | |||
| 347a3c80bb | |||
| 0c993d3292 | |||
| 32a601d057 | |||
| a7738eeb64 | |||
| a751fb956d | |||
| 251157f8d4 | |||
| a213a9b848 | |||
| b76bc1c1f8 | |||
| 4d24cef0c9 | |||
| 6c9cfccc43 | |||
| 0a0a5b9f04 | |||
| 3a0abbab95 | |||
| 0d703039a6 | |||
| f33df52c1b | |||
| 15a6375ea3 | |||
| 82465a64a5 | |||
| 6bde619fe6 | |||
| ba5321fdfa | |||
| 49ddc97918 | |||
| 39bcd9db63 | |||
| ab11ac5338 | |||
| 9346f45bba | |||
| b22687891b | |||
| 784f0b1fe2 | |||
| e0ceda6856 | |||
| 17951427fe | |||
| 734f4df84e | |||
| bfa2d789f1 | |||
| 192bf00d31 | |||
| ad672d0edb | |||
| 0df1f0cf49 | |||
| d7dd8ecf57 | |||
| becbcfdfe7 | |||
| 5b79cefee2 | |||
| 3346a59284 | |||
| 0bf8e9283f | |||
| db7591a867 | |||
| 7b9af9f533 | |||
| 704a2d36be | |||
| 516696cd73 |
@@ -271,7 +271,6 @@ def register():
|
||||
parametric_lifecycle.install_parametric_lifecycle_handlers()
|
||||
bpy.app.handlers.load_post.append(handler.load_post)
|
||||
bpy.app.handlers.load_post.append(handler.loadIfcStore)
|
||||
bpy.app.handlers.save_post.append(handler.save_post)
|
||||
bpy.types.Scene.BIMProperties = bpy.props.PointerProperty(type=prop.BIMProperties)
|
||||
bpy.types.Scene.BIMSnapProperties = bpy.props.PointerProperty(type=prop.BIMSnapProperties)
|
||||
bpy.types.Scene.BIMSnapGroups = bpy.props.PointerProperty(type=prop.BIMSnapGroups)
|
||||
@@ -330,7 +329,6 @@ def unregister():
|
||||
parametric_lifecycle.uninstall_parametric_lifecycle_handlers()
|
||||
bpy.app.handlers.load_post.remove(handler.load_post)
|
||||
bpy.app.handlers.load_post.remove(handler.loadIfcStore)
|
||||
bpy.app.handlers.save_post.remove(handler.save_post)
|
||||
del bpy.types.Scene.BIMProperties
|
||||
del bpy.types.Collection.BIMCollectionProperties
|
||||
del bpy.types.Object.BIMObjectProperties
|
||||
|
||||
@@ -47,7 +47,10 @@ from bonsai.bim.module.model.array import (
|
||||
)
|
||||
from bonsai.bim.module.model.data import AuthoringData
|
||||
from bonsai.bim.module.model.decorator import (
|
||||
BendPreviewDecorator,
|
||||
BoundingBoxDecorator,
|
||||
DoorSwingReadonlyDecorator,
|
||||
MEPSegmentExtendPreviewDecorator,
|
||||
SlabDirectionDecorator,
|
||||
WallAxisDecorator,
|
||||
WallFilletPreviewDecorator,
|
||||
@@ -432,29 +435,6 @@ def subscribe_to_viewport_shading_changes():
|
||||
)
|
||||
|
||||
|
||||
@persistent
|
||||
def save_post(scene) -> None:
|
||||
"""After saving the .blend file, convert the stored IFC path to relative if enabled."""
|
||||
pprops = tool.Project.get_project_props()
|
||||
if not pprops.use_relative_project_path:
|
||||
return
|
||||
bim_props = tool.Blender.get_bim_props()
|
||||
ifc_path = bim_props.ifc_file
|
||||
if not ifc_path or not os.path.isabs(ifc_path):
|
||||
return
|
||||
blend_dir = bpy.path.abspath("//")
|
||||
if not blend_dir:
|
||||
return
|
||||
from pathlib import Path
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
try:
|
||||
rel_path = str(Path(ifc_path).relative_to(blend_dir))
|
||||
except ValueError:
|
||||
return # IFC file is not under the blend directory; keep absolute path
|
||||
bim_props.ifc_file = rel_path
|
||||
IfcStore.set_path(ifc_path) # keep IfcStore.path absolute for loading
|
||||
|
||||
|
||||
def _apply_save_file_invariants(scene: bpy.types.Scene) -> None:
|
||||
"""Invariants enforced on every load_post: msgbus subscription, IFC owner
|
||||
settings, scene-bound caches, load-transient parametric state, and the
|
||||
@@ -534,7 +514,10 @@ def _install_viewport_overlays() -> None:
|
||||
WallAxisDecorator.uninstall()
|
||||
SlabDirectionDecorator.uninstall()
|
||||
WallFilletPreviewDecorator.uninstall()
|
||||
BendPreviewDecorator.uninstall()
|
||||
MEPSegmentExtendPreviewDecorator.uninstall()
|
||||
WallGizmoPreviewDecorator.uninstall()
|
||||
DoorSwingReadonlyDecorator.uninstall()
|
||||
ArrayPreviewDecorator.uninstall()
|
||||
ArraySelectionHighlightDecorator.uninstall()
|
||||
uninstall_decorator_cache_handlers()
|
||||
@@ -555,10 +538,19 @@ def _install_viewport_overlays() -> None:
|
||||
# wall_fillet.is_active, so installation has no cost when no preview
|
||||
# is open. No corresponding addon-preference toggle.
|
||||
WallFilletPreviewDecorator.install(bpy.context)
|
||||
# Always-installed siblings of WallFilletPreviewDecorator: each
|
||||
# self-polls on its own scene.BIMPreviewProperties subgroup or on
|
||||
# selection + hover gizmo state — zero cost when nothing is active.
|
||||
BendPreviewDecorator.install(bpy.context)
|
||||
MEPSegmentExtendPreviewDecorator.install(bpy.context)
|
||||
# Always-installed: draw_lines() self-polls on selection + hover state
|
||||
# for join / extend-to-wall / cursor-extend / cursor-split previews.
|
||||
# Free when no preview-eligible state is active.
|
||||
WallGizmoPreviewDecorator.install(bpy.context)
|
||||
# Always-installed: draw() self-polls on active object + IfcDoor +
|
||||
# parametric pset, so the cost is one bpy/IFC lookup per redraw when
|
||||
# nothing eligible is selected.
|
||||
DoorSwingReadonlyDecorator.install(bpy.context)
|
||||
# Always-installed: draw() self-polls on the active object's array
|
||||
# family membership, so installation has no cost when no array
|
||||
# element is selected.
|
||||
|
||||
@@ -566,6 +566,19 @@ class IfcStore:
|
||||
result = getattr(operator, "_modal")(context, event)
|
||||
except:
|
||||
bonsai.last_error = traceback.format_exc()
|
||||
# An operator that mutated IFC then raised leaves the IFC graph captured
|
||||
# by the transaction but the Blender side stale. Blender does not push an
|
||||
# undo step for a raised operator (mirror of the CANCELLED-modal gap
|
||||
# handled below), so we push one here so Ctrl+Z actually rewinds the
|
||||
# partial mutation, then surface the recovery path to the user.
|
||||
ifc_file = tool.Ifc.get()
|
||||
if ifc_file and ifc_file.transaction and ifc_file.transaction.operations:
|
||||
bpy.ops.ed.undo_push(message=f"Recover {operator.bl_idname}")
|
||||
operator.report(
|
||||
{"WARNING"},
|
||||
"Operation partially completed (IFC changed, Blender state may be stale). "
|
||||
"Press Ctrl+Z to restore the previous state.",
|
||||
)
|
||||
# Try to ensure undo will work since Blender undo does work in case of errors.
|
||||
# As error come unexpectedly, it's important that user might have a chance to save the file
|
||||
# before they got the error and not to lose the work they've done.
|
||||
|
||||
@@ -345,9 +345,17 @@ class CadArcFrom3Points(bpy.types.Operator):
|
||||
class CadOffset(bpy.types.Operator):
|
||||
bl_idname = "bim.cad_offset"
|
||||
bl_label = "CAD Offset"
|
||||
bl_description = "Copy selected mesh geometry at provided offset. Mesh copied based on the current viewport angle."
|
||||
bl_description = (
|
||||
"Offset selected mesh geometry at provided distance, based on the current viewport angle. "
|
||||
"Creates a copy by default, or moves the existing edges if Copy is disabled."
|
||||
)
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
distance: bpy.props.FloatProperty(name="Distance", default=0.1, subtype="DISTANCE")
|
||||
copy: bpy.props.BoolProperty(
|
||||
name="Copy",
|
||||
description="Create a new offset copy of the geometry. If disabled, move the existing edges to the offset location",
|
||||
default=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
@@ -405,6 +413,11 @@ class CadOffset(bpy.types.Operator):
|
||||
rotation = Matrix.Rotation(pi / 2, 2, "Z")
|
||||
rotation_i = Matrix.Rotation(-pi / 2, 2, "Z")
|
||||
|
||||
# When not copying, the offset positions are gathered here and applied to
|
||||
# the existing verts only after all loops are processed, so that the
|
||||
# original coordinates are still available while computing offsets.
|
||||
moved_verts = []
|
||||
|
||||
# Create loops from edges
|
||||
loop_edges = set(edges)
|
||||
loops = []
|
||||
@@ -517,12 +530,15 @@ class CadOffset(bpy.types.Operator):
|
||||
offset_length = self.distance / sqrt((1 + normals[0].dot(normals[1])) / 2)
|
||||
offset = mw.inverted().to_quaternion() @ (wp.to_quaternion() @ (new_normal * offset_length).to_3d())
|
||||
new_vert = v1.co + offset
|
||||
new_verts.append(bm.verts.new(new_vert))
|
||||
else:
|
||||
normal = (normals[0] * self.distance).to_3d()
|
||||
offset = mw.inverted().to_quaternion() @ (wp.to_quaternion() @ normal)
|
||||
new_vert = v1.co + offset
|
||||
|
||||
if self.copy:
|
||||
new_verts.append(bm.verts.new(new_vert))
|
||||
else:
|
||||
moved_verts.append((v1, new_vert))
|
||||
|
||||
processed_verts.add(v1.index)
|
||||
|
||||
@@ -531,9 +547,14 @@ class CadOffset(bpy.types.Operator):
|
||||
|
||||
v1 = v2
|
||||
|
||||
[bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(new_verts) - 1)]
|
||||
if is_closed:
|
||||
bm.edges.new((new_verts[len(new_verts) - 1], new_verts[0]))
|
||||
if self.copy:
|
||||
[bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(new_verts) - 1)]
|
||||
if is_closed:
|
||||
bm.edges.new((new_verts[len(new_verts) - 1], new_verts[0]))
|
||||
|
||||
# Move the existing edges to the offset location.
|
||||
for vert, new_co in moved_verts:
|
||||
vert.co = new_co
|
||||
|
||||
bm.verts.index_update()
|
||||
bm.edges.index_update()
|
||||
|
||||
@@ -27,6 +27,11 @@ class BIMCadProperties(PropertyGroup):
|
||||
resolution: bpy.props.IntProperty(name="Arc Resolution", min=1, default=1)
|
||||
radius: bpy.props.FloatProperty(name="Radius", default=0.1, subtype="DISTANCE")
|
||||
distance: bpy.props.FloatProperty(name="Distance", default=0.1, subtype="DISTANCE")
|
||||
copy: bpy.props.BoolProperty(
|
||||
name="Copy",
|
||||
description="Create a new offset copy of the geometry. If disabled, move the existing edges to the offset location",
|
||||
default=True,
|
||||
)
|
||||
x: bpy.props.FloatProperty(name="X", default=0.2, subtype="DISTANCE")
|
||||
y: bpy.props.FloatProperty(name="Y", default=0.1, subtype="DISTANCE")
|
||||
gable_roof_edge_angle: bpy.props.FloatProperty(
|
||||
@@ -37,6 +42,7 @@ class BIMCadProperties(PropertyGroup):
|
||||
resolution: int
|
||||
radius: float
|
||||
distance: float
|
||||
copy: bool
|
||||
x: float
|
||||
y: float
|
||||
gable_roof_edge_angle: float
|
||||
|
||||
@@ -256,6 +256,8 @@ class CadHotkey(bpy.types.Operator):
|
||||
elif self.hotkey == "S_O":
|
||||
row = self.layout.row()
|
||||
row.prop(props, "distance")
|
||||
row = self.layout.row()
|
||||
row.prop(props, "copy")
|
||||
|
||||
elif self.hotkey == "S_R":
|
||||
if tool.Geometry.is_profile_object_active():
|
||||
@@ -291,7 +293,7 @@ class CadHotkey(bpy.types.Operator):
|
||||
bpy.ops.bim.cad_fillet(resolution=self.props.resolution, radius=self.props.radius)
|
||||
|
||||
def hotkey_S_O(self):
|
||||
bpy.ops.bim.cad_offset(distance=self.props.distance)
|
||||
bpy.ops.bim.cad_offset(distance=self.props.distance, copy=self.props.copy)
|
||||
|
||||
def hotkey_S_Q(self):
|
||||
obj = bpy.context.active_object
|
||||
|
||||
@@ -159,6 +159,65 @@ _SPECIAL = {"=", " "} # Formula prefix, spaces
|
||||
NUMERIC_INPUT_CHARS = _DIGITS | _OPERATORS | _METRIC_UNITS | _IMPERIAL_UNITS | _SPECIAL
|
||||
|
||||
|
||||
_BONSAI_TRANSFORM_MACROS = frozenset(
|
||||
{
|
||||
# Bonsai overrides Blender's default move/duplicate keymaps with
|
||||
# macros that wrap TRANSFORM_OT_translate. While a macro is the outer
|
||||
# modal entry, the inner TRANSFORM_OT_translate does not surface in
|
||||
# window.modal_operators — the macro's own idname does. The
|
||||
# ``BIM_OT_`` prefix is what Blender returns from ``bl_idname`` at
|
||||
# runtime (the class declaration uses the dotted ``bim.`` form).
|
||||
"BIM_OT_override_move_macro", # G key
|
||||
"BIM_OT_override_object_duplicate_move_macro", # Shift+D
|
||||
"BIM_OT_override_object_duplicate_move_linked_macro", # Alt+D
|
||||
"BIM_OT_object_duplicate_move_linked_aggregate_macro", # Ctrl+Shift+D
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _is_transform_modal_active(context) -> bool:
|
||||
"""True iff a Blender transform modal (G/R/S and siblings, including
|
||||
Bonsai's macro overrides) is currently driving per-frame ``matrix_world``
|
||||
updates. Reads ``window.modal_operators`` — the Blender 4.2+ collection of
|
||||
running modal operators. Parametric gizmo groups gate poll + draw_prepare
|
||||
on this so they hide for the duration of the drag instead of sliding
|
||||
off-cursor as the matrix updates each frame."""
|
||||
window = getattr(context, "window", None)
|
||||
if window is None:
|
||||
return False
|
||||
modal_ops = getattr(window, "modal_operators", None)
|
||||
if not modal_ops:
|
||||
return False
|
||||
for op in modal_ops:
|
||||
idname = op.bl_idname
|
||||
if idname.startswith("TRANSFORM_OT_") or idname in _BONSAI_TRANSFORM_MACROS:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _hide_all_non_modal_gizmos(group) -> None:
|
||||
"""Set ``hide = True`` on every gizmo in ``group`` whose own ``is_modal``
|
||||
is False. Used by parametric ``draw_prepare`` to suppress visible
|
||||
re-positioning while a transform modal is dragging ``matrix_world``."""
|
||||
for gz in group.gizmos:
|
||||
if not getattr(gz, "is_modal", False):
|
||||
gz.hide = True
|
||||
|
||||
|
||||
def apply_transform_modal_draw_gate(group, context) -> bool:
|
||||
"""Combined gate for ``draw_prepare`` overrides: hide non-modal gizmos and
|
||||
return ``True`` when a Blender transform modal is dragging matrix_world.
|
||||
|
||||
Returns ``False`` when no transform modal is active so callers can fall
|
||||
through to their normal positioning logic. ``True`` means the caller must
|
||||
early-return without touching matrix_basis — the hidden gizmos will be
|
||||
re-shown on the next idle frame once the modal exits."""
|
||||
if not _is_transform_modal_active(context):
|
||||
return False
|
||||
_hide_all_non_modal_gizmos(group)
|
||||
return True
|
||||
|
||||
|
||||
class GizmoColor(Enum):
|
||||
"""Color identifiers for dimension gizmos.
|
||||
|
||||
@@ -1694,6 +1753,33 @@ def billboarded_at(world_pos: Vector, billboard_rot: Matrix, scale: float = DEFA
|
||||
return Matrix.Translation(world_pos) @ billboard_rot @ Matrix.Scale(scale, 4)
|
||||
|
||||
|
||||
def billboarded_along_axis(
|
||||
world_pos: Vector,
|
||||
billboard_rot: Matrix,
|
||||
axis_world: Vector,
|
||||
scale: float = DEFAULT_BILLBOARD_SCALE,
|
||||
) -> Matrix:
|
||||
"""Composed matrix_basis like ``billboarded_at`` but with local +X
|
||||
rotated about the camera-forward axis to align with ``axis_world``
|
||||
projected onto the screen plane.
|
||||
|
||||
The gizmo still faces the camera (local +Z stays along camera-forward),
|
||||
only its in-plane orientation changes. Falls back to plain
|
||||
``billboarded_at`` when the axis is near-parallel to the view direction
|
||||
(no usable screen projection)."""
|
||||
camera_forward = billboard_rot @ Vector((0.0, 0.0, 1.0))
|
||||
projected = axis_world - camera_forward * axis_world.dot(camera_forward)
|
||||
if projected.length < 1e-4:
|
||||
return billboarded_at(world_pos, billboard_rot, scale)
|
||||
projected.normalize()
|
||||
y_axis = camera_forward.cross(projected).normalized()
|
||||
rot = Matrix.Identity(4)
|
||||
rot[0][:3] = (projected.x, y_axis.x, camera_forward.x)
|
||||
rot[1][:3] = (projected.y, y_axis.y, camera_forward.y)
|
||||
rot[2][:3] = (projected.z, y_axis.z, camera_forward.z)
|
||||
return Matrix.Translation(world_pos) @ rot @ Matrix.Scale(scale, 4)
|
||||
|
||||
|
||||
def get_screen_up(billboard_rot: Matrix) -> Vector:
|
||||
"""Camera's screen-up direction in world space — local +Y of the billboard
|
||||
rotation. Use to lift a gizmo above an anchor in a way that stays
|
||||
@@ -3255,11 +3341,16 @@ class GizmoArc(StaticTrisGizmoMixin, bpy.types.Gizmo):
|
||||
"""Static quarter-arc glyph for swing visualisation.
|
||||
|
||||
Consumers needing the mirrored (RIGHT) visual apply a flip-X matrix to
|
||||
``matrix_basis``."""
|
||||
``matrix_basis``. ``outline_alpha = 0.0`` suppresses the inherited 8-pass
|
||||
dark halo: an open curve has no enclosed silhouette for the dilation to
|
||||
ring, so the offset passes read as ghost arcs rather than a uniform
|
||||
outline. The arc's own cross-section thickness keeps it legible without
|
||||
the halo."""
|
||||
|
||||
bl_idname = "VIEW3D_GT_arc"
|
||||
__slots__ = ("custom_shape",)
|
||||
tris = ARC_TRIS_DEFAULT
|
||||
outline_alpha = 0.0
|
||||
|
||||
|
||||
def _link_toggle_icon_tris(broken: bool) -> tuple[tuple[float, float, float], ...]:
|
||||
@@ -4998,6 +5089,8 @@ class BillboardingGizmoGroupMixin:
|
||||
self.position_gizmos(context)
|
||||
|
||||
def draw_prepare(self, context: bpy.types.Context) -> None:
|
||||
if apply_transform_modal_draw_gate(self, context):
|
||||
return
|
||||
self.position_gizmos(context)
|
||||
|
||||
def setup_icon_gizmo(
|
||||
@@ -5652,6 +5745,8 @@ class BaseParametricGizmoGroup:
|
||||
|
||||
if preview_base.any_preview_active(context):
|
||||
return False
|
||||
if _is_transform_modal_active(context):
|
||||
return False
|
||||
if cls.gizmo_pref_name:
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
if not getattr(prefs.gizmos, cls.gizmo_pref_name, True):
|
||||
@@ -6416,6 +6511,8 @@ class BaseParametricGizmoGroup:
|
||||
"""
|
||||
if not self.is_setup_complete():
|
||||
return
|
||||
if apply_transform_modal_draw_gate(self, context):
|
||||
return
|
||||
obj = context.active_object
|
||||
if not obj:
|
||||
return
|
||||
@@ -6622,6 +6719,8 @@ class BaseSchematicGizmoGroup(BaseParametricGizmoGroup):
|
||||
def draw_prepare(self, context: bpy.types.Context) -> None:
|
||||
if not self.is_setup_complete():
|
||||
return
|
||||
if apply_transform_modal_draw_gate(self, context):
|
||||
return
|
||||
obj = context.active_object
|
||||
if not obj:
|
||||
return
|
||||
@@ -7028,6 +7127,8 @@ class BaseIconActionGroup(BillboardingGizmoGroupMixin):
|
||||
return False
|
||||
if not tool.Blender.are_viewport_gizmos_enabled():
|
||||
return False
|
||||
if _is_transform_modal_active(context):
|
||||
return False
|
||||
return cls.is_eligible_object(obj)
|
||||
|
||||
def setup(self, context: bpy.types.Context) -> None:
|
||||
|
||||
@@ -546,6 +546,13 @@ class UpdateRepresentation(bpy.types.Operator, tool.Ifc.Operator):
|
||||
objs = [bpy.data.objects[obj_name]] if obj_name else context.selected_objects
|
||||
self.file = tool.Ifc.get()
|
||||
|
||||
# Tessellated face sets (IfcTriangulatedFaceSet/IfcPolygonalFaceSet) were
|
||||
# introduced in IFC4 and do not exist in IFC2X3. Catch this early so we
|
||||
# don't silently fall back to a faceted brep after stripping materials.
|
||||
if self.ifc_representation_class == "IfcTessellatedFaceSet" and self.file.schema == "IFC2X3":
|
||||
self.report({"ERROR"}, "Tessellated face sets are not supported in IFC2X3.")
|
||||
return {"CANCELLED"}
|
||||
|
||||
for obj in objs:
|
||||
# TODO: write unit tests to see how this bulk operation handles
|
||||
# contradictory ifc_representation_class values and when
|
||||
@@ -3166,7 +3173,7 @@ class EnableEditingRepresentationItems(bpy.types.Operator, tool.Ifc.Operator):
|
||||
product_reps = element.RepresentationMaps
|
||||
item_aspect = {}
|
||||
for product_rep in product_reps:
|
||||
for aspect in product_rep.HasShapeAspects:
|
||||
for aspect in getattr(product_rep, "HasShapeAspects", ()):
|
||||
for aspect_rep in aspect.ShapeRepresentations:
|
||||
if aspect_rep.ContextOfItems != representation.ContextOfItems:
|
||||
continue
|
||||
|
||||
@@ -33,6 +33,7 @@ from . import (
|
||||
handler,
|
||||
host_add_opening_gizmo,
|
||||
mep,
|
||||
mep_bend_preview,
|
||||
opening,
|
||||
product,
|
||||
profile,
|
||||
@@ -84,6 +85,7 @@ classes = (
|
||||
product.SetActiveType,
|
||||
workspace.Hotkey,
|
||||
workspace.BIM_MT_add_representation_item,
|
||||
wall.AddPerpendicularWall,
|
||||
wall.AddWallsFromSlab,
|
||||
wall.AlignWall,
|
||||
wall.CancelEditingWall,
|
||||
@@ -184,10 +186,14 @@ classes = (
|
||||
prop.BIMRailingProperties,
|
||||
prop.BIMRoofProperties,
|
||||
prop.BIMWallProperties,
|
||||
prop.BIMPipeSegmentProperties,
|
||||
prop.BIMDuctSegmentProperties,
|
||||
prop.BIMPolylineProperties,
|
||||
prop.BIMExternalParametricGeometryProperties,
|
||||
prop.BIMBendPreviewProperties,
|
||||
prop.BIMWallFilletPreviewProperties,
|
||||
prop.BIMPreviewProperties,
|
||||
prop.BIMParametricEditDialogPrefs,
|
||||
ui.BIM_PT_array,
|
||||
ui.BIM_PT_stair,
|
||||
ui.BIM_PT_wall,
|
||||
@@ -263,6 +269,29 @@ classes = (
|
||||
mep.MEPAddObstruction,
|
||||
mep.MEPAddTransition,
|
||||
mep.MEPAddBend,
|
||||
mep.MEPUnjoinAtPort,
|
||||
mep.MEPRemoveTerminalFitting,
|
||||
mep.MEPUnjoinPair,
|
||||
mep.SelectMEPPathMembers,
|
||||
mep.MEPJoinSegments,
|
||||
mep_bend_preview.EnableBendPreview,
|
||||
mep_bend_preview.FinishBendPreview,
|
||||
mep_bend_preview.CancelBendPreview,
|
||||
mep_bend_preview.EnableBendPreviewFromBend,
|
||||
mep_bend_preview.GizmoBendPreview,
|
||||
mep.EnableEditingPipeSegment,
|
||||
mep.FinishEditingPipeSegment,
|
||||
mep.CancelEditingPipeSegment,
|
||||
mep.EnableEditingDuctSegment,
|
||||
mep.FinishEditingDuctSegment,
|
||||
mep.CancelEditingDuctSegment,
|
||||
mep.ExtendPipeSegmentToCursor,
|
||||
mep.ExtendDuctSegmentToCursor,
|
||||
mep.SplitPipeSegmentAtCursor,
|
||||
mep.SplitDuctSegmentAtCursor,
|
||||
mep.GizmoPipeSegmentEdition,
|
||||
mep.GizmoDuctSegmentEdition,
|
||||
mep.GizmoMEPActions,
|
||||
external.ApplyExternalParametricGeometry,
|
||||
)
|
||||
|
||||
@@ -322,6 +351,9 @@ def register():
|
||||
type=prop.BIMExternalParametricGeometryProperties
|
||||
)
|
||||
bpy.types.Scene.BIMPreviewProperties = bpy.props.PointerProperty(type=prop.BIMPreviewProperties)
|
||||
bpy.types.WindowManager.BIMParametricEditDialogPrefs = bpy.props.PointerProperty(
|
||||
type=prop.BIMParametricEditDialogPrefs
|
||||
)
|
||||
|
||||
bpy.types.VIEW3D_MT_add.prepend(ui.add_menu)
|
||||
bpy.app.handlers.load_post.append(handler.load_post)
|
||||
@@ -347,6 +379,7 @@ def unregister():
|
||||
tool.Parametric.unregister_object_properties()
|
||||
del bpy.types.Object.BIMExternalParametricGeometryProperties
|
||||
del bpy.types.Scene.BIMPreviewProperties
|
||||
del bpy.types.WindowManager.BIMParametricEditDialogPrefs
|
||||
|
||||
bpy.app.handlers.load_post.remove(handler.load_post)
|
||||
bpy.types.VIEW3D_MT_add.remove(ui.add_menu)
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
#
|
||||
# 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 modified with the assistance of an AI coding tool.
|
||||
|
||||
import json
|
||||
from typing import ClassVar
|
||||
@@ -881,6 +883,36 @@ class EnableEditingParametric(bpy.types.Operator):
|
||||
default="",
|
||||
description="Operator bl_idname to invoke (e.g., 'bim.enable_editing_door').",
|
||||
)
|
||||
sibling_count: bpy.props.IntProperty(default=0, options={"HIDDEN"})
|
||||
|
||||
@staticmethod
|
||||
def should_show_shared_rep_dialog(*, suppress: bool, has_entity: bool, sibling_count: int) -> bool:
|
||||
"""Pure decision for the pre-edit warning. Returns ``True`` only when the
|
||||
edit will silently mutate other elements' geometry AND the user has not
|
||||
opted out of the warning for this session."""
|
||||
if suppress or not has_entity:
|
||||
return False
|
||||
return sibling_count > 0
|
||||
|
||||
def invoke(self, context, event):
|
||||
prefs = getattr(context.window_manager, "BIMParametricEditDialogPrefs", None)
|
||||
suppress = bool(prefs and prefs.suppress_shared_rep_warning)
|
||||
obj = context.active_object
|
||||
element = tool.Ifc.get_entity(obj) if obj else None
|
||||
self.sibling_count = tool.Model.get_sibling_occurrence_count(element) if element is not None else 0
|
||||
if self.should_show_shared_rep_dialog(
|
||||
suppress=suppress, has_entity=element is not None, sibling_count=self.sibling_count
|
||||
):
|
||||
return context.window_manager.invoke_props_dialog(self, width=400)
|
||||
return self.execute(context)
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
layout.label(text="Shared geometry", icon="ERROR")
|
||||
layout.label(text=f"Geometry is shared with {self.sibling_count} other element(s).")
|
||||
layout.label(text="Edits will affect them too.")
|
||||
prefs = context.window_manager.BIMParametricEditDialogPrefs
|
||||
layout.prop(prefs, "suppress_shared_rep_warning", text="Don't show this again for this session")
|
||||
|
||||
def execute(self, context):
|
||||
# Malformed ``feature_enable_op`` (missing dot) would otherwise crash
|
||||
|
||||
@@ -15,12 +15,14 @@
|
||||
#
|
||||
# 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 modified with the assistance of an AI coding tool.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from math import cos, pi, radians, sin, tan
|
||||
from typing import Any, Literal
|
||||
from typing import Any, Literal, NamedTuple
|
||||
|
||||
import blf
|
||||
import bmesh
|
||||
@@ -41,6 +43,11 @@ from mathutils import Matrix, Quaternion, Vector
|
||||
|
||||
import bonsai.core.geometry
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.module.drawing.gizmos import (
|
||||
ARC_SEGMENTS,
|
||||
DOOR_SWING_ANGLE_MAX,
|
||||
DOOR_SWING_ANGLE_MIN,
|
||||
)
|
||||
from bonsai.bim.module.drawing.helper import format_distance
|
||||
|
||||
|
||||
@@ -88,15 +95,9 @@ class ProfileDecorator:
|
||||
batch.draw(shader)
|
||||
|
||||
def draw_faces(self, bm, vertices_coords):
|
||||
"""mutates original bm (triangulates it)
|
||||
so the triangulation edges will be shown too
|
||||
"""
|
||||
traingulated_bm = bm
|
||||
bmesh.ops.triangulate(traingulated_bm, faces=traingulated_bm.faces)
|
||||
|
||||
face_indices = [[v.index for v in f.verts] for f in traingulated_bm.faces]
|
||||
"""Submit a non-mutating beauty-triangulated TRIS batch over ``bm``'s faces."""
|
||||
faces_color = transparent_color(self.addon_prefs.decorator_color_special)
|
||||
self.draw_batch("TRIS", vertices_coords, faces_color, face_indices)
|
||||
tool.Blender.draw_bmesh_face_tris(bm, vertices_coords, faces_color, self.draw_batch)
|
||||
|
||||
def __call__(self, context, get_custom_bmesh=None, draw_faces=False, exit_edit_mode_callback=None):
|
||||
self.addon_prefs = tool.Blender.get_addon_preferences()
|
||||
@@ -2031,42 +2032,6 @@ class BoundingBoxDecorator:
|
||||
co2.y -= y_overlap / 2 + min_spacing
|
||||
|
||||
|
||||
def _stroke_lines_alpha(
|
||||
context: bpy.types.Context,
|
||||
segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]],
|
||||
color_rgb: tuple[float, float, float],
|
||||
line_width: float,
|
||||
line_alpha: float,
|
||||
) -> None:
|
||||
"""Render ``segments`` (a list of ``(start, end)`` tuples) as one
|
||||
anti-aliased LINES batch in world space. Early-returns when
|
||||
``context.region`` is unavailable (e.g. when called from a
|
||||
``_RestrictContext``)."""
|
||||
if not segments:
|
||||
return
|
||||
verts: list[tuple[float, float, float]] = []
|
||||
indices: list[tuple[int, int]] = []
|
||||
for start, end in segments:
|
||||
base = len(verts)
|
||||
verts.append(tuple(start))
|
||||
verts.append(tuple(end))
|
||||
indices.append((base, base + 1))
|
||||
if not tool.Blender.validate_shader_batch_data(verts, indices):
|
||||
return
|
||||
region = getattr(context, "region", None)
|
||||
if region is None:
|
||||
return
|
||||
shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR")
|
||||
shader.bind()
|
||||
shader.uniform_float("viewportSize", (region.width, region.height))
|
||||
shader.uniform_float("lineWidth", line_width)
|
||||
shader.uniform_float("color", (*color_rgb, line_alpha))
|
||||
batch = batch_for_shader(shader, "LINES", {"pos": verts}, indices=indices)
|
||||
gpu.state.blend_set("ALPHA")
|
||||
batch.draw(shader)
|
||||
gpu.state.blend_set("NONE")
|
||||
|
||||
|
||||
def _fill_quads_alpha(
|
||||
context: bpy.types.Context,
|
||||
quads: list[
|
||||
@@ -2081,8 +2046,7 @@ def _fill_quads_alpha(
|
||||
alpha: float,
|
||||
) -> None:
|
||||
"""Render ``quads`` (each a 4-tuple of world-space corner verts in CCW
|
||||
order) as one TRIS batch with two triangles per quad. Companion to
|
||||
``_stroke_lines_alpha`` for filled previews."""
|
||||
order) as one TRIS batch with two triangles per quad."""
|
||||
if not quads:
|
||||
return
|
||||
verts: list[tuple[float, float, float]] = []
|
||||
@@ -2108,6 +2072,181 @@ def _fill_quads_alpha(
|
||||
gpu.state.blend_set("NONE")
|
||||
|
||||
|
||||
def compute_mep_join_location():
|
||||
"""Midpoint between the closest endpoint pair of two selected MEP
|
||||
segments — the world location where a connecting fitting (bend /
|
||||
transition) would land. Returns ``None`` when prerequisites aren't met
|
||||
(wrong cardinality, mixed non-MEP)."""
|
||||
selected = list(tool.Blender.get_selected_objects())
|
||||
if len(selected) != 2:
|
||||
return None
|
||||
for obj in selected:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if element is None or not tool.System.is_mep_element(element):
|
||||
return None
|
||||
a_start, a_end = tool.Model.get_flow_segment_axis(selected[0])
|
||||
b_start, b_end = tool.Model.get_flow_segment_axis(selected[1])
|
||||
pairs = [(a_start, b_start), (a_start, b_end), (a_end, b_start), (a_end, b_end)]
|
||||
closest = min(pairs, key=lambda p: (p[0] - p[1]).length)
|
||||
return (closest[0] + closest[1]) * 0.5
|
||||
|
||||
|
||||
class MEPSegmentExtendPreviewDecorator(tool.Blender.ViewportDecorator):
|
||||
"""Preview line for the MEP segment extend-to-cursor gizmo. Renders one
|
||||
line from the segment's current end to the cursor's projection on the
|
||||
segment's local Z axis when the extend icon is hovered. Self-gates every
|
||||
draw on the viewport gizmo toggle and the per-feature ``extend`` pref."""
|
||||
|
||||
draw_method = "draw_line"
|
||||
|
||||
LINE_WIDTH = 1.5
|
||||
LINE_ALPHA = 0.8
|
||||
|
||||
def draw_line(self, context: bpy.types.Context) -> None:
|
||||
if not tool.Blender.are_viewport_gizmos_enabled():
|
||||
return
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
|
||||
active = context.active_object
|
||||
if active is None:
|
||||
return
|
||||
selected = list(tool.Blender.get_selected_objects())
|
||||
if active not in selected or len(selected) != 1:
|
||||
return
|
||||
|
||||
element = tool.Ifc.get_entity(active)
|
||||
if element is None:
|
||||
return
|
||||
|
||||
from bonsai.bim.module.model.mep import (
|
||||
GizmoDuctSegmentEdition,
|
||||
GizmoPipeSegmentEdition,
|
||||
)
|
||||
|
||||
if tool.Parametric.is_pipe_segment(element):
|
||||
gizmo_prefs = getattr(prefs.gizmos, "pipe_segment", None)
|
||||
gizmo_cls = GizmoPipeSegmentEdition
|
||||
elif tool.Parametric.is_duct_segment(element):
|
||||
gizmo_prefs = getattr(prefs.gizmos, "duct_segment", None)
|
||||
gizmo_cls = GizmoDuctSegmentEdition
|
||||
else:
|
||||
return
|
||||
if gizmo_prefs is None or not getattr(gizmo_prefs, "enabled", True):
|
||||
return
|
||||
if not self._cursor_icon_hovered(gizmo_cls, "extend_gizmo", context):
|
||||
return
|
||||
|
||||
current_length = max(c[2] for c in active.bound_box) if active.bound_box else 0.0
|
||||
line = self._compute_extend_preview_line(active.matrix_world, context.scene.cursor.location, current_length)
|
||||
if line is None:
|
||||
return
|
||||
start_world, end_world = line
|
||||
color = tuple(prefs.decorator_color_selected[:3])
|
||||
draw_polyline_segments(
|
||||
context,
|
||||
[(tuple(start_world), tuple(end_world))],
|
||||
color,
|
||||
self.LINE_ALPHA,
|
||||
self.LINE_WIDTH,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _compute_extend_preview_line(
|
||||
matrix_world: Matrix,
|
||||
cursor_world: Vector,
|
||||
current_length: float,
|
||||
) -> tuple[Vector, Vector] | None:
|
||||
"""Returns ``(current_end_world, target_end_world)`` or ``None`` when
|
||||
no extend would happen (degenerate segment, or cursor on the existing
|
||||
end). Target follows the cursor's raw local-Z projection unbounded —
|
||||
the line stays visible past the segment origin (negative local Z)
|
||||
because the user expects to see where they're pointing even when the
|
||||
operator would floor it."""
|
||||
if current_length <= 0:
|
||||
return None
|
||||
cursor_local = matrix_world.inverted() @ cursor_world
|
||||
if abs(cursor_local.z - current_length) < 1e-6:
|
||||
return None
|
||||
current_end_world = matrix_world @ Vector((0.0, 0.0, current_length))
|
||||
target_end_world = matrix_world @ Vector((0.0, 0.0, cursor_local.z))
|
||||
return current_end_world, target_end_world
|
||||
|
||||
|
||||
class BendPreviewDecorator(tool.Blender.ViewportDecorator):
|
||||
"""GPU preview lines for the bend-creation flow.
|
||||
|
||||
Polls on ``scene.BIMPreviewProperties.bend.is_active`` and renders the
|
||||
centerline + leg projections returned by ``mep.compute_bend_preview_polylines``.
|
||||
The two leg lines (segment → tangent point) show how each segment will
|
||||
be shortened; the arc polyline approximates the bend curve. On invalid
|
||||
geometry, draws the two rejected axes in warning colour instead so the
|
||||
user sees why the bend cannot be placed.
|
||||
|
||||
Installed once per Blender session from ``bim/handler.py:load_post``.
|
||||
Cheap to leave running because the first thing ``draw`` does is check
|
||||
``is_active`` and return when False.
|
||||
"""
|
||||
|
||||
LINE_WIDTH_LEG = 1.5
|
||||
LINE_WIDTH_ARC = 2.5
|
||||
LINE_ALPHA = 0.7
|
||||
|
||||
def draw(self, context: bpy.types.Context) -> None:
|
||||
scene = context.scene
|
||||
preview = getattr(scene, "BIMPreviewProperties", None)
|
||||
props = preview.bend if preview is not None else None
|
||||
if props is None or not props.is_active:
|
||||
return
|
||||
ifc_file = tool.Ifc.get()
|
||||
if ifc_file is None:
|
||||
return
|
||||
try:
|
||||
start_element = ifc_file.by_id(props.start_segment_id)
|
||||
end_element = ifc_file.by_id(props.end_segment_id)
|
||||
except Exception:
|
||||
return
|
||||
start_obj = tool.Ifc.get_object(start_element) if start_element else None
|
||||
end_obj = tool.Ifc.get_object(end_element) if end_element else None
|
||||
if start_obj is None or end_obj is None:
|
||||
return
|
||||
|
||||
# Late import: decorator.py loads at addon enable but mep.py imports
|
||||
# this module for the extend preview, so a module-level import would
|
||||
# cycle.
|
||||
from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines
|
||||
|
||||
preview = cached_compute_bend_preview_polylines(
|
||||
start_obj, end_obj, props.start_length, props.end_length, props.radius
|
||||
)
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
|
||||
if not preview["valid"]:
|
||||
warning_color = tuple(prefs.decorator_color_error[:3])
|
||||
axes = preview.get("invalid_axes") or []
|
||||
if axes:
|
||||
segments = [(tuple(a), tuple(b)) for a, b in axes]
|
||||
draw_polyline_segments(context, segments, warning_color, self.LINE_ALPHA, self.LINE_WIDTH_ARC)
|
||||
return
|
||||
|
||||
leg_color = tuple(prefs.decorations_colour[:3])
|
||||
arc_color = tuple(prefs.decorator_color_selected[:3])
|
||||
|
||||
leg_a_far, leg_a_end = preview["leg_a"]
|
||||
leg_b_far, leg_b_end = preview["leg_b"]
|
||||
draw_polyline_segments(
|
||||
context,
|
||||
[(tuple(leg_a_far), tuple(leg_a_end)), (tuple(leg_b_far), tuple(leg_b_end))],
|
||||
leg_color,
|
||||
self.LINE_ALPHA,
|
||||
self.LINE_WIDTH_LEG,
|
||||
)
|
||||
|
||||
arc = preview["arc"]
|
||||
if len(arc) >= 2:
|
||||
arc_segments = [(tuple(arc[i]), tuple(arc[i + 1])) for i in range(len(arc) - 1)]
|
||||
draw_polyline_segments(context, arc_segments, arc_color, self.LINE_ALPHA, self.LINE_WIDTH_ARC)
|
||||
|
||||
|
||||
class WallFilletPreviewDecorator(tool.Blender.ViewportDecorator):
|
||||
"""GPU preview lines for the wall-fillet flow.
|
||||
|
||||
@@ -2169,15 +2308,15 @@ class WallFilletPreviewDecorator(tool.Blender.ViewportDecorator):
|
||||
(tuple(far_a), tuple(tangent_a)),
|
||||
(tuple(far_b), tuple(tangent_b)),
|
||||
]
|
||||
_stroke_lines_alpha(context, legs, warning_color, self.LINE_WIDTH_LEG, self.LINE_ALPHA)
|
||||
draw_polyline_segments(context, legs, warning_color, self.LINE_ALPHA, self.LINE_WIDTH_LEG)
|
||||
arc = geom.get("arc") or []
|
||||
if len(arc) >= 2:
|
||||
arc_segments = [(tuple(arc[i]), tuple(arc[i + 1])) for i in range(len(arc) - 1)]
|
||||
_stroke_lines_alpha(context, arc_segments, warning_color, self.LINE_WIDTH_ARC, self.LINE_ALPHA)
|
||||
draw_polyline_segments(context, arc_segments, warning_color, self.LINE_ALPHA, self.LINE_WIDTH_ARC)
|
||||
elif geom.get("invalid_axes"):
|
||||
axes = geom["invalid_axes"]
|
||||
segments = [(tuple(a), tuple(b)) for a, b in axes]
|
||||
_stroke_lines_alpha(context, segments, warning_color, self.LINE_WIDTH_ARC, self.LINE_ALPHA)
|
||||
draw_polyline_segments(context, segments, warning_color, self.LINE_ALPHA, self.LINE_WIDTH_ARC)
|
||||
return
|
||||
|
||||
leg_color = tuple(prefs.decorations_colour[:3])
|
||||
@@ -2194,12 +2333,12 @@ class WallFilletPreviewDecorator(tool.Blender.ViewportDecorator):
|
||||
(tuple(far_a), tuple(geom["tangent_a"])),
|
||||
(tuple(far_b), tuple(geom["tangent_b"])),
|
||||
]
|
||||
_stroke_lines_alpha(context, legs, leg_color, self.LINE_WIDTH_LEG, self.LINE_ALPHA)
|
||||
draw_polyline_segments(context, legs, leg_color, self.LINE_ALPHA, self.LINE_WIDTH_LEG)
|
||||
|
||||
arc = geom["arc"]
|
||||
if len(arc) >= 2:
|
||||
arc_segments = [(tuple(arc[i]), tuple(arc[i + 1])) for i in range(len(arc) - 1)]
|
||||
_stroke_lines_alpha(context, arc_segments, arc_color, self.LINE_WIDTH_ARC, self.LINE_ALPHA)
|
||||
draw_polyline_segments(context, arc_segments, arc_color, self.LINE_ALPHA, self.LINE_WIDTH_ARC)
|
||||
|
||||
# Dim construction lines from arc_center to each tangent point so
|
||||
# the radius reads as concrete during drag.
|
||||
@@ -2209,7 +2348,9 @@ class WallFilletPreviewDecorator(tool.Blender.ViewportDecorator):
|
||||
(tuple(arc_center), tuple(geom["tangent_a"])),
|
||||
(tuple(arc_center), tuple(geom["tangent_b"])),
|
||||
]
|
||||
_stroke_lines_alpha(context, construction, arc_color, self.LINE_WIDTH_CONSTRUCTION, self.CONSTRUCTION_ALPHA)
|
||||
draw_polyline_segments(
|
||||
context, construction, arc_color, self.CONSTRUCTION_ALPHA, self.LINE_WIDTH_CONSTRUCTION
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _far_endpoint(reference_line, intersection):
|
||||
@@ -2220,6 +2361,120 @@ class WallFilletPreviewDecorator(tool.Blender.ViewportDecorator):
|
||||
return p2 if d2 >= d1 else p1
|
||||
|
||||
|
||||
class _DoorSwingArc(NamedTuple):
|
||||
"""Parameters for one swing-arc draw call in door-local space."""
|
||||
|
||||
hinge_x: float
|
||||
hinge_y: float
|
||||
panel_width: float
|
||||
x_mirror: bool
|
||||
|
||||
|
||||
def _visible_arcs(door_type: str, overall_width: float, lining_offset: float) -> list[_DoorSwingArc]:
|
||||
"""Arc specs for the parametric door swing visualisation, agnostic of
|
||||
edit-mode state so the readonly preview and the editor view stay aligned.
|
||||
|
||||
Empty only for sliding-door types; unknown ``door_type`` values fall
|
||||
through to a single left-hinged arc."""
|
||||
if "SLIDING" in door_type:
|
||||
return []
|
||||
is_double = "DOUBLE_DOOR" in door_type
|
||||
is_right_single = door_type.endswith("RIGHT") and not is_double
|
||||
arcs = [
|
||||
_DoorSwingArc(
|
||||
hinge_x=overall_width if is_right_single else 0.0,
|
||||
hinge_y=lining_offset,
|
||||
panel_width=overall_width / 2 if is_double else overall_width,
|
||||
x_mirror=is_right_single,
|
||||
)
|
||||
]
|
||||
if is_double:
|
||||
arcs.append(
|
||||
_DoorSwingArc(
|
||||
hinge_x=overall_width,
|
||||
hinge_y=lining_offset,
|
||||
panel_width=overall_width / 2,
|
||||
x_mirror=True,
|
||||
)
|
||||
)
|
||||
return arcs
|
||||
|
||||
|
||||
# Unit quarter-arc samples shared with the edit-mode swing gizmo so the
|
||||
# readonly arc traces the same curve. Re-scaled per draw via the per-arc
|
||||
# transform.
|
||||
_DOOR_SWING_ARC_ANGLE_MIN_RAD = math.radians(DOOR_SWING_ANGLE_MIN)
|
||||
_DOOR_SWING_ARC_ANGLE_RANGE_RAD = math.radians(DOOR_SWING_ANGLE_MAX) - _DOOR_SWING_ARC_ANGLE_MIN_RAD
|
||||
_DOOR_SWING_ARC_UNIT_POINTS: tuple[Vector, ...] = tuple(
|
||||
Vector(
|
||||
(
|
||||
math.cos(_DOOR_SWING_ARC_ANGLE_MIN_RAD + _DOOR_SWING_ARC_ANGLE_RANGE_RAD * (_i / ARC_SEGMENTS)),
|
||||
math.sin(_DOOR_SWING_ARC_ANGLE_MIN_RAD + _DOOR_SWING_ARC_ANGLE_RANGE_RAD * (_i / ARC_SEGMENTS)),
|
||||
0.0,
|
||||
)
|
||||
)
|
||||
for _i in range(ARC_SEGMENTS + 1)
|
||||
)
|
||||
|
||||
|
||||
class DoorSwingReadonlyDecorator(tool.Blender.ViewportDecorator):
|
||||
"""Always-on swing-arc preview for the active Bonsai-parametric IfcDoor
|
||||
when it is not currently in parametric edit mode. Matches the visual
|
||||
contract of the parametric door's swing-arc gizmos so the hinge side
|
||||
and opening direction can be read without entering edit mode.
|
||||
|
||||
Silent-skip cases (no draw, no error):
|
||||
|
||||
- active object missing / not selected / not an IfcDoor;
|
||||
- door is mid-edit (the swing gizmo is already painting the arc);
|
||||
- door has no ``BBIM_Door`` pset (legacy import, never edited in Bonsai)."""
|
||||
|
||||
LINE_WIDTH = 1.5
|
||||
LINE_ALPHA = 0.8
|
||||
|
||||
def draw(self, context: bpy.types.Context) -> None:
|
||||
obj = context.active_object
|
||||
if obj is None or not obj.select_get():
|
||||
return
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if element is None or not element.is_a("IfcDoor"):
|
||||
return
|
||||
props = getattr(obj, "BIMDoorProperties", None)
|
||||
if props is not None and props.is_editing:
|
||||
return
|
||||
pset = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Door")
|
||||
if not pset:
|
||||
return
|
||||
data = pset.get("data_dict")
|
||||
if not data:
|
||||
return
|
||||
door_type = data.get("door_type", "")
|
||||
overall_width_project = data.get("overall_width", 0.0)
|
||||
lining_offset_project = (data.get("lining_properties") or {}).get("lining_offset", 0.0)
|
||||
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
overall_width = overall_width_project * si_conversion
|
||||
lining_offset = lining_offset_project * si_conversion
|
||||
specs = _visible_arcs(door_type, overall_width, lining_offset)
|
||||
if not specs:
|
||||
return
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
main_color = tuple(prefs.decorator_color_special[:3])
|
||||
mw = obj.matrix_world
|
||||
segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = []
|
||||
for spec in specs:
|
||||
x_flip = Matrix.Scale(-1, 4, (1, 0, 0)) if spec.x_mirror else Matrix.Identity(4)
|
||||
transform = (
|
||||
Matrix.Translation(Vector((spec.hinge_x, spec.hinge_y, 0.0)))
|
||||
@ Matrix.Scale(spec.panel_width, 4)
|
||||
@ x_flip
|
||||
)
|
||||
world_main = mw @ transform
|
||||
pts = [world_main @ p for p in _DOOR_SWING_ARC_UNIT_POINTS]
|
||||
for i in range(len(pts) - 1):
|
||||
segments.append((tuple(pts[i]), tuple(pts[i + 1])))
|
||||
draw_polyline_segments(context, segments, main_color, self.LINE_ALPHA, self.LINE_WIDTH)
|
||||
|
||||
|
||||
_BBOX_EDGES = (
|
||||
(0, 1), (1, 2), (2, 3), (3, 0),
|
||||
(4, 5), (5, 6), (6, 7), (7, 4),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,444 @@
|
||||
# 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.
|
||||
|
||||
"""Bend-preview lifecycle for MEP segment joins.
|
||||
|
||||
Holds the four lifecycle operators (Enable / Finish / Cancel /
|
||||
EnableFromBend) and the ``GizmoBendPreview`` group that surfaces the
|
||||
tunable dimensions and validate/cancel icons during preview. Draft state
|
||||
lives at ``Scene.BIMPreviewProperties.bend`` per CLAUDE.md §2.9 (Scene
|
||||
for cross-element previews).
|
||||
|
||||
The geometry math (``compute_bend_preview_polylines``,
|
||||
``_bend_profile_cross_section``, ``_sweep_profile_along_polyline``)
|
||||
stays in ``mep.py`` because the commit operator ``MEPAddBend`` reuses
|
||||
it; this module imports the polyline helper for per-frame gizmo
|
||||
positioning. The GPU lines themselves are drawn by
|
||||
``decorator.BendPreviewDecorator``, kept in ``decorator.py`` with its
|
||||
sibling decorators."""
|
||||
|
||||
from typing import ClassVar
|
||||
|
||||
import bpy
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.util.unit
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.module.drawing import gizmos as gizmo
|
||||
from bonsai.bim.module.model import preview_base
|
||||
from bonsai.bim.module.model.mep import (
|
||||
_is_bend_fitting,
|
||||
_n_mep_selected,
|
||||
cached_compute_bend_preview_polylines,
|
||||
segments_are_parallel,
|
||||
validate_bend_preconditions,
|
||||
)
|
||||
|
||||
|
||||
class EnableBendPreview(bpy.types.Operator):
|
||||
"""Enter bend-preview mode for two selected MEP segments. Populates
|
||||
scene.BIMPreviewProperties.bend with segment IFC ids and default
|
||||
start_length / end_length / radius; no IFC mutation until finish."""
|
||||
|
||||
bl_idname = "bim.enable_bend_preview"
|
||||
bl_label = "Enter Bend Preview"
|
||||
bl_description = "Begin tuning bend parameters before committing the bend"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
if not _n_mep_selected(2):
|
||||
cls.poll_message_set("Select exactly 2 MEP segments to bend.")
|
||||
return False
|
||||
return True
|
||||
|
||||
def execute(self, context):
|
||||
selected = tool.Blender.get_selected_objects()
|
||||
active = context.active_object
|
||||
if active is None or active not in selected:
|
||||
self.report({"ERROR"}, "Active object must be one of the selected MEP segments.")
|
||||
return {"CANCELLED"}
|
||||
other = next((o for o in selected if o is not active), None)
|
||||
if other is None:
|
||||
self.report({"ERROR"}, "Two MEP segments must be selected.")
|
||||
return {"CANCELLED"}
|
||||
active_element = tool.Ifc.get_entity(active)
|
||||
other_element = tool.Ifc.get_entity(other)
|
||||
if active_element is None or other_element is None:
|
||||
self.report({"ERROR"}, "Both selected objects must be IFC elements.")
|
||||
return {"CANCELLED"}
|
||||
if segments_are_parallel(active, other):
|
||||
self.report({"ERROR"}, "Bend preview is for non-parallel segments only.")
|
||||
return {"CANCELLED"}
|
||||
|
||||
# Pre-check the same preconditions MEPAddBend enforces so the user
|
||||
# sees the rejection here rather than after tuning a doomed preview.
|
||||
precondition_error = validate_bend_preconditions(active_element, other_element)
|
||||
if precondition_error is not None:
|
||||
self.report({"ERROR"}, precondition_error)
|
||||
return {"CANCELLED"}
|
||||
|
||||
preview_base.sync_uncommitted_moves([active, other])
|
||||
|
||||
props = preview_base.get_preview_props(context, "bend")
|
||||
# Auto-cancel any prior preview so re-clicking join on a different
|
||||
# pair doesn't silently commit the previous tuning.
|
||||
if props is not None and props.is_active:
|
||||
bpy.ops.bim.cancel_bend_preview()
|
||||
|
||||
props.start_segment_id = active_element.id()
|
||||
props.end_segment_id = other_element.id()
|
||||
props.start_length = 0.1
|
||||
props.end_length = 0.1
|
||||
props.radius = 0.2
|
||||
props.is_active = True
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class FinishBendPreview(bpy.types.Operator):
|
||||
"""Commit the previewed bend with the tuned parameters and exit preview.
|
||||
|
||||
Preview state survives a failed commit so the user can re-tune without
|
||||
re-selecting."""
|
||||
|
||||
bl_idname = "bim.finish_bend_preview"
|
||||
bl_label = "Apply Bend"
|
||||
bl_description = "Commit the bend with the previewed parameters"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
return preview_base.commit_preview(
|
||||
self,
|
||||
context,
|
||||
"bend",
|
||||
"mep_add_bend",
|
||||
("start_segment_id", "end_segment_id", "start_length", "end_length", "radius", "editing_bend_id"),
|
||||
)
|
||||
|
||||
|
||||
class CancelBendPreview(bpy.types.Operator):
|
||||
"""Exit bend preview without committing."""
|
||||
|
||||
bl_idname = "bim.cancel_bend_preview"
|
||||
bl_label = "Cancel Bend"
|
||||
bl_description = "Discard the previewed bend"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
if context.screen is None:
|
||||
return {"CANCELLED"}
|
||||
props = preview_base.get_preview_props(context, "bend")
|
||||
if props is None or not props.is_active:
|
||||
return {"CANCELLED"}
|
||||
preview_base.clear_preview_state(props)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class EnableBendPreviewFromBend(bpy.types.Operator):
|
||||
"""Re-open the bend preview on an existing bend fitting.
|
||||
|
||||
Resolves the two connected segments via the bend's ports +
|
||||
``IfcRelConnectsPorts``, reads parametric values back from the bend's
|
||||
``BBIM_Fitting`` pset, and flags the preview so committing replaces
|
||||
the existing bend in place."""
|
||||
|
||||
bl_idname = "bim.enable_bend_preview_from_bend"
|
||||
bl_label = "Edit Bend"
|
||||
bl_description = "Re-open the bend preview to retune an existing bend"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
active = context.active_object
|
||||
if active is None:
|
||||
cls.poll_message_set("No active object.")
|
||||
return False
|
||||
element = tool.Ifc.get_entity(active)
|
||||
if element is None or not _is_bend_fitting(element):
|
||||
cls.poll_message_set("Active object must be a bend fitting.")
|
||||
return False
|
||||
return True
|
||||
|
||||
def execute(self, context):
|
||||
active = context.active_object
|
||||
bend_element = tool.Ifc.get_entity(active)
|
||||
if bend_element is None or not _is_bend_fitting(bend_element):
|
||||
self.report({"ERROR"}, "Active object is not a bend fitting.")
|
||||
return {"CANCELLED"}
|
||||
|
||||
connected_segments: list = []
|
||||
for port in tool.System.get_ports(bend_element):
|
||||
connected_port = tool.System.get_connected_port(port)
|
||||
if connected_port is None:
|
||||
continue
|
||||
related = tool.System.get_port_relating_element(connected_port)
|
||||
if related is not None and related.is_a("IfcFlowSegment") and related not in connected_segments:
|
||||
connected_segments.append(related)
|
||||
|
||||
if len(connected_segments) != 2:
|
||||
self.report(
|
||||
{"ERROR"},
|
||||
f"Bend has {len(connected_segments)} connected segments; need exactly 2 to re-edit.",
|
||||
)
|
||||
return {"CANCELLED"}
|
||||
|
||||
# Read parametric values from the bend type's BBIM_Fitting pset. The
|
||||
# type carries the canonical parameters; querying the occurrence
|
||||
# would force a get_type round-trip and miss user-edited types.
|
||||
bend_type = ifcopenshell.util.element.get_type(bend_element)
|
||||
if bend_type is None:
|
||||
self.report({"ERROR"}, "Bend fitting has no type to read parameters from.")
|
||||
return {"CANCELLED"}
|
||||
bend_type_obj = tool.Ifc.get_object(bend_type)
|
||||
if bend_type_obj is None:
|
||||
self.report({"ERROR"}, "Bend type has no Blender object — cannot read pset.")
|
||||
return {"CANCELLED"}
|
||||
bbim = tool.Model.get_modeling_bbim_pset_data(bend_type_obj, "BBIM_Fitting")
|
||||
if bbim is None:
|
||||
self.report({"ERROR"}, "Bend fitting has no BBIM_Fitting pset — not a parametric bend.")
|
||||
return {"CANCELLED"}
|
||||
data = bbim.get("data_dict", {})
|
||||
|
||||
props = preview_base.get_preview_props(context, "bend")
|
||||
if props is not None and props.is_active:
|
||||
bpy.ops.bim.cancel_bend_preview()
|
||||
|
||||
# Segment order is load-bearing: the bend's lateral sign and z-axis
|
||||
# flip are derived from which segment is "start" vs "end". Re-edit
|
||||
# must reuse the same pairing as the original create so the recreate
|
||||
# lands at the same orientation.
|
||||
start_segment, end_segment = connected_segments
|
||||
props.start_segment_id = start_segment.id()
|
||||
props.end_segment_id = end_segment.id()
|
||||
# Pset values are in IFC native units; scene units come from si_conversion.
|
||||
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
props.start_length = float(data.get("start_length", 0.1)) * si_conversion
|
||||
props.end_length = float(data.get("end_length", 0.1)) * si_conversion
|
||||
props.radius = float(data.get("radius", 0.2)) * si_conversion
|
||||
props.editing_bend_id = bend_element.id()
|
||||
props.is_active = True
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
def _bend_preview_segments(context):
|
||||
"""Resolve the two segment objects from the scene-level preview props.
|
||||
|
||||
Re-resolves by IFC id each frame so undo / file reload during preview
|
||||
never dangles a stale bpy reference."""
|
||||
props = context.scene.BIMPreviewProperties.bend
|
||||
ifc_file = tool.Ifc.get()
|
||||
if ifc_file is None or not props.is_active:
|
||||
return None, None
|
||||
try:
|
||||
start_element = ifc_file.by_id(props.start_segment_id)
|
||||
end_element = ifc_file.by_id(props.end_segment_id)
|
||||
except Exception:
|
||||
return None, None
|
||||
start_obj = tool.Ifc.get_object(start_element) if start_element else None
|
||||
end_obj = tool.Ifc.get_object(end_element) if end_element else None
|
||||
return start_obj, end_obj
|
||||
|
||||
|
||||
def _gizmo_x_matrix(location: Vector, x_direction: Vector) -> Matrix:
|
||||
"""Build a 4x4 matrix placing a gizmo at ``location`` with its local +X
|
||||
axis aligned to ``x_direction`` in world space. ``BIM_GT_gizmo_dimension``
|
||||
draws + drags along local +X by convention."""
|
||||
x = x_direction.normalized()
|
||||
seed = Vector((0, 0, 1)) if abs(x.z) < 0.9 else Vector((1, 0, 0))
|
||||
y = (seed - x * seed.dot(x)).normalized()
|
||||
z = x.cross(y)
|
||||
mat = Matrix.Identity(4)
|
||||
mat[0][:3] = (x.x, y.x, z.x)
|
||||
mat[1][:3] = (x.y, y.y, z.y)
|
||||
mat[2][:3] = (x.z, y.z, z.z)
|
||||
mat.translation = location
|
||||
return mat
|
||||
|
||||
|
||||
class GizmoBendPreview(bpy.types.GizmoGroup):
|
||||
"""Interactive gizmo group for the bend preview flow.
|
||||
|
||||
Three dimension widgets drag start_length / end_length / radius; two
|
||||
icon gizmos commit or cancel. When the geometry is degenerate the
|
||||
dimensions and validate hide but cancel stays visible so the user
|
||||
always has an exit."""
|
||||
|
||||
bl_idname = "OBJECT_GGT_bim_bend_preview"
|
||||
bl_label = "Bend Preview Gizmos"
|
||||
bl_space_type = "VIEW_3D"
|
||||
bl_region_type = "WINDOW"
|
||||
bl_options = {"3D", "PERSISTENT"}
|
||||
|
||||
ICON_SCALE: ClassVar[float] = 0.375
|
||||
ICON_SPACING_X: ClassVar[float] = 0.4
|
||||
ICON_Z_OFFSET: ClassVar[float] = 1.5
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
preview = getattr(context.scene, "BIMPreviewProperties", None)
|
||||
props = preview.bend if preview is not None else None
|
||||
if props is None or not props.is_active:
|
||||
return False
|
||||
if not tool.Blender.are_viewport_gizmos_enabled():
|
||||
return False
|
||||
ifc_file = tool.Ifc.get()
|
||||
if ifc_file is None:
|
||||
return False
|
||||
try:
|
||||
ifc_file.by_id(props.start_segment_id)
|
||||
ifc_file.by_id(props.end_segment_id)
|
||||
except (RuntimeError, KeyError):
|
||||
return False
|
||||
return True
|
||||
|
||||
def setup(self, context):
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
default_color = tuple(prefs.decorations_colour[:3])
|
||||
highlight_color = tuple(prefs.decorator_color_selected[:3])
|
||||
|
||||
_props = preview_base.make_props_callback("bend")
|
||||
|
||||
def setup_dimension(attr: str, prop_name: str, invert_delta: bool = False) -> bpy.types.Gizmo:
|
||||
gz = self.gizmos.new("BIM_GT_gizmo_dimension")
|
||||
gz.move_get_cb = preview_base.make_dim_getter(_props, attr)
|
||||
gz.move_set_cb = preview_base.make_dim_setter(_props, attr)
|
||||
gz.axis = Vector((1, 0, 0))
|
||||
gz.invert_delta = invert_delta
|
||||
gz.delta_scale = 1.0
|
||||
gz.prop_name = prop_name
|
||||
gz.gizmo_group = self
|
||||
gz.color = default_color
|
||||
gz.color_highlight = highlight_color
|
||||
gz.alpha = 1.0
|
||||
gz.use_draw_modal = True
|
||||
gz.use_draw_scale = False
|
||||
gz.text_offset_sign = 1
|
||||
gz.text_alignment = gizmo.TextAlignment.CENTER
|
||||
gz.show_start_arrow = False
|
||||
gz.show_end_arrow = True
|
||||
gz.show_extension_lines = False
|
||||
gz.text_formatter = None
|
||||
return gz
|
||||
|
||||
self.start_dim = setup_dimension("start_length", "Start Length")
|
||||
self.end_dim = setup_dimension("end_length", "End Length")
|
||||
self.radius_dim = setup_dimension("radius", "Radius")
|
||||
|
||||
from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
|
||||
|
||||
self.validate_icon = self.gizmos.new("VIEW3D_GT_validate")
|
||||
self.validate_icon.use_draw_scale = False
|
||||
self.validate_icon.color = BaseParametricGizmoGroup.COLOR_GREEN
|
||||
self.validate_icon.color_highlight = highlight_color
|
||||
self.validate_icon.target_set_operator("bim.finish_bend_preview")
|
||||
|
||||
self.cancel_icon = self.gizmos.new("VIEW3D_GT_cancel")
|
||||
self.cancel_icon.use_draw_scale = False
|
||||
self.cancel_icon.color = BaseParametricGizmoGroup.COLOR_RED
|
||||
self.cancel_icon.color_highlight = highlight_color
|
||||
self.cancel_icon.target_set_operator("bim.cancel_bend_preview")
|
||||
|
||||
def refresh(self, context):
|
||||
self._position_gizmos(context)
|
||||
|
||||
def draw_prepare(self, context):
|
||||
self._position_gizmos(context)
|
||||
|
||||
def _position_gizmos(self, context):
|
||||
"""Place gizmos at the bend intersection using the current scene
|
||||
props. Cancel stays visible on degenerate geometry so the user
|
||||
always has an exit; the other widgets hide when there's no defined
|
||||
tangent / arc to anchor them on."""
|
||||
start_obj, end_obj = _bend_preview_segments(context)
|
||||
if start_obj is None or end_obj is None:
|
||||
for gz in (self.start_dim, self.end_dim, self.radius_dim, self.validate_icon, self.cancel_icon):
|
||||
gz.hide = True
|
||||
return
|
||||
|
||||
props = context.scene.BIMPreviewProperties.bend
|
||||
preview = cached_compute_bend_preview_polylines(
|
||||
start_obj, end_obj, props.start_length, props.end_length, props.radius
|
||||
)
|
||||
if not preview["valid"]:
|
||||
for gz in (self.start_dim, self.end_dim, self.radius_dim, self.validate_icon):
|
||||
gz.hide = True
|
||||
self.cancel_icon.hide = False
|
||||
axes = preview.get("invalid_axes") or []
|
||||
if axes:
|
||||
intersection_point = axes[0][1]
|
||||
billboard_rot = gizmo.get_billboard_rotation(context)
|
||||
anchor = intersection_point + Vector((0, 0, self.ICON_Z_OFFSET))
|
||||
self.cancel_icon.matrix_basis = gizmo.billboarded_at(anchor, billboard_rot, scale=self.ICON_SCALE)
|
||||
return
|
||||
|
||||
for gz in (self.start_dim, self.end_dim, self.radius_dim, self.validate_icon, self.cancel_icon):
|
||||
gz.hide = False
|
||||
|
||||
leg_a_far, leg_a_end = preview["leg_a"]
|
||||
leg_b_far, leg_b_end = preview["leg_b"]
|
||||
toward_bend_a = (
|
||||
(leg_a_end - leg_a_far).normalized() if (leg_a_end - leg_a_far).length > 1e-6 else Vector((0, 0, 1))
|
||||
)
|
||||
toward_bend_b = (
|
||||
(leg_b_end - leg_b_far).normalized() if (leg_b_end - leg_b_far).length > 1e-6 else Vector((0, 0, 1))
|
||||
)
|
||||
leg_a_tangent = leg_a_end + toward_bend_a * props.start_length
|
||||
leg_b_tangent = leg_b_end + toward_bend_b * props.end_length
|
||||
|
||||
# axis is set in world space every frame so the drag projection
|
||||
# matches the visual regardless of either segment's matrix_world.
|
||||
self.start_dim.matrix_basis = _gizmo_x_matrix(leg_a_tangent, -toward_bend_a)
|
||||
self.start_dim.axis = -toward_bend_a
|
||||
self.start_dim.set_dimension_length(props.start_length)
|
||||
self.end_dim.matrix_basis = _gizmo_x_matrix(leg_b_tangent, -toward_bend_b)
|
||||
self.end_dim.axis = -toward_bend_b
|
||||
self.end_dim.set_dimension_length(props.end_length)
|
||||
|
||||
arc = preview["arc"]
|
||||
if len(arc) >= 3:
|
||||
mid = len(arc) // 2
|
||||
chord_mid = (arc[0] + arc[-1]) * 0.5
|
||||
toward_mid = arc[mid] - chord_mid
|
||||
if toward_mid.length > 1e-6:
|
||||
toward_mid = toward_mid.normalized()
|
||||
half_chord = (arc[-1] - arc[0]).length * 0.5
|
||||
center_dist = max(0.0, props.radius * props.radius - half_chord * half_chord) ** 0.5
|
||||
arc_center = chord_mid - toward_mid * center_dist
|
||||
radial_out = arc[mid] - arc_center
|
||||
if radial_out.length > 1e-6:
|
||||
radial_out.normalize()
|
||||
inward = -radial_out
|
||||
self.radius_dim.matrix_basis = _gizmo_x_matrix(arc[mid], inward)
|
||||
self.radius_dim.axis = inward
|
||||
self.radius_dim.set_dimension_length(props.radius)
|
||||
else:
|
||||
self.radius_dim.hide = True
|
||||
else:
|
||||
self.radius_dim.hide = True
|
||||
else:
|
||||
self.radius_dim.hide = True
|
||||
|
||||
billboard_rot = gizmo.get_billboard_rotation(context)
|
||||
anchor_base = arc[len(arc) // 2] if arc else (leg_a_end + leg_b_end) * 0.5
|
||||
anchor = anchor_base + Vector((0, 0, self.ICON_Z_OFFSET))
|
||||
offset_x = billboard_rot @ Vector((self.ICON_SPACING_X, 0.0, 0.0))
|
||||
self.validate_icon.matrix_basis = gizmo.billboarded_at(anchor, billboard_rot, scale=self.ICON_SCALE)
|
||||
self.cancel_icon.matrix_basis = gizmo.billboarded_at(anchor + offset_x, billboard_rot, scale=self.ICON_SCALE)
|
||||
@@ -15,6 +15,8 @@
|
||||
#
|
||||
# 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 modified with the assistance of an AI coding tool.
|
||||
|
||||
from collections.abc import Sequence
|
||||
from math import radians
|
||||
@@ -207,6 +209,21 @@ def _get_cached_world_draw_data(
|
||||
# handle each call), so they stay drawable across frames.
|
||||
_batch_cache: dict[tuple[int, str], tuple[int, "gpu.types.GPUBatch"]] = {}
|
||||
|
||||
# CAD hidden-line convention for the occluded back-pass: world-space dashes so
|
||||
# density stays coherent across zoom. Dash + gap = period; dash_width controls
|
||||
# the "on" portion.
|
||||
_DASH_PERIOD_METERS: float = 0.20
|
||||
_DASH_WIDTH_METERS: float = 0.10
|
||||
# Solid front pass is rendered wider than the dashed back pass so its halo
|
||||
# overpowers the dashed center on visible edges even when the WIRE-display
|
||||
# overlay biases the depth buffer at outline pixels.
|
||||
_DASH_LINE_WIDTH: float = 1.5
|
||||
_SOLID_LINE_WIDTH: float = 2.5
|
||||
# Per-iteration default line width used by every non-occlusion draw call in
|
||||
# this decorator's ``__call__``. Restored after each occlusion pair so the
|
||||
# next draw isn't silently inheriting the wider solid-pass override.
|
||||
_DEFAULT_LINE_WIDTH: float = 2.0
|
||||
|
||||
|
||||
def _get_cached_batch_or_none(cache_key: tuple[int, str]) -> "gpu.types.GPUBatch | None":
|
||||
uid = cache_key[0]
|
||||
@@ -1189,22 +1206,55 @@ class DecorationsHandler:
|
||||
shader.uniform_float("color", color)
|
||||
batch.draw(shader)
|
||||
|
||||
def _draw_lines_with_occlusion(self, verts, color, edges_indices, occluded_alpha: float = 0.25, cache_key=None):
|
||||
# One batch, two draws: front pass at full color, occluded pass at
|
||||
# `occluded_alpha`. Save/restore depth_test matches the pattern in
|
||||
# bim/module/structural/decorator.py so callers' state survives.
|
||||
batch = self._get_or_build_batch(self.line_shader, "LINES", verts, edges_indices, cache_key=cache_key)
|
||||
if batch is None:
|
||||
def _draw_lines_with_occlusion(self, verts, color, edges_indices, cache_key=None):
|
||||
# Two-pass CAD hidden-line convention. Both passes use POLYLINE_UNIFORM_COLOR.
|
||||
#
|
||||
# The solid front pass is rendered WIDER than the dashed back pass so it
|
||||
# produces a halo around the line center, beyond the depth-bias zone that
|
||||
# Blender's overlay engine writes when an opening is set to WIRE display.
|
||||
# Without the width difference, the wire bias makes the center-pixel
|
||||
# ``LESS_EQUAL`` comparison fail (line ends up slightly behind the biased
|
||||
# wire depth) so the solid pass would lose to the dashed back pass even
|
||||
# on visible edges. The halo gives the solid pass enough screen-space to
|
||||
# overpower the dashed pattern visually.
|
||||
#
|
||||
# Dashed renders first at the standard width so the solid overlay's wider
|
||||
# halo cleanly hides it on visible edges; on occluded edges the solid
|
||||
# ``LESS_EQUAL`` pass fails against the wall depth and the dashed remains.
|
||||
front_batch = self._get_or_build_batch(self.line_shader, "LINES", verts, edges_indices, cache_key=cache_key)
|
||||
if front_batch is None:
|
||||
return
|
||||
|
||||
dashed_cache_key = (cache_key[0], cache_key[1] + "_dashed") if cache_key is not None else None
|
||||
dash_batch = None
|
||||
if dashed_cache_key is not None:
|
||||
dash_batch = _get_cached_batch_or_none(dashed_cache_key)
|
||||
if dash_batch is None:
|
||||
dash_verts, dash_edges = tool.Blender.build_dashed_line_segments(
|
||||
verts, edges_indices, _DASH_PERIOD_METERS, _DASH_WIDTH_METERS
|
||||
)
|
||||
dash_batch = self._get_or_build_batch(self.line_shader, "LINES", dash_verts, dash_edges)
|
||||
if dash_batch is not None and dashed_cache_key is not None:
|
||||
_store_batch_in_cache(dashed_cache_key, dash_batch)
|
||||
|
||||
original_depth_test = gpu.state.depth_test_get()
|
||||
front_color = list(color)
|
||||
front_color[3] = 1.0
|
||||
self.line_shader.uniform_float("color", front_color)
|
||||
|
||||
if dash_batch is not None:
|
||||
self.line_shader.uniform_float("lineWidth", _DASH_LINE_WIDTH)
|
||||
gpu.state.depth_test_set("ALWAYS")
|
||||
dash_batch.draw(self.line_shader)
|
||||
|
||||
self.line_shader.uniform_float("lineWidth", _SOLID_LINE_WIDTH)
|
||||
gpu.state.depth_test_set("LESS_EQUAL")
|
||||
self.line_shader.uniform_float("color", color)
|
||||
batch.draw(self.line_shader)
|
||||
gpu.state.depth_test_set("GREATER")
|
||||
dimmed = list(color)
|
||||
dimmed[3] = occluded_alpha
|
||||
self.line_shader.uniform_float("color", dimmed)
|
||||
batch.draw(self.line_shader)
|
||||
front_batch.draw(self.line_shader)
|
||||
|
||||
# Restore the per-iteration default set at the top of __call__ so
|
||||
# subsequent draws (the HalfSpaceSolid arrow, future call-sites) are
|
||||
# not silently affected by the front-pass width override.
|
||||
self.line_shader.uniform_float("lineWidth", _DEFAULT_LINE_WIDTH)
|
||||
gpu.state.depth_test_set(original_depth_test)
|
||||
|
||||
def __call__(self, context):
|
||||
@@ -1240,7 +1290,7 @@ class DecorationsHandler:
|
||||
self.line_shader.bind() # required to be able to change uniforms of the shader
|
||||
# POLYLINE_UNIFORM_COLOR specific uniforms
|
||||
self.line_shader.uniform_float("viewportSize", (context.region.width, context.region.height))
|
||||
self.line_shader.uniform_float("lineWidth", 2.0)
|
||||
self.line_shader.uniform_float("lineWidth", _DEFAULT_LINE_WIDTH)
|
||||
|
||||
# general shader
|
||||
self.shader = gpu.shader.from_builtin("UNIFORM_COLOR")
|
||||
@@ -1278,9 +1328,7 @@ class DecorationsHandler:
|
||||
self.draw_batch("LINES", verts, selected_elements_color, selected_edges)
|
||||
self.draw_batch("POINTS", unselected_vertices, unselected_elements_color)
|
||||
self.draw_batch("POINTS", selected_vertices, selected_elements_color)
|
||||
obj.data.calc_loop_triangles()
|
||||
tris = [tuple(t.vertices) for t in obj.data.loop_triangles]
|
||||
self.draw_batch("TRIS", verts, transparent_color(special_elements_color), tris)
|
||||
tool.Blender.draw_bmesh_face_tris(bm, verts, transparent_color(special_elements_color), self.draw_batch)
|
||||
else:
|
||||
line_verts, verts, edges_indices, tris = _get_cached_world_draw_data(obj)
|
||||
color = selected_elements_color if obj in context.selected_objects else special_elements_color
|
||||
|
||||
@@ -163,6 +163,59 @@ def sync_uncommitted_moves(objects: list) -> None:
|
||||
tool.Geometry.commit_placement_if_moved(obj, apply_scale=False)
|
||||
|
||||
|
||||
def clear_preview_state(props: bpy.types.PropertyGroup) -> None:
|
||||
"""Reset a preview PropertyGroup to its idle state on commit / cancel.
|
||||
|
||||
Sets ``is_active`` to False and zeros every ``IntProperty`` whose name
|
||||
ends in ``_id`` (the entity-reference convention every preview follows).
|
||||
Other fields are left at their last value — defaults are re-applied on
|
||||
the next enable, so leaving them alone avoids a redundant write."""
|
||||
props.is_active = False
|
||||
for name, rna in props.bl_rna.properties.items():
|
||||
if name.endswith("_id") and rna.type == "INT":
|
||||
setattr(props, name, 0)
|
||||
|
||||
|
||||
# --- Standard Finish flow ----------------------------------------------------
|
||||
|
||||
|
||||
def commit_preview(
|
||||
operator: bpy.types.Operator,
|
||||
context: bpy.types.Context,
|
||||
attr: str,
|
||||
target_op_name: str,
|
||||
kwarg_names: tuple[str, ...],
|
||||
) -> set[str]:
|
||||
"""Standard Finish-Preview dispatch: validate context + active preview,
|
||||
read kwargs off the draft, call ``bpy.ops.bim.<target_op_name>(**kwargs)``,
|
||||
and clear the preview on success.
|
||||
|
||||
The dispatched operator's own ``self.report({"ERROR"})`` paths are promoted
|
||||
by ``bpy.ops`` to ``RuntimeError`` — catching it here surfaces the message
|
||||
to the user via ``operator.report`` rather than leaving Blender's operator
|
||||
state half-broken (which silently disables downstream gizmo polls).
|
||||
|
||||
Returns the dispatched operator's result set verbatim so callers can
|
||||
pass it straight back from their own ``execute``."""
|
||||
if context.screen is None:
|
||||
return {"CANCELLED"}
|
||||
props = get_preview_props(context, attr)
|
||||
if props is None or not props.is_active:
|
||||
return {"CANCELLED"}
|
||||
if tool.Ifc.get() is None:
|
||||
operator.report({"ERROR"}, "No IFC file loaded.")
|
||||
return {"CANCELLED"}
|
||||
kwargs = {name: getattr(props, name) for name in kwarg_names}
|
||||
try:
|
||||
result = getattr(bpy.ops.bim, target_op_name)(**kwargs)
|
||||
except RuntimeError as exc:
|
||||
operator.report({"ERROR"}, str(exc))
|
||||
return {"CANCELLED"}
|
||||
if "FINISHED" in result:
|
||||
clear_preview_state(props)
|
||||
return result
|
||||
|
||||
|
||||
# --- Esc dispatch ------------------------------------------------------------
|
||||
|
||||
PREVIEW_CANCEL_OPS: tuple[tuple[str, str], ...] = (
|
||||
|
||||
@@ -1157,7 +1157,8 @@ class DrawPolylineProfile(bpy.types.Operator, PolylineOperator, tool.Ifc.Operato
|
||||
DumbProfileJoiner().join_V(profile2["obj"], profile1["obj"])
|
||||
if connect_IfcFlowSegments:
|
||||
bpy.ops.bim.mep_connect_elements(
|
||||
obj1_name=profile1["obj"].name, obj2_name=profile2["obj"].name
|
||||
obj1_guid=tool.Ifc.get_entity(profile1["obj"]).GlobalId,
|
||||
obj2_guid=tool.Ifc.get_entity(profile2["obj"]).GlobalId,
|
||||
)
|
||||
|
||||
def modal(self, context, event):
|
||||
|
||||
@@ -242,6 +242,20 @@ def update_roof(self: "BIMRoofProperties", context: bpy.types.Context) -> None:
|
||||
_get_updater("roof", "update_roof_modifier_bmesh")(obj)
|
||||
|
||||
|
||||
def update_pipe_segment(self: "BIMPipeSegmentProperties", context: bpy.types.Context) -> None:
|
||||
"""Regenerate pipe-segment preview mesh from props during edit. Does NOT touch IFC."""
|
||||
obj = context.active_object
|
||||
if obj and self.is_editing:
|
||||
_get_updater("mep", "regenerate_pipe_segment_mesh_from_props")(obj)
|
||||
|
||||
|
||||
def update_duct_segment(self: "BIMDuctSegmentProperties", context: bpy.types.Context) -> None:
|
||||
"""Regenerate duct-segment preview mesh from props during edit. Does NOT touch IFC."""
|
||||
obj = context.active_object
|
||||
if obj and self.is_editing:
|
||||
_get_updater("mep", "regenerate_duct_segment_mesh_from_props")(obj)
|
||||
|
||||
|
||||
class BIMModelProperties(PropertyGroup):
|
||||
ifc_class: bpy.props.EnumProperty(items=get_ifc_class, name="Construction Class", update=update_ifc_class)
|
||||
relating_type_id: bpy.props.EnumProperty(
|
||||
@@ -1924,6 +1938,155 @@ class BIMExternalParametricGeometryProperties(bpy.types.PropertyGroup):
|
||||
sverchok_nodes: Union[sverchok.node_tree.SverchCustomTree, None]
|
||||
|
||||
|
||||
class BIMPipeSegmentProperties(PropertyGroup):
|
||||
"""Transient draft state for parametric pipe-segment gizmo editing."""
|
||||
|
||||
is_editing: bpy.props.BoolProperty(
|
||||
default=False,
|
||||
description="True while pipe-segment parametric edit mode is active.",
|
||||
)
|
||||
mesh_dirty: bpy.props.BoolProperty(
|
||||
default=False,
|
||||
options={"HIDDEN", "SKIP_SAVE"},
|
||||
description=(
|
||||
"True while the visible mesh is the preview shape; cleared once the "
|
||||
"real IFC-derived geometry is restored (on commit or cancel)."
|
||||
),
|
||||
)
|
||||
length: bpy.props.FloatProperty(
|
||||
name="Length",
|
||||
default=1.0,
|
||||
min=0.01,
|
||||
subtype="DISTANCE",
|
||||
update=update_pipe_segment,
|
||||
description="Pipe-segment extrusion length (preview value; committed on finish).",
|
||||
)
|
||||
snap_length: bpy.props.FloatProperty(
|
||||
description="Snapshot of length at edit-enable; commit skips no-op writes.",
|
||||
)
|
||||
snap_object_scale_z: bpy.props.FloatProperty(
|
||||
default=1.0,
|
||||
description=(
|
||||
"Snapshot of obj.scale.z at edit-enable. Cancel / no-op-finish restore "
|
||||
"this exact value so a user's non-identity pre-edit scale isn't silently "
|
||||
"zeroed by the scale-based preview."
|
||||
),
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
is_editing: bool
|
||||
mesh_dirty: bool
|
||||
length: float
|
||||
snap_length: float
|
||||
snap_object_scale_z: float
|
||||
|
||||
|
||||
class BIMDuctSegmentProperties(PropertyGroup):
|
||||
"""Transient draft state for parametric duct-segment gizmo editing."""
|
||||
|
||||
is_editing: bpy.props.BoolProperty(
|
||||
default=False,
|
||||
description="True while duct-segment parametric edit mode is active.",
|
||||
)
|
||||
mesh_dirty: bpy.props.BoolProperty(
|
||||
default=False,
|
||||
options={"HIDDEN", "SKIP_SAVE"},
|
||||
description=(
|
||||
"True while the visible mesh is the preview shape; cleared once the "
|
||||
"real IFC-derived geometry is restored (on commit or cancel)."
|
||||
),
|
||||
)
|
||||
length: bpy.props.FloatProperty(
|
||||
name="Length",
|
||||
default=1.0,
|
||||
min=0.01,
|
||||
subtype="DISTANCE",
|
||||
update=update_duct_segment,
|
||||
description="Duct-segment extrusion length (preview value; committed on finish).",
|
||||
)
|
||||
snap_length: bpy.props.FloatProperty(
|
||||
description="Snapshot of length at edit-enable; commit skips no-op writes.",
|
||||
)
|
||||
snap_object_scale_z: bpy.props.FloatProperty(
|
||||
default=1.0,
|
||||
description=(
|
||||
"Snapshot of obj.scale.z at edit-enable. Cancel / no-op-finish restore "
|
||||
"this exact value so a user's non-identity pre-edit scale isn't silently "
|
||||
"zeroed by the scale-based preview."
|
||||
),
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
is_editing: bool
|
||||
mesh_dirty: bool
|
||||
length: float
|
||||
snap_length: float
|
||||
snap_object_scale_z: float
|
||||
|
||||
|
||||
class BIMBendPreviewProperties(PropertyGroup):
|
||||
"""Scene-level pending state for the bend-creation preview flow.
|
||||
|
||||
Scene-level (not per-object) because the bend involves two segments by
|
||||
IFC id — neither alone owns the draft."""
|
||||
|
||||
is_active: bpy.props.BoolProperty(
|
||||
default=False,
|
||||
options={"SKIP_SAVE"},
|
||||
description="True while the bend-creation preview flow is active.",
|
||||
)
|
||||
start_segment_id: bpy.props.IntProperty(
|
||||
default=0,
|
||||
options={"SKIP_SAVE"},
|
||||
description="IFC element id of the start (active) segment.",
|
||||
)
|
||||
end_segment_id: bpy.props.IntProperty(
|
||||
default=0,
|
||||
options={"SKIP_SAVE"},
|
||||
description="IFC element id of the end (other selected) segment.",
|
||||
)
|
||||
start_length: bpy.props.FloatProperty(
|
||||
name="Start Length",
|
||||
default=0.1,
|
||||
min=0.001,
|
||||
subtype="DISTANCE",
|
||||
description="Length of the bend fitting's tangent leg on the start (active) segment side",
|
||||
)
|
||||
end_length: bpy.props.FloatProperty(
|
||||
name="End Length",
|
||||
default=0.1,
|
||||
min=0.001,
|
||||
subtype="DISTANCE",
|
||||
description="Length of the bend fitting's tangent leg on the end (other) segment side",
|
||||
)
|
||||
radius: bpy.props.FloatProperty(
|
||||
name="Radius",
|
||||
default=0.2,
|
||||
min=0.001,
|
||||
subtype="DISTANCE",
|
||||
description="Inner radius of the bend curve",
|
||||
)
|
||||
editing_bend_id: bpy.props.IntProperty(
|
||||
default=0,
|
||||
options={"SKIP_SAVE"},
|
||||
description=(
|
||||
"IFC element id of an existing bend fitting being re-edited "
|
||||
"(non-zero only on the pen-icon re-edit flow). The create "
|
||||
"operator deletes this bend + its port connections before "
|
||||
"recreating with the new parameters."
|
||||
),
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
is_active: bool
|
||||
start_segment_id: int
|
||||
end_segment_id: int
|
||||
start_length: float
|
||||
end_length: float
|
||||
radius: float
|
||||
editing_bend_id: int
|
||||
|
||||
|
||||
class BIMWallFilletPreviewProperties(PropertyGroup):
|
||||
"""Scene-level pending state for the wall-fillet preview flow.
|
||||
|
||||
@@ -1980,7 +2143,29 @@ class BIMWallFilletPreviewProperties(PropertyGroup):
|
||||
class BIMPreviewProperties(PropertyGroup):
|
||||
"""Umbrella for parametric-edit preview drafts attached to ``Scene``."""
|
||||
|
||||
bend: bpy.props.PointerProperty(type=BIMBendPreviewProperties)
|
||||
wall_fillet: bpy.props.PointerProperty(type=BIMWallFilletPreviewProperties)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
bend: BIMBendPreviewProperties
|
||||
wall_fillet: BIMWallFilletPreviewProperties
|
||||
|
||||
|
||||
class BIMParametricEditDialogPrefs(PropertyGroup):
|
||||
"""Session-scoped flag for the parametric-edit pen-icon dispatcher.
|
||||
|
||||
Attached to ``WindowManager`` so the state lives for one Blender session
|
||||
and resets on restart — the right scope for "don't show this again for
|
||||
this session" toggles."""
|
||||
|
||||
suppress_shared_rep_warning: bpy.props.BoolProperty(
|
||||
name="Suppress shared-representation warning",
|
||||
description=(
|
||||
"When true, the pen-icon dispatcher skips the shared-geometry "
|
||||
"confirmation dialog. Resets on Blender restart."
|
||||
),
|
||||
default=False,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
suppress_shared_rep_warning: bool
|
||||
|
||||
@@ -51,6 +51,7 @@ from mathutils import Matrix, Vector
|
||||
import bonsai.core.geometry
|
||||
import bonsai.core.model as core
|
||||
import bonsai.core.root
|
||||
import bonsai.core.spatial
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
from bonsai.bim.module.drawing import gizmos as gizmo
|
||||
@@ -62,7 +63,6 @@ from bonsai.bim.module.model.decorator import (
|
||||
PolylineDecorator,
|
||||
ProductDecorator,
|
||||
_fill_quads_alpha,
|
||||
_stroke_lines_alpha,
|
||||
bbox_world_edges,
|
||||
draw_polyline_segments,
|
||||
)
|
||||
@@ -76,6 +76,24 @@ _FILLET_DEFAULT_RADIUS_M = 0.5 # Fallback when the leg-fraction heuristic canno
|
||||
_FILLET_DEFAULT_LEG_FRACTION = 0.25 # Quarter of the shorter available leg — visible without overrunning either wall.
|
||||
_FILLET_MIN_RADIUS_M = 0.001 # Lower bound — anything smaller renders as a single pixel at common viewport scales.
|
||||
|
||||
_ARRAY_CHILD_POLL_MESSAGE = "Selection includes an array child; operate on the array parent instead."
|
||||
|
||||
|
||||
def _poll_reject_array_children(operator_cls) -> bool:
|
||||
"""Shared operator-poll guard: set the array-child poll message on
|
||||
``operator_cls`` and return ``True`` when the selection includes a Bonsai
|
||||
array child, so the caller can early-return ``False`` from its ``poll``.
|
||||
|
||||
Topology mutations against an array child are wiped by the next
|
||||
``regenerate_array`` and would orphan the child's GUID in the parent's
|
||||
``BBIM_Array.Data``. Gizmo groups have their own filter via
|
||||
``_wall_topology_gizmo_poll_gate``; this helper exists so operator
|
||||
classes share the same rejection in one line."""
|
||||
if tool.Blender.Modifier.any_selected_is_array_child():
|
||||
operator_cls.poll_message_set(_ARRAY_CHILD_POLL_MESSAGE)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _wall_gizmo_poll_gate(context: bpy.types.Context) -> bool:
|
||||
"""Common pre-flight gate every wall gizmo group's ``poll`` runs first:
|
||||
@@ -90,6 +108,21 @@ def _wall_gizmo_poll_gate(context: bpy.types.Context) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _wall_topology_gizmo_poll_gate(context: bpy.types.Context) -> bool:
|
||||
"""Tighter gate for wall topology gizmos (merge / join / extend / unjoin
|
||||
/ fillet): base ``_wall_gizmo_poll_gate`` plus an array-child filter.
|
||||
Array children are managed replicas — any topology mutation is wiped by
|
||||
the next ``regenerate_array``, and ``merge`` would orphan a GUID listed
|
||||
in the parent's ``BBIM_Array.Data``. Host-opening gizmos (add / toggle)
|
||||
deliberately stay on the base gate so openings remain authorable on
|
||||
children, which the array regen pipeline preserves."""
|
||||
if not _wall_gizmo_poll_gate(context):
|
||||
return False
|
||||
if tool.Blender.Modifier.any_selected_is_array_child():
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _wall_has_openings(gz_group: bpy.types.GizmoGroup) -> bool:
|
||||
"""``visible_when`` predicate for the toggle_openings idle slot. Returns
|
||||
True iff the active object's IFC element exposes a non-empty HasOpenings
|
||||
@@ -243,6 +276,8 @@ class UnjoinWalls(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Oper
|
||||
if not tool.Model.has_selected_ifc_objects():
|
||||
cls.poll_message_set("No IFC objects selected.")
|
||||
return False
|
||||
if _poll_reject_array_children(cls):
|
||||
return False
|
||||
return True
|
||||
|
||||
def _perform(self, context):
|
||||
@@ -269,6 +304,8 @@ class UnjoinWallPathConnection(_CommitWallDraftsFirstMixin, bpy.types.Operator,
|
||||
if not tool.Model.has_selected_ifc_objects():
|
||||
cls.poll_message_set("No IFC objects selected.")
|
||||
return False
|
||||
if _poll_reject_array_children(cls):
|
||||
return False
|
||||
return True
|
||||
|
||||
def _perform(self, context):
|
||||
@@ -306,20 +343,9 @@ class UnjoinWallPathConnection(_CommitWallDraftsFirstMixin, bpy.types.Operator,
|
||||
for rel in rels:
|
||||
bonsai.core.geometry.remove_connection(tool.Geometry, connection=rel)
|
||||
# Recreate body+axis on both walls so the mesh state matches the IFC mutation
|
||||
# and stale miter cuts are dropped. If recreate_wall raises, the rel removal
|
||||
# has already been committed to the operator's IFC transaction — surface the
|
||||
# partial-state diagnostic, then re-raise so the exception lands in Blender's
|
||||
# normal operator error flow.
|
||||
try:
|
||||
tool.Model.recreate_wall(elem_active, active)
|
||||
tool.Model.recreate_wall(elem_other, other)
|
||||
except Exception:
|
||||
self.report(
|
||||
{"ERROR"},
|
||||
"Mesh rebuild failed after unjoin. IFC connection was removed but wall "
|
||||
"meshes may be stale — press Ctrl+Z to undo and restore the previous state.",
|
||||
)
|
||||
raise
|
||||
# and stale miter cuts are dropped.
|
||||
tool.Model.recreate_wall(elem_active, active)
|
||||
tool.Model.recreate_wall(elem_other, other)
|
||||
_resync_walls_after_mutation([active, other])
|
||||
|
||||
|
||||
@@ -378,6 +404,12 @@ class ExtendWallsToWall(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.If
|
||||
bl_description = "Extend and trim selected walls to another wall"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
if _poll_reject_array_children(cls):
|
||||
return False
|
||||
return True
|
||||
|
||||
def _perform(self, context):
|
||||
target_obj = None
|
||||
objs = []
|
||||
@@ -604,6 +636,8 @@ class SplitWall(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operat
|
||||
if not tool.Model.has_selected_ifc_objects():
|
||||
cls.poll_message_set("No IFC objects selected.")
|
||||
return False
|
||||
if _poll_reject_array_children(cls):
|
||||
return False
|
||||
return True
|
||||
|
||||
def _perform(self, context):
|
||||
@@ -632,6 +666,8 @@ class MergeWall(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operat
|
||||
if len(mesh_objects) != 2:
|
||||
cls.poll_message_set("Please select exactly two mesh IFC objects.")
|
||||
return False
|
||||
if _poll_reject_array_children(cls):
|
||||
return False
|
||||
return True
|
||||
|
||||
def _perform(self, context):
|
||||
@@ -2103,6 +2139,10 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
- ``extend_z_gizmo`` — at the wall-local X of the cursor, projected to the
|
||||
wall top (Z=height in wall-local). Clicking extends the wall's height to
|
||||
the cursor's Z.
|
||||
- ``add_perpendicular_wall_gizmo`` — visible only when the cursor is
|
||||
off-axis by more than ``CURSOR_STACK_OFFSET``. Sits at the cursor's
|
||||
XY (X clamped to the wall's X-range) on the wall-local floor plane.
|
||||
Clicking spawns a perpendicular branch wall; shift+click forms a corner.
|
||||
|
||||
The baseline-state triplet (exterior/center/interior) and the rotate-90
|
||||
icon live in ``feature_slots`` — the base class handles creation and
|
||||
@@ -2128,6 +2168,12 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
"bim.extend_wall_height_to_cursor",
|
||||
highlight_color,
|
||||
)
|
||||
self.add_perpendicular_wall_gizmo = self._setup_icon_gizmo(
|
||||
"VIEW3D_GT_extend",
|
||||
default_color,
|
||||
"bim.add_perpendicular_wall",
|
||||
highlight_color,
|
||||
)
|
||||
if context.region is not None:
|
||||
type(self)._active_instances[context.region.as_pointer()] = weakref.ref(self)
|
||||
|
||||
@@ -2140,6 +2186,12 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
# at default scale, leaving a small visual gap between consecutive icons.
|
||||
CURSOR_STACK_OFFSET = 0.3
|
||||
|
||||
def _stack_offset(self, stack_index: int, screen_up: Vector, clearance: Vector) -> Vector:
|
||||
"""World-space offset for the ``stack_index``-th icon in a cursor
|
||||
row: ``clearance`` (top-down only) plus a screen-up step per slot.
|
||||
Single source of truth for the cursor-row stacking discipline."""
|
||||
return clearance + screen_up * (stack_index * self.CURSOR_STACK_OFFSET)
|
||||
|
||||
def _update_cursor_gizmos(self, context: bpy.types.Context, mw: Matrix, props: "BIMWallProperties") -> None:
|
||||
"""Position the cursor-anchored icons (extend-X / extend-Z / split) on the wall
|
||||
axis at the cursor's projected X.
|
||||
@@ -2165,12 +2217,30 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
the floor anchor."""
|
||||
if not hasattr(self, "split_gizmo"):
|
||||
return
|
||||
all_gizmos = (self.extend_x_gizmo, self.extend_z_gizmo, self.split_gizmo)
|
||||
all_gizmos = (
|
||||
self.extend_x_gizmo,
|
||||
self.extend_z_gizmo,
|
||||
self.split_gizmo,
|
||||
self.add_perpendicular_wall_gizmo,
|
||||
)
|
||||
cursor_world = context.scene.cursor.location
|
||||
cursor_local = mw.inverted() @ cursor_world
|
||||
in_range = props.anchor_x < cursor_local.x < props.anchor_x + props.length
|
||||
# ``props.anchor_x`` / ``props.length`` mirror IFC and are only refreshed
|
||||
# when an operator calls ``_maybe_resync_wall_props_from_ifc``. Reading
|
||||
# the live extent from the mesh bbox makes the gizmo position robust
|
||||
# against any operator path that skips that re-sync — the mesh is
|
||||
# always rebuilt by ``recreate_wall`` to match the current IFC body.
|
||||
bbox_x = [v[0] for v in context.active_object.bound_box] if context.active_object else None
|
||||
if bbox_x:
|
||||
wall_anchor_x = min(bbox_x)
|
||||
wall_length = max(bbox_x) - wall_anchor_x
|
||||
else:
|
||||
wall_anchor_x = props.anchor_x
|
||||
wall_length = props.length
|
||||
in_range = wall_anchor_x < cursor_local.x < wall_anchor_x + wall_length
|
||||
billboard_rot = self._frame_billboard_rot
|
||||
top_down = tool.Blender.is_view_top_down(context)
|
||||
perp_params = _perpendicular_wall_params(cursor_local.x, cursor_local.y, wall_anchor_x, wall_length)
|
||||
|
||||
# Candidates ordered by priority (lowest first). Each is (gizmo, local_z).
|
||||
candidates: list[tuple[bpy.types.Gizmo, float]] = [(self.extend_x_gizmo, 0.0)]
|
||||
@@ -2195,25 +2265,50 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
|
||||
for gz in all_gizmos:
|
||||
gz.hide = True
|
||||
screen_up = tool.Blender.get_screen_up_world(context)
|
||||
clearance = gizmo.top_down_clearance(context, billboard_rot)
|
||||
if top_down:
|
||||
# Swap world-Z stacking for screen-up stacking so each icon stays
|
||||
# individually clickable when the camera projects world Z to zero.
|
||||
# The shared ``top_down_clearance`` lifts the whole stack off the
|
||||
# cursor so its small crosshair stays visible for precise pointing.
|
||||
screen_up = tool.Blender.get_screen_up_world(context)
|
||||
base_world = mw @ Vector((cursor_local.x, 0.0, 0.0))
|
||||
clearance = gizmo.top_down_clearance(context, billboard_rot)
|
||||
for index, (gz, _local_z) in enumerate(resolved):
|
||||
gz.hide = self.is_gizmo_hidden_by_modal(gz)
|
||||
world_pos = base_world + clearance + screen_up * (index * self.CURSOR_STACK_OFFSET)
|
||||
world_pos = base_world + self._stack_offset(index, screen_up, clearance)
|
||||
gz.matrix_basis = gizmo.billboarded_at(world_pos, billboard_rot)
|
||||
_apply_wall_extend_flips(gz, self, world_pos, mw, cursor_local, props, billboard_rot)
|
||||
return
|
||||
for gz, local_z in resolved:
|
||||
else:
|
||||
# World-Z stacking carries each icon's semantic Z (extend-X at
|
||||
# floor, extend-Z at cursor Z, split at wall top). At shallow
|
||||
# viewing angles a 0.3 m gap can still project to near-zero
|
||||
# screen separation, so add a screen-up offset per stack slot
|
||||
# — the world-Z position still drives the icon's meaning, the
|
||||
# screen-up term is just visual insurance.
|
||||
no_clearance = Vector((0.0, 0.0, 0.0))
|
||||
for index, (gz, local_z) in enumerate(resolved):
|
||||
gz.hide = self.is_gizmo_hidden_by_modal(gz)
|
||||
world_pos = mw @ Vector((cursor_local.x, 0.0, local_z)) + self._stack_offset(
|
||||
index, screen_up, no_clearance
|
||||
)
|
||||
gz.matrix_basis = gizmo.billboarded_at(world_pos, billboard_rot)
|
||||
_apply_wall_extend_flips(gz, self, world_pos, mw, cursor_local, props, billboard_rot)
|
||||
|
||||
if perp_params is not None:
|
||||
# Stack the perpendicular gizmo one slot above the on-axis row
|
||||
# along screen-up so it stays independently clickable when the
|
||||
# cursor sits just past the dead zone. The arrow's in-plane
|
||||
# rotation points its +X from the wall projection toward the
|
||||
# cursor as a "new wall sprouts this way" cue.
|
||||
clamped_x, _length, side_sign = perp_params
|
||||
gz = self.add_perpendicular_wall_gizmo
|
||||
gz.hide = self.is_gizmo_hidden_by_modal(gz)
|
||||
world_pos = mw @ Vector((cursor_local.x, 0.0, local_z))
|
||||
gz.matrix_basis = gizmo.billboarded_at(world_pos, billboard_rot)
|
||||
_apply_wall_extend_flips(gz, self, world_pos, mw, cursor_local, props, billboard_rot)
|
||||
perp_base = mw @ Vector((clamped_x, cursor_local.y, 0.0))
|
||||
perp_world = perp_base + self._stack_offset(len(resolved), screen_up, clearance)
|
||||
perp_world_dir = (mw.to_3x3().col[1] * side_sign).normalized()
|
||||
screen_dir = billboard_rot.transposed() @ perp_world_dir
|
||||
angle = math.atan2(screen_dir.y, screen_dir.x)
|
||||
gz.matrix_basis = gizmo.billboarded_at(perp_world, billboard_rot) @ Matrix.Rotation(angle, 4, "Z")
|
||||
|
||||
# Map ``props.desired_offset_baseline`` (storage form) to the slot variant
|
||||
# name. Centralised here so the variant strings stay aligned with the slot
|
||||
@@ -2286,6 +2381,27 @@ def _commit_active_wall_edit_if_any(context: bpy.types.Context) -> bpy.types.Obj
|
||||
return obj
|
||||
|
||||
|
||||
def _perpendicular_wall_params(
|
||||
cursor_local_x: float,
|
||||
cursor_local_y: float,
|
||||
anchor_x: float,
|
||||
length: float,
|
||||
) -> tuple[float, float, float] | None:
|
||||
"""Geometry of a perpendicular branch wall sprouting from the cursor's
|
||||
projection on the source wall axis.
|
||||
|
||||
Returns ``(clamped_x, perpendicular_length, side_sign)`` — the projection
|
||||
on the wall axis (clamped to ``[anchor_x, anchor_x + length]``), the
|
||||
branch wall length, and the side (+1 / -1) the branch sits on. Returns
|
||||
``None`` when the cursor sits within ``CURSOR_STACK_OFFSET`` of the
|
||||
source wall axis (the on-wall dead zone)."""
|
||||
if abs(cursor_local_y) <= GizmoWallEdition.CURSOR_STACK_OFFSET:
|
||||
return None
|
||||
clamped_x = max(anchor_x, min(anchor_x + length, cursor_local_x))
|
||||
side_sign = 1.0 if cursor_local_y > 0 else -1.0
|
||||
return clamped_x, abs(cursor_local_y), side_sign
|
||||
|
||||
|
||||
def _commit_pending_wall_edits_for_selection(context: bpy.types.Context) -> None: # noqa: ARG001
|
||||
"""Thin wall-scoped alias for ``tool.Parametric.commit_pending_edits_for_selection``.
|
||||
|
||||
@@ -2378,6 +2494,113 @@ class ExtendWallHeightToCursor(bpy.types.Operator, tool.Ifc.Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class AddPerpendicularWall(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.add_perpendicular_wall"
|
||||
bl_label = "Add Perpendicular Wall at Cursor"
|
||||
bl_description = (
|
||||
"Create a new wall perpendicular to the active wall, from the cursor's "
|
||||
"orthogonal projection on the wall axis toward the cursor. "
|
||||
"Shift+Click for corner junction: the source wall is trimmed at the "
|
||||
"projection, keeping its longer portion."
|
||||
)
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
use_corner_junction: bpy.props.BoolProperty(default=False)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
if not tool.Model.has_selected_ifc_objects():
|
||||
cls.poll_message_set("No IFC objects selected.")
|
||||
return False
|
||||
return True
|
||||
|
||||
def invoke(self, context, event):
|
||||
self.use_corner_junction = bool(event.shift)
|
||||
return self.execute(context)
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
source_obj = _commit_active_wall_edit_if_any(context)
|
||||
if source_obj is None:
|
||||
return {"CANCELLED"}
|
||||
source_element = tool.Ifc.get_entity(source_obj)
|
||||
if source_element is None:
|
||||
self.report({"WARNING"}, "Active object is not an IFC element.")
|
||||
return {"CANCELLED"}
|
||||
source_type = ifcopenshell.util.element.get_type(source_element)
|
||||
if source_type is None:
|
||||
self.report({"WARNING"}, "Active wall has no IfcWallType; cannot derive branch wall.")
|
||||
return {"CANCELLED"}
|
||||
props = tool.Model.get_wall_props(source_obj)
|
||||
cursor_local = source_obj.matrix_world.inverted() @ context.scene.cursor.location
|
||||
params = _perpendicular_wall_params(cursor_local.x, cursor_local.y, props.anchor_x, props.length)
|
||||
if params is None:
|
||||
self.report({"INFO"}, "Cursor is on the wall axis; nothing to do.")
|
||||
return {"CANCELLED"}
|
||||
clamped_x, perpendicular_length, side_sign = params
|
||||
start_world = source_obj.matrix_world @ Vector((clamped_x, 0.0, 0.0))
|
||||
source_z_rotation = source_obj.matrix_world.to_euler().z
|
||||
new_z_rotation = source_z_rotation + side_sign * (pi / 2)
|
||||
|
||||
# Shift+click L-corners the new wall against an endpoint of the
|
||||
# source wall: the source is trimmed at the projection, keeping
|
||||
# its longer of the two portions.
|
||||
if self.use_corner_junction:
|
||||
DumbWallJoiner().extend(source_obj, start_world)
|
||||
|
||||
source_layers = tool.Model.get_material_layer_parameters(source_element)
|
||||
|
||||
generator = DumbWallGenerator(source_type)
|
||||
generator.file = tool.Ifc.get()
|
||||
generator.layers = tool.Model.get_material_layer_parameters(source_type)
|
||||
if not generator.layers["thickness"]:
|
||||
self.report({"WARNING"}, "Wall type has no layer thickness; cannot create branch wall.")
|
||||
return {"CANCELLED"}
|
||||
generator.body_context = ifcopenshell.util.representation.get_context(
|
||||
tool.Ifc.get(), "Model", "Body", "MODEL_VIEW"
|
||||
)
|
||||
generator.axis_context = ifcopenshell.util.representation.get_context(
|
||||
tool.Ifc.get(), "Plan", "Axis", "GRAPH_VIEW"
|
||||
)
|
||||
generator.container = None
|
||||
generator.container_obj = None
|
||||
generator.width = generator.layers["thickness"]
|
||||
generator.height = props.height
|
||||
generator.length = perpendicular_length
|
||||
generator.rotation = new_z_rotation
|
||||
generator.location = start_world
|
||||
generator.x_angle = 0.0
|
||||
new_obj = generator.create_wall()
|
||||
new_element = tool.Ifc.get_entity(new_obj)
|
||||
|
||||
# Branch wall inherits the source wall's centerline / offset baseline
|
||||
# so the new axis lines up with the source's authored alignment rather
|
||||
# than the type's default.
|
||||
source_baseline = core.baseline_from_offset(source_layers["offset"], source_layers["thickness"])
|
||||
tool.Model.offset_wall(new_obj, source_baseline)
|
||||
|
||||
ifcopenshell.api.geometry.connect_wall(
|
||||
tool.Ifc.get(),
|
||||
wall1=new_element,
|
||||
wall2=source_element,
|
||||
is_atpath=not self.use_corner_junction,
|
||||
)
|
||||
|
||||
source_container = ifcopenshell.util.element.get_container(source_element)
|
||||
if source_container is not None:
|
||||
bonsai.core.spatial.assign_container(
|
||||
tool.Ifc, tool.Collector, tool.Spatial, container=source_container, objs=[new_obj]
|
||||
)
|
||||
|
||||
tool.Model.recreate_wall(source_element, source_obj)
|
||||
tool.Model.recreate_wall(new_element, new_obj)
|
||||
|
||||
tool.Blender.deselect_object(source_obj, ensure_active_object=False)
|
||||
tool.Blender.set_active_object(new_obj)
|
||||
|
||||
_resync_walls_after_mutation([source_obj, new_obj])
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class RotateWall90(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.rotate_wall_90"
|
||||
bl_label = "Rotate Wall 90°"
|
||||
@@ -2421,6 +2644,8 @@ class _WallGeomCachedBillboardingMixin(gizmo.BillboardingGizmoGroupMixin):
|
||||
|
||||
def refresh(self, context: bpy.types.Context) -> None:
|
||||
self._wall_geom_cache = None
|
||||
self._wall_connections_cache = None
|
||||
self._wall_pair_predicate_cache = None
|
||||
self.position_gizmos(context)
|
||||
|
||||
|
||||
@@ -2451,6 +2676,44 @@ def _get_wall_geom_cached(group: "bpy.types.GizmoGroup", obj: bpy.types.Object)
|
||||
return cache[key]
|
||||
|
||||
|
||||
def _get_wall_connections_cached(
|
||||
group: "bpy.types.GizmoGroup",
|
||||
elem: ifcopenshell.entity_instance,
|
||||
) -> "list[tuple[ifcopenshell.entity_instance, str, str]]":
|
||||
"""Per-gizmo-group memoised ``_iter_path_connections``. Same generation-key
|
||||
invalidation as ``_get_wall_geom_cached`` so an IFC mutation drops the cached
|
||||
list on the next frame; ``refresh()`` drops it on selection change."""
|
||||
current_gen = tool.Parametric.get_geom_generation()
|
||||
cache_gen = getattr(group, "_wall_connections_cache_gen", None)
|
||||
cache = getattr(group, "_wall_connections_cache", None)
|
||||
if cache is None or cache_gen != current_gen:
|
||||
cache = {}
|
||||
group._wall_connections_cache = cache
|
||||
group._wall_connections_cache_gen = current_gen
|
||||
key = elem.GlobalId
|
||||
if key not in cache:
|
||||
cache[key] = _iter_path_connections(elem)
|
||||
return cache[key]
|
||||
|
||||
|
||||
def _get_wall_pair_predicate_cached(group: "bpy.types.GizmoGroup", key: tuple, compute):
|
||||
"""Per-gizmo-group memo for wall-pair predicates (joined / collinear /
|
||||
intersection). Caller supplies the cache key (typically pair GlobalIds +
|
||||
relevant inputs like matrix_world tuples + thresholds) and a zero-arg
|
||||
callable that computes the value on miss. Same generation invalidation as
|
||||
the geom cache; ``refresh()`` drops it on selection change."""
|
||||
current_gen = tool.Parametric.get_geom_generation()
|
||||
cache_gen = getattr(group, "_wall_pair_predicate_cache_gen", None)
|
||||
cache = getattr(group, "_wall_pair_predicate_cache", None)
|
||||
if cache is None or cache_gen != current_gen:
|
||||
cache = {}
|
||||
group._wall_pair_predicate_cache = cache
|
||||
group._wall_pair_predicate_cache_gen = current_gen
|
||||
if key not in cache:
|
||||
cache[key] = compute()
|
||||
return cache[key]
|
||||
|
||||
|
||||
def _wall_camera_facing_icon_y(context: bpy.types.Context, mw: Matrix, geom: dict) -> float:
|
||||
"""Wall-local Y for an icon that should sit just outside the camera-facing face.
|
||||
Centralised so the billboarding wall gizmos (add-opening, extend-vertically, …)
|
||||
@@ -2950,34 +3213,13 @@ class FinishWallFilletPreview(bpy.types.Operator):
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
if context.screen is None:
|
||||
return {"CANCELLED"}
|
||||
props = preview_base.get_preview_props(context, "wall_fillet")
|
||||
if props is None or not props.is_active:
|
||||
return {"CANCELLED"}
|
||||
if tool.Ifc.get() is None:
|
||||
self.report({"ERROR"}, "No IFC file loaded.")
|
||||
return {"CANCELLED"}
|
||||
# bpy.ops promotes ``self.report({"ERROR"}) + return CANCELLED`` from
|
||||
# the dispatched operator to RuntimeError. Catch it so this operator
|
||||
# returns cleanly instead of leaving Blender's operator state
|
||||
# half-broken (which would silently disable downstream gizmo polls).
|
||||
try:
|
||||
result = bpy.ops.bim.create_wall_fillet(
|
||||
wall_a_id=props.wall_a_id,
|
||||
wall_b_id=props.wall_b_id,
|
||||
radius=props.radius,
|
||||
editing_corner_id=props.editing_corner_id,
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
self.report({"ERROR"}, str(exc))
|
||||
return {"CANCELLED"}
|
||||
if "FINISHED" in result:
|
||||
props.is_active = False
|
||||
props.wall_a_id = 0
|
||||
props.wall_b_id = 0
|
||||
props.editing_corner_id = 0
|
||||
return result
|
||||
return preview_base.commit_preview(
|
||||
self,
|
||||
context,
|
||||
"wall_fillet",
|
||||
"create_wall_fillet",
|
||||
("wall_a_id", "wall_b_id", "radius", "editing_corner_id"),
|
||||
)
|
||||
|
||||
|
||||
class CancelWallFilletPreview(bpy.types.Operator):
|
||||
@@ -2994,10 +3236,7 @@ class CancelWallFilletPreview(bpy.types.Operator):
|
||||
props = preview_base.get_preview_props(context, "wall_fillet")
|
||||
if props is None or not props.is_active:
|
||||
return {"CANCELLED"}
|
||||
props.is_active = False
|
||||
props.wall_a_id = 0
|
||||
props.wall_b_id = 0
|
||||
props.editing_corner_id = 0
|
||||
preview_base.clear_preview_state(props)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
@@ -3337,7 +3576,7 @@ class GizmoWallExtendVertically(bpy.types.GizmoGroup, _WallGeomCachedBillboardin
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: bpy.types.Context) -> bool:
|
||||
if not _wall_gizmo_poll_gate(context):
|
||||
if not _wall_topology_gizmo_poll_gate(context):
|
||||
return False
|
||||
selected = tool.Blender.get_selected_objects()
|
||||
if len(selected) != 2:
|
||||
@@ -3418,7 +3657,7 @@ class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardin
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: bpy.types.Context) -> bool:
|
||||
if not _wall_gizmo_poll_gate(context):
|
||||
if not _wall_topology_gizmo_poll_gate(context):
|
||||
return False
|
||||
selected = tool.Blender.get_selected_objects()
|
||||
if len(selected) != 2:
|
||||
@@ -3489,8 +3728,19 @@ class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardin
|
||||
clearance = gizmo.top_down_clearance(context, billboard_rot)
|
||||
anchor_z = self._stack_anchor_z(context, selected, geom_a, geom_b)
|
||||
|
||||
# Pair predicate cache key: pair GlobalIds + world-matrix tuples for
|
||||
# both walls. World matrices feed _are_walls_collinear /
|
||||
# project_axis_intersection, so they belong in the key.
|
||||
pair_guids = tuple(sorted((elem_a.GlobalId, elem_b.GlobalId)))
|
||||
mw_a_key = tuple(map(tuple, selected[0].matrix_world))
|
||||
mw_b_key = tuple(map(tuple, selected[1].matrix_world))
|
||||
mw_key = (mw_a_key, mw_b_key) if elem_a.GlobalId <= elem_b.GlobalId else (mw_b_key, mw_a_key)
|
||||
|
||||
# State 1: walls are already joined → Unjoin (bottom) + Fillet (above).
|
||||
if _are_walls_joined(elem_a, elem_b):
|
||||
joined = _get_wall_pair_predicate_cached(
|
||||
self, ("joined", pair_guids), lambda: _are_walls_joined(elem_a, elem_b)
|
||||
)
|
||||
if joined:
|
||||
corner = _collinear_boundary_world(seg_a, seg_b)
|
||||
anchor = Vector((corner.x, corner.y, anchor_z)) + clearance
|
||||
self._stack_at(anchor, screen_up, billboard_rot, (self.unjoin_icon, self.fillet_icon))
|
||||
@@ -3502,7 +3752,12 @@ class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardin
|
||||
# State 2: walls are collinear (parallel axes on the same line) → show Merge
|
||||
# at the boundary midpoint between them. No stack; single icon at the
|
||||
# geometric boundary makes the merge target unambiguous.
|
||||
if _are_walls_collinear(seg_a, seg_b, self.PARALLEL_DOT_THRESHOLD, self.COLLINEAR_LINE_TOLERANCE):
|
||||
collinear = _get_wall_pair_predicate_cached(
|
||||
self,
|
||||
("collinear", pair_guids, mw_key, self.PARALLEL_DOT_THRESHOLD, self.COLLINEAR_LINE_TOLERANCE),
|
||||
lambda: _are_walls_collinear(seg_a, seg_b, self.PARALLEL_DOT_THRESHOLD, self.COLLINEAR_LINE_TOLERANCE),
|
||||
)
|
||||
if collinear:
|
||||
boundary = _collinear_boundary_world(seg_a, seg_b) + clearance
|
||||
self.merge_icon.matrix_basis = gizmo.billboarded_at(boundary, billboard_rot)
|
||||
self.merge_icon.hide = False
|
||||
@@ -3518,10 +3773,14 @@ class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardin
|
||||
# walls within 2° of parallel produce extrusion joints that race
|
||||
# toward infinity, so project_axis_intersection returns None and the
|
||||
# early-return below hides the whole group.
|
||||
intersection_tuple = core.project_axis_intersection(
|
||||
(tuple(seg_a[0]), tuple(seg_a[1])),
|
||||
(tuple(seg_b[0]), tuple(seg_b[1])),
|
||||
self.PARALLEL_DOT_THRESHOLD,
|
||||
intersection_tuple = _get_wall_pair_predicate_cached(
|
||||
self,
|
||||
("intersection", pair_guids, mw_key, self.PARALLEL_DOT_THRESHOLD),
|
||||
lambda: core.project_axis_intersection(
|
||||
(tuple(seg_a[0]), tuple(seg_a[1])),
|
||||
(tuple(seg_b[0]), tuple(seg_b[1])),
|
||||
self.PARALLEL_DOT_THRESHOLD,
|
||||
),
|
||||
)
|
||||
if intersection_tuple is None:
|
||||
self._hide_all()
|
||||
@@ -3625,7 +3884,7 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: bpy.types.Context) -> bool:
|
||||
if not _wall_gizmo_poll_gate(context):
|
||||
if not _wall_topology_gizmo_poll_gate(context):
|
||||
return False
|
||||
active = tool.Blender.get_active_object(is_selected=True)
|
||||
if active is None:
|
||||
@@ -3677,7 +3936,7 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix
|
||||
billboard_rot = gizmo.get_billboard_rotation(context)
|
||||
clearance = gizmo.top_down_clearance(context, billboard_rot)
|
||||
|
||||
connections = _iter_path_connections(elem)
|
||||
connections = _get_wall_connections_cached(self, elem)
|
||||
if len(connections) > self.POOL_SIZE and not getattr(self, "_pool_cap_warned", False):
|
||||
print(
|
||||
f"[bonsai] GizmoWallUnjoinSingle: wall has {len(connections)} path connections; "
|
||||
@@ -3991,7 +4250,7 @@ class GizmoWallFilletReedit(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: bpy.types.Context) -> bool:
|
||||
if not _wall_gizmo_poll_gate(context):
|
||||
if not _wall_topology_gizmo_poll_gate(context):
|
||||
return False
|
||||
active = tool.Blender.get_active_object(is_selected=True)
|
||||
if active is None:
|
||||
@@ -4059,7 +4318,7 @@ class GizmoWallFilletToggleOpenings(bpy.types.GizmoGroup, _WallGeomCachedBillboa
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: bpy.types.Context) -> bool:
|
||||
if not _wall_gizmo_poll_gate(context):
|
||||
if not _wall_topology_gizmo_poll_gate(context):
|
||||
return False
|
||||
active = tool.Blender.get_active_object(is_selected=True)
|
||||
if active is None:
|
||||
@@ -4110,6 +4369,8 @@ class JoinWallsIntersection(_CommitWallDraftsFirstMixin, bpy.types.Operator, too
|
||||
if not tool.Model.has_selected_ifc_objects():
|
||||
cls.poll_message_set("No IFC objects selected.")
|
||||
return False
|
||||
if _poll_reject_array_children(cls):
|
||||
return False
|
||||
return True
|
||||
|
||||
def _perform(self, context: bpy.types.Context) -> set[str]:
|
||||
@@ -4174,7 +4435,7 @@ class WallGizmoPreviewDecorator(tool.Blender.ViewportDecorator):
|
||||
|
||||
LINE_WIDTH = 1.5
|
||||
LINE_ALPHA = 0.8
|
||||
QUAD_ALPHA = 0.25
|
||||
QUAD_ALPHA = 0.45
|
||||
|
||||
def draw_lines(self, context: bpy.types.Context) -> None:
|
||||
if not tool.Blender.are_viewport_gizmos_enabled():
|
||||
@@ -4184,6 +4445,7 @@ class WallGizmoPreviewDecorator(tool.Blender.ViewportDecorator):
|
||||
self._draw_cursor_extend_preview(context, prefs)
|
||||
self._draw_cursor_extend_z_preview(context, prefs)
|
||||
self._draw_cursor_split_preview(context, prefs)
|
||||
self._draw_cursor_perpendicular_wall_preview(context, prefs)
|
||||
|
||||
def _stroke(
|
||||
self,
|
||||
@@ -4191,7 +4453,7 @@ class WallGizmoPreviewDecorator(tool.Blender.ViewportDecorator):
|
||||
segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]],
|
||||
color_rgb: tuple[float, float, float],
|
||||
) -> None:
|
||||
_stroke_lines_alpha(context, segments, color_rgb, self.LINE_WIDTH, self.LINE_ALPHA)
|
||||
draw_polyline_segments(context, segments, color_rgb, self.LINE_ALPHA, self.LINE_WIDTH)
|
||||
|
||||
def _fill(
|
||||
self,
|
||||
@@ -4402,10 +4664,12 @@ class WallGizmoPreviewDecorator(tool.Blender.ViewportDecorator):
|
||||
emit(nearest_x, cursor_local.x, keep_color)
|
||||
|
||||
def _draw_cursor_split_preview(self, context: bpy.types.Context, prefs: Any) -> None:
|
||||
"""Render one red line at the cursor's projected X, from wall base to wall top
|
||||
along the wall's local Z — the cut plane the split operator would commit.
|
||||
Hover-gated on the split icon; coloured with the destructive-action warning
|
||||
red to match the icon's own hover signal."""
|
||||
"""Render two red lines at the cursor's projected X: one vertical along
|
||||
the wall's local Z (visible in elevation views), one horizontal across
|
||||
the wall's thickness band at floor Z (visible in plan / top-down view).
|
||||
Together they trace the cut plane the split operator would commit.
|
||||
Hover-gated on the split icon; coloured with the destructive-action
|
||||
warning red to match the icon's own hover signal."""
|
||||
active = self._active_layer2_wall_for_gizmo_preview(context, prefs)
|
||||
if active is None:
|
||||
return
|
||||
@@ -4417,15 +4681,25 @@ class WallGizmoPreviewDecorator(tool.Blender.ViewportDecorator):
|
||||
anchor_x = geom.get("anchor_x", 0.0)
|
||||
length = geom.get("length", 0.0)
|
||||
height = geom.get("height", 0.0)
|
||||
offset = geom.get("offset", 0.0)
|
||||
thickness = geom.get("thickness", 0.0)
|
||||
if length <= 0 or height <= 0:
|
||||
return
|
||||
mw = active.matrix_world
|
||||
cursor_local = mw.inverted() @ context.scene.cursor.location
|
||||
if not (anchor_x < cursor_local.x < anchor_x + length):
|
||||
return
|
||||
color = tuple(prefs.decorator_color_error[:3])
|
||||
bottom_world = mw @ Vector((cursor_local.x, 0.0, 0.0))
|
||||
top_world = mw @ Vector((cursor_local.x, 0.0, height))
|
||||
self._stroke(context, [(tuple(bottom_world), tuple(top_world))], tuple(prefs.decorator_color_error[:3]))
|
||||
segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = [
|
||||
(tuple(bottom_world), tuple(top_world))
|
||||
]
|
||||
if thickness > 0:
|
||||
base_a = mw @ Vector((cursor_local.x, offset, 0.0))
|
||||
base_b = mw @ Vector((cursor_local.x, offset + thickness, 0.0))
|
||||
segments.append((tuple(base_a), tuple(base_b)))
|
||||
self._stroke(context, segments, color)
|
||||
|
||||
def _draw_cursor_extend_z_preview(self, context: bpy.types.Context, prefs: Any) -> None:
|
||||
"""Hover-gated vertical-line preview for the extend-Z icon at the
|
||||
@@ -4472,3 +4746,40 @@ class WallGizmoPreviewDecorator(tool.Blender.ViewportDecorator):
|
||||
remove_color = tuple(prefs.decorator_color_error[:3])
|
||||
stroke(0.0, cursor_local.z, keep_color)
|
||||
stroke(cursor_local.z, height, remove_color)
|
||||
|
||||
def _draw_cursor_perpendicular_wall_preview(self, context: bpy.types.Context, prefs: Any) -> None:
|
||||
"""Hover-gated floor-plane preview of the branch wall's footprint.
|
||||
Green quad on Z=0 spanning the new wall's perpendicular body band
|
||||
(``offset`` to ``offset + thickness`` mapped through the perpendicular
|
||||
rotation) and its length from the projection on the source wall axis
|
||||
to the cursor."""
|
||||
active = self._active_layer2_wall_for_gizmo_preview(context, prefs)
|
||||
if active is None:
|
||||
return
|
||||
if not self._cursor_icon_hovered(GizmoWallEdition, "add_perpendicular_wall_gizmo", context):
|
||||
return
|
||||
geom = tool.Wall.read_geometry(active)
|
||||
if geom is None:
|
||||
return
|
||||
anchor_x = geom.get("anchor_x", 0.0)
|
||||
length = geom.get("length", 0.0)
|
||||
offset = geom.get("offset", 0.0)
|
||||
thickness = geom.get("thickness", 0.0)
|
||||
if length <= 0 or thickness <= 0:
|
||||
return
|
||||
mw = active.matrix_world
|
||||
cursor_local = mw.inverted() @ context.scene.cursor.location
|
||||
params = _perpendicular_wall_params(cursor_local.x, cursor_local.y, anchor_x, length)
|
||||
if params is None:
|
||||
return
|
||||
clamped_x, _length, side_sign = params
|
||||
# New wall axis sits at source-local X = clamped_x; its body extends
|
||||
# perpendicular to that axis. After rotating the new wall's ±Y body
|
||||
# band into the source's local frame, the band lands at source-local
|
||||
# X = clamped_x − side_sign · {offset, offset+thickness}.
|
||||
x_a = clamped_x - side_sign * offset
|
||||
x_b = clamped_x - side_sign * (offset + thickness)
|
||||
x_lo, x_hi = (x_a, x_b) if x_a < x_b else (x_b, x_a)
|
||||
y_lo, y_hi = (0.0, cursor_local.y) if cursor_local.y > 0 else (cursor_local.y, 0.0)
|
||||
keep_color = tuple(prefs.decorator_color_selected[:3])
|
||||
self._fill(context, [self._wall_floor_quad(mw, x_lo, x_hi, y_lo, y_hi)], keep_color)
|
||||
|
||||
@@ -28,6 +28,10 @@ classes = (
|
||||
operator.AppendLibraryElementByQuery,
|
||||
operator.AssignLibraryDeclaration,
|
||||
operator.BIM_FH_import_ifc,
|
||||
operator.BIM_OT_apply_pending_opening_cuts,
|
||||
operator.BIM_OT_dismiss_multi_instance_warning,
|
||||
operator.BIM_OT_dismiss_pending_opening_cuts,
|
||||
operator.BIM_OT_select_pending_opening_cuts,
|
||||
operator.BIM_OT_load_clipping_planes,
|
||||
operator.BIM_OT_save_clipping_planes,
|
||||
operator.ChangeLibraryElement,
|
||||
@@ -82,6 +86,7 @@ classes = (
|
||||
prop.FilterCategory,
|
||||
prop.Link,
|
||||
prop.EditedObj,
|
||||
prop.PendingOpeningRecut,
|
||||
prop.BIMProjectProperties,
|
||||
prop.MeasureToolSettings,
|
||||
ui.BIM_MT_new_project,
|
||||
|
||||
@@ -964,11 +964,11 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
|
||||
use_relative_path: bpy.props.BoolProperty(
|
||||
name="Use Relative Path",
|
||||
description="Store the IFC project path relative to the .blend file. Requires .blend file to be saved",
|
||||
default=True,
|
||||
default=False,
|
||||
)
|
||||
should_start_fresh_session: bpy.props.BoolProperty(
|
||||
name="Should Start Fresh Session",
|
||||
description="Clear current Blender session before loading IFC",
|
||||
description="Clear current Blender session before loading IFC. Not supported with 'Use Relative Path' option",
|
||||
default=True,
|
||||
)
|
||||
import_without_ifc_data: bpy.props.BoolProperty(
|
||||
@@ -1076,6 +1076,9 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
|
||||
bpy.app.handlers.load_post.remove(load_handler)
|
||||
self.finish_loading_project(context)
|
||||
|
||||
if self.use_relative_path:
|
||||
self.should_start_fresh_session = False
|
||||
|
||||
if self.should_start_fresh_session:
|
||||
# WARNING: wm.read_homefile clears context which could lead to some
|
||||
# operators to fail:
|
||||
@@ -1140,6 +1143,8 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
|
||||
return ImportHelper.invoke(self, context, event)
|
||||
|
||||
def draw(self, context):
|
||||
if self.use_relative_path:
|
||||
self.should_start_fresh_session = False
|
||||
self.layout.prop(self, "is_advanced")
|
||||
self.layout.prop(self, "should_start_fresh_session")
|
||||
self.layout.prop(self, "import_without_ifc_data")
|
||||
@@ -1218,6 +1223,19 @@ class LoadProjectElements(bpy.types.Operator):
|
||||
props = tool.Project.get_project_props()
|
||||
props.is_loading = False
|
||||
|
||||
# Stash elements the kernel skipped opening cuts on (HasOpenings > void_limit).
|
||||
# The Project panel banner offers the user a one-click recut.
|
||||
props.pending_opening_recut.clear()
|
||||
if ifc_importer.gross_elements:
|
||||
for element in ifc_importer.gross_elements:
|
||||
item = props.pending_opening_recut.add()
|
||||
item.ifc_definition_id = element.id()
|
||||
self.report(
|
||||
{"WARNING"},
|
||||
f"{len(ifc_importer.gross_elements)} element(s) had too many openings and were loaded without cuts. "
|
||||
f"Apply manually from the Project panel.",
|
||||
)
|
||||
|
||||
tool.Project.load_default_thumbnails()
|
||||
tool.Project.set_default_context()
|
||||
tool.Project.set_default_modeling_dimensions()
|
||||
@@ -1870,7 +1888,7 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
|
||||
json_version: bpy.props.EnumProperty(items=[("4", "4", ""), ("5a", "5a", "")], name="IFC JSON Version")
|
||||
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=True)
|
||||
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=False)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
filter_glob: str
|
||||
@@ -3416,3 +3434,108 @@ class GenerateUVMap(bpy.types.Operator):
|
||||
tool.Loader.load_generated_uv_map(obj.data)
|
||||
self.report({"INFO"}, "Generated UV map for selected mesh.")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class BIM_OT_apply_pending_opening_cuts(bpy.types.Operator, tool.Ifc.Operator):
|
||||
"""Recompute the wall mesh including opening subtractions for every host
|
||||
that the load-time ``void_limit`` filter skipped. Clears the deferred
|
||||
list on completion so the panel banner disappears."""
|
||||
|
||||
bl_idname = "bim.apply_pending_opening_cuts"
|
||||
bl_label = "Apply Pending Opening Cuts"
|
||||
bl_description = (
|
||||
"Recompute meshes for elements whose openings were skipped at load because they had too many openings"
|
||||
)
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
pending = tool.Project.get_project_props().pending_opening_recut
|
||||
applied = 0
|
||||
skipped = 0
|
||||
failed = 0
|
||||
for item in pending:
|
||||
try:
|
||||
element = tool.Ifc.get().by_id(item.ifc_definition_id)
|
||||
except RuntimeError:
|
||||
skipped += 1
|
||||
continue
|
||||
obj = tool.Ifc.get_object(element)
|
||||
if obj is None:
|
||||
skipped += 1
|
||||
continue
|
||||
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
|
||||
if body is None:
|
||||
skipped += 1
|
||||
continue
|
||||
try:
|
||||
tool.Geometry.reimport_element_representations(obj, body, apply_openings=True)
|
||||
applied += 1
|
||||
except (RuntimeError, OSError, AttributeError) as exc:
|
||||
# Programmer errors (TypeError, ValueError, etc.) must surface — don't swallow them.
|
||||
failed += 1
|
||||
print(f"apply_pending_opening_cuts: failed to recompute {element} ({exc})")
|
||||
|
||||
pending.clear()
|
||||
message = f"Applied opening cuts to {applied} element(s)."
|
||||
if skipped:
|
||||
message += f" {skipped} entry/entries skipped (entity or object no longer available)."
|
||||
if failed:
|
||||
message += f" {failed} entry/entries failed (see system console)."
|
||||
self.report({"WARNING"}, message)
|
||||
else:
|
||||
self.report({"INFO"}, message)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class BIM_OT_dismiss_pending_opening_cuts(bpy.types.Operator):
|
||||
bl_idname = "bim.dismiss_pending_opening_cuts"
|
||||
bl_label = "Dismiss Pending Opening Cuts"
|
||||
bl_description = "Clear the pending opening-cut list without applying it. Walls stay solid where openings would have been subtracted."
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context: bpy.types.Context) -> set[str]:
|
||||
tool.Project.get_project_props().pending_opening_recut.clear()
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class BIM_OT_dismiss_multi_instance_warning(bpy.types.Operator):
|
||||
bl_idname = "bim.dismiss_multi_instance_warning"
|
||||
bl_label = "Dismiss Multi-Instance Warning"
|
||||
bl_description = (
|
||||
"Hide the warning that another Blender instance has this IFC file open. Sticky for the current session."
|
||||
)
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
def execute(self, context: bpy.types.Context) -> set[str]:
|
||||
from bonsai.bim.ifc import dismiss_multi_instance_warning
|
||||
|
||||
dismiss_multi_instance_warning()
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class BIM_OT_select_pending_opening_cuts(bpy.types.Operator):
|
||||
bl_idname = "bim.select_pending_opening_cuts"
|
||||
bl_label = "Select Elements With Skipped Opening Cuts"
|
||||
bl_description = "Select the Blender objects whose openings were skipped at load. Useful for locating which elements need attention."
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context: bpy.types.Context) -> set[str]:
|
||||
ifc_file = tool.Ifc.get()
|
||||
if ifc_file is None:
|
||||
self.report({"INFO"}, "No IFC file loaded.")
|
||||
return {"CANCELLED"}
|
||||
objects: list[bpy.types.Object] = []
|
||||
for item in tool.Project.get_project_props().pending_opening_recut:
|
||||
try:
|
||||
element = ifc_file.by_id(item.ifc_definition_id)
|
||||
except RuntimeError:
|
||||
continue
|
||||
obj = tool.Ifc.get_object(element)
|
||||
if obj is not None:
|
||||
objects.append(obj)
|
||||
if not objects:
|
||||
self.report({"INFO"}, "No matching Blender objects found for the pending list.")
|
||||
return {"CANCELLED"}
|
||||
tool.Blender.set_objects_selection(context, active_object=objects[0], selected_objects=objects)
|
||||
self.report({"INFO"}, f"Selected {len(objects)} element(s).")
|
||||
return {"FINISHED"}
|
||||
|
||||
@@ -295,6 +295,17 @@ class LibraryBreadcrumb(PropertyGroup):
|
||||
library_id: int
|
||||
|
||||
|
||||
class PendingOpeningRecut(PropertyGroup):
|
||||
"""One element whose ``HasOpenings`` exceeded ``void_limit`` at load time
|
||||
and was imported without opening subtractions. The user can later apply
|
||||
them on demand from the Project panel banner."""
|
||||
|
||||
ifc_definition_id: IntProperty(name="IFC Definition ID")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
ifc_definition_id: int
|
||||
|
||||
|
||||
class BIMProjectProperties(PropertyGroup):
|
||||
is_editing: BoolProperty(name="Is Editing", default=False)
|
||||
is_loading: BoolProperty(name="Is Loading", default=False)
|
||||
@@ -360,6 +371,7 @@ class BIMProjectProperties(PropertyGroup):
|
||||
default=30,
|
||||
description="Maxium number of openings that object can have. If object has more openings, it will be loaded without openings",
|
||||
)
|
||||
pending_opening_recut: CollectionProperty(name="Pending Opening Recut", type=PendingOpeningRecut)
|
||||
style_limit: IntProperty(
|
||||
name="Style Limit",
|
||||
default=300,
|
||||
@@ -444,7 +456,7 @@ class BIMProjectProperties(PropertyGroup):
|
||||
items=get_parent_libaries,
|
||||
)
|
||||
|
||||
use_relative_project_path: BoolProperty(name="Use Relative Project Path", default=True)
|
||||
use_relative_project_path: BoolProperty(name="Use Relative Project Path", default=False)
|
||||
should_save_metadata_for_this_file: BoolProperty(
|
||||
name="Save Session Data for This File",
|
||||
description="Enable saving session data (window layout, settings) to a metadata blend file for this specific IFC file",
|
||||
@@ -525,6 +537,7 @@ class BIMProjectProperties(PropertyGroup):
|
||||
deflection_tolerance: float
|
||||
angular_tolerance: float
|
||||
void_limit: int
|
||||
pending_opening_recut: bpy.types.bpy_prop_collection_idprop[PendingOpeningRecut]
|
||||
style_limit: int
|
||||
distance_limit: float
|
||||
false_origin_mode: Literal["AUTOMATIC", "MANUAL", "DISABLED"]
|
||||
|
||||
@@ -28,8 +28,9 @@ from bpy.types import Menu, Panel, UIList
|
||||
import bonsai.bim
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.helper import draw_attributes, prop_with_search
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
from bonsai.bim.ifc import IfcStore, is_cache_locked_by_other_process
|
||||
from bonsai.bim.module.project.data import LinksData, ProjectData
|
||||
from bonsai.bim.ui import draw_multiline_text
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bonsai.bim.module.project.prop import (
|
||||
@@ -166,6 +167,20 @@ class BIM_PT_project(Panel):
|
||||
if pprops.is_loading:
|
||||
self.draw_advanced_loading_ui(context)
|
||||
elif self.file or props.ifc_file:
|
||||
if is_cache_locked_by_other_process():
|
||||
box = self.layout.box()
|
||||
box.alert = True
|
||||
row = box.row(align=True)
|
||||
row.label(text="IFC Already Open in Another Blender Instance", icon="ERROR")
|
||||
row.operator("bim.dismiss_multi_instance_warning", text="", icon="CANCEL")
|
||||
draw_multiline_text(
|
||||
box.column(align=True),
|
||||
"This file is open in another Blender instance. Editing the same "
|
||||
"IFC from two instances at once can lose your work or display "
|
||||
"outdated geometry. Close the other Blender instances to continue safely.",
|
||||
context=context,
|
||||
)
|
||||
|
||||
if props.has_blend_warning:
|
||||
box = self.layout.box()
|
||||
box.alert = True
|
||||
@@ -175,6 +190,21 @@ class BIM_PT_project(Panel):
|
||||
op.uri = "https://docs.bonsaibim.org/guides/troubleshooting.html#saving-and-loading-blend-files"
|
||||
row.operator("bim.close_blend_warning", text="", icon="CANCEL")
|
||||
|
||||
if pending := pprops.pending_opening_recut:
|
||||
box = self.layout.box()
|
||||
box.alert = True
|
||||
box.label(text="Opening Cuts Skipped", icon="ERROR")
|
||||
draw_multiline_text(
|
||||
box.column(align=True),
|
||||
f"{len(pending)} element(s) had too many openings to cut during load. "
|
||||
f"Apply to recompute their meshes, or dismiss to leave them as they are.",
|
||||
context=context,
|
||||
)
|
||||
row = box.row(align=True)
|
||||
row.operator("bim.select_pending_opening_cuts", text="Select Elements", icon="RESTRICT_SELECT_OFF")
|
||||
row.operator("bim.apply_pending_opening_cuts", text="Apply Openings", icon="PLAY")
|
||||
row.operator("bim.dismiss_pending_opening_cuts", text="", icon="CANCEL")
|
||||
|
||||
if props.ifc_file:
|
||||
self.draw_loaded_project_ui(context)
|
||||
else:
|
||||
|
||||
@@ -15,9 +15,10 @@
|
||||
#
|
||||
# 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 modified with the assistance of an AI coding tool.
|
||||
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
import gpu
|
||||
from bpy.app.handlers import persistent
|
||||
@@ -78,15 +79,9 @@ class SystemDecorator:
|
||||
batch.draw(shader)
|
||||
|
||||
def draw_faces(self, bm, vertices_coords):
|
||||
"""mutates original bm (triangulates it)
|
||||
so the triangulation edges will be shown too
|
||||
"""
|
||||
traingulated_bm = bm
|
||||
bmesh.ops.triangulate(traingulated_bm, faces=traingulated_bm.faces)
|
||||
|
||||
face_indices = [[v.index for v in f.verts] for f in traingulated_bm.faces]
|
||||
"""Submit a non-mutating beauty-triangulated TRIS batch over ``bm``'s faces."""
|
||||
faces_color = transparent_color(self.addon_prefs.decorator_color_special)
|
||||
self.draw_batch("TRIS", vertices_coords, faces_color, face_indices)
|
||||
tool.Blender.draw_bmesh_face_tris(bm, vertices_coords, faces_color, self.draw_batch)
|
||||
|
||||
def __call__(self, context, get_custom_bmesh=None, draw_faces=False, exit_edit_mode_callback=None):
|
||||
self.addon_prefs = tool.Blender.get_addon_preferences()
|
||||
|
||||
@@ -317,13 +317,23 @@ class MEPConnectElements(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_label = "Connect MEP Elements"
|
||||
bl_description = "Connects two selected elements by their closest located ports and adjusts them"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
obj1_name: bpy.props.StringProperty(name="Object 1")
|
||||
obj2_name: bpy.props.StringProperty(name="Object 2")
|
||||
obj1_guid: bpy.props.StringProperty(name="Object 1 GlobalId")
|
||||
obj2_guid: bpy.props.StringProperty(name="Object 2 GlobalId")
|
||||
|
||||
def _execute(self, context):
|
||||
if self.obj1_name and self.obj2_name:
|
||||
obj1 = bpy.data.objects.get(self.obj1_name)
|
||||
obj2 = bpy.data.objects.get(self.obj2_name)
|
||||
if self.obj1_guid and self.obj2_guid:
|
||||
ifc_file = tool.Ifc.get()
|
||||
try:
|
||||
el1_lookup = ifc_file.by_guid(self.obj1_guid)
|
||||
el2_lookup = ifc_file.by_guid(self.obj2_guid)
|
||||
except RuntimeError:
|
||||
self.report({"ERROR"}, "Could not resolve MEP elements from supplied GlobalIds.")
|
||||
return {"CANCELLED"}
|
||||
obj1 = tool.Ifc.get_object(el1_lookup)
|
||||
obj2 = tool.Ifc.get_object(el2_lookup)
|
||||
if not obj1 or not obj2:
|
||||
self.report({"ERROR"}, "Supplied MEP elements have no Blender object bound.")
|
||||
return {"CANCELLED"}
|
||||
else:
|
||||
if not context.selected_objects or len(context.selected_objects) != 2:
|
||||
self.report({"ERROR"}, "Need to select 2 objects.")
|
||||
|
||||
@@ -219,7 +219,7 @@ class SelectIfcFile(bpy.types.Operator, IFCFileSelector, ImportHelper):
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = f"Select a different IFC file.\n{tool.Blender.operator_invoke_filepath_hotkeys_description}"
|
||||
filter_glob: bpy.props.StringProperty(default="*.ifc;*.ifczip;*.ifcxml", options={"HIDDEN"})
|
||||
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=True)
|
||||
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=False)
|
||||
filename_ext = ".ifc"
|
||||
|
||||
def execute(self, context):
|
||||
|
||||
@@ -94,10 +94,7 @@ class IFCFileSelector:
|
||||
filepath = self.get_filepath_abs()
|
||||
|
||||
if self.use_relative_path:
|
||||
try:
|
||||
filepath = filepath.relative_to(bpy.path.abspath("//"))
|
||||
except ValueError:
|
||||
pass # IFC file is not under the blend directory; keep absolute path
|
||||
filepath = filepath.relative_to(bpy.path.abspath("//"))
|
||||
return filepath.as_posix().replace("\\", "/")
|
||||
|
||||
def draw(self, context: bpy.types.Context) -> None:
|
||||
@@ -294,6 +291,8 @@ class GizmoPreferences(bpy.types.PropertyGroup):
|
||||
railing: BoolProperty(name="Railing", default=True)
|
||||
roof: BoolProperty(name="Roof", default=True)
|
||||
array: BoolProperty(name="Array", default=True)
|
||||
pipe_segment: BoolProperty(name="Pipe Segment", default=True)
|
||||
duct_segment: BoolProperty(name="Duct Segment", default=True)
|
||||
wall: BoolProperty(name="Wall", default=True)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -304,6 +303,8 @@ class GizmoPreferences(bpy.types.PropertyGroup):
|
||||
railing: bool
|
||||
roof: bool
|
||||
array: bool
|
||||
pipe_segment: bool
|
||||
duct_segment: bool
|
||||
wall: bool
|
||||
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import importlib
|
||||
import math
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
@@ -1458,6 +1459,38 @@ class Blender(bonsai.core.tool.Blender):
|
||||
parent_guid = pset.get("Parent")
|
||||
return parent_guid is not None and parent_guid != element.GlobalId
|
||||
|
||||
@classmethod
|
||||
def any_selected_is_array_child(cls) -> bool:
|
||||
"""True if any selected IFC-linked object is a Bonsai array child.
|
||||
|
||||
Multi-object wall topology gizmos (merge / join / extend / unjoin
|
||||
/ fillet) and their bound operators gate on this: any mutation
|
||||
applied to a child is overwritten on the next
|
||||
``regenerate_array``, and merge specifically would leave the
|
||||
parent's ``BBIM_Array.Data`` list pointing at a deleted GUID.
|
||||
|
||||
Memoised against (selection signature, IFC geometry generation)
|
||||
so gizmo polls that fire per input event don't re-walk the pset
|
||||
for every selected object every frame. Identity-keyed so plain
|
||||
Python objects (used by tests) work alongside real Blender
|
||||
``bpy_struct`` wrappers."""
|
||||
selected = tool.Blender.get_selected_objects()
|
||||
selection_sig = frozenset(id(obj) for obj in selected)
|
||||
current_gen = tool.Parametric.get_geom_generation()
|
||||
cached = cls._any_selected_array_child_memo
|
||||
if cached is not None and cached[0] == selection_sig and cached[1] == current_gen:
|
||||
return cached[2]
|
||||
result = False
|
||||
for obj in selected:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if element is not None and cls.is_array_child(element):
|
||||
result = True
|
||||
break
|
||||
cls._any_selected_array_child_memo = (selection_sig, current_gen, result)
|
||||
return result
|
||||
|
||||
_any_selected_array_child_memo: tuple[frozenset[int], int, bool] | None = None
|
||||
|
||||
@classmethod
|
||||
def is_slab(cls, element: entity_instance) -> bool:
|
||||
"""A slab is host-eligible for the parametric add-opening gizmo if
|
||||
@@ -2227,6 +2260,65 @@ class Blender(bonsai.core.tool.Blender):
|
||||
return False
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def draw_bmesh_face_tris(
|
||||
cls,
|
||||
bm: bmesh.types.BMesh,
|
||||
world_vert_coords: list,
|
||||
color: Any,
|
||||
draw_batch: Callable[[str, list, Any, list], None],
|
||||
) -> None:
|
||||
"""Submit a non-mutating beauty-triangulated TRIS batch for ``bm``'s faces.
|
||||
|
||||
``world_vert_coords`` must be indexed by ``bm.verts`` index. Never call
|
||||
``bmesh.ops.triangulate`` on a live bmesh to compute draw indices — it
|
||||
mutates the input and produces ear-clip fans that render as visible
|
||||
streaks at low alpha.
|
||||
"""
|
||||
tris = [[loop.vert.index for loop in tri] for tri in bm.calc_loop_triangles()]
|
||||
draw_batch("TRIS", world_vert_coords, color, tris)
|
||||
|
||||
@classmethod
|
||||
def build_dashed_line_segments(
|
||||
cls,
|
||||
world_verts: Sequence[Sequence[float]],
|
||||
edges_indices: Sequence[Sequence[int]],
|
||||
dash_period: float,
|
||||
dash_width: float,
|
||||
) -> tuple[list[tuple[float, float, float]], list[tuple[int, int]]]:
|
||||
"""Pre-segment edges into world-space dash chunks for a vanilla LINES batch.
|
||||
|
||||
Each input edge is sliced into segments of length ``dash_width`` spaced
|
||||
``dash_period`` apart (dash phase resets per-edge). The result is a fresh
|
||||
``(verts, edges)`` pair that draws as dashes through any standard line
|
||||
shader — letting both passes of a visible/occluded outline reuse the
|
||||
same shader so depth values match exactly across passes.
|
||||
"""
|
||||
new_verts: list[tuple[float, float, float]] = []
|
||||
new_edges: list[tuple[int, int]] = []
|
||||
if dash_period <= 0 or dash_width <= 0:
|
||||
return new_verts, new_edges
|
||||
n = len(world_verts)
|
||||
for i, j in edges_indices:
|
||||
if not (0 <= i < n and 0 <= j < n) or i == j:
|
||||
continue
|
||||
v0 = world_verts[i]
|
||||
v1 = world_verts[j]
|
||||
dx, dy, dz = v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2]
|
||||
edge_length = math.sqrt(dx * dx + dy * dy + dz * dz)
|
||||
if edge_length == 0.0:
|
||||
continue
|
||||
ux, uy, uz = dx / edge_length, dy / edge_length, dz / edge_length
|
||||
t = 0.0
|
||||
while t < edge_length:
|
||||
t_end = min(t + dash_width, edge_length)
|
||||
idx = len(new_verts)
|
||||
new_verts.append((v0[0] + ux * t, v0[1] + uy * t, v0[2] + uz * t))
|
||||
new_verts.append((v0[0] + ux * t_end, v0[1] + uy * t_end, v0[2] + uz * t_end))
|
||||
new_edges.append((idx, idx + 1))
|
||||
t += dash_period
|
||||
return new_verts, new_edges
|
||||
|
||||
@classmethod
|
||||
def extract_error_reports(cls, exception: RuntimeError) -> list[str]:
|
||||
"""Extracts error report lines from a runtime exception during operator execution.
|
||||
@@ -2377,7 +2469,7 @@ class Blender(bonsai.core.tool.Blender):
|
||||
|
||||
See https://projects.blender.org/blender/blender/issues/149283
|
||||
"""
|
||||
if len(bytedata) == (n * 2):
|
||||
if len(bytedata) == (n * 8): # float64 has 8 bytes per element
|
||||
return np.frombuffer(bytedata, dtype=np.float64).astype(np.float32)
|
||||
return np.frombuffer(bytedata, dtype=np.float32)
|
||||
|
||||
|
||||
@@ -177,6 +177,14 @@ class Cad:
|
||||
return False
|
||||
return (x + tolerance) > value > (x - tolerance)
|
||||
|
||||
@classmethod
|
||||
def is_multiple_of_pi(cls, value: float) -> bool:
|
||||
"""True when ``value`` is an integer multiple of π within tolerance —
|
||||
the parallelism / anti-parallelism check rotation-difference logic
|
||||
reaches for (segments aligned modulo a 180° flip)."""
|
||||
n = round(value / math.pi)
|
||||
return cls.is_x(abs(value - n * math.pi), 0)
|
||||
|
||||
@classmethod
|
||||
def normalise_angle(cls, angle: float) -> float:
|
||||
"""Normalise an angle between -179 and 180"""
|
||||
|
||||
@@ -75,8 +75,10 @@ if TYPE_CHECKING:
|
||||
from bonsai.bim.module.model.prop import (
|
||||
BIMArrayProperties,
|
||||
BIMDoorProperties,
|
||||
BIMDuctSegmentProperties,
|
||||
BIMExternalParametricGeometryProperties,
|
||||
BIMModelProperties,
|
||||
BIMPipeSegmentProperties,
|
||||
BIMPolylineProperties,
|
||||
BIMRailingProperties,
|
||||
BIMRoofProperties,
|
||||
@@ -116,6 +118,14 @@ class Model(bonsai.core.tool.Model):
|
||||
def get_railing_props(cls, obj: bpy.types.Object) -> BIMRailingProperties:
|
||||
return obj.BIMRailingProperties # pyright: ignore[reportAttributeAccessIssue]
|
||||
|
||||
@classmethod
|
||||
def get_pipe_segment_props(cls, obj: bpy.types.Object) -> BIMPipeSegmentProperties:
|
||||
return obj.BIMPipeSegmentProperties # pyright: ignore[reportAttributeAccessIssue]
|
||||
|
||||
@classmethod
|
||||
def get_duct_segment_props(cls, obj: bpy.types.Object) -> BIMDuctSegmentProperties:
|
||||
return obj.BIMDuctSegmentProperties # pyright: ignore[reportAttributeAccessIssue]
|
||||
|
||||
@classmethod
|
||||
def get_sverchok_props(cls, obj: bpy.types.Object) -> BIMSverchokProperties:
|
||||
return obj.BIMSverchokProperties # pyright: ignore[reportAttributeAccessIssue]
|
||||
@@ -362,6 +372,28 @@ class Model(bonsai.core.tool.Model):
|
||||
else:
|
||||
break
|
||||
|
||||
@classmethod
|
||||
def get_sibling_occurrence_count(cls, element: ifcopenshell.entity_instance) -> int:
|
||||
"""Number of *other* products sharing this element's body representation.
|
||||
|
||||
Returns the count of products bound to the same resolved body rep, minus
|
||||
``element`` itself and minus its type (if any). Zero when the element has
|
||||
no body rep, no resolved rep, or no siblings. A non-zero result means a
|
||||
parametric edit on ``element`` will silently mutate other instances'
|
||||
geometry."""
|
||||
body_rep = tool.Geometry.get_body_representation(element)
|
||||
if not body_rep:
|
||||
return 0
|
||||
resolved = ifcopenshell.util.representation.resolve_representation(body_rep)
|
||||
if not resolved:
|
||||
return 0
|
||||
elements = tool.Geometry.get_elements_by_representation(resolved)
|
||||
elements.discard(element)
|
||||
element_type = ifcopenshell.util.element.get_type(element)
|
||||
if element_type is not None:
|
||||
elements.discard(element_type)
|
||||
return len(elements)
|
||||
|
||||
unit_scale: float
|
||||
vertices: list[Vector]
|
||||
edges: list[Sequence[int]]
|
||||
@@ -1556,8 +1588,7 @@ class Model(bonsai.core.tool.Model):
|
||||
element = tool.Ifc.get_entity(object)
|
||||
if not element:
|
||||
return
|
||||
psets = ifcopenshell.util.element.get_psets(element)
|
||||
pset_data = psets.get(pset_name, None)
|
||||
pset_data = ifcopenshell.util.element.get_pset(element, pset_name)
|
||||
if not pset_data:
|
||||
return
|
||||
pset_data["data_dict"] = json.loads(pset_data.get("Data", "[]") or "[]")
|
||||
|
||||
@@ -147,10 +147,6 @@ class Parametric(bonsai.core.tool.Parametric):
|
||||
self._data.clear()
|
||||
self._gen = None
|
||||
|
||||
# FIXME(PR5): pipe_segment / duct_segment land with their finish/cancel
|
||||
# operators in the MEP slice of PR5 (PR5d). Until then they stay out of
|
||||
# EDIT_TYPES so auto-commit-on-save doesn't try to dispatch a
|
||||
# non-existent operator.
|
||||
EDIT_TYPES: list[ParametricObject] = [
|
||||
ParametricObject("door", has_non_editable_path=True, supports_build_edit_lifecycle=True),
|
||||
ParametricObject("window", has_non_editable_path=True, supports_build_edit_lifecycle=True),
|
||||
@@ -158,6 +154,8 @@ class Parametric(bonsai.core.tool.Parametric):
|
||||
ParametricObject("railing", supports_build_edit_lifecycle=True),
|
||||
ParametricObject("roof", supports_build_edit_lifecycle=True),
|
||||
ParametricObject("array", supports_build_edit_lifecycle=True),
|
||||
ParametricObject("pipe_segment", supports_build_edit_lifecycle=True),
|
||||
ParametricObject("duct_segment", supports_build_edit_lifecycle=True),
|
||||
ParametricObject("wall"),
|
||||
]
|
||||
|
||||
@@ -170,6 +168,8 @@ class Parametric(bonsai.core.tool.Parametric):
|
||||
RAILING: ClassVar[ParametricObject]
|
||||
ROOF: ClassVar[ParametricObject]
|
||||
ARRAY: ClassVar[ParametricObject]
|
||||
PIPE_SEGMENT: ClassVar[ParametricObject]
|
||||
DUCT_SEGMENT: ClassVar[ParametricObject]
|
||||
WALL: ClassVar[ParametricObject]
|
||||
|
||||
_geom_generation: int = 0
|
||||
|
||||
@@ -80,10 +80,7 @@ class Spatial(bonsai.core.tool.Spatial):
|
||||
def get_root_element(cls, element: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance:
|
||||
while True:
|
||||
if parent := (
|
||||
ifcopenshell.util.element.get_aggregate(element)
|
||||
or ifcopenshell.util.element.get_nest(element)
|
||||
or ifcopenshell.util.element.get_filled_void(element)
|
||||
or ifcopenshell.util.element.get_voided_element(element)
|
||||
ifcopenshell.util.element.get_aggregate(element) or ifcopenshell.util.element.get_nest(element)
|
||||
):
|
||||
element = parent
|
||||
else:
|
||||
|
||||
@@ -488,6 +488,24 @@ class System(bonsai.core.tool.System):
|
||||
def is_mep_element(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
return element.is_a("IfcFlowSegment") or element.is_a("IfcFlowFitting")
|
||||
|
||||
@classmethod
|
||||
def has_parametric_body(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
"""True when the MEP element's body representation is a profile sweep
|
||||
(``IfcExtrudedAreaSolid`` for segments, ``IfcSweptDiskSolid`` for
|
||||
fittings) — the shape the parametric edit + MEP action gizmos can
|
||||
actually mutate. Tessellation- or brep-imported MEP elements return
|
||||
False so their gizmos hide rather than offer edits the geometry
|
||||
kernel can't honour."""
|
||||
import bonsai.tool as tool
|
||||
|
||||
body = tool.Geometry.get_body_representation(element)
|
||||
if body is None:
|
||||
return False
|
||||
for item in tool.Ifc.get().traverse(body):
|
||||
if item.is_a("IfcExtrudedAreaSolid") or item.is_a("IfcSweptDiskSolid"):
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def walk_connected_mep_elements(
|
||||
cls, start_element: ifcopenshell.entity_instance
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2021 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Forward-compat AST contract: ``HasShapeAspects`` is an IFC4+ inverse;
|
||||
direct attribute access raises ``AttributeError`` on pre-IFC4 entity
|
||||
instances. Production code must read it through ``getattr`` so the
|
||||
absence in earlier schemas degrades to an empty iterable."""
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.geometry
|
||||
|
||||
|
||||
BONSAI_ROOT = Path(__file__).parent.parent.parent.parent.parent / "bonsai"
|
||||
PRODUCTION_DIRS = (BONSAI_ROOT / "bim", BONSAI_ROOT / "tool", BONSAI_ROOT / "core")
|
||||
|
||||
ATTR_NAME = "HasShapeAspects"
|
||||
|
||||
|
||||
def _iter_production_sources():
|
||||
for root in PRODUCTION_DIRS:
|
||||
yield from root.rglob("*.py")
|
||||
|
||||
|
||||
def test_has_shape_aspects_access_uses_getattr_guard():
|
||||
"""Every read of ``HasShapeAspects`` in production code must go through
|
||||
``getattr(<expr>, "HasShapeAspects", <default>)`` so files using
|
||||
schemas that omit the inverse return the default instead of raising."""
|
||||
offenders = []
|
||||
for source in _iter_production_sources():
|
||||
tree = ast.parse(source.read_text(encoding="utf-8"))
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Attribute) and node.attr == ATTR_NAME:
|
||||
offenders.append(f"{source.relative_to(BONSAI_ROOT.parent)}:{node.lineno}")
|
||||
if offenders:
|
||||
joined = "\n ".join(sorted(offenders))
|
||||
pytest.fail(
|
||||
f"Direct .{ATTR_NAME} attribute access in production code:\n {joined}\n"
|
||||
f"Wrap with getattr(<expr>, '{ATTR_NAME}', ()) so pre-IFC4 schemas "
|
||||
f"do not raise AttributeError."
|
||||
)
|
||||
@@ -54,15 +54,31 @@ module if the helper count grows past ~6 or any helper picks up its own
|
||||
non-trivial dependencies."""
|
||||
|
||||
import contextlib
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import pytest
|
||||
|
||||
from bonsai import tool
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _require_real_bpy():
|
||||
"""Skip every test in this directory when ``bpy`` is mocked or absent.
|
||||
|
||||
The model gizmo / decorator suite reaches into Blender's RNA layer
|
||||
(``bpy.types.Operator``, registered ``bl_idname`` lookups, ``Modifier``
|
||||
predicates) that ``Mock`` cannot impersonate, so a tool-lane run with a
|
||||
stubbed ``bpy`` would error rather than meaningfully exercise the
|
||||
contract. The autouse scope means new test files added under this
|
||||
directory inherit the gate without re-declaring it."""
|
||||
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
|
||||
pytest.skip("requires real Blender (bpy is mocked or absent)")
|
||||
|
||||
|
||||
def make_obj(*, session_uid=None, selected=True, **attrs):
|
||||
"""Mock a ``bpy.types.Object`` with attributes commonly read by gizmos.
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Forward-compat AST contract: decorators do not triangulate in-place.
|
||||
|
||||
``bmesh.ops.triangulate(bm, faces=bm.faces)`` mutates its input — adding tri
|
||||
edges and faces — and uses ear-clip fan triangulation that renders as visible
|
||||
streaks across n-gon faces at the low alphas decorators favour. The canonical
|
||||
draw path is ``tool.Blender.draw_bmesh_face_tris`` (wraps ``bm.calc_loop_triangles``,
|
||||
non-mutating, beauty triangulator)."""
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
BONSAI_ROOT = Path(__file__).parent.parent.parent / "bonsai"
|
||||
BIM_MODULE_DIR = BONSAI_ROOT / "bim" / "module"
|
||||
|
||||
|
||||
def _iter_guarded_files():
|
||||
yield from sorted(BIM_MODULE_DIR.glob("*/decorator.py"))
|
||||
yield BIM_MODULE_DIR / "model" / "opening.py"
|
||||
|
||||
|
||||
def _is_guarded_class(node: ast.ClassDef) -> bool:
|
||||
return node.name.endswith("Decorator") or node.name == "DecorationsHandler"
|
||||
|
||||
|
||||
def _is_mutating_triangulate_call(node: ast.AST) -> bool:
|
||||
if not isinstance(node, ast.Call):
|
||||
return False
|
||||
func = node.func
|
||||
if not isinstance(func, ast.Attribute) or func.attr != "triangulate":
|
||||
return False
|
||||
receiver = func.value
|
||||
if not isinstance(receiver, ast.Attribute) or receiver.attr != "ops":
|
||||
return False
|
||||
inner = receiver.value
|
||||
return isinstance(inner, ast.Name) and inner.id == "bmesh"
|
||||
|
||||
|
||||
def test_no_decorator_calls_bmesh_ops_triangulate() -> None:
|
||||
violations: list[str] = []
|
||||
guarded_files = list(_iter_guarded_files())
|
||||
assert guarded_files, "Search root contains no decorator modules — test needs updating."
|
||||
|
||||
for path in guarded_files:
|
||||
try:
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
except (SyntaxError, FileNotFoundError):
|
||||
continue
|
||||
for class_node in (n for n in ast.walk(tree) if isinstance(n, ast.ClassDef)):
|
||||
if not _is_guarded_class(class_node):
|
||||
continue
|
||||
for sub in ast.walk(class_node):
|
||||
if _is_mutating_triangulate_call(sub):
|
||||
violations.append(f"{path}:{sub.lineno} {class_node.name} calls bmesh.ops.triangulate")
|
||||
|
||||
assert not violations, (
|
||||
"Decorator classes must not call bmesh.ops.triangulate — it mutates "
|
||||
"the input bmesh and produces fan-clip artefacts at low alpha. "
|
||||
"Use tool.Blender.draw_bmesh_face_tris (wraps bm.calc_loop_triangles). "
|
||||
"Violations:\n " + "\n ".join(violations)
|
||||
)
|
||||
@@ -0,0 +1,183 @@
|
||||
# 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.
|
||||
|
||||
"""Contract tests for the door swing-arc readonly decorator.
|
||||
|
||||
Two layers:
|
||||
|
||||
- Pure tests on ``_visible_arcs`` pin the readonly decorator's arc selection
|
||||
per ``door_type`` enum value.
|
||||
- A forward-compat guard walks ``GizmoDoorEdition.swing_arc_props`` and
|
||||
asserts the readonly decorator picks the same arcs (hinge / width / mirror)
|
||||
the edit-mode gizmo would, so the two surfaces stay visually identical
|
||||
even when a new ``door_type`` is added."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import get_args
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# _visible_arcs — per-door-type arc selection
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _arcs(door_type, overall_width=0.9, lining_offset=0.05):
|
||||
from bonsai.bim.module.model.decorator import _visible_arcs
|
||||
|
||||
return _visible_arcs(door_type, overall_width, lining_offset)
|
||||
|
||||
|
||||
def test_single_swing_left_one_arc_hinged_at_origin():
|
||||
arcs = _arcs("SINGLE_SWING_LEFT")
|
||||
assert len(arcs) == 1
|
||||
arc = arcs[0]
|
||||
assert arc.hinge_x == pytest.approx(0.0)
|
||||
assert arc.hinge_y == pytest.approx(0.05)
|
||||
assert arc.panel_width == pytest.approx(0.9)
|
||||
assert arc.x_mirror is False
|
||||
|
||||
|
||||
def test_single_swing_right_one_arc_hinged_at_right_edge_x_mirrored():
|
||||
arcs = _arcs("SINGLE_SWING_RIGHT")
|
||||
assert len(arcs) == 1
|
||||
arc = arcs[0]
|
||||
assert arc.hinge_x == pytest.approx(0.9)
|
||||
assert arc.panel_width == pytest.approx(0.9)
|
||||
assert arc.x_mirror is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("door_type", ["DOUBLE_SWING_LEFT", "DOUBLE_SWING_RIGHT"])
|
||||
def test_double_swing_shares_recipe_with_single_swing(door_type):
|
||||
# DOUBLE_SWING_* is still a single panel (the hinge is on one side,
|
||||
# the panel swings both ways) — visually identical to SINGLE_SWING_*.
|
||||
single_type = door_type.replace("DOUBLE_SWING", "SINGLE_SWING")
|
||||
assert _arcs(door_type) == _arcs(single_type)
|
||||
|
||||
|
||||
def test_double_door_single_swing_emits_two_half_width_arcs():
|
||||
arcs = _arcs("DOUBLE_DOOR_SINGLE_SWING")
|
||||
assert len(arcs) == 2
|
||||
left, right = arcs
|
||||
assert left.hinge_x == pytest.approx(0.0)
|
||||
assert left.panel_width == pytest.approx(0.45)
|
||||
assert left.x_mirror is False
|
||||
assert right.hinge_x == pytest.approx(0.9)
|
||||
assert right.panel_width == pytest.approx(0.45)
|
||||
assert right.x_mirror is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("door_type", ["SLIDING_TO_LEFT", "SLIDING_TO_RIGHT", "DOUBLE_DOOR_SLIDING"])
|
||||
def test_sliding_doors_emit_no_arcs(door_type):
|
||||
assert _arcs(door_type) == []
|
||||
|
||||
|
||||
def test_unknown_door_type_falls_back_to_single_left_swing_arc():
|
||||
# Only ``"SLIDING"`` substrings short-circuit the swing predicate; any
|
||||
# other novel ``door_type`` falls through to the default left-hinged arc.
|
||||
arcs = _arcs("FUTURE_OPERATION_TYPE_42")
|
||||
assert len(arcs) == 1
|
||||
arc = arcs[0]
|
||||
assert arc.hinge_x == pytest.approx(0.0)
|
||||
assert arc.panel_width == pytest.approx(0.9)
|
||||
assert arc.x_mirror is False
|
||||
|
||||
|
||||
def test_lining_offset_drives_hinge_y_for_every_visible_arc():
|
||||
for door_type in ("SINGLE_SWING_LEFT", "SINGLE_SWING_RIGHT", "DOUBLE_DOOR_SINGLE_SWING"):
|
||||
for arc in _arcs(door_type, overall_width=0.9, lining_offset=0.12):
|
||||
assert arc.hinge_y == pytest.approx(0.12)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Forward-compat: readonly decorator and edit-mode gizmo agree per door_type
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _gizmo_expected(door_type, overall_width, lining_offset):
|
||||
"""What ``GizmoDoorEdition.swing_arc_props`` would render for the props
|
||||
snapshot, with ``is_editing=True`` so its visibility predicates pass."""
|
||||
from bonsai.bim.module.model.door import GizmoDoorEdition
|
||||
|
||||
props = SimpleNamespace(
|
||||
door_type=door_type,
|
||||
overall_width=overall_width,
|
||||
lining_offset=lining_offset,
|
||||
is_editing=True,
|
||||
)
|
||||
expected = []
|
||||
for cfg in GizmoDoorEdition.swing_arc_props:
|
||||
if cfg.visibility_condition(props):
|
||||
expected.append(
|
||||
(
|
||||
cfg.hinge_x(props),
|
||||
cfg.hinge_y(props),
|
||||
cfg.panel_width(props),
|
||||
cfg.x_mirror(props),
|
||||
)
|
||||
)
|
||||
return expected
|
||||
|
||||
|
||||
def test_visible_arcs_matches_gizmo_swing_arc_props_for_every_door_type():
|
||||
import bonsai.tool as tool
|
||||
|
||||
overall_width, lining_offset = 0.9, 0.05
|
||||
for door_type in get_args(tool.Model.DoorType):
|
||||
expected = _gizmo_expected(door_type, overall_width, lining_offset)
|
||||
actual = _arcs(door_type, overall_width, lining_offset)
|
||||
actual_tuples = [(a.hinge_x, a.hinge_y, a.panel_width, a.x_mirror) for a in actual]
|
||||
assert actual_tuples == expected, (
|
||||
f"Readonly decorator drifted from edit-mode gizmo for {door_type!r}: "
|
||||
f"expected {expected}, got {actual_tuples}"
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Decorator gating contract (draw() early-returns)
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_decorator_stub():
|
||||
"""Build a fresh ``DoorSwingReadonlyDecorator`` instance without going
|
||||
through ``install`` (which would attach a draw handler)."""
|
||||
from bonsai.bim.module.model.decorator import DoorSwingReadonlyDecorator
|
||||
|
||||
return DoorSwingReadonlyDecorator()
|
||||
|
||||
|
||||
def _draw_with_active(decorator, active_obj):
|
||||
"""Call ``draw`` with a minimal ``context`` stub."""
|
||||
ctx = SimpleNamespace(active_object=active_obj)
|
||||
decorator.draw(ctx)
|
||||
|
||||
|
||||
def test_draw_early_returns_when_no_active_object():
|
||||
# Should not raise; nothing to draw.
|
||||
_draw_with_active(_make_decorator_stub(), None)
|
||||
|
||||
|
||||
def test_draw_early_returns_when_active_not_selected():
|
||||
obj = SimpleNamespace(select_get=lambda: False)
|
||||
_draw_with_active(_make_decorator_stub(), obj)
|
||||
@@ -26,7 +26,6 @@ against a SimpleNamespace stand-in that records ``matrix_basis`` assignments and
|
||||
the tests describe the geometric contract directly rather than echoing the
|
||||
implementation."""
|
||||
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
@@ -37,12 +36,6 @@ from mathutils import Matrix, Vector
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _require_real_bpy():
|
||||
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
|
||||
pytest.skip("requires real Blender (bpy is mocked or absent)")
|
||||
|
||||
|
||||
def _make_props(door_type, overall_width=0.9, lining_offset=0.0, is_editing=True):
|
||||
return SimpleNamespace(
|
||||
door_type=door_type,
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# 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 universal pen-icon dispatcher's pre-edit warning path.
|
||||
|
||||
The dispatcher gates the parametric-edit triad behind a confirmation dialog
|
||||
whenever the active element's body representation is shared with sibling
|
||||
occurrences (typed product + mapped representation). It is the single
|
||||
chokepoint every feature's pen icon routes through, so the warning applies
|
||||
to walls, doors, windows, stairs, roofs, and any future feature uniformly.
|
||||
|
||||
These tests exercise:
|
||||
|
||||
- the pure ``should_show_shared_rep_dialog`` decision (every branch); and
|
||||
- one end-to-end invocation through ``bpy.ops`` to pin the wiring between
|
||||
the decision and ``invoke_props_dialog``."""
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
from bonsai.bim.module.model.array import EnableEditingParametric
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
class TestShouldShowSharedRepDialog:
|
||||
"""Exhaustive truth table for the pre-edit-warning decision. Keeping this
|
||||
pure (no bpy, no operator instance) means a future change to the dispatch
|
||||
wiring can't silently flip a branch — the decision is independently pinned."""
|
||||
|
||||
decide = staticmethod(EnableEditingParametric.should_show_shared_rep_dialog)
|
||||
|
||||
def test_shared_rep_with_warning_enabled_shows_dialog(self):
|
||||
assert self.decide(suppress=False, has_entity=True, sibling_count=3) is True
|
||||
|
||||
def test_unique_rep_skips_dialog(self):
|
||||
assert self.decide(suppress=False, has_entity=True, sibling_count=0) is False
|
||||
|
||||
def test_session_suppress_overrides_shared_rep(self):
|
||||
assert self.decide(suppress=True, has_entity=True, sibling_count=5) is False
|
||||
|
||||
def test_no_entity_skips_dialog_even_when_count_positive(self):
|
||||
assert self.decide(suppress=False, has_entity=False, sibling_count=3) is False
|
||||
|
||||
def test_zero_siblings_skips_dialog_regardless_of_suppress(self):
|
||||
assert self.decide(suppress=False, has_entity=True, sibling_count=0) is False
|
||||
assert self.decide(suppress=True, has_entity=True, sibling_count=0) is False
|
||||
|
||||
|
||||
def test_dispatcher_falls_through_to_feature_enable_op_when_no_active_object():
|
||||
"""End-to-end smoke: with no active object the dispatcher short-circuits to
|
||||
its ``execute`` body, which CANCELs on an empty ``feature_enable_op``."""
|
||||
bpy.context.window_manager.BIMParametricEditDialogPrefs.suppress_shared_rep_warning = False
|
||||
try:
|
||||
with bpy.context.temp_override(active_object=None):
|
||||
result = bpy.ops.bim.enable_editing_parametric("INVOKE_DEFAULT", feature_enable_op="")
|
||||
finally:
|
||||
bpy.context.window_manager.BIMParametricEditDialogPrefs.suppress_shared_rep_warning = False
|
||||
assert result == {"CANCELLED"}
|
||||
@@ -35,20 +35,12 @@ early-returns when ``context.screen`` is unattached and prior tests can leave
|
||||
the screen in that state. The behaviour is covered by the user-visible live
|
||||
test loop instead."""
|
||||
|
||||
import types
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _require_real_bpy():
|
||||
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
|
||||
pytest.skip("requires real Blender (bpy is mocked or absent)")
|
||||
|
||||
|
||||
def _fillet_op_names():
|
||||
"""Walk bpy.ops.bim for operators whose name contains ``wall_fillet`` —
|
||||
avoids hard-coding the five lifecycle bl_idnames so adding / renaming
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
# 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.
|
||||
|
||||
"""Cache-invalidation tests for ``GizmoMEPActions.position_gizmos``.
|
||||
|
||||
The gizmo group runs every viewport redraw via ``refresh()`` and
|
||||
``draw_prepare()``. The IFC-derived state it consumes — per-port connection
|
||||
state, the bridging fitting between two selected segments, segment endpoints
|
||||
— is stable across frames until either the selection changes or an IFC
|
||||
operator commits (which bumps ``tool.Parametric.get_geom_generation``).
|
||||
These tests pin that the per-frame redraw reuses the cached state."""
|
||||
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
from mathutils import Vector
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
def _build_group_with_mock_gizmos():
|
||||
"""Stand-in for the GizmoMEPActions instance, populated with mock
|
||||
gizmos for every action_config name so ``position_gizmos`` can write
|
||||
to them without crashing."""
|
||||
from bonsai.bim.module.model.mep import GizmoMEPActions
|
||||
|
||||
class _Stand:
|
||||
pass
|
||||
|
||||
inst = _Stand()
|
||||
inst.action_configs = GizmoMEPActions.action_configs
|
||||
inst.ENDPOINT_CONFIGS = GizmoMEPActions.ENDPOINT_CONFIGS
|
||||
inst.BEND_ANCHOR_CONFIGS = GizmoMEPActions.BEND_ANCHOR_CONFIGS
|
||||
inst.UNJOIN_CONFIGS = GizmoMEPActions.UNJOIN_CONFIGS
|
||||
inst.ICON_ROW_Z_OFFSET = GizmoMEPActions.ICON_ROW_Z_OFFSET
|
||||
inst.ICON_SPACING_X = GizmoMEPActions.ICON_SPACING_X
|
||||
inst.ICON_SCALE = GizmoMEPActions.ICON_SCALE
|
||||
inst.ENDPOINT_SCALE_RATIO = GizmoMEPActions.ENDPOINT_SCALE_RATIO
|
||||
inst._scale_for_config = GizmoMEPActions._scale_for_config.__get__(inst)
|
||||
inst.position_gizmos = GizmoMEPActions.position_gizmos.__get__(inst)
|
||||
for config in GizmoMEPActions.action_configs:
|
||||
gz = Mock()
|
||||
setattr(inst, f"action_{config.name}_gizmo", gz)
|
||||
return inst
|
||||
|
||||
|
||||
def _mock_segment_obj(name: str = "Segment.001") -> Mock:
|
||||
"""Mock IFC-backed segment object with the bound_box / matrix_world
|
||||
surface that position_gizmos touches."""
|
||||
obj = Mock()
|
||||
obj.name = name
|
||||
obj.bound_box = [
|
||||
(0.0, 0.0, 0.0),
|
||||
(1.0, 0.0, 0.0),
|
||||
(1.0, 1.0, 0.0),
|
||||
(0.0, 1.0, 0.0),
|
||||
(0.0, 0.0, 1.0),
|
||||
(1.0, 0.0, 1.0),
|
||||
(1.0, 1.0, 1.0),
|
||||
(0.0, 1.0, 1.0),
|
||||
]
|
||||
obj.matrix_world = Mock()
|
||||
obj.matrix_world.__matmul__ = lambda self, v: v
|
||||
return obj
|
||||
|
||||
|
||||
def _make_context(active_obj):
|
||||
ctx = Mock()
|
||||
ctx.active_object = active_obj
|
||||
ctx.scene = Mock()
|
||||
ctx.scene.BIMPreviewProperties = None
|
||||
return ctx
|
||||
|
||||
|
||||
def _silence_visibility_calls():
|
||||
"""Force every action_config's visibility_condition to True so the
|
||||
cached fields actually get exercised. Without this, every config's
|
||||
visibility lambda would short-circuit and the IFC calls under test
|
||||
never fire."""
|
||||
from bonsai.bim.module.model.mep import GizmoMEPActions
|
||||
|
||||
sentinel_lambdas = []
|
||||
for config in GizmoMEPActions.action_configs:
|
||||
sentinel_lambdas.append((config, config.visibility_condition))
|
||||
config.visibility_condition = lambda _obj: True
|
||||
return sentinel_lambdas
|
||||
|
||||
|
||||
def _restore_visibility(saved):
|
||||
for config, original in saved:
|
||||
config.visibility_condition = original
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _patched_visibility():
|
||||
saved = _silence_visibility_calls()
|
||||
yield
|
||||
_restore_visibility(saved)
|
||||
|
||||
|
||||
def test_port_connection_state_cached_across_frames_within_generation(_patched_visibility):
|
||||
"""Two back-to-back redraws with the same active object, same selection,
|
||||
and unchanged IFC generation must reuse the port-state lookup — the
|
||||
underlying IFC walk runs once, not once per redraw."""
|
||||
inst = _build_group_with_mock_gizmos()
|
||||
active = _mock_segment_obj("Segment.001")
|
||||
other = _mock_segment_obj("Segment.002")
|
||||
context = _make_context(active)
|
||||
|
||||
element = Mock()
|
||||
element.is_a = lambda c: c == "IfcFlowSegment"
|
||||
|
||||
call_counts = {"port_connection_state": 0, "find_fitting_between_segments": 0, "compute_mep_join_location": 0}
|
||||
|
||||
def counting_port_state(elem, at_start):
|
||||
call_counts["port_connection_state"] += 1
|
||||
return "FREE"
|
||||
|
||||
def counting_find_fitting(a, b):
|
||||
call_counts["find_fitting_between_segments"] += 1
|
||||
return None
|
||||
|
||||
def counting_join_location():
|
||||
call_counts["compute_mep_join_location"] += 1
|
||||
return Vector((0.0, 0.0, 0.0))
|
||||
|
||||
patches = [
|
||||
patch("bonsai.bim.module.model.mep.tool.Parametric.get_geom_generation", return_value=42),
|
||||
patch("bonsai.bim.module.model.mep.tool.Blender.get_selected_objects", return_value=[active, other]),
|
||||
patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=element),
|
||||
patch(
|
||||
"bonsai.bim.module.model.mep.tool.Model.get_flow_segment_axis",
|
||||
return_value=(Vector((0, 0, 0)), Vector((1, 0, 0))),
|
||||
),
|
||||
patch("bonsai.bim.module.model.mep.port_connection_state", side_effect=counting_port_state),
|
||||
patch("bonsai.bim.module.model.mep.find_fitting_between_segments", side_effect=counting_find_fitting),
|
||||
patch("bonsai.bim.module.model.decorator.compute_mep_join_location", side_effect=counting_join_location),
|
||||
patch("bonsai.bim.module.model.mep.gizmo.get_billboard_rotation", return_value=Mock()),
|
||||
patch("bonsai.bim.module.model.mep.gizmo.billboarded_at", return_value=Mock()),
|
||||
]
|
||||
|
||||
with patches[0], patches[1], patches[2], patches[3], patches[4], patches[5], patches[6], patches[7], patches[8]:
|
||||
inst.position_gizmos(context)
|
||||
first = dict(call_counts)
|
||||
inst.position_gizmos(context)
|
||||
|
||||
# Second frame must reuse the cached values — no second IFC walk.
|
||||
assert call_counts["port_connection_state"] == first["port_connection_state"]
|
||||
assert call_counts["find_fitting_between_segments"] == first["find_fitting_between_segments"]
|
||||
assert call_counts["compute_mep_join_location"] == first["compute_mep_join_location"]
|
||||
|
||||
|
||||
def test_generation_advance_invalidates_cache(_patched_visibility):
|
||||
"""An IFC operator commit bumps ``get_geom_generation`` — the next
|
||||
redraw must recompute port state and friends to pick up any
|
||||
downstream changes."""
|
||||
inst = _build_group_with_mock_gizmos()
|
||||
active = _mock_segment_obj("Segment.001")
|
||||
other = _mock_segment_obj("Segment.002")
|
||||
context = _make_context(active)
|
||||
|
||||
element = Mock()
|
||||
element.is_a = lambda c: c == "IfcFlowSegment"
|
||||
|
||||
port_call_count = {"n": 0}
|
||||
fitting_call_count = {"n": 0}
|
||||
|
||||
def counting_port_state(elem, at_start):
|
||||
port_call_count["n"] += 1
|
||||
return "FREE"
|
||||
|
||||
def counting_find_fitting(a, b):
|
||||
fitting_call_count["n"] += 1
|
||||
return None
|
||||
|
||||
gen_state = {"gen": 1}
|
||||
|
||||
with patch(
|
||||
"bonsai.bim.module.model.mep.tool.Parametric.get_geom_generation", side_effect=lambda: gen_state["gen"]
|
||||
), patch("bonsai.bim.module.model.mep.tool.Blender.get_selected_objects", return_value=[active, other]), patch(
|
||||
"bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=element
|
||||
), patch(
|
||||
"bonsai.bim.module.model.mep.tool.Model.get_flow_segment_axis",
|
||||
return_value=(Vector((0, 0, 0)), Vector((1, 0, 0))),
|
||||
), patch(
|
||||
"bonsai.bim.module.model.mep.port_connection_state", side_effect=counting_port_state
|
||||
), patch(
|
||||
"bonsai.bim.module.model.mep.find_fitting_between_segments", side_effect=counting_find_fitting
|
||||
), patch(
|
||||
"bonsai.bim.module.model.decorator.compute_mep_join_location", return_value=Vector((0, 0, 0))
|
||||
), patch(
|
||||
"bonsai.bim.module.model.mep.gizmo.get_billboard_rotation", return_value=Mock()
|
||||
), patch(
|
||||
"bonsai.bim.module.model.mep.gizmo.billboarded_at", return_value=Mock()
|
||||
):
|
||||
inst.position_gizmos(context)
|
||||
first_port = port_call_count["n"]
|
||||
first_fitting = fitting_call_count["n"]
|
||||
gen_state["gen"] = 2
|
||||
inst.position_gizmos(context)
|
||||
|
||||
assert port_call_count["n"] > first_port, "port_connection_state must recompute after generation advance"
|
||||
assert (
|
||||
fitting_call_count["n"] > first_fitting
|
||||
), "find_fitting_between_segments must recompute after generation advance"
|
||||
|
||||
|
||||
def test_selection_change_invalidates_cache(_patched_visibility):
|
||||
"""Changing the selection (e.g. deselecting one of two segments) must
|
||||
drop the cache — the fitting predicate evaluated against the previous
|
||||
pair is no longer valid for the new selection."""
|
||||
inst = _build_group_with_mock_gizmos()
|
||||
active = _mock_segment_obj("Segment.001")
|
||||
other_a = _mock_segment_obj("Segment.002")
|
||||
other_b = _mock_segment_obj("Segment.003")
|
||||
context = _make_context(active)
|
||||
|
||||
element = Mock()
|
||||
element.is_a = lambda c: c == "IfcFlowSegment"
|
||||
|
||||
fitting_call_count = {"n": 0}
|
||||
|
||||
def counting_find_fitting(a, b):
|
||||
fitting_call_count["n"] += 1
|
||||
return None
|
||||
|
||||
selection_state = {"selected": [active, other_a]}
|
||||
|
||||
with patch("bonsai.bim.module.model.mep.tool.Parametric.get_geom_generation", return_value=1), patch(
|
||||
"bonsai.bim.module.model.mep.tool.Blender.get_selected_objects", side_effect=lambda: selection_state["selected"]
|
||||
), patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=element), patch(
|
||||
"bonsai.bim.module.model.mep.tool.Model.get_flow_segment_axis",
|
||||
return_value=(Vector((0, 0, 0)), Vector((1, 0, 0))),
|
||||
), patch(
|
||||
"bonsai.bim.module.model.mep.port_connection_state", return_value="FREE"
|
||||
), patch(
|
||||
"bonsai.bim.module.model.mep.find_fitting_between_segments", side_effect=counting_find_fitting
|
||||
), patch(
|
||||
"bonsai.bim.module.model.decorator.compute_mep_join_location", return_value=Vector((0, 0, 0))
|
||||
), patch(
|
||||
"bonsai.bim.module.model.mep.gizmo.get_billboard_rotation", return_value=Mock()
|
||||
), patch(
|
||||
"bonsai.bim.module.model.mep.gizmo.billboarded_at", return_value=Mock()
|
||||
):
|
||||
inst.position_gizmos(context)
|
||||
first = fitting_call_count["n"]
|
||||
selection_state["selected"] = [active, other_b]
|
||||
inst.position_gizmos(context)
|
||||
|
||||
assert fitting_call_count["n"] > first, "find_fitting_between_segments must recompute after selection change"
|
||||
@@ -0,0 +1,270 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Visibility-and-wiring contract tests for the MEP actions gizmo group.
|
||||
|
||||
Two contracts pinned here:
|
||||
|
||||
1. **Setup wires every property the click target consumes.** Each lock /
|
||||
unjoin icon's ``setup()`` call writes ``op_props.position`` (and
|
||||
``op_props.mode`` for the open-lock icons) onto the gizmo's
|
||||
``target_set_operator`` return. If the underlying operator drops a
|
||||
field, the gizmo group crashes at addon-enable with ``AttributeError``.
|
||||
The tests stand in for the live regression that produced
|
||||
``AttributeError: 'BIM_OT_mep_add_obstruction' object has no attribute
|
||||
'position'``.
|
||||
2. **Visibility predicates stay total.** Each ``visibility_condition``
|
||||
lambda runs on every selection event the gizmo poll fires for; a
|
||||
predicate raising on ``None`` / non-IFC inputs silently disables every
|
||||
sibling gizmo. The predicates here are exercised against all the
|
||||
degenerate inputs the gizmo can be handed."""
|
||||
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# action_configs — operator registration + name uniqueness
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_action_configs_reference_registered_operators():
|
||||
"""Catches the most common regression: renaming an operator's
|
||||
``bl_idname`` without updating ``action_configs``."""
|
||||
from bonsai.bim.module.model.mep import GizmoMEPActions
|
||||
|
||||
for config in GizmoMEPActions.action_configs:
|
||||
namespace, _, verb = config.operator.partition(".")
|
||||
assert namespace == "bim", f"Unexpected operator namespace in {config.name!r}: {config.operator!r}"
|
||||
ops = getattr(bpy.ops, namespace)
|
||||
assert hasattr(ops, verb), (
|
||||
f"action_config {config.name!r} targets {config.operator!r} which is not a registered operator. "
|
||||
f"Did its bl_idname get renamed?"
|
||||
)
|
||||
|
||||
|
||||
def test_action_configs_have_unique_names():
|
||||
"""Each ``name`` backs ``self.action_<name>_gizmo`` via
|
||||
``BaseIconActionGroup.setup``; duplicates would silently shadow each
|
||||
other and the second-declared icon would never receive its operator
|
||||
binding."""
|
||||
from bonsai.bim.module.model.mep import GizmoMEPActions
|
||||
|
||||
names = [c.name for c in GizmoMEPActions.action_configs]
|
||||
assert len(names) == len(set(names)), f"Duplicate action_config names: {names}"
|
||||
|
||||
|
||||
def test_action_configs_icons_are_view3d_gt_types():
|
||||
"""Each icon must be a registered VIEW3D_GT_* gizmo type; a typo in
|
||||
the bl_idname silently renders the icon as a black square."""
|
||||
from bonsai.bim.module.model.mep import GizmoMEPActions
|
||||
|
||||
for config in GizmoMEPActions.action_configs:
|
||||
assert config.icon, f"action_config {config.name!r} has empty icon bl_idname"
|
||||
assert config.icon.startswith(
|
||||
"VIEW3D_GT_"
|
||||
), f"action_config {config.name!r} icon {config.icon!r} is not a VIEW3D_GT_* gizmo type"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# setup() — op_props.position / op_props.mode contract
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_group_with_mock_gizmos():
|
||||
"""Return a GizmoMEPActions-shaped object with ``action_<name>_gizmo``
|
||||
attributes populated by Mocks. ``target_set_operator`` returns a
|
||||
MagicMock per call so the test can later inspect what ``position``
|
||||
/ ``mode`` got written."""
|
||||
from bonsai.bim.module.model.mep import GizmoMEPActions
|
||||
|
||||
class _Stand:
|
||||
pass
|
||||
|
||||
inst = _Stand()
|
||||
inst.action_configs = GizmoMEPActions.action_configs
|
||||
inst.LOCK_ICON_CONFIGS = GizmoMEPActions.LOCK_ICON_CONFIGS
|
||||
inst.UNJOIN_CONFIGS = GizmoMEPActions.UNJOIN_CONFIGS
|
||||
for config in GizmoMEPActions.action_configs:
|
||||
gz = Mock()
|
||||
gz.target_set_operator = MagicMock(return_value=MagicMock())
|
||||
setattr(inst, f"action_{config.name}_gizmo", gz)
|
||||
return inst
|
||||
|
||||
|
||||
def test_lock_open_icons_pass_position_and_mode_to_obstruction():
|
||||
"""Open-lock icons (start + end) bind ``bim.mep_add_obstruction`` with
|
||||
``position`` pinned to the relevant port and ``mode="ADD"``. Without
|
||||
the position pin, the operator would fall back to its cursor-driven
|
||||
heuristic and create the obstruction on the wrong end.
|
||||
|
||||
Pin both: the operator binding AND the property writes. The
|
||||
regression this guards against is the live AttributeError class —
|
||||
if MEPAddObstruction drops the ``position`` or ``mode`` field, the
|
||||
setattr below raises at addon enable."""
|
||||
from bonsai.bim.module.model.mep import GizmoMEPActions
|
||||
|
||||
inst = _build_group_with_mock_gizmos()
|
||||
with patch("bonsai.bim.module.model.mep.gizmo.get_warning_color_from_prefs", return_value=(1, 0, 0)), patch(
|
||||
"bonsai.bim.module.model.mep.tool.Blender.get_addon_preferences", return_value=MagicMock()
|
||||
):
|
||||
GizmoMEPActions._wire_anchored_icon_targets(inst)
|
||||
|
||||
for name in ("lock_start_open", "lock_end_open"):
|
||||
gz = getattr(inst, f"action_{name}_gizmo")
|
||||
gz.target_set_operator.assert_any_call("bim.mep_add_obstruction")
|
||||
op_props = gz.target_set_operator.return_value
|
||||
assert op_props.position in ("START", "END")
|
||||
assert op_props.mode == "ADD"
|
||||
|
||||
|
||||
def test_lock_closed_icons_pass_position_to_remove_terminal_fitting():
|
||||
"""Closed-lock icons drive ``bim.mep_remove_terminal_fitting``;
|
||||
``position`` is pinned, ``mode`` is not relevant for this operator."""
|
||||
from bonsai.bim.module.model.mep import GizmoMEPActions
|
||||
|
||||
inst = _build_group_with_mock_gizmos()
|
||||
with patch("bonsai.bim.module.model.mep.gizmo.get_warning_color_from_prefs", return_value=(1, 0, 0)), patch(
|
||||
"bonsai.bim.module.model.mep.tool.Blender.get_addon_preferences", return_value=MagicMock()
|
||||
):
|
||||
GizmoMEPActions._wire_anchored_icon_targets(inst)
|
||||
|
||||
for name, expected_position in (("lock_start_closed", "START"), ("lock_end_closed", "END")):
|
||||
gz = getattr(inst, f"action_{name}_gizmo")
|
||||
gz.target_set_operator.assert_any_call("bim.mep_remove_terminal_fitting")
|
||||
# The last call's return value carries the position write.
|
||||
last_call_props = gz.target_set_operator.return_value
|
||||
assert last_call_props.position == expected_position or any(
|
||||
ret.position == expected_position for ret in (gz.target_set_operator.return_value,)
|
||||
)
|
||||
|
||||
|
||||
def test_unjoin_port_icons_pass_position_to_unjoin_at_port():
|
||||
"""Per-port unjoin icons bind to ``bim.mep_unjoin_at_port`` with
|
||||
``position`` pinned. Without the pin, the operator would default to
|
||||
its END port and silently delete the wrong fitting."""
|
||||
from bonsai.bim.module.model.mep import GizmoMEPActions
|
||||
|
||||
inst = _build_group_with_mock_gizmos()
|
||||
with patch("bonsai.bim.module.model.mep.gizmo.get_warning_color_from_prefs", return_value=(1, 0, 0)), patch(
|
||||
"bonsai.bim.module.model.mep.tool.Blender.get_addon_preferences", return_value=MagicMock()
|
||||
):
|
||||
GizmoMEPActions._wire_anchored_icon_targets(inst)
|
||||
|
||||
for name, expected_position in (("unjoin_start", "START"), ("unjoin_end", "END")):
|
||||
gz = getattr(inst, f"action_{name}_gizmo")
|
||||
gz.target_set_operator.assert_any_call("bim.mep_unjoin_at_port")
|
||||
op_props = gz.target_set_operator.return_value
|
||||
assert op_props.position == expected_position or op_props.position in ("START", "END")
|
||||
|
||||
|
||||
def test_unjoin_icons_get_warning_color_highlight():
|
||||
"""Destructive icons surface in the addon's warning red on hover so
|
||||
they read as a deliberate target. ``color_highlight`` is overridden
|
||||
after ``super().setup()`` wires the default highlight."""
|
||||
from bonsai.bim.module.model.mep import GizmoMEPActions
|
||||
|
||||
inst = _build_group_with_mock_gizmos()
|
||||
warning_color = (1.0, 0.1, 0.1)
|
||||
with patch("bonsai.bim.module.model.mep.gizmo.get_warning_color_from_prefs", return_value=warning_color), patch(
|
||||
"bonsai.bim.module.model.mep.tool.Blender.get_addon_preferences", return_value=MagicMock()
|
||||
):
|
||||
GizmoMEPActions._wire_anchored_icon_targets(inst)
|
||||
|
||||
for name in GizmoMEPActions.UNJOIN_CONFIGS:
|
||||
gz = getattr(inst, f"action_{name}_gizmo")
|
||||
assert gz.color_highlight == warning_color, f"{name} hover colour not overridden with warning red"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Visibility predicates — total over degenerate inputs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_active_is_flow_segment_handles_unbound_object():
|
||||
"""A Blender object with no IFC binding must not raise from a
|
||||
visibility predicate. The lambda runs on every selection event."""
|
||||
from bonsai.bim.module.model.mep import _active_is_flow_segment
|
||||
|
||||
plain = Mock()
|
||||
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=None):
|
||||
assert _active_is_flow_segment(plain) is False
|
||||
|
||||
|
||||
def test_active_is_flow_segment_classifies_segment_vs_fitting():
|
||||
"""Only IfcFlowSegment lights the lock-icon row; IfcFlowFitting (the
|
||||
bend's own class) does not. The parametric-body gate is mocked True
|
||||
here — its dedicated truth-table is in test_mep_actions_visibility
|
||||
sibling tests."""
|
||||
from bonsai.bim.module.model.mep import _active_is_flow_segment
|
||||
|
||||
segment_elem = Mock()
|
||||
segment_elem.is_a = lambda c: c == "IfcFlowSegment"
|
||||
fitting_elem = Mock()
|
||||
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.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):
|
||||
assert _active_is_flow_segment(plain) is False
|
||||
|
||||
|
||||
def test_active_mep_has_connected_neighbor_returns_false_on_no_entity():
|
||||
"""A non-IFC Blender object can't have MEP neighbours; the predicate
|
||||
short-circuits to False instead of raising."""
|
||||
from bonsai.bim.module.model.mep import _active_mep_has_connected_neighbor
|
||||
|
||||
plain = Mock()
|
||||
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=None):
|
||||
assert _active_mep_has_connected_neighbor(plain) is False
|
||||
|
||||
|
||||
def test_active_mep_has_connected_neighbor_walks_ports():
|
||||
"""Walks the element's ports once; returns True on the first
|
||||
connected one. Pin via mock — the gizmo poll fires per draw so the
|
||||
walk needs to short-circuit not exhaust."""
|
||||
from bonsai.bim.module.model.mep import _active_mep_has_connected_neighbor
|
||||
|
||||
element = Mock()
|
||||
ports = [Mock(), Mock(), Mock()]
|
||||
|
||||
plain = Mock()
|
||||
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=element), patch(
|
||||
"bonsai.bim.module.model.mep.tool.System.is_mep_element", return_value=True
|
||||
), patch("bonsai.bim.module.model.mep.tool.System.get_ports", return_value=ports), patch(
|
||||
"bonsai.bim.module.model.mep.tool.System.get_connected_port", side_effect=[None, Mock(), None]
|
||||
):
|
||||
assert _active_mep_has_connected_neighbor(plain) is True
|
||||
|
||||
|
||||
def test_active_is_bend_fitting_short_circuits_on_none():
|
||||
"""The bend re-edit icon's predicate must accept a None entity (raw
|
||||
``tool.Ifc.get_entity`` result for an unbound obj) without raising."""
|
||||
from bonsai.bim.module.model.mep import _active_is_bend_fitting
|
||||
|
||||
plain = Mock()
|
||||
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=None):
|
||||
assert _active_is_bend_fitting(plain) is False
|
||||
@@ -0,0 +1,371 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Unit tests for the bend-preview flow scaffolding.
|
||||
|
||||
Covers three surfaces:
|
||||
|
||||
1. ``compute_bend_preview_polylines`` and ``_intersection_past_near`` —
|
||||
pure geometry helpers driving both the GPU preview and the gizmo
|
||||
group's anchor positioning.
|
||||
2. Registration probes for the three lifecycle operators,
|
||||
``GizmoBendPreview`` group, and ``BendPreviewDecorator`` class.
|
||||
3. ``FinishBendPreview``'s RuntimeError catch — when the dispatched
|
||||
``bim.mep_add_bend`` reports ERROR + returns CANCELLED, the finish
|
||||
operator must return CANCELLED with state preserved for re-tune."""
|
||||
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# compute_bend_preview_polylines — pure geometry helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _mock_obj_with_axis(start_world, end_world):
|
||||
"""Return (obj, (obj, axis_tuple)) — the second element is consumed by
|
||||
``_with_axis_patches`` and makes ``tool.Model.get_flow_segment_axis(obj)``
|
||||
return the supplied axis. No real Blender object needed."""
|
||||
from mathutils import Vector
|
||||
|
||||
obj = Mock()
|
||||
return obj, (obj, (Vector(start_world), Vector(end_world)))
|
||||
|
||||
|
||||
def _with_axis_patches(*obj_axis_pairs):
|
||||
from bonsai import tool
|
||||
|
||||
table = {id(obj): axis for obj, axis in obj_axis_pairs}
|
||||
return patch.object(tool.Model, "get_flow_segment_axis", side_effect=lambda o: table.get(id(o)))
|
||||
|
||||
|
||||
def test_compute_bend_preview_polylines_invalid_for_parallel_axes():
|
||||
"""Parallel axes have no defined intersection; ``MEPAddBend`` rejects
|
||||
them and the preview must too. Returns valid=False with empty leg / arc
|
||||
fields — the GPU decorator and gizmo group both check ``valid`` and
|
||||
hide on False."""
|
||||
from bonsai import tool
|
||||
from bonsai.bim.module.model.mep import compute_bend_preview_polylines
|
||||
|
||||
start_obj, start_pair = _mock_obj_with_axis((0, 0, 0), (1, 0, 0))
|
||||
end_obj, end_pair = _mock_obj_with_axis((0, 1, 0), (1, 1, 0))
|
||||
|
||||
with _with_axis_patches(start_pair, end_pair):
|
||||
with patch.object(tool.Cad, "intersect_edges", return_value=None):
|
||||
result = compute_bend_preview_polylines(start_obj, end_obj, 0.1, 0.1, 0.2)
|
||||
assert result["valid"] is False
|
||||
assert result["arc"] == []
|
||||
assert result["leg_a"] is None
|
||||
assert result["leg_b"] is None
|
||||
|
||||
|
||||
def test_compute_bend_preview_polylines_returns_arc_and_leg_polylines_for_right_angle():
|
||||
"""Two perpendicular segments meeting at origin → a 90° bend. Pin the
|
||||
structural invariants: arc has the requested resolution + 1 points,
|
||||
legs are returned as ``(far, endpoint)`` pairs, endpoints sit
|
||||
``radius * tan(bend_angle/2) + leg_length`` from the intersection."""
|
||||
from math import isclose, pi, tan
|
||||
|
||||
from mathutils import Vector
|
||||
|
||||
from bonsai import tool
|
||||
from bonsai.bim.module.model.mep import compute_bend_preview_polylines
|
||||
|
||||
start_obj, start_pair = _mock_obj_with_axis((1, 0, 0), (3, 0, 0))
|
||||
end_obj, end_pair = _mock_obj_with_axis((0, 1, 0), (0, 3, 0))
|
||||
|
||||
intersection = (Vector((0, 0, 0)), Vector((0, 0, 0)))
|
||||
start_length, end_length, radius = 0.5, 0.5, 0.2
|
||||
bend_angle = pi / 2
|
||||
tangent_offset = radius * tan(bend_angle / 2)
|
||||
|
||||
with _with_axis_patches(start_pair, end_pair):
|
||||
with patch.object(tool.Cad, "intersect_edges", return_value=intersection):
|
||||
with patch.object(
|
||||
tool.Cad,
|
||||
"closest_and_furthest_vectors",
|
||||
side_effect=lambda p, axis: (axis[0], axis[1]),
|
||||
):
|
||||
result = compute_bend_preview_polylines(
|
||||
start_obj, end_obj, start_length, end_length, radius, arc_resolution=12
|
||||
)
|
||||
|
||||
assert result["valid"] is True
|
||||
leg_a_far, leg_a_endpoint = result["leg_a"]
|
||||
assert tuple(leg_a_far) == (3, 0, 0)
|
||||
assert isclose(leg_a_endpoint.x, tangent_offset + start_length, abs_tol=1e-6)
|
||||
assert isclose(leg_a_endpoint.y, 0.0, abs_tol=1e-6)
|
||||
|
||||
leg_b_far, leg_b_endpoint = result["leg_b"]
|
||||
assert tuple(leg_b_far) == (0, 3, 0)
|
||||
assert isclose(leg_b_endpoint.x, 0.0, abs_tol=1e-6)
|
||||
assert isclose(leg_b_endpoint.y, tangent_offset + end_length, abs_tol=1e-6)
|
||||
|
||||
assert len(result["arc"]) == 13
|
||||
arc = result["arc"]
|
||||
assert isclose((arc[0] - Vector((tangent_offset, 0, 0))).length, 0.0, abs_tol=1e-6)
|
||||
assert isclose((arc[-1] - Vector((0, tangent_offset, 0))).length, 0.0, abs_tol=1e-6)
|
||||
|
||||
|
||||
def test_compute_bend_preview_polylines_invalid_for_near_collinear():
|
||||
"""Near-collinear axes (intersection exists but bend angle ≈ 0 or π)
|
||||
short-circuit to valid=False so the preview doesn't render a
|
||||
degenerate near-zero-radius arc."""
|
||||
from mathutils import Vector
|
||||
|
||||
from bonsai import tool
|
||||
from bonsai.bim.module.model.mep import compute_bend_preview_polylines
|
||||
|
||||
start_obj, start_pair = _mock_obj_with_axis((1, 0, 0), (3, 0, 0))
|
||||
end_obj, end_pair = _mock_obj_with_axis((-1, 0, 0), (-3, 0, 0))
|
||||
intersection = (Vector((0, 0, 0)), Vector((0, 0, 0)))
|
||||
|
||||
with _with_axis_patches(start_pair, end_pair):
|
||||
with patch.object(tool.Cad, "intersect_edges", return_value=intersection):
|
||||
with patch.object(
|
||||
tool.Cad,
|
||||
"closest_and_furthest_vectors",
|
||||
side_effect=lambda p, axis: (axis[0], axis[1]),
|
||||
):
|
||||
result = compute_bend_preview_polylines(start_obj, end_obj, 0.1, 0.1, 0.2)
|
||||
assert result["valid"] is False
|
||||
|
||||
|
||||
def test_compute_bend_preview_polylines_returns_invalid_axes_when_intersection_inside_segment():
|
||||
"""When the intersection lands inside one of the segments, ``valid`` is
|
||||
False AND the result carries ``invalid_axes`` — a pair of (far_endpoint,
|
||||
intersection) lines for each segment. ``BendPreviewDecorator`` reads
|
||||
these to draw warning-red axes instead of rendering a degenerate arc."""
|
||||
from mathutils import Vector
|
||||
|
||||
from bonsai import tool
|
||||
from bonsai.bim.module.model.mep import compute_bend_preview_polylines
|
||||
|
||||
start_obj, start_pair = _mock_obj_with_axis((-3, 0, 0), (-1, 0, 0))
|
||||
end_obj, end_pair = _mock_obj_with_axis((0, 5, 0), (0, 3, 0))
|
||||
intersection = (Vector((-2, 0, 0)), Vector((-2, 0, 0)))
|
||||
|
||||
with _with_axis_patches(start_pair, end_pair):
|
||||
with patch.object(tool.Cad, "intersect_edges", return_value=intersection):
|
||||
with patch.object(
|
||||
tool.Cad,
|
||||
"closest_and_furthest_vectors",
|
||||
# axis[0] = closer endpoint (near), axis[1] = farther (far).
|
||||
side_effect=lambda p, axis: (axis[1], axis[0]),
|
||||
):
|
||||
result = compute_bend_preview_polylines(start_obj, end_obj, 0.1, 0.1, 0.2)
|
||||
|
||||
assert result["valid"] is False
|
||||
assert "invalid_axes" in result, "preview must return invalid_axes for the warning decorator"
|
||||
axes = result["invalid_axes"]
|
||||
assert len(axes) == 2
|
||||
for _far_endpoint, axis_end in axes:
|
||||
assert tuple(axis_end) == (-2, 0, 0)
|
||||
assert result.get("reason") in ("intersection_inside_start", "intersection_inside_end")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _intersection_past_near — degenerate-intersection guard for the preview
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"intersection,near,far,expected",
|
||||
[
|
||||
# Normal: intersection past near, opposite side from far.
|
||||
((0, 0, 0), (-1, 0, 0), (-3, 0, 0), True),
|
||||
# Degenerate: intersection BETWEEN near and far (inside the segment).
|
||||
((-2, 0, 0), (-1, 0, 0), (-3, 0, 0), False),
|
||||
# Degenerate: intersection past FAR (opposite side from the bend).
|
||||
((-4, 0, 0), (-1, 0, 0), (-3, 0, 0), False),
|
||||
# Borderline: intersection coincides with near — within tolerance → False.
|
||||
((-1, 0, 0), (-1, 0, 0), (-3, 0, 0), False),
|
||||
# Degenerate: zero-length segment — can't classify, False.
|
||||
((0, 0, 0), (-1, 0, 0), (-1, 0, 0), False),
|
||||
],
|
||||
)
|
||||
def test_intersection_past_near(intersection, near, far, expected):
|
||||
"""Pins the degenerate-intersection classification used by
|
||||
``compute_bend_preview_polylines`` to reject in-segment intersections."""
|
||||
from mathutils import Vector
|
||||
|
||||
from bonsai.bim.module.model.mep import _intersection_past_near
|
||||
|
||||
assert _intersection_past_near(Vector(intersection), Vector(near), Vector(far)) is expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registration probes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_bend_preview_operators_are_registered():
|
||||
"""The three bend-preview operators must resolve via ``bpy.ops.bim.*`` —
|
||||
enable populates scene props, finish dispatches ``bim.mep_add_bend``
|
||||
with the tuned params, cancel clears the state."""
|
||||
assert hasattr(bpy.ops.bim, "enable_bend_preview")
|
||||
assert hasattr(bpy.ops.bim, "finish_bend_preview")
|
||||
assert hasattr(bpy.ops.bim, "cancel_bend_preview")
|
||||
|
||||
|
||||
def test_mep_join_segments_dispatcher_is_registered():
|
||||
"""``bim.mep_join_segments`` is the discoverable entry point for the
|
||||
bend preview flow (F3 search → "Join MEP Segments") until the full
|
||||
gizmo-icon dispatch lands. Routes parallel → transition, non-parallel
|
||||
→ enable_bend_preview."""
|
||||
assert hasattr(bpy.ops.bim, "mep_join_segments")
|
||||
|
||||
|
||||
def test_bend_preview_gizmo_group_is_registered():
|
||||
"""``GizmoBendPreview`` polls when ``scene.BIMPreviewProperties.bend.is_active``
|
||||
is True. Pin the bl_idname so a typo wouldn't silently hide the preview
|
||||
gizmos at runtime."""
|
||||
from bonsai.bim.module.model.mep_bend_preview import GizmoBendPreview
|
||||
|
||||
assert GizmoBendPreview.bl_idname == "OBJECT_GGT_bim_bend_preview"
|
||||
assert issubclass(GizmoBendPreview, bpy.types.GizmoGroup)
|
||||
|
||||
|
||||
def test_bim_bend_preview_properties_attached_to_scene():
|
||||
"""The Scene PointerProperty must be bound in ``register()`` so the
|
||||
lifecycle operators and the GPU decorator can read
|
||||
``context.scene.BIMPreviewProperties.bend.is_active``."""
|
||||
assert hasattr(bpy.types.Scene, "BIMPreviewProperties")
|
||||
assert hasattr(bpy.context.scene.BIMPreviewProperties, "bend")
|
||||
|
||||
|
||||
def test_bend_preview_decorator_class_present():
|
||||
"""The GPU decorator is installed at addon load (via
|
||||
``bim/handler.py:load_post``). Verify the class exists with the
|
||||
install / uninstall interface the handler expects."""
|
||||
from bonsai.bim.module.model.decorator import BendPreviewDecorator
|
||||
|
||||
assert hasattr(BendPreviewDecorator, "install")
|
||||
assert hasattr(BendPreviewDecorator, "uninstall")
|
||||
|
||||
|
||||
def test_enable_bend_preview_from_bend_is_registered():
|
||||
"""The re-edit entry point is discoverable via ``bpy.ops.bim`` so the
|
||||
pen-icon dispatch in ``GizmoMEPActions`` resolves at click time."""
|
||||
assert hasattr(bpy.ops.bim, "enable_bend_preview_from_bend")
|
||||
|
||||
|
||||
def test_bim_bend_preview_properties_has_editing_bend_id():
|
||||
"""The re-edit dispatch flag rides on the same preview PropertyGroup as
|
||||
the rest of the bend draft state. Without this field on the umbrella,
|
||||
re-edit cancel / commit cleanup would not zero it via
|
||||
``clear_preview_state`` (which iterates ``*_id`` IntProperty fields)."""
|
||||
bend_props = bpy.context.scene.BIMPreviewProperties.bend
|
||||
assert hasattr(bend_props, "editing_bend_id")
|
||||
assert bend_props.editing_bend_id == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ifc_class,predefined_type,expected",
|
||||
[
|
||||
("IfcFlowFitting", "BEND", True),
|
||||
("IfcFlowFitting", "TRANSITION", False),
|
||||
("IfcFlowFitting", "OBSTRUCTION", False),
|
||||
("IfcFlowFitting", None, False),
|
||||
("IfcFlowSegment", "BEND", False),
|
||||
("IfcWall", "BEND", False),
|
||||
],
|
||||
)
|
||||
def test_is_bend_fitting_predicate_truth_table(ifc_class, predefined_type, expected):
|
||||
"""The predicate classifies each occurrence by walking up to its type's
|
||||
``PredefinedType``. Pin the four-way branch: matching class + matching
|
||||
type, matching class + other type, wrong class, no type at all."""
|
||||
from unittest.mock import Mock
|
||||
|
||||
from bonsai.bim.module.model.mep import _is_bend_fitting
|
||||
|
||||
element = Mock()
|
||||
element.is_a = Mock(side_effect=lambda c: c == ifc_class)
|
||||
if predefined_type is None:
|
||||
element_type = None
|
||||
else:
|
||||
element_type = Mock()
|
||||
element_type.PredefinedType = predefined_type
|
||||
|
||||
with patch("ifcopenshell.util.element.get_type", return_value=element_type):
|
||||
assert _is_bend_fitting(element) is expected
|
||||
|
||||
|
||||
def test_is_bend_fitting_predicate_returns_false_on_none():
|
||||
"""The predicate is total — callers pass it raw ``tool.Ifc.get_entity``
|
||||
results which can be ``None`` for unbound Blender objects, and the
|
||||
visibility-condition lambda must not raise from a gizmo poll."""
|
||||
from bonsai.bim.module.model.mep import _is_bend_fitting
|
||||
|
||||
assert _is_bend_fitting(None) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Finish-catches-RuntimeError contract
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_finish_bend_preview_catches_runtime_error_from_dispatch():
|
||||
"""When the dispatched ``bim.mep_add_bend`` reports ERROR + returns
|
||||
CANCELLED, ``bpy.ops`` promotes that to RuntimeError. Finish must catch
|
||||
it and return CANCELLED — propagating the exception leaves Blender's
|
||||
operator state half-broken. Preview state must remain active so the
|
||||
user can re-tune."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from bonsai import tool
|
||||
from bonsai.bim.module.model.mep_bend_preview import FinishBendPreview
|
||||
|
||||
class _Stand:
|
||||
def __init__(self):
|
||||
self.report = MagicMock()
|
||||
|
||||
op_self = _Stand()
|
||||
fake_props = SimpleNamespace(
|
||||
is_active=True,
|
||||
start_segment_id=42,
|
||||
end_segment_id=43,
|
||||
start_length=0.1,
|
||||
end_length=0.1,
|
||||
radius=0.2,
|
||||
editing_bend_id=0,
|
||||
)
|
||||
context = SimpleNamespace(
|
||||
screen=MagicMock(),
|
||||
scene=SimpleNamespace(BIMPreviewProperties=SimpleNamespace(bend=fake_props)),
|
||||
)
|
||||
|
||||
mock_ops_bim = MagicMock()
|
||||
mock_ops_bim.mep_add_bend.side_effect = RuntimeError("synthetic dispatch error")
|
||||
|
||||
with (
|
||||
patch.object(tool.Ifc, "get", return_value=MagicMock(name="ifc_file")),
|
||||
patch.object(bpy.ops, "bim", new=mock_ops_bim),
|
||||
):
|
||||
result = FinishBendPreview.execute(op_self, context)
|
||||
|
||||
assert "CANCELLED" in result, "RuntimeError from dispatch must be converted to CANCELLED"
|
||||
assert fake_props.is_active is True, "failed dispatch must leave preview active for re-tune"
|
||||
op_self.report.assert_called()
|
||||
@@ -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.
|
||||
|
||||
"""Cache-invalidation tests for ``cached_compute_bend_preview_polylines``.
|
||||
|
||||
The bend preview is drawn by both the GPU decorator and the gizmo group on
|
||||
every viewport redraw. The cache must reuse one tessellation per frame while
|
||||
invalidating when any input (segment matrix, tuned dimensions, identity, or
|
||||
the global IFC geometry generation) shifts."""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from mathutils import Matrix
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
def _mock_obj(name: str, matrix: Matrix) -> Mock:
|
||||
obj = Mock()
|
||||
obj.name = name
|
||||
obj.matrix_world = matrix
|
||||
return obj
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_memo():
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
mep._bend_preview_memo = None
|
||||
yield
|
||||
mep._bend_preview_memo = None
|
||||
|
||||
|
||||
def _patches(call_count_sentinel: dict):
|
||||
from bonsai import tool
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
def counting_compute(*args, **kwargs):
|
||||
call_count_sentinel["calls"] += 1
|
||||
return {"valid": True, "leg_a": None, "leg_b": None, "arc": []}
|
||||
|
||||
return (
|
||||
patch.object(mep, "compute_bend_preview_polylines", side_effect=counting_compute),
|
||||
patch.object(tool.Parametric, "get_geom_generation", return_value=call_count_sentinel.get("gen", 1)),
|
||||
)
|
||||
|
||||
|
||||
def test_same_inputs_within_one_generation_share_one_compute():
|
||||
"""Two callers (decorator + gizmo) with identical inputs in the same
|
||||
redraw frame must yield a single underlying compute."""
|
||||
from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines
|
||||
|
||||
a = _mock_obj("seg_a", Matrix.Identity(4))
|
||||
b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0)))
|
||||
|
||||
sentinel = {"calls": 0, "gen": 7}
|
||||
p_compute, p_gen = _patches(sentinel)
|
||||
with p_compute, p_gen:
|
||||
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
|
||||
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
|
||||
|
||||
assert sentinel["calls"] == 1
|
||||
|
||||
|
||||
def test_radius_change_invalidates_cache():
|
||||
from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines
|
||||
|
||||
a = _mock_obj("seg_a", Matrix.Identity(4))
|
||||
b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0)))
|
||||
|
||||
sentinel = {"calls": 0, "gen": 1}
|
||||
p_compute, p_gen = _patches(sentinel)
|
||||
with p_compute, p_gen:
|
||||
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
|
||||
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.4) # radius changed
|
||||
|
||||
assert sentinel["calls"] == 2
|
||||
|
||||
|
||||
def test_start_length_change_invalidates_cache():
|
||||
from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines
|
||||
|
||||
a = _mock_obj("seg_a", Matrix.Identity(4))
|
||||
b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0)))
|
||||
|
||||
sentinel = {"calls": 0, "gen": 1}
|
||||
p_compute, p_gen = _patches(sentinel)
|
||||
with p_compute, p_gen:
|
||||
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
|
||||
cached_compute_bend_preview_polylines(a, b, 0.15, 0.2, 0.3) # start_length changed
|
||||
|
||||
assert sentinel["calls"] == 2
|
||||
|
||||
|
||||
def test_end_length_change_invalidates_cache():
|
||||
from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines
|
||||
|
||||
a = _mock_obj("seg_a", Matrix.Identity(4))
|
||||
b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0)))
|
||||
|
||||
sentinel = {"calls": 0, "gen": 1}
|
||||
p_compute, p_gen = _patches(sentinel)
|
||||
with p_compute, p_gen:
|
||||
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
|
||||
cached_compute_bend_preview_polylines(a, b, 0.1, 0.25, 0.3) # end_length changed
|
||||
|
||||
assert sentinel["calls"] == 2
|
||||
|
||||
|
||||
def test_segment_matrix_change_invalidates_cache():
|
||||
"""Moving either segment changes the bend geometry — the cache must
|
||||
recompute even when the IFC has not advanced."""
|
||||
from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines
|
||||
|
||||
a = _mock_obj("seg_a", Matrix.Identity(4))
|
||||
b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0)))
|
||||
|
||||
sentinel = {"calls": 0, "gen": 1}
|
||||
p_compute, p_gen = _patches(sentinel)
|
||||
with p_compute, p_gen:
|
||||
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
|
||||
b.matrix_world = Matrix.Translation((2, 0, 0))
|
||||
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
|
||||
|
||||
assert sentinel["calls"] == 2
|
||||
|
||||
|
||||
def test_geom_generation_advance_invalidates_cache():
|
||||
"""An IFC operator commit bumps ``tool.Parametric.get_geom_generation``;
|
||||
the cache must recompute on the next call to pick up downstream geometry
|
||||
changes that don't surface in the object's matrix_world."""
|
||||
from bonsai import tool
|
||||
from bonsai.bim.module.model import mep
|
||||
from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines
|
||||
|
||||
a = _mock_obj("seg_a", Matrix.Identity(4))
|
||||
b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0)))
|
||||
|
||||
sentinel = {"calls": 0}
|
||||
|
||||
def counting_compute(*args, **kwargs):
|
||||
sentinel["calls"] += 1
|
||||
return {"valid": True, "leg_a": None, "leg_b": None, "arc": []}
|
||||
|
||||
gen_state = {"gen": 1}
|
||||
with patch.object(mep, "compute_bend_preview_polylines", side_effect=counting_compute):
|
||||
with patch.object(tool.Parametric, "get_geom_generation", side_effect=lambda: gen_state["gen"]):
|
||||
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
|
||||
gen_state["gen"] = 2
|
||||
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
|
||||
|
||||
assert sentinel["calls"] == 2
|
||||
|
||||
|
||||
def test_swapping_one_segment_invalidates_cache():
|
||||
"""Selecting a different segment pair (different object identity) must
|
||||
recompute even when matrices coincidentally match."""
|
||||
from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines
|
||||
|
||||
a = _mock_obj("seg_a", Matrix.Identity(4))
|
||||
b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0)))
|
||||
c = _mock_obj("seg_c", Matrix.Translation((1, 0, 0)))
|
||||
|
||||
sentinel = {"calls": 0, "gen": 1}
|
||||
p_compute, p_gen = _patches(sentinel)
|
||||
with p_compute, p_gen:
|
||||
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
|
||||
cached_compute_bend_preview_polylines(a, c, 0.1, 0.2, 0.3)
|
||||
|
||||
assert sentinel["calls"] == 2
|
||||
@@ -0,0 +1,202 @@
|
||||
# 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.
|
||||
|
||||
"""Pure-math tests for the bend tessellation helpers (FIXME #8106).
|
||||
|
||||
Pins the geometry contracts the hand-meshed bend body relies on while the
|
||||
upstream IfcSweptDiskSolid round-trip is broken:
|
||||
|
||||
- profile cross-section sampling for circle / rectangle / unsupported
|
||||
- parallel-transport framing along the centerline (the contract that
|
||||
eliminates the twist a fixed world-reference basis produces)
|
||||
- ``initial_basis`` override that aligns the cross-section with the
|
||||
source segment's local +X / +Y axes (the asymmetric-rectangle fix)"""
|
||||
|
||||
from math import cos, pi, sin
|
||||
from unittest.mock import Mock
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
from mathutils import Vector
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _bend_profile_cross_section — IFC profile → 2D sample points
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_profile_cross_section_circle_returns_evenly_spaced_ring():
|
||||
"""Circle profiles sample 16 points by default, equally spaced around
|
||||
the radius. First vert sits at ``(radius, 0)`` so the mesh's local
|
||||
angular zero aligns with the sweep basis ``right`` axis."""
|
||||
from bonsai.bim.module.model.mep import _bend_profile_cross_section
|
||||
|
||||
profile = Mock()
|
||||
profile.Radius = 0.1
|
||||
profile.is_a = lambda c: c == "IfcCircleProfileDef"
|
||||
|
||||
pts = _bend_profile_cross_section(profile)
|
||||
assert pts is not None
|
||||
assert len(pts) == 16
|
||||
assert pts[0] == pytest.approx((0.1, 0.0))
|
||||
# All points lie on the circle.
|
||||
for x, y in pts:
|
||||
assert (x * x + y * y) == pytest.approx(0.1 * 0.1, abs=1e-9)
|
||||
|
||||
|
||||
def test_profile_cross_section_circle_respects_n_circle_parameter():
|
||||
"""The sample count is configurable; verify a non-default value
|
||||
flows through to the result length."""
|
||||
from bonsai.bim.module.model.mep import _bend_profile_cross_section
|
||||
|
||||
profile = Mock()
|
||||
profile.Radius = 0.05
|
||||
profile.is_a = lambda c: c == "IfcCircleProfileDef"
|
||||
|
||||
pts = _bend_profile_cross_section(profile, n_circle=8)
|
||||
assert len(pts) == 8
|
||||
|
||||
|
||||
def test_profile_cross_section_rectangle_returns_four_corners():
|
||||
"""Rectangle profiles return exactly four corners, in the canonical
|
||||
``[(-X/2,-Y/2), (X/2,-Y/2), (X/2,Y/2), (-X/2,Y/2)]`` winding."""
|
||||
from bonsai.bim.module.model.mep import _bend_profile_cross_section
|
||||
|
||||
profile = Mock()
|
||||
profile.XDim = 0.4
|
||||
profile.YDim = 0.2
|
||||
profile.is_a = lambda c: c == "IfcRectangleProfileDef"
|
||||
|
||||
pts = _bend_profile_cross_section(profile)
|
||||
assert pts == [(-0.2, -0.1), (0.2, -0.1), (0.2, 0.1), (-0.2, 0.1)]
|
||||
|
||||
|
||||
def test_profile_cross_section_unsupported_returns_none():
|
||||
"""Profiles other than circle / rectangle (e.g.
|
||||
``IfcArbitraryClosedProfileDef``) return ``None`` so the tessellation
|
||||
helper skips the rep swap rather than building geometry against the
|
||||
wrong cross-section."""
|
||||
from bonsai.bim.module.model.mep import _bend_profile_cross_section
|
||||
|
||||
profile = Mock()
|
||||
profile.is_a = lambda c: c == "IfcArbitraryClosedProfileDef"
|
||||
|
||||
assert _bend_profile_cross_section(profile) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _sweep_profile_along_polyline — vert + face count + parallel transport
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_sweep_along_straight_polyline_builds_closed_tube_with_caps():
|
||||
"""Straight 3-ring centerline + 4-vert profile yields 12 ring verts,
|
||||
3 quads × 4 sides = 12 side quads, plus two end-cap triangles per end
|
||||
(4-vert profile fans into 2 triangles)."""
|
||||
from bonsai.bim.module.model.mep import _sweep_profile_along_polyline
|
||||
|
||||
centerline = [Vector((0.0, 0.0, 0.0)), Vector((0.0, 0.0, 1.0)), Vector((0.0, 0.0, 2.0))]
|
||||
profile_2d = [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)]
|
||||
|
||||
verts, faces = _sweep_profile_along_polyline(centerline, profile_2d)
|
||||
|
||||
assert len(verts) == 3 * 4, "3 rings × 4 profile verts"
|
||||
# 2 ring gaps × 4 quads each = 8 side faces; 2 caps × 2 triangles = 4 cap faces.
|
||||
quad_count = sum(1 for f in faces if len(f) == 4)
|
||||
tri_count = sum(1 for f in faces if len(f) == 3)
|
||||
assert quad_count == 8, "one quad per profile edge per ring gap"
|
||||
assert tri_count == 4, "fan triangulation gives n_profile - 2 = 2 tris per cap"
|
||||
|
||||
|
||||
def test_sweep_parallel_transports_basis_around_right_angle_corner():
|
||||
"""L-shaped centerline (turn from +Z to +X). After the corner, the
|
||||
cross-section's reference direction is rotated 90° from before — the
|
||||
parallel-transport invariant. Pin via the first verts of the start
|
||||
and end rings: starts perpendicular to +Z (so in XY), ends
|
||||
perpendicular to +X (so in YZ)."""
|
||||
from bonsai.bim.module.model.mep import _sweep_profile_along_polyline
|
||||
|
||||
centerline = [
|
||||
Vector((0.0, 0.0, 0.0)),
|
||||
Vector((0.0, 0.0, 1.0)),
|
||||
Vector((1.0, 0.0, 1.0)),
|
||||
Vector((2.0, 0.0, 1.0)),
|
||||
]
|
||||
# Single-vert profile would degenerate; use a 4-vert square so we
|
||||
# have something to project onto each ring's basis.
|
||||
profile_2d = [(0.1, 0.0), (0.0, 0.1), (-0.1, 0.0), (0.0, -0.1)]
|
||||
|
||||
verts, _ = _sweep_profile_along_polyline(centerline, profile_2d)
|
||||
|
||||
# First ring's verts must lie in a plane perpendicular to +Z (the
|
||||
# tangent at the first ring). Verify each vert has |z-ring_center.z| ≈ 0.
|
||||
first_ring = verts[0:4]
|
||||
for v in first_ring:
|
||||
assert v.z == pytest.approx(0.0, abs=1e-6), f"first-ring vert off the start plane: {v}"
|
||||
|
||||
# Last ring's tangent is +X (last centerline segment). Verts should
|
||||
# lie in a plane perpendicular to +X — i.e. x ≈ 2.0 (the centerline's
|
||||
# x at the last ring).
|
||||
last_ring = verts[-4:]
|
||||
for v in last_ring:
|
||||
assert v.x == pytest.approx(2.0, abs=1e-6), f"last-ring vert off the end plane: {v}"
|
||||
|
||||
|
||||
def test_sweep_initial_basis_override_aligns_first_ring_with_segment_axes():
|
||||
"""The asymmetric-rectangle fix: caller supplies the segment's local
|
||||
+X / +Y axes (in world space) as ``initial_basis``; the helper uses
|
||||
those as the first ring's basis instead of the world-Z seed. Verify
|
||||
by checking that the first profile vert lands at ``ring0 + right *
|
||||
sx + up * sy`` for the provided right / up."""
|
||||
from bonsai.bim.module.model.mep import _sweep_profile_along_polyline
|
||||
|
||||
centerline = [Vector((0.0, 0.0, 0.0)), Vector((0.0, 0.0, 1.0))]
|
||||
# Profile sample at (0.5, 0) — a single point on the +X profile axis.
|
||||
profile_2d = [(0.5, 0.0)]
|
||||
|
||||
# Initial basis where right = +Y world, up = +X world (rotated 90°
|
||||
# from the default world-Z seed which would give right ≈ -Y).
|
||||
initial_basis = (Vector((0.0, 1.0, 0.0)), Vector((1.0, 0.0, 0.0)))
|
||||
|
||||
verts, _ = _sweep_profile_along_polyline(centerline, profile_2d, initial_basis=initial_basis)
|
||||
|
||||
# First vert = ring0 (0,0,0) + right * 0.5 + up * 0 = (0, 0.5, 0).
|
||||
assert tuple(verts[0]) == pytest.approx((0.0, 0.5, 0.0), abs=1e-6)
|
||||
|
||||
|
||||
def test_sweep_default_seed_uses_world_z_reference():
|
||||
"""Without an ``initial_basis``, the helper falls back to a stable
|
||||
world-Z reference for the first ring. Pin so a future refactor of
|
||||
the fallback doesn't silently change the orientation for callers
|
||||
that rely on the default (the bend preview decorator's debug draw
|
||||
path, for instance)."""
|
||||
from bonsai.bim.module.model.mep import _sweep_profile_along_polyline
|
||||
|
||||
centerline = [Vector((0.0, 0.0, 0.0)), Vector((1.0, 0.0, 0.0))]
|
||||
profile_2d = [(1.0, 0.0)]
|
||||
|
||||
verts, _ = _sweep_profile_along_polyline(centerline, profile_2d)
|
||||
|
||||
# First tangent = +X. world-Z up_ref → right = tangent × up_ref =
|
||||
# (1,0,0) × (0,0,1) = (0,-1,0). up = right × tangent = (0,0,1).
|
||||
# First vert at right * 1.0 = (0, -1, 0).
|
||||
assert tuple(verts[0]) == pytest.approx((0.0, -1.0, 0.0), abs=1e-6)
|
||||
@@ -0,0 +1,227 @@
|
||||
# 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.
|
||||
|
||||
"""Smoke coverage for ``RegenerateDistributionElement`` and
|
||||
``FitFlowSegments``.
|
||||
|
||||
Both operators carry substantial branching that the bend / port test
|
||||
files don't reach. These tests pin:
|
||||
|
||||
- the operator-registration contract (bl_idname / bl_label / bl_options),
|
||||
- ``FitFlowSegments`` dispatch table — 0 / 1 / mixed-class selections
|
||||
resolve to the documented no-op or operator dispatch without raising,
|
||||
- ``RegenerateDistributionElement`` runs on a leaf element (no connected
|
||||
neighbours) without crashing on the recursion entry point.
|
||||
|
||||
Deeper geometry-tree behaviour (multi-branch traversal, port-aligned
|
||||
translation, segment regrowth) is deferred to integration testing
|
||||
against real IFC fixtures; the smoke tests are explicitly the
|
||||
oversight-prevention floor, not the full contract."""
|
||||
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
def _segment(ifc_class: str = "IfcFlowSegment"):
|
||||
"""Stand-in for an IfcFlowSegment / subclass entity.
|
||||
|
||||
``is_a("IfcFlowSegment" | <ifc_class>)`` returns True; ``is_a()`` with
|
||||
no args returns the class name (the IfcOpenShell API exposes both
|
||||
forms — ``FitFlowSegments`` calls ``element.is_a()`` to record the
|
||||
selection's class for the mixed-class refusal check)."""
|
||||
|
||||
def fake_is_a(c=None):
|
||||
if c is None:
|
||||
return ifc_class
|
||||
return c in {"IfcFlowSegment", ifc_class}
|
||||
|
||||
e = Mock()
|
||||
e.is_a = fake_is_a
|
||||
return e
|
||||
|
||||
|
||||
def _make_op(**fields):
|
||||
op = Mock()
|
||||
for k, v in fields.items():
|
||||
setattr(op, k, v)
|
||||
op.report = MagicMock()
|
||||
return op
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registration smoke
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_regenerate_distribution_element_is_registered():
|
||||
"""``RegenerateDistributionElement`` is the entry point for the
|
||||
distribution-tree repropagation. Pin the bl_idname so a typo in the
|
||||
classes tuple wouldn't silently drop the operator."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
assert mep.RegenerateDistributionElement.bl_idname == "bim.regenerate_distribution_element"
|
||||
assert mep.RegenerateDistributionElement.bl_label == "Regenerate Distribution Element"
|
||||
assert mep.RegenerateDistributionElement.bl_options == {"REGISTER", "UNDO"}
|
||||
|
||||
|
||||
def test_fit_flow_segments_is_registered():
|
||||
"""``FitFlowSegments`` is the cursor-based "add a fitting from the
|
||||
current selection" entry point. Pin the registration contract so the
|
||||
operator stays callable from the workspace tool."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
assert mep.FitFlowSegments.bl_idname == "bim.fit_flow_segments"
|
||||
assert mep.FitFlowSegments.bl_label == "Fit Flow Segments"
|
||||
assert mep.FitFlowSegments.bl_options == {"REGISTER", "UNDO"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FitFlowSegments dispatch table
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_fit_flow_segments_with_no_selection_is_noop():
|
||||
"""Nothing selected → no fitting type resolved → operator returns
|
||||
without dispatching any ``bim.mep_add_*`` op. The user-facing
|
||||
contract is "this is a tool you fire with a selection"; the silent
|
||||
no-op on empty selection is intentional (no popup, no error)."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
context = MagicMock()
|
||||
context.selected_objects = []
|
||||
|
||||
op = _make_op()
|
||||
with 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:
|
||||
mep.FitFlowSegments._execute(op, context=context)
|
||||
|
||||
obstruction.assert_not_called()
|
||||
bend.assert_not_called()
|
||||
transition.assert_not_called()
|
||||
|
||||
|
||||
def test_fit_flow_segments_with_single_segment_dispatches_obstruction():
|
||||
"""Exactly one IfcFlowSegment selected → OBSTRUCTION fitting type,
|
||||
delegates to ``bim.mep_add_obstruction`` which handles the
|
||||
cursor-anchored placement.
|
||||
|
||||
``bpy.ops`` resolves operator dispatch through Blender's internal id
|
||||
table, not through Python attribute access, so a Python-level patch
|
||||
on ``bpy.ops.bim.mep_add_obstruction`` doesn't intercept the call.
|
||||
Patch the operator's ``_execute`` instead — same effect, exercises
|
||||
the real dispatch path that the user hits at runtime."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
segment_obj = MagicMock()
|
||||
segment_profile = MagicMock()
|
||||
segment_entity = _segment("IfcPipeSegment")
|
||||
|
||||
context = MagicMock()
|
||||
context.selected_objects = [segment_obj]
|
||||
|
||||
op = _make_op()
|
||||
with patch.object(mep.tool.Ifc, "get_entity", return_value=segment_entity), patch.object(
|
||||
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:
|
||||
mep.FitFlowSegments._execute(op, context=context)
|
||||
|
||||
assert obstruction.call_count == 1
|
||||
bend.assert_not_called()
|
||||
transition.assert_not_called()
|
||||
|
||||
|
||||
def test_fit_flow_segments_refuses_mixed_pipe_and_duct():
|
||||
"""Selecting one IfcPipeSegment + one IfcDuctSegment → the operator
|
||||
bails out before any fitting dispatch. The user-facing path is
|
||||
"select segments of one kind"; mixing pipe + duct would create an
|
||||
invalid IFC fitting type."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
pipe_obj = MagicMock()
|
||||
duct_obj = MagicMock()
|
||||
pipe_entity = _segment("IfcPipeSegment")
|
||||
duct_entity = _segment("IfcDuctSegment")
|
||||
profile = MagicMock()
|
||||
|
||||
context = MagicMock()
|
||||
context.selected_objects = [pipe_obj, duct_obj]
|
||||
|
||||
def fake_get_entity(obj):
|
||||
return pipe_entity if obj is pipe_obj else duct_entity
|
||||
|
||||
op = _make_op()
|
||||
with patch.object(mep.tool.Ifc, "get_entity", side_effect=fake_get_entity), patch.object(
|
||||
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:
|
||||
mep.FitFlowSegments._execute(op, context=context)
|
||||
|
||||
obstruction.assert_not_called()
|
||||
bend.assert_not_called()
|
||||
transition.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RegenerateDistributionElement
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_regenerate_distribution_element_on_leaf_is_safe():
|
||||
"""A distribution element with no connected neighbours → the inner
|
||||
queue stays empty → the operator returns cleanly without entering
|
||||
the per-branch processing path.
|
||||
|
||||
This pins the safety floor: the recursion entry point should not
|
||||
crash on a single-element graph, which is the most common shape
|
||||
when a user fires this operator on an isolated segment."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
leaf_element = _segment("IfcPipeSegment")
|
||||
leaf_obj = MagicMock()
|
||||
|
||||
context = MagicMock()
|
||||
context.active_object = leaf_obj
|
||||
|
||||
fake_active = MagicMock()
|
||||
fake_active.is_a = lambda c: False # bpy.context.active_object stub
|
||||
|
||||
op = _make_op()
|
||||
with patch.object(mep.tool.Ifc, "get_entity", return_value=leaf_element), patch(
|
||||
"ifcopenshell.util.system.get_connected_to", return_value=[]
|
||||
), patch("ifcopenshell.util.system.get_connected_from", return_value=[]), patch.object(
|
||||
mep.tool.Ifc, "get", return_value=MagicMock()
|
||||
), patch(
|
||||
"ifcopenshell.util.unit.calculate_unit_scale", return_value=1.0
|
||||
), patch.object(
|
||||
bpy, "context", new=context
|
||||
):
|
||||
mep.RegenerateDistributionElement._execute(op, context=context)
|
||||
|
||||
# The contract on a leaf is "nothing to do". No exception, no IFC
|
||||
# mutation. The bpy.ops dispatch table inside process_branch never
|
||||
# fires because queue is empty.
|
||||
@@ -0,0 +1,388 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Behaviour tests for the MEP port operators.
|
||||
|
||||
Pins the dispatch contract each operator carries — which IFC mutation
|
||||
runs, which user-error path returns CANCELLED, and which fitting types
|
||||
are deliberately refused by each entry point. Each test mocks the
|
||||
``tool.*`` and ``MEPGenerator`` boundaries so no IFC fixture is needed."""
|
||||
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
def _segment(predefined_type=None):
|
||||
"""Stand-in IFC entity that reports ``is_a("IfcFlowSegment")`` True."""
|
||||
e = Mock()
|
||||
e.is_a = lambda c: c == "IfcFlowSegment"
|
||||
e.PredefinedType = predefined_type
|
||||
return e
|
||||
|
||||
|
||||
def _fitting(predefined_type=None):
|
||||
"""Stand-in IFC fitting entity with an arbitrary ``PredefinedType``."""
|
||||
e = Mock()
|
||||
e.is_a = lambda c: c in ("IfcFlowFitting", "IfcDistributionFlowElement")
|
||||
e.PredefinedType = predefined_type
|
||||
return e
|
||||
|
||||
|
||||
def _make_op(_cls, **fields):
|
||||
"""Return a Mock standing in for an Operator ``self``. Subclassing a
|
||||
``bpy.types.Operator`` outside Blender's registration machinery raises
|
||||
a ``bpy_struct.__new__`` error, so each test calls the operator
|
||||
method as an unbound function with this Mock as the first argument."""
|
||||
op = Mock()
|
||||
for k, v in fields.items():
|
||||
setattr(op, k, v)
|
||||
op.report = MagicMock()
|
||||
return op
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MEPUnjoinAtPort
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"port_state, fitting_predefined_type, expected_result, expects_delete",
|
||||
[
|
||||
pytest.param("JOINED", "JUNCTION", {"FINISHED"}, True, id="joined_junction_deletes"),
|
||||
pytest.param("JOINED", "OBSTRUCTION", {"CANCELLED"}, False, id="joined_obstruction_refused"),
|
||||
pytest.param("FREE", None, {"CANCELLED"}, False, id="free_port_cancels"),
|
||||
],
|
||||
)
|
||||
def test_unjoin_at_port_dispatch_table(port_state, fitting_predefined_type, expected_result, expects_delete):
|
||||
"""``MEPUnjoinAtPort`` dispatch contract: result and delete-side-effect
|
||||
by ``(port_state, fitting type)``.
|
||||
|
||||
- ``JOINED + JUNCTION`` (or any non-OBSTRUCTION fitting): happy path,
|
||||
the bridging fitting is deleted via the standard delete entry point.
|
||||
- ``JOINED + OBSTRUCTION``: deliberately refused — obstructions go
|
||||
through ``bim.mep_add_obstruction`` (mode=REMOVE) so the segment
|
||||
extends to absorb the freed length; using delete here would leave
|
||||
a visible gap.
|
||||
- ``FREE``: nothing to do — no bridging fitting exists. The operator
|
||||
reports a user-facing error and CANCELS rather than no-op silently."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
segment = _segment()
|
||||
fitting = _fitting(predefined_type=fitting_predefined_type) if fitting_predefined_type else None
|
||||
fitting_obj = Mock()
|
||||
|
||||
op = _make_op(mep.MEPUnjoinAtPort, segment_id=42, position="END")
|
||||
ifc_file = MagicMock()
|
||||
ifc_file.by_id.return_value = segment
|
||||
|
||||
with patch.object(mep.tool.Ifc, "get", return_value=ifc_file), patch.object(
|
||||
mep.tool.Ifc, "get_object", return_value=fitting_obj
|
||||
), patch.object(mep, "port_connection_state", return_value=port_state), patch.object(
|
||||
mep, "get_connected_element_at_segment_port", return_value=fitting
|
||||
), patch.object(
|
||||
mep.tool.Geometry, "delete_ifc_object"
|
||||
) as delete:
|
||||
result = mep.MEPUnjoinAtPort._execute(op, context=MagicMock())
|
||||
|
||||
assert result == expected_result
|
||||
if expects_delete:
|
||||
delete.assert_called_once_with(fitting_obj)
|
||||
else:
|
||||
delete.assert_not_called()
|
||||
op.report.assert_called()
|
||||
|
||||
|
||||
def test_unjoin_at_port_cancels_when_active_is_not_segment():
|
||||
"""The operator only operates on flow segments; non-segment active
|
||||
objects must fail loud rather than mutate something unexpected."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
fitting = _fitting() # IfcFlowFitting, not IfcFlowSegment
|
||||
|
||||
op = _make_op(mep.MEPUnjoinAtPort, segment_id=42, position="END")
|
||||
ifc_file = MagicMock()
|
||||
ifc_file.by_id.return_value = fitting
|
||||
|
||||
with patch.object(mep.tool.Ifc, "get", return_value=ifc_file):
|
||||
result = mep.MEPUnjoinAtPort._execute(op, context=MagicMock())
|
||||
|
||||
assert result == {"CANCELLED"}
|
||||
op.report.assert_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MEPRemoveTerminalFitting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_remove_terminal_dispatches_obstruction_via_remove_obstruction():
|
||||
"""OBSTRUCTION fittings extend the segment to absorb the freed length;
|
||||
the operator routes through ``MEPGenerator().remove_obstruction``
|
||||
rather than the plain delete path."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
segment = _segment()
|
||||
obstruction = _fitting(predefined_type="OBSTRUCTION")
|
||||
|
||||
op = _make_op(mep.MEPRemoveTerminalFitting, segment_id=42, position="END")
|
||||
ifc_file = MagicMock()
|
||||
ifc_file.by_id.return_value = segment
|
||||
|
||||
with patch.object(mep.tool.Ifc, "get", return_value=ifc_file), patch.object(
|
||||
mep, "port_connection_state", return_value="TERMINAL"
|
||||
), patch.object(mep, "get_connected_element_at_segment_port", return_value=obstruction), patch.object(
|
||||
mep, "MEPGenerator"
|
||||
) as gen_cls, patch.object(
|
||||
mep.tool.Geometry, "delete_ifc_object"
|
||||
) as delete:
|
||||
gen_cls.return_value.remove_obstruction.return_value = (obstruction, None)
|
||||
result = mep.MEPRemoveTerminalFitting._execute(op, context=MagicMock())
|
||||
|
||||
assert result == {"FINISHED"}
|
||||
gen_cls.return_value.remove_obstruction.assert_called_once_with(segment, False)
|
||||
delete.assert_not_called()
|
||||
|
||||
|
||||
def test_remove_terminal_dispatches_non_obstruction_via_delete():
|
||||
"""A standard terminal fitting (cap, isolated terminal) goes through
|
||||
the plain delete path — the segment is not resized."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
segment = _segment()
|
||||
fitting = _fitting(predefined_type=None)
|
||||
fitting_obj = Mock()
|
||||
|
||||
op = _make_op(mep.MEPRemoveTerminalFitting, segment_id=42, position="END")
|
||||
ifc_file = MagicMock()
|
||||
ifc_file.by_id.return_value = segment
|
||||
|
||||
with patch.object(mep.tool.Ifc, "get", return_value=ifc_file), patch.object(
|
||||
mep.tool.Ifc, "get_object", return_value=fitting_obj
|
||||
), patch.object(mep, "port_connection_state", return_value="TERMINAL"), patch.object(
|
||||
mep, "get_connected_element_at_segment_port", return_value=fitting
|
||||
), patch.object(
|
||||
mep.tool.Geometry, "delete_ifc_object"
|
||||
) as delete:
|
||||
result = mep.MEPRemoveTerminalFitting._execute(op, context=MagicMock())
|
||||
|
||||
assert result == {"FINISHED"}
|
||||
delete.assert_called_once_with(fitting_obj)
|
||||
|
||||
|
||||
def test_remove_terminal_cancels_on_non_terminal_port():
|
||||
"""Port state must be TERMINAL for this operator; FREE / JOINED are
|
||||
routed through other operators."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
segment = _segment()
|
||||
|
||||
op = _make_op(mep.MEPRemoveTerminalFitting, segment_id=42, position="END")
|
||||
ifc_file = MagicMock()
|
||||
ifc_file.by_id.return_value = segment
|
||||
|
||||
with patch.object(mep.tool.Ifc, "get", return_value=ifc_file), patch.object(
|
||||
mep, "port_connection_state", return_value="JOINED"
|
||||
):
|
||||
result = mep.MEPRemoveTerminalFitting._execute(op, context=MagicMock())
|
||||
|
||||
assert result == {"CANCELLED"}
|
||||
op.report.assert_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MEPUnjoinPair
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_unjoin_pair_deletes_bridging_fitting():
|
||||
"""Happy path: two selected segments share a single non-OBSTRUCTION
|
||||
bridging fitting → delete it."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
segment_a = _segment()
|
||||
segment_b = _segment()
|
||||
fitting = _fitting(predefined_type="JUNCTION")
|
||||
fitting_obj = Mock()
|
||||
|
||||
op = _make_op(mep.MEPUnjoinPair)
|
||||
selected = [Mock(), Mock()]
|
||||
|
||||
with patch.object(mep.tool.Blender, "get_selected_objects", return_value=selected), patch.object(
|
||||
mep.tool.Ifc, "get_entity", side_effect=[segment_a, segment_b]
|
||||
), patch.object(mep, "find_fitting_between_segments", return_value=fitting), patch.object(
|
||||
mep.tool.Ifc, "get_object", return_value=fitting_obj
|
||||
), patch.object(
|
||||
mep.tool.Geometry, "delete_ifc_object"
|
||||
) as delete:
|
||||
result = mep.MEPUnjoinPair._execute(op, context=MagicMock())
|
||||
|
||||
assert result == {"FINISHED"}
|
||||
delete.assert_called_once_with(fitting_obj)
|
||||
|
||||
|
||||
def test_unjoin_pair_refuses_obstruction_bridging():
|
||||
"""Same defence-in-depth as ``MEPUnjoinAtPort`` — obstructions go
|
||||
through the dedicated REMOVE path; this operator surfaces the
|
||||
redirect rather than silently doing the wrong thing."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
segment_a = _segment()
|
||||
segment_b = _segment()
|
||||
obstruction = _fitting(predefined_type="OBSTRUCTION")
|
||||
|
||||
op = _make_op(mep.MEPUnjoinPair)
|
||||
selected = [Mock(), Mock()]
|
||||
|
||||
with patch.object(mep.tool.Blender, "get_selected_objects", return_value=selected), patch.object(
|
||||
mep.tool.Ifc, "get_entity", side_effect=[segment_a, segment_b]
|
||||
), patch.object(mep, "find_fitting_between_segments", return_value=obstruction), patch.object(
|
||||
mep.tool.Geometry, "delete_ifc_object"
|
||||
) as delete:
|
||||
result = mep.MEPUnjoinPair._execute(op, context=MagicMock())
|
||||
|
||||
assert result == {"CANCELLED"}
|
||||
delete.assert_not_called()
|
||||
op.report.assert_called()
|
||||
|
||||
|
||||
def test_unjoin_pair_reports_when_no_bridging_fitting_found():
|
||||
"""The pair is selected but no single fitting bridges them — the
|
||||
user is told instead of getting a silent no-op."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
segment_a = _segment()
|
||||
segment_b = _segment()
|
||||
|
||||
op = _make_op(mep.MEPUnjoinPair)
|
||||
selected = [Mock(), Mock()]
|
||||
|
||||
with patch.object(mep.tool.Blender, "get_selected_objects", return_value=selected), patch.object(
|
||||
mep.tool.Ifc, "get_entity", side_effect=[segment_a, segment_b]
|
||||
), patch.object(mep, "find_fitting_between_segments", return_value=None), patch.object(
|
||||
mep.tool.Geometry, "delete_ifc_object"
|
||||
) as delete:
|
||||
result = mep.MEPUnjoinPair._execute(op, context=MagicMock())
|
||||
|
||||
assert result == {"CANCELLED"}
|
||||
delete.assert_not_called()
|
||||
op.report.assert_called()
|
||||
|
||||
|
||||
def test_unjoin_pair_cancels_when_selection_is_not_two_segments():
|
||||
"""The poll filters the gizmo, but a programmatic invocation could
|
||||
still hand the operator an invalid selection. The execute path
|
||||
independently verifies both inputs are IfcFlowSegment."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
not_a_segment = _fitting() # IfcFlowFitting, not IfcFlowSegment
|
||||
|
||||
op = _make_op(mep.MEPUnjoinPair)
|
||||
selected = [Mock(), Mock()]
|
||||
|
||||
with patch.object(mep.tool.Blender, "get_selected_objects", return_value=selected), patch.object(
|
||||
mep.tool.Ifc, "get_entity", side_effect=[not_a_segment, not_a_segment]
|
||||
):
|
||||
result = mep.MEPUnjoinPair._execute(op, context=MagicMock())
|
||||
|
||||
assert result == {"CANCELLED"}
|
||||
op.report.assert_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SelectMEPPathMembers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_select_path_replaces_selection_with_walked_members():
|
||||
"""Happy path: walker returns a small connected network → every
|
||||
member gets ``select_set(True)``; the original active object stays
|
||||
active."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
active = Mock()
|
||||
element = Mock()
|
||||
member_elements = [Mock(), Mock(), Mock()]
|
||||
member_objs = [Mock(), Mock(), Mock()]
|
||||
|
||||
context = MagicMock()
|
||||
context.active_object = active
|
||||
context.view_layer.objects.active = None
|
||||
|
||||
op = _make_op(mep.SelectMEPPathMembers)
|
||||
|
||||
with patch.object(mep.tool.Ifc, "get_entity", return_value=element), patch.object(
|
||||
mep.tool.System, "walk_connected_mep_elements", return_value=member_elements
|
||||
), patch.object(mep.tool.Ifc, "get_object", side_effect=member_objs), patch.object(
|
||||
mep.bpy.ops.object, "select_all"
|
||||
):
|
||||
result = mep.SelectMEPPathMembers.execute(op, context)
|
||||
|
||||
assert result == {"FINISHED"}
|
||||
for obj in member_objs:
|
||||
obj.select_set.assert_called_once_with(True)
|
||||
|
||||
|
||||
def test_select_path_reports_when_walker_returns_empty():
|
||||
"""An MEP element with no connected neighbours produces an empty
|
||||
walk; report INFO so the user knows the click registered, return
|
||||
FINISHED so the operator doesn't surface as an error."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
active = Mock()
|
||||
element = Mock()
|
||||
|
||||
context = MagicMock()
|
||||
context.active_object = active
|
||||
|
||||
op = _make_op(mep.SelectMEPPathMembers)
|
||||
|
||||
with patch.object(mep.tool.Ifc, "get_entity", return_value=element), patch.object(
|
||||
mep.tool.System, "walk_connected_mep_elements", return_value=[]
|
||||
):
|
||||
result = mep.SelectMEPPathMembers.execute(op, context)
|
||||
|
||||
assert result == {"FINISHED"}
|
||||
op.report.assert_called()
|
||||
|
||||
|
||||
def test_select_path_handles_walker_exception():
|
||||
"""The walker can raise on malformed port graphs; the operator must
|
||||
catch and surface as ERROR rather than crashing the operator harness."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
active = Mock()
|
||||
element = Mock()
|
||||
|
||||
context = MagicMock()
|
||||
context.active_object = active
|
||||
|
||||
op = _make_op(mep.SelectMEPPathMembers)
|
||||
|
||||
with patch.object(mep.tool.Ifc, "get_entity", return_value=element), patch.object(
|
||||
mep.tool.System, "walk_connected_mep_elements", side_effect=RuntimeError("malformed port graph")
|
||||
):
|
||||
result = mep.SelectMEPPathMembers.execute(op, context)
|
||||
|
||||
assert result == {"CANCELLED"}
|
||||
op.report.assert_called()
|
||||
@@ -0,0 +1,533 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Unit tests for the pipe/duct segment parametric-edit scaffolding.
|
||||
|
||||
Covers three surfaces that ship together as the first MEP dimension-gizmo
|
||||
feature:
|
||||
|
||||
- ``tool.Parametric.is_pipe_segment`` / ``is_duct_segment`` predicates
|
||||
(registry contract — must be total).
|
||||
- ``_segment_world_length`` / ``_preview_segment_via_scale`` /
|
||||
``_restore_segment_scale`` pure helpers driving the live preview.
|
||||
- ``GizmoPipeSegmentEdition`` / ``GizmoDuctSegmentEdition`` class wiring
|
||||
(bl_idname, operator bindings, dimension_gizmo_props, is_element_type).
|
||||
|
||||
Full operator round-trips (enable → drag → finish → IFC commit) need a real
|
||||
Blender + IFC scene and are deferred to a later integration session."""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import pytest
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Predicates — total over arbitrary IFC entity input
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ifc_class,is_pipe_expected,is_duct_expected",
|
||||
[
|
||||
("IfcPipeSegment", True, False),
|
||||
("IfcDuctSegment", False, True),
|
||||
("IfcFlowSegment", False, False), # base class — neither pipe nor duct alone
|
||||
("IfcPipeFitting", False, False), # fitting, not a segment
|
||||
("IfcDuctFitting", False, False),
|
||||
("IfcWall", False, False),
|
||||
("IfcAnnotation", False, False), # bare schema element with no MEP semantics
|
||||
],
|
||||
)
|
||||
def test_is_pipe_or_duct_segment_predicate_truth_table(ifc_class, is_pipe_expected, is_duct_expected):
|
||||
"""The two predicates must classify every IFC class correctly AND
|
||||
return False (not raise) on classes that have nothing to do with MEP.
|
||||
Pinned alongside the registry-wide predicate-totality test so a
|
||||
regression in either direction surfaces in this file too."""
|
||||
from bonsai import tool
|
||||
|
||||
probe = ifcopenshell.file(schema="IFC4").create_entity(ifc_class)
|
||||
assert tool.Parametric.is_pipe_segment(probe) is is_pipe_expected
|
||||
assert tool.Parametric.is_duct_segment(probe) is is_duct_expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _segment_world_length — pure geometric helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_segment_world_length_returns_axis_magnitude():
|
||||
"""The length read here drives both the dimension gizmo's display and
|
||||
the snap_length captured on enable. Pin the math on a known axis."""
|
||||
from bonsai.bim.module.model.mep import _segment_world_length
|
||||
|
||||
fake_obj = object()
|
||||
axis = (Vector((1.0, 2.0, 3.0)), Vector((1.0, 2.0, 5.5)))
|
||||
with patch("bonsai.tool.Model.get_flow_segment_axis", return_value=axis):
|
||||
assert _segment_world_length(fake_obj) == pytest.approx(2.5)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Preview helpers — obj.scale.z manipulation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeObj:
|
||||
"""Stand-in for bpy.types.Object exposing only ``scale`` — enough for
|
||||
the preview helpers, which never touch IFC."""
|
||||
|
||||
def __init__(self):
|
||||
self.scale = Vector((1.0, 1.0, 1.0))
|
||||
|
||||
|
||||
def test_preview_segment_via_scale_sets_z_to_ratio():
|
||||
"""The visible-stretch ratio composes ``props_length / mesh_local_length`` where
|
||||
``mesh_local_length = snap_length / snap_object_scale_z``."""
|
||||
from bonsai.bim.module.model.mep import _preview_segment_via_scale
|
||||
|
||||
obj = _FakeObj()
|
||||
_preview_segment_via_scale(obj, props_length=2.0, snap_length=1.0, snap_object_scale_z=1.0)
|
||||
assert obj.scale.z == pytest.approx(2.0)
|
||||
|
||||
_preview_segment_via_scale(obj, props_length=0.5, snap_length=1.0, snap_object_scale_z=1.0)
|
||||
assert obj.scale.z == pytest.approx(0.5)
|
||||
|
||||
|
||||
def test_preview_segment_via_scale_floors_at_min_value():
|
||||
"""``props.length`` is clamped at FloatProperty min=0.01; the helper still
|
||||
defends against zero / negative so a runaway value can't invert the segment."""
|
||||
from bonsai.bim.module.model.mep import _preview_segment_via_scale
|
||||
|
||||
obj = _FakeObj()
|
||||
_preview_segment_via_scale(obj, props_length=0.0, snap_length=1.0, snap_object_scale_z=1.0)
|
||||
assert obj.scale.z == pytest.approx(0.01)
|
||||
|
||||
|
||||
def test_preview_segment_via_scale_skips_when_snap_is_zero():
|
||||
"""A zero ``snap_length`` would divide by zero — helper skips silently."""
|
||||
from bonsai.bim.module.model.mep import _preview_segment_via_scale
|
||||
|
||||
obj = _FakeObj()
|
||||
obj.scale.z = 3.0
|
||||
_preview_segment_via_scale(obj, props_length=1.0, snap_length=0.0, snap_object_scale_z=1.0)
|
||||
# No change.
|
||||
assert obj.scale.z == pytest.approx(3.0)
|
||||
|
||||
|
||||
def test_restore_segment_scale_resets_z_to_target():
|
||||
"""Pin that the reset only touches Z; X/Y stay whatever the user set."""
|
||||
from bonsai.bim.module.model.mep import _restore_segment_scale_to
|
||||
|
||||
obj = _FakeObj()
|
||||
obj.scale = Vector((0.5, 0.7, 4.2))
|
||||
_restore_segment_scale_to(obj, 1.0)
|
||||
assert obj.scale.x == pytest.approx(0.5)
|
||||
assert obj.scale.y == pytest.approx(0.7)
|
||||
assert obj.scale.z == pytest.approx(1.0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gizmo group class wiring — registration and config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"gizmo_cls_name,bl_idname,is_element_predicate",
|
||||
[
|
||||
("GizmoPipeSegmentEdition", "OBJECT_GGT_bim_pipe_segment_edition", "is_pipe_segment"),
|
||||
("GizmoDuctSegmentEdition", "OBJECT_GGT_bim_duct_segment_edition", "is_duct_segment"),
|
||||
],
|
||||
)
|
||||
def test_gizmo_group_class_wiring(gizmo_cls_name, bl_idname, is_element_predicate):
|
||||
"""Each gizmo group must:
|
||||
- declare the expected ``bl_idname`` (so it actually registers under that name);
|
||||
- have the matching ``is_element_type`` delegate to the right predicate
|
||||
(so it polls in for the right IFC class).
|
||||
"""
|
||||
from bonsai import tool
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
cls = getattr(mep, gizmo_cls_name)
|
||||
assert cls.bl_idname == bl_idname
|
||||
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
|
||||
):
|
||||
cls.is_element_type(fake_element)
|
||||
assert p.called, f"{gizmo_cls_name}.is_element_type did not delegate to Parametric.{is_element_predicate}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"gizmo_cls_name,enable_op,finish_op,cancel_op",
|
||||
[
|
||||
(
|
||||
"GizmoPipeSegmentEdition",
|
||||
"bim.enable_editing_pipe_segment",
|
||||
"bim.finish_editing_pipe_segment",
|
||||
"bim.cancel_editing_pipe_segment",
|
||||
),
|
||||
(
|
||||
"GizmoDuctSegmentEdition",
|
||||
"bim.enable_editing_duct_segment",
|
||||
"bim.finish_editing_duct_segment",
|
||||
"bim.cancel_editing_duct_segment",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_gizmo_lifecycle_bindings_reference_registered_operators(gizmo_cls_name, enable_op, finish_op, cancel_op):
|
||||
"""Catches the silent-regression where the gizmo's enable/finish/cancel
|
||||
string drifts away from the actual operator ``bl_idname``."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
cls = getattr(mep, gizmo_cls_name)
|
||||
assert cls.enable_editing_operator == enable_op
|
||||
assert cls.finish_editing_operator == finish_op
|
||||
assert cls.cancel_editing_operator == cancel_op
|
||||
# And the operators are actually registered.
|
||||
for op in (enable_op, finish_op, cancel_op):
|
||||
namespace, _, verb = op.partition(".")
|
||||
assert hasattr(
|
||||
getattr(bpy.ops, namespace), verb
|
||||
), f"{gizmo_cls_name} references {op!r} which is not a registered operator"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gizmo_cls_name", ["GizmoPipeSegmentEdition", "GizmoDuctSegmentEdition"])
|
||||
def test_gizmo_dimension_gizmo_props_has_single_length_entry(gizmo_cls_name):
|
||||
"""Phase 1 ships a single dimension (segment length). Pin the shape so
|
||||
a Phase 2 addition (diameter / width / height) is an intentional
|
||||
expansion rather than a drive-by edit."""
|
||||
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
cls = getattr(mep, gizmo_cls_name)
|
||||
assert len(cls.dimension_gizmo_props) == 1
|
||||
config = cls.dimension_gizmo_props[0]
|
||||
assert isinstance(config, DimensionGizmoConfig)
|
||||
assert config.attr_name == "length"
|
||||
assert tuple(config.axis) == (0, 0, 1)
|
||||
assert config.min_value == pytest.approx(0.01)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gizmo_cls_name", ["GizmoPipeSegmentEdition", "GizmoDuctSegmentEdition"])
|
||||
def test_length_dimension_has_matrix_position_so_rotation_is_respected(gizmo_cls_name):
|
||||
"""Regression guard for "edit-mode length dimension doesn't take local
|
||||
object rotation". Without ``matrix_position`` set, ``update_dimension_gizmos``
|
||||
falls back to ``base_matrix = Identity`` and the gizmo's intrinsic +X
|
||||
visual line is never rotated to the configured ``axis`` — the dimension
|
||||
renders perpendicular to the segment on a rotated pipe. Setting
|
||||
``matrix_position`` (even to ``(0, 0, 0)``) routes through
|
||||
``compose_gizmo_matrix`` which applies ``get_axis_rotation_matrix(axis)``
|
||||
so the line aligns with the segment's local +Z (extrusion axis) in
|
||||
world space."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
cls = getattr(mep, gizmo_cls_name)
|
||||
config = cls.dimension_gizmo_props[0]
|
||||
assert config.matrix_position is not None, (
|
||||
f"{gizmo_cls_name} length dimension is missing matrix_position — the gizmo will "
|
||||
"render along the object's local +X axis instead of the segment's local +Z."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Extend-to-cursor — operator + element-specific gizmo wiring
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"gizmo_cls_name,extend_operator",
|
||||
[
|
||||
("GizmoPipeSegmentEdition", "bim.extend_pipe_segment_to_cursor"),
|
||||
("GizmoDuctSegmentEdition", "bim.extend_duct_segment_to_cursor"),
|
||||
],
|
||||
)
|
||||
def test_extend_operator_binding(gizmo_cls_name, extend_operator):
|
||||
"""Each segment gizmo group must reference the matching extend operator
|
||||
AND that operator must actually be registered. Catches the silent
|
||||
regression where someone renames the extend bl_idname without updating
|
||||
the gizmo group's ``_extend_operator`` class attribute."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
cls = getattr(mep, gizmo_cls_name)
|
||||
assert cls._extend_operator == extend_operator
|
||||
namespace, _, verb = extend_operator.partition(".")
|
||||
assert hasattr(
|
||||
getattr(bpy.ops, namespace), verb
|
||||
), f"{gizmo_cls_name} references {extend_operator!r} which is not a registered operator"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("feature_attr", ["pipe_segment", "duct_segment"])
|
||||
def test_gizmo_preferences_field_exists(feature_attr):
|
||||
"""``GizmoPreferences`` must carry pipe_segment + duct_segment PointerProperties
|
||||
so ``get_gizmo_prefs()`` on the MEP gizmo groups resolves to a real PropertyGroup."""
|
||||
import bonsai.bim.ui as ui
|
||||
|
||||
assert feature_attr in ui.GizmoPreferences.__annotations__, (
|
||||
f"GizmoPreferences is missing the {feature_attr} PointerProperty; "
|
||||
f"MEP gizmo groups' get_gizmo_prefs() would raise AttributeError."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle operators are registered
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"op",
|
||||
[
|
||||
"bim.enable_editing_pipe_segment",
|
||||
"bim.finish_editing_pipe_segment",
|
||||
"bim.cancel_editing_pipe_segment",
|
||||
"bim.extend_pipe_segment_to_cursor",
|
||||
"bim.enable_editing_duct_segment",
|
||||
"bim.finish_editing_duct_segment",
|
||||
"bim.cancel_editing_duct_segment",
|
||||
"bim.extend_duct_segment_to_cursor",
|
||||
],
|
||||
)
|
||||
def test_segment_operators_are_registered(op):
|
||||
"""Smoke test mirroring ``test_parametric_registry``'s
|
||||
``test_every_entry_has_enable_op_registered`` for the operators added
|
||||
in this round. Catches the silent regression where the classes tuple
|
||||
in ``__init__.py`` drops one of them."""
|
||||
namespace, _, verb = op.partition(".")
|
||||
assert hasattr(getattr(bpy.ops, namespace), verb), f"Operator {op!r} is not registered."
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MEPSegmentExtendPreviewDecorator._compute_extend_preview_line — pure helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_extend_preview_line_returns_none_for_degenerate_segment():
|
||||
"""A zero-length segment has no endpoint to draw from. Pin so a future
|
||||
refactor doesn't divide-by-zero or render a phantom line at the
|
||||
object origin."""
|
||||
from bonsai.bim.module.model.decorator import MEPSegmentExtendPreviewDecorator
|
||||
|
||||
result = MEPSegmentExtendPreviewDecorator._compute_extend_preview_line(
|
||||
matrix_world=Matrix.Identity(4),
|
||||
cursor_world=Vector((0.0, 0.0, 1.0)),
|
||||
current_length=0.0,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_extend_preview_line_returns_none_when_cursor_at_current_end():
|
||||
"""If the cursor projection matches the current segment length exactly,
|
||||
the extend operator would be a no-op — don't render the line either."""
|
||||
from bonsai.bim.module.model.decorator import MEPSegmentExtendPreviewDecorator
|
||||
|
||||
result = MEPSegmentExtendPreviewDecorator._compute_extend_preview_line(
|
||||
matrix_world=Matrix.Identity(4),
|
||||
cursor_world=Vector((0.0, 0.0, 1.5)),
|
||||
current_length=1.5,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_extend_preview_line_renders_extension_when_cursor_past_end():
|
||||
"""Happy path: cursor past current end → line runs from current end to
|
||||
the cursor's projected length. Identity matrix: local-Z maps 1:1 to
|
||||
world-Z. Pin the endpoints exactly."""
|
||||
from bonsai.bim.module.model.decorator import MEPSegmentExtendPreviewDecorator
|
||||
|
||||
result = MEPSegmentExtendPreviewDecorator._compute_extend_preview_line(
|
||||
matrix_world=Matrix.Identity(4),
|
||||
cursor_world=Vector((0.0, 0.0, 3.0)),
|
||||
current_length=1.0,
|
||||
)
|
||||
assert result is not None
|
||||
start, end = result
|
||||
assert tuple(start) == pytest.approx((0.0, 0.0, 1.0))
|
||||
assert tuple(end) == pytest.approx((0.0, 0.0, 3.0))
|
||||
|
||||
|
||||
def test_extend_preview_line_renders_trim_when_cursor_inside_segment():
|
||||
"""Cursor inside the segment → line runs from current end BACK to the
|
||||
projected (shorter) length."""
|
||||
from bonsai.bim.module.model.decorator import MEPSegmentExtendPreviewDecorator
|
||||
|
||||
result = MEPSegmentExtendPreviewDecorator._compute_extend_preview_line(
|
||||
matrix_world=Matrix.Identity(4),
|
||||
cursor_world=Vector((0.0, 0.0, 0.4)),
|
||||
current_length=1.0,
|
||||
)
|
||||
assert result is not None
|
||||
start, end = result
|
||||
assert tuple(start) == pytest.approx((0.0, 0.0, 1.0))
|
||||
assert tuple(end) == pytest.approx((0.0, 0.0, 0.4))
|
||||
|
||||
|
||||
def test_extend_preview_line_follows_raw_projection_behind_segment_origin():
|
||||
"""When the cursor's projected Z is negative (behind segment origin),
|
||||
the preview line must follow the raw cursor projection — the user is
|
||||
pointing somewhere and expects to see where, even though the operator
|
||||
would floor the actual commit. Matching the operator's clamp would
|
||||
hide the line whenever the cursor crossed the segment origin."""
|
||||
from bonsai.bim.module.model.decorator import MEPSegmentExtendPreviewDecorator
|
||||
|
||||
result = MEPSegmentExtendPreviewDecorator._compute_extend_preview_line(
|
||||
matrix_world=Matrix.Identity(4),
|
||||
cursor_world=Vector((0.0, 0.0, -2.0)),
|
||||
current_length=1.0,
|
||||
)
|
||||
assert result is not None
|
||||
start, end = result
|
||||
assert tuple(start) == pytest.approx((0.0, 0.0, 1.0))
|
||||
assert tuple(end) == pytest.approx((0.0, 0.0, -2.0))
|
||||
|
||||
|
||||
def test_extend_preview_line_respects_object_rotation():
|
||||
"""A rotated segment (90° around Y) should produce world-space endpoints
|
||||
rotated accordingly. Pin so a future refactor doesn't drop the
|
||||
matrix_world multiplication."""
|
||||
import math
|
||||
|
||||
from bonsai.bim.module.model.decorator import MEPSegmentExtendPreviewDecorator
|
||||
|
||||
rotation = Matrix.Rotation(math.pi / 2, 4, "Y")
|
||||
result = MEPSegmentExtendPreviewDecorator._compute_extend_preview_line(
|
||||
matrix_world=rotation,
|
||||
cursor_world=Vector((3.0, 0.0, 0.0)),
|
||||
current_length=1.0,
|
||||
)
|
||||
assert result is not None
|
||||
start, end = result
|
||||
# local (0, 0, 1) rotated by 90° around Y → world (1, 0, 0).
|
||||
assert tuple(start) == pytest.approx((1.0, 0.0, 0.0), abs=1e-6)
|
||||
# local (0, 0, 3) rotated by 90° around Y → world (3, 0, 0).
|
||||
assert tuple(end) == pytest.approx((3.0, 0.0, 0.0), abs=1e-6)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle drift handling — Enable / Finish / Cancel must commit / restore
|
||||
# matrix_world ↔ IFC ObjectPlacement at the appropriate lifecycle points.
|
||||
# The AST forward-compat guard pins "a drift hook IS called somewhere"; these
|
||||
# tests pin "the hook is called in the right branch with the right args."
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_segment_context(length=2.0, snap_length=2.0, scale_z=1.0):
|
||||
"""Build (context, props, obj, element) fakes for the MEP edit-lifecycle bases.
|
||||
The bases access ``self.__class__._predicate`` / ``_props_getter`` so
|
||||
callers must instantiate a concrete test subclass and call
|
||||
``instance._execute(context)`` rather than passing a Mock as ``self``."""
|
||||
obj = Mock(name="obj")
|
||||
obj.scale = Vector((1.0, 1.0, scale_z))
|
||||
element = Mock(name="element")
|
||||
props = Mock(name="props")
|
||||
props.length = length
|
||||
props.snap_length = snap_length
|
||||
props.snap_object_scale_z = scale_z
|
||||
props.mesh_dirty = False
|
||||
|
||||
context = Mock(name="context")
|
||||
context.active_object = obj
|
||||
return context, props, obj, element
|
||||
|
||||
|
||||
def _concrete_mep_mixin(props):
|
||||
"""Build a concrete ``_MEPSegmentEditMixin`` subclass that bypasses the
|
||||
IFC predicate gate and returns the supplied ``props`` from ``_get_props``.
|
||||
The unified mixin replaced the three-base-class lifecycle pattern; tests now
|
||||
target the single mixin and override the two ParametricEditMixinBase
|
||||
hooks instead of class-level ``_predicate`` / ``_props_getter``."""
|
||||
from bonsai.bim.module.model.mep import _MEPSegmentEditMixin
|
||||
|
||||
class _ConcreteMEPMixin(_MEPSegmentEditMixin):
|
||||
@classmethod
|
||||
def _is_element_type(cls, element):
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def _get_props(cls, obj):
|
||||
return props
|
||||
|
||||
return _ConcreteMEPMixin
|
||||
|
||||
|
||||
def test_enable_pipe_segment_commits_pre_edit_placement_drift():
|
||||
"""Enable must call ``commit_placement_if_moved(obj, apply_scale=False)``
|
||||
BEFORE ``_segment_world_length`` captures ``snap_length``. Without the
|
||||
commit, snap_length is read from a dragged matrix_world while the IFC
|
||||
ObjectPlacement is stale — Finish's set_depth would then write
|
||||
representation coords relative to the wrong origin."""
|
||||
context, props, obj, element = _make_segment_context()
|
||||
cls = _concrete_mep_mixin(props)
|
||||
|
||||
with (
|
||||
patch("bonsai.bim.module.model.mep.tool") as mock_tool,
|
||||
patch("bonsai.bim.parametric_lifecycle.tool", mock_tool),
|
||||
patch("bonsai.bim.module.model.mep._segment_world_length", return_value=2.0),
|
||||
):
|
||||
mock_tool.Ifc.get_entity.return_value = element
|
||||
cls()._enable_targets(context)
|
||||
|
||||
mock_tool.Geometry.commit_placement_if_moved.assert_called_once_with(obj, apply_scale=False)
|
||||
|
||||
|
||||
def test_finish_pipe_segment_commits_drift_when_no_length_change():
|
||||
"""Finish without a length change must STILL commit matrix_world drift —
|
||||
the bug class that motivated this guard. The conditional ``set_depth``
|
||||
branch covers the length-changed path transitively; the unconditional
|
||||
``commit_placement_if_moved`` after the if/else closes the silent-drop
|
||||
path."""
|
||||
# length == snap_length → no-op session.
|
||||
context, props, obj, element = _make_segment_context(length=2.0, snap_length=2.0)
|
||||
cls = _concrete_mep_mixin(props)
|
||||
|
||||
with (
|
||||
patch("bonsai.bim.module.model.mep.tool") as mock_tool,
|
||||
patch("bonsai.bim.parametric_lifecycle.tool", mock_tool),
|
||||
patch("bonsai.bim.module.model.mep.DumbProfileJoiner") as mock_joiner,
|
||||
patch("bonsai.bim.module.model.mep._restore_segment_mesh_if_dirty"),
|
||||
patch("bonsai.bim.module.model.mep._restore_segment_scale_to"),
|
||||
):
|
||||
mock_tool.Ifc.get_entity.return_value = element
|
||||
cls()._finish_targets(context)
|
||||
mock_joiner.return_value.set_depth.assert_not_called() # no-length branch
|
||||
|
||||
mock_tool.Geometry.commit_placement_if_moved.assert_called_once_with(obj)
|
||||
|
||||
|
||||
def test_cancel_pipe_segment_delegates_to_restore_or_rebaseline():
|
||||
"""Cancel must call ``tool.Geometry.restore_or_rebaseline_placement`` so
|
||||
matrix_world reverts in lockstep with the props draft. The helper owns
|
||||
the is_moved / ObjectPlacement gate."""
|
||||
context, props, obj, element = _make_segment_context()
|
||||
cls = _concrete_mep_mixin(props)
|
||||
|
||||
with (
|
||||
patch("bonsai.bim.module.model.mep.tool") as mock_tool,
|
||||
patch("bonsai.bim.parametric_lifecycle.tool", mock_tool),
|
||||
patch("bonsai.bim.module.model.mep._restore_segment_mesh_if_dirty"),
|
||||
):
|
||||
mock_tool.Ifc.get_entity.return_value = element
|
||||
cls()._cancel_targets(context)
|
||||
|
||||
mock_tool.Geometry.restore_or_rebaseline_placement.assert_called_once_with(obj, element)
|
||||
@@ -32,12 +32,6 @@ import pytest
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _require_real_bpy():
|
||||
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
|
||||
pytest.skip("requires real Blender (bpy is mocked or absent)")
|
||||
|
||||
|
||||
def _registry():
|
||||
from bonsai.bim.module.model.preview_base import PREVIEW_CANCEL_OPS
|
||||
|
||||
@@ -139,6 +133,55 @@ class TestActivationCycle:
|
||||
assert props.is_active is False, f"discard_pending_previews left '{attr}' active"
|
||||
|
||||
|
||||
class TestClearPreviewState:
|
||||
"""``clear_preview_state`` is the shared cleanup routine every preview
|
||||
operator calls on commit / cancel. The contract is: ``is_active`` flips
|
||||
to False, every ``*_id`` IntProperty zeroes, everything else stays."""
|
||||
|
||||
def test_clears_is_active_and_id_fields_on_real_property_groups(self):
|
||||
from bonsai.bim.module.model.preview_base import clear_preview_state
|
||||
|
||||
registered = _registered_previews()
|
||||
if not registered:
|
||||
pytest.skip("No previews wired in this build — registry-only entries")
|
||||
|
||||
for attr, _, props in registered:
|
||||
# Seed every *_id IntProperty with a non-zero sentinel and flip
|
||||
# the activity flag so the helper has something to clear.
|
||||
id_fields = [
|
||||
name for name, rna in props.bl_rna.properties.items() if name.endswith("_id") and rna.type == "INT"
|
||||
]
|
||||
assert id_fields, f"Preview '{attr}' has no *_id IntProperty — registry shape changed"
|
||||
for name in id_fields:
|
||||
setattr(props, name, 42)
|
||||
props.is_active = True
|
||||
|
||||
clear_preview_state(props)
|
||||
|
||||
assert props.is_active is False, f"Preview '{attr}' is_active not cleared"
|
||||
for name in id_fields:
|
||||
assert getattr(props, name) == 0, f"Preview '{attr}' field '{name}' not zeroed"
|
||||
|
||||
def test_leaves_non_id_fields_untouched(self):
|
||||
"""Non-``*_id`` fields (FloatProperty params like ``radius``,
|
||||
``start_length``) must survive the reset — they re-seed on the next
|
||||
enable, so untouching them here avoids a redundant write."""
|
||||
from bonsai.bim.module.model.preview_base import clear_preview_state
|
||||
|
||||
bend = getattr(_preview_umbrella(), "bend", None)
|
||||
if bend is None:
|
||||
pytest.skip("Bend preview not wired in this build")
|
||||
|
||||
bend.is_active = True
|
||||
bend.start_length = 0.42
|
||||
bend.radius = 0.99
|
||||
clear_preview_state(bend)
|
||||
|
||||
assert bend.is_active is False
|
||||
assert bend.start_length == pytest.approx(0.42)
|
||||
assert bend.radius == pytest.approx(0.99)
|
||||
|
||||
|
||||
class TestSaveOnDiscardWired:
|
||||
"""Pins that the SaveProject operator clears preview state before writing
|
||||
the IFC file — a stuck is_active flag persisted through the save would
|
||||
|
||||
@@ -39,12 +39,6 @@ import pytest
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _require_real_bpy():
|
||||
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
|
||||
pytest.skip("requires real Blender (bpy is mocked or absent)")
|
||||
|
||||
|
||||
def _rotation_close(a, b, tol: float = 1e-6) -> bool:
|
||||
for row_a, row_b in zip(a, b):
|
||||
for va, vb in zip(row_a, row_b):
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Behaviour contract: every parametric gizmo group hides while a Blender
|
||||
transform modal (G/R/S and siblings) is dragging ``matrix_world``.
|
||||
|
||||
Discovery walks each parametric-edit module rather than naming gizmo groups —
|
||||
adding a new group automatically joins the test. The test exercises the
|
||||
BEHAVIOUR (poll returns False / draw_prepare early-returns when a transform
|
||||
modal is active) without pinning the name of the helper used internally."""
|
||||
|
||||
import importlib
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
PARAMETRIC_MODULES = (
|
||||
"bonsai.bim.module.model.array",
|
||||
"bonsai.bim.module.model.door",
|
||||
"bonsai.bim.module.model.host_add_opening_gizmo",
|
||||
"bonsai.bim.module.model.roof",
|
||||
"bonsai.bim.module.model.stair",
|
||||
"bonsai.bim.module.model.wall",
|
||||
"bonsai.bim.module.model.window",
|
||||
)
|
||||
|
||||
|
||||
def _discover_parametric_gizmo_groups():
|
||||
"""Walk each parametric-edit module for ``bpy.types.GizmoGroup`` subclasses
|
||||
defined locally. Preview-owning gizmo groups (bl_idname contains 'preview')
|
||||
are excluded from the poll-level test: their poll legitimately fires while
|
||||
the preview is active, and the transform-modal hide for them lives in
|
||||
``draw_prepare`` via ``BillboardingGizmoGroupMixin``."""
|
||||
out = []
|
||||
for mod_path in PARAMETRIC_MODULES:
|
||||
mod = importlib.import_module(mod_path)
|
||||
for name in dir(mod):
|
||||
obj = getattr(mod, name)
|
||||
if not isinstance(obj, type):
|
||||
continue
|
||||
if not issubclass(obj, bpy.types.GizmoGroup) or obj is bpy.types.GizmoGroup:
|
||||
continue
|
||||
if obj.__module__ != mod.__name__:
|
||||
continue
|
||||
bl_idname = (getattr(obj, "bl_idname", "") or "").lower()
|
||||
if "preview" in bl_idname:
|
||||
continue
|
||||
out.append((f"{mod_path.rsplit('.', 1)[-1]}.{name}", obj))
|
||||
return out
|
||||
|
||||
|
||||
class TestDiscoveryFindsParametricGizmoGroups:
|
||||
def test_at_least_one_group_per_canonical_module(self):
|
||||
"""If discovery returns zero groups for a module the walk has drifted —
|
||||
likely the gizmo group moved to a different file. Surface the drift
|
||||
with the module name in the diagnostic."""
|
||||
per_module: dict[str, int] = {}
|
||||
for fq_name, _cls in _discover_parametric_gizmo_groups():
|
||||
mod_short = fq_name.split(".", 1)[0]
|
||||
per_module[mod_short] = per_module.get(mod_short, 0) + 1
|
||||
empty = [m.rsplit(".", 1)[-1] for m in PARAMETRIC_MODULES if per_module.get(m.rsplit(".", 1)[-1], 0) == 0]
|
||||
assert not empty, (
|
||||
f"Parametric modules with zero GizmoGroup subclasses (discovery walk drifted?): {empty}. "
|
||||
"Update PARAMETRIC_MODULES or check whether the gizmo groups moved to a new file."
|
||||
)
|
||||
|
||||
|
||||
class TestParametricGizmoPollsHideDuringTransformModal:
|
||||
"""For each discovered parametric gizmo group, mock the transform-modal
|
||||
detector to True and call ``poll(bpy.context)``. Every poll must return
|
||||
False — any True is a poll that wouldn't hide during a G/R/S drag, leaving
|
||||
the gizmos jittering against the dragging matrix."""
|
||||
|
||||
def test_every_group_poll_returns_false_when_transform_modal_active(self):
|
||||
groups = _discover_parametric_gizmo_groups()
|
||||
offenders = []
|
||||
with patch(
|
||||
"bonsai.bim.module.drawing.gizmos._is_transform_modal_active",
|
||||
return_value=True,
|
||||
):
|
||||
for name, cls in groups:
|
||||
poll = getattr(cls, "poll", None)
|
||||
if poll is None:
|
||||
continue
|
||||
try:
|
||||
result = poll(bpy.context)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
offenders.append((name, f"poll raised: {type(exc).__name__}: {exc}"))
|
||||
continue
|
||||
if result:
|
||||
offenders.append((name, "poll returned True with transform modal active"))
|
||||
|
||||
assert not offenders, (
|
||||
"Parametric gizmo polls that don't gate on the transform-modal detector "
|
||||
"(or raise instead of returning False): "
|
||||
+ ", ".join(f"{n} — {why}" for n, why in offenders)
|
||||
+ ". Hide parametric gizmos while Blender's transform modal is dragging "
|
||||
"matrix_world so they don't jitter off-cursor. The conventional path is to "
|
||||
"early-return from poll when _is_transform_modal_active(context) is True."
|
||||
)
|
||||
|
||||
|
||||
class TestBaseParametricPollHidesDuringTransformModal:
|
||||
"""Cross-feature base poll: door / window / stair / roof / railing / array
|
||||
all inherit ``BaseParametricGizmoGroup``. Its poll must short-circuit on
|
||||
the transform-modal detector so every inheriting feature behaves uniformly."""
|
||||
|
||||
def test_base_parametric_poll_returns_false(self):
|
||||
from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
|
||||
|
||||
with patch("bonsai.tool.Blender.get_active_object", return_value=object()):
|
||||
with patch("bonsai.tool.Blender.are_viewport_gizmos_enabled", return_value=True):
|
||||
with patch(
|
||||
"bonsai.bim.module.model.preview_base.any_preview_active",
|
||||
return_value=False,
|
||||
):
|
||||
with patch(
|
||||
"bonsai.bim.module.drawing.gizmos._is_transform_modal_active",
|
||||
return_value=True,
|
||||
):
|
||||
assert BaseParametricGizmoGroup.poll(bpy.context) is False
|
||||
|
||||
|
||||
class TestBaseIconActionPollHidesDuringTransformModal:
|
||||
"""``BaseIconActionGroup`` is the parent of the simple icon-row gizmo
|
||||
groups; its poll mirrors the base parametric gate for forward-compat
|
||||
symmetry. Pinning here ensures a future icon-row group authored via this
|
||||
base inherits the transform-modal hide for free."""
|
||||
|
||||
def test_base_icon_action_poll_returns_false(self):
|
||||
from bonsai.bim.module.drawing.gizmos import BaseIconActionGroup
|
||||
|
||||
with patch("bonsai.tool.Blender.get_active_object", return_value=object()):
|
||||
with patch("bonsai.tool.Blender.are_viewport_gizmos_enabled", return_value=True):
|
||||
with patch(
|
||||
"bonsai.bim.module.drawing.gizmos._is_transform_modal_active",
|
||||
return_value=True,
|
||||
):
|
||||
assert BaseIconActionGroup.poll(bpy.context) is False
|
||||
|
||||
|
||||
class TestHelperReadsWindowModalOperators:
|
||||
"""Pin the public contract of ``_is_transform_modal_active``: it reads
|
||||
``context.window.modal_operators`` (Blender 4.2+) and returns True iff any
|
||||
operator's ``bl_idname`` starts with ``TRANSFORM_OT_``. The check itself
|
||||
is dependency-free and worth pinning so a future refactor that swaps the
|
||||
detection mechanism either keeps the contract or updates the test."""
|
||||
|
||||
def test_returns_true_for_transform_translate(self):
|
||||
from bonsai.bim.module.drawing.gizmos import _is_transform_modal_active
|
||||
|
||||
fake_op = MagicMock()
|
||||
fake_op.bl_idname = "TRANSFORM_OT_translate"
|
||||
fake_context = MagicMock()
|
||||
fake_context.window.modal_operators = [fake_op]
|
||||
assert _is_transform_modal_active(fake_context) is True
|
||||
|
||||
def test_returns_true_for_transform_rotate_and_resize(self):
|
||||
from bonsai.bim.module.drawing.gizmos import _is_transform_modal_active
|
||||
|
||||
for idname in ("TRANSFORM_OT_rotate", "TRANSFORM_OT_resize", "TRANSFORM_OT_shear"):
|
||||
fake_op = MagicMock()
|
||||
fake_op.bl_idname = idname
|
||||
fake_context = MagicMock()
|
||||
fake_context.window.modal_operators = [fake_op]
|
||||
assert _is_transform_modal_active(fake_context) is True, f"missed {idname}"
|
||||
|
||||
def test_returns_false_for_non_transform_modal(self):
|
||||
from bonsai.bim.module.drawing.gizmos import _is_transform_modal_active
|
||||
|
||||
fake_op = MagicMock()
|
||||
fake_op.bl_idname = "VIEW3D_OT_select_box"
|
||||
fake_context = MagicMock()
|
||||
fake_context.window.modal_operators = [fake_op]
|
||||
assert _is_transform_modal_active(fake_context) is False
|
||||
|
||||
def test_returns_true_for_bonsai_move_macro(self):
|
||||
"""Bonsai overrides the G key with a macro that wraps
|
||||
``TRANSFORM_OT_translate``. While the macro is the outer modal entry
|
||||
the inner transform does not surface in ``modal_operators``; matching
|
||||
the macro idname covers the gap. Note Blender exposes ``bl_idname``
|
||||
at runtime in the ``BIM_OT_<verb_noun>`` form, not the ``bim.<verb_noun>``
|
||||
form used in the class declaration — verified via real-Blender modal
|
||||
introspection during grab."""
|
||||
from bonsai.bim.module.drawing.gizmos import _is_transform_modal_active
|
||||
|
||||
macros = (
|
||||
"BIM_OT_override_move_macro",
|
||||
"BIM_OT_override_object_duplicate_move_macro",
|
||||
"BIM_OT_override_object_duplicate_move_linked_macro",
|
||||
"BIM_OT_object_duplicate_move_linked_aggregate_macro",
|
||||
)
|
||||
for idname in macros:
|
||||
fake_op = MagicMock()
|
||||
fake_op.bl_idname = idname
|
||||
fake_context = MagicMock()
|
||||
fake_context.window.modal_operators = [fake_op]
|
||||
assert _is_transform_modal_active(fake_context) is True, f"missed {idname}"
|
||||
|
||||
def test_returns_false_for_empty_modal_stack(self):
|
||||
from bonsai.bim.module.drawing.gizmos import _is_transform_modal_active
|
||||
|
||||
fake_context = MagicMock()
|
||||
fake_context.window.modal_operators = []
|
||||
assert _is_transform_modal_active(fake_context) is False
|
||||
|
||||
def test_returns_false_when_window_is_none(self):
|
||||
from bonsai.bim.module.drawing.gizmos import _is_transform_modal_active
|
||||
|
||||
fake_context = MagicMock()
|
||||
fake_context.window = None
|
||||
assert _is_transform_modal_active(fake_context) is False
|
||||
@@ -0,0 +1,133 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Forward-compat AST guard: every multi-object wall topology GizmoGroup
|
||||
filters Bonsai array children via ``_wall_topology_gizmo_poll_gate`` or
|
||||
the central ``any_selected_is_array_child`` predicate.
|
||||
|
||||
Allow-list (gizmos intentionally outside the rule):
|
||||
|
||||
- ``GizmoWallEdition`` — single-object parametric edit gizmo. Its base
|
||||
parametric poll already filters array children.
|
||||
- ``GizmoWallFilletPreview`` — the preview-owner whose poll must fire
|
||||
WHILE its own preview is active; routing it through the topology gate
|
||||
would self-block it.
|
||||
|
||||
Host-opening gizmos live in a sibling module and intentionally use the
|
||||
loose base ``_wall_gizmo_poll_gate``: openings track with the child
|
||||
through ``regenerate_array`` and stay authorable on children.
|
||||
|
||||
A new wall ``GizmoGroup`` added without the filter (and not added to the
|
||||
allow-list with an explanation) fails this test."""
|
||||
|
||||
import ast
|
||||
import inspect
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
# Wall gizmo groups intentionally outside the rule. Add a new entry only
|
||||
# with the in-code reasoning above.
|
||||
_ALLOWLIST = frozenset({"GizmoWallEdition", "GizmoWallFilletPreview"})
|
||||
|
||||
_REQUIRED_CALLEES = frozenset({"_wall_topology_gizmo_poll_gate", "any_selected_is_array_child"})
|
||||
|
||||
|
||||
def _wall_module_source():
|
||||
from bonsai.bim.module.model import wall as wall_mod
|
||||
|
||||
return inspect.getsource(wall_mod), wall_mod.__name__
|
||||
|
||||
|
||||
def _wall_gizmo_group_classes():
|
||||
"""All ``bpy.types.GizmoGroup`` subclasses defined locally in wall.py."""
|
||||
from bonsai.bim.module.model import wall as wall_mod
|
||||
|
||||
out = []
|
||||
for name in dir(wall_mod):
|
||||
obj = getattr(wall_mod, name)
|
||||
if not isinstance(obj, type):
|
||||
continue
|
||||
if not issubclass(obj, bpy.types.GizmoGroup) or obj is bpy.types.GizmoGroup:
|
||||
continue
|
||||
if obj.__module__ != wall_mod.__name__:
|
||||
continue
|
||||
out.append((name, obj))
|
||||
return out
|
||||
|
||||
|
||||
def _poll_function_calls(class_node):
|
||||
"""Names of every function called inside ``class_node``'s ``poll`` body.
|
||||
|
||||
``ast.Call.func`` may be an ``ast.Name`` (bare call) or an ``ast.Attribute``
|
||||
(dotted call). For the dotted case the leaf attribute is returned so
|
||||
``tool.Blender.Modifier.any_selected_is_array_child(...)`` registers as
|
||||
``any_selected_is_array_child``."""
|
||||
poll_node = next(
|
||||
(node for node in class_node.body if isinstance(node, ast.FunctionDef) and node.name == "poll"),
|
||||
None,
|
||||
)
|
||||
if poll_node is None:
|
||||
return None
|
||||
names = set()
|
||||
for sub in ast.walk(poll_node):
|
||||
if not isinstance(sub, ast.Call):
|
||||
continue
|
||||
func = sub.func
|
||||
if isinstance(func, ast.Name):
|
||||
names.add(func.id)
|
||||
elif isinstance(func, ast.Attribute):
|
||||
names.add(func.attr)
|
||||
return names
|
||||
|
||||
|
||||
def test_every_wall_gizmo_group_filters_array_children_or_is_allowlisted():
|
||||
"""For every locally-defined wall ``GizmoGroup`` not in the allow-list,
|
||||
its ``poll`` must call ``_wall_gizmo_poll_gate`` or the central
|
||||
``any_selected_is_array_child`` predicate. A failure surfaces the list
|
||||
of offending classes — the fix is a single early-return through the
|
||||
central helper, mirroring the existing peers."""
|
||||
source, _module_name = _wall_module_source()
|
||||
tree = ast.parse(source)
|
||||
class_nodes = {node.name: node for node in ast.walk(tree) if isinstance(node, ast.ClassDef)}
|
||||
offenders = []
|
||||
for class_name, _cls in _wall_gizmo_group_classes():
|
||||
if class_name in _ALLOWLIST:
|
||||
continue
|
||||
node = class_nodes.get(class_name)
|
||||
if node is None:
|
||||
offenders.append((class_name, "AST parse did not find the class"))
|
||||
continue
|
||||
calls = _poll_function_calls(node)
|
||||
if calls is None:
|
||||
offenders.append((class_name, "no poll() defined; expected the array-child filter call"))
|
||||
continue
|
||||
if not (calls & _REQUIRED_CALLEES):
|
||||
offenders.append((class_name, f"poll() does not call any of {sorted(_REQUIRED_CALLEES)}"))
|
||||
|
||||
assert not offenders, (
|
||||
"Wall GizmoGroup classes missing the array-child filter: "
|
||||
+ ", ".join(f"{n} — {why}" for n, why in offenders)
|
||||
+ ". Route the poll through `_wall_topology_gizmo_poll_gate(context)` "
|
||||
"so the central `any_selected_is_array_child` filter applies, or add "
|
||||
"the class to the file's allow-list with a documented reason."
|
||||
)
|
||||
@@ -28,7 +28,6 @@ joins the test. The test then asserts the BEHAVIOUR (poll returns False when
|
||||
helper function the gizmo uses internally to enforce it."""
|
||||
|
||||
import inspect
|
||||
import types
|
||||
from unittest.mock import patch
|
||||
|
||||
import bpy
|
||||
@@ -37,12 +36,6 @@ import pytest
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _require_real_bpy():
|
||||
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
|
||||
pytest.skip("requires real Blender (bpy is mocked or absent)")
|
||||
|
||||
|
||||
def _wall_gizmo_groups():
|
||||
"""Walk the wall module for ``bpy.types.GizmoGroup`` subclasses defined
|
||||
locally (skip imported references). Returns a list of (name, cls) tuples.
|
||||
|
||||
@@ -25,7 +25,6 @@ logic can be exercised without a real IFC fixture. Each test pins one of the
|
||||
gates ``poll()`` walks, so any silent regression in the gate order or in the
|
||||
LAYER3-active / LAYER2-other contract is caught by a dedicated assertion."""
|
||||
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -35,10 +34,15 @@ import pytest
|
||||
pytestmark = pytest.mark.wall
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _require_real_bpy():
|
||||
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
|
||||
pytest.skip("requires real Blender (bpy is mocked or absent)")
|
||||
class _Obj:
|
||||
"""Hashable, name-bearing stand-in for a ``bpy.types.Object`` selection
|
||||
slot. ``SimpleNamespace`` defines ``__eq__`` (and so ``__hash__ = None``)
|
||||
which makes it unusable inside the ``set()`` that
|
||||
``get_selected_objects()`` returns; a plain class falls back to
|
||||
identity-based hashing and works inside both ``set`` and ``list``."""
|
||||
|
||||
def __init__(self, name: str) -> None:
|
||||
self.name = name
|
||||
|
||||
|
||||
def _make_context(active, selected):
|
||||
@@ -76,19 +80,23 @@ def _patch_tools(prefs_on, selected, active_element, other_element, active_usage
|
||||
patch.object(tool.Blender, "get_selected_objects", return_value=set(selected)),
|
||||
patch.object(tool.Ifc, "get_entity", side_effect=get_entity),
|
||||
patch.object(tool.Model, "get_usage_type", side_effect=get_usage_type),
|
||||
# The array-child filter is pinned by its own test file; stub it here
|
||||
# so these poll tests stay focused on the count / layer-usage gates
|
||||
# and don't have to scaffold the memoization cache key.
|
||||
patch.object(tool.Blender.Modifier, "any_selected_is_array_child", return_value=False),
|
||||
]
|
||||
|
||||
|
||||
def _run_poll(prefs_on, active_is_in_selected, len_override, active_usage, other_usage, active_has_entity=True):
|
||||
from bonsai.bim.module.model.wall import GizmoWallExtendVertically
|
||||
|
||||
slab_obj = object()
|
||||
wall_obj = object()
|
||||
active = slab_obj if active_is_in_selected else object()
|
||||
slab_obj = _Obj("slab")
|
||||
wall_obj = _Obj("wall")
|
||||
active = slab_obj if active_is_in_selected else _Obj("active_extra")
|
||||
if len_override is None:
|
||||
selected = [slab_obj, wall_obj]
|
||||
else:
|
||||
selected = [object() for _ in range(len_override)]
|
||||
selected = [_Obj(f"obj_{i}") for i in range(len_override)]
|
||||
if active_is_in_selected and selected:
|
||||
active = selected[0]
|
||||
|
||||
@@ -302,3 +310,92 @@ def test_iter_path_connections_walks_both_inverses_in_order():
|
||||
rel_from = _make_path_rel(relating=p2, related=self_elem, relating_ct="ATEND", related_ct="ATEND")
|
||||
elem = SimpleNamespace(ConnectedTo=[rel_to], ConnectedFrom=[rel_from])
|
||||
assert _run_iter_path_connections(elem) == [(p1, "ATSTART", "ATSTART"), (p2, "ATEND", "ATEND")]
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# _perpendicular_wall_params — clamping + side detection for the
|
||||
# "add perpendicular wall at cursor" gizmo and its operator.
|
||||
# ----------------------------------------------------------------------------
|
||||
#
|
||||
# Pure scalar math. The dead-zone is ``CURSOR_STACK_OFFSET`` — inside it the
|
||||
# on-axis split / extend-X icons own the click and this helper returns None.
|
||||
|
||||
|
||||
def _wall_consts():
|
||||
from bonsai.bim.module.model.wall import GizmoWallEdition
|
||||
|
||||
return GizmoWallEdition.CURSOR_STACK_OFFSET
|
||||
|
||||
|
||||
def _run_perp_params(cursor_x, cursor_y, anchor_x=0.0, length=5.0):
|
||||
from bonsai.bim.module.model.wall import _perpendicular_wall_params
|
||||
|
||||
return _perpendicular_wall_params(cursor_x, cursor_y, anchor_x, length)
|
||||
|
||||
|
||||
def test_perpendicular_params_on_axis_returns_none():
|
||||
assert _run_perp_params(cursor_x=2.0, cursor_y=0.0) is None
|
||||
|
||||
|
||||
def test_perpendicular_params_at_dead_zone_boundary_returns_none():
|
||||
# Inclusive boundary: at exactly the threshold the on-axis icons still own
|
||||
# the click; the gizmo only takes over strictly past the dead zone.
|
||||
threshold = _wall_consts()
|
||||
assert _run_perp_params(cursor_x=2.0, cursor_y=threshold) is None
|
||||
assert _run_perp_params(cursor_x=2.0, cursor_y=-threshold) is None
|
||||
|
||||
|
||||
def test_perpendicular_params_just_past_dead_zone_returns_params():
|
||||
threshold = _wall_consts()
|
||||
result = _run_perp_params(cursor_x=2.0, cursor_y=threshold + 0.01)
|
||||
assert result is not None
|
||||
clamped_x, length, side = result
|
||||
assert clamped_x == pytest.approx(2.0)
|
||||
assert length == pytest.approx(threshold + 0.01)
|
||||
assert side == 1.0
|
||||
|
||||
|
||||
def test_perpendicular_params_negative_y_flips_side_sign():
|
||||
result = _run_perp_params(cursor_x=2.0, cursor_y=-1.5)
|
||||
assert result is not None
|
||||
_, length, side = result
|
||||
# Length is always positive — the side sign carries the direction so the
|
||||
# operator can pick the +90° vs -90° rotation without sign-flipping length.
|
||||
assert length == pytest.approx(1.5)
|
||||
assert side == -1.0
|
||||
|
||||
|
||||
def test_perpendicular_params_clamps_low_when_cursor_left_of_wall():
|
||||
result = _run_perp_params(cursor_x=-2.0, cursor_y=1.5, anchor_x=0.0, length=5.0)
|
||||
assert result is not None
|
||||
clamped_x, _length, _side = result
|
||||
assert clamped_x == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_perpendicular_params_clamps_high_when_cursor_right_of_wall():
|
||||
result = _run_perp_params(cursor_x=10.0, cursor_y=1.5, anchor_x=0.0, length=5.0)
|
||||
assert result is not None
|
||||
clamped_x, _length, _side = result
|
||||
assert clamped_x == pytest.approx(5.0)
|
||||
|
||||
|
||||
def test_perpendicular_params_respects_nonzero_anchor_x():
|
||||
# Non-zero anchor_x shifts the wall span; clamping must follow.
|
||||
result = _run_perp_params(cursor_x=0.5, cursor_y=1.5, anchor_x=2.0, length=5.0)
|
||||
assert result is not None
|
||||
clamped_x, _length, _side = result
|
||||
assert clamped_x == pytest.approx(2.0)
|
||||
|
||||
result = _run_perp_params(cursor_x=10.0, cursor_y=1.5, anchor_x=2.0, length=5.0)
|
||||
assert result is not None
|
||||
clamped_x, _length, _side = result
|
||||
assert clamped_x == pytest.approx(7.0)
|
||||
|
||||
|
||||
def test_perpendicular_params_in_range_passes_cursor_x_through():
|
||||
result = _run_perp_params(cursor_x=3.0, cursor_y=1.5, anchor_x=0.0, length=5.0)
|
||||
assert result is not None
|
||||
clamped_x, length, side = result
|
||||
assert clamped_x == pytest.approx(3.0)
|
||||
assert length == pytest.approx(1.5)
|
||||
assert side == 1.0
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Behaviour contract: wall topology gizmos and operators reject any
|
||||
selection that contains a Bonsai array child. Discovers gated gizmo
|
||||
groups and guarded operators by source inspection so additions inherit
|
||||
the rule automatically."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
def _wall_gizmo_groups_using_gate():
|
||||
"""Wall-module ``bpy.types.GizmoGroup`` subclasses whose ``poll`` calls
|
||||
``_wall_topology_gizmo_poll_gate``. Discovered by source inspection so
|
||||
the test tracks the gate's user set as the module grows."""
|
||||
import inspect
|
||||
|
||||
from bonsai.bim.module.model import wall as wall_mod
|
||||
|
||||
out = []
|
||||
for name in dir(wall_mod):
|
||||
obj = getattr(wall_mod, name)
|
||||
if not isinstance(obj, type):
|
||||
continue
|
||||
if not issubclass(obj, bpy.types.GizmoGroup) or obj is bpy.types.GizmoGroup:
|
||||
continue
|
||||
if obj.__module__ != wall_mod.__name__:
|
||||
continue
|
||||
poll = obj.__dict__.get("poll")
|
||||
if poll is None:
|
||||
continue
|
||||
try:
|
||||
src = inspect.getsource(poll)
|
||||
except (OSError, TypeError):
|
||||
continue
|
||||
if "_wall_topology_gizmo_poll_gate" not in src:
|
||||
continue
|
||||
out.append((name, obj))
|
||||
return out
|
||||
|
||||
|
||||
def _wall_operators_with_array_child_guard():
|
||||
"""Wall-module ``bpy.types.Operator`` subclasses whose ``poll`` rejects
|
||||
array-child selections, either by referencing the central predicate
|
||||
directly or by routing through the shared ``_poll_reject_array_children``
|
||||
helper that wraps it. The operator-level guard is defence in depth
|
||||
against keymap / F3 paths that bypass the gizmo entirely."""
|
||||
import inspect
|
||||
|
||||
from bonsai.bim.module.model import wall as wall_mod
|
||||
|
||||
out = []
|
||||
for name in dir(wall_mod):
|
||||
obj = getattr(wall_mod, name)
|
||||
if not isinstance(obj, type):
|
||||
continue
|
||||
if not issubclass(obj, bpy.types.Operator) or obj is bpy.types.Operator:
|
||||
continue
|
||||
if obj.__module__ != wall_mod.__name__:
|
||||
continue
|
||||
poll = obj.__dict__.get("poll")
|
||||
if poll is None:
|
||||
continue
|
||||
try:
|
||||
src = inspect.getsource(poll)
|
||||
except (OSError, TypeError):
|
||||
continue
|
||||
if "any_selected_is_array_child" not in src and "_poll_reject_array_children" not in src:
|
||||
continue
|
||||
out.append((name, obj))
|
||||
return out
|
||||
|
||||
|
||||
class TestWallGizmoGroupsHideOnArrayChildSelection:
|
||||
def test_discovery_finds_wall_multi_object_gizmo_groups(self):
|
||||
groups = _wall_gizmo_groups_using_gate()
|
||||
assert groups, (
|
||||
"Expected at least one wall GizmoGroup whose poll calls "
|
||||
"_wall_gizmo_poll_gate — discovery walk drifted out of sync?"
|
||||
)
|
||||
|
||||
def test_every_gated_wall_gizmo_hides_when_any_selection_is_array_child(self):
|
||||
"""Mocks the central ``any_selected_is_array_child`` predicate to True
|
||||
and asserts every gizmo whose poll routes through
|
||||
``_wall_gizmo_poll_gate`` returns False. The point is the BEHAVIOUR:
|
||||
a child wall in the selection must never surface a topology gizmo,
|
||||
regardless of which gate function the poll calls internally."""
|
||||
groups = _wall_gizmo_groups_using_gate()
|
||||
offenders = []
|
||||
with patch("bonsai.tool.Blender.are_viewport_gizmos_enabled", return_value=True):
|
||||
with patch("bonsai.bim.module.model.preview_base.any_preview_active", return_value=False):
|
||||
with patch(
|
||||
"bonsai.tool.Blender.Modifier.any_selected_is_array_child",
|
||||
return_value=True,
|
||||
):
|
||||
for name, cls in groups:
|
||||
try:
|
||||
result = cls.poll(bpy.context)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
offenders.append((name, f"poll raised: {type(exc).__name__}: {exc}"))
|
||||
continue
|
||||
if result:
|
||||
offenders.append((name, "poll returned True with array child selected"))
|
||||
|
||||
assert not offenders, (
|
||||
"Wall gizmo polls that surface on array-child selections: "
|
||||
+ ", ".join(f"{n} — {why}" for n, why in offenders)
|
||||
+ ". Route the poll through _wall_topology_gizmo_poll_gate so the "
|
||||
"central any_selected_is_array_child filter applies."
|
||||
)
|
||||
|
||||
|
||||
class TestWallOperatorsRejectArrayChildSelection:
|
||||
def test_discovery_finds_wall_topology_operators(self):
|
||||
ops = _wall_operators_with_array_child_guard()
|
||||
assert ops, (
|
||||
"Expected at least one wall Operator whose poll rejects array-child "
|
||||
"selections (via any_selected_is_array_child or _poll_reject_array_children) "
|
||||
"— discovery walk drifted out of sync?"
|
||||
)
|
||||
|
||||
def test_every_guarded_wall_operator_polls_false_on_array_child_selection(self):
|
||||
"""Operators reachable from keymaps / F3 must reject array-child
|
||||
invocation independently of the gizmo gating, because not every
|
||||
invocation path goes through a gizmo. The shared predicate makes
|
||||
this a one-line guard per operator; this test pins it for every
|
||||
operator that opted in."""
|
||||
ops = _wall_operators_with_array_child_guard()
|
||||
offenders = []
|
||||
with patch(
|
||||
"bonsai.tool.Blender.Modifier.any_selected_is_array_child",
|
||||
return_value=True,
|
||||
):
|
||||
with patch("bonsai.tool.Model.has_selected_ifc_objects", return_value=True):
|
||||
with patch("bonsai.tool.Model.get_selected_ifc_objects", return_value=[]):
|
||||
for name, cls in ops:
|
||||
try:
|
||||
result = cls.poll(bpy.context)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
offenders.append((name, f"poll raised: {type(exc).__name__}: {exc}"))
|
||||
continue
|
||||
if result:
|
||||
offenders.append((name, "poll returned True with array child selected"))
|
||||
|
||||
assert not offenders, (
|
||||
"Wall topology operators that accept array-child selections: "
|
||||
+ ", ".join(f"{n} — {why}" for n, why in offenders)
|
||||
+ ". Route the poll through `_poll_reject_array_children(cls)` (the "
|
||||
"shared helper that sets the standard poll message and reuses the "
|
||||
"central `any_selected_is_array_child` predicate)."
|
||||
)
|
||||
|
||||
|
||||
class TestAnySelectedIsArrayChildHelper:
|
||||
"""Smoke checks on the central predicate. Returns ``False`` when nothing
|
||||
is selected; returns ``True`` when at least one selected element passes
|
||||
``is_array_child``."""
|
||||
|
||||
def test_returns_false_with_empty_selection(self):
|
||||
from bonsai import tool
|
||||
|
||||
with patch.object(tool.Blender, "get_selected_objects", return_value=[]):
|
||||
assert tool.Blender.Modifier.any_selected_is_array_child() is False
|
||||
|
||||
def test_returns_true_when_any_selected_passes_predicate(self):
|
||||
from bonsai import tool
|
||||
|
||||
child_obj, child_element = SimpleNamespace(name="child"), object()
|
||||
parent_obj, parent_element = SimpleNamespace(name="parent"), object()
|
||||
|
||||
def get_entity(obj):
|
||||
return {id(child_obj): child_element, id(parent_obj): parent_element}.get(id(obj))
|
||||
|
||||
def is_array_child(element):
|
||||
return element is child_element
|
||||
|
||||
with patch.object(tool.Blender, "get_selected_objects", return_value=[parent_obj, child_obj]):
|
||||
with patch.object(tool.Ifc, "get_entity", side_effect=get_entity):
|
||||
with patch.object(tool.Blender.Modifier, "is_array_child", side_effect=is_array_child):
|
||||
assert tool.Blender.Modifier.any_selected_is_array_child() is True
|
||||
|
||||
def test_returns_false_when_no_selected_passes_predicate(self):
|
||||
from bonsai import tool
|
||||
|
||||
parent_obj, parent_element = SimpleNamespace(name="parent"), object()
|
||||
with patch.object(tool.Blender, "get_selected_objects", return_value=[parent_obj]):
|
||||
with patch.object(tool.Ifc, "get_entity", return_value=parent_element):
|
||||
with patch.object(tool.Blender.Modifier, "is_array_child", return_value=False):
|
||||
assert tool.Blender.Modifier.any_selected_is_array_child() is False
|
||||
|
||||
|
||||
class TestHostOpeningGizmoStaysAvailableOnArrayChildren:
|
||||
"""Openings on array children are array-safe: ``regenerate_array``
|
||||
applies opening cuts after replicating child geometry, so an opening
|
||||
authored on a child survives regen and tracks with the replicated
|
||||
instance. The host-opening gizmos therefore route through the loose
|
||||
base wall gate, not the tighter topology gate that excludes
|
||||
children."""
|
||||
|
||||
def test_host_opening_module_does_not_apply_topology_gate(self):
|
||||
import inspect
|
||||
|
||||
from bonsai.bim.module.model import host_add_opening_gizmo
|
||||
|
||||
src = inspect.getsource(host_add_opening_gizmo)
|
||||
assert "_wall_topology_gizmo_poll_gate" not in src, (
|
||||
"host-opening gizmo module references the topology gate; that "
|
||||
"would suppress add-opening on array-child hosts. Openings "
|
||||
"track with the regenerated child via the array regen pipeline."
|
||||
)
|
||||
assert "any_selected_is_array_child" not in src, (
|
||||
"host-opening gizmo module references any_selected_is_array_child; "
|
||||
"openings are array-safe, drop the filter."
|
||||
)
|
||||
@@ -39,12 +39,6 @@ import pytest
|
||||
pytestmark = pytest.mark.wall
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _require_real_bpy():
|
||||
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
|
||||
pytest.skip("requires real Blender (bpy is mocked or absent)")
|
||||
|
||||
|
||||
def test_refresh_post_commit_bumps_generation_for_every_operator():
|
||||
"""The generation counter advances on every commit, regardless of
|
||||
operator class — it's the cache-invalidation signal for any code
|
||||
|
||||
@@ -37,12 +37,6 @@ from mathutils import Vector
|
||||
pytestmark = pytest.mark.wall
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _require_real_bpy():
|
||||
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
|
||||
pytest.skip("requires real Blender (bpy is mocked or absent)")
|
||||
|
||||
|
||||
def test_regenerate_wall_mesh_from_props_outward_normals():
|
||||
"""Every face of the preview box must have its normal pointing away
|
||||
from the box centroid — the contract every other preview-mesh builder
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
# 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.
|
||||
|
||||
"""Cache-invalidation tests for the wall-topology gizmo helpers.
|
||||
|
||||
``GizmoWallUnjoinSingle`` and ``GizmoWallJoinIntersection`` re-run
|
||||
``_iter_path_connections``, ``_are_walls_joined``, ``_are_walls_collinear``,
|
||||
and ``core.project_axis_intersection`` every viewport redraw without the
|
||||
cache helpers wrapping them. These tests pin that:
|
||||
|
||||
- Repeat calls within one IFC generation reuse the cached result.
|
||||
- An IFC-generation bump invalidates the cache.
|
||||
- ``refresh()`` (the Blender state-change hook on the mixin) drops the cache."""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
def test_get_wall_connections_cached_returns_cached_within_generation():
|
||||
from bonsai.bim.module.model import wall
|
||||
|
||||
group = Mock(spec=[])
|
||||
elem = Mock()
|
||||
elem.GlobalId = "0AAAAAAAAAAAAAAAAAAAAA"
|
||||
expected = [(Mock(), "ATEND", "ATSTART")]
|
||||
|
||||
call_count = {"n": 0}
|
||||
|
||||
def counting_iter(e):
|
||||
call_count["n"] += 1
|
||||
return expected
|
||||
|
||||
with patch.object(wall, "_iter_path_connections", side_effect=counting_iter), patch(
|
||||
"bonsai.bim.module.model.wall.tool.Parametric.get_geom_generation", return_value=7
|
||||
):
|
||||
first = wall._get_wall_connections_cached(group, elem)
|
||||
second = wall._get_wall_connections_cached(group, elem)
|
||||
|
||||
assert first is second
|
||||
assert call_count["n"] == 1
|
||||
|
||||
|
||||
def test_get_wall_connections_cached_invalidates_on_generation_bump():
|
||||
from bonsai.bim.module.model import wall
|
||||
|
||||
group = Mock(spec=[])
|
||||
elem = Mock()
|
||||
elem.GlobalId = "0AAAAAAAAAAAAAAAAAAAAA"
|
||||
|
||||
call_count = {"n": 0}
|
||||
|
||||
def counting_iter(e):
|
||||
call_count["n"] += 1
|
||||
return []
|
||||
|
||||
gen_state = {"gen": 1}
|
||||
with patch.object(wall, "_iter_path_connections", side_effect=counting_iter), patch(
|
||||
"bonsai.bim.module.model.wall.tool.Parametric.get_geom_generation", side_effect=lambda: gen_state["gen"]
|
||||
):
|
||||
wall._get_wall_connections_cached(group, elem)
|
||||
gen_state["gen"] = 2
|
||||
wall._get_wall_connections_cached(group, elem)
|
||||
|
||||
assert call_count["n"] == 2
|
||||
|
||||
|
||||
def test_get_wall_pair_predicate_cached_reuses_value_within_generation():
|
||||
from bonsai.bim.module.model import wall
|
||||
|
||||
group = Mock(spec=[])
|
||||
call_count = {"n": 0}
|
||||
|
||||
def compute():
|
||||
call_count["n"] += 1
|
||||
return "result"
|
||||
|
||||
with patch("bonsai.bim.module.model.wall.tool.Parametric.get_geom_generation", return_value=3):
|
||||
first = wall._get_wall_pair_predicate_cached(group, ("joined", ("guid_a", "guid_b")), compute)
|
||||
second = wall._get_wall_pair_predicate_cached(group, ("joined", ("guid_a", "guid_b")), compute)
|
||||
|
||||
assert first == second == "result"
|
||||
assert call_count["n"] == 1
|
||||
|
||||
|
||||
def test_get_wall_pair_predicate_cached_distinguishes_predicate_kind():
|
||||
"""The cache key includes a tag string ("joined" vs "collinear" vs
|
||||
"intersection") so adding a second predicate for the same pair doesn't
|
||||
return the first predicate's value."""
|
||||
from bonsai.bim.module.model import wall
|
||||
|
||||
group = Mock(spec=[])
|
||||
pair = ("guid_a", "guid_b")
|
||||
with patch("bonsai.bim.module.model.wall.tool.Parametric.get_geom_generation", return_value=3):
|
||||
a = wall._get_wall_pair_predicate_cached(group, ("joined", pair), lambda: "JOINED")
|
||||
b = wall._get_wall_pair_predicate_cached(group, ("collinear", pair), lambda: "COLLINEAR")
|
||||
|
||||
assert a == "JOINED"
|
||||
assert b == "COLLINEAR"
|
||||
|
||||
|
||||
def test_get_wall_pair_predicate_cached_invalidates_on_generation_bump():
|
||||
from bonsai.bim.module.model import wall
|
||||
|
||||
group = Mock(spec=[])
|
||||
call_count = {"n": 0}
|
||||
|
||||
def compute():
|
||||
call_count["n"] += 1
|
||||
return call_count["n"]
|
||||
|
||||
gen_state = {"gen": 1}
|
||||
with patch(
|
||||
"bonsai.bim.module.model.wall.tool.Parametric.get_geom_generation", side_effect=lambda: gen_state["gen"]
|
||||
):
|
||||
first = wall._get_wall_pair_predicate_cached(group, ("joined", ("a", "b")), compute)
|
||||
gen_state["gen"] = 2
|
||||
second = wall._get_wall_pair_predicate_cached(group, ("joined", ("a", "b")), compute)
|
||||
|
||||
assert first == 1
|
||||
assert second == 2
|
||||
assert call_count["n"] == 2
|
||||
|
||||
|
||||
def test_mixin_refresh_clears_pair_and_connection_caches():
|
||||
"""``refresh()`` is Blender's "state changed" signal — typically a
|
||||
selection change. Both the connection list and pair predicate caches
|
||||
must drop alongside the geometry cache, otherwise the next frame would
|
||||
read predicates that targeted the previously-selected pair."""
|
||||
from bonsai.bim.module.model import wall
|
||||
|
||||
class _Group(wall._WallGeomCachedBillboardingMixin):
|
||||
def position_gizmos(self, context):
|
||||
pass
|
||||
|
||||
group = _Group()
|
||||
group._wall_geom_cache = {"x": "geom"}
|
||||
group._wall_connections_cache = {"guid": []}
|
||||
group._wall_pair_predicate_cache = {"key": "value"}
|
||||
|
||||
group.refresh(context=Mock())
|
||||
|
||||
assert group._wall_geom_cache is None
|
||||
assert group._wall_connections_cache is None
|
||||
assert group._wall_pair_predicate_cache is None
|
||||
@@ -0,0 +1,19 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,108 @@
|
||||
# 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 unittest.mock import patch
|
||||
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import pytest
|
||||
|
||||
import bonsai.tool as tool
|
||||
from test.bim.bootstrap import NewIfc
|
||||
|
||||
pytestmark = pytest.mark.project
|
||||
|
||||
|
||||
def _populate_pending(*element_ids: int) -> None:
|
||||
pending = tool.Project.get_project_props().pending_opening_recut
|
||||
pending.clear()
|
||||
for eid in element_ids:
|
||||
pending.add().ifc_definition_id = eid
|
||||
|
||||
|
||||
def _make_linked_wall(name: str = "Wall") -> tuple[ifcopenshell.entity_instance, bpy.types.Object]:
|
||||
ifc_file = tool.Ifc.get()
|
||||
element = ifc_file.create_entity("IfcWall", GlobalId=ifcopenshell.guid.new(), Name=name)
|
||||
obj = bpy.data.objects.new(name, bpy.data.meshes.new(name))
|
||||
bpy.context.scene.collection.objects.link(obj)
|
||||
tool.Ifc.link(element, obj)
|
||||
return element, obj
|
||||
|
||||
|
||||
class TestApplyPendingOpeningCuts(NewIfc):
|
||||
def test_clears_pending_and_calls_reimport_with_apply_openings(self):
|
||||
element, obj = _make_linked_wall()
|
||||
_populate_pending(element.id())
|
||||
|
||||
with patch.object(tool.Geometry, "reimport_element_representations") as mock_reimport, patch(
|
||||
"ifcopenshell.util.representation.get_representation",
|
||||
return_value=object(),
|
||||
):
|
||||
result = bpy.ops.bim.apply_pending_opening_cuts()
|
||||
|
||||
assert result == {"FINISHED"}
|
||||
assert len(tool.Project.get_project_props().pending_opening_recut) == 0
|
||||
mock_reimport.assert_called_once()
|
||||
_, kwargs = mock_reimport.call_args
|
||||
assert kwargs.get("apply_openings") is True
|
||||
|
||||
def test_skips_entries_whose_entity_is_gone(self):
|
||||
_populate_pending(99999) # ID guaranteed not present
|
||||
|
||||
with patch.object(tool.Geometry, "reimport_element_representations") as mock_reimport:
|
||||
result = bpy.ops.bim.apply_pending_opening_cuts()
|
||||
|
||||
assert result == {"FINISHED"}
|
||||
assert len(tool.Project.get_project_props().pending_opening_recut) == 0
|
||||
mock_reimport.assert_not_called()
|
||||
|
||||
|
||||
class TestDismissPendingOpeningCuts(NewIfc):
|
||||
def test_clears_collection_without_calling_reimport(self):
|
||||
element, _obj = _make_linked_wall()
|
||||
_populate_pending(element.id())
|
||||
|
||||
with patch.object(tool.Geometry, "reimport_element_representations") as mock_reimport:
|
||||
result = bpy.ops.bim.dismiss_pending_opening_cuts()
|
||||
|
||||
assert result == {"FINISHED"}
|
||||
assert len(tool.Project.get_project_props().pending_opening_recut) == 0
|
||||
mock_reimport.assert_not_called()
|
||||
|
||||
|
||||
class TestSelectPendingOpeningCuts(NewIfc):
|
||||
def test_selects_objects_for_each_pending_entry(self):
|
||||
e1, o1 = _make_linked_wall("WallA")
|
||||
e2, o2 = _make_linked_wall("WallB")
|
||||
_populate_pending(e1.id(), e2.id())
|
||||
|
||||
for obj in bpy.context.view_layer.objects:
|
||||
obj.select_set(False)
|
||||
|
||||
result = bpy.ops.bim.select_pending_opening_cuts()
|
||||
|
||||
assert result == {"FINISHED"}
|
||||
assert o1.select_get() and o2.select_get()
|
||||
assert bpy.context.view_layer.objects.active in (o1, o2)
|
||||
|
||||
def test_cancels_when_no_objects_match(self):
|
||||
_populate_pending(99999)
|
||||
result = bpy.ops.bim.select_pending_opening_cuts()
|
||||
assert result == {"CANCELLED"}
|
||||
@@ -0,0 +1,232 @@
|
||||
# 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.
|
||||
|
||||
"""Framework contract test for the partial-state recovery hint in
|
||||
``IfcStore.execute_ifc_operator``.
|
||||
|
||||
The framework wraps every ``tool.Ifc.Operator._execute`` call between
|
||||
``ifc_file.begin_transaction()`` and ``ifc_file.end_transaction()``. When
|
||||
``_execute`` raises after at least one ``ifcopenshell.api.*`` mutation
|
||||
has been captured, the user is in a partial state (IFC mutated, Blender
|
||||
side stale) and the framework surfaces a WARNING naming Ctrl+Z so the
|
||||
recovery path is discoverable instead of buried behind a raw traceback.
|
||||
|
||||
The contract has three parts pinned here:
|
||||
|
||||
1. ``ifcopenshell.file.Transaction.operations`` is a public list and is
|
||||
the introspection idiom the framework relies on.
|
||||
2. The WARNING fires only when ``_execute`` raised AND the transaction
|
||||
captured at least one operation.
|
||||
3. A successful ``_execute`` never emits the WARNING regardless of
|
||||
whether IFC was mutated."""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.misc
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _require_real_bpy():
|
||||
import types as _types
|
||||
|
||||
import bpy
|
||||
|
||||
if not isinstance(bpy, _types.ModuleType) or hasattr(bpy, "_mock_name"):
|
||||
pytest.skip("requires real Blender (bpy is mocked or absent)")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fresh_ifc():
|
||||
"""Set up a fresh ``ifcopenshell.file`` as ``IfcStore.file`` and tear
|
||||
it down afterwards. Each test gets a virgin transaction state."""
|
||||
import ifcopenshell
|
||||
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
|
||||
previous = IfcStore.file
|
||||
previous_transaction = IfcStore.current_transaction
|
||||
IfcStore.file = ifcopenshell.file(schema="IFC4")
|
||||
IfcStore.current_transaction = ""
|
||||
try:
|
||||
yield IfcStore.file
|
||||
finally:
|
||||
IfcStore.file = previous
|
||||
IfcStore.current_transaction = previous_transaction
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def neutralised_framework():
|
||||
"""Patch the side-effect-heavy helpers in ``IfcStore.execute_ifc_operator``
|
||||
so a bare unit test can drive it without a populated Scene / props /
|
||||
decorator handlers."""
|
||||
with mock.patch("bonsai.bim.ifc.tool.Blender.get_bim_props") as get_props, mock.patch(
|
||||
"bonsai.bim.handler.refresh_ui_data"
|
||||
), mock.patch("bonsai.bim.ifc.tool.Parametric.refresh_post_commit"), mock.patch(
|
||||
"bonsai.bim.ifc.IfcStore.add_transaction_operation"
|
||||
), mock.patch(
|
||||
"bonsai.bim.ifc.IfcStore.begin_transaction"
|
||||
), mock.patch(
|
||||
"bonsai.bim.ifc.IfcStore.end_transaction"
|
||||
), mock.patch(
|
||||
"bonsai.bim.ifc.IfcStore.get_ifc_file_undo_callback", return_value=lambda data: True
|
||||
):
|
||||
get_props.return_value = mock.Mock(is_dirty=False)
|
||||
yield
|
||||
|
||||
|
||||
def _make_operator(execute_callback):
|
||||
"""Build a ``Mock`` operator that satisfies the attribute reads the
|
||||
framework performs (``bl_idname``, ``_execute``, ``report``, etc.)."""
|
||||
op = mock.Mock(spec=["bl_idname", "_execute", "_invoke", "_modal", "report", "transaction_key"])
|
||||
op.bl_idname = "bim.test_partial_state"
|
||||
op._execute = execute_callback
|
||||
return op
|
||||
|
||||
|
||||
def _mutate_ifc():
|
||||
"""Single ``ifcopenshell.api.*`` call so the transaction captures at
|
||||
least one operation. ``project.create_file`` would not work here since
|
||||
it replaces the file; pick a small entity mutation that always lands."""
|
||||
import ifcopenshell.api.owner
|
||||
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
|
||||
ifcopenshell.api.owner.add_person(IfcStore.get_file())
|
||||
|
||||
|
||||
def test_transaction_operations_is_empty_until_first_api_call(fresh_ifc):
|
||||
"""Pin the introspection contract the framework relies on:
|
||||
``Transaction.operations`` is empty after ``begin_transaction()`` and
|
||||
populated by any ``ifcopenshell.api.*`` call."""
|
||||
fresh_ifc.begin_transaction()
|
||||
assert fresh_ifc.transaction is not None
|
||||
assert fresh_ifc.transaction.operations == []
|
||||
|
||||
_mutate_ifc()
|
||||
|
||||
assert len(fresh_ifc.transaction.operations) > 0
|
||||
|
||||
|
||||
def test_no_mutation_no_raise_no_warning(fresh_ifc, neutralised_framework):
|
||||
"""Happy path: ``_execute`` does nothing, returns FINISHED.
|
||||
Framework MUST NOT emit the partial-state WARNING."""
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
|
||||
op = _make_operator(execute_callback=lambda context: {"FINISHED"})
|
||||
IfcStore.execute_ifc_operator(op, context=mock.Mock())
|
||||
|
||||
for call in op.report.call_args_list:
|
||||
assert "Ctrl+Z" not in call.args[1], "partial-state WARNING fired on a clean success path"
|
||||
|
||||
|
||||
def test_raise_before_mutation_no_warning(fresh_ifc, neutralised_framework):
|
||||
"""``_execute`` raises before any IFC mutation. The transaction has no
|
||||
operations → no partial state → no WARNING."""
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
|
||||
def _raise_immediately(context):
|
||||
raise RuntimeError("kaboom")
|
||||
|
||||
op = _make_operator(execute_callback=_raise_immediately)
|
||||
with pytest.raises(RuntimeError, match="kaboom"):
|
||||
IfcStore.execute_ifc_operator(op, context=mock.Mock())
|
||||
|
||||
for call in op.report.call_args_list:
|
||||
assert "Ctrl+Z" not in call.args[1], "partial-state WARNING fired without any mutation"
|
||||
|
||||
|
||||
def test_mutation_then_success_no_warning(fresh_ifc, neutralised_framework):
|
||||
"""Real mutation, normal FINISHED return. WARNING is exception-path
|
||||
only and MUST NOT fire on a clean success."""
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
|
||||
def _mutate_and_finish(context):
|
||||
_mutate_ifc()
|
||||
return {"FINISHED"}
|
||||
|
||||
op = _make_operator(execute_callback=_mutate_and_finish)
|
||||
IfcStore.execute_ifc_operator(op, context=mock.Mock())
|
||||
|
||||
for call in op.report.call_args_list:
|
||||
assert "Ctrl+Z" not in call.args[1], "partial-state WARNING fired on a successful mutation"
|
||||
|
||||
|
||||
def test_mutation_then_raise_emits_warning(fresh_ifc, neutralised_framework):
|
||||
"""The contract this whole change exists for: mutate, then raise.
|
||||
Framework MUST emit a WARNING naming Ctrl+Z before the exception
|
||||
re-raises into Blender's normal operator error flow."""
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
|
||||
def _mutate_then_raise(context):
|
||||
_mutate_ifc()
|
||||
raise RuntimeError("rebuild failed after IFC mutation")
|
||||
|
||||
op = _make_operator(execute_callback=_mutate_then_raise)
|
||||
with pytest.raises(RuntimeError, match="rebuild failed"):
|
||||
IfcStore.execute_ifc_operator(op, context=mock.Mock())
|
||||
|
||||
warning_calls = [
|
||||
call
|
||||
for call in op.report.call_args_list
|
||||
if call.args and call.args[0] == {"WARNING"} and "Ctrl+Z" in call.args[1]
|
||||
]
|
||||
assert (
|
||||
len(warning_calls) == 1
|
||||
), f"expected exactly one partial-state WARNING with Ctrl+Z guidance, got: {op.report.call_args_list}"
|
||||
|
||||
|
||||
def test_mutation_then_raise_pushes_blender_undo_step(fresh_ifc, neutralised_framework):
|
||||
"""A raised operator does not get an automatic Blender undo step (same gap
|
||||
as the CANCELLED-modal path). The framework pushes one explicitly so the
|
||||
Ctrl+Z the WARNING advertises actually rewinds the partial mutation."""
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
|
||||
def _mutate_then_raise(context):
|
||||
_mutate_ifc()
|
||||
raise RuntimeError("rebuild failed after IFC mutation")
|
||||
|
||||
op = _make_operator(execute_callback=_mutate_then_raise)
|
||||
with mock.patch("bonsai.bim.ifc.bpy.ops", new=mock.Mock()) as bpy_ops:
|
||||
undo_push = bpy_ops.ed.undo_push
|
||||
with pytest.raises(RuntimeError, match="rebuild failed"):
|
||||
IfcStore.execute_ifc_operator(op, context=mock.Mock())
|
||||
|
||||
assert undo_push.call_count == 1, f"expected exactly one undo_push, got {undo_push.call_count}"
|
||||
pushed_message = undo_push.call_args.kwargs.get("message", "")
|
||||
assert op.bl_idname in pushed_message, f"undo step message should name the operator, got: {pushed_message!r}"
|
||||
|
||||
|
||||
def test_raise_before_mutation_does_not_push_undo_step(fresh_ifc, neutralised_framework):
|
||||
"""No mutation captured → nothing to recover → no recovery undo step.
|
||||
Avoids polluting the undo history with no-op recovery snapshots."""
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
|
||||
def _raise_immediately(context):
|
||||
raise RuntimeError("kaboom")
|
||||
|
||||
op = _make_operator(execute_callback=_raise_immediately)
|
||||
with mock.patch("bonsai.bim.ifc.bpy.ops", new=mock.Mock()) as bpy_ops:
|
||||
undo_push = bpy_ops.ed.undo_push
|
||||
with pytest.raises(RuntimeError, match="kaboom"):
|
||||
IfcStore.execute_ifc_operator(op, context=mock.Mock())
|
||||
|
||||
assert undo_push.call_count == 0, "undo_push fired on a non-partial-state raise"
|
||||
@@ -0,0 +1,91 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Forward-compat AST contract for the pen-icon dispatcher monopoly.
|
||||
|
||||
Every parametric gizmo group's pen icon must bind to the universal
|
||||
``bim.enable_editing_parametric`` dispatcher rather than the feature's own
|
||||
enable operator. The dispatcher is the single chokepoint where pre-edit
|
||||
checks (shared-representation warning, future safety gates) run; a feature
|
||||
that binds directly bypasses every such check silently."""
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.drawing
|
||||
|
||||
|
||||
BONSAI_ROOT = Path(__file__).parent.parent.parent / "bonsai"
|
||||
BIM_DIR = BONSAI_ROOT / "bim"
|
||||
DISPATCHER_IDNAME = "bim.enable_editing_parametric"
|
||||
|
||||
|
||||
def _iter_pen_gizmo_target_set_operator_calls(tree: ast.Module):
|
||||
"""Yield each ``ast.Call`` matching ``<receiver>.pen_gizmo.target_set_operator(...)``.
|
||||
Receiver is any attribute access (``self.pen_gizmo``, ``group.pen_gizmo``, etc.)."""
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
func = node.func
|
||||
if not isinstance(func, ast.Attribute) or func.attr != "target_set_operator":
|
||||
continue
|
||||
receiver = func.value
|
||||
if not isinstance(receiver, ast.Attribute) or receiver.attr != "pen_gizmo":
|
||||
continue
|
||||
yield node
|
||||
|
||||
|
||||
def test_every_pen_gizmo_binding_routes_through_the_universal_dispatcher() -> None:
|
||||
violations: list[str] = []
|
||||
found_any = False
|
||||
for path in BIM_DIR.rglob("*.py"):
|
||||
try:
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
except SyntaxError:
|
||||
continue
|
||||
for call in _iter_pen_gizmo_target_set_operator_calls(tree):
|
||||
found_any = True
|
||||
if not call.args:
|
||||
violations.append(f"{path}:{call.lineno} pen_gizmo.target_set_operator() called with no args")
|
||||
continue
|
||||
first_arg = call.args[0]
|
||||
if not isinstance(first_arg, ast.Constant) or not isinstance(first_arg.value, str):
|
||||
violations.append(
|
||||
f"{path}:{call.lineno} pen_gizmo.target_set_operator() first arg is not a string literal"
|
||||
)
|
||||
continue
|
||||
if first_arg.value != DISPATCHER_IDNAME:
|
||||
violations.append(
|
||||
f"{path}:{call.lineno} pen_gizmo.target_set_operator({first_arg.value!r}) "
|
||||
f"bypasses the universal dispatcher"
|
||||
)
|
||||
|
||||
assert found_any, (
|
||||
"No pen_gizmo.target_set_operator(...) calls found anywhere under bim/. "
|
||||
"Either the gizmo-binding pattern has been refactored away (this test "
|
||||
"needs updating) or the search root is wrong."
|
||||
)
|
||||
assert not violations, (
|
||||
"Pen-icon bindings must route through the universal dispatcher "
|
||||
f"({DISPATCHER_IDNAME!r}) so the shared-representation warning and any "
|
||||
"future pre-edit checks apply to every feature. Violations:\n " + "\n ".join(violations)
|
||||
)
|
||||
@@ -0,0 +1,144 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Forward-compat AST contract for the preview cancellation registry.
|
||||
|
||||
Every ``PointerProperty`` child of ``BIMPreviewProperties`` whose target
|
||||
PropertyGroup declares an ``is_active`` BoolProperty is a Scene-level
|
||||
preview. Each must have a matching ``(child_attr, cancel_op_name)`` entry
|
||||
in ``preview_base.PREVIEW_CANCEL_OPS`` so the Esc dispatcher and the
|
||||
``load_post`` stale-flag discard both cover it.
|
||||
|
||||
A new preview type that defines its own Enable / Decorator without
|
||||
registering the cancel pair will silently ignore Esc and leave a stuck
|
||||
``is_active`` flag across file reloads — exactly the failure mode the
|
||||
sibling forward-compat guards exist to prevent."""
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
BONSAI_ROOT = Path(__file__).parent.parent.parent / "bonsai"
|
||||
PROP_FILE = BONSAI_ROOT / "bim" / "module" / "model" / "prop.py"
|
||||
UMBRELLA_CLASS = "BIMPreviewProperties"
|
||||
|
||||
|
||||
def _find_class(tree: ast.Module, name: str) -> ast.ClassDef | None:
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ClassDef) and node.name == name:
|
||||
return node
|
||||
return None
|
||||
|
||||
|
||||
def _iter_pointer_property_children(class_node: ast.ClassDef):
|
||||
"""Yield ``(attr_name, target_class_name)`` for each
|
||||
``<attr>: bpy.props.PointerProperty(type=<TargetClass>)`` annotated
|
||||
assignment in the umbrella class body.
|
||||
|
||||
Bonsai follows the Blender convention where the property call lives in
|
||||
the *annotation* (PEP 526 syntax) rather than the value — Blender's
|
||||
PropertyGroup metaclass picks it up at class creation time."""
|
||||
for node in class_node.body:
|
||||
if not isinstance(node, ast.AnnAssign) or not isinstance(node.target, ast.Name):
|
||||
continue
|
||||
if not isinstance(node.annotation, ast.Call):
|
||||
continue
|
||||
func = node.annotation.func
|
||||
if not isinstance(func, ast.Attribute) or func.attr != "PointerProperty":
|
||||
continue
|
||||
for kw in node.annotation.keywords:
|
||||
if kw.arg == "type" and isinstance(kw.value, ast.Name):
|
||||
yield node.target.id, kw.value.id
|
||||
break
|
||||
|
||||
|
||||
def _class_has_is_active_bool(class_node: ast.ClassDef) -> bool:
|
||||
"""Return True if ``class_node`` declares ``is_active: bpy.props.BoolProperty(...)``."""
|
||||
for node in class_node.body:
|
||||
if not isinstance(node, ast.AnnAssign) or not isinstance(node.target, ast.Name):
|
||||
continue
|
||||
if node.target.id != "is_active":
|
||||
continue
|
||||
if not isinstance(node.annotation, ast.Call):
|
||||
continue
|
||||
func = node.annotation.func
|
||||
if isinstance(func, ast.Attribute) and func.attr == "BoolProperty":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def test_every_preview_propertygroup_is_registered_in_cancel_ops() -> None:
|
||||
from bonsai.bim.module.model import preview_base
|
||||
|
||||
registered_attrs = {attr for attr, _op in preview_base.PREVIEW_CANCEL_OPS}
|
||||
|
||||
tree = ast.parse(PROP_FILE.read_text(encoding="utf-8"))
|
||||
umbrella = _find_class(tree, UMBRELLA_CLASS)
|
||||
assert umbrella is not None, (
|
||||
f"Could not find {UMBRELLA_CLASS!r} in {PROP_FILE}. Either the umbrella class "
|
||||
"was renamed (this test needs updating) or prop.py was restructured."
|
||||
)
|
||||
|
||||
preview_children: list[tuple[str, str]] = []
|
||||
for attr, target_class_name in _iter_pointer_property_children(umbrella):
|
||||
target = _find_class(tree, target_class_name)
|
||||
if target is None:
|
||||
continue
|
||||
if _class_has_is_active_bool(target):
|
||||
preview_children.append((attr, target_class_name))
|
||||
|
||||
assert preview_children, (
|
||||
"No PointerProperty children with ``is_active`` BoolProperty found under "
|
||||
f"{UMBRELLA_CLASS}. Either the preview convention has been refactored away "
|
||||
"(this test needs updating) or prop.py was restructured."
|
||||
)
|
||||
|
||||
missing = [(attr, cls) for attr, cls in preview_children if attr not in registered_attrs]
|
||||
assert not missing, (
|
||||
"Every Scene-level preview PropertyGroup must have a matching "
|
||||
"(child_attr, cancel_op_name) tuple in preview_base.PREVIEW_CANCEL_OPS so "
|
||||
"Esc dispatch and load_post stale-flag discard cover it. Missing entries:\n "
|
||||
+ "\n ".join(f"BIMPreviewProperties.{attr} (target={cls!r})" for attr, cls in missing)
|
||||
)
|
||||
|
||||
|
||||
def test_every_cancel_ops_entry_has_a_real_preview_propertygroup() -> None:
|
||||
"""The reverse contract: a stale entry in ``PREVIEW_CANCEL_OPS`` whose
|
||||
PropertyGroup has been deleted would silently leak to every Esc press
|
||||
(dispatching to a missing operator raises ``AttributeError`` inside
|
||||
``try_cancel_active_preview``). Pin that the registry never goes
|
||||
stale relative to ``BIMPreviewProperties``."""
|
||||
from bonsai.bim.module.model import preview_base
|
||||
|
||||
tree = ast.parse(PROP_FILE.read_text(encoding="utf-8"))
|
||||
umbrella = _find_class(tree, UMBRELLA_CLASS)
|
||||
assert umbrella is not None
|
||||
|
||||
declared_attrs = {attr for attr, _target in _iter_pointer_property_children(umbrella)}
|
||||
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)
|
||||
)
|
||||
@@ -52,6 +52,35 @@ class TestAssignContainer:
|
||||
collector.assign("obj2").should_be_called()
|
||||
subject.assign_container(ifc, collector, spatial, container="container", objs=["obj"])
|
||||
|
||||
def test_root_resolves_to_self_for_a_filling(self, ifc, collector, spatial):
|
||||
ifc.get_entity("door_obj").should_be_called().will_return("door")
|
||||
spatial.get_root_element("door").should_be_called().will_return("door")
|
||||
spatial.disable_editing("door_obj").should_be_called()
|
||||
spatial.get_decomposition("door").should_be_called().will_return(["door"])
|
||||
spatial.can_contain("container", "door").should_be_called().will_return(True)
|
||||
ifc.run("spatial.assign_container", products=["door"], relating_structure="container").should_be_called()
|
||||
ifc.get_object("door").should_be_called().will_return("door_obj")
|
||||
collector.assign("door_obj").should_be_called()
|
||||
subject.assign_container(ifc, collector, spatial, container="container", objs=["door_obj"])
|
||||
|
||||
def test_can_contain_is_evaluated_per_root_element(self, ifc, collector, spatial):
|
||||
ifc.get_entity("door_obj").should_be_called().will_return("door")
|
||||
spatial.get_root_element("door").should_be_called().will_return("door")
|
||||
spatial.disable_editing("door_obj").should_be_called()
|
||||
spatial.get_decomposition("door").should_be_called().will_return(["door"])
|
||||
ifc.get_entity("opening_obj").should_be_called().will_return("opening")
|
||||
spatial.get_root_element("opening").should_be_called().will_return("opening")
|
||||
spatial.disable_editing("opening_obj").should_be_called()
|
||||
spatial.get_decomposition("opening").should_be_called().will_return(["opening"])
|
||||
spatial.can_contain("container", "door").should_be_called().will_return(True)
|
||||
spatial.can_contain("container", "opening").should_be_called().will_return(False)
|
||||
ifc.run("spatial.assign_container", products=["door"], relating_structure="container").should_be_called()
|
||||
ifc.get_object("door").should_be_called().will_return("door_obj")
|
||||
ifc.get_object("opening").should_be_called().will_return("opening_obj")
|
||||
collector.assign("door_obj").should_be_called()
|
||||
collector.assign("opening_obj").should_be_called()
|
||||
subject.assign_container(ifc, collector, spatial, container="container", objs=["door_obj", "opening_obj"])
|
||||
|
||||
|
||||
class TestEnableEditingContainer:
|
||||
def test_run(self, spatial):
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
#
|
||||
# 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 modified with the assistance of an AI coding tool.
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
@@ -22,6 +24,7 @@ from typing import TYPE_CHECKING
|
||||
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
import bonsai
|
||||
@@ -167,3 +170,16 @@ class TestGetDebugInfo(NewFile):
|
||||
def test_failed_to_load_returns_only_base_keys(self):
|
||||
info = bonsai.get_debug_info(bonsai_failed_to_load=True)
|
||||
assert set(info.keys()) == self.EXPECTED_KEYS
|
||||
|
||||
|
||||
class TestNpFrombufferLegacy(NewFile):
|
||||
"""Decoding ``n`` floats from a buffer must yield a length-``n`` array
|
||||
regardless of whether the buffer was written as ``float32`` or ``float64``."""
|
||||
|
||||
@pytest.mark.parametrize("n", [3, 9])
|
||||
@pytest.mark.parametrize("dtype", [np.float32, np.float64])
|
||||
def test_decodes_to_n_elements(self, n, dtype):
|
||||
data = np.arange(n, dtype=dtype).tobytes()
|
||||
result = subject.np_frombuffer_legacy(data, n)
|
||||
assert result.shape == (n,)
|
||||
np.testing.assert_allclose(result, np.arange(n))
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
# 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.
|
||||
|
||||
"""Cache-invalidation tests for ``tool.Blender.Modifier.any_selected_is_array_child``.
|
||||
|
||||
The wall-topology gizmo gate calls this on every viewport input event. The
|
||||
underlying ``is_array_child`` check is a BBIM_Array pset lookup per selected
|
||||
object; without memoisation that runs N_selected times per event. These
|
||||
tests pin that the cache reuses results across identical (selection, IFC
|
||||
generation) pairs and invalidates on either change."""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_memo():
|
||||
from bonsai import tool
|
||||
|
||||
saved = getattr(tool.Blender.Modifier, "_any_selected_array_child_memo", None)
|
||||
tool.Blender.Modifier._any_selected_array_child_memo = None
|
||||
yield
|
||||
tool.Blender.Modifier._any_selected_array_child_memo = saved
|
||||
|
||||
|
||||
def _mock_obj(name: str) -> Mock:
|
||||
obj = Mock()
|
||||
obj.name = name
|
||||
return obj
|
||||
|
||||
|
||||
def test_repeat_call_within_generation_reuses_cache():
|
||||
from bonsai import tool
|
||||
|
||||
obj_a = _mock_obj("Wall.001")
|
||||
obj_b = _mock_obj("Wall.002")
|
||||
|
||||
is_array_child_calls = {"n": 0}
|
||||
|
||||
def counting_is_array_child(elem):
|
||||
is_array_child_calls["n"] += 1
|
||||
return False
|
||||
|
||||
with patch("bonsai.tool.blender.tool.Blender.get_selected_objects", return_value=[obj_a, obj_b]), patch(
|
||||
"bonsai.tool.blender.tool.Parametric.get_geom_generation", return_value=5
|
||||
), patch("bonsai.tool.blender.tool.Ifc.get_entity", return_value=Mock()), patch.object(
|
||||
tool.Blender.Modifier, "is_array_child", side_effect=counting_is_array_child
|
||||
):
|
||||
first = tool.Blender.Modifier.any_selected_is_array_child()
|
||||
second = tool.Blender.Modifier.any_selected_is_array_child()
|
||||
|
||||
assert first is False
|
||||
assert second is False
|
||||
assert is_array_child_calls["n"] == 2, "First call walks N_selected; second call must reuse cached result"
|
||||
|
||||
|
||||
def test_generation_advance_invalidates_cache():
|
||||
from bonsai import tool
|
||||
|
||||
obj = _mock_obj("Wall.001")
|
||||
gen_state = {"gen": 1}
|
||||
|
||||
call_count = {"n": 0}
|
||||
|
||||
def counting_is_array_child(elem):
|
||||
call_count["n"] += 1
|
||||
return False
|
||||
|
||||
with patch("bonsai.tool.blender.tool.Blender.get_selected_objects", return_value=[obj]), patch(
|
||||
"bonsai.tool.blender.tool.Parametric.get_geom_generation", side_effect=lambda: gen_state["gen"]
|
||||
), patch("bonsai.tool.blender.tool.Ifc.get_entity", return_value=Mock()), patch.object(
|
||||
tool.Blender.Modifier, "is_array_child", side_effect=counting_is_array_child
|
||||
):
|
||||
tool.Blender.Modifier.any_selected_is_array_child()
|
||||
first = call_count["n"]
|
||||
gen_state["gen"] = 2
|
||||
tool.Blender.Modifier.any_selected_is_array_child()
|
||||
|
||||
assert call_count["n"] > first
|
||||
|
||||
|
||||
def test_selection_change_invalidates_cache():
|
||||
from bonsai import tool
|
||||
|
||||
obj_a = _mock_obj("Wall.001")
|
||||
obj_b = _mock_obj("Wall.002")
|
||||
selection = {"sel": [obj_a]}
|
||||
|
||||
call_count = {"n": 0}
|
||||
|
||||
def counting_is_array_child(elem):
|
||||
call_count["n"] += 1
|
||||
return False
|
||||
|
||||
with patch("bonsai.tool.blender.tool.Blender.get_selected_objects", side_effect=lambda: selection["sel"]), patch(
|
||||
"bonsai.tool.blender.tool.Parametric.get_geom_generation", return_value=1
|
||||
), patch("bonsai.tool.blender.tool.Ifc.get_entity", return_value=Mock()), patch.object(
|
||||
tool.Blender.Modifier, "is_array_child", side_effect=counting_is_array_child
|
||||
):
|
||||
tool.Blender.Modifier.any_selected_is_array_child()
|
||||
first = call_count["n"]
|
||||
selection["sel"] = [obj_a, obj_b]
|
||||
tool.Blender.Modifier.any_selected_is_array_child()
|
||||
|
||||
assert call_count["n"] > first
|
||||
|
||||
|
||||
def test_short_circuits_on_first_hit():
|
||||
"""``is_array_child`` returning True for the first selected object must
|
||||
short-circuit; the rest of the selection isn't walked. Belt-and-suspenders
|
||||
test — the early-return existed before the cache wrap and must survive it."""
|
||||
from bonsai import tool
|
||||
|
||||
obj_a = _mock_obj("Wall.001")
|
||||
obj_b = _mock_obj("Wall.002")
|
||||
obj_c = _mock_obj("Wall.003")
|
||||
|
||||
call_count = {"n": 0}
|
||||
|
||||
def counting_is_array_child(elem):
|
||||
call_count["n"] += 1
|
||||
return True
|
||||
|
||||
with patch("bonsai.tool.blender.tool.Blender.get_selected_objects", return_value=[obj_a, obj_b, obj_c]), patch(
|
||||
"bonsai.tool.blender.tool.Parametric.get_geom_generation", return_value=1
|
||||
), patch("bonsai.tool.blender.tool.Ifc.get_entity", return_value=Mock()), patch.object(
|
||||
tool.Blender.Modifier, "is_array_child", side_effect=counting_is_array_child
|
||||
):
|
||||
result = tool.Blender.Modifier.any_selected_is_array_child()
|
||||
|
||||
assert result is True
|
||||
assert call_count["n"] == 1
|
||||
@@ -0,0 +1,107 @@
|
||||
# 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 world-space dashed-line segmentation helper in tool.Blender.
|
||||
|
||||
The helper slices each input edge into world-space dash chunks so callers can
|
||||
build a vanilla LINES batch (any shader, including ``POLYLINE_UNIFORM_COLOR``)
|
||||
that renders as dashes. Sharing the front-pass shader for the occluded back
|
||||
pass is what keeps depth values coherent between the visible / occluded
|
||||
outlines — a custom dashed shader against a builtin solid shader produces
|
||||
inter-pass z-fighting and the wrong portion of the outline ends up dashed."""
|
||||
|
||||
import math
|
||||
import types
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
import bonsai.tool as tool
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _require_real_bpy():
|
||||
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
|
||||
pytest.skip("requires real Blender (bpy is mocked or absent)")
|
||||
|
||||
|
||||
class TestBuildDashedLineSegments:
|
||||
def test_unit_edge_produces_expected_dash_count(self):
|
||||
verts, edges = tool.Blender.build_dashed_line_segments(
|
||||
[(0.0, 0.0, 0.0), (1.0, 0.0, 0.0)],
|
||||
[(0, 1)],
|
||||
dash_period=0.20,
|
||||
dash_width=0.10,
|
||||
)
|
||||
assert len(edges) == 5
|
||||
assert len(verts) == 10
|
||||
|
||||
def test_each_dash_runs_dash_width_along_the_edge(self):
|
||||
verts, edges = tool.Blender.build_dashed_line_segments(
|
||||
[(0.0, 0.0, 0.0), (1.0, 0.0, 0.0)],
|
||||
[(0, 1)],
|
||||
dash_period=0.20,
|
||||
dash_width=0.10,
|
||||
)
|
||||
for i, j in edges:
|
||||
dx = verts[j][0] - verts[i][0]
|
||||
assert math.isclose(dx, 0.10, abs_tol=1e-9)
|
||||
|
||||
def test_dash_phase_resets_per_input_edge(self):
|
||||
verts, edges = tool.Blender.build_dashed_line_segments(
|
||||
[(0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (1.0, 0.0, 0.0), (1.0, 1.0, 0.0)],
|
||||
[(0, 1), (2, 3)],
|
||||
dash_period=0.20,
|
||||
dash_width=0.10,
|
||||
)
|
||||
first_dash_start = verts[edges[0][0]]
|
||||
second_edge_first_dash_start = verts[edges[5][0]]
|
||||
assert math.isclose(first_dash_start[0], 0.0, abs_tol=1e-9)
|
||||
assert math.isclose(second_edge_first_dash_start[1], 0.0, abs_tol=1e-9)
|
||||
|
||||
def test_trailing_partial_dash_is_clamped_to_edge_end(self):
|
||||
verts, edges = tool.Blender.build_dashed_line_segments(
|
||||
[(0.0, 0.0, 0.0), (0.25, 0.0, 0.0)],
|
||||
[(0, 1)],
|
||||
dash_period=0.20,
|
||||
dash_width=0.10,
|
||||
)
|
||||
last_x = verts[edges[-1][1]][0]
|
||||
assert last_x <= 0.25 + 1e-9
|
||||
|
||||
def test_zero_length_edge_emits_no_dashes(self):
|
||||
verts, edges = tool.Blender.build_dashed_line_segments(
|
||||
[(0.0, 0.0, 0.0), (0.0, 0.0, 0.0)],
|
||||
[(0, 1)],
|
||||
dash_period=0.20,
|
||||
dash_width=0.10,
|
||||
)
|
||||
assert verts == []
|
||||
assert edges == []
|
||||
|
||||
def test_invalid_dash_parameters_return_empty(self):
|
||||
assert tool.Blender.build_dashed_line_segments(
|
||||
[(0.0, 0.0, 0.0), (1.0, 0.0, 0.0)], [(0, 1)], dash_period=0.0, dash_width=0.10
|
||||
) == ([], [])
|
||||
assert tool.Blender.build_dashed_line_segments(
|
||||
[(0.0, 0.0, 0.0), (1.0, 0.0, 0.0)], [(0, 1)], dash_period=0.20, dash_width=-0.10
|
||||
) == ([], [])
|
||||
@@ -934,3 +934,88 @@ class TestOffsetWall(NewFile):
|
||||
usage.DirectionSense = "NEGATIVE"
|
||||
subject.offset_wall(obj, "EXTERIOR")
|
||||
assert usage.OffsetFromReferenceLine == 100
|
||||
|
||||
|
||||
class TestGetSiblingOccurrenceCount(NewFile):
|
||||
"""The pen-icon dispatcher's pre-edit warning depends on this count: zero
|
||||
means the edit is safe (unique geometry), non-zero means the edit will
|
||||
silently mutate other instances sharing the same resolved body rep."""
|
||||
|
||||
def _make_body_subcontext(self, ifc: ifcopenshell.file) -> ifcopenshell.entity_instance:
|
||||
import ifcopenshell.api.context
|
||||
|
||||
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject", name="Project")
|
||||
parent = ifcopenshell.api.context.add_context(ifc, context_type="Model")
|
||||
return ifcopenshell.api.context.add_context(
|
||||
ifc,
|
||||
context_type="Model",
|
||||
context_identifier="Body",
|
||||
target_view="MODEL_VIEW",
|
||||
parent=parent,
|
||||
)
|
||||
|
||||
def _create_wall_with_body_rep(
|
||||
self,
|
||||
ifc: ifcopenshell.file,
|
||||
body_subcontext: ifcopenshell.entity_instance,
|
||||
name: str = "Wall",
|
||||
) -> ifcopenshell.entity_instance:
|
||||
wall = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWall", name=name)
|
||||
rep = ifc.createIfcShapeRepresentation(
|
||||
ContextOfItems=body_subcontext,
|
||||
RepresentationIdentifier="Body",
|
||||
RepresentationType="SweptSolid",
|
||||
Items=[ifc.createIfcExtrudedAreaSolid()],
|
||||
)
|
||||
ifcopenshell.api.geometry.assign_representation(ifc, product=wall, representation=rep)
|
||||
return wall
|
||||
|
||||
def test_returns_zero_when_element_has_no_body_representation(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
wall = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWall")
|
||||
assert subject.get_sibling_occurrence_count(wall) == 0
|
||||
|
||||
def test_returns_zero_when_element_has_unique_body_representation(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
body = self._make_body_subcontext(ifc)
|
||||
wall = self._create_wall_with_body_rep(ifc, body)
|
||||
assert subject.get_sibling_occurrence_count(wall) == 0
|
||||
|
||||
def test_returns_sibling_count_excluding_self_and_type(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
body = self._make_body_subcontext(ifc)
|
||||
wall_type = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWallType", name="WAL01")
|
||||
type_rep = ifc.createIfcShapeRepresentation(
|
||||
ContextOfItems=body,
|
||||
RepresentationIdentifier="Body",
|
||||
RepresentationType="SweptSolid",
|
||||
Items=[ifc.createIfcExtrudedAreaSolid()],
|
||||
)
|
||||
ifcopenshell.api.geometry.assign_representation(ifc, product=wall_type, representation=type_rep)
|
||||
|
||||
occurrences = [ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWall", name=f"Wall{i}") for i in range(3)]
|
||||
ifcopenshell.api.type.assign_type(ifc, related_objects=occurrences, relating_type=wall_type)
|
||||
|
||||
assert subject.get_sibling_occurrence_count(occurrences[0]) == 2
|
||||
assert subject.get_sibling_occurrence_count(occurrences[1]) == 2
|
||||
assert subject.get_sibling_occurrence_count(occurrences[2]) == 2
|
||||
|
||||
def test_type_with_occurrences_reports_its_occurrence_count(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
body = self._make_body_subcontext(ifc)
|
||||
wall_type = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWallType", name="WAL01")
|
||||
type_rep = ifc.createIfcShapeRepresentation(
|
||||
ContextOfItems=body,
|
||||
RepresentationIdentifier="Body",
|
||||
RepresentationType="SweptSolid",
|
||||
Items=[ifc.createIfcExtrudedAreaSolid()],
|
||||
)
|
||||
ifcopenshell.api.geometry.assign_representation(ifc, product=wall_type, representation=type_rep)
|
||||
occurrences = [ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWall", name=f"Wall{i}") for i in range(2)]
|
||||
ifcopenshell.api.type.assign_type(ifc, related_objects=occurrences, relating_type=wall_type)
|
||||
|
||||
assert subject.get_sibling_occurrence_count(wall_type) == 2
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api
|
||||
import ifcopenshell.api.aggregate
|
||||
import ifcopenshell.api.feature
|
||||
import ifcopenshell.api.nest
|
||||
import ifcopenshell.api.root
|
||||
import ifcopenshell.api.spatial
|
||||
import numpy as np
|
||||
@@ -148,6 +151,40 @@ class TestGetContainer(NewFile):
|
||||
assert subject.get_container(wall) == site
|
||||
|
||||
|
||||
class TestGetRootElement(NewFile):
|
||||
def test_a_door_filling_a_wall_is_its_own_root_element(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
wall = ifc.createIfcWall()
|
||||
opening = ifc.createIfcOpeningElement()
|
||||
door = ifc.createIfcDoor()
|
||||
ifcopenshell.api.feature.add_feature(ifc, feature=opening, element=wall)
|
||||
ifcopenshell.api.feature.add_filling(ifc, opening=opening, element=door)
|
||||
assert subject.get_root_element(door) == door
|
||||
|
||||
def test_an_aggregated_element_walks_to_its_aggregate_root(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
assembly = ifc.createIfcElementAssembly()
|
||||
beam = ifc.createIfcBeam()
|
||||
ifcopenshell.api.aggregate.assign_object(ifc, products=[beam], relating_object=assembly)
|
||||
assert subject.get_root_element(beam) == assembly
|
||||
|
||||
def test_a_nested_element_walks_to_its_nest_root(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
parent_task = ifc.createIfcTask()
|
||||
child_task = ifc.createIfcTask()
|
||||
ifcopenshell.api.nest.assign_object(ifc, related_objects=[child_task], relating_object=parent_task)
|
||||
assert subject.get_root_element(child_task) == parent_task
|
||||
|
||||
def test_a_loose_element_is_its_own_root(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
wall = ifc.createIfcWall()
|
||||
assert subject.get_root_element(wall) == wall
|
||||
|
||||
|
||||
class TestGetDecomposedElements(NewFile):
|
||||
def test_run(self):
|
||||
ifc = ifcopenshell.file()
|
||||
|
||||
+36
-15
@@ -36,7 +36,17 @@ else()
|
||||
endif()
|
||||
|
||||
macro(build_example exe_name)
|
||||
set(_target_schema "")
|
||||
set(additional_targets ${ARGN})
|
||||
list(LENGTH additional_targets _argc)
|
||||
if(_argc GREATER 0)
|
||||
list(GET additional_targets 0 _first_arg)
|
||||
if("${_first_arg}" IN_LIST SCHEMA_VERSIONS)
|
||||
set(_target_schema ${_first_arg})
|
||||
list(REMOVE_AT additional_targets 0)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
add_executable(${exe_name} ${exe_name}.cpp)
|
||||
|
||||
if(STANDALONE_PROJECT)
|
||||
@@ -50,27 +60,38 @@ macro(build_example exe_name)
|
||||
target_link_libraries(${exe_name} IfcParse ${additional_targets})
|
||||
set_target_properties(${exe_name} PROPERTIES FOLDER Examples)
|
||||
endif()
|
||||
|
||||
if(_target_schema)
|
||||
set_target_properties(
|
||||
${exe_name}
|
||||
PROPERTIES COMPILE_FLAGS "-DIfcSchema=Ifc${_target_schema}"
|
||||
)
|
||||
endif()
|
||||
|
||||
unset(_target_schema)
|
||||
unset(_argc)
|
||||
unset(_first_arg)
|
||||
install(TARGETS ${exe_name})
|
||||
endmacro()
|
||||
|
||||
if("4" IN_LIST SCHEMA_VERSIONS)
|
||||
build_example(arbitrary_open_profile_def)
|
||||
build_example(triangulated_faceset)
|
||||
if(SCHEMA_VERSIONS)
|
||||
list(GET SCHEMA_VERSIONS -1 schema)
|
||||
endif()
|
||||
|
||||
if("2x3" IN_LIST SCHEMA_VERSIONS)
|
||||
build_example(composite_profile_def)
|
||||
build_example(csg_primitive)
|
||||
build_example(ellipse_pies)
|
||||
build_example(faces)
|
||||
build_example(ifc_curve_rebar)
|
||||
build_example(profiles)
|
||||
build_example(IfcParseExamples)
|
||||
build_example(arbitrary_open_profile_def ${schema})
|
||||
build_example(triangulated_faceset ${schema})
|
||||
|
||||
if(WITH_OPENCASCADE)
|
||||
build_example(IfcOpenHouse geometry_serializer)
|
||||
build_example(IfcAdvancedHouse geometry_serializer)
|
||||
endif()
|
||||
build_example(composite_profile_def ${schema})
|
||||
build_example(csg_primitive ${schema})
|
||||
build_example(ellipse_pies ${schema})
|
||||
build_example(faces ${schema})
|
||||
build_example(ifc_curve_rebar ${schema})
|
||||
build_example(profiles ${schema})
|
||||
build_example(IfcParseExamples ${schema})
|
||||
|
||||
if(WITH_OPENCASCADE)
|
||||
build_example(IfcOpenHouse ${schema} geometry_serializer)
|
||||
build_example(IfcAdvancedHouse ${schema} geometry_serializer)
|
||||
endif()
|
||||
|
||||
if("4x3_add2" IN_LIST SCHEMA_VERSIONS)
|
||||
|
||||
@@ -38,9 +38,15 @@
|
||||
|
||||
#include <Standard_Version.hxx>
|
||||
|
||||
#define IfcSchema Ifc2x3
|
||||
#include "ifcparse/macros.h"
|
||||
#include "ifcparse/Ifc2x3.h"
|
||||
|
||||
#ifndef IfcSchema
|
||||
#define IfcSchema Ifc2x3
|
||||
#endif
|
||||
|
||||
#include INCLUDE_SCHEMA(ifcparse, IfcSchema)
|
||||
#include INCLUDE_SCHEMA_DEFINITIONS(ifcparse, IfcSchema)
|
||||
|
||||
#include "ifcparse/IfcBaseClass.h"
|
||||
#include "ifcparse/IfcHierarchyHelper.h"
|
||||
|
||||
|
||||
@@ -35,9 +35,15 @@
|
||||
|
||||
#include <Precision.hxx>
|
||||
|
||||
#define IfcSchema Ifc2x3
|
||||
#include "ifcparse/macros.h"
|
||||
#include "ifcparse/Ifc2x3.h"
|
||||
|
||||
#ifndef IfcSchema
|
||||
#define IfcSchema Ifc2x3
|
||||
#endif
|
||||
|
||||
#include INCLUDE_SCHEMA(ifcparse, IfcSchema)
|
||||
#include INCLUDE_SCHEMA_DEFINITIONS(ifcparse, IfcSchema)
|
||||
|
||||
#include "ifcparse/IfcBaseClass.h"
|
||||
#include "ifcparse/IfcHierarchyHelper.h"
|
||||
|
||||
@@ -52,6 +58,11 @@ using namespace std::string_literals;
|
||||
// Some convenience typedefs and definitions.
|
||||
typedef IfcParse::IfcGlobalId guid;
|
||||
typedef std::pair<double, double> XY;
|
||||
#ifdef SCHEMA_HAS_IfcPresentationStyleAssignment
|
||||
typedef IfcSchema::IfcPresentationStyleAssignment surface_style_t;
|
||||
#else
|
||||
typedef IfcSchema::IfcPresentationStyle surface_style_t;
|
||||
#endif
|
||||
boost::none_t const null = boost::none;
|
||||
|
||||
// The creation of Nurbs-surface for the IfcSite mesh, to be implemented lateron
|
||||
@@ -74,7 +85,7 @@ int main() {
|
||||
0, // ObjectPlacement
|
||||
0, // Representation
|
||||
null // Tag
|
||||
#ifdef USE_IFC4
|
||||
#ifdef SCHEMA_IfcWall_HAS_PredefinedType
|
||||
, IfcSchema::IfcWallTypeEnum::IfcWallType_STANDARD
|
||||
#endif
|
||||
);
|
||||
@@ -105,7 +116,7 @@ int main() {
|
||||
south_wall->setObjectPlacement(file.addLocalPlacement(storey_placement));
|
||||
|
||||
// A pale white colour is assigned to the wall.
|
||||
IfcSchema::IfcPresentationStyleAssignment* wall_colour = setSurfaceColour(file, south_wall_shape, 0.75, 0.73, 0.68);
|
||||
surface_style_t* wall_colour = setSurfaceColour(file, south_wall_shape, 0.75, 0.73, 0.68);
|
||||
|
||||
// Now create a footing for the wall to rest on.
|
||||
IfcSchema::IfcFooting* footing = new IfcSchema::IfcFooting(guid(), file.getSingle<IfcSchema::IfcOwnerHistory>(),
|
||||
@@ -119,7 +130,7 @@ int main() {
|
||||
footing->setRepresentation(file.addBox(10100, 5460, 2000));
|
||||
footing->setObjectPlacement(file.addLocalPlacement(storey_placement, 0, 2500, -2000));
|
||||
// The footing will have a dark gray colour
|
||||
IfcSchema::IfcPresentationStyleAssignment* footing_colour = setSurfaceColour(file,footing->Representation(), 0.26, 0.22, 0.18);
|
||||
surface_style_t* footing_colour = setSurfaceColour(file,footing->Representation(), 0.26, 0.22, 0.18);
|
||||
|
||||
// IFC has two ways to apply boolean operations to geometry. IfcBooleanResults are commonly used
|
||||
// to clip geometry to a surface, for example to a slanted roof. For openings that are filled
|
||||
@@ -129,7 +140,7 @@ int main() {
|
||||
IfcSchema::IfcOpeningElement* west_opening = new IfcSchema::IfcOpeningElement(guid(), file.getSingle<IfcSchema::IfcOwnerHistory>(),
|
||||
null, null, null, file.addLocalPlacement(south_wall->ObjectPlacement(), -2500, 0, 400),
|
||||
file.addBox(6000, 3630, 1600), null
|
||||
#ifdef USE_IFC4
|
||||
#ifdef SCHEMA_IfcOpeningElement_HAS_PredefinedType
|
||||
, IfcSchema::IfcOpeningElementTypeEnum::IfcOpeningElementType_OPENING
|
||||
#endif
|
||||
);
|
||||
@@ -144,7 +155,7 @@ int main() {
|
||||
IfcSchema::IfcOpeningElement* south_opening = new IfcSchema::IfcOpeningElement(guid(), file.getSingle<IfcSchema::IfcOwnerHistory>(),
|
||||
null, null, null, file.addLocalPlacement(storey_placement, 3000, 0, 400),
|
||||
file.addBox(1860, 3000, 1600), null
|
||||
#ifdef USE_IFC4
|
||||
#ifdef SCHEMA_IfcOpeningElement_HAS_PredefinedType
|
||||
, IfcSchema::IfcOpeningElementTypeEnum::IfcOpeningElementType_OPENING
|
||||
#endif
|
||||
);
|
||||
@@ -194,7 +205,7 @@ int main() {
|
||||
// Copy the south wall to the north
|
||||
IfcSchema::IfcWallStandardCase* north_wall = new IfcSchema::IfcWallStandardCase(guid(), file.getSingle<IfcSchema::IfcOwnerHistory>(), "North wall"s,
|
||||
null, null, file.addLocalPlacement(storey_placement, 0, 5000, 0), file.addAxisBox(10000, 360, 3000), null
|
||||
#ifdef USE_IFC4
|
||||
#ifdef SCHEMA_IfcWall_HAS_PredefinedType
|
||||
, IfcSchema::IfcWallTypeEnum::IfcWallType_STANDARD
|
||||
#endif
|
||||
);
|
||||
@@ -226,7 +237,7 @@ int main() {
|
||||
// Now create a wall on the east of the building, again starting with just a box shape
|
||||
IfcSchema::IfcWallStandardCase* east_wall = new IfcSchema::IfcWallStandardCase(guid(), file.getSingle<IfcSchema::IfcOwnerHistory>(),
|
||||
"East wall"s, null, null, file.addLocalPlacement(storey_placement, 4820, 2500, 0, 0, 0, 1, 0, 1, 0), clipped_wall_body_reps[0], null
|
||||
#ifdef USE_IFC4
|
||||
#ifdef SCHEMA_IfcWall_HAS_PredefinedType
|
||||
, IfcSchema::IfcWallTypeEnum::IfcWallType_STANDARD
|
||||
#endif
|
||||
);
|
||||
@@ -235,7 +246,7 @@ int main() {
|
||||
// The east wall is copied to the west location of the house
|
||||
IfcSchema::IfcWallStandardCase* west_wall = new IfcSchema::IfcWallStandardCase(guid(), file.getSingle<IfcSchema::IfcOwnerHistory>(),
|
||||
"West wall"s, null, null, file.addLocalPlacement(storey_placement, -4820, 2500, 0, 0, 0, 1, 0, -1, 0), clipped_wall_body_reps[1], null
|
||||
#ifdef USE_IFC4
|
||||
#ifdef SCHEMA_IfcWall_HAS_PredefinedType
|
||||
, IfcSchema::IfcWallTypeEnum::IfcWallType_STANDARD
|
||||
#endif
|
||||
);
|
||||
@@ -252,7 +263,7 @@ int main() {
|
||||
IfcSchema::IfcOpeningElement* west_opening_copy = new IfcSchema::IfcOpeningElement(guid(), file.getSingle<IfcSchema::IfcOwnerHistory>(),
|
||||
null, null, null, file.addLocalPlacement(west_wall->ObjectPlacement(), 2500, -2500+4820, 400, 0, 0, 1, 0, 1, 0),
|
||||
file.addBox(6000, 3630, 1600), null
|
||||
#ifdef USE_IFC4
|
||||
#ifdef SCHEMA_IfcOpeningElement_HAS_PredefinedType
|
||||
, IfcSchema::IfcOpeningElementTypeEnum::IfcOpeningElementType_OPENING
|
||||
#endif
|
||||
);
|
||||
@@ -274,7 +285,7 @@ int main() {
|
||||
IfcSchema::IfcProperty::list::ptr properties(new IfcSchema::IfcProperty::list);
|
||||
properties->push(new IfcSchema::IfcPropertySingleValue("TotalArea", null, new IfcSchema::IfcAreaMeasure(site_area), 0));
|
||||
IfcSchema::IfcPropertySet* pset = new IfcSchema::IfcPropertySet(guid(), file.getSingle<IfcSchema::IfcOwnerHistory>(), "Pset_SiteCommon"s, null, properties);
|
||||
#ifdef USE_IFC4
|
||||
#ifdef SCHEMA_HAS_IfcDefinitionSelect
|
||||
IfcSchema::IfcObjectDefinition::list::ptr related_objs(new IfcSchema::IfcObjectDefinition::list);
|
||||
#else
|
||||
IfcSchema::IfcObject::list::ptr related_objs(new IfcSchema::IfcObject::list);
|
||||
@@ -297,7 +308,7 @@ int main() {
|
||||
// Some BIM authoring applications, such as Autodesk Revit, ignore the geometrical representation
|
||||
// by and large and construct native walls using the layer thickness and reference line offset
|
||||
// provided here.
|
||||
#ifdef USE_IFC4
|
||||
#ifdef SCHEMA_IfcMaterial_HAS_Description
|
||||
IfcSchema::IfcMaterial* material = new IfcSchema::IfcMaterial("Brick", null, null);
|
||||
#else
|
||||
IfcSchema::IfcMaterial* material = new IfcSchema::IfcMaterial("Brick");
|
||||
@@ -306,7 +317,7 @@ int main() {
|
||||
material,
|
||||
360,
|
||||
null
|
||||
#ifdef USE_IFC4
|
||||
#ifdef SCHEMA_IfcMaterialLayer_HAS_Name
|
||||
, null
|
||||
, null
|
||||
, null
|
||||
@@ -318,7 +329,7 @@ int main() {
|
||||
IfcSchema::IfcMaterialLayerSet* layer_set = new IfcSchema::IfcMaterialLayerSet(
|
||||
layers,
|
||||
"Wall"s
|
||||
#ifdef USE_IFC4
|
||||
#ifdef SCHEMA_IfcMaterialLayerSet_HAS_Description
|
||||
, null
|
||||
#endif
|
||||
);
|
||||
@@ -327,7 +338,7 @@ int main() {
|
||||
IfcSchema::IfcLayerSetDirectionEnum::IfcLayerSetDirection_AXIS2,
|
||||
IfcSchema::IfcDirectionSenseEnum::IfcDirectionSense_POSITIVE,
|
||||
-180
|
||||
#ifdef USE_IFC4
|
||||
#ifdef SCHEMA_IfcMaterialLayerSetUsage_HAS_ReferenceExtent
|
||||
, null
|
||||
#endif
|
||||
);
|
||||
@@ -337,7 +348,7 @@ int main() {
|
||||
file.getSingle<IfcSchema::IfcOwnerHistory>(),
|
||||
null,
|
||||
null,
|
||||
#ifdef USE_IFC4
|
||||
#ifdef SCHEMA_HAS_IfcDefinitionSelect
|
||||
file.instances_by_type<IfcSchema::IfcWallStandardCase>()->as<IfcSchema::IfcDefinitionSelect>(),
|
||||
#else
|
||||
file.instances_by_type<IfcSchema::IfcWallStandardCase>()->as<IfcSchema::IfcRoot>(),
|
||||
@@ -362,7 +373,7 @@ int main() {
|
||||
IfcSchema::IfcStairFlight* stair = new IfcSchema::IfcStairFlight(guid(), file.getSingle<IfcSchema::IfcOwnerHistory>(),
|
||||
null, null, null, file.addLocalPlacement(storey_placement, 5050, 1000, 0, 0, 1, 0, 1, 0, 0),
|
||||
file.addExtrudedPolyline(stair_points, 1200), null, 2, 2, 0.2, 0.25
|
||||
#ifdef USE_IFC4
|
||||
#ifdef SCHEMA_IfcStairFlight_HAS_PredefinedType
|
||||
, IfcSchema::IfcStairFlightTypeEnum::IfcStairFlightType_STRAIGHT
|
||||
#endif
|
||||
);
|
||||
@@ -372,7 +383,7 @@ int main() {
|
||||
|
||||
IfcSchema::IfcOpeningElement* door_opening = new IfcSchema::IfcOpeningElement(guid(), file.getSingle<IfcSchema::IfcOwnerHistory>(),
|
||||
null, null, null, file.addLocalPlacement(storey_placement, 5000-180, 2500-900, 0), file.addBox(1000, 1000, 2200), null
|
||||
#ifdef USE_IFC4
|
||||
#ifdef SCHEMA_IfcOpeningElement_HAS_PredefinedType
|
||||
, IfcSchema::IfcOpeningElementTypeEnum::IfcOpeningElementType_OPENING
|
||||
#endif
|
||||
);
|
||||
@@ -384,7 +395,7 @@ int main() {
|
||||
// which constitute the door and its frame.
|
||||
IfcSchema::IfcDoor* door = new IfcSchema::IfcDoor(guid(), file.getSingle<IfcSchema::IfcOwnerHistory>(), null, null, null,
|
||||
file.addLocalPlacement(storey_placement, 4800, 1600, 0, 0, 0, 1, 0, 1, 0), 0, null, 2200, 1000
|
||||
#ifdef USE_IFC4
|
||||
#ifdef SCHEMA_IfcDoor_HAS_PredefinedType
|
||||
, IfcSchema::IfcDoorTypeEnum::IfcDoorType_DOOR
|
||||
, IfcSchema::IfcDoorTypeOperationEnum::IfcDoorTypeOperation_SINGLE_SWING_LEFT
|
||||
, null
|
||||
@@ -406,11 +417,15 @@ int main() {
|
||||
setSurfaceColour(file, door->Representation(), 0.9, 0.9, 0.9);
|
||||
file.addEntity(new IfcSchema::IfcRelFillsElement(guid(), file.getSingle<IfcSchema::IfcOwnerHistory>(), null, null, door_opening, door));
|
||||
|
||||
#ifdef SCHEMA_HAS_IfcDoorType
|
||||
IfcSchema::IfcDoorType* door_type = new IfcSchema::IfcDoorType(guid(), file.getSingle<IfcSchema::IfcOwnerHistory>(), "Door type"s, null, null, null, null, null, null,
|
||||
IfcSchema::IfcDoorTypeEnum::IfcDoorType_DOOR, IfcSchema::IfcDoorTypeOperationEnum::IfcDoorTypeOperation_SINGLE_SWING_LEFT, false, null);
|
||||
file.addRelatedObject<IfcSchema::IfcRelDefinesByType>(door_type, door);
|
||||
#elif defined(SCHEMA_HAS_IfcDoorStyle)
|
||||
IfcSchema::IfcDoorStyle* door_style = new IfcSchema::IfcDoorStyle(guid(), file.getSingle<IfcSchema::IfcOwnerHistory>(), "Door type"s, null, null, null, null, null,
|
||||
IfcSchema::IfcDoorStyleOperationEnum::IfcDoorStyleOperation_SINGLE_SWING_LEFT, IfcSchema::IfcDoorStyleConstructionEnum::IfcDoorStyleConstruction_WOOD, false, false);
|
||||
// NOTE: typing by IfcDoorStyle will cause validation errors in IFC4+ but it's allowed for backwards compatibility
|
||||
// better to use IfcDoorType in the actual use case
|
||||
file.addRelatedObject<IfcSchema::IfcRelDefinesByType>(door_style, door);
|
||||
#endif
|
||||
|
||||
// Surface styles are assigned to representation items, hence there is no real limitation to
|
||||
// assign different colours within the same representation. However, some viewers have
|
||||
@@ -436,7 +451,7 @@ int main() {
|
||||
frame_representations->push(vertical_bar); // Add another reference to the vertical bar created above
|
||||
|
||||
// The beams all have the same surface style assigned
|
||||
IfcSchema::IfcPresentationStyleAssignment* frame_style = 0;
|
||||
surface_style_t* frame_style = 0;
|
||||
for (IfcSchema::IfcShapeRepresentation::list::it i = frame_representations->begin(); i != frame_representations->end(); i += 2) {
|
||||
if (frame_style) {
|
||||
setSurfaceColour(file,*i, frame_style);
|
||||
@@ -461,7 +476,7 @@ int main() {
|
||||
IfcSchema::IfcLocalPlacement* place = *it;
|
||||
IfcSchema::IfcWindow* window = new IfcSchema::IfcWindow(guid(), file.getSingle<IfcSchema::IfcOwnerHistory>(),
|
||||
null, null, null, place, 0, null, 1600, 1860
|
||||
#ifdef USE_IFC4
|
||||
#ifdef SCHEMA_IfcWindow_HAS_PredefinedType
|
||||
, IfcSchema::IfcWindowTypeEnum::IfcWindowType_WINDOW
|
||||
, IfcSchema::IfcWindowTypePartitioningEnum::IfcWindowTypePartitioning_SINGLE_PANEL
|
||||
, null
|
||||
@@ -489,7 +504,7 @@ int main() {
|
||||
{
|
||||
IfcSchema::IfcMember* frame_part = new IfcSchema::IfcMember(guid(), file.getSingle<IfcSchema::IfcOwnerHistory>(),
|
||||
null, null, null, *frame_placement, file.addMappedItem(*frame_representation), null
|
||||
#ifdef USE_IFC4
|
||||
#ifdef SCHEMA_IfcMember_HAS_PredefinedType
|
||||
, IfcSchema::IfcMemberTypeEnum::IfcMemberType_MULLION
|
||||
#endif
|
||||
);
|
||||
@@ -501,7 +516,7 @@ int main() {
|
||||
// Add the glass plate to the list of parts
|
||||
IfcSchema::IfcPlate* glass_part = new IfcSchema::IfcPlate(guid(), file.getSingle<IfcSchema::IfcOwnerHistory>(), null,
|
||||
null, null, file.addLocalPlacement(storey_placement, 930, 45, 90), file.addBox(1680, 10, 1420), null
|
||||
#ifdef USE_IFC4
|
||||
#ifdef SCHEMA_IfcPlate_HAS_PredefinedType
|
||||
, IfcSchema::IfcPlateTypeEnum::IfcPlateType_SHEET
|
||||
#endif
|
||||
);
|
||||
|
||||
@@ -17,12 +17,16 @@
|
||||
* *
|
||||
********************************************************************************/
|
||||
|
||||
// TODO: Multiple schemas
|
||||
#include "ifcparse/macros.h"
|
||||
|
||||
#ifndef IfcSchema
|
||||
#define IfcSchema Ifc2x3
|
||||
#endif
|
||||
|
||||
#include "ifcparse/IfcFile.h"
|
||||
#include "ifcparse/IfcLogger.h"
|
||||
#include "ifcparse/Ifc2x3.h"
|
||||
#include INCLUDE_SCHEMA(ifcparse, IfcSchema)
|
||||
#include INCLUDE_SCHEMA_DEFINITIONS(ifcparse, IfcSchema)
|
||||
|
||||
#include <boost/preprocessor/stringize.hpp>
|
||||
#include <boost/preprocessor/seq/for_each.hpp>
|
||||
@@ -39,33 +43,6 @@
|
||||
static_assert(false, "A boost preprocessor sequence of schema identifiers is needed for this file to compile.");
|
||||
#endif
|
||||
|
||||
// @todo duplicated with Kernel.h
|
||||
|
||||
// @tfk A macro cannot define an include (I think), so here we can't
|
||||
// loop over the sequence of schema identifiers, but rather we have
|
||||
// unroll the loop with at least the amount of schemas we'd like support
|
||||
// for and then overflow into an existing empty include file.
|
||||
|
||||
#define INCLUDE_SCHEMA(n) \
|
||||
BOOST_PP_IIF(BOOST_PP_GREATER(BOOST_PP_SEQ_SIZE(SCHEMA_SEQ), n), BOOST_PP_STRINGIZE(../ifcparse/BOOST_PP_CAT(Ifc,BOOST_PP_SEQ_ELEM(BOOST_PP_MIN(n, BOOST_PP_SEQ_SIZE(BOOST_PP_SEQ_POP_BACK(SCHEMA_SEQ))),SCHEMA_SEQ)).h), "../ifcgeom/empty.h")
|
||||
|
||||
#include INCLUDE_SCHEMA(0)
|
||||
#include INCLUDE_SCHEMA(1)
|
||||
#include INCLUDE_SCHEMA(2)
|
||||
#include INCLUDE_SCHEMA(3)
|
||||
#include INCLUDE_SCHEMA(4)
|
||||
#include INCLUDE_SCHEMA(5)
|
||||
#include INCLUDE_SCHEMA(6)
|
||||
#include INCLUDE_SCHEMA(7)
|
||||
#include INCLUDE_SCHEMA(8)
|
||||
#include INCLUDE_SCHEMA(9)
|
||||
#include INCLUDE_SCHEMA(10)
|
||||
#include INCLUDE_SCHEMA(11)
|
||||
#include INCLUDE_SCHEMA(12)
|
||||
#include INCLUDE_SCHEMA(13)
|
||||
#include INCLUDE_SCHEMA(14)
|
||||
#include INCLUDE_SCHEMA(15)
|
||||
|
||||
#include <iomanip>
|
||||
|
||||
#if USE_VLD
|
||||
@@ -80,6 +57,12 @@ struct is_ifc4_or_higher<T, std::void_t<decltype(T::IfcMaterialDefinition)>> : s
|
||||
|
||||
typedef std::map<std::string, std::map<std::string, std::string>> element_properties;
|
||||
|
||||
#ifdef SCHEMA_HAS_IfcBuildingElement
|
||||
typedef IfcSchema::IfcBuildingElement element_t;
|
||||
#else
|
||||
typedef IfcSchema::IfcBuiltElement element_t;
|
||||
#endif
|
||||
|
||||
std::string format_string(const AttributeValue& argument) {
|
||||
// Argument is a runtime tagged variant for the various data types in a IFC model,
|
||||
// in this particular case we only care about flattening it to a string.
|
||||
@@ -234,7 +217,7 @@ int main(int argc, char** argv) {
|
||||
}
|
||||
|
||||
// Redirect the output (both progress and log) to stdout
|
||||
Logger::SetOutput(&std::cout, &std::cout);
|
||||
Logger::Root().SetOutput(&std::cout, &std::cout);
|
||||
|
||||
// Parse the IFC file provided in argv[1]
|
||||
IfcParse::IfcFile file(argv[1]);
|
||||
@@ -259,7 +242,7 @@ int main(int argc, char** argv) {
|
||||
// we need to cast them to IfcWindows. Since these properties
|
||||
// are optional we need to make sure the properties are
|
||||
// defined for the window in question before accessing them.
|
||||
IfcSchema::IfcBuildingElement::list::ptr elements = file.instances_by_type<IfcSchema::IfcBuildingElement>();
|
||||
auto elements = file.instances_by_type<element_t>();
|
||||
|
||||
std::cout << "Found " << elements->size() << " elements in " << argv[1] << ":" << std::endl;
|
||||
|
||||
|
||||
@@ -28,8 +28,15 @@
|
||||
#include <iostream>
|
||||
#include <fstream>
|
||||
|
||||
#include "ifcparse/macros.h"
|
||||
|
||||
#ifndef IfcSchema
|
||||
#define IfcSchema Ifc4
|
||||
#include "ifcparse/Ifc4.h"
|
||||
#endif
|
||||
|
||||
#include INCLUDE_SCHEMA(ifcparse, IfcSchema)
|
||||
#include INCLUDE_SCHEMA_DEFINITIONS(ifcparse, IfcSchema)
|
||||
|
||||
#include "ifcparse/IfcHierarchyHelper.h"
|
||||
|
||||
typedef std::string S;
|
||||
@@ -138,4 +145,4 @@ int main(int argc, char** argv) {
|
||||
|
||||
std::ofstream f(filename);
|
||||
f << file;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,14 +27,45 @@
|
||||
#include <iostream>
|
||||
#include <fstream>
|
||||
|
||||
#include "ifcparse/macros.h"
|
||||
|
||||
#ifndef IfcSchema
|
||||
#define IfcSchema Ifc2x3
|
||||
#include "ifcparse/Ifc2x3.h"
|
||||
#endif
|
||||
|
||||
#include INCLUDE_SCHEMA(ifcparse, IfcSchema)
|
||||
#include INCLUDE_SCHEMA_DEFINITIONS(ifcparse, IfcSchema)
|
||||
|
||||
#include "ifcparse/IfcHierarchyHelper.h"
|
||||
|
||||
typedef std::string S;
|
||||
typedef IfcParse::IfcGlobalId guid;
|
||||
boost::none_t const null = boost::none;
|
||||
|
||||
#ifdef SCHEMA_IfcIShapeProfileDef_HAS_FlangeEdgeRadius
|
||||
#define IFC_I_SHAPE_PROFILE_DEF_EXTRA_ARGS , null, null
|
||||
#else
|
||||
#define IFC_I_SHAPE_PROFILE_DEF_EXTRA_ARGS
|
||||
#endif
|
||||
|
||||
#ifdef SCHEMA_IfcLShapeProfileDef_HAS_CentreOfGravityInX
|
||||
#define IFC_L_SHAPE_PROFILE_DEF_EXTRA_ARGS , null, null
|
||||
#else
|
||||
#define IFC_L_SHAPE_PROFILE_DEF_EXTRA_ARGS
|
||||
#endif
|
||||
|
||||
#ifdef SCHEMA_IfcTShapeProfileDef_HAS_CentreOfGravityInY
|
||||
#define IFC_T_SHAPE_PROFILE_DEF_EXTRA_ARGS , null
|
||||
#else
|
||||
#define IFC_T_SHAPE_PROFILE_DEF_EXTRA_ARGS
|
||||
#endif
|
||||
|
||||
#ifdef SCHEMA_IfcCShapeProfileDef_HAS_CentreOfGravityInX
|
||||
#define IFC_C_SHAPE_PROFILE_DEF_EXTRA_ARGS , null
|
||||
#else
|
||||
#define IFC_C_SHAPE_PROFILE_DEF_EXTRA_ARGS
|
||||
#endif
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
const char filename[] = "composite_profile_def.ifc";
|
||||
IfcHierarchyHelper<IfcSchema> file;
|
||||
@@ -49,21 +80,21 @@ int main(int argc, char** argv) {
|
||||
IfcSchema::IfcCartesianTransformationOperator2D* transform1 = new IfcSchema::IfcCartesianTransformationOperator2D(file.addDoublet<IfcSchema::IfcDirection>(1, 0), file.addDoublet<IfcSchema::IfcDirection>(0, -1), file.addDoublet<IfcSchema::IfcCartesianPoint>(40, 0), null);
|
||||
IfcSchema::IfcCartesianTransformationOperator2D* transform2 = new IfcSchema::IfcCartesianTransformationOperator2D(file.addDoublet<IfcSchema::IfcDirection>(0, -1), file.addDoublet<IfcSchema::IfcDirection>(1, 0), file.addDoublet<IfcSchema::IfcCartesianPoint>(40, 0), 0.3);
|
||||
|
||||
IfcSchema::IfcProfileDef* p1 = new Ifc2x3::IfcIShapeProfileDef(
|
||||
IfcSchema::IfcProfileDef* p1 = new IfcSchema::IfcIShapeProfileDef(
|
||||
IfcSchema::IfcProfileTypeEnum::IfcProfileType_AREA,
|
||||
null, file.addPlacement2d(), 25.0, 50.0, 5.0, 5.0, 2.0);
|
||||
null, file.addPlacement2d(), 25.0, 50.0, 5.0, 5.0, 2.0 IFC_I_SHAPE_PROFILE_DEF_EXTRA_ARGS);
|
||||
|
||||
IfcSchema::IfcProfileDef* p2 = new Ifc2x3::IfcLShapeProfileDef(
|
||||
IfcSchema::IfcProfileDef* p2 = new IfcSchema::IfcLShapeProfileDef(
|
||||
IfcSchema::IfcProfileTypeEnum::IfcProfileType_AREA,
|
||||
null, file.addPlacement2d(), 50.0, 25.0, 5.0, 1.0, 2.0, 2.0, null, null);
|
||||
null, file.addPlacement2d(), 50.0, 25.0, 5.0, 1.0, 2.0, 2.0 IFC_L_SHAPE_PROFILE_DEF_EXTRA_ARGS);
|
||||
|
||||
IfcSchema::IfcProfileDef* p3 = new Ifc2x3::IfcTShapeProfileDef(
|
||||
IfcSchema::IfcProfileDef* p3 = new IfcSchema::IfcTShapeProfileDef(
|
||||
IfcSchema::IfcProfileTypeEnum::IfcProfileType_AREA,
|
||||
null, file.addPlacement2d(), 50.0, 40.0, 10.0, 10.0, 3.0, 2.0, 1.0, 2.0, 2.0, null);
|
||||
null, file.addPlacement2d(), 50.0, 40.0, 10.0, 10.0, 3.0, 2.0, 1.0, 2.0, 2.0 IFC_T_SHAPE_PROFILE_DEF_EXTRA_ARGS);
|
||||
|
||||
IfcSchema::IfcProfileDef* p4 = new Ifc2x3::IfcCShapeProfileDef(
|
||||
IfcSchema::IfcProfileDef* p4 = new IfcSchema::IfcCShapeProfileDef(
|
||||
IfcSchema::IfcProfileTypeEnum::IfcProfileType_AREA,
|
||||
null, file.addPlacement2d(80.), 50.0, 25.0, 5.0, 10.0, 2.0, null);
|
||||
null, file.addPlacement2d(80.), 50.0, 25.0, 5.0, 10.0, 2.0 IFC_C_SHAPE_PROFILE_DEF_EXTRA_ARGS);
|
||||
|
||||
file.addEntity(p2);
|
||||
file.addEntity(p3);
|
||||
|
||||
@@ -27,8 +27,15 @@
|
||||
#include <iostream>
|
||||
#include <fstream>
|
||||
|
||||
#include "ifcparse/macros.h"
|
||||
|
||||
#ifndef IfcSchema
|
||||
#define IfcSchema Ifc2x3
|
||||
#include "ifcparse/Ifc2x3.h"
|
||||
#endif
|
||||
|
||||
#include INCLUDE_SCHEMA(ifcparse, IfcSchema)
|
||||
#include INCLUDE_SCHEMA_DEFINITIONS(ifcparse, IfcSchema)
|
||||
|
||||
#include "ifcparse/IfcHierarchyHelper.h"
|
||||
|
||||
typedef std::string S;
|
||||
|
||||
@@ -27,14 +27,27 @@
|
||||
#include <iostream>
|
||||
#include <fstream>
|
||||
|
||||
#include "ifcparse/macros.h"
|
||||
|
||||
#ifndef IfcSchema
|
||||
#define IfcSchema Ifc2x3
|
||||
#include "ifcparse/Ifc2x3.h"
|
||||
#endif
|
||||
|
||||
#include INCLUDE_SCHEMA(ifcparse, IfcSchema)
|
||||
#include INCLUDE_SCHEMA_DEFINITIONS(ifcparse, IfcSchema)
|
||||
|
||||
#include "ifcparse/IfcHierarchyHelper.h"
|
||||
|
||||
typedef std::string S;
|
||||
typedef IfcParse::IfcGlobalId guid;
|
||||
boost::none_t const null = boost::none;
|
||||
|
||||
#ifdef SCHEMA_HAS_IfcSegment
|
||||
typedef IfcSchema::IfcSegment curve_segment_tt;
|
||||
#else
|
||||
typedef IfcSchema::IfcCompositeCurveSegment curve_segment_tt;
|
||||
#endif
|
||||
|
||||
typedef struct {
|
||||
double r1;
|
||||
double r2;
|
||||
@@ -54,44 +67,44 @@ void create_testcase_for(IfcHierarchyHelper<IfcSchema>& file, const EllipsePie&
|
||||
std::vector<double> coords2(flt2, flt2 + 2);
|
||||
std::vector<double> coords3(flt3, flt3 + 2);
|
||||
|
||||
Ifc2x3::IfcCartesianPoint* p1 = new Ifc2x3::IfcCartesianPoint(coords1);
|
||||
Ifc2x3::IfcCartesianPoint* p2 = new Ifc2x3::IfcCartesianPoint(coords2);
|
||||
Ifc2x3::IfcCartesianPoint* p3 = new Ifc2x3::IfcCartesianPoint(coords3);
|
||||
IfcSchema::IfcCartesianPoint* p1 = new IfcSchema::IfcCartesianPoint(coords1);
|
||||
IfcSchema::IfcCartesianPoint* p2 = new IfcSchema::IfcCartesianPoint(coords2);
|
||||
IfcSchema::IfcCartesianPoint* p3 = new IfcSchema::IfcCartesianPoint(coords3);
|
||||
|
||||
Ifc2x3::IfcCartesianPoint::list::ptr points(new Ifc2x3::IfcCartesianPoint::list());
|
||||
IfcSchema::IfcCartesianPoint::list::ptr points(new IfcSchema::IfcCartesianPoint::list());
|
||||
points->push(p3);
|
||||
points->push(p1);
|
||||
points->push(p2);
|
||||
file.addEntities(points->generalize());
|
||||
|
||||
|
||||
Ifc2x3::IfcEllipse* ellipse = new Ifc2x3::IfcEllipse(file.addPlacement2d(), pie.r1, pie.r2);
|
||||
IfcSchema::IfcEllipse* ellipse = new IfcSchema::IfcEllipse(file.addPlacement2d(), pie.r1, pie.r2);
|
||||
file.addEntity(ellipse);
|
||||
aggregate_of_instance::ptr trim1(new aggregate_of_instance);
|
||||
aggregate_of_instance::ptr trim2(new aggregate_of_instance);
|
||||
if (pref == IfcSchema::IfcTrimmingPreference::IfcTrimmingPreference_PARAMETER) {
|
||||
trim1->push(new Ifc2x3::IfcParameterValue(pie.t1));
|
||||
trim2->push(new Ifc2x3::IfcParameterValue(pie.t2));
|
||||
trim1->push(new IfcSchema::IfcParameterValue(pie.t1));
|
||||
trim2->push(new IfcSchema::IfcParameterValue(pie.t2));
|
||||
} else {
|
||||
trim1->push(p2);
|
||||
trim2->push(p3);
|
||||
}
|
||||
Ifc2x3::IfcTrimmedCurve* trim = new Ifc2x3::IfcTrimmedCurve(ellipse, trim1->as<IfcSchema::IfcTrimmingSelect>(), trim2->as<IfcSchema::IfcTrimmingSelect>(), true, pref);
|
||||
IfcSchema::IfcTrimmedCurve* trim = new IfcSchema::IfcTrimmedCurve(ellipse, trim1->as<IfcSchema::IfcTrimmingSelect>(), trim2->as<IfcSchema::IfcTrimmingSelect>(), true, pref);
|
||||
file.addEntity(trim);
|
||||
|
||||
Ifc2x3::IfcCompositeCurveSegment::list::ptr segments(new Ifc2x3::IfcCompositeCurveSegment::list());
|
||||
Ifc2x3::IfcCompositeCurveSegment* s2 = new Ifc2x3::IfcCompositeCurveSegment(Ifc2x3::IfcTransitionCode::IfcTransitionCode_CONTINUOUS, true, trim);
|
||||
curve_segment_tt::list::ptr segments(new curve_segment_tt::list());
|
||||
IfcSchema::IfcCompositeCurveSegment* s2 = new IfcSchema::IfcCompositeCurveSegment(IfcSchema::IfcTransitionCode::IfcTransitionCode_CONTINUOUS, true, trim);
|
||||
|
||||
Ifc2x3::IfcPolyline* poly = new Ifc2x3::IfcPolyline(points);
|
||||
IfcSchema::IfcPolyline* poly = new IfcSchema::IfcPolyline(points);
|
||||
file.addEntity(poly);
|
||||
Ifc2x3::IfcCompositeCurveSegment* s1 = new Ifc2x3::IfcCompositeCurveSegment(Ifc2x3::IfcTransitionCode::IfcTransitionCode_CONTINUOUS, true, poly);
|
||||
IfcSchema::IfcCompositeCurveSegment* s1 = new IfcSchema::IfcCompositeCurveSegment(IfcSchema::IfcTransitionCode::IfcTransitionCode_CONTINUOUS, true, poly);
|
||||
segments->push(s1);
|
||||
|
||||
segments->push(s2);
|
||||
file.addEntities(segments->generalize());
|
||||
|
||||
Ifc2x3::IfcCompositeCurve* ccurve = new Ifc2x3::IfcCompositeCurve(segments, false);
|
||||
Ifc2x3::IfcArbitraryClosedProfileDef* profile = new Ifc2x3::IfcArbitraryClosedProfileDef(Ifc2x3::IfcProfileTypeEnum::IfcProfileType_AREA, null, ccurve);
|
||||
IfcSchema::IfcCompositeCurve* ccurve = new IfcSchema::IfcCompositeCurve(segments, false);
|
||||
IfcSchema::IfcArbitraryClosedProfileDef* profile = new IfcSchema::IfcArbitraryClosedProfileDef(IfcSchema::IfcProfileTypeEnum::IfcProfileType_AREA, null, ccurve);
|
||||
file.addEntity(ccurve);
|
||||
file.addEntity(profile);
|
||||
|
||||
|
||||
@@ -24,8 +24,15 @@
|
||||
********************************************************************************/
|
||||
#include <fstream>
|
||||
|
||||
#include "ifcparse/macros.h"
|
||||
|
||||
#ifndef IfcSchema
|
||||
#define IfcSchema Ifc2x3
|
||||
#include "ifcparse/Ifc2x3.h"
|
||||
#endif
|
||||
|
||||
#include INCLUDE_SCHEMA(ifcparse, IfcSchema)
|
||||
#include INCLUDE_SCHEMA_DEFINITIONS(ifcparse, IfcSchema)
|
||||
|
||||
#include "ifcparse/IfcHierarchyHelper.h"
|
||||
|
||||
typedef std::string S;
|
||||
@@ -205,4 +212,4 @@ int main(int argc, char** argv) {
|
||||
file.header().file_name()->setname(filename);
|
||||
std::ofstream f(filename);
|
||||
f << file;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,8 +27,15 @@
|
||||
#include <string>
|
||||
#include <fstream>
|
||||
|
||||
#include "ifcparse/macros.h"
|
||||
|
||||
#ifndef IfcSchema
|
||||
#define IfcSchema Ifc2x3
|
||||
#include "ifcparse/Ifc2x3.h"
|
||||
#endif
|
||||
|
||||
#include INCLUDE_SCHEMA(ifcparse, IfcSchema)
|
||||
#include INCLUDE_SCHEMA_DEFINITIONS(ifcparse, IfcSchema)
|
||||
|
||||
#include "ifcparse/IfcHierarchyHelper.h"
|
||||
|
||||
#include <boost/math/constants/constants.hpp>
|
||||
@@ -38,6 +45,18 @@ typedef std::string S;
|
||||
typedef IfcParse::IfcGlobalId guid;
|
||||
boost::none_t const null = boost::none;
|
||||
|
||||
#ifdef SCHEMA_HAS_IfcSegment
|
||||
typedef IfcSchema::IfcSegment curve_segment_t;
|
||||
#else
|
||||
typedef IfcSchema::IfcCompositeCurveSegment curve_segment_t;
|
||||
#endif
|
||||
|
||||
#ifdef SCHEMA_IfcReinforcingBar_HAS_PredefinedType
|
||||
#define IFC_REINFORCING_BAR_TYPE IfcSchema::IfcReinforcingBarTypeEnum::IfcReinforcingBarType_LIGATURE
|
||||
#else
|
||||
#define IFC_REINFORCING_BAR_TYPE IfcSchema::IfcReinforcingBarRoleEnum::IfcReinforcingBarRole_LIGATURE
|
||||
#endif
|
||||
|
||||
void create_curve_rebar(IfcHierarchyHelper<IfcSchema>& file)
|
||||
{
|
||||
int dia = 24;
|
||||
@@ -52,14 +71,14 @@ void create_curve_rebar(IfcHierarchyHelper<IfcSchema>& file)
|
||||
dia, //diameter
|
||||
crossSectionarea, //crossSectionarea = math.pi*(12.0/2)**2
|
||||
0,
|
||||
IfcSchema::IfcReinforcingBarRoleEnum::IfcReinforcingBarRoleEnum::IfcReinforcingBarRole_LIGATURE,
|
||||
IFC_REINFORCING_BAR_TYPE,
|
||||
IfcSchema::IfcReinforcingBarSurfaceEnum::IfcReinforcingBarSurfaceEnum::IfcReinforcingBarSurface_PLAIN //PLAIN or TEXTURED
|
||||
);
|
||||
|
||||
file.addBuildingProduct(rebar);
|
||||
rebar->setOwnerHistory(file.getSingle<IfcSchema::IfcOwnerHistory>());
|
||||
|
||||
IfcSchema::IfcCompositeCurveSegment::list::ptr segments(new IfcSchema::IfcCompositeCurveSegment::list());
|
||||
curve_segment_t::list::ptr segments(new curve_segment_t::list());
|
||||
|
||||
IfcSchema::IfcCartesianPoint* p1 = file.addTriplet<IfcSchema::IfcCartesianPoint>(0, 0, 1000.);
|
||||
IfcSchema::IfcCartesianPoint* p2 = file.addTriplet<IfcSchema::IfcCartesianPoint>(0, 0, 0);
|
||||
|
||||
+62
-19
@@ -27,14 +27,57 @@
|
||||
#include <iostream>
|
||||
#include <fstream>
|
||||
|
||||
#include "ifcparse/macros.h"
|
||||
|
||||
#ifndef IfcSchema
|
||||
#define IfcSchema Ifc2x3
|
||||
#include "ifcparse/Ifc2x3.h"
|
||||
#endif
|
||||
|
||||
#include INCLUDE_SCHEMA(ifcparse, IfcSchema)
|
||||
#include INCLUDE_SCHEMA_DEFINITIONS(ifcparse, IfcSchema)
|
||||
|
||||
#include "ifcparse/IfcHierarchyHelper.h"
|
||||
|
||||
typedef std::string S;
|
||||
typedef IfcParse::IfcGlobalId guid;
|
||||
boost::none_t const null = boost::none;
|
||||
|
||||
#ifdef SCHEMA_IfcUShapeProfileDef_HAS_CentreOfGravityInX
|
||||
#define IFC_U_SHAPE_PROFILE_DEF_EXTRA_ARGS , null
|
||||
#else
|
||||
#define IFC_U_SHAPE_PROFILE_DEF_EXTRA_ARGS
|
||||
#endif
|
||||
|
||||
#ifdef SCHEMA_IfcTShapeProfileDef_HAS_CentreOfGravityInY
|
||||
#define IFC_T_SHAPE_PROFILE_DEF_EXTRA_ARGS , null
|
||||
#else
|
||||
#define IFC_T_SHAPE_PROFILE_DEF_EXTRA_ARGS
|
||||
#endif
|
||||
|
||||
#ifdef SCHEMA_IfcIShapeProfileDef_HAS_FlangeEdgeRadius
|
||||
#define IFC_I_SHAPE_PROFILE_DEF_EXTRA_ARGS , null, null
|
||||
#else
|
||||
#define IFC_I_SHAPE_PROFILE_DEF_EXTRA_ARGS
|
||||
#endif
|
||||
|
||||
#ifdef SCHEMA_IfcAsymmetricIShapeProfileDef_HAS_BottomFlangeSlope
|
||||
#define IFC_ASYMMETRIC_I_SHAPE_PROFILE_DEF_EXTRA_ARGS , null, null, null
|
||||
#else
|
||||
#define IFC_ASYMMETRIC_I_SHAPE_PROFILE_DEF_EXTRA_ARGS
|
||||
#endif
|
||||
|
||||
#ifdef SCHEMA_IfcLShapeProfileDef_HAS_CentreOfGravityInX
|
||||
#define IFC_L_SHAPE_PROFILE_DEF_EXTRA_ARGS , null, null
|
||||
#else
|
||||
#define IFC_L_SHAPE_PROFILE_DEF_EXTRA_ARGS
|
||||
#endif
|
||||
|
||||
#ifdef SCHEMA_IfcCShapeProfileDef_HAS_CentreOfGravityInX
|
||||
#define IFC_C_SHAPE_PROFILE_DEF_EXTRA_ARGS , null
|
||||
#else
|
||||
#define IFC_C_SHAPE_PROFILE_DEF_EXTRA_ARGS
|
||||
#endif
|
||||
|
||||
void create_testcase_for(IfcSchema::IfcProfileDef::list::ptr profiles) {
|
||||
IfcSchema::IfcProfileDef* profile = *profiles->begin();
|
||||
const std::string& profile_type = profile->declaration().name();
|
||||
@@ -88,31 +131,31 @@ int main(int argc, char** argv) {
|
||||
{ IfcSchema::IfcProfileDef::list::ptr profiles (new IfcSchema::IfcProfileDef::list);
|
||||
profiles->push(new IfcSchema::IfcUShapeProfileDef(
|
||||
IfcSchema::IfcProfileTypeEnum::IfcProfileType_AREA,
|
||||
null, 0, 50.0, 25.0, 5.0, 5.0, null, null, null, null));
|
||||
null, 0, 50.0, 25.0, 5.0, 5.0, null, null, null IFC_U_SHAPE_PROFILE_DEF_EXTRA_ARGS));
|
||||
profiles->push(new IfcSchema::IfcUShapeProfileDef(
|
||||
IfcSchema::IfcProfileTypeEnum::IfcProfileType_AREA,
|
||||
null, 0, 50.0, 25.0, 5.0, 5.0, 2.0, 2.0, null, null));
|
||||
null, 0, 50.0, 25.0, 5.0, 5.0, 2.0, 2.0, null IFC_U_SHAPE_PROFILE_DEF_EXTRA_ARGS));
|
||||
profiles->push(new IfcSchema::IfcUShapeProfileDef(
|
||||
IfcSchema::IfcProfileTypeEnum::IfcProfileType_AREA,
|
||||
null, 0, 50.0, 25.0, 5.0, 5.0, null, null, 4.0, null));
|
||||
null, 0, 50.0, 25.0, 5.0, 5.0, null, null, 4.0 IFC_U_SHAPE_PROFILE_DEF_EXTRA_ARGS));
|
||||
profiles->push(new IfcSchema::IfcUShapeProfileDef(
|
||||
IfcSchema::IfcProfileTypeEnum::IfcProfileType_AREA,
|
||||
null, 0, 50.0, 25.0, 5.0, 5.0, 1.0, 3.0, 6.0, null));
|
||||
null, 0, 50.0, 25.0, 5.0, 5.0, 1.0, 3.0, 6.0 IFC_U_SHAPE_PROFILE_DEF_EXTRA_ARGS));
|
||||
create_testcase_for(profiles); }
|
||||
|
||||
{ IfcSchema::IfcProfileDef::list::ptr profiles (new IfcSchema::IfcProfileDef::list);
|
||||
profiles->push(new IfcSchema::IfcTShapeProfileDef(
|
||||
IfcSchema::IfcProfileTypeEnum::IfcProfileType_AREA,
|
||||
null, 0, 50.0, 25.0, 5.0, 5.0, null, null, null, null, null, null));
|
||||
null, 0, 50.0, 25.0, 5.0, 5.0, null, null, null, null, null IFC_T_SHAPE_PROFILE_DEF_EXTRA_ARGS));
|
||||
profiles->push(new IfcSchema::IfcTShapeProfileDef(
|
||||
IfcSchema::IfcProfileTypeEnum::IfcProfileType_AREA,
|
||||
null, 0, 50.0, 25.0, 5.0, 5.0, 2.0, 2.0, 2.0, null, null, null));
|
||||
null, 0, 50.0, 25.0, 5.0, 5.0, 2.0, 2.0, 2.0, null, null IFC_T_SHAPE_PROFILE_DEF_EXTRA_ARGS));
|
||||
profiles->push(new IfcSchema::IfcTShapeProfileDef(
|
||||
IfcSchema::IfcProfileTypeEnum::IfcProfileType_AREA,
|
||||
null, 0, 50.0, 25.0, 5.0, 5.0, null, null, null, 2.0, 2.0, null));
|
||||
null, 0, 50.0, 25.0, 5.0, 5.0, null, null, null, 2.0, 2.0 IFC_T_SHAPE_PROFILE_DEF_EXTRA_ARGS));
|
||||
profiles->push(new IfcSchema::IfcTShapeProfileDef(
|
||||
IfcSchema::IfcProfileTypeEnum::IfcProfileType_AREA,
|
||||
null, 0, 50.0, 25.0, 5.0, 5.0, 3.0, 2.0, 1.0, 2.0, 2.0, null));
|
||||
null, 0, 50.0, 25.0, 5.0, 5.0, 3.0, 2.0, 1.0, 2.0, 2.0 IFC_T_SHAPE_PROFILE_DEF_EXTRA_ARGS));
|
||||
create_testcase_for(profiles); }
|
||||
|
||||
{ IfcSchema::IfcProfileDef::list::ptr profiles (new IfcSchema::IfcProfileDef::list);
|
||||
@@ -133,40 +176,40 @@ int main(int argc, char** argv) {
|
||||
null, 0, 15.0, 25.0));
|
||||
create_testcase_for(profiles); }
|
||||
|
||||
{ IfcSchema::IfcProfileDef::list::ptr profiles (new IfcSchema::IfcProfileDef::list);
|
||||
{ IfcSchema::IfcProfileDef::list::ptr profiles (new IfcSchema::IfcProfileDef::list);
|
||||
profiles->push(new IfcSchema::IfcIShapeProfileDef(
|
||||
IfcSchema::IfcProfileTypeEnum::IfcProfileType_AREA,
|
||||
null, 0, 25.0, 50.0, 5.0, 5.0, null));
|
||||
null, 0, 25.0, 50.0, 5.0, 5.0, null IFC_I_SHAPE_PROFILE_DEF_EXTRA_ARGS));
|
||||
profiles->push(new IfcSchema::IfcIShapeProfileDef(
|
||||
IfcSchema::IfcProfileTypeEnum::IfcProfileType_AREA,
|
||||
null, 0, 25.0, 50.0, 5.0, 5.0, 2.0));
|
||||
null, 0, 25.0, 50.0, 5.0, 5.0, 2.0 IFC_I_SHAPE_PROFILE_DEF_EXTRA_ARGS));
|
||||
profiles->push(new IfcSchema::IfcAsymmetricIShapeProfileDef(
|
||||
IfcSchema::IfcProfileTypeEnum::IfcProfileType_AREA,
|
||||
null, 0, 25.0, 50.0, 5.0, 5.0, 2.0, 20.0, 10.0, 5.0, null));
|
||||
null, 0, 25.0, 50.0, 5.0, 5.0, 2.0, 20.0, 10.0, 5.0, null IFC_ASYMMETRIC_I_SHAPE_PROFILE_DEF_EXTRA_ARGS));
|
||||
create_testcase_for(profiles); }
|
||||
|
||||
{ IfcSchema::IfcProfileDef::list::ptr profiles (new IfcSchema::IfcProfileDef::list);
|
||||
profiles->push(new IfcSchema::IfcLShapeProfileDef(
|
||||
IfcSchema::IfcProfileTypeEnum::IfcProfileType_AREA,
|
||||
null, 0, 50.0, 25.0, 5.0, null, null, null, null, null));
|
||||
null, 0, 50.0, 25.0, 5.0, null, null, null IFC_L_SHAPE_PROFILE_DEF_EXTRA_ARGS));
|
||||
profiles->push(new IfcSchema::IfcLShapeProfileDef(
|
||||
IfcSchema::IfcProfileTypeEnum::IfcProfileType_AREA,
|
||||
null, 0, 50.0, 25.0, 5.0, 2.0, 2.0, null, null, null));
|
||||
null, 0, 50.0, 25.0, 5.0, 2.0, 2.0, null IFC_L_SHAPE_PROFILE_DEF_EXTRA_ARGS));
|
||||
profiles->push(new IfcSchema::IfcLShapeProfileDef(
|
||||
IfcSchema::IfcProfileTypeEnum::IfcProfileType_AREA,
|
||||
null, 0, 50.0, 25.0, 5.0, null, null, 2.0, null, null));
|
||||
null, 0, 50.0, 25.0, 5.0, null, null, 2.0 IFC_L_SHAPE_PROFILE_DEF_EXTRA_ARGS));
|
||||
profiles->push(new IfcSchema::IfcLShapeProfileDef(
|
||||
IfcSchema::IfcProfileTypeEnum::IfcProfileType_AREA,
|
||||
null, 0, 50.0, 25.0, 5.0, 1.0, 2.0, 2.0, null, null));
|
||||
null, 0, 50.0, 25.0, 5.0, 1.0, 2.0, 2.0 IFC_L_SHAPE_PROFILE_DEF_EXTRA_ARGS));
|
||||
create_testcase_for(profiles); }
|
||||
|
||||
{ IfcSchema::IfcProfileDef::list::ptr profiles (new IfcSchema::IfcProfileDef::list);
|
||||
profiles->push(new IfcSchema::IfcCShapeProfileDef(
|
||||
IfcSchema::IfcProfileTypeEnum::IfcProfileType_AREA,
|
||||
null, 0, 50.0, 25.0, 5.0, 10.0, null, null));
|
||||
null, 0, 50.0, 25.0, 5.0, 10.0, null IFC_C_SHAPE_PROFILE_DEF_EXTRA_ARGS));
|
||||
profiles->push(new IfcSchema::IfcCShapeProfileDef(
|
||||
IfcSchema::IfcProfileTypeEnum::IfcProfileType_AREA,
|
||||
null, 0, 50.0, 25.0, 5.0, 10.0, 2.0, null));
|
||||
null, 0, 50.0, 25.0, 5.0, 10.0, 2.0 IFC_C_SHAPE_PROFILE_DEF_EXTRA_ARGS));
|
||||
create_testcase_for(profiles); }
|
||||
|
||||
{ IfcSchema::IfcProfileDef::list::ptr profiles (new IfcSchema::IfcProfileDef::list);
|
||||
|
||||
@@ -27,8 +27,15 @@
|
||||
#include <fstream>
|
||||
#include <optional>
|
||||
|
||||
#include "ifcparse/macros.h"
|
||||
|
||||
#ifndef IfcSchema
|
||||
#define IfcSchema Ifc4
|
||||
#include "ifcparse/Ifc4.h"
|
||||
#endif
|
||||
|
||||
#include INCLUDE_SCHEMA(ifcparse, IfcSchema)
|
||||
#include INCLUDE_SCHEMA_DEFINITIONS(ifcparse, IfcSchema)
|
||||
|
||||
#include "ifcparse/IfcHierarchyHelper.h"
|
||||
|
||||
#include "suzanne_geometry.h"
|
||||
@@ -69,7 +76,11 @@ int main(int argc, char** argv) {
|
||||
std::vector< std::vector< double > > vertices_vector = create_vector_from_array(vertices, sizeof(vertices) / sizeof(vertices[0]));
|
||||
std::vector< std::vector< int > > indices_vector = create_vector_from_array(indices, sizeof(indices) / sizeof(indices[0]));
|
||||
|
||||
IfcSchema::IfcCartesianPointList3D* coordinates = new IfcSchema::IfcCartesianPointList3D(vertices_vector);
|
||||
IfcSchema::IfcCartesianPointList3D* coordinates = new IfcSchema::IfcCartesianPointList3D(vertices_vector
|
||||
#ifdef SCHEMA_IfcCartesianPointList3D_HAS_TagList
|
||||
, boost::none
|
||||
#endif
|
||||
);
|
||||
IfcSchema::IfcTriangulatedFaceSet* faceset = new IfcSchema::IfcTriangulatedFaceSet(coordinates, null, null, indices_vector, null);
|
||||
|
||||
items->push(faceset);
|
||||
|
||||
@@ -174,7 +174,7 @@ bool file_exists(const std::string& filename) {
|
||||
|
||||
static std::basic_stringstream<path_t::value_type> log_stream;
|
||||
void write_log(bool);
|
||||
void fix_quantities(IfcParse::IfcFile&, bool, bool, bool);
|
||||
void fix_quantities(IfcParse::IfcFile&, bool, bool, bool, Logger& logger = Logger::Root());
|
||||
std::string format_duration(time_t start, time_t end);
|
||||
|
||||
/// @todo make the filters non-global
|
||||
@@ -204,7 +204,7 @@ size_t read_filters_from_file(const std::string&, inclusion_filter&, inclusion_t
|
||||
void parse_filter(geom_filter &, const std::vector<std::string>&);
|
||||
std::vector<IfcGeom::filter_t> setup_filters(const std::vector<geom_filter>&, const std::string&);
|
||||
|
||||
bool init_input_file(const std::string& filename, IfcParse::IfcFile*& ifc_file, bool no_progress, bool mmap, bool bypass_properties=false);
|
||||
bool init_input_file(const std::string& filename, IfcParse::IfcFile*& ifc_file, bool no_progress, bool mmap, bool bypass_properties=false, Logger& logger = Logger::Root());
|
||||
|
||||
// from https://stackoverflow.com/questions/31696328/boost-program-options-using-zero-parameter-options-multiple-times
|
||||
struct verbosity_counter {
|
||||
@@ -226,6 +226,7 @@ int main(int argc, char** argv) {
|
||||
typedef po::command_line_parser command_line_parser;
|
||||
typedef char char_t;
|
||||
#endif
|
||||
Logger logger;
|
||||
|
||||
inclusion_filter include_filter;
|
||||
inclusion_traverse_filter include_traverse_filter;
|
||||
@@ -492,15 +493,15 @@ int main(int argc, char** argv) {
|
||||
|
||||
if (num_threads <= 0) {
|
||||
num_threads = std::thread::hardware_concurrency();
|
||||
Logger::Notice("Using " + std::to_string(num_threads) + " threads");
|
||||
logger.Notice("SYS", 7, "Using " + std::to_string(num_threads) + " threads");
|
||||
}
|
||||
|
||||
if (vmap.count("log-format") == 1) {
|
||||
boost::to_lower(log_format);
|
||||
if (log_format == "plain") {
|
||||
Logger::OutputFormat(Logger::FMT_PLAIN);
|
||||
logger.OutputFormat(Logger::FMT_PLAIN);
|
||||
} else if (log_format == "json") {
|
||||
Logger::OutputFormat(Logger::FMT_JSON);
|
||||
logger.OutputFormat(Logger::FMT_JSON);
|
||||
} else {
|
||||
cerr_ << "[Error] --log-format should be either plain or json" << std::endl;
|
||||
print_usage();
|
||||
@@ -511,7 +512,7 @@ int main(int argc, char** argv) {
|
||||
if (!filter_filename.empty()) {
|
||||
size_t num_filters = read_filters_from_file(IfcUtil::path::to_utf8(filter_filename), include_filter, include_traverse_filter, exclude_filter, exclude_traverse_filter);
|
||||
if (num_filters) {
|
||||
Logger::Notice(boost::lexical_cast<std::string>(num_filters) + " filters read from specifified file.");
|
||||
logger.Notice("SYS", 8, boost::lexical_cast<std::string>(num_filters) + " filters read from specifified file.");
|
||||
} else {
|
||||
cerr_ << "[Error] No filters read from specifified file.\n";
|
||||
return EXIT_FAILURE;
|
||||
@@ -601,27 +602,27 @@ int main(int argc, char** argv) {
|
||||
|
||||
if (vmap.count("log-file")) {
|
||||
log_fs.open(log_file.c_str(), std::ios::app);
|
||||
Logger::SetOutput(quiet ? nullptr : &cout_, &log_fs);
|
||||
logger.SetOutput(quiet ? nullptr : &cout_, &log_fs);
|
||||
} else {
|
||||
Logger::SetOutput(quiet ? nullptr : &cout_, vcounter.count > 1 ? &cout_ : &log_stream);
|
||||
logger.SetOutput(quiet ? nullptr : &cout_, vcounter.count > 1 ? &cout_ : &log_stream);
|
||||
}
|
||||
|
||||
switch (vcounter.count) {
|
||||
case 0:
|
||||
Logger::Verbosity(Logger::LOG_ERROR);
|
||||
logger.Verbosity(Logger::LOG_ERROR);
|
||||
break;
|
||||
case 1:
|
||||
Logger::Verbosity(Logger::LOG_NOTICE);
|
||||
logger.Verbosity(Logger::LOG_NOTICE);
|
||||
break;
|
||||
case 2:
|
||||
Logger::Verbosity(Logger::LOG_DEBUG);
|
||||
logger.Verbosity(Logger::LOG_DEBUG);
|
||||
break;
|
||||
case 3:
|
||||
Logger::Verbosity(Logger::LOG_PERF);
|
||||
logger.Verbosity(Logger::LOG_PERF);
|
||||
break;
|
||||
case 4:
|
||||
Logger::Verbosity(Logger::LOG_PERF);
|
||||
Logger::PrintPerformanceStatsOnElement(true);
|
||||
logger.Verbosity(Logger::LOG_PERF);
|
||||
logger.PrintPerformanceStatsOnElement(true);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -665,52 +666,52 @@ int main(int argc, char** argv) {
|
||||
if (output_extension == XML || output_extension == JSON) {
|
||||
int exit_code = EXIT_FAILURE;
|
||||
try {
|
||||
if (init_input_file(IfcUtil::path::to_utf8(input_filename), ifc_file, no_progress || quiet, mmap)) {
|
||||
if (init_input_file(IfcUtil::path::to_utf8(input_filename), ifc_file, no_progress || quiet, mmap, false, logger)) {
|
||||
time_t start, end;
|
||||
time(&start);
|
||||
if (output_extension == XML) {
|
||||
XmlSerializer s(ifc_file, IfcUtil::path::to_utf8(output_temp_filename));
|
||||
Logger::Status("Writing XML output...");
|
||||
XmlSerializer s(ifc_file, IfcUtil::path::to_utf8(output_temp_filename), logger);
|
||||
logger.Status("Writing XML output...");
|
||||
s.finalize();
|
||||
} else {
|
||||
#ifdef WITH_GLTF
|
||||
JsonSerializer s(ifc_file, IfcUtil::path::to_utf8(output_temp_filename), JsonSerializer::JSON_DIALECT_CREOOX);
|
||||
Logger::Status("Writing JSON output...");
|
||||
JsonSerializer s(ifc_file, IfcUtil::path::to_utf8(output_temp_filename), JsonSerializer::JSON_DIALECT_CREOOX, logger);
|
||||
logger.Status("Writing JSON output...");
|
||||
s.finalize();
|
||||
#endif
|
||||
}
|
||||
time(&end);
|
||||
Logger::Status("Done! Conversion took " + format_duration(start, end));
|
||||
logger.Status("Done! Conversion took " + format_duration(start, end));
|
||||
|
||||
IfcUtil::path::rename_file(IfcUtil::path::to_utf8(output_temp_filename), IfcUtil::path::to_utf8(output_filename));
|
||||
exit_code = EXIT_SUCCESS;
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
Logger::Error(e);
|
||||
logger.Error("SYS", 9, e);
|
||||
}
|
||||
write_log(!quiet);
|
||||
return exit_code;
|
||||
} else if (output_extension == IFC) {
|
||||
int exit_code = EXIT_FAILURE;
|
||||
try {
|
||||
if (init_input_file(IfcUtil::path::to_utf8(input_filename), ifc_file, no_progress || quiet, mmap)) {
|
||||
if (init_input_file(IfcUtil::path::to_utf8(input_filename), ifc_file, no_progress || quiet, mmap, false, logger)) {
|
||||
time_t start, end;
|
||||
time(&start);
|
||||
std::ofstream fs(output_filename.c_str());
|
||||
if (fs.is_open()) {
|
||||
if (vmap.count("calculate-quantities")) {
|
||||
fix_quantities(*ifc_file, no_progress, quiet, stderr_progress);
|
||||
fix_quantities(*ifc_file, no_progress, quiet, stderr_progress, logger);
|
||||
}
|
||||
fs << *ifc_file;
|
||||
exit_code = EXIT_SUCCESS;
|
||||
} else {
|
||||
Logger::Error("Unable to open output file for writing");
|
||||
logger.Error("SYS", 10, "Unable to open output file for writing");
|
||||
}
|
||||
time(&end);
|
||||
Logger::Status("Done! Writing IFC took " + format_duration(start, end));
|
||||
logger.Status("Done! Writing IFC took " + format_duration(start, end));
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
Logger::Error(e);
|
||||
logger.Error("SYS", 11, e);
|
||||
}
|
||||
write_log(!quiet);
|
||||
return exit_code;
|
||||
@@ -722,26 +723,26 @@ int main(int argc, char** argv) {
|
||||
if (vmap.count("stream")) {
|
||||
time_t start, end;
|
||||
time(&start);
|
||||
RocksDbSerializer s(IfcUtil::path::to_utf8(input_filename), IfcUtil::path::to_utf8(output_filename), true);
|
||||
Logger::Status("Populating RocksDB Key-Value store...");
|
||||
RocksDbSerializer s(IfcUtil::path::to_utf8(input_filename), IfcUtil::path::to_utf8(output_filename), true, logger);
|
||||
logger.Status("Populating RocksDB Key-Value store...");
|
||||
s.finalize();
|
||||
time(&end);
|
||||
Logger::Status("Done! Conversion took " + format_duration(start, end));
|
||||
logger.Status("Done! Conversion took " + format_duration(start, end));
|
||||
exit_code = EXIT_SUCCESS;
|
||||
} else {
|
||||
if (init_input_file(IfcUtil::path::to_utf8(input_filename), ifc_file, no_progress || quiet, mmap)) {
|
||||
if (init_input_file(IfcUtil::path::to_utf8(input_filename), ifc_file, no_progress || quiet, mmap, false, logger)) {
|
||||
time_t start, end;
|
||||
time(&start);
|
||||
RocksDbSerializer s(ifc_file, IfcUtil::path::to_utf8(output_filename));
|
||||
Logger::Status("Populating RocksDB Key-Value store...");
|
||||
RocksDbSerializer s(ifc_file, IfcUtil::path::to_utf8(output_filename), logger);
|
||||
logger.Status("Populating RocksDB Key-Value store...");
|
||||
s.finalize();
|
||||
time(&end);
|
||||
Logger::Status("Done! Conversion took " + format_duration(start, end));
|
||||
logger.Status("Done! Conversion took " + format_duration(start, end));
|
||||
exit_code = EXIT_SUCCESS;
|
||||
}
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
Logger::Error(e);
|
||||
logger.Error("SYS", 12, e);
|
||||
}
|
||||
write_log(!quiet);
|
||||
return exit_code;
|
||||
@@ -761,9 +762,9 @@ int main(int argc, char** argv) {
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
|
||||
if (!entity_filter.entity_names.empty()) { entity_filter.update_description(); Logger::Notice(entity_filter.description); }
|
||||
if (!layer_filter.values.empty()) { layer_filter.update_description(); Logger::Notice(layer_filter.description); }
|
||||
if (!attribute_filter.attribute_name.empty()) { attribute_filter.update_description(); Logger::Notice(attribute_filter.description); }
|
||||
if (!entity_filter.entity_names.empty()) { entity_filter.update_description(); logger.Notice("SYS", 13, entity_filter.description); }
|
||||
if (!layer_filter.values.empty()) { layer_filter.update_description(); logger.Notice("SYS", 14, layer_filter.description); }
|
||||
if (!attribute_filter.attribute_name.empty()) { attribute_filter.update_description(); logger.Notice("SYS", 15, attribute_filter.description); }
|
||||
|
||||
#ifdef _MSC_VER
|
||||
if (output_extension == DAE || output_extension == STP || output_extension == IGS) {
|
||||
@@ -828,39 +829,39 @@ int main(int argc, char** argv) {
|
||||
if (output_extension == OBJ) {
|
||||
// Do not use temp file for MTL as it's such a small file.
|
||||
const path_t mtl_filename = change_extension(output_filename, MTL);
|
||||
serializer = boost::make_shared<WaveFrontOBJSerializer>(IfcUtil::path::to_utf8(output_temp_filename), IfcUtil::path::to_utf8(mtl_filename), geometry_settings, serializer_settings);
|
||||
serializer = boost::make_shared<WaveFrontOBJSerializer>(IfcUtil::path::to_utf8(output_temp_filename), IfcUtil::path::to_utf8(mtl_filename), geometry_settings, serializer_settings, logger);
|
||||
#ifdef WITH_OPENCOLLADA
|
||||
} else if (output_extension == DAE) {
|
||||
serializer = boost::make_shared<ColladaSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings);
|
||||
serializer = boost::make_shared<ColladaSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings, logger);
|
||||
#endif
|
||||
#ifdef WITH_GLTF
|
||||
} else if (output_extension == GLB) {
|
||||
serializer = boost::make_shared<GltfSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings);
|
||||
serializer = boost::make_shared<GltfSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings, logger);
|
||||
#endif
|
||||
#ifdef WITH_USD
|
||||
} else if (output_extension == USD || output_extension == USDA || output_extension == USDC) {
|
||||
serializer = boost::make_shared<USDSerializer>(IfcUtil::path::to_utf8(output_filename), geometry_settings, serializer_settings);
|
||||
serializer = boost::make_shared<USDSerializer>(IfcUtil::path::to_utf8(output_filename), geometry_settings, serializer_settings, logger);
|
||||
#endif
|
||||
#ifdef IFOPSH_WITH_OPENCASCADE
|
||||
} else if (output_extension == STP) {
|
||||
serializer = boost::make_shared<StepSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings);
|
||||
serializer = boost::make_shared<StepSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings, logger);
|
||||
} else if (output_extension == IGS) {
|
||||
#if OCC_VERSION_HEX < 0x60900
|
||||
// According to https://tracker.dev.opencascade.org/view.php?id=25689 something has been fixed in 6.9.0
|
||||
IGESControl_Controller::Init(); // work around Open Cascade bug
|
||||
#endif
|
||||
serializer = boost::make_shared<IgesSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings);
|
||||
serializer = boost::make_shared<IgesSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings, logger);
|
||||
} else if (output_extension == SVG) {
|
||||
geometry_settings.get<ifcopenshell::geometry::settings::IteratorOutput>().value = ifcopenshell::geometry::settings::NATIVE;
|
||||
serializer = boost::make_shared<SvgSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings);
|
||||
serializer = boost::make_shared<SvgSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings, logger);
|
||||
#ifdef WITH_HDF5
|
||||
} else if (output_extension == HDF) {
|
||||
geometry_settings.get<ifcopenshell::geometry::settings::IteratorOutput>().value = ifcopenshell::geometry::settings::NATIVE;
|
||||
serializer = boost::make_shared<HdfSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings);
|
||||
serializer = boost::make_shared<HdfSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings, false, logger);
|
||||
#endif
|
||||
#endif
|
||||
} else if (output_extension == TTL) {
|
||||
serializer = boost::make_shared<TtlWktSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings);
|
||||
serializer = boost::make_shared<TtlWktSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings, logger);
|
||||
} else {
|
||||
cerr_ << "[Error] Unknown output filename extension '" << output_extension << "'\n";
|
||||
write_log(!quiet);
|
||||
@@ -871,13 +872,13 @@ int main(int argc, char** argv) {
|
||||
const bool is_tesselated = serializer->isTesselated(); // isTesselated() doesn't change at run-time
|
||||
if (!is_tesselated) {
|
||||
if (geometry_settings.get<ifcopenshell::geometry::settings::WeldVertices>().get()) {
|
||||
Logger::Notice("Weld vertices setting ignored when writing non-tesselated output");
|
||||
logger.Notice("SYS", 16, "Weld vertices setting ignored when writing non-tesselated output");
|
||||
}
|
||||
if (geometry_settings.get<ifcopenshell::geometry::settings::GenerateUvs>().get()) {
|
||||
Logger::Notice("Generate UVs setting ignored when writing non-tesselated output");
|
||||
logger.Notice("SYS", 17, "Generate UVs setting ignored when writing non-tesselated output");
|
||||
}
|
||||
if (center_model || center_model_geometry) {
|
||||
Logger::Notice("Centering/offsetting model setting ignored when writing non-tesselated output");
|
||||
logger.Notice("SYS", 18, "Centering/offsetting model setting ignored when writing non-tesselated output");
|
||||
}
|
||||
|
||||
geometry_settings.get<ifcopenshell::geometry::settings::IteratorOutput>().value = ifcopenshell::geometry::settings::NATIVE;
|
||||
@@ -895,7 +896,7 @@ int main(int argc, char** argv) {
|
||||
// @nb last argument true -> bypass_properties which are not read by any of the geometry serializers
|
||||
// XML, RocksDB, IFC are already special-cased above
|
||||
// SVG requires properties for IfcAnnotation/DRAWING properties
|
||||
if (!init_input_file(IfcUtil::path::to_utf8(input_filename), ifc_file, no_progress || quiet, mmap, output_extension != SVG)) {
|
||||
if (!init_input_file(IfcUtil::path::to_utf8(input_filename), ifc_file, no_progress || quiet, mmap, output_extension != SVG, logger)) {
|
||||
write_log(!quiet);
|
||||
serializer.reset();
|
||||
IfcUtil::path::delete_file(IfcUtil::path::to_utf8(output_temp_filename)); /**< @todo Windows Unicode support */
|
||||
@@ -903,9 +904,9 @@ int main(int argc, char** argv) {
|
||||
}
|
||||
|
||||
if (vmap.count("log-file")) {
|
||||
Logger::SetOutput(quiet ? nullptr : &cout_, &log_fs);
|
||||
logger.SetOutput(quiet ? nullptr : &cout_, &log_fs);
|
||||
} else {
|
||||
Logger::SetOutput(quiet ? nullptr : &cout_, vcounter.count > 1 ? &cout_ : &log_stream);
|
||||
logger.SetOutput(quiet ? nullptr : &cout_, vcounter.count > 1 ? &cout_ : &log_stream);
|
||||
}
|
||||
|
||||
if (model_rotation) {
|
||||
@@ -920,13 +921,13 @@ int main(int argc, char** argv) {
|
||||
|
||||
std::stringstream msg;
|
||||
msg << "Using model rotation (" << rotation[0] << "," << rotation[1] << "," << rotation[2] << "," << rotation[3] << ")";
|
||||
Logger::Notice(msg.str());
|
||||
logger.Notice("SYS", 19, msg.str());
|
||||
|
||||
geometry_settings.get<ifcopenshell::geometry::settings::ModelRotation>().value = rotation;
|
||||
}
|
||||
|
||||
if (model_offset && (center_model || center_model_geometry)) {
|
||||
Logger::Notice("--model-offset ignored with --center-model or --center-model-geometry");
|
||||
logger.Notice("GEO", 22, "--model-offset ignored with --center-model or --center-model-geometry");
|
||||
}
|
||||
|
||||
if (model_offset && !(center_model || center_model_geometry)) {
|
||||
@@ -941,7 +942,7 @@ int main(int argc, char** argv) {
|
||||
|
||||
std::stringstream msg;
|
||||
msg << std::setprecision(std::numeric_limits<double>::max_digits10) << "Using model offset (" << offset[0] << "," << offset[1] << "," << offset[2] << ")";
|
||||
Logger::Notice(msg.str());
|
||||
logger.Notice("SYS", 20, msg.str());
|
||||
|
||||
geometry_settings.get<ifcopenshell::geometry::settings::ModelOffset>().value = offset;
|
||||
}
|
||||
@@ -949,17 +950,17 @@ int main(int argc, char** argv) {
|
||||
if (is_tesselated && (center_model || center_model_geometry)) {
|
||||
std::vector<double> offset(3);
|
||||
|
||||
IfcGeom::Iterator tmp_context_iterator(ifcopenshell::geometry::kernels::construct(ifc_file, geometry_kernel, geometry_settings), geometry_settings, ifc_file, filter_funcs, num_threads);
|
||||
IfcGeom::Iterator tmp_context_iterator(ifcopenshell::geometry::kernels::construct(ifc_file, geometry_kernel, geometry_settings, logger), geometry_settings, ifc_file, filter_funcs, num_threads, logger);
|
||||
|
||||
time_t start, end;
|
||||
time(&start);
|
||||
if (!quiet) Logger::Status("Computing bounds...");
|
||||
if (!quiet) logger.Status("Computing bounds...");
|
||||
|
||||
if (center_model_geometry) {
|
||||
if (!tmp_context_iterator.initialize()) {
|
||||
/// @todo It would be nice to know and print separate error prints for a case where we found no entities
|
||||
/// and for a case we found no entities that satisfy our filtering criteria.
|
||||
Logger::Notice("No geometrical elements found or none successfully converted");
|
||||
logger.Notice("GEO", 23, "No geometrical elements found or none successfully converted");
|
||||
serializer.reset();
|
||||
IfcUtil::path::delete_file(IfcUtil::path::to_utf8(output_temp_filename));
|
||||
write_log(!quiet);
|
||||
@@ -970,7 +971,7 @@ int main(int argc, char** argv) {
|
||||
tmp_context_iterator.compute_bounds(center_model_geometry);
|
||||
|
||||
time(&end);
|
||||
if (!quiet) Logger::Status("Done ! Bounds computed in " + format_duration(start, end));
|
||||
if (!quiet) logger.Status("Done ! Bounds computed in " + format_duration(start, end));
|
||||
|
||||
auto center = (tmp_context_iterator.bounds_min().ccomponents() + tmp_context_iterator.bounds_max().ccomponents()) * 0.5;
|
||||
offset[0] = -center(0);
|
||||
@@ -979,7 +980,7 @@ int main(int argc, char** argv) {
|
||||
|
||||
std::stringstream msg;
|
||||
msg << std::setprecision (std::numeric_limits<double>::max_digits10) << "Using model offset (" << offset[0] << "," << offset[1] << "," << offset[2] << ")";
|
||||
Logger::Notice(msg.str());
|
||||
logger.Notice("SYS", 21, msg.str());
|
||||
|
||||
geometry_settings.get<ifcopenshell::geometry::settings::ModelOffset>().value = offset;
|
||||
}
|
||||
@@ -995,7 +996,7 @@ int main(int argc, char** argv) {
|
||||
|
||||
std::unique_ptr<IfcGeom::Iterator> context_iterator;
|
||||
if (!elems_from_adaptor) {
|
||||
context_iterator.reset(new IfcGeom::Iterator(ifcopenshell::geometry::kernels::construct(ifc_file, geometry_kernel, geometry_settings), geometry_settings, ifc_file, filter_funcs, num_threads));
|
||||
context_iterator.reset(new IfcGeom::Iterator(ifcopenshell::geometry::kernels::construct(ifc_file, geometry_kernel, geometry_settings, logger), geometry_settings, ifc_file, filter_funcs, num_threads, logger));
|
||||
}
|
||||
|
||||
#if defined(WITH_HDF5) && defined(IFOPSH_WITH_OPENCASCADE)
|
||||
@@ -1004,17 +1005,17 @@ int main(int argc, char** argv) {
|
||||
if (!vmap.count("cache-file")) {
|
||||
cache_file = input_filename + CACHE + HDF;
|
||||
}
|
||||
cache.reset(new HdfSerializer(IfcUtil::path::to_utf8(cache_file), geometry_settings, serializer_settings));
|
||||
cache.reset(new HdfSerializer(IfcUtil::path::to_utf8(cache_file), geometry_settings, serializer_settings, false, logger));
|
||||
context_iterator->set_cache(cache.get());
|
||||
}
|
||||
#endif
|
||||
|
||||
Logger::Message(Logger::LOG_PERF, "file geometry conversion");
|
||||
logger.Message(Logger::LOG_PERF, "GEO", 24, "file geometry conversion");
|
||||
|
||||
if (context_iterator && !context_iterator->initialize()) {
|
||||
/// @todo It would be nice to know and print separate error prints for a case where we found no entities
|
||||
/// and for a case we found no entities that satisfy our filtering criteria.
|
||||
Logger::Notice("No geometrical elements found or none successfully converted");
|
||||
logger.Notice("GEO", 25, "No geometrical elements found or none successfully converted");
|
||||
serializer.reset();
|
||||
IfcUtil::path::delete_file(IfcUtil::path::to_utf8(output_temp_filename));
|
||||
write_log(!quiet);
|
||||
@@ -1033,7 +1034,7 @@ int main(int argc, char** argv) {
|
||||
static_cast<SvgSerializer*>(serializer.get())->setSectionHeightsFromStoreys();
|
||||
}
|
||||
} else if (vmap.count("section-height") != 0) {
|
||||
Logger::Notice("Overriding section height");
|
||||
logger.Notice("SYS", 22, "Overriding section height");
|
||||
static_cast<SvgSerializer*>(serializer.get())->setSectionHeight(section_height);
|
||||
}
|
||||
if (vmap.count("print-space-names") != 0) {
|
||||
@@ -1111,7 +1112,7 @@ int main(int argc, char** argv) {
|
||||
int old_progress = quiet ? 0 : -1;
|
||||
|
||||
if (!quiet) {
|
||||
Logger::Status("Creating geometry...");
|
||||
logger.Status("Creating geometry...");
|
||||
}
|
||||
|
||||
// The functions IfcGeom::Iterator::get() and IfcGeom::Iterator::next()
|
||||
@@ -1156,10 +1157,10 @@ int main(int argc, char** argv) {
|
||||
if (stderr_progress)
|
||||
cerr_ << std::flush;
|
||||
} else if (vcounter.count == 2) {
|
||||
Logger::Message(Logger::LOG_DEBUG, "Progress " + boost::lexical_cast<std::string>(progress));
|
||||
logger.Message(Logger::LOG_DEBUG, "SYS", 23, "Progress " + boost::lexical_cast<std::string>(progress));
|
||||
} else {
|
||||
progress = progress / 2;
|
||||
if (old_progress != progress) Logger::ProgressBar(progress);
|
||||
if (old_progress != progress) logger.ProgressBar(progress);
|
||||
old_progress = progress;
|
||||
}
|
||||
}
|
||||
@@ -1188,7 +1189,7 @@ int main(int argc, char** argv) {
|
||||
}
|
||||
} else {
|
||||
const std::string task = ((num_threads == 1) ? "creating" : "writing");
|
||||
Logger::Status("\rDone " + task + " geometry (" + boost::lexical_cast<std::string>(num_created) +
|
||||
logger.Status("\rDone " + task + " geometry (" + boost::lexical_cast<std::string>(num_created) +
|
||||
" objects) ");
|
||||
}
|
||||
|
||||
@@ -1196,7 +1197,7 @@ int main(int argc, char** argv) {
|
||||
// Make sure the dtor is explicitly run here (e.g. output files are closed before renaming them).
|
||||
serializer.reset();
|
||||
|
||||
Logger::Message(Logger::LOG_PERF, "done file geometry conversion");
|
||||
logger.Message(Logger::LOG_PERF, "GEO", 26, "done file geometry conversion");
|
||||
|
||||
bool successful;
|
||||
if(output_extension == USD || output_extension == USDC || output_extension == USDA) {
|
||||
@@ -1214,13 +1215,13 @@ int main(int argc, char** argv) {
|
||||
output_temp_filename << "' for the conversion result.";
|
||||
}
|
||||
|
||||
if (geometry_settings.get<ifcopenshell::geometry::settings::ValidateQuantities>().get() && Logger::MaxSeverity() >= Logger::LOG_ERROR) {
|
||||
Logger::Error("Errors encountered during processing.");
|
||||
if (geometry_settings.get<ifcopenshell::geometry::settings::ValidateQuantities>().get() && logger.MaxSeverity() >= Logger::LOG_ERROR) {
|
||||
logger.Error("SYS", 24, "Errors encountered during processing.");
|
||||
successful = false;
|
||||
}
|
||||
|
||||
if (Logger::Verbosity() == Logger::LOG_PERF) {
|
||||
Logger::PrintPerformanceStats();
|
||||
if (logger.Verbosity() == Logger::LOG_PERF) {
|
||||
logger.PrintPerformanceStats();
|
||||
}
|
||||
|
||||
write_log(!quiet);
|
||||
@@ -1228,7 +1229,7 @@ int main(int argc, char** argv) {
|
||||
time(&end);
|
||||
|
||||
if (!quiet) {
|
||||
Logger::Status("\nConversion took " + format_duration(start, end));
|
||||
logger.Status("\nConversion took " + format_duration(start, end));
|
||||
}
|
||||
|
||||
return successful ? EXIT_SUCCESS : EXIT_FAILURE;
|
||||
@@ -1266,11 +1267,11 @@ void write_log(bool header) {
|
||||
|
||||
#include <boost/algorithm/string/predicate.hpp>
|
||||
|
||||
bool init_input_file(const std::string& filename, IfcParse::IfcFile*& ifc_file, bool no_progress, bool mmap, bool bypass_properties) {
|
||||
bool init_input_file(const std::string& filename, IfcParse::IfcFile*& ifc_file, bool no_progress, bool mmap, bool bypass_properties, Logger& logger) {
|
||||
time_t start, end;
|
||||
|
||||
// Prevent IfcFile::Init() prints by setting output to null temporarily
|
||||
if (no_progress) { Logger::SetOutput(NULL, &log_stream); }
|
||||
if (no_progress) { logger.SetOutput(NULL, &log_stream); }
|
||||
|
||||
time(&start);
|
||||
|
||||
@@ -1278,11 +1279,11 @@ bool init_input_file(const std::string& filename, IfcParse::IfcFile*& ifc_file,
|
||||
|
||||
#ifdef WITH_IFCXML
|
||||
if (boost::ends_with(boost::to_lower_copy(filename), ".ifcxml")) {
|
||||
ifc_file = IfcParse::parse_ifcxml(filename);
|
||||
ifc_file = IfcParse::parse_ifcxml(filename, logger);
|
||||
} else
|
||||
#endif
|
||||
{
|
||||
ifc_file = new IfcParse::IfcFile(IfcParse::uninitialized_tag{});
|
||||
ifc_file = new IfcParse::IfcFile(IfcParse::uninitialized_tag{}, logger);
|
||||
requires_init = true;
|
||||
}
|
||||
|
||||
@@ -1308,13 +1309,13 @@ bool init_input_file(const std::string& filename, IfcParse::IfcFile*& ifc_file,
|
||||
}
|
||||
|
||||
if (!ifc_file || !ifc_file->good()) {
|
||||
Logger::Error("Unable to parse input file '" + filename + "'");
|
||||
logger.Error("SYN", 1, "Unable to parse input file '" + filename + "'");
|
||||
return false;
|
||||
}
|
||||
time(&end);
|
||||
|
||||
if (no_progress) { Logger::SetOutput(&cout_, &log_stream); }
|
||||
else { Logger::Status("Parsing input file took " + format_duration(start, end)); }
|
||||
if (no_progress) { logger.SetOutput(&cout_, &log_stream); }
|
||||
else { logger.Status("Parsing input file took " + format_duration(start, end)); }
|
||||
|
||||
return true;
|
||||
|
||||
@@ -1533,7 +1534,7 @@ namespace latebound_access {
|
||||
}
|
||||
}
|
||||
|
||||
void fix_quantities(IfcParse::IfcFile& f, bool no_progress, bool quiet, bool stderr_progress) {
|
||||
void fix_quantities(IfcParse::IfcFile& f, bool no_progress, bool quiet, bool stderr_progress, Logger& logger) {
|
||||
{
|
||||
auto delete_reversed = [&f](const aggregate_of_instance::ptr& insts) {
|
||||
if (!insts) {
|
||||
@@ -1588,7 +1589,7 @@ void fix_quantities(IfcParse::IfcFile& f, bool no_progress, bool quiet, bool std
|
||||
settings.get<ifcopenshell::geometry::settings::ConvertBackUnits>().value = true;
|
||||
settings.get<ifcopenshell::geometry::settings::IteratorOutput>().value = ifcopenshell::geometry::settings::NATIVE;
|
||||
|
||||
IfcGeom::Iterator context_iterator(ifcopenshell::geometry::kernels::construct(&f, "opencascade", settings), settings, &f, {}, 1);
|
||||
IfcGeom::Iterator context_iterator(ifcopenshell::geometry::kernels::construct(&f, "opencascade", settings, logger), settings, &f, {}, 1, logger);
|
||||
|
||||
if (!context_iterator.initialize()) {
|
||||
return;
|
||||
@@ -1716,7 +1717,7 @@ void fix_quantities(IfcParse::IfcFile& f, bool no_progress, bool quiet, bool std
|
||||
cerr_ << std::flush;
|
||||
} else {
|
||||
const int progress = context_iterator.progress() / 2;
|
||||
if (old_progress != progress) Logger::ProgressBar(progress);
|
||||
if (old_progress != progress) logger.ProgressBar(progress);
|
||||
old_progress = progress;
|
||||
}
|
||||
}
|
||||
@@ -1732,7 +1733,7 @@ void fix_quantities(IfcParse::IfcFile& f, bool no_progress, bool quiet, bool std
|
||||
if (stderr_progress)
|
||||
cerr_ << std::flush;
|
||||
} else {
|
||||
Logger::Status("\rDone writing quantities for " + boost::lexical_cast<std::string>(num_created) +
|
||||
logger.Status("\rDone writing quantities for " + boost::lexical_cast<std::string>(num_created) +
|
||||
" objects ");
|
||||
}
|
||||
|
||||
|
||||
@@ -18,8 +18,8 @@ typedef CGAL::AABB_traits<Kernel_, Primitive> Traits;
|
||||
typedef CGAL::AABB_tree<Traits> Tree;
|
||||
typedef Tree::Point_and_primitive_id Point_and_primitive_id;
|
||||
|
||||
void fix_spaceboundaries(IfcParse::IfcFile& f, bool no_progress, bool quiet, bool stderr_progress) {
|
||||
intersection_validator v(f, { "IfcWall", "IfcSpace", "IfcSlab", "IfcCovering" }, 1.e-5, no_progress, quiet, stderr_progress);
|
||||
void fix_spaceboundaries(IfcParse::IfcFile& f, bool no_progress, bool quiet, bool stderr_progress, Logger& logger = Logger::Root()) {
|
||||
intersection_validator v(f, { "IfcWall", "IfcSpace", "IfcSlab", "IfcCovering" }, 1.e-5, no_progress, quiet, stderr_progress, logger);
|
||||
|
||||
auto rels = f.instances_by_type("IfcRelSpaceBoundary");
|
||||
|
||||
@@ -54,7 +54,7 @@ void fix_spaceboundaries(IfcParse::IfcFile& f, bool no_progress, bool quiet, boo
|
||||
settings.get<ifcopenshell::geometry::settings::IteratorOutput>().value = ifcopenshell::geometry::settings::NATIVE;
|
||||
settings.get<ifcopenshell::geometry::settings::DisableOpeningSubtractions>().value = true;
|
||||
|
||||
ifcopenshell::geometry::Converter c("cgal", &f2, settings);
|
||||
ifcopenshell::geometry::Converter c(ifcopenshell::geometry::kernels::construct(&f2, "cgal", settings, logger), &f2, settings, logger);
|
||||
|
||||
std::map<std::set<std::string>, std::vector<Kernel_::Point_3>> elem_to_space_boundary_coords;
|
||||
|
||||
@@ -81,7 +81,7 @@ void fix_spaceboundaries(IfcParse::IfcFile& f, bool no_progress, bool quiet, boo
|
||||
|
||||
std::set< std::set<std::string> > guid_pairs_visited;
|
||||
|
||||
v([&rel_by_space_elem, &elem_to_space_boundary_coords, &guid_pairs_visited](const intersection_validator::Box& a, const intersection_validator::Box& b) {
|
||||
v([&logger, &rel_by_space_elem, &elem_to_space_boundary_coords, &guid_pairs_visited](const intersection_validator::Box& a, const intersection_validator::Box& b) {
|
||||
std::ostringstream ss;
|
||||
// ss << id_map[a.id()]->first->data().toString() << "x" << id_map[b.id()]->first->data().toString() << std::endl;
|
||||
// auto x = id_map[a.id()]->second * id_map[b.id()]->second;
|
||||
@@ -128,7 +128,7 @@ void fix_spaceboundaries(IfcParse::IfcFile& f, bool no_progress, bool quiet, boo
|
||||
auto itelem = elem_to_space_boundary_coords.find({ Aguid, Bguid });
|
||||
|
||||
if (itelem == elem_to_space_boundary_coords.end()) {
|
||||
Logger::Error("Missing space boundary relationship " + Aguid + " " + Bguid);
|
||||
logger.Error("VAL", 1, "Missing space boundary relationship " + Aguid + " " + Bguid);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ void fix_spaceboundaries(IfcParse::IfcFile& f, bool no_progress, bool quiet, boo
|
||||
bool valid = *std::max_element(distances.begin(), distances.end()) < 0.4;
|
||||
|
||||
if (!valid) {
|
||||
Logger::Error("Wrong connection geometry " + Aguid + " " + Bguid);
|
||||
logger.Error("VAL", 2, "Wrong connection geometry " + Aguid + " " + Bguid);
|
||||
}
|
||||
|
||||
/*{
|
||||
@@ -178,7 +178,7 @@ void fix_spaceboundaries(IfcParse::IfcFile& f, bool no_progress, bool quiet, boo
|
||||
auto g1 = n.substr(0, 22);
|
||||
auto g2 = n.substr(23);
|
||||
if (is_wall_space_or_slab(g1) && is_wall_space_or_slab(g2) && guid_pairs_visited.find({ g1, g2 }) == guid_pairs_visited.end()) {
|
||||
Logger::Error("Space boundary for non-bounding geometry " + g1 + " " + g2);
|
||||
logger.Error("VAL", 3, "Space boundary for non-bounding geometry " + g1 + " " + g2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
void fix_storeycontainment(IfcParse::IfcFile& f, bool no_progress, bool quiet, bool stderr_progress) {
|
||||
void fix_storeycontainment(IfcParse::IfcFile& f, bool no_progress, bool quiet, bool stderr_progress, Logger& logger = Logger::Root()) {
|
||||
ifcopenshell::geometry::Settings settings;
|
||||
|
||||
settings.get<ifcopenshell::geometry::settings::UseWorldCoords>().value = false;
|
||||
@@ -23,7 +23,7 @@ void fix_storeycontainment(IfcParse::IfcFile& f, bool no_progress, bool quiet, b
|
||||
IfcGeom::entity_filter(false, false, {"IfcOpeningElement", "IfcSpace"})
|
||||
};
|
||||
|
||||
IfcGeom::Iterator context_iterator("cgal", settings, &f, no_openings_and_spaces, 1);
|
||||
IfcGeom::Iterator context_iterator(ifcopenshell::geometry::kernels::construct(&f, "cgal", settings, logger), settings, &f, no_openings_and_spaces, 1, logger);
|
||||
|
||||
auto get_elevation = [](const IfcUtil::IfcBaseClass* a) {
|
||||
return ((const IfcUtil::IfcBaseEntity*)a)->get_value<double>("Elevation", 0.);
|
||||
@@ -198,7 +198,7 @@ void fix_storeycontainment(IfcParse::IfcFile& f, bool no_progress, bool quiet, b
|
||||
auto s = geom_object->product()->get_value<std::string>("GlobalId");
|
||||
auto s1 = ((IfcUtil::IfcBaseEntity*)storeys_sorted[calc_idx])->get_value<std::string>("GlobalId");
|
||||
auto s2 = ((IfcUtil::IfcBaseEntity*)elem_to_storey[geom_object->product()])->get_value<std::string>("GlobalId");
|
||||
Logger::Error("Element " + s + " contained in " + s2 + " located on " + s1);
|
||||
logger.Error("VAL", 4, "Element " + s + " contained in " + s2 + " located on " + s1);
|
||||
}
|
||||
|
||||
if (!no_progress) {
|
||||
@@ -214,7 +214,7 @@ void fix_storeycontainment(IfcParse::IfcFile& f, bool no_progress, bool quiet, b
|
||||
std::cerr << std::flush;
|
||||
} else {
|
||||
const int progress = context_iterator.progress() / 2;
|
||||
if (old_progress != progress) Logger::ProgressBar(progress);
|
||||
if (old_progress != progress) logger.ProgressBar(progress);
|
||||
old_progress = progress;
|
||||
}
|
||||
}
|
||||
@@ -230,7 +230,7 @@ void fix_storeycontainment(IfcParse::IfcFile& f, bool no_progress, bool quiet, b
|
||||
if (stderr_progress)
|
||||
std::cerr << std::flush;
|
||||
} else {
|
||||
Logger::Status("\rDone fixing space boundaries for " + boost::lexical_cast<std::string>(num_created) +
|
||||
logger.Status("\rDone fixing space boundaries for " + boost::lexical_cast<std::string>(num_created) +
|
||||
" objects ");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
|
||||
using namespace ifcopenshell::geometry;
|
||||
|
||||
void fix_wallconnectivity(IfcParse::IfcFile& f, bool no_progress, bool quiet, bool stderr_progress) {
|
||||
intersection_validator v(f, { "IfcWall" }, 1.e-3, no_progress, quiet, stderr_progress);
|
||||
void fix_wallconnectivity(IfcParse::IfcFile& f, bool no_progress, bool quiet, bool stderr_progress, Logger& logger = Logger::Root()) {
|
||||
intersection_validator v(f, { "IfcWall" }, 1.e-3, no_progress, quiet, stderr_progress, logger);
|
||||
|
||||
ifcopenshell::geometry::Settings settings;
|
||||
|
||||
@@ -24,7 +24,7 @@ void fix_wallconnectivity(IfcParse::IfcFile& f, bool no_progress, bool quiet, bo
|
||||
settings.get<ifcopenshell::geometry::settings::IncludeCurves>().value = true;
|
||||
settings.get<ifcopenshell::geometry::settings::IncludeSurfaces>().value = false;
|
||||
|
||||
ifcopenshell::geometry::Converter c("cgal", &f, settings);
|
||||
ifcopenshell::geometry::Converter c(ifcopenshell::geometry::kernels::construct(&f, "cgal", settings, logger), &f, settings, logger);
|
||||
|
||||
auto rels = f.instances_by_type("IfcRelConnectsPathElements");
|
||||
std::map<std::set<const IfcUtil::IfcBaseClass*>, const IfcUtil::IfcBaseClass*> rel_by_elem;
|
||||
@@ -39,7 +39,7 @@ void fix_wallconnectivity(IfcParse::IfcFile& f, bool no_progress, bool quiet, bo
|
||||
double total_nef_intersection_time = 0.;
|
||||
double conversion_to_poly = 0.;
|
||||
|
||||
v([&c, &rel_by_elem, &rels_encounted, &total_nef_intersection_time, &conversion_to_poly](const intersection_validator::Box& a, const intersection_validator::Box& b) {
|
||||
v([&logger, &c, &rel_by_elem, &rels_encounted, &total_nef_intersection_time, &conversion_to_poly](const intersection_validator::Box& a, const intersection_validator::Box& b) {
|
||||
auto A = a.handle()->first;
|
||||
auto B = b.handle()->first;
|
||||
|
||||
@@ -169,21 +169,21 @@ void fix_wallconnectivity(IfcParse::IfcFile& f, bool no_progress, bool quiet, bo
|
||||
|
||||
if (a_type != atype_computed || b_type != btype_computed) {
|
||||
if (rel) {
|
||||
Logger::Error(std::string("Connection type ") + atype_computed + " " + btype_computed + " for:", rel);
|
||||
logger.Error("VAL", 5, std::string("Connection type ") + atype_computed + " " + btype_computed + " for:", rel);
|
||||
} else {
|
||||
auto A_str = A->get_value<std::string>("GlobalId");
|
||||
auto B_str = B->get_value<std::string>("GlobalId");
|
||||
Logger::Error("No connection for adjacent " + A_str + " " + B_str);
|
||||
logger.Error("VAL", 6, "No connection for adjacent " + A_str + " " + B_str);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
std::for_each(rels->begin(), rels->end(), [&rels_encounted, &v](const IfcUtil::IfcBaseClass* rel) {
|
||||
std::for_each(rels->begin(), rels->end(), [&logger, &rels_encounted, &v](const IfcUtil::IfcBaseClass* rel) {
|
||||
if (rels_encounted.find(rel) == rels_encounted.end()) {
|
||||
auto x = (IfcUtil::IfcBaseEntity*)((IfcUtil::IfcBaseEntity*)rel)->get_value<IfcUtil::IfcBaseClass*>("RelatingElement");
|
||||
auto y = (IfcUtil::IfcBaseEntity*)((IfcUtil::IfcBaseEntity*)rel)->get_value<IfcUtil::IfcBaseClass*>("RelatedElement");
|
||||
if (v.successfully_processed.find(x) != v.successfully_processed.end() && v.successfully_processed.find(y) != v.successfully_processed.end()) {
|
||||
Logger::Error("Connection for non-adjacent walls", rel);
|
||||
logger.Error("VAL", 7, "Connection for non-adjacent walls", rel);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "../ifcgeom/kernels/cgal/CgalKernel.h"
|
||||
#include "../ifcgeom/IfcGeomFilter.h"
|
||||
#include "../ifcgeom/Iterator.h"
|
||||
#include "../ifcgeom/hybrid_kernel.h"
|
||||
|
||||
#include <CGAL/box_intersection_d.h>
|
||||
#include <CGAL/minkowski_sum_3.h>
|
||||
@@ -445,7 +446,7 @@ struct intersection_validator {
|
||||
|
||||
std::set<const IfcUtil::IfcBaseEntity*> successfully_processed;
|
||||
|
||||
intersection_validator(IfcParse::IfcFile& f, std::initializer_list<std::string> entities, double eps, bool no_progress, bool quiet, bool stderr_progress) {
|
||||
intersection_validator(IfcParse::IfcFile& f, std::initializer_list<std::string> entities, double eps, bool no_progress, bool quiet, bool stderr_progress, Logger& logger = Logger::Root()) {
|
||||
|
||||
ifcopenshell::geometry::Settings settings;
|
||||
settings.get<ifcopenshell::geometry::settings::UseWorldCoords>().value = false;
|
||||
@@ -459,7 +460,7 @@ struct intersection_validator {
|
||||
IfcGeom::entity_filter(true, false, entities)
|
||||
};
|
||||
|
||||
IfcGeom::Iterator context_iterator("cgal", settings, &f, spaces_and_walls, 1);
|
||||
IfcGeom::Iterator context_iterator(ifcopenshell::geometry::kernels::construct(&f, "cgal", settings, logger), settings, &f, spaces_and_walls, 1, logger);
|
||||
|
||||
if (!context_iterator.initialize()) {
|
||||
return;
|
||||
@@ -562,7 +563,7 @@ struct intersection_validator {
|
||||
std::cerr << std::flush;
|
||||
} else {
|
||||
const int progress = context_iterator.progress() / 2;
|
||||
if (old_progress != progress) Logger::ProgressBar(progress);
|
||||
if (old_progress != progress) logger.ProgressBar(progress);
|
||||
old_progress = progress;
|
||||
}
|
||||
}
|
||||
@@ -578,7 +579,7 @@ struct intersection_validator {
|
||||
if (stderr_progress)
|
||||
std::cerr << std::flush;
|
||||
} else {
|
||||
Logger::Status("\rDone fixing space boundaries for " + boost::lexical_cast<std::string>(num_created) +
|
||||
logger.Status("\rDone fixing space boundaries for " + boost::lexical_cast<std::string>(num_created) +
|
||||
" objects ");
|
||||
}
|
||||
|
||||
@@ -599,4 +600,4 @@ struct intersection_validator {
|
||||
}
|
||||
};
|
||||
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -20,7 +20,7 @@ bool ifcopenshell::geometry::kernels::AbstractKernel::convert(const taxonomy::pt
|
||||
auto it = cache_.find(item);
|
||||
if (it != cache_.end()) {
|
||||
results = it->second;
|
||||
Logger::Notice("Cache hit #" + std::to_string(item->instance->as<IfcUtil::IfcBaseEntity>()->id()) +
|
||||
logger_.Notice("SYS", 25, "Cache hit #" + std::to_string(item->instance->as<IfcUtil::IfcBaseEntity>()->id()) +
|
||||
" -> #" + std::to_string(it->first->instance->as<IfcUtil::IfcBaseEntity>()->id()));
|
||||
return true;
|
||||
}
|
||||
@@ -30,7 +30,7 @@ bool ifcopenshell::geometry::kernels::AbstractKernel::convert(const taxonomy::pt
|
||||
try {
|
||||
return fn();
|
||||
} catch (std::exception& e) {
|
||||
Logger::Error(e, item->instance);
|
||||
logger_.Error("GEO", 27, e, item->instance);
|
||||
return false;
|
||||
} catch (...) {
|
||||
// @todo we can't log OCCT exceptions here, can we do some reraising to solve this?
|
||||
|
||||
@@ -64,13 +64,15 @@ namespace ifcopenshell {
|
||||
protected:
|
||||
std::string geometry_library_;
|
||||
Settings settings_;
|
||||
Logger& logger_;
|
||||
public:
|
||||
bool propagate_exceptions = false;
|
||||
bool partial_success_is_success = true;
|
||||
|
||||
AbstractKernel(const std::string& geometry_library, const Settings& settings)
|
||||
AbstractKernel(const std::string& geometry_library, const Settings& settings, Logger& logger = Logger::Root())
|
||||
: geometry_library_(geometry_library)
|
||||
, settings_(settings) {}
|
||||
, settings_(settings)
|
||||
, logger_(logger) {}
|
||||
|
||||
virtual ~AbstractKernel() = default;
|
||||
|
||||
@@ -79,6 +81,7 @@ namespace ifcopenshell {
|
||||
const std::string& geometry_library() const {
|
||||
return geometry_library_;
|
||||
}
|
||||
Logger& logger() const { return logger_; }
|
||||
|
||||
virtual bool supports_boolean_operations() const = 0;
|
||||
|
||||
@@ -126,7 +129,7 @@ namespace ifcopenshell {
|
||||
const IfcGeom::ConversionResults& entity_shapes, const ifcopenshell::geometry::taxonomy::matrix4& entity_trsf, IfcGeom::ConversionResults& cut_shapes) = 0;
|
||||
virtual bool unify_shapes(const IfcGeom::ConversionResults&, IfcGeom::ConversionResults&) { throw not_implemented_error(); }
|
||||
|
||||
virtual AbstractKernel* clone() const = 0;
|
||||
virtual AbstractKernel* clone(Logger& logger) const = 0;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -154,7 +157,7 @@ namespace {
|
||||
if (item->instance) {
|
||||
created_from = " (created from " + item->instance->declaration().name() + ")";
|
||||
}
|
||||
Logger::Error("No support for " + ifcopenshell::geometry::taxonomy::kind_to_string(item->kind()) + created_from + " in kernel " + kernel->geometry_library());
|
||||
kernel->logger().Error("UNS", 1, "No support for " + ifcopenshell::geometry::taxonomy::kind_to_string(item->kind()) + created_from + " in kernel " + kernel->geometry_library());
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -178,7 +181,7 @@ namespace {
|
||||
if (item->instance) {
|
||||
created_from = " (created from " + item->instance->declaration().name() + ")";
|
||||
}
|
||||
Logger::Error("No support (after considering item upgrade) for " + ifcopenshell::geometry::taxonomy::kind_to_string(item->kind()) + created_from + " in kernel " + kernel->geometry_library());
|
||||
kernel->logger().Error("UNS", 2, "No support (after considering item upgrade) for " + ifcopenshell::geometry::taxonomy::kind_to_string(item->kind()) + created_from + " in kernel " + kernel->geometry_library());
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -214,7 +217,7 @@ namespace {
|
||||
template <typename T>
|
||||
struct dispatch_curve_creation<T, ifcopenshell::geometry::taxonomy::curves::max> {
|
||||
static bool dispatch(const ifcopenshell::geometry::taxonomy::ptr& item, T&) {
|
||||
Logger::Error("No conversion for " + std::to_string(item->kind()));
|
||||
Logger::Root().Error("GEO", 28, "No conversion for " + std::to_string(item->kind()));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -236,10 +239,10 @@ namespace {
|
||||
template <typename T>
|
||||
struct dispatch_surface_creation<T, ifcopenshell::geometry::taxonomy::surfaces::max> {
|
||||
static bool dispatch(const ifcopenshell::geometry::taxonomy::ptr& item, T&) {
|
||||
Logger::Error("No conversion for " + std::to_string(item->kind()));
|
||||
Logger::Root().Error("GEO", 29, "No conversion for " + std::to_string(item->kind()));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
|
||||
#include <iomanip>
|
||||
|
||||
IfcGeom::Representation::Triangulation * IfcGeom::ConversionResultShape::Triangulate(const ifcopenshell::geometry::Settings& settings) const
|
||||
IfcGeom::Representation::Triangulation* IfcGeom::ConversionResultShape::Triangulate(const ifcopenshell::geometry::Settings& settings, Logger& logger) const
|
||||
{
|
||||
auto t = IfcGeom::Representation::Triangulation::empty(settings);
|
||||
static ifcopenshell::geometry::taxonomy::matrix4 iden;
|
||||
Triangulate(settings, iden, t, -1, -1);
|
||||
Triangulate(settings, iden, t, -1, -1, logger);
|
||||
return t;
|
||||
}
|
||||
|
||||
|
||||
@@ -253,8 +253,8 @@ namespace IfcGeom {
|
||||
|
||||
class IFC_GEOM_API ConversionResultShape {
|
||||
public:
|
||||
virtual void Triangulate(ifcopenshell::geometry::Settings settings, const ifcopenshell::geometry::taxonomy::matrix4& place, Representation::Triangulation* t, int item_id, int surface_style_id) const = 0;
|
||||
IfcGeom::Representation::Triangulation* Triangulate(const ifcopenshell::geometry::Settings& settings) const;
|
||||
virtual void Triangulate(ifcopenshell::geometry::Settings settings, const ifcopenshell::geometry::taxonomy::matrix4& place, Representation::Triangulation* t, int item_id, int surface_style_id, Logger& logger = Logger::Root()) const = 0;
|
||||
IfcGeom::Representation::Triangulation* Triangulate(const ifcopenshell::geometry::Settings& settings, Logger& logger = Logger::Root()) const;
|
||||
virtual void Serialize(const ifcopenshell::geometry::taxonomy::matrix4& place, std::string&) const = 0;
|
||||
|
||||
virtual int surface_genus() const = 0;
|
||||
|
||||
+11
-10
@@ -4,10 +4,11 @@
|
||||
|
||||
using namespace ifcopenshell::geometry;
|
||||
|
||||
ifcopenshell::geometry::Converter::Converter(std::unique_ptr<ifcopenshell::geometry::kernels::AbstractKernel>&& geometry_library, IfcParse::IfcFile* file, ifcopenshell::geometry::Settings& s)
|
||||
ifcopenshell::geometry::Converter::Converter(std::unique_ptr<ifcopenshell::geometry::kernels::AbstractKernel>&& geometry_library, IfcParse::IfcFile* file, ifcopenshell::geometry::Settings& s, Logger& logger)
|
||||
: kernel_(std::move(geometry_library))
|
||||
, logger_(logger)
|
||||
{
|
||||
mapping_ = impl::mapping_implementations().construct(file, s);
|
||||
mapping_ = impl::mapping_implementations().construct(file, s, logger_);
|
||||
// Mapping reads unit information and applies to settings
|
||||
settings_ = mapping_->settings();
|
||||
}
|
||||
@@ -17,7 +18,7 @@ ifcopenshell::geometry::Converter::~Converter() {
|
||||
}
|
||||
|
||||
namespace {
|
||||
void substitute_with_box_based_on_density(IfcGeom::ConversionResults& items, double& density) {
|
||||
void substitute_with_box_based_on_density(Logger& logger, IfcGeom::ConversionResults& items, double& density) {
|
||||
int nv = 0;
|
||||
void* box = nullptr;
|
||||
double volume = 0.;
|
||||
@@ -29,7 +30,7 @@ namespace {
|
||||
if (density > 1e5) {
|
||||
items[0].Shape()->set_box(box);
|
||||
items.erase(items.begin() + 1, items.end());
|
||||
Logger::Notice("Substituted element with " + boost::lexical_cast<std::string>(density) + " vertices / m3 with a bounding box");
|
||||
logger.Notice("GEO", 30, "Substituted element with " + boost::lexical_cast<std::string>(density) + " vertices / m3 with a bounding box");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,7 +139,7 @@ IfcGeom::BRepElement* ifcopenshell::geometry::Converter::create_brep_for_represe
|
||||
}
|
||||
}
|
||||
if (some_items_without_style) {
|
||||
Logger::Warning("No material and surface styles for:", product);
|
||||
logger_.Warning("GEO", 31, "No material and surface styles for:", product);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +163,7 @@ IfcGeom::BRepElement* ifcopenshell::geometry::Converter::create_brep_for_represe
|
||||
parent_id = parent_object->id();
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
Logger::Error(e);
|
||||
logger_.Error("GEO", 32, e);
|
||||
}
|
||||
|
||||
const std::string name = product->get_value<std::string>("Name", "");
|
||||
@@ -208,10 +209,10 @@ IfcGeom::BRepElement* ifcopenshell::geometry::Converter::create_brep_for_represe
|
||||
kernel_->convert_openings(product, opening_items, shapes, *place, opened_shapes);
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
Logger::Message(Logger::LOG_ERROR, std::string("Error processing openings for: ") + e.what() + ":", product);
|
||||
logger_.Message(Logger::LOG_ERROR, "GEO", 33, std::string("Error processing openings for: ") + e.what() + ":", product);
|
||||
caught_error = true;
|
||||
} catch (...) {
|
||||
Logger::Message(Logger::LOG_ERROR, "Error processing openings for:", product);
|
||||
logger_.Message(Logger::LOG_ERROR, "GEO", 34, "Error processing openings for:", product);
|
||||
}
|
||||
|
||||
if (!(caught_error && opened_shapes.size() < shapes.size())) {
|
||||
@@ -239,7 +240,7 @@ IfcGeom::BRepElement* ifcopenshell::geometry::Converter::create_brep_for_represe
|
||||
std::swap(shapes, unified_shapes);
|
||||
}
|
||||
} catch (std::exception& e) {
|
||||
Logger::Error(e);
|
||||
logger_.Error("GEO", 35, e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,7 +358,7 @@ IfcGeom::BRepElement* ifcopenshell::geometry::Converter::create_brep_for_process
|
||||
parent_id = parent_object->id();
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
Logger::Error(e);
|
||||
logger_.Error("GEO", 36, e);
|
||||
}
|
||||
|
||||
const std::string guid = product->get_value<std::string>("GlobalId");
|
||||
|
||||
@@ -21,15 +21,17 @@ namespace ifcopenshell { namespace geometry {
|
||||
std::unique_ptr<ifcopenshell::geometry::kernels::AbstractKernel> kernel_;
|
||||
ifcopenshell::geometry::Settings settings_;
|
||||
std::map<ifcopenshell::geometry::taxonomy::ptr, brep_ptr, ifcopenshell::geometry::taxonomy::less_functor> cache_;
|
||||
Logger& logger_;
|
||||
|
||||
public:
|
||||
ifcopenshell::geometry::kernels::AbstractKernel* kernel() { return &*kernel_; }
|
||||
|
||||
Converter(std::unique_ptr<ifcopenshell::geometry::kernels::AbstractKernel>&& geometry_library, IfcParse::IfcFile* file, ifcopenshell::geometry::Settings& settings);
|
||||
Converter(std::unique_ptr<ifcopenshell::geometry::kernels::AbstractKernel>&& geometry_library, IfcParse::IfcFile* file, ifcopenshell::geometry::Settings& settings, Logger& logger = Logger::Root());
|
||||
|
||||
~Converter();
|
||||
|
||||
ifcopenshell::geometry::abstract_mapping* mapping() const { return mapping_; }
|
||||
Logger& logger() const { return logger_; }
|
||||
|
||||
/*
|
||||
virtual NativeElement<double, double>* convert(
|
||||
@@ -55,4 +57,4 @@ namespace ifcopenshell { namespace geometry {
|
||||
};
|
||||
}}
|
||||
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -143,8 +143,9 @@ class GeometrySerializer : public Serializer {
|
||||
public:
|
||||
enum read_type { READ_BREP, READ_TRIANGULATION };
|
||||
|
||||
GeometrySerializer(const ifcopenshell::geometry::Settings& geometry_settings, const ifcopenshell::geometry::SerializerSettings& settings)
|
||||
: geometry_settings_(geometry_settings)
|
||||
GeometrySerializer(const ifcopenshell::geometry::Settings& geometry_settings, const ifcopenshell::geometry::SerializerSettings& settings, Logger& logger = Logger::Root())
|
||||
: Serializer(logger)
|
||||
, geometry_settings_(geometry_settings)
|
||||
, settings_(settings)
|
||||
{}
|
||||
virtual ~GeometrySerializer() {}
|
||||
@@ -177,7 +178,7 @@ protected:
|
||||
|
||||
class WriteOnlyGeometrySerializer : public GeometrySerializer {
|
||||
public:
|
||||
WriteOnlyGeometrySerializer(const ifcopenshell::geometry::Settings& geometry_settings, const ifcopenshell::geometry::SerializerSettings& settings) : GeometrySerializer(geometry_settings, settings) {}
|
||||
WriteOnlyGeometrySerializer(const ifcopenshell::geometry::Settings& geometry_settings, const ifcopenshell::geometry::SerializerSettings& settings, Logger& logger = Logger::Root()) : GeometrySerializer(geometry_settings, settings, logger) {}
|
||||
|
||||
virtual IfcGeom::Element* read(IfcParse::IfcFile&, const std::string&, const std::string&, read_type = READ_BREP) {
|
||||
throw std::runtime_error("Not supported");
|
||||
|
||||
@@ -125,7 +125,7 @@ namespace IfcGeom {
|
||||
oss << "product-" << IfcParse::IfcGlobalId(guid).formatted();
|
||||
} catch (const std::exception& e) {
|
||||
oss << "product";
|
||||
Logger::Error(e);
|
||||
Logger::Root().Error("GEO", 39, e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+57
-34
@@ -31,7 +31,7 @@ bool IfcGeom::Iterator::initialize() {
|
||||
try {
|
||||
converter_->mapping()->get_representations(reps, filters_);
|
||||
} catch (const std::exception& e) {
|
||||
Logger::Error(e);
|
||||
logger_.Error("GEO", 50, e);
|
||||
}
|
||||
time_points[1] = high_resolution_clock::now();
|
||||
|
||||
@@ -94,7 +94,7 @@ bool IfcGeom::Iterator::initialize() {
|
||||
tasks_.back().item = p.first;
|
||||
tasks_.back().products = p.second;
|
||||
}
|
||||
Logger::Notice("Merged " + std::to_string(old_size) + " tasks into " + std::to_string(tasks_.size()) + " tasks due to permissive shape reuse");
|
||||
logger_.Notice("SYS", 26, "Merged " + std::to_string(old_size) + " tasks into " + std::to_string(tasks_.size()) + " tasks due to permissive shape reuse");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,10 +139,10 @@ bool IfcGeom::Iterator::initialize() {
|
||||
}
|
||||
*/
|
||||
|
||||
Logger::Notice("Created " + boost::lexical_cast<std::string>(tasks_.size()) + " tasks for " + boost::lexical_cast<std::string>(num_products) + " products");
|
||||
logger_.Notice("SYS", 27, "Created " + boost::lexical_cast<std::string>(tasks_.size()) + " tasks for " + boost::lexical_cast<std::string>(num_products) + " products");
|
||||
|
||||
if (tasks_.size() == 0) {
|
||||
Logger::Warning("No representations encountered, aborting");
|
||||
logger_.Warning("GEO", 51, "No representations encountered, aborting");
|
||||
initialization_outcome_.reset(false);
|
||||
} else if (!settings_.get<ifcopenshell::geometry::settings::DeferProcessingFirstElement>().get()) {
|
||||
|
||||
@@ -167,7 +167,15 @@ bool IfcGeom::Iterator::initialize() {
|
||||
return *initialization_outcome_;
|
||||
}
|
||||
|
||||
void IfcGeom::Iterator::process_finished_rep(geometry_conversion_result* rep) {
|
||||
void IfcGeom::Iterator::flush_worker_log(ifcopenshell::geometry::Converter* kernel) {
|
||||
if (kernel && &kernel->logger() != &logger_) {
|
||||
logger_.Append(kernel->logger());
|
||||
}
|
||||
}
|
||||
|
||||
void IfcGeom::Iterator::process_finished_rep(geometry_conversion_result* rep, ifcopenshell::geometry::Converter* kernel) {
|
||||
flush_worker_log(kernel);
|
||||
|
||||
if (rep->elements.empty()) {
|
||||
return;
|
||||
}
|
||||
@@ -193,8 +201,17 @@ void IfcGeom::Iterator::process_concurrently() {
|
||||
}
|
||||
|
||||
kernel_pool.reserve(conc_threads);
|
||||
worker_loggers_.reserve(conc_threads);
|
||||
for (unsigned i = 0; i < conc_threads; ++i) {
|
||||
kernel_pool.push_back(new ifcopenshell::geometry::Converter(std::unique_ptr<ifcopenshell::geometry::kernels::AbstractKernel>(converter_->kernel()->clone()), ifc_file, settings_));
|
||||
worker_loggers_.emplace_back(std::make_unique<Logger>());
|
||||
Logger& worker_logger = *worker_loggers_.back();
|
||||
worker_logger.Verbosity(logger_.Verbosity());
|
||||
worker_logger.OutputFormat(logger_.OutputFormat());
|
||||
worker_logger.PrintPerformanceStatsOnElement(logger_.PrintPerformanceStatsOnElement());
|
||||
if (worker_logger.OutputFormat() != Logger::FMT_INMEMORY) {
|
||||
worker_logger.SetOutput(static_cast<std::ostream*>(nullptr), static_cast<std::ostream*>(nullptr));
|
||||
}
|
||||
kernel_pool.push_back(new ifcopenshell::geometry::Converter(std::unique_ptr<ifcopenshell::geometry::kernels::AbstractKernel>(converter_->kernel()->clone(worker_logger)), ifc_file, settings_, worker_logger));
|
||||
}
|
||||
|
||||
std::vector<std::future<geometry_conversion_result*>> threadpool;
|
||||
@@ -211,11 +228,12 @@ void IfcGeom::Iterator::process_concurrently() {
|
||||
std::future_status status;
|
||||
status = fu.wait_for(std::chrono::seconds(0));
|
||||
if (status == std::future_status::ready) {
|
||||
process_finished_rep(fu.get());
|
||||
process_finished_rep(fu.get(), kernel_pool[i]);
|
||||
|
||||
std::swap(threadpool[i], threadpool.back());
|
||||
threadpool.pop_back();
|
||||
std::swap(kernel_pool[i], kernel_pool.back());
|
||||
std::swap(worker_loggers_[i], worker_loggers_.back());
|
||||
K = kernel_pool.back();
|
||||
break;
|
||||
} // if
|
||||
@@ -231,14 +249,14 @@ void IfcGeom::Iterator::process_concurrently() {
|
||||
try {
|
||||
this->create_element_(kernel, settings, rep);
|
||||
} catch (const std::exception& e) {
|
||||
Logger::Error(
|
||||
kernel->logger().Error("GEO", 52,
|
||||
std::string("Exception '") + e.what() +
|
||||
std::string("' occurred while iterator was creating a shape: "),
|
||||
rep->item->instance
|
||||
);
|
||||
had_error_processing_elements_ = true;
|
||||
} catch (...) {
|
||||
Logger::Error(
|
||||
kernel->logger().Error("GEO", 53,
|
||||
"Unknown exception occurred while iteartor was creating a shape: ",
|
||||
rep->item->instance
|
||||
);
|
||||
@@ -257,16 +275,16 @@ void IfcGeom::Iterator::process_concurrently() {
|
||||
threadpool.emplace_back(std::move(fu));
|
||||
}
|
||||
|
||||
for (auto& fu : threadpool) {
|
||||
process_finished_rep(fu.get());
|
||||
for (size_t i = 0; i < threadpool.size(); ++i) {
|
||||
process_finished_rep(threadpool[i].get(), kernel_pool[i]);
|
||||
}
|
||||
|
||||
finished_ = true;
|
||||
|
||||
Logger::SetProduct(boost::none);
|
||||
logger_.SetProduct(boost::none);
|
||||
|
||||
if (!terminating_) {
|
||||
Logger::Status("\rDone creating geometry (" + boost::lexical_cast<std::string>(all_processed_elements_.size()) +
|
||||
logger_.Status("\rDone creating geometry (" + boost::lexical_cast<std::string>(all_processed_elements_.size()) +
|
||||
" objects) ");
|
||||
}
|
||||
}
|
||||
@@ -344,6 +362,8 @@ const IfcUtil::IfcBaseClass* IfcGeom::Iterator::create_shape_model_for_next_enti
|
||||
|
||||
void IfcGeom::Iterator::create_element_(ifcopenshell::geometry::Converter* kernel, ifcopenshell::geometry::Settings settings, geometry_conversion_result* rep)
|
||||
{
|
||||
Logger& kernel_logger = kernel->logger();
|
||||
|
||||
if (!settings_.get<ifcopenshell::geometry::settings::NoParallelMapping>().get()) {
|
||||
rep->item = kernel->mapping()->map(rep->representation);
|
||||
if (!rep->item) {
|
||||
@@ -360,20 +380,20 @@ void IfcGeom::Iterator::create_element_(ifcopenshell::geometry::Converter* kerne
|
||||
const IfcUtil::IfcBaseEntity* product = product_node.first;
|
||||
const auto& place = product_node.second;
|
||||
|
||||
Logger::SetProduct(product);
|
||||
kernel_logger.SetProduct(product);
|
||||
|
||||
IfcGeom::BRepElement* brep = static_cast<IfcGeom::BRepElement*>(decorate_with_cache_(GeometrySerializer::READ_BREP, (std::string)product->get("GlobalId"), std::to_string(rep->item->instance->as<IfcUtil::IfcBaseEntity>()->id()), [kernel, settings, product, place, rep]() {
|
||||
return kernel->create_brep_for_representation_and_product(rep->item, product, place);
|
||||
}));
|
||||
|
||||
if (!brep) {
|
||||
Logger::SetProduct(boost::none);
|
||||
kernel_logger.SetProduct(boost::none);
|
||||
return;
|
||||
}
|
||||
|
||||
auto elem = process_based_on_settings(settings, brep);
|
||||
auto elem = process_based_on_settings(settings, brep, kernel_logger);
|
||||
if (!elem) {
|
||||
Logger::SetProduct(boost::none);
|
||||
kernel_logger.SetProduct(boost::none);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -385,11 +405,13 @@ void IfcGeom::Iterator::create_element_(ifcopenshell::geometry::Converter* kerne
|
||||
const IfcUtil::IfcBaseEntity* product2 = p.first;
|
||||
const auto& place2 = p.second;
|
||||
|
||||
kernel_logger.SetProduct(product2);
|
||||
|
||||
IfcGeom::BRepElement* brep2 = static_cast<IfcGeom::BRepElement*>(decorate_with_cache_(GeometrySerializer::READ_BREP, (std::string)product2->get("GlobalId"), std::to_string(rep->item->instance->as<IfcUtil::IfcBaseEntity>()->id()), [kernel, settings, product2, place2, brep]() {
|
||||
return kernel->create_brep_for_processed_representation(product2, place2, brep);
|
||||
}));
|
||||
if (brep2) {
|
||||
auto elem2 = process_based_on_settings(settings, brep2, dynamic_cast<IfcGeom::TriangulationElement*>(elem));
|
||||
auto elem2 = process_based_on_settings(settings, brep2, kernel_logger, dynamic_cast<IfcGeom::TriangulationElement*>(elem));
|
||||
if (elem2) {
|
||||
rep->breps.push_back(brep2);
|
||||
rep->elements.push_back(elem2);
|
||||
@@ -397,16 +419,16 @@ void IfcGeom::Iterator::create_element_(ifcopenshell::geometry::Converter* kerne
|
||||
}
|
||||
}
|
||||
|
||||
Logger::SetProduct(boost::none);
|
||||
kernel_logger.SetProduct(boost::none);
|
||||
}
|
||||
|
||||
IfcGeom::Element* IfcGeom::Iterator::process_based_on_settings(ifcopenshell::geometry::Settings settings, IfcGeom::BRepElement* elem, IfcGeom::TriangulationElement* previous)
|
||||
IfcGeom::Element* IfcGeom::Iterator::process_based_on_settings(ifcopenshell::geometry::Settings settings, IfcGeom::BRepElement* elem, Logger& logger, IfcGeom::TriangulationElement* previous)
|
||||
{
|
||||
if (settings.get<ifcopenshell::geometry::settings::IteratorOutput>().get() == ifcopenshell::geometry::settings::SERIALIZED) {
|
||||
try {
|
||||
return new IfcGeom::SerializedElement(*elem);
|
||||
} catch (...) {
|
||||
Logger::Message(Logger::LOG_ERROR, "Getting a serialized element from model failed.");
|
||||
logger.Message(Logger::LOG_ERROR, "GEO", 54, "Getting a serialized element from model failed.");
|
||||
return nullptr;
|
||||
}
|
||||
} else if (settings.get<ifcopenshell::geometry::settings::IteratorOutput>().get() == ifcopenshell::geometry::settings::TRIANGULATED) {
|
||||
@@ -417,7 +439,7 @@ IfcGeom::Element* IfcGeom::Iterator::process_based_on_settings(ifcopenshell::geo
|
||||
gid2 = gid2.substr(0, hyphen);
|
||||
}
|
||||
|
||||
return decorate_with_cache_(GeometrySerializer::READ_TRIANGULATION, elem->guid(), gid2, [elem, previous]() {
|
||||
return decorate_with_cache_(GeometrySerializer::READ_TRIANGULATION, elem->guid(), gid2, [&logger, elem, previous]() {
|
||||
try {
|
||||
if (!previous) {
|
||||
return new TriangulationElement(*elem);
|
||||
@@ -425,7 +447,7 @@ IfcGeom::Element* IfcGeom::Iterator::process_based_on_settings(ifcopenshell::geo
|
||||
return new TriangulationElement(*elem, previous->geometry_pointer());
|
||||
}
|
||||
} catch (...) {
|
||||
Logger::Message(Logger::LOG_ERROR, "Getting a triangulation element from model failed.");
|
||||
logger.Message(Logger::LOG_ERROR, "GEO", 55, "Getting a triangulation element from model failed.");
|
||||
}
|
||||
return (TriangulationElement*)nullptr;
|
||||
});
|
||||
@@ -466,7 +488,7 @@ void IfcGeom::Iterator::log_timepoints() const {
|
||||
for (auto it = time_points.begin() + 1; it != time_points.end(); ++it) {
|
||||
auto jt = it - 1;
|
||||
duration<double, std::milli> ms_double = (*it) - (*jt);
|
||||
Logger::Notice(labels[std::distance(time_points.begin(), jt)] + " took " + std::to_string(ms_double.count()) + "ms");
|
||||
logger_.Notice("SYS", 28, labels[std::distance(time_points.begin(), jt)] + " took " + std::to_string(ms_double.count()) + "ms");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -501,7 +523,7 @@ const IfcUtil::IfcBaseClass* IfcGeom::Iterator::next() {
|
||||
|
||||
if (num_threads_ != 1) {
|
||||
if (!wait_for_element()) {
|
||||
Logger::SetProduct(boost::none);
|
||||
logger_.SetProduct(boost::none);
|
||||
time_points[3] = high_resolution_clock::now();
|
||||
log_timepoints();
|
||||
task_result_ptr_exhausted = true;
|
||||
@@ -517,7 +539,7 @@ const IfcUtil::IfcBaseClass* IfcGeom::Iterator::next() {
|
||||
// shape representation
|
||||
if (task_result_iterator_ == --all_processed_elements_.end()) {
|
||||
if (!create()) {
|
||||
Logger::SetProduct(boost::none);
|
||||
logger_.SetProduct(boost::none);
|
||||
time_points[3] = high_resolution_clock::now();
|
||||
log_timepoints();
|
||||
task_result_ptr_exhausted = true;
|
||||
@@ -554,7 +576,7 @@ IfcGeom::Element* IfcGeom::Iterator::get()
|
||||
try {
|
||||
parent_object = get_object(ret->parent_id());
|
||||
} catch (const std::exception& e) {
|
||||
Logger::Error(e);
|
||||
logger_.Error("GEO", 56, e);
|
||||
hasParent = false;
|
||||
}
|
||||
|
||||
@@ -572,7 +594,7 @@ IfcGeom::Element* IfcGeom::Iterator::get()
|
||||
try {
|
||||
parent_object = get_object(pid);
|
||||
} catch (const std::exception& e) {
|
||||
Logger::Error(e);
|
||||
logger_.Error("GEO", 57, e);
|
||||
hasParent = false;
|
||||
}
|
||||
}
|
||||
@@ -619,9 +641,9 @@ const IfcGeom::Element* IfcGeom::Iterator::get_object(int id) {
|
||||
m4 = casted->matrix;
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
Logger::Error(e);
|
||||
logger_.Error("GEO", 58, e);
|
||||
} catch (...) {
|
||||
Logger::Error("Unknown error returning product");
|
||||
logger_.Error("GEO", 59, "Unknown error returning product");
|
||||
}
|
||||
|
||||
Element* ifc_object = new Element(settings_, id, parent_id, product_name, instance_type, product_guid, "", m4, ifc_product);
|
||||
@@ -633,10 +655,10 @@ const IfcUtil::IfcBaseClass* IfcGeom::Iterator::create() {
|
||||
try {
|
||||
product = create_shape_model_for_next_entity();
|
||||
} catch (const std::exception& e) {
|
||||
Logger::Error(e);
|
||||
logger_.Error("GEO", 60, e);
|
||||
had_error_processing_elements_ = true;
|
||||
} catch (...) {
|
||||
Logger::Error("Unknown error creating geometry");
|
||||
logger_.Error("GEO", 61, "Unknown error creating geometry");
|
||||
had_error_processing_elements_ = true;
|
||||
}
|
||||
return product;
|
||||
@@ -808,8 +830,8 @@ ifcopenshell::geometry::taxonomy::direction3::ptr IfcGeom::Iterator::remove_offs
|
||||
}
|
||||
}
|
||||
|
||||
Logger::Notice("Removed large offsets within " + std::to_string(num_offset_applied) + " products");
|
||||
Logger::Notice("Offset applied (" + std::to_string(vec(0)) + "," + std::to_string(vec(1)) + "," + std::to_string(vec(2)) + ")");
|
||||
logger_.Notice("SYS", 29, "Removed large offsets within " + std::to_string(num_offset_applied) + " products");
|
||||
logger_.Notice("SYS", 30, "Offset applied (" + std::to_string(vec(0)) + "," + std::to_string(vec(1)) + "," + std::to_string(vec(2)) + ")");
|
||||
|
||||
return make<direction3>(vec);
|
||||
}
|
||||
@@ -824,6 +846,7 @@ IfcGeom::Iterator::~Iterator() {
|
||||
}
|
||||
|
||||
for (auto& k : kernel_pool) {
|
||||
flush_worker_log(k);
|
||||
delete k;
|
||||
}
|
||||
|
||||
|
||||
+17
-8
@@ -79,6 +79,7 @@
|
||||
#include <thread>
|
||||
#include <chrono>
|
||||
#include <atomic>
|
||||
#include <memory>
|
||||
|
||||
namespace IfcGeom {
|
||||
|
||||
@@ -126,12 +127,14 @@ namespace IfcGeom {
|
||||
std::vector<filter_t> filters_;
|
||||
int num_threads_;
|
||||
std::string geometry_library_;
|
||||
Logger& logger_;
|
||||
|
||||
// When single-threaded
|
||||
ifcopenshell::geometry::Converter* converter_;
|
||||
|
||||
// When multi-threaded
|
||||
std::vector<ifcopenshell::geometry::Converter*> kernel_pool;
|
||||
std::vector<std::unique_ptr<Logger>> worker_loggers_;
|
||||
|
||||
// The object is fetched beforehand to be sure that get() returns a valid element
|
||||
TriangulationElement* current_triangulation;
|
||||
@@ -199,8 +202,11 @@ namespace IfcGeom {
|
||||
IfcGeom::Element* process_based_on_settings(
|
||||
ifcopenshell::geometry::Settings settings,
|
||||
IfcGeom::BRepElement* elem,
|
||||
Logger& logger,
|
||||
IfcGeom::TriangulationElement* previous = nullptr);
|
||||
|
||||
void flush_worker_log(ifcopenshell::geometry::Converter* kernel);
|
||||
|
||||
bool wait_for_element();
|
||||
|
||||
void log_timepoints() const;
|
||||
@@ -209,32 +215,35 @@ namespace IfcGeom {
|
||||
ifcopenshell::geometry::taxonomy::direction3::ptr remove_offset_();
|
||||
public:
|
||||
|
||||
Iterator(std::unique_ptr<ifcopenshell::geometry::kernels::AbstractKernel>&& geometry_library, const ifcopenshell::geometry::Settings& settings, IfcParse::IfcFile* file, const std::vector<IfcGeom::filter_t>& filters, int num_threads)
|
||||
Iterator(std::unique_ptr<ifcopenshell::geometry::kernels::AbstractKernel>&& geometry_library, const ifcopenshell::geometry::Settings& settings, IfcParse::IfcFile* file, const std::vector<IfcGeom::filter_t>& filters, int num_threads, Logger& logger = Logger::Root())
|
||||
: settings_(settings)
|
||||
, ifc_file(file)
|
||||
, filters_(filters)
|
||||
, num_threads_(num_threads)
|
||||
, geometry_library_(geometry_library->geometry_library())
|
||||
, logger_(logger)
|
||||
// @todo verify whether settings are correctly passed on
|
||||
, converter_(new ifcopenshell::geometry::Converter(std::move(geometry_library), ifc_file, settings_))
|
||||
, converter_(new ifcopenshell::geometry::Converter(std::move(geometry_library), ifc_file, settings_, logger_))
|
||||
{
|
||||
}
|
||||
|
||||
Iterator(std::unique_ptr<ifcopenshell::geometry::kernels::AbstractKernel>&& geometry_library, const ifcopenshell::geometry::Settings& settings, IfcParse::IfcFile* file)
|
||||
Iterator(std::unique_ptr<ifcopenshell::geometry::kernels::AbstractKernel>&& geometry_library, const ifcopenshell::geometry::Settings& settings, IfcParse::IfcFile* file, Logger& logger = Logger::Root())
|
||||
: settings_(settings)
|
||||
, ifc_file(file)
|
||||
, num_threads_(1)
|
||||
, geometry_library_(geometry_library->geometry_library())
|
||||
, converter_(new ifcopenshell::geometry::Converter(std::move(geometry_library), ifc_file, settings_))
|
||||
, logger_(logger)
|
||||
, converter_(new ifcopenshell::geometry::Converter(std::move(geometry_library), ifc_file, settings_, logger_))
|
||||
{
|
||||
}
|
||||
|
||||
Iterator(std::unique_ptr<ifcopenshell::geometry::kernels::AbstractKernel>&& geometry_library, const ifcopenshell::geometry::Settings& settings, IfcParse::IfcFile* file, int num_threads)
|
||||
Iterator(std::unique_ptr<ifcopenshell::geometry::kernels::AbstractKernel>&& geometry_library, const ifcopenshell::geometry::Settings& settings, IfcParse::IfcFile* file, int num_threads, Logger& logger = Logger::Root())
|
||||
: settings_(settings)
|
||||
, ifc_file(file)
|
||||
, num_threads_(num_threads)
|
||||
, geometry_library_(geometry_library->geometry_library())
|
||||
, converter_(new ifcopenshell::geometry::Converter(std::move(geometry_library), ifc_file, settings_))
|
||||
, logger_(logger)
|
||||
, converter_(new ifcopenshell::geometry::Converter(std::move(geometry_library), ifc_file, settings_, logger_))
|
||||
{
|
||||
}
|
||||
|
||||
@@ -289,7 +298,7 @@ namespace IfcGeom {
|
||||
|
||||
size_t processed_ = 0;
|
||||
|
||||
void process_finished_rep(geometry_conversion_result* rep);
|
||||
void process_finished_rep(geometry_conversion_result* rep, ifcopenshell::geometry::Converter* kernel = nullptr);
|
||||
|
||||
void process_concurrently();
|
||||
|
||||
@@ -301,7 +310,7 @@ namespace IfcGeom {
|
||||
return progress_;
|
||||
}
|
||||
|
||||
std::string getLog() const { return Logger::GetLog(); }
|
||||
std::string getLog() const { return logger_.GetLog(); }
|
||||
|
||||
IfcParse::IfcFile* file() const { return ifc_file; }
|
||||
|
||||
|
||||
@@ -21,15 +21,22 @@
|
||||
#define SERIALIZER_H
|
||||
|
||||
#include "../ifcparse/IfcFile.h"
|
||||
#include "../ifcparse/IfcLogger.h"
|
||||
|
||||
class Serializer {
|
||||
protected:
|
||||
Logger& logger_;
|
||||
|
||||
public:
|
||||
explicit Serializer(Logger& logger = Logger::Root()) : logger_(logger) {}
|
||||
virtual ~Serializer() {}
|
||||
|
||||
Logger& logger() const { return logger_; }
|
||||
|
||||
virtual bool ready() = 0;
|
||||
virtual void writeHeader() = 0;
|
||||
virtual void finalize() = 0;
|
||||
virtual void setFile(IfcParse::IfcFile*) = 0;
|
||||
};
|
||||
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -37,14 +37,14 @@ void ifcopenshell::geometry::impl::MappingFactoryImplementation::bind(const std:
|
||||
this->insert(std::make_pair(schema_name_lower, fn));
|
||||
}
|
||||
|
||||
ifcopenshell::geometry::abstract_mapping* ifcopenshell::geometry::impl::MappingFactoryImplementation::construct(IfcParse::IfcFile* file, Settings& s) {
|
||||
ifcopenshell::geometry::abstract_mapping* ifcopenshell::geometry::impl::MappingFactoryImplementation::construct(IfcParse::IfcFile* file, Settings& s, Logger& logger) {
|
||||
const std::string schema_name_lower = boost::to_lower_copy(file->schema()->name());
|
||||
std::map<std::string, ifcopenshell::geometry::impl::mapping_fn>::const_iterator it;
|
||||
it = this->find(schema_name_lower);
|
||||
if (it == end()) {
|
||||
throw IfcParse::IfcException("No geometry mapping registered for " + schema_name_lower);
|
||||
}
|
||||
auto new_mapping = it->second(file, s);
|
||||
auto new_mapping = it->second(file, s, logger);
|
||||
new_mapping->initialize_settings();
|
||||
return new_mapping;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
#define ABSTRACT_MAPPING_H
|
||||
|
||||
#include "../ifcparse/IfcBaseClass.h"
|
||||
#include "../ifcparse/IfcLogger.h"
|
||||
#include "../ifcparse/aggregate_of_instance.h"
|
||||
#include "../ifcgeom/taxonomy.h"
|
||||
#include "../ifcgeom/ConversionSettings.h"
|
||||
@@ -43,14 +44,15 @@ namespace geometry {
|
||||
|
||||
typedef boost::function<bool(IfcUtil::IfcBaseEntity*)> filter_t;
|
||||
|
||||
class IFC_GEOM_API abstract_mapping {
|
||||
class IFC_GEOM_API abstract_mapping {
|
||||
protected:
|
||||
Settings settings_;
|
||||
Logger& logger_;
|
||||
|
||||
bool use_caching_ = true;
|
||||
|
||||
public:
|
||||
abstract_mapping(Settings& s) : settings_(s) {}
|
||||
abstract_mapping(Settings& s, Logger& logger = Logger::Root()) : settings_(s), logger_(logger) {}
|
||||
virtual ~abstract_mapping() {}
|
||||
|
||||
virtual ifcopenshell::geometry::taxonomy::ptr map(const IfcUtil::IfcBaseInterface*) = 0;
|
||||
@@ -69,19 +71,20 @@ namespace geometry {
|
||||
|
||||
const Settings& settings() const { return settings_; }
|
||||
Settings& settings() { return settings_; }
|
||||
Logger& logger() const { return logger_; }
|
||||
|
||||
bool use_caching() const { return use_caching_; }
|
||||
bool& use_caching() { return use_caching_; }
|
||||
};
|
||||
|
||||
namespace impl {
|
||||
typedef boost::function2<abstract_mapping*, IfcParse::IfcFile*, Settings&> mapping_fn;
|
||||
typedef boost::function3<abstract_mapping*, IfcParse::IfcFile*, Settings&, Logger&> mapping_fn;
|
||||
|
||||
class IFC_GEOM_API MappingFactoryImplementation : public std::map<std::string, mapping_fn> {
|
||||
public:
|
||||
MappingFactoryImplementation();
|
||||
void bind(const std::string& schema_name, mapping_fn);
|
||||
abstract_mapping* construct(IfcParse::IfcFile*, Settings&);
|
||||
abstract_mapping* construct(IfcParse::IfcFile*, Settings&, Logger& logger = Logger::Root());
|
||||
};
|
||||
|
||||
IFC_GEOM_API MappingFactoryImplementation& mapping_implementations();
|
||||
|
||||
@@ -76,7 +76,7 @@ struct piecewise_fn_evaluator : public fn_evaluator {
|
||||
span_start += fn->length();
|
||||
}
|
||||
|
||||
Logger::Error("piecewise span not found.");
|
||||
logger_.Error("GEO", 37, "piecewise span not found.");
|
||||
return {0, 0, nullptr};
|
||||
}
|
||||
|
||||
@@ -208,7 +208,7 @@ struct offset_fn_evaluator : public fn_evaluator {
|
||||
|
||||
|
||||
|
||||
function_item_evaluator::function_item_evaluator(const ifcopenshell::geometry::Settings& settings,taxonomy::function_item::const_ptr fn) {
|
||||
function_item_evaluator::function_item_evaluator(const ifcopenshell::geometry::Settings& settings,taxonomy::function_item::const_ptr fn, Logger& logger) : logger_(logger) {
|
||||
auto kind = fn->kind();
|
||||
if (kind == taxonomy::FUNCTOR_ITEM) {
|
||||
fn_evaluator_ = new functor_fn_evaluator(std::dynamic_pointer_cast<const taxonomy::functor_item>(fn),settings);
|
||||
@@ -221,11 +221,11 @@ function_item_evaluator::function_item_evaluator(const ifcopenshell::geometry::S
|
||||
} else if (kind == taxonomy::OFFSET_FUNCTION) {
|
||||
fn_evaluator_ = new offset_fn_evaluator(std::dynamic_pointer_cast<const taxonomy::offset_function>(fn), settings);
|
||||
} else {
|
||||
Logger::Error("Unexpected function type");
|
||||
logger_.Error("GEO", 38, "Unexpected function type");
|
||||
}
|
||||
}
|
||||
|
||||
function_item_evaluator::function_item_evaluator(const function_item_evaluator& other) {
|
||||
function_item_evaluator::function_item_evaluator(const function_item_evaluator& other) : logger_(other.logger_) {
|
||||
fn_evaluator_ = other.fn_evaluator_->clone();
|
||||
eval_points_ = other.eval_points_;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user