diff --git a/src/bonsai/bonsai/core/alignment.py b/src/bonsai/bonsai/core/alignment.py
new file mode 100644
index 0000000000..85c74a01e6
--- /dev/null
+++ b/src/bonsai/bonsai/core/alignment.py
@@ -0,0 +1,270 @@
+# ==============================================================================
+# Saikei Civil - Civil Engineering Tools for Blender
+# Copyright (c) 2025 Michael Yoder / Desert Springs Civil Engineering PLLC
+#
+# This program 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.
+#
+# This program 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 this program. If not, see .
+#
+# Primary Author: Michael Yoder
+# Company: Desert Springs Civil Engineering PLLC
+# ==============================================================================
+
+
+"""Core alignment business logic - Pure Python, NO bpy imports.
+
+This module contains all alignment-related calculations and logic that
+can be tested outside of Blender. Functions receive tool classes as
+parameters following Bonsai's dependency injection pattern.
+"""
+
+from __future__ import annotations
+import math
+from typing import TYPE_CHECKING, List, Tuple, Optional
+from dataclasses import dataclass
+
+if TYPE_CHECKING:
+ import ifcopenshell
+ from .. import tool
+
+
+# =============================================================================
+# Data Classes for Pure Python PI Handling
+# =============================================================================
+
+
+@dataclass
+class PIPoint:
+ """Pure Python representation of a PI (Point of Intersection).
+
+ This mirrors the Blender PropertyGroup but without bpy dependencies,
+ allowing for testing and core logic operations.
+ """
+
+ x: float
+ y: float
+ pi_type: str = "TANGENT" # ENDPOINT, TANGENT, or CURVE
+ radius: float = 0.0
+ length_to_next: float = 0.0
+ direction_to_next: 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
+# =============================================================================
+
+
+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)
+# =============================================================================
+
+
+def create_alignment_hierarchy(
+ ifc_tool: type[tool.Ifc],
+ alignment_tool: type[tool.Alignment],
+ alignment: ifcopenshell.entity_instance,
+) -> object:
+ """Create the Blender object hierarchy for an IFC alignment.
+
+ This is a core function that orchestrates the creation process
+ by calling tool methods. It contains the business logic but
+ delegates actual Blender operations to the tool layer.
+
+ Args:
+ ifc_tool: The IFC tool class for IFC operations
+ alignment_tool: The Alignment tool class for Blender operations
+ alignment: The IFC alignment entity
+
+ Returns:
+ The root Blender object for the alignment
+ """
+ # Create the alignment object
+ alignment_obj = alignment_tool.create_object_for_alignment(alignment)
+ if not alignment_obj:
+ return None
+
+ # Get nested layouts via IfcRelNests
+ layouts = []
+ for rel in getattr(alignment, "IsNestedBy", []) or []:
+ for obj in rel.RelatedObjects or []:
+ if obj.is_a() in ("IfcAlignmentHorizontal", "IfcAlignmentVertical", "IfcAlignmentCant"):
+ layouts.append(obj)
+
+ # Create Blender objects for each layout and its segments
+ for layout in layouts:
+ layout_obj = alignment_tool.create_object_for_layout(layout, alignment_obj)
+ if layout_obj:
+ create_layout_segment_objects(alignment_tool, layout, layout_obj)
+
+ return alignment_obj
+
+
+def create_layout_segment_objects(
+ alignment_tool: type[tool.Alignment],
+ layout: ifcopenshell.entity_instance,
+ layout_obj: object,
+) -> list:
+ """Create Blender objects for all segments in a layout.
+
+ Args:
+ alignment_tool: The Alignment tool class
+ layout: The IFC layout entity
+ layout_obj: The parent Blender object
+
+ Returns:
+ List of created segment Blender objects
+ """
+ segment_objs = []
+
+ for rel in getattr(layout, "IsNestedBy", []) or []:
+ for i, segment in enumerate(rel.RelatedObjects or []):
+ if segment.is_a() == "IfcAlignmentSegment":
+ seg_obj = alignment_tool.create_object_for_segment(segment, i, layout_obj)
+ if seg_obj:
+ segment_objs.append(seg_obj)
+
+ return segment_objs
diff --git a/src/bonsai/bonsai/tool/alignment.py b/src/bonsai/bonsai/tool/alignment.py
new file mode 100644
index 0000000000..436c182aac
--- /dev/null
+++ b/src/bonsai/bonsai/tool/alignment.py
@@ -0,0 +1,780 @@
+# ==============================================================================
+# Saikei Civil - Civil Engineering Tools for Blender
+# Copyright (c) 2025 Michael Yoder / Desert Springs Civil Engineering PLLC
+#
+# This program 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.
+#
+# This program 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 this program. If not, see .
+#
+# Primary Author: Michael Yoder
+# Company: Desert Springs Civil Engineering PLLC
+# ==============================================================================
+
+
+"""Alignment Tool - Blender implementations for alignment visualization.
+
+This module contains Blender-specific code for creating and managing
+alignment objects in the 3D view. It bridges the core business logic
+to the Blender environment.
+
+All methods are classmethods following Bonsai's tool pattern.
+"""
+
+from __future__ import annotations
+import bpy
+from typing import TYPE_CHECKING, Optional, List
+
+if TYPE_CHECKING:
+ import ifcopenshell
+
+
+class Alignment:
+ """Tool class for alignment-related Blender operations.
+
+ Following Bonsai's tool pattern, all methods are classmethods
+ that can be called without instantiation.
+ """
+
+ @classmethod
+ def get_ifc_file(cls) -> Optional[ifcopenshell.file]:
+ """Get the current IFC file from Bonsai.
+
+ Returns:
+ The IFC file object, or None if not available
+ """
+ try:
+ import bonsai.tool as tool
+
+ return tool.Ifc.get()
+ except (ImportError, AttributeError):
+ return None
+
+ @classmethod
+ def create_object_for_alignment(cls, alignment: ifcopenshell.entity_instance) -> Optional[bpy.types.Object]:
+ """Create a Blender object for an IFC alignment and link it properly.
+
+ This follows Bonsai's pattern for creating Blender representations:
+ 1. Create a Blender Empty object
+ 2. Link it to the IFC element via tool.Ifc.link()
+ 3. Assign it to the appropriate collection via tool.Collector.assign()
+
+ Args:
+ alignment: The IFC alignment entity
+
+ Returns:
+ The created Blender object, or existing one if already linked
+ """
+ try:
+ import bonsai.tool as tool
+
+ # Check if a Blender object already exists for this IFC element
+ existing_obj = tool.Ifc.get_object(alignment)
+ if existing_obj:
+ return existing_obj
+
+ # Create Blender Empty object with naming pattern "IfcClass/Name"
+ name = f"IfcAlignment/{alignment.Name or 'Unnamed'}"
+ 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)
+ tool.Ifc.link(alignment, obj)
+
+ # Also set ifc_definition_id manually as fallback for lookups
+ 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
+ def create_object_for_layout(
+ cls, layout_entity: ifcopenshell.entity_instance, parent_obj: Optional[bpy.types.Object] = None
+ ) -> Optional[bpy.types.Object]:
+ """Create a Blender object for an IFC alignment layout.
+
+ Args:
+ layout_entity: The IFC layout entity (IfcAlignmentHorizontal, etc.)
+ parent_obj: The parent Blender object (IfcAlignment object)
+
+ Returns:
+ The created Blender object, or existing one if already linked
+ """
+ try:
+ import bonsai.tool as tool
+
+ # Check if a Blender object already exists for this IFC element
+ existing_obj = tool.Ifc.get_object(layout_entity)
+ if existing_obj:
+ return existing_obj
+
+ # Determine the layout type from the IFC class
+ ifc_class = layout_entity.is_a()
+ name = f"{ifc_class}"
+
+ obj = bpy.data.objects.new(name, None)
+ obj.empty_display_type = "PLAIN_AXES"
+ obj.empty_display_size = 0.5
+
+ # Link to IFC element
+ tool.Ifc.link(layout_entity, obj)
+
+ # Also set ifc_definition_id manually as fallback for lookups
+ obj["ifc_definition_id"] = layout_entity.id()
+
+ # Set parent relationship in Blender (mirrors IFC nesting)
+ 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
+ def create_object_for_segment(
+ cls, segment: ifcopenshell.entity_instance, index: int, parent_obj: Optional[bpy.types.Object] = None
+ ) -> Optional[bpy.types.Object]:
+ """Create a Blender curve object for an IFC alignment segment.
+
+ Creates actual curve geometry (not just an empty) to visualize
+ the segment. LINE segments become straight curves, CIRCULARARC
+ segments become arcs.
+
+ Args:
+ segment: The IfcAlignmentSegment entity
+ index: The segment index (for naming)
+ parent_obj: The parent Blender object (layout object)
+
+ Returns:
+ The created Blender object, or existing one if already linked
+ """
+ import math
+
+ try:
+ import bonsai.tool as tool
+
+ # 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
+
+ @classmethod
+ def _create_line_segment(
+ cls, name: str, start_point: tuple, direction: float, length: float
+ ) -> Optional[bpy.types.Object]:
+ """Create a Blender curve for a LINE segment.
+
+ Args:
+ name: Object name
+ start_point: (x, y, z) start coordinates
+ direction: Direction angle in radians (IFC uses bearing from North/Y-axis)
+ length: Segment length
+
+ Returns:
+ Blender curve object
+ """
+ import math
+
+ # IFC uses standard math convention: angle counter-clockwise from +X axis
+ end_x = start_point[0] + length * math.cos(direction)
+ end_y = start_point[1] + length * math.sin(direction)
+ end_point = (end_x, end_y, start_point[2])
+
+ # Create curve data
+ curve_data = bpy.data.curves.new(name, type="CURVE")
+ curve_data.dimensions = "3D"
+
+ # Create a polyline spline
+ spline = curve_data.splines.new("POLY")
+ spline.points.add(1) # Start with 1 point, add 1 more = 2 total
+
+ # Set point coordinates (Blender uses 4D coords: x, y, z, w)
+ spline.points[0].co = (start_point[0], start_point[1], start_point[2], 1.0)
+ spline.points[1].co = (end_point[0], end_point[1], end_point[2], 1.0)
+
+ # Create object
+ obj = bpy.data.objects.new(name, curve_data)
+
+ # Set curve display properties
+ curve_data.bevel_depth = 0.0 # No thickness for now
+ obj.show_in_front = True # Always visible
+
+ return obj
+
+ @classmethod
+ def _create_arc_segment(
+ cls, name: str, start_point: tuple, direction: float, length: float, radius: float
+ ) -> Optional[bpy.types.Object]:
+ """Create a Blender curve for a CIRCULARARC segment.
+
+ Args:
+ name: Object name
+ start_point: (x, y, z) start coordinates
+ direction: Start direction angle in radians (IFC uses bearing from North/Y-axis)
+ length: Arc length
+ radius: Radius of curvature (positive = curves left, negative = curves right)
+
+ Returns:
+ Blender curve object
+ """
+ import math
+
+ # Calculate arc parameters
+ # Arc length L = R * theta, so theta = L / R
+ abs_radius = abs(radius)
+ if abs_radius < 0.0001:
+ # Degenerate case - just make a line
+ return cls._create_line_segment(name, start_point, direction, length)
+
+ theta = length / abs_radius # Total angle swept
+
+ # Determine if curving left (positive radius) or right (negative radius)
+ curve_left = radius > 0
+
+ # Generate points along the arc
+ num_points = max(int(theta * 10) + 2, 8) # At least 8 points, more for larger arcs
+
+ # Create curve data
+ curve_data = bpy.data.curves.new(name, type="CURVE")
+ curve_data.dimensions = "3D"
+
+ # Create a polyline spline
+ spline = curve_data.splines.new("POLY")
+ spline.points.add(num_points - 1) # Add points (starts with 1)
+
+ # Calculate center of the arc
+ # Center is perpendicular to start direction at distance R
+ # For standard math convention (angle from +X, CCW):
+ # Perpendicular left = direction + 90°, perpendicular right = direction - 90°
+ if curve_left:
+ center_angle = direction + math.pi / 2
+ else:
+ center_angle = direction - math.pi / 2
+
+ center_x = start_point[0] + abs_radius * math.cos(center_angle)
+ center_y = start_point[1] + abs_radius * math.sin(center_angle)
+
+ # Start angle from center to start point
+ start_angle = math.atan2(start_point[1] - center_y, start_point[0] - center_x)
+
+ # Generate points
+ for i in range(num_points):
+ t = i / (num_points - 1) # Parameter from 0 to 1
+ if curve_left:
+ angle = start_angle + t * theta
+ else:
+ angle = start_angle - t * theta
+
+ px = center_x + abs_radius * math.cos(angle)
+ py = center_y + abs_radius * math.sin(angle)
+ pz = start_point[2]
+
+ spline.points[i].co = (px, py, pz, 1.0)
+
+ # Create object
+ obj = bpy.data.objects.new(name, curve_data)
+
+ # Set curve display properties
+ curve_data.bevel_depth = 0.0
+ obj.show_in_front = True
+
+ return obj
+
+ @classmethod
+ def create_hierarchy_for_alignment(cls, alignment: ifcopenshell.entity_instance) -> Optional[bpy.types.Object]:
+ """Create the full Blender object hierarchy for an alignment.
+
+ Creates:
+ - IfcAlignment object (root)
+ - IfcAlignmentHorizontal object (child)
+ - IfcAlignmentVertical object (child, if present)
+ - IfcAlignmentCant object (child, if present)
+ - Segment objects under each layout
+
+ Args:
+ alignment: The IFC alignment entity
+
+ Returns:
+ The root alignment Blender object
+ """
+ # Create the alignment object
+ alignment_obj = cls.create_object_for_alignment(alignment)
+ if not alignment_obj:
+ return None
+
+ # Get nested layouts via IfcRelNests
+ layouts = []
+ for rel in getattr(alignment, "IsNestedBy", []) or []:
+ for obj in rel.RelatedObjects or []:
+ if obj.is_a() in ("IfcAlignmentHorizontal", "IfcAlignmentVertical", "IfcAlignmentCant"):
+ layouts.append(obj)
+
+ # Create Blender objects for each layout and its segments
+ for layout in layouts:
+ layout_obj = cls.create_object_for_layout(layout, alignment_obj)
+ if layout_obj:
+ cls.create_objects_for_layout_segments(layout, layout_obj)
+
+ return alignment_obj
+
+ @classmethod
+ def create_objects_for_layout_segments(
+ cls, layout: ifcopenshell.entity_instance, layout_obj: bpy.types.Object
+ ) -> List[bpy.types.Object]:
+ """Create Blender objects for all segments in a layout.
+
+ Args:
+ layout: The IFC layout entity (IfcAlignmentHorizontal, etc.)
+ layout_obj: The parent Blender object for the layout
+
+ Returns:
+ List of created segment Blender objects
+ """
+ segment_objs = []
+
+ # Get segments via IfcRelNests
+ for rel in getattr(layout, "IsNestedBy", []) or []:
+ for i, segment in enumerate(rel.RelatedObjects or []):
+ if segment.is_a() == "IfcAlignmentSegment":
+ seg_obj = cls.create_object_for_segment(segment, i, layout_obj)
+ if seg_obj:
+ segment_objs.append(seg_obj)
+
+ return segment_objs
+
+ @classmethod
+ def update_pi_properties(cls, props, geometry_result) -> None:
+ """Update Blender PropertyGroup with calculated geometry.
+
+ This bridges the pure Python calculation results back to
+ the Blender UI properties.
+
+ Args:
+ props: The SaikeiAlignmentProperties PropertyGroup
+ geometry_result: PIGeometryResult from core.alignment
+ """
+ pis = props.pis
+ for i, pi in enumerate(pis):
+ if i < len(geometry_result.stations):
+ pi.station = geometry_result.stations[i]
+ if i < len(geometry_result.lengths):
+ pi.length_to_next = geometry_result.lengths[i]
+ if i < len(geometry_result.directions):
+ pi.direction_to_next = geometry_result.directions[i]
+
+ @classmethod
+ def _remove_blender_object(cls, obj: bpy.types.Object) -> bool:
+ """Safely remove a Blender object and its data.
+
+ Args:
+ obj: The Blender object to remove
+
+ Returns:
+ True if removed successfully
+ """
+ try:
+ import bonsai.tool as tool
+
+ # Unlink from IFC if linked
+ try:
+ tool.Ifc.unlink(obj)
+ except Exception:
+ pass # Object might not be linked
+
+ # Store data reference before removing object
+ data = obj.data
+
+ # Remove the object
+ bpy.data.objects.remove(obj, do_unlink=True)
+
+ # Clean up orphan curve/mesh data
+ 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
+ def remove_layout_segment_objects(cls, layout: ifcopenshell.entity_instance) -> int:
+ """Remove all Blender objects for segments in a layout.
+
+ Args:
+ layout: The IFC layout entity (IfcAlignmentHorizontal, etc.)
+
+ Returns:
+ Number of objects removed
+ """
+ try:
+ import bonsai.tool as tool
+ except ImportError:
+ tool = None
+
+ removed_count = 0
+
+ # Get segments via IfcRelNests
+ for rel in getattr(layout, "IsNestedBy", []) or []:
+ for segment in rel.RelatedObjects or []:
+ if segment.is_a() == "IfcAlignmentSegment":
+ obj = None
+
+ # 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):
+ removed_count += 1
+
+ return removed_count
+
+ @classmethod
+ def remove_alignment_hierarchy(cls, alignment: ifcopenshell.entity_instance) -> int:
+ """Remove all Blender objects for an alignment and its children.
+
+ Args:
+ alignment: The IFC alignment entity
+
+ Returns:
+ Number of objects removed
+ """
+ try:
+ import bonsai.tool as tool
+ except ImportError:
+ tool = None
+
+ removed_count = 0
+ alignment_name = alignment.Name or "Unnamed"
+
+ # Get nested layouts via IfcRelNests
+ for rel in getattr(alignment, "IsNestedBy", []) or []:
+ for layout in rel.RelatedObjects or []:
+ if layout.is_a() in ("IfcAlignmentHorizontal", "IfcAlignmentVertical", "IfcAlignmentCant"):
+ # Remove segment objects first
+ removed_count += cls.remove_layout_segment_objects(layout)
+
+ # Try to get layout object via Bonsai's tool
+ layout_obj = None
+ 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):
+ removed_count += 1
+
+ # Try to get alignment object via Bonsai's tool
+ alignment_obj = None
+ 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):
+ removed_count += 1
+
+ return removed_count
+
+ @classmethod
+ def refresh_layout_visualization(
+ cls, layout: ifcopenshell.entity_instance, layout_obj: Optional[bpy.types.Object] = None
+ ) -> List[bpy.types.Object]:
+ """Refresh the visualization for a layout by removing and recreating segment objects.
+
+ Args:
+ layout: The IFC layout entity
+ layout_obj: Optional parent Blender object (will be looked up if not provided)
+
+ Returns:
+ List of newly created segment objects
+ """
+ try:
+ import bonsai.tool as tool
+
+ # Get or find the layout object
+ 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 []
+
+ # =========================================================================
+ # Validation and Safe Wrappers
+ # =========================================================================
+ # These methods provide pre-validation before calling IfcOpenShell alignment
+ # API functions. This prevents issues like orphan layouts (from undo/redo)
+ # causing invalid IFC entities (e.g., IfcRelPositions with empty RelatedProducts).
+ #
+ # The key principle: validate BEFORE operations to prevent invalid data,
+ # rather than cleaning up after the fact.
+
+ @classmethod
+ def validate_layout_has_parent_alignment(
+ cls, layout: "ifcopenshell.entity_instance"
+ ) -> Optional["ifcopenshell.entity_instance"]:
+ """Check if a layout entity has a valid parent IfcAlignment.
+
+ Orphan layouts (e.g., from undo/redo operations) can cause issues
+ when the alignment API tries to create referents, as the code
+ expects a parent alignment to exist.
+
+ Args:
+ layout: The IFC layout entity (IfcAlignmentHorizontal, etc.)
+
+ Returns:
+ The parent IfcAlignment if found, None otherwise
+ """
+ try:
+ import ifcopenshell.api.alignment as align_api
+
+ return align_api.get_alignment(layout)
+ except Exception:
+ return None
+
+ @classmethod
+ def get_alignment_for_layout(
+ cls, layout: "ifcopenshell.entity_instance"
+ ) -> Optional["ifcopenshell.entity_instance"]:
+ """Get the parent IfcAlignment for a layout entity.
+
+ This is an alias for validate_layout_has_parent_alignment that
+ makes the intent clearer when you need the alignment itself.
+
+ Args:
+ layout: The IFC layout entity (IfcAlignmentHorizontal, etc.)
+
+ Returns:
+ The parent IfcAlignment if found, None otherwise
+ """
+ return cls.validate_layout_has_parent_alignment(layout)
+
+ @classmethod
+ def safe_layout_horizontal_by_pi_method(
+ cls, ifc_file: "ifcopenshell.file", layout: "ifcopenshell.entity_instance", hpoints: list, radii: list
+ ) -> bool:
+ """Safely add segments to a horizontal layout using PI method.
+
+ This wrapper validates that the layout has a valid parent alignment
+ before calling the IfcOpenShell API. This prevents the creation of
+ invalid IfcRelPositions entities.
+
+ Args:
+ ifc_file: The IFC file
+ layout: The IfcAlignmentHorizontal layout
+ hpoints: List of (X, Y) coordinate pairs for PIs
+ radii: List of curve radii
+
+ Returns:
+ True if successful
+
+ Raises:
+ ValueError: If layout has no parent alignment
+ """
+ import ifcopenshell.api.alignment as align_api
+
+ # Validate layout has a parent alignment - this is the key check
+ # that prevents orphan stationing from being created
+ alignment = cls.validate_layout_has_parent_alignment(layout)
+ if alignment is None:
+ raise ValueError(
+ f"Layout #{layout.id()} ({layout.is_a()}) has no parent IfcAlignment. "
+ "This may be an orphan layout from undo/redo. "
+ "Cannot add segments without a valid parent alignment."
+ )
+
+ # Now safe to call the API - stationing will be associated with alignment
+ align_api.layout_horizontal_alignment_by_pi_method(ifc_file, layout, hpoints, radii)
+
+ return True
+
+ @classmethod
+ def safe_create_alignment_by_pi_method(
+ cls, ifc_file: "ifcopenshell.file", name: str, hpoints: list, radii: list, start_station: float = 0.0
+ ) -> "ifcopenshell.entity_instance":
+ """Safely create a new alignment using PI method.
+
+ When creating a new alignment, we don't need validation since
+ we're creating the alignment itself - stationing will be
+ properly associated with it.
+
+ Args:
+ ifc_file: The IFC file
+ name: Alignment name
+ hpoints: List of (X, Y) coordinate pairs for PIs
+ radii: List of curve radii
+ start_station: Starting station value
+
+ Returns:
+ The created IfcAlignment entity
+ """
+ import ifcopenshell.api.alignment as align_api
+
+ # Create the alignment - this creates a new alignment so stationing
+ # will be properly associated with it
+ alignment = align_api.create_by_pi_method(
+ ifc_file, name=name, hpoints=hpoints, radii=radii, start_station=start_station
+ )
+
+ return alignment