Refactor Saikei alignment module to follow Bonsai architecture

- Move math/calculation functions from core to tool layer
  (calculate_pi_geometry, calculate_deflection_angle, etc.)
- Remove duplicate get_ifc_file() wrappers, use tool.Ifc.get() directly
- Remove redundant ifc_definition_id manual settings (tool.Ifc.link handles this)
- Remove fallback object lookup methods (_find_object_by_ifc_id, _find_object_by_name_pattern)
- Simplify remove methods to use tool.Ifc.get_object() directly
- Clean up defensive try/except ImportError blocks
- Update is_ifc4x3() to use tool.Ifc.get_schema()
- Update license headers to Bonsai standard

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
DesertSpringsCivil
2026-01-23 13:07:03 -07:00
committed by Dion Moult
parent 8416ca46a7
commit 04ad0b92bd
6 changed files with 344 additions and 492 deletions
+10 -19
View File
@@ -1,21 +1,20 @@
# ============================================================================== # Bonsai - OpenBIM Blender Add-on
# Saikei Civil - Civil Engineering Tools for Blender # Copyright (C) 2025, 2026 Michael Yoder <myoder@desertspringscivil.com>
# Copyright (c) 2025 Michael Yoder / Desert Springs Civil Engineering PLLC
# #
# This program is free software: you can redistribute it and/or modify # 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 # it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or # the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version. # (at your option) any later version.
# #
# This program is distributed in the hope that it will be useful, # Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of # but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details. # GNU General Public License for more details.
# You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
# #
# Primary Author: Michael Yoder # You should have received a copy of the GNU General Public License
# Company: Desert Springs Civil Engineering PLLC # along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
# ==============================================================================
"""Data caching layer for the alignment module """Data caching layer for the alignment module
@@ -24,15 +23,7 @@ This module provides cached access to alignment data for UI display,
following Bonsai's data loading pattern. following Bonsai's data loading pattern.
""" """
import bonsai.tool as tool
def get_ifc_file():
"""Get the current IFC file from Bonsai"""
try:
import bonsai.tool as tool
return tool.Ifc.get()
except (ImportError, AttributeError):
return None
class AlignmentData: class AlignmentData:
@@ -50,7 +41,7 @@ class AlignmentData:
"segments": [], "segments": [],
} }
ifc = get_ifc_file() ifc = tool.Ifc.get()
if ifc is None: if ifc is None:
cls.is_loaded = True cls.is_loaded = True
return return
@@ -593,17 +593,17 @@ def on_radius_changed(pi, context):
def recalculate_pi_geometry(props): def recalculate_pi_geometry(props):
"""Recalculate lengths and stations for all PIs using core logic.""" """Recalculate lengths and stations for all PIs using tool layer."""
pis = props.pis pis = props.pis
if len(pis) < 2: if len(pis) < 2:
rebuild_display_rows(props) rebuild_display_rows(props)
return return
# Extract PI coordinates for pure Python calculation # Extract PI coordinates for calculation
pi_coords = [(pi.x, pi.y) for pi in pis] pi_coords = [(pi.x, pi.y) for pi in pis]
# Use core function for calculation # Use tool layer for calculation (math belongs in tool, not core)
result = core.calculate_pi_geometry(pi_coords, props.start_station) result = tool.Alignment.calculate_pi_geometry(pi_coords, props.start_station)
# Update Blender properties with results # Update Blender properties with results
tool.Alignment.update_pi_properties(props, result) tool.Alignment.update_pi_properties(props, result)
@@ -1,21 +1,20 @@
# ============================================================================== # Bonsai - OpenBIM Blender Add-on
# Saikei Civil - Civil Engineering Tools for Blender # Copyright (C) 2025, 2026 Michael Yoder <myoder@desertspringscivil.com>
# Copyright (c) 2025 Michael Yoder / Desert Springs Civil Engineering PLLC
# #
# This program is free software: you can redistribute it and/or modify # 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 # it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or # the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version. # (at your option) any later version.
# #
# This program is distributed in the hope that it will be useful, # Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of # but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details. # GNU General Public License for more details.
# You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
# #
# Primary Author: Michael Yoder # You should have received a copy of the GNU General Public License
# Company: Desert Springs Civil Engineering PLLC # along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
# ==============================================================================
"""Property groups for the alignment module""" """Property groups for the alignment module"""
+11 -22
View File
@@ -1,21 +1,20 @@
# ============================================================================== # Bonsai - OpenBIM Blender Add-on
# Saikei Civil - Civil Engineering Tools for Blender # Copyright (C) 2025, 2026 Michael Yoder <myoder@desertspringscivil.com>
# Copyright (c) 2025 Michael Yoder / Desert Springs Civil Engineering PLLC
# #
# This program is free software: you can redistribute it and/or modify # 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 # it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or # the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version. # (at your option) any later version.
# #
# This program is distributed in the hope that it will be useful, # Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of # but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details. # GNU General Public License for more details.
# You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
# #
# Primary Author: Michael Yoder # You should have received a copy of the GNU General Public License
# Company: Desert Springs Civil Engineering PLLC # along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
# ==============================================================================
"""UI panels for the alignment module """UI panels for the alignment module
@@ -24,23 +23,13 @@ All panels appear in the VIEW_3D N-panel under the "Saikei Civil" tab.
""" """
import bpy import bpy
import bonsai.tool as tool
from bpy.types import Panel, UIList from bpy.types import Panel, UIList
def get_ifc_file():
"""Get the current IFC file from Bonsai"""
try:
import bonsai.tool as tool
return tool.Ifc.get()
except (ImportError, AttributeError):
return None
def is_ifc4x3(): def is_ifc4x3():
"""Check if the current IFC file is IFC4X3 schema""" """Check if the current IFC file is IFC4X3 schema"""
ifc = get_ifc_file() return tool.Ifc.get_schema() == "IFC4X3"
return ifc is not None and ifc.schema == "IFC4X3"
# ============================================================================= # =============================================================================
@@ -148,7 +137,7 @@ class SAIKEI_PT_horizontal_alignment(Panel):
# Status box # Status box
box = layout.box() box = layout.box()
ifc = get_ifc_file() ifc = tool.Ifc.get()
if ifc is None: if ifc is None:
box.label(text="No IFC file loaded", icon="ERROR") box.label(text="No IFC file loaded", icon="ERROR")
+21 -157
View File
@@ -1,33 +1,38 @@
# ============================================================================== # Bonsai - OpenBIM Blender Add-on
# Saikei Civil - Civil Engineering Tools for Blender # Copyright (C) 2025, 2026 Michael Yoder <myoder@desertspringscivil.com>
# Copyright (c) 2025 Michael Yoder / Desert Springs Civil Engineering PLLC
# #
# This program is free software: you can redistribute it and/or modify # 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 # it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or # the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version. # (at your option) any later version.
# #
# This program is distributed in the hope that it will be useful, # Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of # but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details. # GNU General Public License for more details.
# You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
# #
# Primary Author: Michael Yoder # You should have received a copy of the GNU General Public License
# Company: Desert Springs Civil Engineering PLLC # along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
# ==============================================================================
"""Core alignment business logic - Pure Python, NO bpy imports. """Core alignment business logic - Orchestration only, NO bpy imports.
This module contains all alignment-related calculations and logic that This module contains alignment-related business logic and workflow
can be tested outside of Blender. Functions receive tool classes as orchestration. All calculations and algorithms are in the tool layer.
parameters following Bonsai's dependency injection pattern. Functions receive tool classes as parameters following Bonsai's
dependency injection pattern.
NOTE: Math, calculations, and algorithms 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 __future__ import annotations
import math from typing import TYPE_CHECKING, Optional
from typing import TYPE_CHECKING, List, Tuple, Optional
from dataclasses import dataclass from dataclasses import dataclass
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -57,149 +62,8 @@ class PIPoint:
station: float = 0.0 station: float = 0.0
@dataclass
class PIGeometryResult:
"""Result of PI geometry calculation."""
stations: List[float]
lengths: List[float]
directions: List[float]
total_length: float
# ============================================================================= # =============================================================================
# Pure Python Calculation Functions # Alignment Visualization Logic (Business Logic Orchestration)
# =============================================================================
def calculate_pi_geometry(pis: List[Tuple[float, float]], start_station: float = 0.0) -> PIGeometryResult:
"""Calculate lengths, stations, and directions for a list of PI points.
This is a pure Python function with no Blender dependencies.
Args:
pis: List of (x, y) coordinate tuples for each PI
start_station: Starting station value
Returns:
PIGeometryResult containing calculated values
"""
if len(pis) < 2:
return PIGeometryResult(
stations=[start_station] if pis else [],
lengths=[0.0] if pis else [],
directions=[0.0] if pis else [],
total_length=0.0,
)
stations = []
lengths = []
directions = []
cumulative_length = start_station
for i, pi in enumerate(pis):
stations.append(cumulative_length)
if i < len(pis) - 1:
next_pi = pis[i + 1]
dx = next_pi[0] - pi[0]
dy = next_pi[1] - pi[1]
length = math.sqrt(dx * dx + dy * dy)
direction = math.atan2(dy, dx)
lengths.append(length)
directions.append(direction)
cumulative_length += length
else:
lengths.append(0.0)
directions.append(0.0)
total_length = cumulative_length - start_station
return PIGeometryResult(stations=stations, lengths=lengths, directions=directions, total_length=total_length)
def calculate_deflection_angle(incoming_direction: float, outgoing_direction: float) -> float:
"""Calculate the deflection angle between two tangent directions.
Args:
incoming_direction: Direction angle of incoming tangent (radians)
outgoing_direction: Direction angle of outgoing tangent (radians)
Returns:
Deflection angle in radians (always positive)
"""
delta = outgoing_direction - incoming_direction
# Normalize to -pi to pi
while delta > math.pi:
delta -= 2 * math.pi
while delta < -math.pi:
delta += 2 * math.pi
return abs(delta)
def calculate_tangent_length(radius: float, deflection_angle: float) -> float:
"""Calculate tangent length for a circular curve.
T = R * tan(Δ/2)
Args:
radius: Curve radius
deflection_angle: Deflection angle in radians
Returns:
Tangent length
"""
if deflection_angle == 0 or radius == 0:
return 0.0
return radius * math.tan(deflection_angle / 2)
def calculate_arc_length(radius: float, deflection_angle: float) -> float:
"""Calculate arc length for a circular curve.
L = R * Δ
Args:
radius: Curve radius
deflection_angle: Deflection angle in radians
Returns:
Arc length
"""
return radius * deflection_angle
def calculate_bc_ec_points(
pi_x: float, pi_y: float, incoming_direction: float, outgoing_direction: float, tangent_length: float
) -> Tuple[Tuple[float, float], Tuple[float, float]]:
"""Calculate Begin Curve (BC) and End Curve (EC) points.
BC = PI - incoming_tangent_vector * T
EC = PI + outgoing_tangent_vector * T
Args:
pi_x: PI X coordinate
pi_y: PI Y coordinate
incoming_direction: Direction of incoming tangent (radians)
outgoing_direction: Direction of outgoing tangent (radians)
tangent_length: Calculated tangent length
Returns:
Tuple of (BC point, EC point) as (x, y) tuples
"""
# BC is along the incoming tangent, before the PI
bc_x = pi_x - tangent_length * math.cos(incoming_direction)
bc_y = pi_y - tangent_length * math.sin(incoming_direction)
# EC is along the outgoing tangent, after the PI
ec_x = pi_x + tangent_length * math.cos(outgoing_direction)
ec_y = pi_y + tangent_length * math.sin(outgoing_direction)
return ((bc_x, bc_y), (ec_x, ec_y))
# =============================================================================
# Alignment Visualization Logic (Pure Python)
# ============================================================================= # =============================================================================
+290 -281
View File
@@ -1,21 +1,20 @@
# ============================================================================== # Bonsai - OpenBIM Blender Add-on
# Saikei Civil - Civil Engineering Tools for Blender # Copyright (C) 2025, 2026 Michael Yoder <myoder@desertspringscivil.com>
# Copyright (c) 2025 Michael Yoder / Desert Springs Civil Engineering PLLC
# #
# This program is free software: you can redistribute it and/or modify # 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 # it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or # the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version. # (at your option) any later version.
# #
# This program is distributed in the hope that it will be useful, # Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of # but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details. # GNU General Public License for more details.
# You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
# #
# Primary Author: Michael Yoder # You should have received a copy of the GNU General Public License
# Company: Desert Springs Civil Engineering PLLC # along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
# ==============================================================================
"""Alignment Tool - Blender implementations for alignment visualization. """Alignment Tool - Blender implementations for alignment visualization.
@@ -29,12 +28,30 @@ All methods are classmethods following Bonsai's tool pattern.
from __future__ import annotations from __future__ import annotations
import bpy import bpy
from typing import TYPE_CHECKING, Optional, List import math
import bonsai.tool as tool
from typing import TYPE_CHECKING, Optional, List, Tuple
from dataclasses import dataclass
if TYPE_CHECKING: if TYPE_CHECKING:
import ifcopenshell import ifcopenshell
# =============================================================================
# Data Classes for PI Geometry Results
# =============================================================================
@dataclass
class PIGeometryResult:
"""Result of PI geometry calculation."""
stations: List[float]
lengths: List[float]
directions: List[float]
total_length: float
class Alignment: class Alignment:
"""Tool class for alignment-related Blender operations. """Tool class for alignment-related Blender operations.
@@ -42,19 +59,146 @@ class Alignment:
that can be called without instantiation. that can be called without instantiation.
""" """
# =========================================================================
# Geometry Calculation Methods
# =========================================================================
@classmethod @classmethod
def get_ifc_file(cls) -> Optional[ifcopenshell.file]: def calculate_pi_geometry(
"""Get the current IFC file from Bonsai. cls, pis: List[Tuple[float, float]], start_station: float = 0.0
) -> PIGeometryResult:
"""Calculate lengths, stations, and directions for a list of PI points.
Args:
pis: List of (x, y) coordinate tuples for each PI
start_station: Starting station value
Returns: Returns:
The IFC file object, or None if not available PIGeometryResult containing calculated values
""" """
try: if len(pis) < 2:
import bonsai.tool as tool return PIGeometryResult(
stations=[start_station] if pis else [],
lengths=[0.0] if pis else [],
directions=[0.0] if pis else [],
total_length=0.0,
)
return tool.Ifc.get() stations = []
except (ImportError, AttributeError): lengths = []
return None directions = []
cumulative_length = start_station
for i, pi in enumerate(pis):
stations.append(cumulative_length)
if i < len(pis) - 1:
next_pi = pis[i + 1]
dx = next_pi[0] - pi[0]
dy = next_pi[1] - pi[1]
length = math.sqrt(dx * dx + dy * dy)
direction = math.atan2(dy, dx)
lengths.append(length)
directions.append(direction)
cumulative_length += length
else:
lengths.append(0.0)
directions.append(0.0)
total_length = cumulative_length - start_station
return PIGeometryResult(
stations=stations, lengths=lengths, directions=directions, total_length=total_length
)
@classmethod
def calculate_deflection_angle(cls, incoming_direction: float, outgoing_direction: float) -> float:
"""Calculate the deflection angle between two tangent directions.
Args:
incoming_direction: Direction angle of incoming tangent (radians)
outgoing_direction: Direction angle of outgoing tangent (radians)
Returns:
Deflection angle in radians (always positive)
"""
delta = outgoing_direction - incoming_direction
# Normalize to -pi to pi
while delta > math.pi:
delta -= 2 * math.pi
while delta < -math.pi:
delta += 2 * math.pi
return abs(delta)
@classmethod
def calculate_tangent_length(cls, radius: float, deflection_angle: float) -> float:
"""Calculate tangent length for a circular curve.
T = R * tan(Δ/2)
Args:
radius: Curve radius
deflection_angle: Deflection angle in radians
Returns:
Tangent length
"""
if deflection_angle == 0 or radius == 0:
return 0.0
return radius * math.tan(deflection_angle / 2)
@classmethod
def calculate_arc_length(cls, radius: float, deflection_angle: float) -> float:
"""Calculate arc length for a circular curve.
L = R * Δ
Args:
radius: Curve radius
deflection_angle: Deflection angle in radians
Returns:
Arc length
"""
return radius * deflection_angle
@classmethod
def calculate_bc_ec_points(
cls,
pi_x: float,
pi_y: float,
incoming_direction: float,
outgoing_direction: float,
tangent_length: float,
) -> Tuple[Tuple[float, float], Tuple[float, float]]:
"""Calculate Begin Curve (BC) and End Curve (EC) points.
BC = PI - incoming_tangent_vector * T
EC = PI + outgoing_tangent_vector * T
Args:
pi_x: PI X coordinate
pi_y: PI Y coordinate
incoming_direction: Direction of incoming tangent (radians)
outgoing_direction: Direction of outgoing tangent (radians)
tangent_length: Calculated tangent length
Returns:
Tuple of (BC point, EC point) as (x, y) tuples
"""
# BC is along the incoming tangent, before the PI
bc_x = pi_x - tangent_length * math.cos(incoming_direction)
bc_y = pi_y - tangent_length * math.sin(incoming_direction)
# EC is along the outgoing tangent, after the PI
ec_x = pi_x + tangent_length * math.cos(outgoing_direction)
ec_y = pi_y + tangent_length * math.sin(outgoing_direction)
return ((bc_x, bc_y), (ec_x, ec_y))
# =========================================================================
# Blender Object Creation
# =========================================================================
@classmethod @classmethod
def create_object_for_alignment(cls, alignment: ifcopenshell.entity_instance) -> Optional[bpy.types.Object]: def create_object_for_alignment(cls, alignment: ifcopenshell.entity_instance) -> Optional[bpy.types.Object]:
@@ -71,33 +215,24 @@ class Alignment:
Returns: Returns:
The created Blender object, or existing one if already linked The created Blender object, or existing one if already linked
""" """
try: # Check if a Blender object already exists for this IFC element
import bonsai.tool as tool existing_obj = tool.Ifc.get_object(alignment)
if existing_obj:
return existing_obj
# Check if a Blender object already exists for this IFC element # Create Blender Empty object with naming pattern "IfcClass/Name"
existing_obj = tool.Ifc.get_object(alignment) name = f"IfcAlignment/{alignment.Name or 'Unnamed'}"
if existing_obj: obj = bpy.data.objects.new(name, None) # None = Empty object
return existing_obj obj.empty_display_type = "ARROWS"
obj.empty_display_size = 1.0
# Create Blender Empty object with naming pattern "IfcClass/Name" # Link the Blender object to the IFC element (creates bidirectional mapping)
name = f"IfcAlignment/{alignment.Name or 'Unnamed'}" tool.Ifc.link(alignment, obj)
obj = bpy.data.objects.new(name, None) # None = Empty object
obj.empty_display_type = "ARROWS"
obj.empty_display_size = 1.0
# Link the Blender object to the IFC element (creates bidirectional mapping) # Assign to appropriate collection (Bonsai handles collection hierarchy)
tool.Ifc.link(alignment, obj) tool.Collector.assign(obj)
# Also set ifc_definition_id manually as fallback for lookups return obj
obj["ifc_definition_id"] = alignment.id()
# Assign to appropriate collection (Bonsai handles collection hierarchy)
tool.Collector.assign(obj)
return obj
except (ImportError, AttributeError) as e:
print(f"Warning: Could not create Blender object for alignment: {e}")
return None
@classmethod @classmethod
def create_object_for_layout( def create_object_for_layout(
@@ -112,42 +247,33 @@ class Alignment:
Returns: Returns:
The created Blender object, or existing one if already linked The created Blender object, or existing one if already linked
""" """
try: # Check if a Blender object already exists for this IFC element
import bonsai.tool as tool existing_obj = tool.Ifc.get_object(layout_entity)
if existing_obj:
return existing_obj
# Check if a Blender object already exists for this IFC element # Determine the layout type from the IFC class
existing_obj = tool.Ifc.get_object(layout_entity) ifc_class = layout_entity.is_a()
if existing_obj: name = f"{ifc_class}"
return existing_obj
# Determine the layout type from the IFC class obj = bpy.data.objects.new(name, None)
ifc_class = layout_entity.is_a() obj.empty_display_type = "PLAIN_AXES"
name = f"{ifc_class}" obj.empty_display_size = 0.5
obj = bpy.data.objects.new(name, None) # Link to IFC element
obj.empty_display_type = "PLAIN_AXES" tool.Ifc.link(layout_entity, obj)
obj.empty_display_size = 0.5
# Link to IFC element # Set parent relationship in Blender (mirrors IFC nesting)
tool.Ifc.link(layout_entity, obj) if parent_obj:
obj.parent = parent_obj
# Also set ifc_definition_id manually as fallback for lookups # Assign to same collection as parent (avoid "Unsorted")
obj["ifc_definition_id"] = layout_entity.id() if parent_obj and parent_obj.users_collection:
parent_obj.users_collection[0].objects.link(obj)
else:
tool.Collector.assign(obj)
# Set parent relationship in Blender (mirrors IFC nesting) return obj
if parent_obj:
obj.parent = parent_obj
# Assign to same collection as parent (avoid "Unsorted")
if parent_obj and parent_obj.users_collection:
parent_obj.users_collection[0].objects.link(obj)
else:
tool.Collector.assign(obj)
return obj
except (ImportError, AttributeError) as e:
print(f"Warning: Could not create Blender object for layout: {e}")
return None
@classmethod @classmethod
def create_object_for_segment( def create_object_for_segment(
@@ -167,83 +293,72 @@ class Alignment:
Returns: Returns:
The created Blender object, or existing one if already linked The created Blender object, or existing one if already linked
""" """
import math # Check if a Blender object already exists for this IFC element
existing_obj = tool.Ifc.get_object(segment)
if existing_obj:
return existing_obj
try: # Get segment parameters
import bonsai.tool as tool if not hasattr(segment, "DesignParameters") or not segment.DesignParameters:
# Check if a Blender object already exists for this IFC element
existing_obj = tool.Ifc.get_object(segment)
if existing_obj:
return existing_obj
# Get segment parameters
if not hasattr(segment, "DesignParameters") or not segment.DesignParameters:
return None
dp = segment.DesignParameters
seg_type = getattr(dp, "PredefinedType", "UNKNOWN") or "UNKNOWN"
seg_length = getattr(dp, "SegmentLength", 0.0) or 0.0
# Skip zero-length terminal segments
if seg_length < 0.0001:
return None
# Get start point
start_point = None
if hasattr(dp, "StartPoint") and dp.StartPoint:
coords = dp.StartPoint.Coordinates
if len(coords) >= 2:
start_point = (coords[0], coords[1], 0.0)
if not start_point:
return None
# Get start direction - IFC stores this in degrees, convert to radians
start_direction_deg = getattr(dp, "StartDirection", 0.0) or 0.0
start_direction = math.radians(start_direction_deg)
name = f"Segment {index + 1} ({seg_type})"
# Create curve geometry based on segment type
if seg_type == "LINE":
obj = cls._create_line_segment(name, start_point, start_direction, seg_length)
elif seg_type == "CIRCULARARC":
# Get radius for arc (positive = left, negative = right in IFC)
radius = getattr(dp, "StartRadiusOfCurvature", None)
if radius is None or radius == 0:
# Fallback to line if no radius
obj = cls._create_line_segment(name, start_point, start_direction, seg_length)
else:
obj = cls._create_arc_segment(name, start_point, start_direction, seg_length, radius)
else:
# For unsupported types, create a simple line approximation
obj = cls._create_line_segment(name, start_point, start_direction, seg_length)
if not obj:
return None
# Link to IFC element
tool.Ifc.link(segment, obj)
# Also set ifc_definition_id manually as fallback for lookups
obj["ifc_definition_id"] = segment.id()
# Set parent relationship
if parent_obj:
obj.parent = parent_obj
# Assign to same collection as parent (avoid "Unsorted")
if parent_obj and parent_obj.users_collection:
parent_obj.users_collection[0].objects.link(obj)
else:
tool.Collector.assign(obj)
return obj
except (ImportError, AttributeError) as e:
print(f"Warning: Could not create Blender object for segment: {e}")
return None return None
dp = segment.DesignParameters
seg_type = getattr(dp, "PredefinedType", "UNKNOWN") or "UNKNOWN"
seg_length = getattr(dp, "SegmentLength", 0.0) or 0.0
# Skip zero-length terminal segments
if seg_length < 0.0001:
return None
# Get start point
start_point = None
if hasattr(dp, "StartPoint") and dp.StartPoint:
coords = dp.StartPoint.Coordinates
if len(coords) >= 2:
start_point = (coords[0], coords[1], 0.0)
if not start_point:
return None
# Get start direction - IFC stores this in degrees, convert to radians
start_direction_deg = getattr(dp, "StartDirection", 0.0) or 0.0
start_direction = math.radians(start_direction_deg)
name = f"Segment {index + 1} ({seg_type})"
# Create curve geometry based on segment type
if seg_type == "LINE":
obj = cls._create_line_segment(name, start_point, start_direction, seg_length)
elif seg_type == "CIRCULARARC":
# Get radius for arc (positive = left, negative = right in IFC)
radius = getattr(dp, "StartRadiusOfCurvature", None)
if radius is None or radius == 0:
# Fallback to line if no radius
obj = cls._create_line_segment(name, start_point, start_direction, seg_length)
else:
obj = cls._create_arc_segment(name, start_point, start_direction, seg_length, radius)
else:
# For unsupported types, create a simple line approximation
obj = cls._create_line_segment(name, start_point, start_direction, seg_length)
if not obj:
return None
# Link to IFC element
tool.Ifc.link(segment, obj)
# Set parent relationship
if parent_obj:
obj.parent = parent_obj
# Assign to same collection as parent (avoid "Unsorted")
if parent_obj and parent_obj.users_collection:
parent_obj.users_collection[0].objects.link(obj)
else:
tool.Collector.assign(obj)
return obj
@classmethod @classmethod
def _create_line_segment( def _create_line_segment(
cls, name: str, start_point: tuple, direction: float, length: float cls, name: str, start_point: tuple, direction: float, length: float
@@ -259,8 +374,6 @@ class Alignment:
Returns: Returns:
Blender curve object Blender curve object
""" """
import math
# IFC uses standard math convention: angle counter-clockwise from +X axis # IFC uses standard math convention: angle counter-clockwise from +X axis
end_x = start_point[0] + length * math.cos(direction) end_x = start_point[0] + length * math.cos(direction)
end_y = start_point[1] + length * math.sin(direction) end_y = start_point[1] + length * math.sin(direction)
@@ -303,8 +416,6 @@ class Alignment:
Returns: Returns:
Blender curve object Blender curve object
""" """
import math
# Calculate arc parameters # Calculate arc parameters
# Arc length L = R * theta, so theta = L / R # Arc length L = R * theta, so theta = L / R
abs_radius = abs(radius) abs_radius = abs(radius)
@@ -458,71 +569,26 @@ class Alignment:
Returns: Returns:
True if removed successfully True if removed successfully
""" """
# Unlink from IFC if linked
try: try:
import bonsai.tool as tool tool.Ifc.unlink(obj=obj)
except Exception:
pass # Object might not be linked
# Unlink from IFC if linked # Store data reference before removing object
try: data = obj.data
tool.Ifc.unlink(obj)
except Exception:
pass # Object might not be linked
# Store data reference before removing object # Remove the object
data = obj.data bpy.data.objects.remove(obj, do_unlink=True)
# Remove the object # Clean up orphan curve/mesh data
bpy.data.objects.remove(obj, do_unlink=True) if data and data.users == 0:
if isinstance(data, bpy.types.Curve):
bpy.data.curves.remove(data)
elif isinstance(data, bpy.types.Mesh):
bpy.data.meshes.remove(data)
# Clean up orphan curve/mesh data return True
if data and data.users == 0:
if isinstance(data, bpy.types.Curve):
bpy.data.curves.remove(data)
elif isinstance(data, bpy.types.Mesh):
bpy.data.meshes.remove(data)
return True
except Exception as e:
print(f"Warning: Could not remove object: {e}")
return False
@classmethod
def _find_object_by_ifc_id(cls, ifc_id: int) -> Optional[bpy.types.Object]:
"""Find a Blender object by its IFC definition ID.
Fallback method when tool.Ifc.get_object() doesn't work.
Args:
ifc_id: The IFC entity ID
Returns:
The Blender object, or None if not found
"""
for obj in bpy.data.objects:
if obj.get("ifc_definition_id") == ifc_id:
return obj
return None
@classmethod
def _find_object_by_name_pattern(cls, name_pattern: str) -> Optional[bpy.types.Object]:
"""Find a Blender object by name pattern.
Last resort fallback that matches object name.
Args:
name_pattern: Name or partial name to match
Returns:
The Blender object, or None if not found
"""
# Try exact match first
if name_pattern in bpy.data.objects:
return bpy.data.objects[name_pattern]
# Try partial match (for names like "IfcAlignment/SH-21")
for obj in bpy.data.objects:
if name_pattern in obj.name:
return obj
return None
@classmethod @classmethod
def remove_layout_segment_objects(cls, layout: ifcopenshell.entity_instance) -> int: def remove_layout_segment_objects(cls, layout: ifcopenshell.entity_instance) -> int:
@@ -534,30 +600,13 @@ class Alignment:
Returns: Returns:
Number of objects removed Number of objects removed
""" """
try:
import bonsai.tool as tool
except ImportError:
tool = None
removed_count = 0 removed_count = 0
# Get segments via IfcRelNests # Get segments via IfcRelNests
for rel in getattr(layout, "IsNestedBy", []) or []: for rel in getattr(layout, "IsNestedBy", []) or []:
for segment in rel.RelatedObjects or []: for segment in rel.RelatedObjects or []:
if segment.is_a() == "IfcAlignmentSegment": if segment.is_a() == "IfcAlignmentSegment":
obj = None obj = tool.Ifc.get_object(segment)
# Try to get object via Bonsai's tool
if tool:
try:
obj = tool.Ifc.get_object(segment)
except Exception:
pass
# Fallback: search by IFC ID
if not obj:
obj = cls._find_object_by_ifc_id(segment.id())
if obj and cls._remove_blender_object(obj): if obj and cls._remove_blender_object(obj):
removed_count += 1 removed_count += 1
@@ -573,13 +622,7 @@ class Alignment:
Returns: Returns:
Number of objects removed Number of objects removed
""" """
try:
import bonsai.tool as tool
except ImportError:
tool = None
removed_count = 0 removed_count = 0
alignment_name = alignment.Name or "Unnamed"
# Get nested layouts via IfcRelNests # Get nested layouts via IfcRelNests
for rel in getattr(alignment, "IsNestedBy", []) or []: for rel in getattr(alignment, "IsNestedBy", []) or []:
@@ -588,41 +631,13 @@ class Alignment:
# Remove segment objects first # Remove segment objects first
removed_count += cls.remove_layout_segment_objects(layout) removed_count += cls.remove_layout_segment_objects(layout)
# Try to get layout object via Bonsai's tool # Remove layout object
layout_obj = None layout_obj = tool.Ifc.get_object(layout)
if tool:
try:
layout_obj = tool.Ifc.get_object(layout)
except Exception:
pass
# Fallback: search by IFC ID
if not layout_obj:
layout_obj = cls._find_object_by_ifc_id(layout.id())
# Last resort: search by name pattern
if not layout_obj:
layout_obj = cls._find_object_by_name_pattern(layout.is_a())
if layout_obj and cls._remove_blender_object(layout_obj): if layout_obj and cls._remove_blender_object(layout_obj):
removed_count += 1 removed_count += 1
# Try to get alignment object via Bonsai's tool # Remove alignment object
alignment_obj = None alignment_obj = tool.Ifc.get_object(alignment)
if tool:
try:
alignment_obj = tool.Ifc.get_object(alignment)
except Exception:
pass
# Fallback: search by IFC ID
if not alignment_obj:
alignment_obj = cls._find_object_by_ifc_id(alignment.id())
# Last resort: search by name pattern (IfcAlignment/Name)
if not alignment_obj:
alignment_obj = cls._find_object_by_name_pattern(f"IfcAlignment/{alignment_name}")
if alignment_obj and cls._remove_blender_object(alignment_obj): if alignment_obj and cls._remove_blender_object(alignment_obj):
removed_count += 1 removed_count += 1
@@ -641,25 +656,19 @@ class Alignment:
Returns: Returns:
List of newly created segment objects List of newly created segment objects
""" """
try: # Get or find the layout object
import bonsai.tool as tool if layout_obj is None:
layout_obj = tool.Ifc.get_object(layout)
# Get or find the layout object if layout_obj is None:
if layout_obj is None:
layout_obj = tool.Ifc.get_object(layout)
if layout_obj is None:
return []
# Remove existing segment objects
cls.remove_layout_segment_objects(layout)
# Create new segment objects
return cls.create_objects_for_layout_segments(layout, layout_obj)
except (ImportError, AttributeError) as e:
print(f"Warning: Could not refresh layout visualization: {e}")
return [] return []
# Remove existing segment objects
cls.remove_layout_segment_objects(layout)
# Create new segment objects
return cls.create_objects_for_layout_segments(layout, layout_obj)
# ========================================================================= # =========================================================================
# Validation and Safe Wrappers # Validation and Safe Wrappers
# ========================================================================= # =========================================================================