Compare commits

..

1 Commits

Author SHA1 Message Date
Bruno Postle 78f4190160 Add AGENTS.md contributor guide
Guidelines for external contributors using AI coding tools,
covering licensing, AI disclosure requirements, PR scope,
commit style, code formatting, and testing expectations.

Generated with the assistance of an AI coding tool.
2026-02-13 18:24:46 +00:00
64 changed files with 845 additions and 4524 deletions
-7
View File
@@ -82,8 +82,6 @@ src/bonsai/bonsai/translations.py
# bonsai test temp files
src/bonsai/test/files/temp
src/bonsai/test/files/basic.ifc.cache.blend
src/bonsai/test/files/basic.ifc.cache.sqlite
# bonsai data
src/bonsai/bonsai/bim/data/build/
@@ -115,8 +113,3 @@ dev_environment.bat
src/ifcopenshell-python/ifcopenshell/express/*.exp
src/ifcopenshell-python/ifcopenshell/express/*.exp.cache.dat
# Claude Code local config (managed via dotfiles repo)
CLAUDE.md
CLAUDE.local.md
.mcp.json
+1 -1
View File
@@ -3,7 +3,7 @@ name = "IfcOpenShell"
version = "0.0.0"
dependencies = [
"black==26.1.0",
"ruff==0.15.1",
"ruff==0.15.0",
"poethepoet",
"gersemi==0.25.4",
]
+1 -1
View File
@@ -371,7 +371,7 @@ test-tool:
ifndef MODULE
pytest test/tool
else
pytest test/tool/test_$(MODULE).py --maxfail=1
pytest test/tool/test_$(MODULE).py
endif
# Reregistering test is not added to the standard test suite because during unregister
-2
View File
@@ -184,8 +184,6 @@ classes = [
ui.BIM_PT_tab_materials,
ui.BIM_PT_tab_styles,
ui.BIM_PT_tab_profiles,
# Civil infrastructure
ui.BIM_PT_tab_horizontal_alignment,
# Drawings and documents
ui.BIM_PT_tab_sheets,
ui.BIM_PT_tab_drawings,
+1
View File
@@ -52,6 +52,7 @@ class IfcExporter:
self.set_header()
IfcStore.update_cache()
self.sync_all_objects()
tool.Project.save_linked_models_to_ifc()
extension = self.ifc_export_settings.output_file.split(".")[-1].lower()
if extension == "ifczip":
with tempfile.TemporaryDirectory() as unzipped_path:
@@ -1,5 +1,5 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2020, 2021 Dion Moult <dion@thinkmoult.com>, 2026 Michael Yoder <myoder@desertspringscivil.com>
# Copyright (C) 2020, 2021 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Bonsai.
#
@@ -17,37 +17,11 @@
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import bpy
from bpy.app.handlers import persistent
from . import ui, prop, operator, decorator
# from . import ui, prop, operator
from . import operator
classes = (
# Property groups (must be registered before classes that use them)
prop.AlignmentPI,
prop.AlignmentDisplayRow,
prop.CivilAlignmentProperties,
# UILists
ui.CIVIL_UL_alignment_pis,
operator.ImportAlignmentCSV,
# Operators - PI Management
operator.CIVIL_OT_add_pi,
operator.CIVIL_OT_remove_pi,
operator.CIVIL_OT_pick_pi_from_viewport,
operator.CIVIL_OT_recalculate_pis,
operator.CIVIL_OT_clear_pis,
# Operators - Creation
operator.CIVIL_OT_create_alignment_by_pi,
operator.CIVIL_OT_import_alignment_csv,
# Operators - Stationing
operator.CIVIL_OT_add_stationing_referent,
operator.CIVIL_OT_name_segments,
# Operators - PI Edit Mode
operator.CIVIL_OT_enter_pi_edit_mode,
# UI Panels (appear in Properties sidebar under CIVIL tab)
ui.CIVIL_PT_alignment_creation,
ui.CIVIL_PT_pi_editor,
ui.CIVIL_PT_alignment_stationing,
)
classes = (operator.ImportAlignmentCSV,)
def menu_func_import(self, context):
@@ -55,10 +29,8 @@ def menu_func_import(self, context):
def register():
bpy.types.Scene.CivilAlignmentProperties = bpy.props.PointerProperty(type=prop.CivilAlignmentProperties)
bpy.types.TOPBAR_MT_file_import.append(menu_func_import)
def unregister():
bpy.types.TOPBAR_MT_file_import.remove(menu_func_import)
del bpy.types.Scene.CivilAlignmentProperties
@@ -1,66 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2025, 2026 Michael Yoder <myoder@desertspringscivil.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/>.
"""Data caching layer for the alignment module
This module provides cached access to alignment data for UI display,
following Bonsai's data loading pattern.
"""
import bonsai.tool as tool
class AlignmentData:
"""Cached alignment data for UI display"""
data = {}
is_loaded = False
@classmethod
def load(cls):
"""Load alignment data from IFC file"""
cls.data = {
"alignments": [],
"active_alignment": None,
"segments": [],
}
ifc = tool.Ifc.get()
if ifc is None:
cls.is_loaded = True
return
# Load all alignments
alignments = ifc.by_type("IfcAlignment")
cls.data["alignments"] = [
{
"id": a.id(),
"name": a.Name or f"Alignment {a.id()}",
"global_id": a.GlobalId,
}
for a in alignments
]
cls.is_loaded = True
@classmethod
def refresh(cls):
"""Force refresh of alignment data"""
cls.is_loaded = False
cls.load()
@@ -1,193 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2025, 2026 Michael Yoder <myoder@desertspringscivil.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/>.
"""Alignment module decorators for GPU visualization.
This module contains decorators for rendering visual feedback during
alignment-related operations, such as PI editing.
"""
import bpy
import blf
import gpu
import bonsai.tool as tool
from bpy.types import SpaceView3D
from gpu_extras.batch import batch_for_shader
class PIEditDecorator:
"""Decorator for visualizing PI edit mode.
This decorator provides visual feedback while the user is editing
PI (Point of Intersection) positions with standard Blender transform tools:
- Yellow lines connecting PI empties (tangent preview)
- HUD text showing instructions
The decorator reads positions directly from the PI empty objects,
which are updated by Blender's transform operators (G key).
"""
# Class-level state (cleared on uninstall)
is_installed = False
handlers = []
# References to PI empty objects
pi_empties = []
# Colors
COLOR_TANGENT_LINE = (1.0, 0.9, 0.2, 1.0) # Yellow for tangent lines
COLOR_HUD_TEXT = (1.0, 1.0, 1.0, 1.0) # White for HUD text
COLOR_EDIT_MODE_BG = (0.2, 0.4, 0.8, 0.8) # Blue tint for edit mode indicator
# Drawing parameters
LINE_WIDTH = 2.5
@classmethod
def install(cls, context, pi_empties):
"""Install decorator handlers for PI edit mode visualization.
Args:
context: Blender context
pi_empties: List of PI EMPTY objects to visualize
"""
if cls.is_installed:
cls.uninstall()
cls.pi_empties = pi_empties
handler = cls()
# POST_VIEW for 3D world-space drawing (tangent lines in 3D)
cls.handlers.append(
SpaceView3D.draw_handler_add(handler.draw_tangent_lines_3d, (context,), "WINDOW", "POST_VIEW")
)
# POST_PIXEL for 2D screen-space drawing (HUD)
cls.handlers.append(
SpaceView3D.draw_handler_add(handler.draw_hud, (context,), "WINDOW", "POST_PIXEL")
)
cls.is_installed = True
@classmethod
def uninstall(cls):
"""Remove all handlers and clear state."""
for handler in cls.handlers:
try:
SpaceView3D.draw_handler_remove(handler, "WINDOW")
except ValueError:
pass
cls.handlers = []
cls.is_installed = False
cls.pi_empties = []
@classmethod
def update_positions(cls, pi_empties):
"""Update the list of PI empties (called when positions change).
Args:
pi_empties: Updated list of PI EMPTY objects
"""
cls.pi_empties = pi_empties
def draw_batch_3d(self, shader_type, content_pos, color, indices=None):
"""Draw a batch of 3D primitives using GPU shader.
Args:
shader_type: Type of primitive ("LINES", "POINTS", etc.)
content_pos: List of 3D vertex positions
color: RGBA color tuple
indices: Optional list of index pairs for lines
"""
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
return
shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR")
shader.bind()
# Get viewport size from active region
region = bpy.context.region
shader.uniform_float("viewportSize", (region.width, region.height))
shader.uniform_float("lineWidth", self.LINE_WIDTH)
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
shader.uniform_float("color", color)
batch.draw(shader)
def draw_tangent_lines_3d(self, context):
"""Draw yellow tangent lines connecting PI empties in 3D space."""
if not self.pi_empties or len(self.pi_empties) < 2:
return
# Collect 3D positions from empties
positions = []
for empty in self.pi_empties:
if empty and empty.name in bpy.data.objects:
positions.append(tuple(empty.location))
if len(positions) < 2:
return
# Setup blending for line drawing
gpu.state.blend_set("ALPHA")
gpu.state.depth_test_set("LESS_EQUAL")
gpu.state.depth_mask_set(False)
# Build edges list
edges = [[i, i + 1] for i in range(len(positions) - 1)]
# Draw lines
self.draw_batch_3d("LINES", positions, self.COLOR_TANGENT_LINE, edges)
# Restore state
gpu.state.blend_set("NONE")
gpu.state.depth_test_set("NONE")
gpu.state.depth_mask_set(True)
def draw_hud(self, context):
"""Draw HUD text with edit mode instructions."""
region = context.region
if not region:
return
font_id = 0
font_size = tool.Blender.scale_font_size(14)
blf.size(font_id, font_size)
blf.enable(font_id, blf.SHADOW)
blf.shadow(font_id, 6, 0, 0, 0, 1) # Black shadow for readability
blf.color(font_id, *self.COLOR_HUD_TEXT)
# Position in top-left of viewport
margin = 20
line_height = 22
y_pos = region.height - margin
# Count valid empties
valid_count = sum(1 for e in self.pi_empties if e and e.name in bpy.data.objects)
# Instructions
instructions = [
"PI Edit Mode",
f"PIs: {valid_count}",
"",
"G: Move selected PI",
"ENTER: Apply changes",
"ESC: Cancel",
]
for i, line in enumerate(instructions):
blf.position(font_id, margin, y_pos - (i * line_height), 0)
blf.draw(font_id, line)
blf.disable(font_id, blf.SHADOW)
File diff suppressed because it is too large Load Diff
@@ -1,215 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2025, 2026 Michael Yoder <myoder@desertspringscivil.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/>.
"""Property groups for the alignment module"""
import bpy
from bpy.types import PropertyGroup
from bpy.props import (
StringProperty,
FloatProperty,
IntProperty,
BoolProperty,
CollectionProperty,
EnumProperty,
)
def _on_radius_update(self, context):
"""Callback when radius property changes.
This dynamically imports the operator module to call on_radius_changed,
avoiding circular imports since prop.py is imported before operator.py.
"""
from . import operator as ops
ops.on_radius_changed(self, context)
class AlignmentPI(PropertyGroup):
"""Property group for a single PI (Point of Intersection)
In the PI method, alignments are defined by:
- Endpoint PIs: Start (POB) and End (POE) points
- Interior PIs: Points where tangents intersect, optionally with curves
"""
# Coordinates stored as global easting/northing (map coordinates).
# Coordinate flow: Blender coords -> xyz2enh() -> global E/N (stored here)
# global E/N -> enh2xyz(to_blender=False) -> local IFC coords (for IfcOpenShell API)
e: StringProperty(name="E", description="Easting (global map coordinates)", default="0.0")
n: StringProperty(name="N", description="Northing (global map coordinates)", default="0.0")
# PI Type
pi_type: EnumProperty(
name="Type",
description="Type of PI point",
items=[
("ENDPOINT", "Endpoint", "Start or end point (no curve)"),
("TANGENT", "Tangent", "Pass-through point (no curve)"),
("CURVE", "Curve", "Point of intersection with curve"),
],
default="TANGENT",
)
# Curve parameters (only used when pi_type == "CURVE")
radius: FloatProperty(
name="Radius",
description="Curve radius (0 = no curve, sharp angle)",
default=0.0,
min=0.0,
precision=3,
unit="LENGTH",
update=_on_radius_update,
)
# Computed/display values (updated by recalculate operator)
length_to_next: FloatProperty(
name="Length",
description="Length of tangent to next PI",
default=0.0,
precision=3,
unit="LENGTH",
)
direction_to_next: FloatProperty(
name="Direction",
description="Bearing/direction to next PI (degrees)",
default=0.0,
precision=4,
subtype="ANGLE",
)
# Station at this PI (computed)
station: FloatProperty(
name="Station",
description="Station value at this PI",
default=0.0,
precision=2,
)
class AlignmentDisplayRow(PropertyGroup):
"""Property group for interleaved point/segment display in the table.
This creates the Civil 3D-style view where points and segments
are shown on separate rows:
Point 1 (End)
Segment 1 (Tan)
Point 2 (Tan)
Segment 2 (Tan)
...
"""
# Row type discriminator
row_type: EnumProperty(
name="Row Type",
items=[
("POINT", "Point", "A PI point row"),
("SEGMENT", "Segment", "A segment row between points"),
],
default="POINT",
)
# Segment number (1, 2, 3...) - only for SEGMENT rows
segment_number: IntProperty(name="Segment #", default=0)
# Point index in the pis collection - for both types
# For POINT rows: the PI index
# For SEGMENT rows: the starting PI index of this segment
pi_index: IntProperty(name="PI Index", default=0)
# Display type string (End, Tan, Curve for points; Tan, Curve for segments)
display_type: StringProperty(name="Type", default="")
# Point coordinates (only for POINT rows)
e: StringProperty(name="E", default="0.0")
n: StringProperty(name="N", default="0.0")
# Segment properties (only for SEGMENT rows)
length: FloatProperty(name="Length", default=0.0, precision=2, unit="LENGTH")
radius: FloatProperty(name="Radius", default=0.0, precision=2, unit="LENGTH")
arc_length: FloatProperty(name="Arc Length", default=0.0, precision=2, unit="LENGTH")
class CivilAlignmentProperties(PropertyGroup):
"""Properties for the alignment module"""
# Active alignment selection
active_alignment_id: IntProperty(
name="Active Alignment ID",
description="IFC entity ID of the active alignment",
default=0,
)
active_alignment_name: StringProperty(
name="Active Alignment",
description="Name of the currently active alignment",
default="",
)
# New alignment creation properties
new_alignment_name: StringProperty(
name="Name",
description="Name for new alignment",
default="Alignment 1",
)
start_station: FloatProperty(
name="Start Station",
description="Starting station value (e.g., 10000 for 100+00)",
default=10000.0,
min=0.0,
)
# PI collection for PI method creation
pis: CollectionProperty(type=AlignmentPI)
active_pi_index: IntProperty(name="Active PI", default=0)
# Combined point/segment display rows (for Civil 3D-style table)
display_rows: CollectionProperty(type=AlignmentDisplayRow)
active_display_row_index: IntProperty(name="Active Display Row", default=0)
# PI Edit Mode state (for moving PIs with G key)
is_pi_edit_mode: BoolProperty(
name="PI Edit Mode Active",
description="Whether PI edit mode is currently active",
default=False,
)
pi_edit_alignment_id: IntProperty(
name="Editing Alignment ID",
description="IFC ID of alignment being edited in PI edit mode",
default=0,
)
# Display options
show_station_labels: BoolProperty(
name="Show Station Labels",
description="Show station labels along alignment",
default=True,
)
station_interval: FloatProperty(
name="Station Interval",
description="Interval between station markers",
default=100.0,
min=1.0,
unit="LENGTH",
)
@@ -1,269 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2025, 2026 Michael Yoder <myoder@desertspringscivil.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/>.
"""UI panels for the alignment module
All panels appear in the Properties sidebar under the CIVIL tab,
nested under BIM_PT_tab_horizontal_alignment.
"""
import bpy
import bonsai.tool as tool
from bpy.types import Panel, UIList
def is_ifc4x3():
"""Check if the current IFC file is IFC4X3 schema"""
return tool.Ifc.get_schema() == "IFC4X3"
# =============================================================================
# UILists
# =============================================================================
class CIVIL_UL_alignment_pis(UIList):
"""UIList for displaying interleaved points and segments (Civil 3D style)
Row types:
- POINT rows: End (endpoint), Mid (interior PI without curve)
- SEGMENT rows: Tan (tangent line), Curve (circular arc)
When a Mid point has radius > 0, it becomes a Curve segment row.
"""
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
if self.layout_type in {"DEFAULT", "COMPACT"}:
row = layout.row(align=True)
if item.row_type == "POINT":
# Point row: No., Type, X, Y, Length, Radius
row.label(text="") # No segment number for points
# Type with point/dot icon
# "End" = endpoint (POB/POE), "Mid" = interior PI point
row.label(text=item.display_type, icon="DOT")
# X, Y coordinates - get actual PI for editing
pi = data.pis[item.pi_index] if item.pi_index < len(data.pis) else None
if pi:
sub = row.row(align=True)
sub.prop(pi, "e", text="")
sub.prop(pi, "n", text="")
else:
row.label(text=f"{float(item.e):.2f}")
row.label(text=f"{float(item.n):.2f}")
# Length column - empty for point rows
row.label(text="")
# Radius column - editable for Mid points (where curves can be added)
if item.display_type == "Mid" and pi:
row.prop(pi, "radius", text="")
else:
row.label(text="")
elif item.row_type == "SEGMENT":
if item.display_type == "Curve":
# Curve segment row: No., Type (arc icon), X, Y, Arc Length, Radius
row.label(text=f"{item.segment_number}")
row.label(text="Curve", icon="SPHERECURVE")
# Show PI coordinates on curve row
row.label(text=f"{float(item.e):.2f}")
row.label(text=f"{float(item.n):.2f}")
# Arc length
row.label(text=f"{item.arc_length:.2f}")
# Radius - editable so user can modify or delete curve (set to 0)
pi = data.pis[item.pi_index] if item.pi_index < len(data.pis) else None
if pi:
row.prop(pi, "radius", text="")
else:
row.label(text=f"{item.radius:.2f}")
else:
# Tangent segment row: No., Type (line icon), -, -, Length, -
row.label(text=f"{item.segment_number}")
row.label(text="Tan", icon="IPO_LINEAR")
# No X, Y for tangent segments
row.label(text="")
row.label(text="")
# Length
row.label(text=f"{item.length:.2f}")
# No radius for tangent segments
row.label(text="-")
elif self.layout_type == "GRID":
layout.alignment = "CENTER"
layout.label(text="", icon="DECORATE")
# =============================================================================
# Creation Sub-Panel
# =============================================================================
class CIVIL_PT_alignment_creation(Panel):
"""Sub-panel for alignment creation tools"""
bl_label = "Creation"
bl_idname = "CIVIL_PT_alignment_creation"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_parent_id = "BIM_PT_tab_horizontal_alignment"
bl_options = {"DEFAULT_CLOSED"}
@classmethod
def poll(cls, context):
return tool.Blender.should_show_panel(context, "CIVIL", cls.bl_idname) and is_ifc4x3()
def draw(self, context):
layout = self.layout
props = context.scene.CivilAlignmentProperties
# New alignment properties
box = layout.box()
box.label(text="New Alignment:", icon="ADD")
box.prop(props, "new_alignment_name")
box.prop(props, "start_station")
# Creation operators
col = layout.column(align=True)
col.operator("civil.create_alignment_by_pi", icon="CURVE_DATA")
# =============================================================================
# PI Editor Sub-Panel
# =============================================================================
class CIVIL_PT_pi_editor(Panel):
"""Sub-panel for PI point table editor (Civil 3D style grid view)"""
bl_label = "PI Editor"
bl_idname = "CIVIL_PT_pi_editor"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_parent_id = "BIM_PT_tab_horizontal_alignment"
bl_options = set() # Open by default
@classmethod
def poll(cls, context):
return tool.Blender.should_show_panel(context, "CIVIL", cls.bl_idname) and is_ifc4x3()
def draw(self, context):
layout = self.layout
props = context.scene.CivilAlignmentProperties
# PI Edit Mode indicator
if props.is_pi_edit_mode:
box = layout.box()
box.alert = True
box.label(text="PI Edit Mode Active", icon="EDITMODE_HLT")
col = box.column(align=True)
col.label(text="Move PIs with G key")
col.label(text="Press Enter to apply")
col.label(text="Press Escape to cancel")
layout.separator()
return # Don't show normal UI while in edit mode
# Edit existing alignment button
if props.active_alignment_id != 0:
box = layout.box()
box.label(text="Edit Alignment:", icon="EDITMODE_HLT")
box.operator("civil.enter_pi_edit_mode", icon="PIVOT_CURSOR", text="Edit PIs (G key)")
layout.separator()
# Header row with column labels
header = layout.row(align=True)
header.label(text="No.")
header.label(text="Type")
header.label(text="E")
header.label(text="N")
header.label(text="Length")
header.label(text="Radius")
# Combined point/segment list (interleaved view)
row = layout.row()
row.template_list(
"CIVIL_UL_alignment_pis",
"",
props,
"display_rows",
props,
"active_display_row_index",
rows=8,
)
# Side buttons for list management
col = row.column(align=True)
col.operator("civil.add_pi", icon="ADD", text="")
col.operator("civil.remove_pi", icon="REMOVE", text="")
col.separator()
col.operator("civil.pick_pi_from_viewport", icon="EYEDROPPER", text="")
# Bottom actions
layout.separator()
row = layout.row(align=True)
row.operator("civil.recalculate_pis", icon="FILE_REFRESH", text="Recalculate")
row.operator("civil.clear_pis", icon="TRASH", text="Clear All")
# =============================================================================
# Stationing Sub-Panel
# =============================================================================
class CIVIL_PT_alignment_stationing(Panel):
"""Sub-panel for stationing and referents"""
bl_label = "Stationing"
bl_idname = "CIVIL_PT_alignment_stationing"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_parent_id = "BIM_PT_tab_horizontal_alignment"
bl_options = {"DEFAULT_CLOSED"}
@classmethod
def poll(cls, context):
return tool.Blender.should_show_panel(context, "CIVIL", cls.bl_idname) and is_ifc4x3()
def draw(self, context):
layout = self.layout
props = context.scene.CivilAlignmentProperties
# Station display options
box = layout.box()
box.label(text="Display:", icon="HIDE_OFF")
box.prop(props, "show_station_labels")
box.prop(props, "station_interval")
layout.separator()
# Stationing operators
col = layout.column(align=True)
col.operator("civil.add_stationing_referent", icon="EMPTY_AXIS")
col.operator("civil.name_segments", icon="FONT_DATA")
+2 -2
View File
@@ -163,7 +163,7 @@ class BIMBSDDProperties(PropertyGroup):
default=False,
)
classification_psets: CollectionProperty(name="Classification Psets", type=BSDDPset)
if TYPE_CHECKING:
active_dictionary: str
active_dictionary: str
@@ -182,7 +182,7 @@ class BIMBSDDProperties(PropertyGroup):
should_filter_ifc_class: bool
use_only_ifc_properties: bool
classification_psets: bpy.types.bpy_prop_collection_idprop[BSDDPset]
@property
def active_class(self) -> Union[BSDDClassification, None]:
return tool.Blender.get_active_uilist_element(self.classes, self.active_class_index)
+1 -1
View File
@@ -24,7 +24,6 @@ import bpy
from bpy.types import Panel, UIList
import bonsai.tool as tool
import bsdd
from bonsai.bim.module.bsdd.data import BSDDData
if TYPE_CHECKING:
@@ -84,6 +83,7 @@ class BIM_PT_bsdd(Panel):
row = self.layout.row()
row.operator("bim.load_bsdd_dictionaries")
class BIM_UL_bsdd_dictionaries(UIList):
def draw_item(
self,
+102 -29
View File
@@ -928,7 +928,7 @@ class CreateDrawing(bpy.types.Operator):
props = tool.Project.get_project_props()
for link in props.get_loaded_links_for_drawings():
files[link.filepath] = self.get_linked_file(link)
files[link.name] = self.get_linked_file(link)
target_view = ifcopenshell.util.element.get_psets(self.camera_element)["EPset_Drawing"]["TargetView"]
self.setup_serialiser(target_view)
@@ -1374,7 +1374,7 @@ class CreateDrawing(bpy.types.Operator):
return True
def get_linked_file(self, link: "Link") -> ifcopenshell.file:
link_path = link.filepath
link_path = link.name
ifc_file = IfcStore.session_files.get(link_path, None)
if ifc_file is not None:
return ifc_file
@@ -2896,16 +2896,12 @@ class AddSchedule(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
bl_options = {"REGISTER", "UNDO"}
bl_description = "Add an .ods, .xls or .xlsx file as a schedule"
files: bpy.props.CollectionProperty(name="Files", type=bpy.types.OperatorFileListElement)
directory: bpy.props.StringProperty(subtype="DIR_PATH")
filter_glob: bpy.props.StringProperty(default="*.ods;*.xls;*.xlsx", options={"HIDDEN"})
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=True)
def _execute(self, context):
for filepath in tool.Blender.get_selected_files(
self.directory, self.files, use_relative_path=self.use_relative_path
):
core.add_document(tool.Ifc, tool.Drawing, "SCHEDULE", uri=filepath)
filepath = tool.Ifc.get_uri(self.filepath, use_relative_path=self.use_relative_path)
core.add_document(tool.Ifc, tool.Drawing, "SCHEDULE", uri=filepath)
class RemoveSchedule(bpy.types.Operator, tool.Ifc.Operator):
@@ -3094,16 +3090,23 @@ class AddReference(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
bl_description = "Import a .svg file to the project as a reference"
bl_options = {"REGISTER", "UNDO"}
files: bpy.props.CollectionProperty(name="Files", type=bpy.types.OperatorFileListElement)
directory: bpy.props.StringProperty(subtype="DIR_PATH")
filter_glob: bpy.props.StringProperty(default="*.svg", options={"HIDDEN"})
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=True)
filename_ext = ".svg"
files: bpy.props.CollectionProperty(type=bpy.types.OperatorFileListElement)
directory: bpy.props.StringProperty(subtype="DIR_PATH")
def _execute(self, context):
for filepath in tool.Blender.get_selected_files(
self.directory, self.files, use_relative_path=self.use_relative_path
):
# Handle both single and multiple file selection
if self.files:
for file_elem in self.files:
filepath = os.path.join(self.directory, file_elem.name)
uri = tool.Ifc.get_uri(filepath, use_relative_path=self.use_relative_path)
core.add_document(tool.Ifc, tool.Drawing, "REFERENCE", uri=uri)
else:
# Fallback for single file (backward compatibility)
filepath = tool.Ifc.get_uri(self.filepath, use_relative_path=self.use_relative_path)
core.add_document(tool.Ifc, tool.Drawing, "REFERENCE", uri=filepath)
@@ -3162,7 +3165,6 @@ class EditTextPopup(bpy.types.Operator):
bpy.ops.bim.disable_editing_text()
def execute(self, context):
# TODO: check for possible subtle undo bug here
# can't use invoke() because this operator
# will be run indirectly by hotkey
# so we use execute() and track whether it's the first run of the operator
@@ -3815,14 +3817,91 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
description="Existing object name to add a style with reference image to. If not provided will create a new object.",
options={"SKIP_SAVE"},
)
size: bpy.props.FloatProperty(name="Size", description="Size of the reference image", default=1.0, unit="LENGTH")
x_length: bpy.props.FloatProperty(
name="X Length",
description="Width of the reference image in project units",
default=1.0,
min=0.001,
soft_min=0.01,
precision=3,
)
y_length: bpy.props.FloatProperty(
name="Y Length",
description="Height of the reference image in project units",
default=1.0,
min=0.001,
soft_min=0.01,
precision=3,
)
show_dimensions_dialog: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"})
def draw(self, context):
if Path(tool.Ifc.get_path()).is_file():
self.layout.prop(self, "use_relative_path")
self.layout.prop(self, "override_existing_image")
self.layout.prop(self, "use_existing_object_by_name")
self.layout.prop(self, "size")
layout = self.layout
if getattr(self, "show_dimensions_dialog", False):
if tool.Ifc.get():
length_unit = ifcopenshell.util.unit.get_project_unit(tool.Ifc.get(), "LENGTHUNIT")
if length_unit:
unit_name = ifcopenshell.util.unit.get_full_unit_name(length_unit).lower()
else:
unit_name = "project units"
layout.label(text=f"Set Reference Image Dimensions (in {unit_name}):")
else:
layout.label(text="Set Reference Image Dimensions (in project units):")
layout.separator()
layout.prop(self, "x_length")
layout.prop(self, "y_length")
else:
if Path(tool.Ifc.get_path()).is_file():
layout.prop(self, "use_relative_path")
else:
self.use_relative_path = False
layout.label(text="Save the .ifc file first ")
layout.label(text="to use relative paths.")
layout.prop(self, "override_existing_image")
layout.prop(self, "use_existing_object_by_name")
def invoke(self, context, event):
if not getattr(self, "show_dimensions_dialog", False):
context.window_manager.fileselect_add(self)
return {"RUNNING_MODAL"}
else:
return context.window_manager.invoke_props_dialog(self)
def execute(self, context):
if not getattr(self, "show_dimensions_dialog", False):
abs_path = Path(self.filepath).absolute().resolve()
if self.override_existing_image:
params = {"check_existing": True, "force_reload": True}
else:
params = {"check_existing": False}
try:
image = load_image(abs_path.name, str(abs_path.parent), **params)
image_width_px = image.size[0]
image_height_px = image.size[1]
aspect_ratio = image_width_px / image_height_px
if aspect_ratio >= 1.0:
self.x_length = 1.0
self.y_length = 1.0 / aspect_ratio
else:
self.x_length = aspect_ratio
self.y_length = 1.0
bpy.data.images.remove(image)
except Exception as e:
self.report({"ERROR"}, f"Failed to load image: {str(e)}")
return {"CANCELLED"}
self.show_dimensions_dialog = True
return context.window_manager.invoke_props_dialog(self)
return self._execute(context)
def _execute(self, context):
space = tool.Blender.get_view3d_space()
@@ -3843,19 +3922,11 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
params = {"check_existing": False}
image = load_image(abs_path.name, str(abs_path.parent), **params)
aspect_ratio = image.size[0] / image.size[1]
if aspect_ratio >= 1.0: # Landscape
x_length = self.size
y_length = self.size / aspect_ratio
else:
x_length = self.size / aspect_ratio
y_length = self.size
def bm_add_image_plane(mesh):
bm = tool.Blender.get_bmesh_for_mesh(mesh, clean=True)
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file)
plane_scale = Vector((x_length / 2.0, y_length / 2.0, 1.0))
plane_scale = Vector((self.x_length * unit_scale / 2.0, self.y_length * unit_scale / 2.0, 1.0))
matrix = Matrix.LocRotScale(None, None, plane_scale)
bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=1, matrix=matrix, calc_uvs=False)
@@ -3959,6 +4030,8 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
tool.Style.reload_material_from_ifc(material)
tool.Geometry.record_object_materials(obj)
return {"FINISHED"}
class ConvertSVGToDXF(bpy.types.Operator):
bl_idname = "bim.convert_svg_to_dxf"
+1 -1
View File
@@ -89,7 +89,7 @@ class BIM_PT_camera(Panel):
for link in links:
row = panel.row(align=True)
split = row.split(factor=0.9)
split.label(text=link.filepath, icon="FILE")
split.label(text=link.name, icon="FILE")
split.prop(link, "include_in_drawings", text="")
else:
panel.label(text="No IFC projects linked and loaded.")
@@ -902,7 +902,6 @@ class OverrideDelete(bpy.types.Operator):
if not is_valid_data_block:
continue
element = tool.Ifc.get_entity(obj)
if element:
if tool.Geometry.is_locked(element):
@@ -913,9 +912,6 @@ class OverrideDelete(bpy.types.Operator):
if ifcopenshell.util.element.get_pset(element, "BBIM_Array"):
self.report({"INFO"}, "Elements that are part of an array cannot be deleted.")
continue
if element.is_a("IfcDocumentReference"):
self.report({"INFO"}, "Linked models cannot be deleted.")
continue
if element.is_a("IfcGridAxis"):
# Deleting the last W axis is OK
if ((grid := element.PartOfU) and len(grid[0].UAxes) == 1) or (
@@ -1212,11 +1208,6 @@ class OverrideDuplicateMove(bpy.types.Operator):
operator.report({"ERROR"}, f"Drawing '{obj.name}' not duplicated.")
continue
if element.is_a("IfcDocumentReference"):
objects_to_remove.add(obj)
operator.report({"ERROR"}, f"Linked model '{obj.name}' not duplicated.")
continue
if tool.Geometry.is_locked(element):
objects_to_remove.add(obj)
operator.report({"ERROR"}, lock_error_message(obj.name))
@@ -51,7 +51,7 @@ class RemoveGeoreferencing(bpy.types.Operator, tool.Ifc.Operator):
bl_description = "Remove the georeferencing"
def _execute(self, context):
core.remove_georeferencing(tool.Ifc, tool.Georeference)
core.remove_georeferencing(tool.Ifc)
class EditGeoreferencing(bpy.types.Operator, tool.Ifc.Operator):
@@ -224,12 +224,16 @@ class BIMGeoreferenceProperties(PropertyGroup):
x_axis_ordinate: StringProperty(name="X Axis Ordinate", update=update_grid_north_vector)
x_axis_is_null: BoolProperty(name="X Axis Is Null")
# These are only for reference to capture data about a host model from a linked model
# If you relink a model from a new host origin, we can autodetect it in theory with this
host_model_origin: StringProperty(name="Host Model Origin")
host_model_origin_si: StringProperty(name="Host Model Origin SI")
host_model_project_north: StringProperty(name="Host Model Angle to Grid North")
# This is the ENH in project units and SI units of the Blender session's 0,0,0.
# These are only for reference, using tool.Georeference.set_model_origin on
# project load, project create, and when linking for the first time from an
# empty Blender session.
model_is_georeferenced: BoolProperty(name="Model Is Georeferenced")
model_crs: StringProperty(name="Model CRS")
model_origin: StringProperty(name="Model Origin")
model_origin_si: StringProperty(name="Model Origin SI")
model_project_north: StringProperty(name="Model Angle to Grid North")
@@ -271,6 +275,10 @@ class BIMGeoreferenceProperties(PropertyGroup):
x_axis_ordinate: str
x_axis_is_null: bool
host_model_origin: str
host_model_origin_si: str
host_model_project_north: str
model_origin: str
model_origin_si: str
model_project_north: str
@@ -31,31 +31,27 @@ classes = (
operator.BIM_OT_load_clipping_planes,
operator.BIM_OT_save_clipping_planes,
operator.ChangeLibraryElement,
operator.ClearMeasurement,
operator.ClearRecentIFCProjects,
operator.CreateClippingPlane,
operator.CreateProject,
operator.DisableCulling,
operator.DisableEditingHeader,
operator.DisableEditingLink,
operator.EditHeader,
operator.EditLink,
operator.EditProjectLibrary,
operator.EnableCulling,
operator.EnableEditingHeader,
operator.EnableEditingLink,
operator.ExportIFC,
operator.FlipClippingPlane,
operator.IFCFileHandlerOperator,
operator.ImageScalingTool,
operator.LinkIfc,
operator.LoadBlendMetadataAndIFC,
operator.LoadLink,
operator.LoadLinkedProject,
operator.LoadProject,
operator.LoadProjectElements,
operator.MeasureFaceAreaTool,
operator.MeasureTool,
operator.MeasureFaceAreaTool,
operator.ClearMeasurement,
operator.NewProject,
operator.QueryLinkedElement,
operator.RefreshClippingPlanes,
@@ -73,6 +69,7 @@ classes = (
operator.UnassignLibraryDeclaration,
operator.UnlinkIfc,
operator.UnloadLink,
operator.LoadBlendMetadataAndIFC,
workspace.ExploreHotkey,
prop.LibraryBreadcrumb,
prop.LibraryElement,
+120 -224
View File
@@ -18,7 +18,6 @@
import datetime
import json
import math
import logging
import os
import subprocess
@@ -60,15 +59,6 @@ import bonsai.core.project as core
import bonsai.tool as tool
from bonsai.bim import export_ifc, import_ifc
from bonsai.bim.ifc import IfcStore
from bonsai.bim.ui import IFCFileSelector
from bonsai.bim import import_ifc
from bonsai.bim import export_ifc
from math import radians, degrees
from pathlib import Path
from collections import defaultdict
from mathutils import Vector, Matrix
from bpy.app.handlers import persistent
from ifcopenshell.geom import ShapeElementType
from bonsai.bim.module.model.decorator import FaceAreaDecorator, PolylineDecorator
from bonsai.bim.module.model.polyline import PolylineOperator
from bonsai.bim.module.project.data import LinksData, ProjectLibraryData
@@ -1331,7 +1321,7 @@ class ToggleFilterCategories(bpy.types.Operator):
return {"FINISHED"}
class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator):
class LinkIfc(bpy.types.Operator, ImportHelper):
bl_idname = "bim.link_ifc"
bl_label = "Link IFC"
bl_options = {"REGISTER", "UNDO"}
@@ -1370,125 +1360,134 @@ class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator):
row = self.layout.row()
row.prop(pprops, "project_north")
def _execute(self, context):
def execute(self, context):
start = time.time()
files = [f.name for f in self.files] if self.files else [self.filepath]
if not files or all(not f or not f.strip() for f in files):
self.report({"ERROR"}, "No file selected")
return {"CANCELLED"}
existing_links = tool.Project.get_linked_models_documents() if tool.Ifc.get() else {}
for filename in files:
if not filename or not filename.strip():
continue
filepath = Path(self.directory) / filename
if bpy.data.filepath and filepath.samefile(bpy.data.filepath):
self.report({"INFO"}, "Can't link the current .blend file")
continue
props = tool.Project.get_project_props()
filepath = tool.Ifc.get_uri(filepath, use_relative_path=self.use_relative_path)
new = props.links.add()
if tool.Ifc.get():
if not (document := existing_links.get(filepath)):
document = ifcopenshell.api.document.add_information(tool.Ifc.get())
document.Name = Path(filepath).name
document.Scope = "LINKED_MODEL"
reference = ifcopenshell.api.document.add_reference(tool.Ifc.get(), information=document)
reference[1] = ",".join([str(o) for o in np.eye(4).flatten().tolist()])
reference.Location = filepath.replace("\\", "/")
new.ifc_definition_id = reference.id()
filepath = tool.Ifc.get_uri(filepath, use_relative_path=self.use_relative_path)
new.name = filepath
new.filepath = filepath
bpy.ops.bim.load_link(link_index=-1, use_cache=self.use_cache)
status = bpy.ops.bim.load_link(filepath=filepath, use_cache=self.use_cache)
if status == {"CANCELLED"}:
error_msg = (
f'Error processing IFC file "{filepath}" '
"was critical and blend file either wasn't saved or wasn't updated. "
"See logs above in system console for details."
)
print(error_msg)
self.report({"ERROR"}, error_msg)
return {"FINISHED"}
print(f"Finished linking {len(files)} IFCs", time.time() - start)
return {"FINISHED"}
class UnlinkIfc(bpy.types.Operator, tool.Ifc.Operator):
class UnlinkIfc(bpy.types.Operator):
bl_idname = "bim.unlink_ifc"
bl_label = "Unlink IFC"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Remove the selected file from the link list"
link_index: bpy.props.IntProperty(name="Link Index")
filepath: bpy.props.StringProperty()
def _execute(self, context):
def execute(self, context):
filepath = Path(self.filepath).as_posix()
bpy.ops.bim.unload_link(filepath=filepath)
props = tool.Project.get_project_props()
link = props.links[self.link_index]
bpy.ops.bim.unload_link(link_index=self.link_index)
if tool.Ifc.get():
reference = tool.Ifc.get().by_id(link.ifc_definition_id)
document = tool.Document.get_reference_document(reference)
ifcopenshell.api.document.remove_reference(tool.Ifc.get(), reference)
if document and not tool.Document.get_document_references(document):
ifcopenshell.api.document.remove_information(tool.Ifc.get(), document)
props.links.remove(self.link_index)
index = props.links.find(filepath)
if index != -1:
props.links.remove(index)
return {"FINISHED"}
class UnloadLink(bpy.types.Operator, tool.Ifc.Operator):
class UnloadLink(bpy.types.Operator):
bl_idname = "bim.unload_link"
bl_label = "Unload Link"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Unload the selected linked file"
link_index: bpy.props.IntProperty(name="Link Index")
filepath: bpy.props.StringProperty()
def _execute(self, context):
link = tool.Project.get_project_props().links[self.link_index]
if obj := tool.Project.get_link_empty_handle(link):
collection = obj.instance_collection
library = collection.library
tool.Ifc.unlink(obj=obj)
bpy.data.objects.remove(obj)
if collection.users == 0:
bpy.data.collections.remove(collection)
if not len([c for c in bpy.data.collections if c.library == library]):
def execute(self, context):
filepath = tool.Blender.ensure_blender_path_is_abs(Path(self.filepath))
if filepath.suffix.lower() == ".ifc":
filepath = filepath.with_suffix(".ifc.cache.blend")
for library in list(bpy.data.libraries):
if tool.Blender.ensure_blender_path_is_abs(Path(library.filepath)) == filepath:
bpy.data.libraries.remove(library)
props = tool.Project.get_project_props()
links = props.links
link = links[self.filepath]
# Let's assume that user might delete it.
if empty_handle := link.empty_handle:
bpy.data.objects.remove(empty_handle)
# following lines removes the library also when use_relative_path=True, otherwise it doesn't
libraries = bpy.data.libraries
for library in libraries:
if library.name == self.filepath + ".cache.blend":
bpy.data.libraries.remove(library)
link.is_loaded = False
ProjectDecorator.uninstall()
if not any([l.is_loaded for l in links]):
ProjectDecorator.uninstall()
# we make sure we don't draw queried object from the file that was just unlinked
elif queried_obj := props.queried_obj:
queried_filepath = Path(queried_obj["ifc_filepath"])
if queried_filepath == filepath:
ProjectDecorator.uninstall()
return {"FINISHED"}
class LoadLink(bpy.types.Operator, tool.Ifc.Operator):
class LoadLink(bpy.types.Operator):
bl_idname = "bim.load_link"
bl_label = "Load Link"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Load the selected file"
link_index: bpy.props.IntProperty(name="Link Index")
filepath: bpy.props.StringProperty(name="Link Filepath")
use_cache: bpy.props.BoolProperty(name="Use Cache", default=True)
def _execute(self, context):
self.link = tool.Project.get_project_props().links[self.link_index]
filepath = Path(tool.Ifc.resolve_uri(self.link.filepath))
filepath_: Path
def execute(self, context):
filepath = Path(tool.Ifc.resolve_uri(self.filepath))
if not filepath.exists():
self.report({"ERROR"}, f"File does not exist: '{filepath}'")
return {"CANCELLED"}
self.filepath_ = filepath
if filepath.suffix.lower().endswith(".ifc"):
return self.link_ifc()
if filepath.suffix.lower().endswith(".blend"):
self.link_blend(filepath)
elif filepath.suffix.lower().endswith(".ifc"):
status = self.link_ifc()
if status:
return status
return {"FINISHED"}
def link_blend(self, filepath: Path) -> None:
with bpy.data.libraries.load(str(filepath), link=True) as (data_from, data_to):
data_to.collections = [c for c in data_from.collections if "IfcProject" in c]
# Find the linked collection
for collection in bpy.data.collections:
if not collection.library or Path(collection.library.filepath) != filepath:
data_to.scenes = data_from.scenes
link = tool.Project.get_project_props().links[self.filepath]
for scene in bpy.data.scenes:
if not scene.library or Path(scene.library.filepath) != filepath:
continue
# Create unique empty instance for this link
empty_name = collection.name
empty = bpy.data.objects.new(empty_name, None)
empty.instance_type = "COLLECTION"
empty.instance_collection = collection
empty.matrix_world = Matrix(tool.Project.calculate_link_matrix(self.link))
tool.Project.set_link_empty_handle(self.link, empty)
bpy.context.scene.collection.objects.link(empty)
self.link.is_loaded = True
if tool.Ifc.get(): # For non-IFC projects, locking has no meaning
tool.Geometry.lock_object(empty)
tool.Blender.select_and_activate_single_object(bpy.context, empty)
for child in scene.collection.children:
if "IfcProject" not in child.name:
continue
empty = bpy.data.objects.new(child.name, None)
empty.instance_type = "COLLECTION"
empty.instance_collection = child
link.empty_handle = empty
bpy.context.scene.collection.objects.link(empty)
break
break
else:
print(f"WARNING: No IfcProject collection found in {filepath}")
self.link.is_loaded = False
link.is_loaded = True
tool.Blender.select_and_activate_single_object(bpy.context, empty)
def link_ifc(self) -> Union[set[str], None]:
blend_filepath = self.filepath_.with_suffix(".ifc.cache.blend")
@@ -1503,12 +1502,14 @@ class LoadLink(bpy.types.Operator, tool.Ifc.Operator):
code = f"""
import bpy
import sys
def run():
import bonsai.tool as tool
gprops = tool.Georeference.get_georeference_props()
# Our model origin becomes their host model origin
gprops.host_model_origin = "{gprops.model_origin}"
gprops.host_model_origin_si = "{gprops.model_origin_si}"
gprops.host_model_project_north = "{gprops.model_project_north}"
gprops.has_blender_offset = {gprops.has_blender_offset}
gprops.blender_offset_x = "{gprops.blender_offset_x}"
gprops.blender_offset_y = "{gprops.blender_offset_y}"
@@ -1521,12 +1522,7 @@ def run():
pprops.false_origin = "{pprops.false_origin}"
pprops.project_north = "{pprops.project_north}"
# Use absolute path to be safe from cwd changes.
try:
bpy.ops.bim.load_linked_project(filepath=r"{str(self.filepath_)}")
except RuntimeError as e:
# Operator failed (returned CANCELLED with error report)
print(f"Failed to load linked project: {{e}}")
sys.exit(1)
bpy.ops.bim.load_linked_project(filepath=r"{str(self.filepath_)}")
# Use str instead of as_posix to avoid issues with Windows shared paths.
bpy.ops.wm.save_as_mainfile(filepath=r"{str(blend_filepath)}")
@@ -1560,17 +1556,14 @@ except Exception as e:
if not blend_filepath.exists() or blend_filepath.stat().st_mtime < t:
return {"CANCELLED"}
self.set_model_origin_from_link()
self.set_georeferencing_indicator()
self.set_model_origin_from_link()
self.link_blend(blend_filepath)
def set_model_origin_from_link(self) -> None:
if tool.Ifc.get():
return # The current model's coordinates always take priority.
if len(tool.Project.get_project_props().links) > 1:
return # Only the first link sets the origin
json_filepath = self.filepath_.with_suffix(".ifc.cache.json")
if not json_filepath.exists():
return
@@ -1583,36 +1576,21 @@ except Exception as e:
if (value := data.get(prop, None)) is not None:
setattr(gprops, prop, value)
def set_georeferencing_indicator(self) -> None:
if not tool.Ifc.get():
self.link.georeferenced = "NONE"
return
if not (crs_name := (ifcopenshell.util.geolocation.get_crs(tool.Ifc.get()) or {}).get("Name", "")):
self.link.georeferenced = "NONE"
return
reference = tool.Ifc.get().by_id(self.link.ifc_definition_id)
json_filepath = Path(reference.Location).with_suffix(".ifc.cache.json")
if not json_filepath.exists():
self.link.georeferenced = "NONE"
return
with open(json_filepath, "r") as f:
data = json.load(f)
if not data["model_is_georeferenced"]:
self.link.georeferenced = "NONE"
else:
self.link.georeferenced = "FULL_COMPATIBLE" if crs_name == data["model_crs"] else "NOT_COMPATIBLE"
class ReloadLink(bpy.types.Operator):
bl_idname = "bim.reload_link"
bl_label = "Reload Link"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Reload the selected file"
link_index: bpy.props.IntProperty(name="Link Index")
filepath: bpy.props.StringProperty()
def execute(self, context):
bpy.ops.bim.unload_link(link_index=self.link_index)
return bpy.ops.bim.load_link(link_index=self.link_index, use_cache=False) or {"FINISHED"}
is_abs = os.path.isabs(Path(self.filepath))
use_relative_path = not is_abs
bpy.ops.bim.unlink_ifc(filepath=self.filepath)
filepath = tool.Ifc.resolve_uri(self.filepath)
status = bpy.ops.bim.link_ifc(filepath=filepath, use_cache=False, use_relative_path=use_relative_path)
return {"FINISHED"}
class ToggleLinkSelectability(bpy.types.Operator):
@@ -1620,18 +1598,16 @@ class ToggleLinkSelectability(bpy.types.Operator):
bl_label = "Toggle Link Selectability"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Toggle selectability"
link_index: bpy.props.IntProperty(name="Link Index")
link: bpy.props.StringProperty(name="Linked IFC Filepath")
def execute(self, context):
props = tool.Project.get_project_props()
link = props.links[self.link_index]
self.library_filepath = tool.Blender.ensure_blender_path_is_abs(
Path(link.filepath).with_suffix(".ifc.cache.blend")
)
link = props.links[self.link]
self.library_filepath = tool.Blender.ensure_blender_path_is_abs(Path(self.link).with_suffix(".ifc.cache.blend"))
link.is_selectable = (is_selectable := not link.is_selectable)
for collection in self.get_linked_collections():
collection.hide_select = not is_selectable
if handle := tool.Project.get_link_empty_handle(link):
if handle := link.empty_handle:
handle.hide_select = not is_selectable
return {"FINISHED"}
@@ -1648,15 +1624,13 @@ class ToggleLinkVisibility(bpy.types.Operator):
bl_label = "Toggle Link Visibility"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Toggle visibility between SOLID and WIREFRAME"
link_index: bpy.props.IntProperty(name="Link Index")
link: bpy.props.StringProperty(name="Linked IFC Filepath")
mode: bpy.props.EnumProperty(name="Visibility Mode", items=((i, i, "") for i in ("WIREFRAME", "VISIBLE")))
def execute(self, context):
props = tool.Project.get_project_props()
link = props.links[self.link_index]
self.library_filepath = tool.Blender.ensure_blender_path_is_abs(
Path(link.filepath).with_suffix(".ifc.cache.blend")
)
link = props.links[self.link]
self.library_filepath = tool.Blender.ensure_blender_path_is_abs(Path(self.link).with_suffix(".ifc.cache.blend"))
if self.mode == "WIREFRAME":
self.toggle_wireframe(link)
elif self.mode == "VISIBLE":
@@ -1678,7 +1652,7 @@ class ToggleLinkVisibility(bpy.types.Operator):
layer_collections = tool.Blender.get_layer_collections_mapping(linked_collections)
for layer_collection in layer_collections.values():
layer_collection.exclude = is_hidden
if handle := tool.Project.get_link_empty_handle(link):
if handle := link.empty_handle:
handle.hide_set(is_hidden)
def get_linked_collections(self) -> list[bpy.types.Collection]:
@@ -1689,95 +1663,17 @@ class ToggleLinkVisibility(bpy.types.Operator):
]
class EnableEditingLink(bpy.types.Operator):
bl_idname = "bim.enable_editing_link"
bl_label = "Enable Editing Link"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Enable editing link location"
def execute(self, context):
link = tool.Project.get_project_props().active_link
link.is_editing = True
tool.Geometry.unlock_object(tool.Project.get_link_empty_handle(link))
return {"FINISHED"}
class DisableEditingLink(bpy.types.Operator):
bl_idname = "bim.disable_editing_link"
bl_label = "Disable Editing Link"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Disable editing link and restore to previously saved location"
def execute(self, context):
link = tool.Project.get_project_props().active_link
link.is_editing = False
obj = tool.Project.get_link_empty_handle(link)
obj.matrix_world = Matrix(tool.Project.calculate_link_matrix(link))
tool.Geometry.lock_object(obj)
return {"FINISHED"}
class EditLink(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.edit_link"
bl_label = "Edit Link"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Disable editing link and restore to previously saved location"
def _execute(self, context):
link = tool.Project.get_project_props().active_link
link.is_editing = False
obj = tool.Project.get_link_empty_handle(link)
new_obj_matrix = obj.matrix_world
filepath = Path(tool.Ifc.resolve_uri(link.filepath))
with open(filepath.with_suffix(".ifc.cache.json"), "r") as f:
metadata = json.load(f)
rot = ifcopenshell.util.shape_builder.np_rotation_matrix(
radians(-float(metadata["model_project_north"])), 4, "Z"
)
global_matrix = rot @ np.eye(4)
global_matrix[:, 3][:3] = [float(o) for o in metadata["model_origin_si"].split(",")]
gprops = tool.Georeference.get_georeference_props()
rot = ifcopenshell.util.shape_builder.np_rotation_matrix(radians(-float(gprops.model_project_north)), 4, "Z")
local_matrix = rot @ np.eye(4)
local_matrix[:, 3][:3] = [float(o) for o in gprops.model_origin_si.split(",")]
# obj_matrix is typically calculated as:
# obj_matrix = np.linalg.inv(local_matrix) @ transformation @ global_matrix
# So let's calculate the transformation
transformed_global_matrix = local_matrix @ np.array(new_obj_matrix)
transformation = transformed_global_matrix @ np.linalg.inv(global_matrix)
if np.allclose(transformation, np.eye(4)):
link.has_transformation = True
transformation = ",".join(map(str, np.eye(4).reshape(-1)))
else:
link.has_transformation = False
transformation = ",".join(map(str, transformation.reshape(-1)))
if tool.Ifc.get():
reference = tool.Ifc.get().by_id(link.ifc_definition_id)
reference[1] = transformation
else:
link.transformation = transformation
obj.matrix_world = Matrix(tool.Project.calculate_link_matrix(link))
tool.Geometry.lock_object(obj)
class SelectLinkHandle(bpy.types.Operator):
bl_idname = "bim.select_link_handle"
bl_label = "Select Link Handle"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Select link empty object handle"
link_index: bpy.props.IntProperty(name="Link Index")
index: bpy.props.IntProperty(name="Link Index")
def execute(self, context):
props = tool.Project.get_project_props()
link = props.links[self.link_index]
handle = tool.Project.get_link_empty_handle(link)
link = props.links[self.index]
handle = link.empty_handle
if not handle:
self.report({"ERROR"}, "Link has no empty handle (probably it was deleted).")
return {"CANCELLED"}
@@ -1970,14 +1866,7 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper):
print("Processing", self.filepath)
self.collection = bpy.data.collections.new("IfcProject/" + os.path.basename(self.filepath))
try:
self.file = ifcopenshell.open(self.filepath)
except Exception as e:
self.report({"ERROR"}, f"Failed to open IFC file: {str(e)}")
bpy.data.collections.remove(self.collection)
return {"CANCELLED"}
self.file = ifcopenshell.open(self.filepath)
tool.Ifc.set(self.file)
print("Finished opening")
@@ -2008,13 +1897,20 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper):
if tool.Loader.settings.false_origin_mode == "MANUAL" and tool.Loader.settings.false_origin:
tool.Loader.set_manual_blender_offset(self.file)
elif tool.Loader.settings.false_origin_mode == "AUTOMATIC":
tool.Loader.guess_false_origin(self.file)
if host_model_origin_si := gprops.host_model_origin_si:
host_model_origin_si = [float(o) / self.unit_scale for o in host_model_origin_si.split(",")]
tool.Loader.settings.false_origin = host_model_origin_si
tool.Loader.settings.project_north = float(gprops.host_model_project_north)
tool.Loader.set_manual_blender_offset(self.file)
else:
tool.Loader.guess_false_origin(self.file)
tool.Georeference.set_model_origin()
self.json_filepath = self.filepath + ".cache.json"
data = {
"model_is_georeferenced": gprops.model_is_georeferenced,
"model_crs": gprops.model_crs,
"host_model_origin": gprops.host_model_origin,
"host_model_origin_si": gprops.host_model_origin_si,
"host_model_project_north": gprops.host_model_project_north,
"model_origin": gprops.model_origin,
"model_origin_si": gprops.model_origin_si,
"model_project_north": gprops.model_project_north,
+4 -42
View File
@@ -16,9 +16,8 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import math
import os
from collections.abc import Generator
from pathlib import Path
from typing import TYPE_CHECKING, Literal, Union, assert_never, get_args
import bpy
@@ -220,62 +219,29 @@ class FilterCategory(PropertyGroup):
class Link(PropertyGroup):
name: StringProperty(name="Name")
filepath: StringProperty(
name="Filepath",
name: StringProperty(
name="Name",
description="Filepath to linked .ifc file, stored in posix format (could be relative to .ifc file, not to .blend)",
)
transformation: StringProperty(
name="Transformation",
description="4x4 matrix transformation as a flattened comma separated list for the linked model",
default="",
)
georeferenced: EnumProperty(
name="Georeferenced",
description="Georeferencing status: compatibility between host and linked model",
items=[
("NONE", "No Georef", "Linked model has no georeferencing"),
("NOT_COMPATIBLE", "Not Compatible", "Has geo data but CRS differ from host"),
("FULL_COMPATIBLE", "Full Compatible", "Both CRS name and vertical datum match host"),
],
default="NONE",
)
has_transformation: BoolProperty(
name="Has Transformation",
description="Whether there is a transformation from its global coordinates",
default=False,
)
is_loaded: BoolProperty(name="Is Loaded", default=False)
is_editing: BoolProperty(name="Is Editing", description="Whether the link is being transformed", default=False)
is_selectable: BoolProperty(name="Is Selectable", default=True)
is_wireframe: BoolProperty(name="Is Wireframe", default=False)
is_hidden: BoolProperty(name="Is Hidden", default=False)
include_in_drawings: BoolProperty(name="Include in Drawings", default=True, options=set())
empty_handle: PointerProperty(
name="Empty Object Handle",
description="Storage for empty handle. Used in non-IFC scenarios or temporarily during link creation",
description="We use empty object handle to allow simple manipulations with a linked model (moving, scaling, rotating)",
type=bpy.types.Object,
)
ifc_definition_id: IntProperty(
name="IFC Definition ID",
description="STEP ID of the IfcDocumentReference when linked to a parent IFC project. Zero when no parent IFC exists",
default=0,
)
if TYPE_CHECKING:
name: str
filepath: str
transformation: str
georeferenced: Literal["NONE", "NOT_COMPATIBLE", "FULL_COMPATIBLE"]
has_transformation: bool
is_loaded: bool
is_editing: bool
is_selectable: bool
is_wireframe: bool
is_hidden: bool
include_in_drawings: bool
empty_handle: Union[bpy.types.Object, None]
ifc_definition_id: int
class EditedObj(PropertyGroup):
@@ -458,10 +424,6 @@ class BIMProjectProperties(PropertyGroup):
clipping_planes_active_index: bpy.props.IntProperty(min=0, default=0, max=5)
edited_objs: bpy.props.CollectionProperty(type=EditedObj)
@property
def active_link(self) -> Union[Link, None]:
return tool.Blender.get_active_uilist_element(self.links, self.active_link_index)
@property
def active_clipping_plane(self) -> ObjProperty | None:
return tool.Blender.get_active_uilist_element(self.clipping_planes, self.clipping_planes_active_index)
+49 -44
View File
@@ -22,7 +22,6 @@ import os
from typing import TYPE_CHECKING
import bpy
import math
import ifcopenshell
from bpy.types import Menu, Panel, UIList
@@ -31,7 +30,6 @@ import bonsai.tool as tool
from bonsai.bim.helper import draw_attributes, prop_with_search
from bonsai.bim.ifc import IfcStore
from bonsai.bim.module.project.data import LinksData, ProjectData
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from bonsai.bim.module.project.prop import (
@@ -479,27 +477,17 @@ class BIM_PT_links(Panel):
def draw(self, context):
self.props = tool.Project.get_project_props()
row = self.layout.row(align=True)
row.operator("bim.link_ifc")
if self.props.links:
if self.props.active_link:
row = self.layout.row(align=True)
row.alignment = "RIGHT"
index = self.props.active_link_index
if self.props.active_link.is_editing:
row.operator("bim.edit_link", text="", icon="CHECKMARK")
row.operator("bim.disable_editing_link", text="", icon="CANCEL")
else:
row.operator("bim.enable_editing_link", text="", icon="GREASEPENCIL")
if self.props.active_link.is_loaded:
row.operator("bim.select_link_handle", text="", icon="OBJECT_DATA").link_index = index
row.operator("bim.unload_link", text="", icon="UNLINKED").link_index = index
row.operator("bim.reload_link", text="", icon="FILE_REFRESH").link_index = index
else:
row.operator("bim.load_link", text="", icon="LINKED").link_index = index
row.operator("bim.unlink_ifc", text="", icon="X").link_index = index
self.layout.template_list("BIM_UL_links", "", self.props, "links", self.props, "active_link_index")
self.layout.template_list(
"BIM_UL_links",
"",
self.props,
"links",
self.props,
"active_link_index",
)
if LinksData.enable_culling:
row = self.layout.row(align=True)
@@ -619,30 +607,47 @@ class BIM_UL_links(UIList):
active_propname,
index,
):
row = layout.row(align=True)
if item.is_loaded:
if item.georeferenced == "NONE":
row.label(text="", icon="QUESTION")
elif item.georeferenced == "NOT_COMPATIBLE":
row.label(text="", icon="ERROR")
elif item.georeferenced == "FULL_COMPATIBLE":
row.label(text="", icon="WORLD")
if item.has_transformation:
row.label(text="", icon="OBJECT_ORIGIN")
row.label(text=item.filepath)
icon = "RESTRICT_SELECT_OFF" if item.is_selectable else "RESTRICT_SELECT_ON"
row.operator("bim.toggle_link_selectability", text="", icon=icon, emboss=False).link_index = index
icon = "CUBE" if item.is_wireframe else "MESH_CUBE"
op = row.operator("bim.toggle_link_visibility", text="", icon=icon, emboss=False)
op.link_index = index
op.mode = "WIREFRAME"
icon = "HIDE_ON" if item.is_hidden else "HIDE_OFF"
op = row.operator("bim.toggle_link_visibility", text="", icon=icon, emboss=False)
op.link_index = index
op.mode = "VISIBLE"
else:
row.label(text=item.filepath)
if item:
row = layout.row(align=True)
if item.is_loaded:
row.label(text=item.name)
op = row.operator(
"bim.toggle_link_selectability",
text="",
icon="RESTRICT_SELECT_OFF" if item.is_selectable else "RESTRICT_SELECT_ON",
emboss=False,
)
op.link = item.name
op = row.operator(
"bim.toggle_link_visibility",
text="",
icon="CUBE" if item.is_wireframe else "MESH_CUBE",
emboss=False,
)
op.link = item.name
op.mode = "WIREFRAME"
op = row.operator(
"bim.toggle_link_visibility",
text="",
icon="HIDE_ON" if item.is_hidden else "HIDE_OFF",
emboss=False,
)
op.link = item.name
op.mode = "VISIBLE"
op = row.operator("bim.select_link_handle", text="", icon="OBJECT_DATA")
op.index = index
op = row.operator("bim.unload_link", text="", icon="UNLINKED")
op.filepath = item.name
op = row.operator("bim.reload_link", text="", icon="FILE_REFRESH")
op.filepath = item.name
else:
row.prop(item, "name", text="")
op = row.operator("bim.select_uri_attribute", text="", icon="FILE_FOLDER")
op.attribute_data_path = tool.Blender.get_full_data_path(item, "name")
op = row.operator("bim.load_link", text="", icon="LINKED")
op.filepath = item.name
op = row.operator("bim.unlink_ifc", text="", icon="X")
op.filepath = item.name
class BIM_PT_purge(Panel):
+1 -5
View File
@@ -82,8 +82,6 @@ class IfcClassData:
feature_elements = ifcopenshell.util.schema.get_subtypes(entity)
for feature_element in feature_elements:
names.remove(feature_element.name())
if ifc_product == "IfcAlignment":
names.extend(("IfcAlignmentHorizontal", "IfcAlignmentVertical", "IfcAlignmentCant"))
version = tool.Ifc.get_schema()
return [(c, c, (get_entity_doc(version, c) or {}).get("description", "")) for c in sorted(names)]
@@ -138,9 +136,7 @@ class IfcClassData:
("EMPTY", "No Geometry", "Start with an empty object"),
]
if ifc_class == "IfcAlignment":
return templates
elif ifc_class in ("IfcWindowType", "IfcWindowStyle", "IfcWindow"):
if ifc_class in ("IfcWindowType", "IfcWindowStyle", "IfcWindow"):
templates.extend([None, ("WINDOW", "Window", "Parametric window")])
elif ifc_class in ("IfcDoorType", "IfcDoorStyle", "IfcDoor"):
templates.extend([None, ("DOOR", "Door", "Parametric door")])
+1 -11
View File
@@ -531,9 +531,6 @@ class AddElement(bpy.types.Operator, tool.Ifc.Operator):
if props.ifc_product == "IfcFeatureElement" and not props.featured_obj:
return self.report({"WARNING"}, "A featured element must be nominated.")
if "Alignment" in props.ifc_product and props.ifc_product != "IfcAlignment" and not props.featured_obj:
return self.report({"WARNING"}, "A parent alignment element must be nominated.")
ifc_context = None
if get_enum_items(props, "contexts", context):
ifc_context = int(props.contexts or "0") or None
@@ -824,13 +821,6 @@ class AddElement(bpy.types.Operator, tool.Ifc.Operator):
tool.Model.purge_scene_openings()
tool.Collector.assign(obj)
if props.featured_obj:
alignment = tool.Ifc.get_entity(props.featured_obj)
if props.ifc_class == "IfcAlignment":
ifcopenshell.api.aggregate.assign_object(tool.Ifc.get(), products=[element], relating_object=alignment)
elif props.ifc_class in ("IfcAlignmentHorizontal", "IfcAlignmentVertical", "IfcAlignmentCant"):
ifcopenshell.api.nest.assign_object(tool.Ifc.get(), related_objects=[element], relating_object=alignment)
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
tool.Blender.set_active_object(obj)
@@ -852,7 +842,7 @@ class AddElement(bpy.types.Operator, tool.Ifc.Operator):
if props.ifc_predefined_type == "USERDEFINED":
row = self.layout.row()
row.prop(props, "ifc_userdefined_type")
if props.ifc_product in ("IfcFeatureElement", "IfcAlignment"):
if props.ifc_product == "IfcFeatureElement":
row = self.layout.row()
row.prop(props, "featured_obj", text="Featured Object")
prop_with_search(self.layout, props, "representation_template", text="Representation", should_click_ok=True)
+46 -28
View File
@@ -268,45 +268,63 @@ class SaveBlendMetadataFile(bpy.types.Operator):
import bpy
# Ensure all styles are loaded before attempting to remove them
bpy.ops.bim.load_styles()
try:
bpy.ops.bim.load_styles()
except Exception:
pass
# 1. Collect all IfcStyle material names
ifcstyle_material_names = []
styles_props = getattr(bpy.context.scene, "BIMStylesProperties", None)
if styles_props is None and bpy.data.scenes:
styles_props = getattr(bpy.data.scenes[0], "BIMStylesProperties", None)
if styles_props:
for style in list(styles_props.styles):
material = getattr(style, "blender_material", None)
if material and material.name:
ifcstyle_material_names.append(material.name)
try:
styles_props = getattr(bpy.context.scene, "BIMStylesProperties", None)
if styles_props is None and bpy.data.scenes:
styles_props = getattr(bpy.data.scenes[0], "BIMStylesProperties", None)
if styles_props:
for style in list(styles_props.styles):
material = getattr(style, "blender_material", None)
if material and material.name:
ifcstyle_material_names.append(material.name)
except Exception:
pass
# 2. Purge IfcStore
from bonsai.bim.ifc import IfcStore
IfcStore.purge()
try:
from bonsai.bim.ifc import IfcStore
except ImportError:
IfcStore = None
if IfcStore:
try:
IfcStore.purge()
except Exception:
pass
# 3. Remove all collections named IfcProject*
for collection in list(bpy.data.collections):
if collection.name.startswith('IfcProject'):
bpy.data.collections.remove(collection, do_unlink=True)
try:
bpy.data.collections.remove(collection, do_unlink=True)
except Exception:
pass
# 4.1 Remove all collections from linked libraries (they will be recreated by bonsai)
for collection in list(bpy.data.collections):
if collection.library:
bpy.data.collections.remove(collection, do_unlink=True)
# 4. Purge orphaned data blocks after removing IfcProject collections
try:
bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True)
except Exception:
pass
# 4.2. Remove all empty objects that are collection instances for linked models
for obj in list(bpy.data.objects):
if obj.type == 'EMPTY' and obj.instance_type == 'COLLECTION' and obj.name.startswith('IfcProject/'):
bpy.data.objects.remove(obj, do_unlink=True)
# 5. Purge orphaned data blocks after removing IfcProject collections
bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True)
# 6. Remove all materials corresponding to the IfcStyles we collected
for mat_name in ifcstyle_material_names:
if mat_name in bpy.data.materials:
bpy.data.materials.remove(bpy.data.materials[mat_name], do_unlink=True)
# 5. Remove all materials corresponding to the IfcStyles we collected
materials_removed = 0
try:
for mat_name in ifcstyle_material_names:
if mat_name in bpy.data.materials:
try:
bpy.data.materials.remove(bpy.data.materials[mat_name], do_unlink=True)
materials_removed += 1
except Exception:
pass
except Exception:
pass
bpy.ops.wm.save_as_mainfile(filepath=r'{blendmetadata_path}')
"""
+8 -9
View File
@@ -498,16 +498,15 @@ def get_tab(
("PROJECT", "Project Overview", "", bonsai.bim.icons[icon_key].icon_id, 0),
("OBJECT", "Object Information", "", "FILE_3D", 1),
("GEOMETRY", "Geometry and Materials", "", "MATERIAL", 2),
("CIVIL", "Civil Infrastructure", "", "CURVE_DATA", 3),
("DRAWINGS", "Drawings and Documents", "", "DOCUMENTS", 4),
("SERVICES", "Services and Systems", "", "NETWORK_DRIVE", 5),
("STRUCTURE", "Structural Analysis", "", "EDITMODE_HLT", 6),
("SCHEDULING", "Costing and Scheduling", "", "NLA", 7),
("FM", "Facility Management", "", "PACKAGE", 8),
("QUALITY", "Quality and Coordination", "", "COMMUNITY", 9),
("BOOKMARK", "Bookmark", "", "SOLO_ON", 10),
("DRAWINGS", "Drawings and Documents", "", "DOCUMENTS", 3),
("SERVICES", "Services and Systems", "", "NETWORK_DRIVE", 4),
("STRUCTURE", "Structural Analysis", "", "EDITMODE_HLT", 5),
("SCHEDULING", "Costing and Scheduling", "", "NLA", 6),
("FM", "Facility Management", "", "PACKAGE", 7),
("QUALITY", "Quality and Coordination", "", "COMMUNITY", 8),
("BOOKMARK", "Bookmark", "", "SOLO_ON", 9),
None,
("BLENDER", "Blender Properties", "", "BLENDER", 11),
("BLENDER", "Blender Properties", "", "BLENDER", 10),
]
return get_tab.enum_items
+2 -27
View File
@@ -467,12 +467,12 @@ class DocPreferences(bpy.types.PropertyGroup):
classes_to_wireframe: StringProperty(
default="IfcVirtualElement",
name="Classes to Wireframe",
description="Upon import, these classes will display as wireframe.\nEx: IfcVirtualElement, IfcSpace",
description="Upon import, these classes will display as wireframe.\nEx: IfcVirtualelement, IfcSpace",
)
classes_no_cut: StringProperty(
default="IfcVirtualElement, IfcSpace",
name="Classes that are not cut",
description="The cut decorator will be turned off for these classes\nEx: IfcVirtualElement, IfcSpace",
description="The cut decoractor will be turned off for these classes\nEx: IfcVirtualelement, IfcSpace",
)
if TYPE_CHECKING:
@@ -660,10 +660,6 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
bsdd_load_test_dictionaries: BoolProperty(
name="Load Test Dictionaries", description="Load dictionaries that are for testing only", default=False
)
bsdd_baseurl: StringProperty(
name="bSDD API Base URL", description="Base URL for data dictionary API requests, e.g. https://api.bsdd.buildingsmart.org/api/",
default="https://api.bsdd.buildingsmart.org/api/",
)
should_disable_undo_on_save: BoolProperty(
name="Disable Undo When Saving (Faster saves, no undo for you!)", default=False
)
@@ -976,7 +972,6 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
layout.prop(self, "bsdd_load_preview_dictionaries")
layout.prop(self, "bsdd_load_inactive_dictionaries")
layout.prop(self, "bsdd_load_test_dictionaries")
layout.prop(self, "bsdd_baseurl")
def draw_extras_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
layout.prop(self, "container_hide_show_isolate")
@@ -1683,25 +1678,6 @@ class BIM_PT_tab_profiles(Panel):
pass
# Civil Infrastructure tab panels
class BIM_PT_tab_horizontal_alignment(Panel):
bl_idname = "BIM_PT_tab_horizontal_alignment"
bl_label = "Horizontal Alignment"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_order = 1
bim_tab_name = "CIVIL"
@classmethod
def poll(cls, context):
if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get():
return True
def draw(self, context):
pass
class BIM_PT_tab_sheets(Panel):
bl_idname = "BIM_PT_tab_sheets"
bl_label = "Sheets"
@@ -1888,7 +1864,6 @@ class UIData:
("PROJECT", bonsai.bim.icons[f"{color_mode}_ifc"].icon_id, True),
("OBJECT", "FILE_3D", is_ifc_project),
("GEOMETRY", "MATERIAL", is_ifc_project),
("CIVIL", "CURVE_DATA", is_ifc_project),
("DRAWINGS", "DOCUMENTS", is_ifc_project),
("SERVICES", "NETWORK_DRIVE", is_ifc_project),
("STRUCTURE", "EDITMODE_HLT", is_ifc_project),
-184
View File
@@ -1,184 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2025, 2026 Michael Yoder <myoder@desertspringscivil.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/>.
"""Core alignment business logic - Orchestration only, NO bpy imports.
This module contains alignment-related business logic and workflow
orchestration. All calculations, algorithms, and IFC operations are
in the tool layer. Functions receive tool classes as parameters
following Bonsai's dependency injection pattern.
NOTE: Math, calculations, algorithms, and IFC API calls belong in
tool/alignment.py. This module only handles:
- Business rules and validation
- Workflow orchestration (calling tool methods in sequence)
- Decision-making about what should happen
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import ifcopenshell
from .. import tool
# =============================================================================
# PI Edit Mode Functions
# =============================================================================
def enter_pi_edit_mode(
ifc_tool: "type[tool.Ifc]",
alignment_tool: "type[tool.Alignment]",
alignment_id: int,
) -> list:
"""Enter PI edit mode for an alignment.
Business logic for entering PI edit mode:
1. Validates that the alignment exists
2. Validates that the alignment has a horizontal layout with real segments
3. Back-calculates PI positions from segments
4. Creates temporary EMPTY objects at each PI location
Args:
ifc_tool: The IFC tool class
alignment_tool: The Alignment tool class
alignment_id: The IFC ID of the alignment to edit
Returns:
List of created PI EMPTY objects
Raises:
ValueError: If alignment doesn't exist, has no horizontal layout,
or has no real segments
"""
# Validate alignment exists
ifc_file = ifc_tool.get()
if ifc_file is None:
raise ValueError("No IFC file loaded")
try:
alignment = ifc_file.by_id(alignment_id)
except RuntimeError:
raise ValueError(f"Alignment with ID {alignment_id} not found")
if not alignment.is_a("IfcAlignment"):
raise ValueError(f"Entity {alignment_id} is not an IfcAlignment")
# Validate alignment has horizontal layout (delegated to tool)
h_layout = alignment_tool.get_horizontal_layout(alignment)
if h_layout is None:
raise ValueError(f"Alignment '{alignment.Name}' has no horizontal layout")
# Validate layout has real segments (not just zero-length terminator)
if not alignment_tool.layout_has_real_segments(h_layout):
raise ValueError(f"Alignment '{alignment.Name}' has no editable segments")
# Back-calculate PI positions from segments
pis = alignment_tool.back_calculate_pis_from_alignment(alignment)
if len(pis) < 2:
raise ValueError(f"Alignment '{alignment.Name}' must have at least 2 PIs")
# Create temporary EMPTY objects at each PI location
empties = alignment_tool.create_pi_edit_empties(alignment, pis)
return empties
def exit_pi_edit_mode(
ifc_tool: "type[tool.Ifc]",
alignment_tool: "type[tool.Alignment]",
alignment_id: int,
apply: bool,
) -> bool:
"""Exit PI edit mode for an alignment.
Business logic for exiting PI edit mode:
1. If apply=True:
- Collect new PI positions from empties
- Validate the new configuration
- Update alignment segments in-place (preserves alignment ID)
2. Always:
- Remove temporary EMPTY objects
- Return success status
This function modifies the alignment segments in-place rather than
deleting and recreating the alignment. This preserves the alignment's
IFC entity ID, preventing stale reference issues.
Args:
ifc_tool: The IFC tool class
alignment_tool: The Alignment tool class
alignment_id: The IFC ID of the alignment being edited
apply: If True, update alignment with new PI positions
Returns:
True if successful
Raises:
ValueError: If alignment doesn't exist or update fails
"""
ifc_file = ifc_tool.get()
if ifc_file is None:
# No file loaded, just clean up empties
alignment_tool.remove_pi_edit_empties(alignment_id)
return True
# Get alignment
try:
alignment = ifc_file.by_id(alignment_id)
except RuntimeError:
# Alignment was deleted, just clean up empties
alignment_tool.remove_pi_edit_empties(alignment_id)
return True
if apply:
# Collect PI positions from empties
hpoints, radii = alignment_tool.collect_pis_from_empties(alignment_id)
if len(hpoints) < 2:
raise ValueError("At least 2 PIs are required")
# Get horizontal layout (delegated to tool)
h_layout = alignment_tool.get_horizontal_layout(alignment)
if h_layout is None:
raise ValueError("Alignment has no horizontal layout")
# Remove empties before modifying segments
alignment_tool.remove_pi_edit_empties(alignment_id)
# Remove Blender visualization for segments (not the whole hierarchy)
alignment_tool.remove_layout_segment_objects(h_layout)
# Clear existing IFC segments and add new ones (delegated to tool)
alignment_tool.clear_layout_segments(h_layout)
alignment_tool.layout_by_pi_method(h_layout, hpoints, radii)
# Refresh Blender visualization for new segments
layout_obj = ifc_tool.get_object(h_layout)
if layout_obj:
alignment_tool.create_objects_for_layout_segments(h_layout, layout_obj)
return True
else:
# Cancel - just remove empties without regenerating
alignment_tool.remove_pi_edit_empties(alignment_id)
return True
+1 -3
View File
@@ -29,7 +29,6 @@ if TYPE_CHECKING:
def add_georeferencing(georeference: type[tool.Georeference]) -> None:
georeference.add_georeferencing()
georeference.set_model_origin()
def enable_editing_georeferencing(georeference: type[tool.Georeference]) -> None:
@@ -38,9 +37,8 @@ def enable_editing_georeferencing(georeference: type[tool.Georeference]) -> None
georeference.enable_editing()
def remove_georeferencing(ifc: type[tool.Ifc], georeference: type[tool.Georeference]) -> None:
def remove_georeferencing(ifc: type[tool.Ifc]) -> None:
ifc.run("georeference.remove_georeferencing")
georeference.set_model_origin()
def disable_editing_georeferencing(georeference: type[tool.Georeference]) -> None:
-1
View File
@@ -17,7 +17,6 @@
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
from bonsai.tool.aggregate import Aggregate
from bonsai.tool.alignment import Alignment
from bonsai.tool.attribute import Attribute
from bonsai.tool.bcf import Bcf
from bonsai.tool.blender import Blender
File diff suppressed because it is too large Load Diff
-10
View File
@@ -2165,13 +2165,3 @@ class Blender(bonsai.core.tool.Blender):
if cls.BLENDER_5:
return np.array(mathutils_type)
return np.array(mathutils_type, dtype=np.float32)
@classmethod
def get_selected_files(
cls, directory: str, files: bpy.types.OperatorFileListElement, use_relative_path=False
) -> list[Path]:
return [
tool.Ifc.get_uri(Path(directory) / f.name, use_relative_path=use_relative_path)
for f in files
if (Path(directory) / f.name).is_file()
]
-4
View File
@@ -123,10 +123,6 @@ class Bsdd(bonsai.core.tool.Bsdd):
@classmethod
def get_dictionaries(cls) -> list[bsdd.DictionaryContractV1]:
prefs = tool.Blender.get_addon_preferences()
baseurl = getattr(prefs, "bsdd_baseurl", "https://api.bsdd.buildingsmart.org/api/")
cls.client = bsdd.Client()
if hasattr(cls.client, "baseurl"):
cls.client.baseurl = baseurl
response = cls.client.get_dictionary(include_test_dictionaries=prefs.bsdd_load_test_dictionaries)
dicts = response.get("dictionaries") or []
statuses = ["Active"]
-8
View File
@@ -251,19 +251,11 @@ class Document(bonsai.core.tool.Document):
def get_document_references(
cls, document: ifcopenshell.entity_instance
) -> tuple[ifcopenshell.entity_instance, ...]:
# TODO: migrate to util.document and replace all instances
"""Get IfcDocumentReference.ReferencedDocuments, compatible with IFC2X3."""
if document.file.schema == "IFC2X3":
return document.DocumentReferences or ()
return document.HasDocumentReferences
@classmethod
def get_reference_document(cls, reference: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance | None:
# TODO: migrate to util.document and replace all instances
if reference.file.schema == "IFC2X3":
return (reference.ReferenceToDocument or (None))[0]
return reference.ReferencedDocument
@classmethod
def clear_active_document(cls) -> None:
props = cls.get_document_props()
+2 -4
View File
@@ -857,11 +857,9 @@ class Drawing(bonsai.core.tool.Drawing):
def edit_text_literals(cls, obj: bpy.types.Object, literal_attributes: dict) -> None:
assert (element := tool.Ifc.get_entity(obj))
assert (rep := cls.get_annotation_representation(element))
to_remove = [i for i in rep.Items if i.is_a("IfcTextLiteral")]
new_literals = [cls.add_literal(**a) for a in literal_attributes]
rep.Items = [i for i in rep.Items if not i.is_a("IfcTextLiteral")] + new_literals
for literal in to_remove:
for literal in cls.get_text_literal(obj, return_list=True):
ifcopenshell.util.element.remove_deep2(tool.Ifc.get(), literal)
rep.Items = [cls.add_literal(**a) for a in literal_attributes]
@classmethod
def add_literal(cls, **attributes: str) -> ifcopenshell.entity_instance:
+2 -20
View File
@@ -299,10 +299,10 @@ class Georeference(bonsai.core.tool.Georeference):
)
@classmethod
def enh2xyz(cls, coordinates: tuple[float, float, float], to_blender: bool = True) -> tuple[float, float, float]:
def enh2xyz(cls, coordinates: tuple[float, float, float]) -> tuple[float, float, float]:
coordinates = ifcopenshell.util.geolocation.auto_enh2xyz(tool.Ifc.get(), *coordinates)
props = cls.get_georeference_props()
if to_blender and props.has_blender_offset:
if props.has_blender_offset:
coordinates = ifcopenshell.util.geolocation.enh2xyz(
coordinates[0],
coordinates[1],
@@ -315,21 +315,6 @@ class Georeference(bonsai.core.tool.Georeference):
)
return coordinates
@classmethod
def global2local(cls, matrix, is_specified_in_map_units: bool) -> tuple[float, float, float]:
matrix = ifcopenshell.util.geolocation.auto_global2local(tool.Ifc.get(), matrix, is_specified_in_map_units=is_specified_in_map_units)
props = cls.get_georeference_props()
if props.has_blender_offset:
matrix = ifcopenshell.util.geolocation.global2local(
matrix,
float(props.blender_offset_x),
float(props.blender_offset_y),
float(props.blender_offset_z),
float(props.blender_x_axis_abscissa),
float(props.blender_x_axis_ordinate),
)
return matrix
@classmethod
def import_plot(cls, filepath: str) -> None:
import bmesh
@@ -400,9 +385,6 @@ class Georeference(bonsai.core.tool.Georeference):
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
gprops = tool.Georeference.get_georeference_props()
e, n, h = cls.xyz2enh((0, 0, 0), should_return_in_map_units=False)
crs = ifcopenshell.util.geolocation.get_crs(tool.Ifc.get()) or {}
gprops.model_is_georeferenced = bool(crs)
gprops.model_crs = crs.get("Name", "") or ""
gprops.model_origin = f"{e},{n},{h}"
gprops.model_origin_si = f"{e * unit_scale},{n * unit_scale},{h * unit_scale}"
angle = ifcopenshell.util.geolocation.get_grid_north(tool.Ifc.get())
+58 -67
View File
@@ -19,14 +19,10 @@
from __future__ import annotations
import os
import json
import math
import shutil
import numpy as np
from collections import defaultdict
from math import radians
from pathlib import Path
from typing import TYPE_CHECKING, NamedTuple, Optional
from typing import TYPE_CHECKING, NamedTuple, Optional, Union
import bpy
import ifcopenshell
@@ -62,47 +58,6 @@ class Project(bonsai.core.tool.Project):
assert (scene := bpy.context.scene)
return scene.MeasureToolSettings # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_link_empty_handle(cls, link) -> bpy.types.Object | None:
if tool.Ifc.get():
return tool.Ifc.get_object(tool.Ifc.get().by_id(link.ifc_definition_id))
return link.empty_handle
@classmethod
def set_link_empty_handle(cls, link, empty: bpy.types.Object) -> None:
if tool.Ifc.get():
tool.Ifc.link(tool.Ifc.get().by_id(link.ifc_definition_id), empty)
else:
link.empty_handle = empty
@classmethod
def calculate_link_matrix(cls, link) -> None:
filepath = Path(tool.Ifc.resolve_uri(link.filepath))
with open(filepath.with_suffix(".ifc.cache.json"), "r") as f:
metadata = json.load(f)
rot = ifcopenshell.util.shape_builder.np_rotation_matrix(
radians(-float(metadata["model_project_north"])), 4, "Z"
)
global_matrix = rot @ np.eye(4)
global_matrix[:, 3][:3] = [float(o) for o in metadata["model_origin_si"].split(",")]
if tool.Ifc.get():
transformation = tool.Ifc.get().by_id(link.ifc_definition_id)[1] # Identification
else:
transformation = link.transformation
if transformation:
transformation = np.fromstring(transformation, sep=",", dtype=np.float64).reshape(4, 4)
if not np.allclose(transformation, np.eye(4)):
global_matrix = transformation @ global_matrix
gprops = tool.Georeference.get_georeference_props()
rot = ifcopenshell.util.shape_builder.np_rotation_matrix(radians(-float(gprops.model_project_north)), 4, "Z")
local_matrix = rot @ np.eye(4)
local_matrix[:, 3][:3] = [float(o) for o in gprops.model_origin_si.split(",")]
return np.linalg.inv(local_matrix) @ global_matrix
@classmethod
def append_all_types_from_template(cls, template: str) -> None:
# TODO refactor
@@ -294,32 +249,68 @@ class Project(bonsai.core.tool.Project):
tool.Root.reload_grid_decorator()
@classmethod
def get_linked_models_documents(cls) -> dict[str, ifcopenshell.entity_instance]:
linked_docs = {}
for doc in tool.Ifc.get().by_type("IfcDocumentInformation"):
if doc.Scope == "LINKED_MODEL":
for reference in tool.Drawing.get_document_references(doc):
linked_docs[Path(reference.Location).as_posix()] = doc
break
return linked_docs
def get_linked_models_document(cls) -> Union[ifcopenshell.entity_instance, None]:
for document in tool.Ifc.get().by_type("IfcDocumentInformation"):
if document.Name == "BBIM_Linked_Models":
return document
@classmethod
def load_linked_models_from_ifc(cls) -> None:
links = tool.Project.get_project_props().links
links.clear()
for doc in tool.Ifc.get().by_type("IfcDocumentInformation"):
if doc.Scope != "LINKED_MODEL":
continue
for reference in tool.Drawing.get_document_references(doc):
filepath = reference.Location
link = links.add()
link.name = filepath
link.filepath = filepath
link.ifc_definition_id = reference.id()
link.has_transformation = False
if reference[1]:
m = np.fromstring(reference[1], sep=",", dtype=np.float64).reshape(4, 4)
link.has_transformation = not np.allclose(m, np.eye(4))
links_document = cls.get_linked_models_document()
if not links_document:
return
references = tool.Document.get_document_references(links_document)
if not references:
return
for reference in references:
link = links.add()
link.name = reference.Location
@classmethod
def save_linked_models_to_ifc(cls) -> None:
ifc_file = tool.Ifc.get()
links = tool.Project.get_project_props().links
filepaths: set[Path] = set()
for link in links:
filepaths.add(Path(link.name))
links_document = cls.get_linked_models_document()
if not filepaths and links_document is None:
return
paths_to_add = filepaths.copy()
references_to_remove: list[ifcopenshell.entity_instance] = []
if links_document:
references = tool.Document.get_document_references(links_document)
for reference in references:
# I guess got corrupted by the user.
if not (location := reference.Location):
references_to_remove.remove(reference)
continue
path = Path(location)
if path in paths_to_add:
paths_to_add.remove(path)
else:
references_to_remove.append(reference)
if paths_to_add:
if links_document is None:
links_document = ifcopenshell.api.document.add_information(ifc_file)
links_document.Name = "BBIM_Linked_Models"
links_document.Description = "Bonsai internal document containing references to currently linked models"
for path in paths_to_add:
reference = ifcopenshell.api.document.add_reference(ifc_file, links_document)
reference.Location = path.as_posix()
if references_to_remove:
for reference in references_to_remove:
ifcopenshell.api.document.remove_reference(ifc_file, reference)
@classmethod
def get_project_library_elements(
+14 -15
View File
@@ -474,10 +474,10 @@ class Root(bonsai.core.tool.Root):
obj.name = obj.name.split("/", 1)[1]
@classmethod
def get_ifc_products(cls) -> list[str]:
def get_ifc_products(cls) -> tuple[str, ...]:
version = tool.Ifc.get_schema()
if version == "IFC2X3":
return [
products = (
"IfcElementType",
"IfcElement",
"IfcFeatureElement",
@@ -485,17 +485,16 @@ class Root(bonsai.core.tool.Root):
"IfcStructuralItem",
"IfcAnnotation",
"IfcRelSpaceBoundary",
]
products = [
"IfcElementType",
"IfcElement",
"IfcFeatureElement",
"IfcSpatialElement",
"IfcSpatialElementType",
"IfcStructuralItem",
"IfcAnnotation",
"IfcRelSpaceBoundary",
]
if version != "IFC4":
products.append("IfcAlignment")
)
else:
products = (
"IfcElementType",
"IfcElement",
"IfcFeatureElement",
"IfcSpatialElement",
"IfcSpatialElementType",
"IfcStructuralItem",
"IfcAnnotation",
"IfcRelSpaceBoundary",
)
return products
+5 -1
View File
@@ -118,10 +118,14 @@ class Snap(bonsai.core.tool.Snap):
def get_angle_snap_value(cls, context: bpy.types.Context) -> float:
"""Get the angle snap increment from Blender's tool settings.
Uses snap_angle_increment_3d (Blender 5.0+) or snap_angle_increment (Blender 4.x).
:param context: Blender context
:return: Angle snap increment in degrees
"""
return math.degrees(context.scene.tool_settings.snap_angle_increment_3d)
if bpy.app.version >= (5, 0, 0):
return math.degrees(context.scene.tool_settings.snap_angle_increment_3d)
return math.degrees(context.scene.tool_settings.snap_angle_increment)
@classmethod
def get_snap_points_on_raycasted_face(cls, context, event, obj, face_index):
-1
View File
@@ -34,7 +34,6 @@ from bonsai.bim.ifc import IfcStore
# Monkey-patch webbrowser opening since we want to test headlessly
webbrowser.open = lambda x: True
tool.Drawing.open_with_user_command = lambda x, y: True
variables = {"cwd": os.getcwd(), "ifc": "IfcStore.get_file()"}
+6 -7
View File
@@ -3,6 +3,12 @@ Feature: Drawing
Scenario: Duplicate drawing
Given an empty IFC project
And I add a cube
And the object "Cube" is selected
And I look at the "Class" panel
And I set the "Products" property to "IfcElement"
And I set the "Class" property to "IfcWall"
And I click "Assign IFC Class"
And I save IFC project
And I look at the "Drawings" panel
And I click "IMPORT"
@@ -309,10 +315,3 @@ Scenario: Create sheet - with a drawing added to it
And I click "IMAGE_PLANE"
When I click "OUTPUT"
Then the file "{ifc_dir}/sheets/A01 - UNTITLED.svg" should contain "IfcWall"
Scenario: Add reference image
Given an empty IFC project
And I save IFC project
When I press "bim.add_reference_image(filepath='{cwd}/test/files/image.jpg')"
Then the object "IfcAnnotation/image" exists
And the object "IfcAnnotation/image" dimensions are "1.0,0.565,0."
+44 -67
View File
@@ -677,43 +677,31 @@ Scenario: Load project elements - all georeferencing coordinate situations with
And the object "IfcActuator/J" has a vertex at "10.366,3.813,-1"
And the object "IfcActuator/J" has a vertex at "12.298,4.331,-1"
Scenario: Link IFC - from an empty IFC project
Scenario: Link IFC
Given an empty IFC project
When I press "bim.link_ifc(filepath='{cwd}/test/files/basic.ifc')"
When I link IFC project from "{cwd}/test/files/basic.ifc"
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_loaded" is "True"
And the collection "IfcProject/basic.ifc" exists
And the object "Chunk" exists
And the object "Chunk" is placed in the collection "IfcProject/basic.ifc"
Scenario: Link IFC - disabled false origin mode
Given an empty IFC project
# Not currently possible via UI
And I set "scene.BIMProjectProperties.distance_limit" to "5"
And I set "scene.BIMProjectProperties.false_origin_mode" to "DISABLED"
When I link IFC project from "{cwd}/test/files/geolocation.ifc"
Then the object "Chunk" exists
And the object "Chunk" has a vertex at "2,2,-1"
And the object "Chunk" has a vertex at "9,-1,-1"
And the object "Chunk" has a vertex at "17,4,-1"
Scenario: Link IFC - from an empty IFC project - automatic false origin mode (0,0,0 will be the false origin)
Given an empty IFC project
# Not currently possible via UI
And I set "scene.BIMProjectProperties.distance_limit" to "5"
And I set "scene.BIMProjectProperties.false_origin_mode" to "AUTOMATIC"
When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
Then the object "Chunk" exists
And the object "Chunk" has a vertex at "2,2,-1"
And the object "Chunk" has a vertex at "9,-1,-1"
And the object "Chunk" has a vertex at "17,4,-1"
Scenario: Link IFC - from an empty IFC project - manual false origin mode
Given an empty IFC project
# Not currently possible via UI
And I set "scene.BIMProjectProperties.distance_limit" to "5"
And I set "scene.BIMProjectProperties.false_origin_mode" to "MANUAL"
And I set "scene.BIMProjectProperties.false_origin" to "10000,0,0"
When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
Then the object "Chunk" exists
And the object "Chunk" has a vertex at "2,2,-1"
And the object "Chunk" has a vertex at "9,-1,-1"
And the object "Chunk" has a vertex at "17,4,-1"
Scenario: Link IFC - from an empty IFC project - disabled false origin mode
Given an empty IFC project
# Not currently possible via UI
And I set "scene.BIMProjectProperties.distance_limit" to "5"
And I set "scene.BIMProjectProperties.false_origin_mode" to "DISABLED"
When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
When I link IFC project from "{cwd}/test/files/geolocation.ifc"
Then the object "Chunk" exists
And the object "Chunk" has a vertex at "2,2,-1"
And the object "Chunk" has a vertex at "9,-1,-1"
@@ -724,42 +712,31 @@ Scenario: Link IFC - from an empty Blender session - automatic false origin mode
# Not currently possible via UI
And I set "scene.BIMProjectProperties.distance_limit" to "5"
And I set "scene.BIMProjectProperties.false_origin_mode" to "AUTOMATIC"
When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
When I link IFC project from "{cwd}/test/files/geolocation.ifc"
Then the object "Chunk" exists
And the object "Chunk" has a vertex at "-11,-2,0"
And the object "Chunk" has a vertex at "-4,-5,0"
And the object "Chunk" has a vertex at "4,0,0"
Scenario: Link IFC - from an empty Blender session - manual false origin mode
Scenario: Link IFC - manual false origin mode
Given an empty Blender session
# Not currently possible via UI
And I set "scene.BIMProjectProperties.distance_limit" to "5"
And I set "scene.BIMProjectProperties.false_origin_mode" to "MANUAL"
And I set "scene.BIMProjectProperties.false_origin" to "10000,0,0"
When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
When I link IFC project from "{cwd}/test/files/geolocation.ifc"
Then the object "Chunk" exists
And the object "Chunk" has a vertex at "-8,2,-1"
And the object "Chunk" has a vertex at "-1,-1,-1"
And the object "Chunk" has a vertex at "7,4,-1"
Scenario: Link IFC - from an empty Blender session - disabled false origin mode
Given an empty Blender session
# Not currently possible via UI
And I set "scene.BIMProjectProperties.distance_limit" to "5"
And I set "scene.BIMProjectProperties.false_origin_mode" to "DISABLED"
When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
Then the object "Chunk" exists
And the object "Chunk" has a vertex at "2,2,-1"
And the object "Chunk" has a vertex at "9,-1,-1"
And the object "Chunk" has a vertex at "17,4,-1"
Scenario: Link IFC - from an empty Blender session - automatic false origin mode - two different false origins and project norths - grid north is up because we start with geolocation.ifc
Scenario: Link IFC - automatic false origin mode - two different false origins and project norths - grid north is up because we start with geolocation.ifc
Given an empty Blender session
# Not currently possible via UI
And I set "scene.BIMProjectProperties.distance_limit" to "5"
And I set "scene.BIMProjectProperties.false_origin_mode" to "AUTOMATIC"
When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
And I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation-mapconversion-angle.ifc', use_cache=False)"
When I link IFC project from "{cwd}/test/files/geolocation.ifc"
And I link IFC project from "{cwd}/test/files/geolocation-mapconversion-angle.ifc"
Then the object "Col:IfcProject/geolocation.ifc:Chunk" exists
And the object "Col:IfcProject/geolocation-mapconversion-angle.ifc:Chunk" exists
And the object "Col:IfcProject/geolocation.ifc:Chunk" has a vertex at "-11,-2,0"
@@ -769,13 +746,13 @@ Scenario: Link IFC - from an empty Blender session - automatic false origin mode
And the object "Col:IfcProject/geolocation-mapconversion-angle.ifc:Chunk" has a vertex at "9.294,-9.366,0"
And the object "Col:IfcProject/geolocation-mapconversion-angle.ifc:Chunk" has a vertex at "18.722,-9.036,0"
Scenario: Link IFC - from an empty Blender session - automatic false origin mode - two different false origins and project norths - project north is up because we start with geolocation-mapconversion-angle.ifc
Scenario: Link IFC - automatic false origin mode - two different false origins and project norths - project north is up because we start with geolocation-mapconversion-angle.ifc
Given an empty Blender session
# Not currently possible via UI
And I set "scene.BIMProjectProperties.distance_limit" to "5"
And I set "scene.BIMProjectProperties.false_origin_mode" to "AUTOMATIC"
When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation-mapconversion-angle.ifc', use_cache=False)"
And I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
When I link IFC project from "{cwd}/test/files/geolocation-mapconversion-angle.ifc"
And I link IFC project from "{cwd}/test/files/geolocation.ifc"
Then the object "Col:IfcProject/geolocation.ifc:Chunk" exists
And the object "Col:IfcProject/geolocation-mapconversion-angle.ifc:Chunk" exists
And the object "Col:IfcProject/geolocation-mapconversion-angle.ifc:Chunk" has a vertex at "-11,-2,0"
@@ -785,14 +762,14 @@ Scenario: Link IFC - from an empty Blender session - automatic false origin mode
And the object "Col:IfcProject/geolocation.ifc:Chunk" has a vertex at "-17.696,-7.866,0"
And the object "Col:IfcProject/geolocation.ifc:Chunk" has a vertex at "-13.268,0.464,0"
Scenario: Link IFC - from an empty Blender session - automatic false origin mode - three identical false origins but different project and map units
Scenario: Link IFC - automatic false origin mode - three identical false origins but different project and map units
Given an empty Blender session
# Not currently possible via UI
And I set "scene.BIMProjectProperties.distance_limit" to "5"
And I set "scene.BIMProjectProperties.false_origin_mode" to "AUTOMATIC"
When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation-unit1.ifc', use_cache=False)"
And I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation-unit2.ifc', use_cache=False)"
And I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation-unit3.ifc', use_cache=False)"
When I link IFC project from "{cwd}/test/files/geolocation-unit1.ifc"
And I link IFC project from "{cwd}/test/files/geolocation-unit2.ifc"
And I link IFC project from "{cwd}/test/files/geolocation-unit3.ifc"
Then the object "Col:IfcProject/geolocation-unit1.ifc:Chunk" exists
And the object "Col:IfcProject/geolocation-unit2.ifc:Chunk" exists
And the object "Col:IfcProject/geolocation-unit3.ifc:Chunk" exists
@@ -802,54 +779,54 @@ Scenario: Link IFC - from an empty Blender session - automatic false origin mode
Scenario: Toggle link visibility - wireframe mode
Given an empty IFC project
And I press "bim.link_ifc(filepath='{cwd}/test/files/basic.ifc')"
When I press "bim.toggle_link_visibility(link_index=0, mode='WIREFRAME')"
And I link IFC project from "{cwd}/test/files/basic.ifc"
When I press "bim.toggle_link_visibility(link='{cwd}/test/files/basic.ifc', mode='WIREFRAME')"
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_wireframe" is "True"
And the object "Chunk" should display as "WIRE"
When I press "bim.toggle_link_visibility(link_index=0, mode='WIREFRAME')"
When I press "bim.toggle_link_visibility(link='{cwd}/test/files/basic.ifc', mode='WIREFRAME')"
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_wireframe" is "False"
And the object "Chunk" should display as "TEXTURED"
Scenario: Toggle link selectability
Given an empty IFC project
And I press "bim.link_ifc(filepath='{cwd}/test/files/basic.ifc')"
When I press "bim.toggle_link_selectability(link_index=0)"
And I link IFC project from "{cwd}/test/files/basic.ifc"
When I press "bim.toggle_link_selectability(link='{cwd}/test/files/basic.ifc')"
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_selectable" is "False"
And the collection "IfcProject/basic.ifc" is unselectable
When I press "bim.toggle_link_selectability(link_index=0)"
When I press "bim.toggle_link_selectability(link='{cwd}/test/files/basic.ifc')"
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_selectable" is "True"
And the collection "IfcProject/basic.ifc" is selectable
Scenario: Toggle link visibility - visible mode
Given an empty IFC project
And I press "bim.link_ifc(filepath='{cwd}/test/files/basic.ifc')"
When I press "bim.toggle_link_visibility(link_index=0, mode='VISIBLE')"
And I link IFC project from "{cwd}/test/files/basic.ifc"
When I press "bim.toggle_link_visibility(link='{cwd}/test/files/basic.ifc', mode='VISIBLE')"
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_hidden" is "True"
And the object "IfcProject/basic.ifc" is not visible
When I press "bim.toggle_link_visibility(link_index=0, mode='VISIBLE')"
When I press "bim.toggle_link_visibility(link='{cwd}/test/files/basic.ifc', mode='VISIBLE')"
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_hidden" is "False"
And the object "IfcProject/basic.ifc" is visible
Scenario: Unload link
Given an empty Blender session
And I press "bim.link_ifc(filepath='{cwd}/test/files/basic.ifc')"
When I press "bim.unload_link(link_index=0)"
And I link IFC project from "{cwd}/test/files/basic.ifc"
When I press "bim.unload_link(filepath='{cwd}/test/files/basic.ifc')"
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_loaded" is "False"
And the collection "IfcProject/basic.ifc" does not exist
Scenario: Load link
Given an empty Blender session
And I press "bim.link_ifc(filepath='{cwd}/test/files/basic.ifc')"
And I press "bim.unload_link(link_index=0)"
When I press "bim.load_link(link_index=0)"
And I link IFC project from "{cwd}/test/files/basic.ifc"
And I press "bim.unload_link(filepath='{cwd}/test/files/basic.ifc')"
When I press "bim.load_link(filepath='{cwd}/test/files/basic.ifc')"
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_loaded" is "True"
And the object "IfcProject/basic.ifc" exists
Scenario: Unlink IFC
Given an empty Blender session
And I press "bim.link_ifc(filepath='{cwd}/test/files/basic.ifc')"
And I press "bim.unload_link(link_index=0)"
When I press "bim.unlink_ifc(link_index=0)"
And I link IFC project from "{cwd}/test/files/basic.ifc"
And I press "bim.unload_link(filepath='{cwd}/test/files/basic.ifc')"
When I press "bim.unlink_ifc(filepath='{cwd}/test/files/basic.ifc')"
Then "scene.BIMProjectProperties.links.get('{cwd}/test/files/basic.ifc')" is "None"
And "scene.collection.children.get('IfcProject/basic.ifc')" is "None"
And the object "Chunk" does not exist
+37 -16
View File
@@ -70,7 +70,10 @@ Can be useful for debugging, but has caveats - can't use ``wm.read_homefile``
as resets the ``bpy.context`` and some it's members become `None`.
"""
TMP = Path(f"{variables['cwd']}/test/files/temp")
TMP = Path.cwd() / "test/files/temp"
TEST_FILES_DIR = Path.cwd() / "test/files"
CLEAN_LINKED_FILES_CACHE = False
EPSET_DRAWING = Path.cwd() / "bonsai/bim/data/pset/EPset_Drawing.ifc"
EPSET_DRAWING_BYTES = EPSET_DRAWING.read_bytes()
@@ -280,8 +283,6 @@ def create_ui_name_cache():
try:
panel_type = getattr(bpy.types, bl_idname)
if panel_type.bl_rna.base.name == "Panel":
if "_tab_" in panel_type.bl_idname:
continue # Tab panels are just groups and not relevant in testing
ui_name_cache[panel_type.bl_label] = panel_type.bl_idname
elif panel_type.bl_rna.base.name == "Operator":
ui_name_cache[panel_type.bl_label] = bl_idname
@@ -310,6 +311,25 @@ def vectors_are_equal(v1, v2):
return all(is_x(v1[i], v2[i]) for i in range(len(v1)))
@pytest.fixture(scope="function", autouse=True)
def run_for_each_test() -> Generator[None]:
# Code before this runs before each test
yield
# Code after this runs after each test
global CLEAN_LINKED_FILES_CACHE
if CLEAN_LINKED_FILES_CACHE:
for filepath in TEST_FILES_DIR.glob("*.ifc.cache.*"):
filepath.unlink()
CLEAN_LINKED_FILES_CACHE = False
# pset_template tests are editing EPset_Drawing.ifc, so we need to restore it.
global RELOAD_EPSET_DRAWING
if RELOAD_EPSET_DRAWING:
EPSET_DRAWING.write_bytes(EPSET_DRAWING_BYTES)
RELOAD_EPSET_DRAWING = False
@given("an untestable scenario")
def an_untestable_scenario():
pass
@@ -364,6 +384,16 @@ def saving_ifc_project() -> None:
tool.Project.save_test_project()
@given(parsers.parse('I link IFC project from "{filepath}"'))
@when(parsers.parse('I link IFC project from "{filepath}"'))
@then(parsers.parse('I link IFC project from "{filepath}"'))
def i_link_ifc_project_from_filepath(filepath: str) -> None:
global CLEAN_LINKED_FILES_CACHE
filepath = replace_variables(filepath)
CLEAN_LINKED_FILES_CACHE = True
bpy.ops.bim.link_ifc(filepath=filepath, use_cache=False)
@given("the Brickschema is stubbed")
def the_brickschema_is_stubbed():
# This makes things run faster since we don't need to load the entire brick schema
@@ -1557,19 +1587,10 @@ def the_object_name_has_a_vertex_at_location(name, location):
is_pass = False
target = Vector([float(co) for co in location.split(",")])
verts = []
depsgraph = bpy.context.evaluated_depsgraph_get()
obj_eval = obj.evaluated_get(depsgraph)
mesh = obj_eval.to_mesh(preserve_all_data_layers=False, depsgraph=depsgraph)
try:
for inst in depsgraph.object_instances:
if inst.object.original is obj:
mw = inst.matrix_world
for i, v in enumerate(mesh.vertices):
verts.append(mw @ v.co)
if (verts[-1] - target).length < 0.001:
is_pass = True
finally:
obj_eval.to_mesh_clear()
for v in obj.data.vertices:
verts.append(obj.matrix_world @ v.co)
if (verts[-1] - target).length < 0.001:
is_pass = True
assert is_pass, f"No verts found at {location}: {verts}"
-34
View File
@@ -24,10 +24,8 @@ import pytest
import bonsai.core.tool
import bonsai.tool as tool
import tempfile
from bonsai.tool.blender import Blender as subject
from test.bim.bootstrap import NewFile
from pathlib import Path
if TYPE_CHECKING:
import bpy.stub_internal.rna_enums as rna_enums
@@ -112,35 +110,3 @@ class TestBlenderErrorMessageExtraction(NewFile):
assert error_reports == []
bpy.utils.unregister_class(OBJECT_OT_test_fail_operator)
class TestGetSelectedFiles(NewFile):
def test_get_a_single_file(self) -> None:
with tempfile.NamedTemporaryFile() as f:
file = type("", (object,), {"name": f.name})()
assert subject.get_selected_files(Path(f.name).parent, [file]) == [f.name]
def test_get_multiple_files(self) -> None:
with tempfile.NamedTemporaryFile() as f:
with tempfile.NamedTemporaryFile() as g:
file = type("", (object,), {"name": f.name})()
file2 = type("", (object,), {"name": g.name})()
assert subject.get_selected_files(Path(f.name).parent, [file, file2]) == [f.name, g.name]
def test_exclude_directories(self) -> None:
with tempfile.NamedTemporaryFile() as f:
with tempfile.TemporaryDirectory() as d:
file = type("", (object,), {"name": f.name})()
directory = type("", (object,), {"name": d})()
assert subject.get_selected_files(Path(f.name).parent, [file, directory]) == [f.name]
def test_get_relative_paths(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
base_path = Path(tmp_dir)
with tempfile.NamedTemporaryFile(dir=tmp_dir, suffix=".ifc") as f:
tool.Ifc.set_path(str(f.name))
with tempfile.NamedTemporaryFile(dir=tmp_dir) as g:
file = type("", (object,), {"name": g.name})
assert subject.get_selected_files(Path(g.name).parent, [file], use_relative_path=True) == [
Path(g.name).name
]
+69 -31
View File
@@ -21,7 +21,6 @@ import xml.etree.ElementTree as ET
from pathlib import Path
import bpy
import pytest
import ifcopenshell
import ifcopenshell.api.drawing
import ifcopenshell.api.group
@@ -32,7 +31,6 @@ import ifcopenshell.util.element
import mathutils
import numpy as np
from mathutils import Vector
from ifcopenshell.util.shape_builder import ShapeBuilder
import bonsai.core.tool
import bonsai.tool as tool
@@ -152,34 +150,6 @@ class TestDisableEditingSheets(NewFile):
assert props.is_editing_sheets == False
class TestEditTextLiterals(NewFile):
def test_run(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
obj = bpy.data.objects.new("Object", None)
element = ifc.createIfcAnnotation()
element.Representation = ifc.createIfcProductDefinitionShape()
context = ifc.createIfcGeometricRepresentationSubContext(ContextType="Plan", ContextIdentifier="Annotation")
item = ifc.createIfcTextLiteralWithExtent(Literal="Literal", Path="RIGHT", BoxAlignment="bottom-left")
builder = ShapeBuilder(tool.Ifc.get())
polyline = builder.polyline([(0.,0.,0.), (1.,0.,0.)])
representation = ifc.createIfcShapeRepresentation(ContextOfItems=context, Items=[item, polyline])
element.Representation.Representations = [representation]
tool.Ifc.link(element, obj)
literal_attributes = [
{
"Literal": "Foo",
"Path": "RIGHT",
"BoxAlignment": "bottom-left",
}
]
subject.edit_text_literals(obj, literal_attributes)
assert len(ifc.by_type("IfcTextLiteralWithExtent")) == 1
literal = ifc.by_type("IfcTextLiteralWithExtent")[0]
assert literal in representation.Items
assert literal.Literal == "Foo"
class TestDisableEditingText(NewFile):
def test_run(self):
obj = bpy.data.objects.new("Object", None)
@@ -938,7 +908,7 @@ class TestAddReferenceImage(NewFile):
obj = bpy.data.objects["IfcAnnotation/image"]
assert obj is not None
assert tool.Cad.are_vectors_equal(obj.dimensions, Vector((1.0, 0.565, 0.0)))
assert tool.Cad.are_vectors_equal(obj.dimensions, Vector((3.53982, 2.0, 0.0)))
material = obj.active_material
assert material
@@ -958,3 +928,71 @@ class TestAddReferenceImage(NewFile):
uv_node = material_nodes["Texture Coordinate"]
assert len(uv_node.outputs["Generated"].links[:]) == 1
class TestAddReference(NewFile):
def test_add_single_reference(self):
"""Test adding a single reference file (backward compatibility)"""
bpy.ops.bim.create_project()
ifc_path = Path("test/files/temp/test.ifc").absolute()
bpy.ops.bim.save_project(filepath=str(ifc_path), should_save_as=True)
# Create a temporary SVG file
svg_path = Path("test/files/temp/reference.svg").absolute()
svg_path.parent.mkdir(parents=True, exist_ok=True)
with open(svg_path, "w") as f:
f.write('<svg xmlns="http://www.w3.org/2000/svg"></svg>')
try:
# Add single reference
bpy.ops.bim.add_reference(filepath=str(svg_path))
# Verify reference was added
ifc = tool.Ifc.get()
references = [doc for doc in ifc.by_type("IfcDocumentInformation") if doc.Scope == "REFERENCE"]
assert len(references) == 1
assert references[0].Name == "reference"
finally:
# Cleanup
if svg_path.exists():
svg_path.unlink()
def test_add_multiple_references(self):
"""Test adding multiple reference files at once"""
bpy.ops.bim.create_project()
ifc_path = Path("test/files/temp/test.ifc").absolute()
bpy.ops.bim.save_project(filepath=str(ifc_path), should_save_as=True)
# Create temporary SVG files
temp_dir = Path("test/files/temp").absolute()
temp_dir.mkdir(parents=True, exist_ok=True)
svg_files = []
for i in range(3):
svg_path = temp_dir / f"reference_{i}.svg"
with open(svg_path, "w") as f:
f.write('<svg xmlns="http://www.w3.org/2000/svg"></svg>')
svg_files.append(svg_path)
try:
# Test by directly calling core.add_document multiple times
# (simulating what the operator does with multiple files)
ifc = tool.Ifc.get()
for svg_file in svg_files:
uri = tool.Ifc.get_uri(str(svg_file), use_relative_path=True)
from bonsai.bim import core
core.drawing.add_document(tool.Ifc, tool.Drawing, "REFERENCE", uri=uri)
# Verify all references were added
references = [doc for doc in ifc.by_type("IfcDocumentInformation") if doc.Scope == "REFERENCE"]
assert len(references) == 3
reference_names = {ref.Name for ref in references}
expected_names = {f"reference_{i}" for i in range(3)}
assert reference_names == expected_names
finally:
# Cleanup
for svg_file in svg_files:
if svg_file.exists():
svg_file.unlink()
+72 -70
View File
@@ -16,12 +16,9 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import json
import contextlib
import tempfile
import numpy as np
from pathlib import Path
from tempfile import NamedTemporaryFile
import bpy
import ifcopenshell
@@ -266,7 +263,7 @@ class TestLoadLinkedModels(NewFile):
props = tool.Project.get_project_props()
ifcopenshell.api.root.create_entity(ifc, "IfcProject")
document = ifcopenshell.api.document.add_information(ifc)
document.Name = "X"
document.Name = "BBIM_Linked_Models"
tool.Ifc.set(ifc)
subject.load_linked_models_from_ifc()
assert len(props.links) == 0
@@ -276,81 +273,86 @@ class TestLoadLinkedModels(NewFile):
props = tool.Project.get_project_props()
ifcopenshell.api.root.create_entity(ifc, "IfcProject")
document = ifcopenshell.api.document.add_information(ifc)
document.Scope = "LINKED_MODEL"
document.Name = "BBIM_Linked_Models"
reference = ifcopenshell.api.document.add_reference(ifc, document)
reference.Location = "test.ifc"
reference.Identification = ""
reference2 = ifcopenshell.api.document.add_reference(ifc, document)
reference2.Location = "test2.ifc"
reference2.Identification = "1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16"
linked_model_path = "test.ifc"
reference.Location = linked_model_path
tool.Ifc.set(ifc)
subject.load_linked_models_from_ifc()
assert len(props.links) == 2
assert props.links[0].name == "test.ifc"
assert props.links[0].ifc_definition_id == reference.id()
assert props.links[0].has_transformation is False
assert props.links[1].name == "test2.ifc"
assert props.links[1].ifc_definition_id == reference2.id()
assert props.links[1].has_transformation is True
assert len(props.links) == 1
assert props.links[0].name == linked_model_path
class TestCalculateLinkMatrix(NewFile):
def test_linking_a_model_without_an_offset_to_our_session_with_no_offset(self):
class TestSaveLinkedModelsToIfc(NewFile):
def test_save_linked_models_to_ifc_no_links(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
subject.save_linked_models_to_ifc()
assert len(ifc.by_type("IfcDocumentInformation")) == 0
assert len(ifc.by_type("IfcDocumentReference")) == 0
def test_save_linked_models_to_ifc_paths_to_add(self):
ifc = ifcopenshell.file()
ifcopenshell.api.root.create_entity(ifc, "IfcProject")
props = tool.Project.get_project_props()
gprops = tool.Georeference.get_georeference_props()
with NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=True) as tmp:
link = props.links.add()
link.filepath = tmp.name.replace(".ifc.cache.json", ".ifc")
json.dump({"model_project_north": "0", "model_origin_si": "0,0,0"}, tmp)
tmp.flush()
gprops.model_project_north = "0"
gprops.model_origin_si = "0,0,0"
assert np.allclose(subject.calculate_link_matrix(link), np.eye(4))
link = props.links.add()
linked_model_path = "test.ifc"
link.name = linked_model_path
tool.Ifc.set(ifc)
subject.save_linked_models_to_ifc()
assert len(documents := ifc.by_type("IfcDocumentInformation")) == 1
assert documents[0].Name == "BBIM_Linked_Models"
assert len(references := ifc.by_type("IfcDocumentReference")) == 1
assert references[0].Location == linked_model_path
def test_linking_an_offset_model_to_our_session_with_no_offset(self):
props = tool.Project.get_project_props()
gprops = tool.Georeference.get_georeference_props()
with NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=True) as tmp:
link = props.links.add()
link.filepath = tmp.name.replace(".ifc.cache.json", ".ifc")
json.dump({"model_project_north": "0", "model_origin_si": "5,0,0"}, tmp)
tmp.flush()
gprops.model_project_north = "0"
gprops.model_origin_si = "0,0,0"
m = np.eye(4)
m[0][3] = 5
assert np.allclose(subject.calculate_link_matrix(link), m)
def test_save_linked_models_to_ifc_already_created_references(self):
ifc = ifcopenshell.file()
links = tool.Project.get_project_props().links
ifcopenshell.api.root.create_entity(ifc, "IfcProject")
def test_linking_an_offset_model_to_our_session_with_offset(self):
props = tool.Project.get_project_props()
gprops = tool.Georeference.get_georeference_props()
with NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=True) as tmp:
link = props.links.add()
link.filepath = tmp.name.replace(".ifc.cache.json", ".ifc")
json.dump({"model_project_north": "0", "model_origin_si": "5,0,0"}, tmp)
tmp.flush()
gprops.model_project_north = "0"
gprops.model_origin_si = "2,0,0"
m = np.eye(4)
m[0][3] = 3
assert np.allclose(subject.calculate_link_matrix(link), m)
document = ifcopenshell.api.document.add_information(ifc)
document.Name = "BBIM_Linked_Models"
document_id = document.id()
reference = ifcopenshell.api.document.add_reference(ifc, document)
linked_model_path = "test.ifc"
reference.Location = linked_model_path
reference_id = reference.id()
def test_linking_an_offset_model_to_our_session_with_offset_and_transformation(self):
props = tool.Project.get_project_props()
gprops = tool.Georeference.get_georeference_props()
with NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=True) as tmp:
link = props.links.add()
link.filepath = tmp.name.replace(".ifc.cache.json", ".ifc")
transformation = np.eye(4)
transformation[0][3] = 4
link.transformation = ",".join(map(str, transformation.reshape(-1)))
json.dump({"model_project_north": "0", "model_origin_si": "5,0,0"}, tmp)
tmp.flush()
gprops.model_project_north = "0"
gprops.model_origin_si = "2,0,0"
m = np.eye(4)
m[0][3] = 7
assert np.allclose(subject.calculate_link_matrix(link), m)
link = links.add()
linked_model_path = "test.ifc"
link.name = linked_model_path
tool.Ifc.set(ifc)
subject.save_linked_models_to_ifc()
# Information and references to stay intact.
assert len(documents := ifc.by_type("IfcDocumentInformation")) == 1
assert documents[0].id() == document_id
assert documents[0].Name == "BBIM_Linked_Models"
assert len(references := ifc.by_type("IfcDocumentReference")) == 1
assert references[0].id() == reference_id
assert references[0].Location == linked_model_path
def test_save_linked_models_to_ifc_references_to_remove(self):
ifc = ifcopenshell.file()
links = tool.Project.get_project_props().links
ifcopenshell.api.root.create_entity(ifc, "IfcProject")
document = ifcopenshell.api.document.add_information(ifc)
document.Name = "BBIM_Linked_Models"
document_id = document.id()
reference = ifcopenshell.api.document.add_reference(ifc, document)
linked_model_path = "test.ifc"
reference.Location = linked_model_path
tool.Ifc.set(ifc)
subject.save_linked_models_to_ifc()
links.clear()
# Remove reference for removed link.
assert len(documents := ifc.by_type("IfcDocumentInformation")) == 1
assert documents[0].id() == document_id
assert documents[0].Name == "BBIM_Linked_Models"
assert len(ifc.by_type("IfcDocumentReference")) == 0
class TestLoadingIfcSqlite(NewFile):
@@ -61,9 +61,33 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcSectionedSolidHorizontal* in
longitudes.push_back(*pbde->DistanceAlong()->as<IfcSchema::IfcLengthMeasure>(true) * length_unit_);
auto linear_placement = taxonomy::cast<taxonomy::matrix4>(map(csp));
profile_offsets.push_back(linear_placement->ccomponents().block<3, 1>(0, 3));
boost::optional<Eigen::Matrix3d> rot(linear_placement->ccomponents().block<3,3>(0,0));
// Corresponds to the profile X, Y directions (hopefully).
Eigen::Vector3d po(
pbde->OffsetLateral().get_value_or(0.),
// @todo I don't understand whether vertical is an offset relative to the tangent plane or to the global XY plane
pbde->OffsetVertical().get_value_or(0.),
0.
);
profile_offsets.push_back(po);
boost::optional<Eigen::Matrix3d> rot;
if (csp->Axis() && csp->RefDirection()) {
rot = taxonomy::matrix4(
Eigen::Vector3d(0, 0, 0),
taxonomy::cast<taxonomy::direction3>(map(csp->Axis()))->ccomponents(),
taxonomy::cast<taxonomy::direction3>(map(csp->RefDirection()))->ccomponents()).ccomponents().block<3,3>(0,0);
} else if (csp->Axis()) {
rot = taxonomy::matrix4(
Eigen::Vector3d(0, 0, 0),
taxonomy::cast<taxonomy::direction3>(map(csp->Axis()))->ccomponents()).ccomponents().block<3, 3>(0, 0);
} else if (csp->RefDirection()) {
rot = taxonomy::matrix4(
Eigen::Vector3d(0, 0, 0),
Eigen::Vector3d(0, 0, 1),
taxonomy::cast<taxonomy::direction3>(map(csp->RefDirection()))->ccomponents()
).ccomponents().block<3, 3>(0, 0);
}
profile_rotations.push_back(rot);
}
if (faces.size() != profile_offsets.size()) {
+29 -4
View File
@@ -63,10 +63,35 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcSectionedSurface* inst) {
longitudes.push_back(*pbde->DistanceAlong()->as<IfcSchema::IfcLengthMeasure>(true) * length_unit_);
auto linear_placement = taxonomy::cast<taxonomy::matrix4>(map(csp));
profile_offsets.push_back(linear_placement->ccomponents().block<3, 1>(0, 3));
boost::optional<Eigen::Matrix3d> rot(linear_placement->ccomponents().block<3, 3>(0, 0));
profile_rotations.push_back(rot);
// Corresponds to the profile X, Y directions (hopefully).
Eigen::Vector3d po(
pbde->OffsetLateral().get_value_or(0.),
// @todo I don't understand whether vertical is an offset relative to the tangent plane or to the global XY plane
pbde->OffsetVertical().get_value_or(0.),
0.
);
profile_offsets.push_back(po);
boost::optional<Eigen::Matrix3d> rot;
if (csp->Axis() && csp->RefDirection()) {
rot = taxonomy::matrix4(
Eigen::Vector3d(0, 0, 0),
taxonomy::cast<taxonomy::direction3>(map(csp->Axis()))->ccomponents(),
taxonomy::cast<taxonomy::direction3>(map(csp->RefDirection()))->ccomponents()).ccomponents().block<3, 3>(0, 0);
} else if (csp->Axis()) {
rot = taxonomy::matrix4(
Eigen::Vector3d(0, 0, 0),
taxonomy::cast<taxonomy::direction3>(map(csp->Axis()))->ccomponents()).ccomponents().block<3, 3>(0, 0);
} else if (csp->RefDirection()) {
rot = taxonomy::matrix4(
Eigen::Vector3d(0, 0, 0),
Eigen::Vector3d(0, 0, 1),
taxonomy::cast<taxonomy::direction3>(map(csp->RefDirection()))->ccomponents())
.ccomponents()
.block<3, 3>(0, 0);
}
profile_rotations.push_back(rot);
}
#else
return nullptr;
@@ -51,7 +51,6 @@ from ._get_segment_start_point_label import register_referent_name_callback
from .add_stationing_referent import add_stationing_referent
from .add_vertical_layout import add_vertical_layout
from .add_zero_length_segment import add_zero_length_segment
from .clear_layout_segments import clear_layout_segments
from .create import create
from .create_as_offset_curve import create_as_offset_curve
from .create_as_polyline import create_as_polyline
@@ -63,7 +62,6 @@ from .create_segment_representations import create_segment_representations
from .distance_along_from_station import distance_along_from_station
from .get_alignment import get_alignment
from .get_alignment_layout_nest import get_alignment_layout_nest
from .get_alignment_layout import get_alignment_layout
from .get_alignment_layouts import get_alignment_layouts
from .get_alignment_segment_nest import get_alignment_segment_nest
from .get_alignment_start_station import get_alignment_start_station
@@ -88,16 +86,13 @@ from .layout_vertical_alignment_by_pi_method import (
layout_vertical_alignment_by_pi_method,
)
from .name_segments import name_segments
from .segment_vertices import segment_vertices
from .update_fallback_position import update_fallback_position
from ._create_geometric_representation import _create_geometric_representation
from .util import *
__all__ = [
"add_stationing_referent",
"add_vertical_layout",
"add_zero_length_segment",
"clear_layout_segments",
"create",
"create_as_offset_curve",
"create_as_polyline",
@@ -106,11 +101,9 @@ __all__ = [
"create_layout_segment",
"create_representation",
"create_segment_representations",
"_create_geometric_representation", # TODO I know I know
"distance_along_from_station",
"get_alignment",
"get_alignment_layout_nest",
"get_alignment_layout",
"get_alignment_layouts",
"get_alignment_segment_nest",
"get_alignment_start_station",
@@ -130,7 +123,6 @@ __all__ = [
"layout_horizontal_alignment_by_pi_method",
"layout_vertical_alignment_by_pi_method",
"name_segments",
"segment_vertices",
"register_referent_name_callback",
"update_fallback_position",
"get_mapped_segments",
@@ -109,8 +109,6 @@ def _add_segment_to_curve(file: ifcopenshell.file, segment: entity_instance, cur
at the end of the curve, but before the manditory zero length segment. The IfcCurveSegment.Transition for the segment
that preceeds the new segment is updated.
The geometric representation is also added to the IfcCurveSegment based on CT 4.1.7.1.1.4 Alignment Geometry - Segments
:param segment: The segment to be added to the curve
:param curve: The representation curve receiving the segment
:return: None
@@ -143,23 +141,6 @@ def _add_segment_to_curve(file: ifcopenshell.file, segment: entity_instance, cur
else:
assert False
items = []
for mapped_segment in mapped_segments:
if mapped_segment:
_add_curve_segment_to_composite_curve(file, mapped_segment, curve)
items.append(mapped_segment)
axis_geom_subcontext = ifcopenshell.api.alignment.get_axis_subcontext(file)
axis_representation = file.createIfcShapeRepresentation(
ContextOfItems=axis_geom_subcontext, RepresentationIdentifier="Axis", RepresentationType="Segment", Items=items
)
product = file.createIfcProductDefinitionShape(Representations=(axis_representation,))
layout = ifcopenshell.api.alignment.get_alignment_layout(segment)
alignment = ifcopenshell.api.alignment.get_alignment(layout)
if alignment != None:
segment.ObjectPlacement = alignment.ObjectPlacement
segment.Representation = product
@@ -36,7 +36,7 @@ def _create_offset_curve_representation(
expected_type = "IfcAlignment"
if not alignment.is_a(expected_type):
raise TypeError(f"Expected {expected_type} but got {alignment.is_a()}")
expected_type = "IfcPointByDistanceExpression"
for offset in offsets:
if not offset.is_a(expected_type):
@@ -1,220 +0,0 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import ifcopenshell
import ifcopenshell.api.alignment
import ifcopenshell.api.nest
import ifcopenshell.util.element
from ifcopenshell import entity_instance
def _is_zero_length_segment(segment: entity_instance) -> bool:
"""Check if segment is a zero-length terminator."""
dp = segment.DesignParameters
if dp.is_a("IfcAlignmentHorizontalSegment"):
return dp.SegmentLength == 0.0
elif dp.is_a("IfcAlignmentVerticalSegment"):
return dp.HorizontalLength == 0.0
elif dp.is_a("IfcAlignmentCantSegment"):
return dp.HorizontalLength == 0.0
return False
def clear_layout_segments(file: ifcopenshell.file, layout: entity_instance) -> None:
"""
Clear all segments from a layout while preserving the layout entity
and zero-length terminator.
This function removes:
- All real (non-zero-length) IfcAlignmentSegment entities from the layout
- Their associated IfcCurveSegment entities from the geometric representation
- Referents positioned on the removed segments
It preserves:
- The layout entity (IfcAlignmentHorizontal, IfcAlignmentVertical, or IfcAlignmentCant)
- The zero-length terminator segment (required by IFC spec)
- The alignment's main stationing referent
:param file: The IFC file
:param layout: An IfcAlignmentHorizontal, IfcAlignmentVertical, or IfcAlignmentCant
Example:
.. code:: python
alignment = model.by_type("IfcAlignment")[0]
h_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
# Clear existing segments
ifcopenshell.api.alignment.clear_layout_segments(model, h_layout)
# Add new segments with updated PI positions
ifcopenshell.api.alignment.layout_horizontal_alignment_by_pi_method(
model, h_layout, new_hpoints, new_radii
)
"""
expected_types = ["IfcAlignmentHorizontal", "IfcAlignmentVertical", "IfcAlignmentCant"]
if layout.is_a() not in expected_types:
raise TypeError(f"Expected entity type to be one of {expected_types}, instead received {layout.is_a()}")
# Get the geometric curve for this layout
curve = ifcopenshell.api.alignment.get_layout_curve(layout)
# Get all segments from the layout
segments = ifcopenshell.api.alignment.get_layout_segments(layout)
if not segments:
return # Nothing to clear
# Identify segments to remove (all except zero-length terminator)
zero_length_segment = None
segments_to_remove = []
for segment in segments:
if _is_zero_length_segment(segment):
zero_length_segment = segment
else:
segments_to_remove.append(segment)
if not segments_to_remove:
return # Only zero-length terminator exists, nothing to clear
# Collect curve segments to remove before removing alignment segments
# (we need the nesting relationship to find mapped segments)
curve_segments_to_remove = []
for segment in segments_to_remove:
try:
mapped = ifcopenshell.api.alignment.get_mapped_segments(segment)
for cs in mapped:
if cs is not None:
curve_segments_to_remove.append(cs)
except (IndexError, AttributeError):
# Segment might not have curve representation yet
pass
# Remove referents positioned on segments being removed
for segment in segments_to_remove:
# Check for referents positioned relative to this segment
if hasattr(segment, "PositionedRelativeTo") and segment.PositionedRelativeTo:
for rel_pos in segment.PositionedRelativeTo:
referent = rel_pos.RelatingPositioningElement
if referent and referent.is_a("IfcReferent"):
# Remove the referent
ifcopenshell.api.run("root.remove_product", file, product=referent)
# Remove segments from nesting relationship
ifcopenshell.api.nest.unassign_object(file, related_objects=segments_to_remove)
# Remove segment entities
for segment in segments_to_remove:
# Remove design parameters
dp = segment.DesignParameters
if dp:
# Remove StartPoint if it exists
if hasattr(dp, "StartPoint") and dp.StartPoint:
file.remove(dp.StartPoint)
file.remove(dp)
# Remove the segment entity itself
file.remove(segment)
# Clear curve segments from the geometric representation
if curve and curve.Segments:
# Keep only the zero-length curve segment (last one)
if ifcopenshell.api.alignment.has_zero_length_segment(curve):
zero_length_curve_seg = curve.Segments[-1]
# Update curve to only contain zero-length segment
curve.Segments = (zero_length_curve_seg,)
else:
# No zero-length segment in curve, clear all
curve.Segments = ()
# Clean up removed curve segment entities
for cs in curve_segments_to_remove:
try:
# Remove the curve segment's parent curve and placement
if hasattr(cs, "ParentCurve") and cs.ParentCurve:
parent_curve = cs.ParentCurve
# Check if parent curve is used elsewhere
if file.get_total_inverses(parent_curve) <= 1:
# Remove placement if exists
if hasattr(parent_curve, "Position") and parent_curve.Position:
pos = parent_curve.Position
if hasattr(pos, "Location") and pos.Location:
if file.get_total_inverses(pos.Location) <= 1:
file.remove(pos.Location)
if hasattr(pos, "RefDirection") and pos.RefDirection:
if file.get_total_inverses(pos.RefDirection) <= 1:
file.remove(pos.RefDirection)
if file.get_total_inverses(pos) <= 1:
file.remove(pos)
file.remove(parent_curve)
# Remove placement on curve segment
if hasattr(cs, "Placement") and cs.Placement:
placement = cs.Placement
if hasattr(placement, "Location") and placement.Location:
if file.get_total_inverses(placement.Location) <= 1:
file.remove(placement.Location)
if hasattr(placement, "RefDirection") and placement.RefDirection:
if file.get_total_inverses(placement.RefDirection) <= 1:
file.remove(placement.RefDirection)
if file.get_total_inverses(placement) <= 1:
file.remove(placement)
# Remove the curve segment itself
file.remove(cs)
except Exception:
# Entity may have already been removed
pass
# Reset zero-length terminator to origin position
if zero_length_segment:
dp = zero_length_segment.DesignParameters
if dp.is_a("IfcAlignmentHorizontalSegment"):
# Reset StartPoint to origin
if dp.StartPoint:
dp.StartPoint.Coordinates = (0.0, 0.0)
dp.StartDirection = 0.0
elif dp.is_a("IfcAlignmentVerticalSegment"):
dp.StartDistAlong = 0.0
dp.StartHeight = 0.0
dp.StartGradient = 0.0
dp.EndGradient = 0.0
elif dp.is_a("IfcAlignmentCantSegment"):
dp.StartDistAlong = 0.0
dp.StartCantLeft = 0.0
dp.StartCantRight = 0.0
# Update the zero-length segment's referent
if hasattr(zero_length_segment, "PositionedRelativeTo") and zero_length_segment.PositionedRelativeTo:
for rel_pos in zero_length_segment.PositionedRelativeTo:
referent = rel_pos.RelatingPositioningElement
if referent and referent.is_a("IfcReferent"):
# Update referent position to origin
if hasattr(referent, "ObjectPlacement") and referent.ObjectPlacement:
placement = referent.ObjectPlacement
if hasattr(placement, "RelativePlacement") and placement.RelativePlacement:
rel_place = placement.RelativePlacement
if hasattr(rel_place, "Location") and rel_place.Location:
if hasattr(rel_place.Location, "DistanceAlong"):
rel_place.Location.DistanceAlong.wrappedValue = 0.0
if hasattr(placement, "CartesianPosition") and placement.CartesianPosition:
cart_pos = placement.CartesianPosition
if hasattr(cart_pos, "Location") and cart_pos.Location:
cart_pos.Location.Coordinates = (0.0, 0.0, 0.0)
@@ -80,29 +80,14 @@ def create(
if include_geometry:
_create_geometric_representation(file, alignment)
name = ifcopenshell.util.alignment.station_as_string(file, start_station)
referent = ifcopenshell.api.alignment.add_stationing_referent(file, alignment, 0.0, start_station, name, alignment)
name = ifcopenshell.util.alignment.station_as_string(file, start_station)
referent = ifcopenshell.api.alignment.add_stationing_referent(
file, alignment, 0.0, start_station, name, alignment
)
for layout in alignment_layouts:
_add_zero_length_segment(file, layout)
if include_geometry:
# add the representation to the zero length segment
curve = ifcopenshell.api.alignment.get_layout_curve(layout)
axis_geom_subcontext = ifcopenshell.api.alignment.get_axis_subcontext(file)
axis_representation = file.createIfcShapeRepresentation(
ContextOfItems=axis_geom_subcontext,
RepresentationIdentifier="Axis",
RepresentationType="Segment",
Items=(curve.Segments[-1],),
)
product = file.createIfcProductDefinitionShape(Representations=(axis_representation,))
layout.IsNestedBy[0].RelatedObjects[-1].ObjectPlacement = alignment.ObjectPlacement
layout.IsNestedBy[0].RelatedObjects[-1].Representation = product
# IFC 4.1.4.1.1 Alignment Aggregation To Project
project = file.by_type("IfcProject")[0]
if project:
@@ -30,6 +30,91 @@ from ifcopenshell.api.alignment._create_polyline_representation import (
)
def _create_layout(file: ifcopenshell.file, alignment: entity_instance, points: Sequence[entity_instance]):
"""
I don't believe it is required for polylines, but the validation serivce gives an error if the alignment doesn't have a layout
"""
include_vertical = False if points[0].Dim == 2 else True
alignment_layouts = []
alignment_layouts.append(file.createIfcAlignmentHorizontal(GlobalId=ifcopenshell.guid.new()))
if include_vertical:
alignment_layouts.append(file.createIfcAlignmentVertical(GlobalId=ifcopenshell.guid.new()))
ifcopenshell.api.nest.assign_object(file, related_objects=alignment_layouts, relating_object=alignment)
start_dist_along = 0.0
for p1, p2 in zip(points, points[1:]):
x1, y1, z1 = p1.Coordinates
x2, y2, z2 = p2.Coordinates
dir = math.atan2(y2 - y1, x2 - x1)
gradient = (z2 - z1) / (x2 - x1)
length = math.sqrt(math.pow((x2 - x1), 2.0) + math.pow((y2 - y1), 2.0))
hsegment = file.createIfcAlignmentSegment(
ifcopenshell.guid.new(),
DesignParameters=file.createIfcAlignmentHorizontalSegment(
StartPoint=p1,
StartDirection=dir,
StartRadiusOfCurvature=0.0,
EndRadiusOfCurvature=0.0,
SegmentLength=length,
PredefinedType="LINE",
),
)
ifcopenshell.api.nest.assign_object(file, related_objects=[hsegment], relating_object=alignment_layouts[0])
if include_vertical:
vsegment = file.createIfcAlignmentSegment(
ifcopenshell.guid.new(),
DesignParameters=file.createIfcAlignmentVerticalSegment(
StartDistAlong=start_dist_along,
HorizontalLength=length,
StartHeight=z1,
StartGradient=gradient,
EndGradient=gradient,
PredefinedType="CONSTANTGRADIENT",
),
)
ifcopenshell.api.nest.assign_object(file, related_objects=[vsegment], relating_object=alignment_layouts[1])
start_dist_along += length
# zero length segment
hsegment = file.createIfcAlignmentSegment(
ifcopenshell.guid.new(),
DesignParameters=file.createIfcAlignmentHorizontalSegment(
StartPoint=points[-1],
StartDirection=dir,
StartRadiusOfCurvature=0.0,
EndRadiusOfCurvature=0.0,
SegmentLength=0.0,
PredefinedType="LINE",
),
)
ifcopenshell.api.nest.assign_object(file, related_objects=[hsegment], relating_object=alignment_layouts[0])
if include_vertical:
vsegment = file.createIfcAlignmentSegment(
ifcopenshell.guid.new(),
DesignParameters=file.createIfcAlignmentVerticalSegment(
StartDistAlong=start_dist_along,
HorizontalLength=0.0,
StartHeight=points[-1].Coordinates[-1],
StartGradient=gradient,
EndGradient=gradient,
PredefinedType="CONSTANTGRADIENT",
),
)
ifcopenshell.api.nest.assign_object(file, related_objects=[vsegment], relating_object=alignment_layouts[1])
def create_as_polyline(
file: ifcopenshell.file,
name: str,
@@ -34,8 +34,6 @@ def create_layout_segment(
Creates a new IfcAlignmentSegment using the IfcAlignmentParameterSegment design parameters.
The new segment is appended to the layout alignment and the corresponding IfcCurveSegment is created in the geometric representation if it exists.
Additionally, if the geometric representation of the alignment exists, the segment's geometric representation is added to the IfcCurveSegment based on CT 4.1.7.1.1.4 Alignment Geometry - Segments
:param layout: The layout to receive the new layout segment. This parameter is expected to be IfcAlignmentHorizontal, IfcAlignmentVertical or IfcAlignmentCant
:param design_parameters: The parameters defining the segment. Expected to be the appropreate subclass of IfcAlignmentParameterSegment
:return: 4x4 matrix at end of segment as np.array intended to be used as the start point geometry for the next segment or None if there is the geometric representation is not defined.
@@ -1,41 +0,0 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
from collections.abc import Sequence
from ifcopenshell import entity_instance
def get_alignment_layout(segment: entity_instance) -> entity_instance:
"""
Returns the layout alignment that the segment is nested into
"""
expected_types = ["IfcAlignmentSegment"]
if not segment.is_a() in expected_types:
raise TypeError(
f"Expected entity type to be one of {[_ for _ in expected_types]}, instead received '{segment.is_a()}"
)
layout = None
layouts = ["IfcAlignmentHorizontal", "IfcAlignmentVertical", "IfcAlignmentCant"]
for nest in segment.Nests:
if nest.RelatingObject.is_a() in layouts:
layout = nest.RelatingObject
break
return layout
@@ -19,12 +19,20 @@
from collections.abc import Sequence
from ifcopenshell import entity_instance
import ifcopenshell.util.alignment
# TODO remove this function, use util directly
def get_alignment_layouts(alignment: entity_instance) -> Sequence[entity_instance]:
"""
Returns the layout alignments nested to this alignment
"""
return ifcopenshell.util.alignment.get_alignment_layouts(alignment)
layouts = []
for rel in alignment.IsNestedBy:
for layout in rel.RelatedObjects:
if (
layout.is_a("IfcAlignmentHorizontal")
or layout.is_a("IfcAlignmentVertical")
or layout.is_a("IfcAlignmentCant")
):
layouts.append(layout)
return layouts
@@ -44,17 +44,6 @@ def get_mapped_segments(layout_segment: entity_instance) -> Sequence[entity_inst
if not layout_segment.is_a(expected_type):
raise TypeError(f"Expected to see type '{expected_type}', instead received '{layout_segment.is_a()}'.")
# if the representation is attached directly to the layout segment, just get the representation curve
representations = ifcopenshell.util.representation.get_representations_iter(layout_segment)
for representation in representations:
if representation.RepresentationIdentifier == "Axis" and representation.RepresentationType == "Segment":
if len(representation.Items) == 1:
return (representation.Items[0],None)
else:
return representation.Items
# representation was not attached directly to the segment, so we have to find
# them from the composite curve
layout = layout_segment.Nests[0].RelatingObject
curve = ifcopenshell.api.alignment.get_layout_curve(layout)
@@ -1,109 +0,0 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import numpy as np
import ifcopenshell
import ifcopenshell.util.unit
from ifcopenshell import entity_instance, ifcopenshell_wrapper
def _intersect_lines(p1, d1, p2, d2):
x1, y1 = p1
dx1, dy1 = d1
x2, y2 = p2
dx2, dy2 = d2
det = dx1 * dy2 - dy1 * dx2
if abs(det) < 1e-12:
return None # lines are parallel
t = ((x2 - x1) * dy2 - (y2 - y1) * dx2) / det
x = x1 + t * dx1
y = y1 + t * dy1
return (x, y)
def segment_vertices(file: ifcopenshell.file, segment: entity_instance):
"""
Generates segment vertices. Segment vertices are at the start and end as well as the points where the tangents
at the start and end of the segment intersect (the TI point) and where lines
normal (perpendicular) to the start and end of the segment intersect (NI).
TI and NI are None if intersection points do not exist, such as in the case of a line.
:param curve_segment: A curve segment of type IfcAlignmentSegment or IfcCurveSegment
:return: tuples for Start, End, TI, NI
"""
supported_segment_types = ["IFCALIGNMENTSEGMENT", "IFCCURVESEGMENT"]
segment_type = segment.is_a().upper()
if not segment_type in supported_segment_types:
raise NotImplementedError(
f"Expected entity type to be one of {[_ for _ in supported_segment_types]}, got '{segment_type}"
)
# in the general case an IfcAlignmentSegment for a Helmert transition curve
# maps into two IfcCurveSegment geometric representations.
# For that reason, we have a start_segment_curve and and end_segment_curve.
# In the more common case, there is only one IfcCurveSegment geometric representation
# and start_segment_curve and end_segment_curve are equal
if segment_type == "IFCALIGNMENTSEGMENT":
segments = ifcopenshell.api.alignment.get_mapped_segments(segment)
start_segment_curve = segments[0]
end_segment_curve = start_segment_curve if segments[1] == None else segment[1]
else:
start_segment_curve = segment
end_segment_curve = segment
settings = ifcopenshell.geom.settings()
# get parameters at start of start_segment_curve
segment_fn = ifcopenshell_wrapper.map_shape(settings, start_segment_curve.wrapped_data)
segment_evaluator = ifcopenshell_wrapper.function_item_evaluator(settings, segment_fn)
s = segment_evaluator.evaluate(segment_fn.start())
start = np.array(s)
sx = float(start[0, 3])
sy = float(start[1, 3])
sdx = float(start[0, 0])
sdy = float(start[1, 0])
# get parameters at end of end_segment_curve
segment_fn = ifcopenshell_wrapper.map_shape(settings, end_segment_curve.wrapped_data)
segment_evaluator = ifcopenshell_wrapper.function_item_evaluator(settings, segment_fn)
e = segment_evaluator.evaluate(segment_fn.end())
end = np.array(e)
ex = float(end[0, 3])
ey = float(end[1, 3])
edx = float(end[0, 0])
edy = float(end[1, 0])
ti = _intersect_lines((sx, sy), (sdx, sdy), (ex, ey), (edx, edy)) # tangent intersection
sdx = float(start[0, 1])
sdy = float(start[1, 1])
edx = float(end[0, 1])
edy = float(end[1, 1])
ni = _intersect_lines((sx, sy), (sdx, sdy), (ex, ey), (edx, edy)) # normal intersection
return (sx, sy), (ex, ey), ti, ni
@@ -60,24 +60,8 @@ def evaluate_segment(segment: entity_instance, dist_along: float) -> np.ndarray:
segment_type = segment.is_a().upper()
if not segment_type in supported_segment_types:
raise NotImplementedError(f"Expected entity type 'IFCCURVESEGMENT', got '{segment_type}")
# Validate dist_along is within segment bounds
# SegmentLength can be negative (indicates curve direction), so we need to handle both cases
seg_len = (
segment.SegmentLength.wrappedValue if hasattr(segment.SegmentLength, "wrappedValue") else segment.SegmentLength
)
if seg_len >= 0:
# Positive length: valid range is 0 to seg_len
if dist_along < 0 or dist_along > seg_len:
raise ValueError(
f"Provided value {dist_along=} is beyond the end of the segment ({segment.SegmentLength})."
)
else:
# Negative length: valid range is seg_len to 0
if dist_along > 0 or dist_along < seg_len:
raise ValueError(
f"Provided value {dist_along=} is beyond the end of the segment ({segment.SegmentLength})."
)
if dist_along > segment.SegmentLength:
raise ValueError(f"Provided value {dist_along=} is beyond the end of the segment ({segment.SegmentLength}).")
s = ifcopenshell.geom.settings()
function_item = ifcopenshell_wrapper.map_shape(s, segment.wrapped_data)
@@ -106,7 +90,8 @@ def generate_vertices(rep_curve: entity_instance, distance_interval: float = 5.0
)
s = ifcopenshell.geom.settings()
s.set("function-step-param", distance_interval)
s.set("piecewise-step-type", 0) # 0 = step-size is maximum step size, 1 = step-size is mininimum number of steps
s.set("piecewise-step-size", distance_interval)
shape = ifcopenshell.geom.create_shape(s, rep_curve)
vertices = shape.verts
if len(vertices) == 0:
@@ -20,7 +20,6 @@ import math
import ifcopenshell
import ifcopenshell.util.unit
from typing import Sequence
def add_linear_placement_fallback_position(file: ifcopenshell.file) -> ifcopenshell.file:
@@ -110,19 +109,3 @@ def station_as_string(file: ifcopenshell.file, sta: float):
station_string = "-" + station_string
return station_string
def get_alignment_layouts(alignment: ifcopenshell.entity_instance) -> Sequence[ifcopenshell.entity_instance]:
"""
Returns the layout alignments nested to this alignment
"""
layouts = []
for rel in alignment.IsNestedBy:
for layout in rel.RelatedObjects:
if (
layout.is_a("IfcAlignmentHorizontal")
or layout.is_a("IfcAlignmentVertical")
or layout.is_a("IfcAlignmentCant")
):
layouts.append(layout)
return layouts
@@ -18,7 +18,7 @@
import math
from decimal import ROUND_HALF_UP, Decimal
from typing import NamedTuple, Optional, Union, Any
from typing import NamedTuple, Optional, Union
import numpy as np
@@ -268,15 +268,6 @@ def get_helmert_transformation_parameters(ifc_file: ifcopenshell.file) -> Option
return HelmertTransformation(e, n, h, xaa, xao, scale, factor_x, factor_y, factor_z)
def get_crs(ifc_file: ifcopenshell.file) -> dict[str, Any]:
"""Get CRS information from an IFC file."""
if ifc_file.schema == "IFC2X3":
return ifcopenshell.util.element.get_pset(ifc_file.by_type("IfcProject")[0], "ePSet_ProjectedCRS")
for context in ifc_file.by_type("IfcGeometricRepresentationContext", include_subtypes=False):
if operation := context.HasCoordinateOperation:
return operation[0].TargetCRS.get_info()
def auto_z2e(ifc_file: ifcopenshell.file, z: float, should_return_in_map_units: bool = True) -> float:
"""Convert a Z coordinate to an elevation using model georeferencing data
@@ -1,49 +0,0 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import ifcopenshell.api.alignment
import ifcopenshell.api.context
import ifcopenshell.api.unit
def test_get_alignment_layout():
file = ifcopenshell.file(schema="IFC4X3_ADD2")
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT")
ifcopenshell.api.unit.assign_unit(file, units=[length])
geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model")
axis_model_representation_subcontext = ifcopenshell.api.context.add_context(
file,
context_type="Model",
context_identifier="Axis",
target_view="MODEL_VIEW",
parent=geometric_representation_context,
)
alignment = ifcopenshell.api.alignment.create(file, "Test", include_vertical=True, include_cant=True)
horiz = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
vert = ifcopenshell.api.alignment.get_vertical_layout(alignment)
cant = ifcopenshell.api.alignment.get_cant_layout(alignment)
assert horiz == ifcopenshell.api.alignment.get_alignment_layout(horiz.IsNestedBy[0].RelatedObjects[0])
assert vert == ifcopenshell.api.alignment.get_alignment_layout(vert.IsNestedBy[0].RelatedObjects[0])
assert cant == ifcopenshell.api.alignment.get_alignment_layout(cant.IsNestedBy[0].RelatedObjects[0])
test_get_alignment_layout()
@@ -1,172 +0,0 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import pytest
import ifcopenshell.api.alignment
import ifcopenshell.api.context
import ifcopenshell.api.unit
def unit_convert(unit_scale, p):
if p == None:
return p
x, y = p
return (x / unit_scale, y / unit_scale)
def test_segment_vertices():
file = ifcopenshell.file(schema="IFC4X3")
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
length = ifcopenshell.api.unit.add_conversion_based_unit(file, name="foot")
angle = ifcopenshell.api.unit.add_si_unit(file, unit_type="PLANEANGLEUNIT")
ifcopenshell.api.unit.assign_unit(file, units=[length, angle])
geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model")
axis_model_representation_subcontext = ifcopenshell.api.context.add_context(
file,
context_type="Model",
context_identifier="Axis",
target_view="MODEL_VIEW",
parent=geometric_representation_context,
)
coordinates = [(500.0, 2500.0), (3340.0, 660.0), (4340.0, 5000.0), (7600.0, 4560.0), (8480.0, 2010.0)]
radii = [(1000.0), (1250.0), (950.0)]
vpoints = [(0.0, 100.0), (2000.0, 135.0), (5000.0, 105.0), (7400.0, 153.0), (9800.0, 105.0), (12800.0, 90.0)]
lengths = [(1600.0), (1200.0), (2000.0), (800.0)]
alignment = ifcopenshell.api.alignment.create_by_pi_method(
file, "TestAlignment", coordinates, radii, vpoints, lengths
)
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file)
# test the horizontal alignment geometry segments
expect = [
[(500.0, 2500.0), (2142.2379952109395, 1436.01482000418), None, None],
[
(2142.2379952109395, 1436.01482000418),
(3660.446122847804, 2050.7361731594674),
(3340.0, 659.9999999999998),
(2685.9792975637306, 2275.267699722618),
],
[(3660.4461228478035, 2050.7361731594674), (4084.115884236641, 3889.4629375870213), None, None],
[
(4084.115884236641, 3889.4629375870218),
(5469.395067206271, 4847.5663099476205),
(4340.0, 5000.000000000001),
(5302.199415841732, 3608.7985293830834),
],
[(5469.395067206271, 4847.56630994762), (7019.971366858418, 4638.286073184753), None, None],
[
(7019.971366858417, 4638.286073184753),
(7790.932128312586, 4006.7307645487535),
(7600.0, 4560.0),
(6892.902671821368, 3696.8225599557054),
],
[(7790.932128312587, 4006.7307645487535), (8480.0, 2010.0000000000002), None, None],
[(8480.0, 2010.0000000000002), (8480.0, 2010.0000000000002), None, None],
]
layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
segments = ifcopenshell.api.alignment.get_layout_segments(layout)
for segment, expected in zip(segments, expect):
s, e, ti, ni = ifcopenshell.api.alignment.segment_vertices(file, segment)
s = unit_convert(unit_scale, s)
e = unit_convert(unit_scale, e)
ti = unit_convert(unit_scale, ti)
ni = unit_convert(unit_scale, ni)
assert s == pytest.approx(expected[0])
assert e == pytest.approx(expected[1])
assert ti == pytest.approx(expected[2])
assert ni == pytest.approx(expected[3])
curve = ifcopenshell.api.alignment.get_basis_curve(alignment)
for segment, expected in zip(curve.Segments, expect):
s, e, ti, ni = ifcopenshell.api.alignment.segment_vertices(file, segment)
s = unit_convert(unit_scale, s)
e = unit_convert(unit_scale, e)
ti = unit_convert(unit_scale, ti)
ni = unit_convert(unit_scale, ni)
assert s == pytest.approx(expected[0])
assert e == pytest.approx(expected[1])
assert ti == pytest.approx(expected[2])
assert ni == pytest.approx(expected[3])
# test vertical curve segments
expect = [
[(0.0, 100.0), (1200.0, 121.0), None, None],
[
(1200.0, 121.0),
(2799.99999384661, 127.00000006153391),
(1999.9999969233054, 134.99999994615786),
(2218.1436363635016, -58058.63636362867),
],
[(2800.0, 127.0), (4400.0, 111.0), None, None],
[
(4400.0, 111.0),
(5599.999994508736, 116.9999998901747),
(4999.999997254367, 105.00000002745632),
(4800.039999999177, 40114.99999991764),
],
[(5600.0, 117.0), (6400.0, 133.0), None, None],
[
(6400.0, 133.0),
(8399.999995932576, 133.0000000813485),
(7399.999997966288, 152.99999995932575),
(7399.999999999187, -49866.99999995936),
],
[(8400.0, 133.0), (9400.0, 113.0), None, None],
[
(9400.0, 113.0),
(10199.99999633883, 103.00000001830585),
(9799.999998169415, 105.00000003661171),
(10466.733333334432, 53449.66666672164),
],
[(10200.0, 103.0), (12800.0, 90.0), None, None],
[(12800.0, 90.0), (12800.0, 90.0), None, None],
]
layout = ifcopenshell.api.alignment.get_vertical_layout(alignment)
segments = ifcopenshell.api.alignment.get_layout_segments(layout)
for segment, expected in zip(segments, expect):
s, e, ti, ni = ifcopenshell.api.alignment.segment_vertices(file, segment)
s = unit_convert(unit_scale, s)
e = unit_convert(unit_scale, e)
ti = unit_convert(unit_scale, ti)
ni = unit_convert(unit_scale, ni)
assert s == pytest.approx(expected[0])
assert e == pytest.approx(expected[1])
assert ti == pytest.approx(expected[2])
assert ni == pytest.approx(expected[3])
curve = ifcopenshell.api.alignment.get_curve(alignment)
for segment, expected in zip(curve.Segments, expect):
s, e, ti, ni = ifcopenshell.api.alignment.segment_vertices(file, segment)
s = unit_convert(unit_scale, s)
e = unit_convert(unit_scale, e)
ti = unit_convert(unit_scale, ti)
ni = unit_convert(unit_scale, ni)
assert s == pytest.approx(expected[0])
assert e == pytest.approx(expected[1])
assert ti == pytest.approx(expected[2])
assert ni == pytest.approx(expected[3])
test_segment_vertices()