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